PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.5
Yatra – Travel Booking & Tour Operator Software v3.0.5
3.0.14 3.0.14.1 3.0.14.2 3.0.12 3.0.13 3.0.11 3.0.10 3.0.9 3.0.8 3.0.7 3.0.6 3.0.5 3.0.5.1 3.0.4 3.0.3 3.0.2.9 3.0.2.7 3.0.2.8 3.0.2.6 trunk 1.0.0 2.0.0 2.0.1 2.0.10 2.0.11 All 82 releases
yatra / app / Controllers / SingleTripController.php

SingleTripController.php in Yatra – Travel Booking & Tour Operator Software 3.0.5, at app/Controllers/SingleTripController.php

2,387 lines 96.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 declare(strict_types=1);
4
5 namespace Yatra\Controllers;
6
7 use Yatra\Database\Tables\ClassificationsTable;
8 use Yatra\Repositories\ReviewRepository;
9 use Yatra\Repositories\TripAttributeRepository;
10 use Yatra\Database\Tables\ReviewsTable;
11 use Yatra\Database\Tables\TripsTable;
12 use Yatra\Database\Tables\TripAvailabilityDatesTable;
13 use Yatra\Database\Tables\TripClassificationsTable;
14 use Yatra\Database\Tables\TripContentTable;
15 use Yatra\Database\Tables\TripItineraryTable;
16 use Yatra\Services\SettingsService;
17
18 /**
19 * Single Trip Frontend Controller
20 *
21 * Prepares all data for the single trip template following Laravel patterns.
22 * This controller handles data transformation and preparation only.
23 *
24 * @package Yatra
25 */
26 class SingleTripController
27 {
28 /**
29 * @var \wpdb WordPress database instance
30 */
31 private $wpdb;
32
33 /**
34 * @var string Trips table name
35 */
36 private string $table_trips;
37
38 /**
39 * @var string Destinations table name
40 */
41 private string $table_destinations;
42
43 /**
44 * @var string Activities table name
45 */
46 private string $table_activities;
47
48 /**
49 * @var string Reviews table name
50 */
51 private string $table_reviews;
52
53 /**
54 * @var string Trip-category relationship table name
55 */
56 private string $table_trip_cat_rel;
57
58 /**
59 * Constructor
60 */
61 public function __construct()
62 {
63 global $wpdb;
64 $this->wpdb = $wpdb;
65 $this->table_trips = TripsTable::getTableName();
66 $this->table_destinations = ClassificationsTable::getTableName();
67 $this->table_activities = ClassificationsTable::getTableName();
68 $this->table_reviews = ReviewsTable::getTableName();
69 $this->table_trip_cat_rel = TripClassificationsTable::getTableName();
70 }
71
72 /**
73 * Get trip by slug with all related data
74 *
75 * @param string $slug Trip slug
76 * @return object|null Prepared trip data or null
77 */
78 public function getBySlug(string $slug): ?object
79 {
80 $trip = $this->wpdb->get_row(
81 $this->wpdb->prepare(
82 "SELECT * FROM {$this->table_trips}
83 WHERE slug = %s
84 AND status IN ('publish', 'published', 'draft')
85 LIMIT 1",
86 $slug
87 )
88 );
89
90
91
92 // Check if trip exists but has other status - return null to show "not found"
93 if (!$trip) {
94 // Try to find trip regardless of status to check if it exists
95 $existingTrip = $this->wpdb->get_row(
96 $this->wpdb->prepare(
97 "SELECT status FROM {$this->table_trips}
98 WHERE slug = %s
99 LIMIT 1",
100 $slug
101 )
102 );
103
104 // If trip exists but viewer can't preview drafts, hide it.
105 // Admin fallback so site owners always see drafts even when
106 // the Team module isn't active and yatra_edit_trips isn't
107 // on the admin role.
108 if (
109 $existingTrip
110 && !current_user_can('manage_options')
111 && !current_user_can('yatra_edit_trips')
112 ) {
113 return null;
114 }
115 }
116
117 // Return null if no trip found or if trip has other status
118 if (!$trip) {
119 return null;
120 }
121
122 return $this->prepareTrip($trip);
123 }
124
125 /**
126 * Get trip by ID with all related data
127 *
128 * @param int $id Trip ID
129 * @return object|null Prepared trip data or null
130 */
131 public function getById(int $id): ?object
132 {
133 $trip = $this->wpdb->get_row(
134 $this->wpdb->prepare(
135 "SELECT * FROM {$this->table_trips}
136 WHERE id = %d
137 LIMIT 1",
138 $id
139 )
140 );
141
142 if (!$trip) {
143 return null;
144 }
145
146 return $this->prepareTrip($trip);
147 }
148
149 /**
150 * Prepare trip data with all relationships and transformations
151 *
152 * @param object $trip Raw trip data from database
153 * @return \Yatra\Models\Trip Prepared trip data as Trip model
154 */
155 private function prepareTrip(object $trip): object
156 {
157 // Decode JSON fields
158 $trip->highlights = $this->decodeJson($trip->highlights ?? '');
159 $trip->testimonials = $this->decodeJson($trip->testimonials ?? '');
160 $trip->countries = $this->decodeJson($trip->countries ?? '');
161 $trip->regions = $this->decodeJson($trip->regions ?? '');
162 $trip->landmarks = $this->decodeJson($trip->landmarks ?? '');
163 $trip->tags = $this->decodeJson($trip->tags ?? '');
164 $trip->included_items = $this->decodeJson($trip->included_items ?? '');
165 $trip->excluded_items = $this->decodeJson($trip->excluded_items ?? '');
166 $trip->testimonial_review_ids = $this->decodeJson($trip->testimonial_review_ids ?? '');
167
168
169 // Gallery images from separate table (with attachment IDs)
170 $trip->gallery_images = $this->getGalleryImages((int) $trip->id);
171
172 // Get highlights from TripContentTable
173 $trip->highlights = $this->getHighlights((int) $trip->id);
174
175 // Get landmarks from TripContentTable
176 $trip->landmarks = $this->getLandmarks((int) $trip->id);
177
178 // Get FAQs from TripContentTable
179 $trip->faqs = $this->getFaqs((int) $trip->id);
180
181 // Get downloadable items from TripContentTable
182 $trip->downloadable_items = $this->getDownloadableItems((int) $trip->id);
183
184 // Get videos, YouTube videos, virtual tours, and documents from TripContentTable
185 $trip->videos = $this->getVideos((int) $trip->id);
186 $trip->youtube_videos = $this->getYoutubeVideos((int) $trip->id);
187 $trip->virtual_tours = $this->getVirtualTours((int) $trip->id);
188 $trip->documents = $this->getDocuments((int) $trip->id);
189
190 // Also check main trip table fields for YouTube videos and virtual tours
191 $youtube_from_table = [];
192 if (!empty($trip->video_url)) {
193 $video_id = $this->extractYoutubeVideoId($trip->video_url);
194 $youtube_from_table[] = [
195 'id' => 'main_' . $trip->id,
196 'title' => $trip->title ?? '',
197 'description' => '',
198 'url' => $trip->video_url,
199 'thumbnail' => $video_id ? "https://img.youtube.com/vi/{$video_id}/maxresdefault.jpg" : '',
200 'video_id' => $video_id,
201 'duration' => '',
202 'embed_url' => $video_id ? "https://www.youtube.com/embed/{$video_id}" : ''
203 ];
204 }
205
206 $tours_from_table = [];
207 if (!empty($trip->virtual_tour_url)) {
208 $tours_from_table[] = [
209 'id' => 'main_' . $trip->id,
210 'title' => $trip->title ?? '360° Virtual Tour',
211 'description' => '',
212 'url' => $trip->virtual_tour_url,
213 'thumbnail' => '',
214 'tour_type' => '360',
215 'is_embeddable' => false
216 ];
217 }
218
219 // Merge TripContentTable results with main table results
220 $trip->youtube_videos = array_merge($trip->youtube_videos, $youtube_from_table);
221 $trip->virtual_tours = array_merge($trip->virtual_tours, $tours_from_table);
222
223
224 // Get price types from database table (for traveler-based pricing)
225 $trip->price_types = $this->getPriceTypes((int) $trip->id);
226
227 // Normalize pricing_type with the same rules as {@see TripPricingService::resolvePricingType}
228 if ($trip->pricing_type === null || $trip->pricing_type === '') {
229 $trip->pricing_type = \Yatra\Services\TripPricingService::resolvePricingType($trip);
230 }
231
232 // Load itinerary from new database tables (preferred) or fallback to JSON field
233 $itinerary_from_db = $this->getItineraryDays((int) $trip->id);
234 if (!empty($itinerary_from_db)) {
235 $trip->itinerary_days = $itinerary_from_db;
236 } else {
237 // Fallback to old JSON field if new tables are empty
238 $trip->itinerary_days = $this->decodeJson($trip->itinerary_days ?? '');
239 }
240
241 // FAQs are now loaded from TripContentTable in getFaqs() method
242 $db_frontend_tabs = $this->decodeJson($trip->frontend_tabs ?? '');
243
244 // Merge database data with complete default array to ensure all sections are available
245 $trip->frontend_tabs = $this->mergeFrontendTabsWithDefaults($db_frontend_tabs);
246
247 // Fetch availability dates from database table
248 $trip->availability_dates = $this->getAvailabilityDates((int) $trip->id);
249
250 // Get booking mode information (date-specific vs flexible)
251 $resolutionService = new \Yatra\Services\AvailabilityResolutionService();
252 $bookingModeInfo = $resolutionService->getBookingMode((int) $trip->id);
253 $trip->booking_mode = $bookingModeInfo['mode'];
254 $trip->has_specific_availability = $bookingModeInfo['has_availability'];
255
256 $trip->blackout_dates = $this->decodeJson($trip->blackout_dates ?? '');
257
258 // Get trip attributes with their values
259 $trip->attributes = $this->getTripAttributes((int) $trip->id);
260
261
262
263 // Set default values for numeric fields
264 $trip->duration_days = (int) ($trip->duration_days ?? 1);
265 $trip->duration_nights = (int) ($trip->duration_nights ?? 0);
266 $trip->min_travelers = (int) ($trip->min_travelers ?? 1);
267 $trip->max_travelers = (int) ($trip->max_travelers ?? 10);
268 $trip->age_min = (int) ($trip->age_min ?? 0);
269 $trip->age_max = (int) ($trip->age_max ?? 99);
270
271 // Set default values for price fields
272 $trip->original_price = (float) ($trip->original_price ?? 0);
273 $trip->sale_price = !empty($trip->sale_price) ? (float) $trip->sale_price : 0;
274 $trip->discounted_price = !empty($trip->discounted_price) ? (float) $trip->discounted_price : 0;
275 $trip->deposit_amount = (float) ($trip->deposit_amount ?? 0);
276
277 // Get currency
278 $trip->currency = SettingsService::getCurrency();
279
280 // Compute effective pricing via centralized TripPricingService (single source of truth)
281 $displayPricing = \Yatra\Services\TripPricingService::resolveDisplayPricing($trip);
282 $trip->effective_price_min = $displayPricing['effective_price_min'];
283 $trip->min_category_original_price = $displayPricing['min_category_original_price'];
284 $trip->max_discount_percentage = $displayPricing['max_discount_percentage'];
285 $trip->discount_percentage = $displayPricing['discount_percentage'];
286
287 // Ensure ID is integer
288 $trip_id = (int) $trip->id;
289
290 // Fetch related data
291 $trip->destinations = $this->getDestinations($trip_id);
292 $trip->activities = $this->getActivities($trip_id);
293 $trip->trip_categories = $this->getTripCategories($trip_id);
294 $trip->reviews = $this->getReviews($trip_id);
295 $trip->testimonials = $this->getTestimonials($trip_id);
296 $trip->similar_trips = $this->getSimilarTrips($trip);
297
298 // Count / average / breakdown from all approved reviews (getReviews() is capped for the list UI).
299 $reviewRepo = new ReviewRepository();
300 $sqlCount = $reviewRepo->getReviewCount($trip_id);
301 $sqlAvg = $reviewRepo->getAverageRating($trip_id);
302 $loadedCount = count($trip->reviews);
303 $trip->review_count = max($sqlCount, $loadedCount);
304 $trip->average_rating = $sqlAvg;
305 if ((float) $trip->average_rating <= 0 && $loadedCount > 0) {
306 $trip->average_rating = $this->averageRatingFromReviewRows($trip->reviews);
307 }
308 $trip->avg_rating = (float) $trip->average_rating;
309 $trip->rating_distribution = $reviewRepo->getRatingDistribution($trip_id);
310 if ($trip->review_count > 0) {
311 $trip->reviews_count = $trip->review_count;
312 }
313
314 // Calculate base price for template display
315 $trip->base_price = $this->calculateBasePrice($trip);
316
317 // Availability flags for backward compatibility and template logic
318 // has_availability: true if specific dates/rules exist
319 $trip->has_availability = !empty($trip->availability_dates) && is_array($trip->availability_dates) && count($trip->availability_dates) > 0;
320
321 // has_booking_capability: true if trip can be booked (either specific dates OR flexible mode)
322 // This ensures booking interface always shows (industry best practice)
323 $trip->has_booking_capability = $trip->has_availability || ($trip->booking_mode === 'flexible');
324
325 $trip->has_traveler_pricing = ($trip->pricing_type === 'traveler_based' && !empty($trip->price_types));
326
327 // Format featured image
328 $trip->featured_image_url = $this->getFeaturedImageUrl($trip->featured_image ?? '');
329
330 // Convert stdClass to Trip model instance
331 return \Yatra\Models\Trip::fromStdClass($trip);
332 }
333
334 /**
335 * Calculate base price for template display
336 *
337 * Delegates to centralized TripPricingService (single source of truth).
338 *
339 * @param object $trip Trip object
340 * @return float Base price
341 */
342 private function calculateBasePrice(object $trip): float
343 {
344 $availDates = !empty($trip->availability_dates) && is_array($trip->availability_dates)
345 ? $trip->availability_dates : null;
346
347 $pricing = \Yatra\Services\TripPricingService::resolveDisplayPricing($trip, $availDates);
348 return (float) $pricing['effective_price_min'];
349 }
350
351 /**
352 * Decode a trip JSON / serialized field safely.
353 *
354 * Accepts both JSON (`[...]`) and PHP-serialized (`a:N:{...}`) input because
355 * different code paths persist these columns differently: most fields are
356 * stored as JSON, but TripValidator::sanitize() uses `maybe_serialize()` for
357 * a handful of array fields (including `frontend_tabs`). If we only tried
358 * `json_decode`, the PHP-serialized payload would silently decode to null
359 * → empty array → admin-saved icons / tab labels disappear on the public
360 * trip page. Try `maybe_unserialize` first; fall back to JSON.
361 *
362 * NOTE on safety: `maybe_unserialize` is used on a trusted DB column written
363 * by our own validator. There's no user-controlled payload path that lands
364 * in this column without sanitisation. Pre-checking the `a:` / `s:` /
365 * `O:` prefix avoids tripping unserialize on random non-serialized data.
366 *
367 * @param string|null $value JSON string OR PHP-serialized string OR null.
368 * @return array Decoded array (empty on any failure).
369 */
370 private function decodeJson(?string $value): array
371 {
372 if (empty($value) || !is_string($value)) {
373 return [];
374 }
375
376 // Looks like PHP-serialized data — try maybe_unserialize first.
377 if (preg_match('/^(?:a|s|O):\d+:/', $value)) {
378 $decoded = maybe_unserialize($value);
379 if (is_array($decoded)) {
380 return $decoded;
381 }
382 }
383
384 // Fall back to JSON.
385 $decoded = json_decode($value, true);
386 return is_array($decoded) ? $decoded : [];
387 }
388
389 /**
390 * Get availability dates for a trip using centralized resolution service
391 *
392 * @param int $trip_id Trip ID
393 * @return array Array of availability date objects
394 */
395 private function getAvailabilityDates(int $trip_id): array
396 {
397 // Use centralized AvailabilityResolutionService
398 $resolutionService = new \Yatra\Services\AvailabilityResolutionService();
399
400 // Get dates for next 12 months
401 $fromDate = date('Y-m-d');
402 $toDate = date('Y-m-d', strtotime('+12 months'));
403
404 $availability = $resolutionService->getAllAvailabilityDates($trip_id, $fromDate, $toDate);
405
406 // Add calculated fields
407 foreach ($availability as $avail) {
408 // Calculate if limited availability
409 $avail->is_limited = ($avail->seats_available <= 5 && $avail->seats_available > 0);
410 $avail->is_sold_out = ($avail->seats_available <= 0 || $avail->status === 'sold_out');
411 }
412
413 return $availability;
414 }
415
416 /**
417 * Get price types for trip (traveler-based pricing)
418 *
419 * @param int $trip_id Trip ID
420 * @return array Price types with category info
421 */
422 private function getPriceTypes(int $trip_id): array
423 {
424 // Read price_types JSON from trips table
425 $table = esc_sql(TripsTable::getTableName());
426 $json = $this->wpdb->get_var(
427 $this->wpdb->prepare("SELECT price_types FROM `{$table}` WHERE id = %d", $trip_id)
428 );
429
430 if (empty($json)) {
431 return [];
432 }
433
434 $decoded = is_string($json) ? json_decode($json, true) : $json;
435 if (!is_array($decoded) || empty($decoded)) {
436 return [];
437 }
438
439 // Collect category IDs for enrichment
440 $category_ids = [];
441 foreach ($decoded as $pt) {
442 if (!is_array($pt)) continue;
443 $cat_id = $pt['category_id'] ?? null;
444 if ($cat_id !== null && $cat_id !== '') {
445 $category_ids[] = (int) $cat_id;
446 }
447 }
448
449 // Fetch category metadata (label, slug, pricing_mode, age_min, age_max, min_pax, max_pax)
450 $categories = [];
451 if (!empty($category_ids)) {
452 $classifications_table = esc_sql(ClassificationsTable::getTableName());
453 $placeholders = implode(',', array_fill(0, count($category_ids), '%d'));
454 $rows = $this->wpdb->get_results(
455 $this->wpdb->prepare(
456 "SELECT id, name, slug, metadata FROM `{$classifications_table}` WHERE id IN ({$placeholders})",
457 ...$category_ids
458 )
459 );
460 foreach ($rows as $row) {
461 $meta = !empty($row->metadata) ? json_decode($row->metadata, true) : [];
462 $categories[(int) $row->id] = (object) [
463 'label' => $row->name,
464 'slug' => $row->slug,
465 'pricing_mode' => $meta['pricing_mode'] ?? 'per_person',
466 'age_min' => isset($meta['age_min']) ? (int) $meta['age_min'] : null,
467 'age_max' => isset($meta['age_max']) ? (int) $meta['age_max'] : null,
468 'min_pax' => isset($meta['min_pax']) ? (int) $meta['min_pax'] : null,
469 'max_pax' => isset($meta['max_pax']) ? (int) $meta['max_pax'] : null,
470 'max_quantity' => isset($meta['max_quantity']) ? (int) $meta['max_quantity'] : null,
471 'description' => $meta['description'] ?? '',
472 ];
473 }
474 }
475
476 // Build enriched price_types as objects
477 $result = [];
478 foreach ($decoded as $pt) {
479 if (!is_array($pt)) continue;
480
481 $cat_id = isset($pt['category_id']) ? (int) $pt['category_id'] : null;
482 $cat = ($cat_id !== null && isset($categories[$cat_id])) ? $categories[$cat_id] : null;
483
484 $original = isset($pt['original_price']) ? (float) $pt['original_price'] : 0;
485 $discounted = isset($pt['discounted_price']) ? (float) $pt['discounted_price'] : 0;
486 $effective = ($discounted > 0 && $discounted < $original) ? $discounted : $original;
487
488 $result[] = (object) [
489 'category_id' => $cat_id,
490 'category_label' => $cat ? $cat->label : ($pt['label'] ?? __('Traveler', 'yatra')),
491 'category_slug' => $cat ? $cat->slug : '',
492 'original_price' => $original,
493 'discounted_price' => $discounted,
494 'effective_price' => $effective,
495 'is_default' => !empty($pt['is_default']),
496 'pricing_mode' => $cat ? $cat->pricing_mode : ($pt['pricing_mode'] ?? 'per_person'),
497 'age_min' => $cat ? $cat->age_min : null,
498 'age_max' => $cat ? $cat->age_max : null,
499 'min_pax' => $cat ? $cat->min_pax : null,
500 'max_pax' => $cat ? $cat->max_pax : null,
501 'max_quantity' => $cat ? $cat->max_quantity : null,
502 'description' => $cat ? $cat->description : ($pt['description'] ?? ''),
503 ];
504 }
505
506 return $result;
507 }
508
509 /**
510 * Get destinations for trip
511 *
512 * @param int $trip_id Trip ID
513 * @return array Destinations
514 */
515 private function getDestinations(int $trip_id): array
516 {
517 // Use TripClassificationsTable for trip-destination relationships
518 $tripClassificationsTable = \Yatra\Database\Tables\TripClassificationsTable::getTableName();
519 $classificationsTable = \Yatra\Database\Tables\ClassificationsTable::getTableName();
520
521 $destination_ids = $this->wpdb->get_col(
522 $this->wpdb->prepare(
523 "SELECT classification_id FROM {$tripClassificationsTable}
524 WHERE trip_id = %d AND classification_id IN (
525 SELECT id FROM {$classificationsTable} WHERE type = 'destination'
526 )",
527 $trip_id
528 )
529 );
530
531 if (empty($destination_ids)) {
532 return [];
533 }
534
535 $placeholders = implode(',', array_fill(0, count($destination_ids), '%d'));
536 $destinations = $this->wpdb->get_results(
537 $this->wpdb->prepare(
538 "SELECT * FROM {$classificationsTable}
539 WHERE id IN ({$placeholders})",
540 ...$destination_ids
541 )
542 );
543
544 return $destinations ?: [];
545 }
546
547 /**
548 * Get activities for trip
549 *
550 * @param int $trip_id Trip ID
551 * @return array Activities
552 */
553 private function getActivities(int $trip_id): array
554 {
555 // Use TripClassificationsTable for trip-activity relationships
556 $tripClassificationsTable = \Yatra\Database\Tables\TripClassificationsTable::getTableName();
557 $classificationsTable = \Yatra\Database\Tables\ClassificationsTable::getTableName();
558
559 $activity_ids = $this->wpdb->get_col(
560 $this->wpdb->prepare(
561 "SELECT classification_id FROM {$tripClassificationsTable}
562 WHERE trip_id = %d AND classification_id IN (
563 SELECT id FROM {$classificationsTable} WHERE type = 'activity'
564 )",
565 $trip_id
566 )
567 );
568
569 if (empty($activity_ids)) {
570 return [];
571 }
572
573 $placeholders = implode(',', array_fill(0, count($activity_ids), '%d'));
574 return $this->wpdb->get_results(
575 $this->wpdb->prepare(
576 "SELECT * FROM {$classificationsTable}
577 WHERE id IN ({$placeholders})",
578 ...$activity_ids
579 )
580 ) ?: [];
581 }
582
583 /**
584 * Get gallery images for trip
585 *
586 * @param int $trip_id Trip ID
587 * @return array Gallery images with URLs
588 */
589 private function getGalleryImages(int $trip_id): array
590 {
591 // Use TripContentTable for gallery images
592 $tripContentTable = \Yatra\Database\Tables\TripContentTable::getTableName();
593
594 // Check if table exists
595 $table_exists = $this->wpdb->get_var(
596 $this->wpdb->prepare(
597 "SHOW TABLES LIKE %s",
598 $tripContentTable
599 )
600 ) === $tripContentTable;
601
602 if (!$table_exists) {
603 return [];
604 }
605
606 $images = $this->wpdb->get_results(
607 $this->wpdb->prepare(
608 "SELECT * FROM {$tripContentTable}
609 WHERE trip_id = %d AND content_type = 'image'
610 ORDER BY sort_order ASC, id ASC",
611 $trip_id
612 )
613 ) ?: [];
614
615 // Convert to array of URLs
616 $gallery = [];
617 foreach ($images as $img) {
618 $url = '';
619
620 // Check metadata for attachment_id (stored as JSON)
621 if (!empty($img->metadata)) {
622 $metadata = json_decode($img->metadata, true);
623 if (is_array($metadata) && !empty($metadata['attachment_id'])) {
624 $url = wp_get_attachment_image_url((int) $metadata['attachment_id'], 'large');
625 }
626 }
627
628 // Fallback to content_url field (direct URL)
629 if (empty($url) && !empty($img->content_url)) {
630 $url = $img->content_url;
631 }
632
633 if (!empty($url)) {
634 $gallery[] = $url;
635 }
636 }
637
638 return $gallery;
639 }
640
641 /**
642 * Get videos for trip
643 *
644 * @param int $trip_id Trip ID
645 * @return array Videos with URLs and metadata
646 */
647 private function getVideos(int $trip_id): array
648 {
649 $tripContentTable = \Yatra\Database\Tables\TripContentTable::getTableName();
650
651 $table_exists = $this->wpdb->get_var(
652 $this->wpdb->prepare(
653 "SHOW TABLES LIKE %s",
654 $tripContentTable
655 )
656 ) === $tripContentTable;
657
658 if (!$table_exists) {
659 return [];
660 }
661
662 $videos = $this->wpdb->get_results(
663 $this->wpdb->prepare(
664 "SELECT * FROM {$tripContentTable}
665 WHERE trip_id = %d AND content_type = 'video'
666 ORDER BY sort_order ASC, id ASC",
667 $trip_id
668 )
669 ) ?: [];
670
671 $video_list = [];
672 foreach ($videos as $video) {
673 $video_data = [
674 'id' => $video->id,
675 'title' => $video->title ?? '',
676 'description' => $video->description ?? '',
677 'url' => $video->content_url ?? '',
678 'thumbnail' => $video->thumbnail_url ?? '',
679 'duration' => '',
680 'file_size' => $video->file_size ?? 0
681 ];
682
683 // Parse metadata for additional info
684 if (!empty($video->metadata)) {
685 $metadata = json_decode($video->metadata, true);
686 if (is_array($metadata)) {
687 $video_data['duration'] = $metadata['duration'] ?? '';
688 $video_data['file_size'] = $metadata['file_size'] ?? $video_data['file_size'];
689 }
690 }
691
692 if (!empty($video_data['url'])) {
693 $video_list[] = $video_data;
694 }
695 }
696
697 return $video_list;
698 }
699
700 /**
701 * Get documents for trip
702 *
703 * @param int $trip_id Trip ID
704 * @return array Documents with URLs and metadata
705 */
706 private function getDocuments(int $trip_id): array
707 {
708 $tripContentTable = \Yatra\Database\Tables\TripContentTable::getTableName();
709
710 $table_exists = $this->wpdb->get_var(
711 $this->wpdb->prepare(
712 "SHOW TABLES LIKE %s",
713 $tripContentTable
714 )
715 ) === $tripContentTable;
716
717 if (!$table_exists) {
718 return [];
719 }
720
721 $documents = $this->wpdb->get_results(
722 $this->wpdb->prepare(
723 "SELECT * FROM {$tripContentTable}
724 WHERE trip_id = %d AND content_type = 'document'
725 ORDER BY sort_order ASC, id ASC",
726 $trip_id
727 )
728 ) ?: [];
729
730 $document_list = [];
731 foreach ($documents as $doc) {
732 $doc_data = [
733 'id' => $doc->id,
734 'title' => $doc->title ?? '',
735 'description' => $doc->description ?? '',
736 'url' => $doc->content_url ?? '',
737 'file_path' => $doc->file_path ?? '',
738 'file_size' => $doc->file_size ?? 0,
739 'file_type' => $doc->file_type ?? '',
740 'is_downloadable' => (bool) ($doc->is_downloadable ?? true)
741 ];
742
743 if (!empty($doc_data['url']) || !empty($doc_data['file_path'])) {
744 $document_list[] = $doc_data;
745 }
746 }
747
748 return $document_list;
749 }
750
751 /**
752 * Get YouTube videos for trip
753 *
754 * @param int $trip_id Trip ID
755 * @return array YouTube videos with URLs and metadata
756 */
757 private function getYoutubeVideos(int $trip_id): array
758 {
759 $tripContentTable = \Yatra\Database\Tables\TripContentTable::getTableName();
760
761 $table_exists = $this->wpdb->get_var(
762 $this->wpdb->prepare(
763 "SHOW TABLES LIKE %s",
764 $tripContentTable
765 )
766 ) === $tripContentTable;
767
768 if (!$table_exists) {
769 return [];
770 }
771
772 // Debug: Check what content types exist for this trip
773 if (defined('WP_DEBUG') && WP_DEBUG) {
774 $all_content = $this->wpdb->get_results(
775 $this->wpdb->prepare(
776 "SELECT content_type, COUNT(*) as count FROM {$tripContentTable}
777 WHERE trip_id = %d
778 GROUP BY content_type",
779 $trip_id
780 )
781 );
782 foreach ($all_content as $content) {
783 }
784 }
785
786 $youtube_videos = $this->wpdb->get_results(
787 $this->wpdb->prepare(
788 "SELECT * FROM {$tripContentTable}
789 WHERE trip_id = %d AND content_type = 'youtube'
790 ORDER BY sort_order ASC, id ASC",
791 $trip_id
792 )
793 ) ?: [];
794
795 $video_list = [];
796 foreach ($youtube_videos as $video) {
797 $video_data = [
798 'id' => $video->id,
799 'title' => $video->title ?? '',
800 'description' => $video->description ?? '',
801 'url' => $video->content_url ?? '',
802 'thumbnail' => $video->thumbnail_url ?? '',
803 'video_id' => '',
804 'duration' => ''
805 ];
806
807 // Extract YouTube video ID from URL
808 if (!empty($video_data['url'])) {
809 $video_id = $this->extractYoutubeVideoId($video_data['url']);
810 if ($video_id) {
811 $video_data['video_id'] = $video_id;
812 $video_data['thumbnail'] = $video_data['thumbnail'] ?: "https://img.youtube.com/vi/{$video_id}/maxresdefault.jpg";
813 $video_data['embed_url'] = "https://www.youtube.com/embed/{$video_id}";
814 }
815 }
816
817 // Parse metadata for additional info
818 if (!empty($video->metadata)) {
819 $metadata = json_decode($video->metadata, true);
820 if (is_array($metadata)) {
821 $video_data['duration'] = $metadata['duration'] ?? $video_data['duration'];
822 }
823 }
824
825 if (!empty($video_data['url'])) {
826 $video_list[] = $video_data;
827 }
828 }
829
830 return $video_list;
831 }
832
833 /**
834 * Get virtual tours (360°) for trip
835 *
836 * @param int $trip_id Trip ID
837 * @return array Virtual tours with URLs and metadata
838 */
839 private function getVirtualTours(int $trip_id): array
840 {
841 $tripContentTable = \Yatra\Database\Tables\TripContentTable::getTableName();
842
843 $table_exists = $this->wpdb->get_var(
844 $this->wpdb->prepare(
845 "SHOW TABLES LIKE %s",
846 $tripContentTable
847 )
848 ) === $tripContentTable;
849
850 if (!$table_exists) {
851 return [];
852 }
853
854 $virtual_tours = $this->wpdb->get_results(
855 $this->wpdb->prepare(
856 "SELECT * FROM {$tripContentTable}
857 WHERE trip_id = %d AND content_type = 'virtual_tour'
858 ORDER BY sort_order ASC, id ASC",
859 $trip_id
860 )
861 ) ?: [];
862
863 $tour_list = [];
864 foreach ($virtual_tours as $tour) {
865 $tour_data = [
866 'id' => $tour->id,
867 'title' => $tour->title ?? '',
868 'description' => $tour->description ?? '',
869 'url' => $tour->content_url ?? '',
870 'thumbnail' => $tour->thumbnail_url ?? '',
871 'tour_type' => '360',
872 'is_embeddable' => false
873 ];
874
875 // Parse metadata for additional info
876 if (!empty($tour->metadata)) {
877 $metadata = json_decode($tour->metadata, true);
878 if (is_array($metadata)) {
879 $tour_data['tour_type'] = $metadata['tour_type'] ?? '360';
880 $tour_data['is_embeddable'] = $metadata['is_embeddable'] ?? false;
881 }
882 }
883
884 if (!empty($tour_data['url'])) {
885 $tour_list[] = $tour_data;
886 }
887 }
888
889 return $tour_list;
890 }
891
892 /**
893 * Get highlights for a trip from TripContentTable
894 *
895 * @param int $trip_id Trip ID
896 * @return array Array of highlights
897 */
898 private function getHighlights(int $trip_id): array
899 {
900 $tripContentTable = \Yatra\Database\Tables\TripContentTable::getTableName();
901
902 // Check if table exists
903 $table_exists = $this->wpdb->get_var(
904 $this->wpdb->prepare(
905 "SHOW TABLES LIKE %s",
906 $tripContentTable
907 )
908 ) === $tripContentTable;
909
910 if (!$table_exists) {
911 return [];
912 }
913
914 $highlights = $this->wpdb->get_results(
915 $this->wpdb->prepare(
916 "SELECT * FROM {$tripContentTable}
917 WHERE trip_id = %d AND content_type = 'highlight'
918 ORDER BY sort_order ASC, id ASC",
919 $trip_id
920 )
921 );
922
923 // Convert to simple array of highlight titles
924 $highlight_texts = [];
925 foreach ($highlights as $highlight) {
926 if (!empty($highlight->title)) {
927 $highlight_texts[] = $highlight->title;
928 } elseif (!empty($highlight->description)) {
929 $highlight_texts[] = $highlight->description;
930 }
931 }
932
933 return $highlight_texts;
934 }
935
936 /**
937 * Get landmarks for a trip
938 *
939 * @param int $trip_id Trip ID
940 * @return array Array of landmark texts
941 */
942 private function getLandmarks(int $trip_id): array
943 {
944 $tripContentTable = \Yatra\Database\Tables\TripContentTable::getTableName();
945
946 // Check if table exists
947 $table_exists = $this->wpdb->get_var(
948 $this->wpdb->prepare(
949 "SHOW TABLES LIKE %s",
950 $tripContentTable
951 )
952 ) === $tripContentTable;
953
954 if (!$table_exists) {
955 return [];
956 }
957
958 $landmarks = $this->wpdb->get_results(
959 $this->wpdb->prepare(
960 "SELECT * FROM {$tripContentTable}
961 WHERE trip_id = %d AND content_type = 'landmark'
962 ORDER BY sort_order ASC, id ASC",
963 $trip_id
964 )
965 );
966
967 // Convert to simple array of landmark texts
968 $landmark_texts = [];
969 foreach ($landmarks as $landmark) {
970 if (!empty($landmark->title)) {
971 $landmark_texts[] = $landmark->title;
972 } elseif (!empty($landmark->description)) {
973 $landmark_texts[] = $landmark->description;
974 }
975 }
976
977 return $landmark_texts;
978 }
979
980 /**
981 * Get FAQs for a trip
982 *
983 * @param int $trip_id Trip ID
984 * @return array Array of FAQ objects
985 */
986 private function getFaqs(int $trip_id): array
987 {
988 $tripContentTable = \Yatra\Database\Tables\TripContentTable::getTableName();
989
990 // Check if table exists
991 $table_exists = $this->wpdb->get_var(
992 $this->wpdb->prepare(
993 "SHOW TABLES LIKE %s",
994 $tripContentTable
995 )
996 ) === $tripContentTable;
997
998 if (!$table_exists) {
999 return [];
1000 }
1001
1002 $faqs = $this->wpdb->get_results(
1003 $this->wpdb->prepare(
1004 "SELECT * FROM {$tripContentTable}
1005 WHERE trip_id = %d AND content_type = 'faq'
1006 ORDER BY sort_order ASC, id ASC",
1007 $trip_id
1008 )
1009 );
1010
1011 return $faqs ?: [];
1012 }
1013
1014 /**
1015 * Get downloadable items for a trip
1016 *
1017 * @param int $trip_id Trip ID
1018 * @return array Array of downloadable item objects
1019 */
1020 private function getDownloadableItems(int $trip_id): array
1021 {
1022 $tripContentTable = \Yatra\Database\Tables\TripContentTable::getTableName();
1023
1024 // Check if table exists
1025 $table_exists = $this->wpdb->get_var(
1026 $this->wpdb->prepare(
1027 "SHOW TABLES LIKE %s",
1028 $tripContentTable
1029 )
1030 ) === $tripContentTable;
1031
1032 if (!$table_exists) {
1033 return [];
1034 }
1035
1036 $downloads = $this->wpdb->get_results(
1037 $this->wpdb->prepare(
1038 "SELECT * FROM {$tripContentTable}
1039 WHERE trip_id = %d AND content_type = 'download'
1040 ORDER BY sort_order ASC, id ASC",
1041 $trip_id
1042 )
1043 );
1044
1045 if (!$downloads) {
1046 return [];
1047 }
1048
1049 // Normalize downloads to match expected format
1050 $normalized = [];
1051 foreach ($downloads as $download) {
1052 $metadata = [];
1053 if (!empty($download->metadata)) {
1054 $decoded = json_decode($download->metadata, true);
1055 if (is_array($decoded)) {
1056 $metadata = $decoded;
1057 }
1058 }
1059
1060 $attachmentId = $metadata['attachment_id'] ?? null;
1061 $protectedPath = $metadata['protected_path'] ?? null;
1062 $visibility = $metadata['visibility'] ?? 'booked_only';
1063
1064 $normalized[] = (object) [
1065 'id' => isset($download->id) ? (int) $download->id : 0,
1066 'title' => $download->title ?? '',
1067 'description' => $download->description ?? '',
1068 'attachment_id' => $attachmentId ? (int) $attachmentId : null,
1069 'protected_path' => $protectedPath,
1070 'content_url' => $download->content_url ?? '',
1071 'file_path' => $download->file_path ?? null,
1072 'file_size' => isset($download->file_size) ? (int) $download->file_size : null,
1073 'file_type' => $download->file_type ?? null,
1074 'thumbnail_url' => $download->thumbnail_url ?? null,
1075 'visibility' => $visibility,
1076 'is_downloadable' => isset($download->is_downloadable) ? (bool) $download->is_downloadable : true,
1077 'sort_order' => isset($download->sort_order) ? (int) $download->sort_order : 0,
1078 ];
1079 }
1080
1081 return $normalized;
1082 }
1083
1084 /**
1085 * Extract YouTube video ID from URL
1086 *
1087 * @param string $url YouTube URL
1088 * @return string|null Video ID or null if not found
1089 */
1090 private function extractYoutubeVideoId(string $url): ?string
1091 {
1092 $patterns = [
1093 '/youtube\.com\/watch\?v=([^&]+)/',
1094 '/youtube\.com\/embed\/([^?]+)/',
1095 '/youtu\.be\/([^?]+)/',
1096 '/youtube\.com\/v\/([^?]+)/'
1097 ];
1098
1099 foreach ($patterns as $pattern) {
1100 if (preg_match($pattern, $url, $matches)) {
1101 return $matches[1];
1102 }
1103 }
1104
1105 return null;
1106 }
1107
1108 /**
1109 * Get trip categories for trip
1110 *
1111 * @param int $trip_id Trip ID
1112 * @return array Trip categories
1113 */
1114 private function getTripCategories(int $trip_id): array
1115 {
1116 // Use new Classification tables
1117 $tripClassificationsTable = \Yatra\Database\Tables\TripClassificationsTable::getTableName();
1118 $classificationsTable = \Yatra\Database\Tables\ClassificationsTable::getTableName();
1119
1120 // Check if relation table exists
1121 $table_exists = $this->wpdb->get_var(
1122 $this->wpdb->prepare(
1123 "SHOW TABLES LIKE %s",
1124 $tripClassificationsTable
1125 )
1126 ) === $tripClassificationsTable;
1127
1128 if (!$table_exists) {
1129 return [];
1130 }
1131
1132 return $this->wpdb->get_results(
1133 $this->wpdb->prepare(
1134 "SELECT c.id, c.name, c.slug
1135 FROM {$tripClassificationsTable} tc
1136 LEFT JOIN {$classificationsTable} c ON tc.classification_id = c.id
1137 WHERE tc.trip_id = %d AND c.type = 'category'
1138 ORDER BY tc.sort_order ASC, tc.id ASC",
1139 $trip_id
1140 )
1141 ) ?: [];
1142 }
1143
1144 /**
1145 * Get reviews for trip
1146 *
1147 * @param int $trip_id Trip ID
1148 * @return array Reviews
1149 */
1150 private function getReviews(int $trip_id): array
1151 {
1152 $reviewRepo = new ReviewRepository();
1153 if (!$reviewRepo->tableExists()) {
1154 return [];
1155 }
1156
1157 $approved = $reviewRepo->sqlApprovedReviewsWhere('status');
1158
1159 return $this->wpdb->get_results(
1160 $this->wpdb->prepare(
1161 "SELECT * FROM {$this->table_reviews}
1162 WHERE trip_id = %d
1163 AND {$approved}
1164 ORDER BY created_at DESC
1165 LIMIT 10",
1166 $trip_id
1167 )
1168 ) ?: [];
1169 }
1170
1171 /**
1172 * Get testimonials (selected reviews for this trip)
1173 *
1174 * @param int $trip_id Trip ID
1175 * @return array Testimonial reviews
1176 */
1177 private function getTestimonials(int $trip_id): array
1178 {
1179 // Get testimonial_review_ids from trip data
1180 $testimonial_ids = $this->wpdb->get_var(
1181 $this->wpdb->prepare(
1182 "SELECT testimonial_review_ids FROM {$this->table_trips}
1183 WHERE id = %d
1184 LIMIT 1",
1185 $trip_id
1186 )
1187 );
1188
1189 if (empty($testimonial_ids)) {
1190 return [];
1191 }
1192
1193 // Decode JSON array of IDs
1194 $review_ids = json_decode($testimonial_ids, true);
1195 if (!is_array($review_ids) || empty($review_ids)) {
1196 return [];
1197 }
1198
1199 // Filter out invalid IDs
1200 $review_ids = array_filter($review_ids, 'is_numeric');
1201 if (empty($review_ids)) {
1202 return [];
1203 }
1204
1205 // Get the actual review data
1206 $placeholders = implode(',', array_fill(0, count($review_ids), '%d'));
1207
1208 return $this->wpdb->get_results(
1209 $this->wpdb->prepare(
1210 "SELECT r.*, u.display_name as author_display_name
1211 FROM {$this->table_reviews} r
1212 LEFT JOIN {$this->wpdb->users} u ON r.user_id = u.ID
1213 WHERE r.id IN ($placeholders)
1214 AND " . (new ReviewRepository())->sqlApprovedReviewsWhere('r.status') . "
1215 ORDER BY r.created_at DESC",
1216 ...$review_ids
1217 )
1218 ) ?: [];
1219 }
1220
1221 /**
1222 * Get similar trips
1223 *
1224 * @param object $trip Current trip
1225 * @return array Similar trips
1226 */
1227 private function getSimilarTrips(object $trip): array
1228 {
1229 $trip_id = (int) $trip->id;
1230
1231 // Get similar trips based on category or difficulty
1232 $similar = $this->wpdb->get_results(
1233 $this->wpdb->prepare(
1234 "SELECT id, title, slug, featured_image AS featured_image_id, '' AS featured_image_url, duration_days, duration_nights,
1235 original_price, sale_price, difficulty_level,
1236 short_description
1237 FROM {$this->table_trips} t
1238 WHERE t.id != %d
1239 AND t.status IN ('publish', 'published')
1240 AND (t.deleted_at IS NULL OR t.deleted_at = '0000-00-00 00:00:00')
1241 AND (
1242 EXISTS (
1243 SELECT 1 FROM {$this->table_trip_cat_rel} tc
1244 WHERE tc.trip_id = t.id
1245 AND tc.classification_type = 'category'
1246 AND tc.classification_id IN (
1247 SELECT tc2.classification_id
1248 FROM {$this->table_trip_cat_rel} tc2
1249 WHERE tc2.trip_id = %d
1250 AND tc2.classification_type = 'category'
1251 )
1252 )
1253 OR t.difficulty_level = %s
1254 )
1255 ORDER BY RAND()
1256 LIMIT 4",
1257 $trip_id,
1258 $trip_id,
1259 $trip->difficulty_level ?? ''
1260 )
1261 );
1262
1263 // Fallback: Get any published trips if no similar found
1264 if (empty($similar)) {
1265 $similar = $this->wpdb->get_results(
1266 $this->wpdb->prepare(
1267 "SELECT id, title, slug, featured_image AS featured_image_id, '' AS featured_image_url, duration_days, duration_nights,
1268 original_price, sale_price, difficulty_level,
1269 short_description
1270 FROM {$this->table_trips}
1271 WHERE id != %d
1272 AND status IN ('publish', 'published')
1273 AND (deleted_at IS NULL OR deleted_at = '0000-00-00 00:00:00')
1274 ORDER BY RAND()
1275 LIMIT 4",
1276 $trip_id
1277 )
1278 );
1279 }
1280
1281 // Prepare each similar trip
1282 foreach ($similar as &$s) {
1283 $s->highlights = []; // Empty array as default
1284 $s->duration_days = (int) ($s->duration_days ?? 1);
1285 $s->duration_nights = (int) ($s->duration_nights ?? 0);
1286 $s->original_price = (float) ($s->original_price ?? 0);
1287 $s->sale_price = (float) ($s->sale_price ?? $s->original_price);
1288 $s->currency = $s->currency ?? get_option('yatra_currency', 'USD');
1289 // Handle featured image - use URL if available, otherwise get from ID
1290 if (!empty($s->featured_image_url)) {
1291 // URL is already set, keep it as is
1292 } elseif (!empty($s->featured_image_id)) {
1293 $s->featured_image_url = $this->getFeaturedImageUrl($s->featured_image_id);
1294 } else {
1295 $s->featured_image_url = $this->getFeaturedImageUrl('');
1296 }
1297
1298 // Calculate discount
1299 $s->discount_percentage = 0;
1300 if ($s->original_price > 0 && $s->sale_price < $s->original_price) {
1301 $s->discount_percentage = round((($s->original_price - $s->sale_price) / $s->original_price) * 100);
1302 }
1303 }
1304
1305 return $similar ?: [];
1306 }
1307
1308 /**
1309 * Get trip attributes with their values
1310 *
1311 * @param int $trip_id Trip ID
1312 * @return array Trip attributes with values
1313 */
1314 private function getTripAttributes(int $trip_id): array
1315 {
1316 $repo = new TripAttributeRepository();
1317 $rows = $repo->getTripAttributes($trip_id);
1318
1319 $formatted_attributes = [];
1320 foreach ($rows as $attr) {
1321 if (!$this->isAttributeMetaFlagEnabled($attr->show_on_frontend ?? null)) {
1322 continue;
1323 }
1324
1325 $value = $attr->value;
1326
1327 $field_type = isset($attr->field_type) ? trim((string) $attr->field_type, '"') : 'text';
1328 $field_options = $attr->field_options ?? null;
1329 if (is_string($field_options)) {
1330 $field_options = trim($field_options, '"');
1331 } elseif (is_array($field_options)) {
1332 $field_options = wp_json_encode($field_options);
1333 }
1334
1335 $icon_data = null;
1336 if (!empty($attr->icon)) {
1337 $icon_data = maybe_unserialize($attr->icon);
1338 if (is_array($icon_data) && $icon_data['type'] === 'image' && !empty($icon_data['value'])) {
1339 $icon_value = $icon_data['value'];
1340 $image_url = '';
1341
1342 if (is_numeric($icon_value)) {
1343 $maybe_url = wp_get_attachment_image_url((int) $icon_value, 'large');
1344 if (!empty($maybe_url)) {
1345 $image_url = $maybe_url;
1346 }
1347 } elseif (is_string($icon_value) && filter_var($icon_value, FILTER_VALIDATE_URL)) {
1348 $image_url = $icon_value;
1349 }
1350
1351 $icon_data['value'] = $image_url;
1352 }
1353 }
1354
1355 $formatted_attributes[] = [
1356 'id' => (int) $attr->attribute_id,
1357 'name' => (string) $attr->name,
1358 'field_type' => $field_type,
1359 'field_options' => $field_options,
1360 'value' => $value,
1361 'icon' => $icon_data,
1362 'description' => (string) ($attr->description ?? ''),
1363 ];
1364 }
1365
1366 return $formatted_attributes;
1367 }
1368
1369 /**
1370 * True when admin "Show on Frontend" (or similar) is enabled for JSON_EXTRACT / API values.
1371 */
1372 private function isAttributeMetaFlagEnabled($raw): bool
1373 {
1374 if ($raw === null) {
1375 return false;
1376 }
1377 if (is_bool($raw)) {
1378 return $raw;
1379 }
1380 if (is_numeric($raw)) {
1381 return (int) $raw === 1;
1382 }
1383 $s = strtolower(trim((string) $raw, " \t\n\r\0\x0B\""));
1384
1385 return in_array($s, ['1', 'true', 'yes', 'on'], true);
1386 }
1387
1388 /**
1389 * Average rating from loaded review rows (fallback when SQL AVG returns 0).
1390 *
1391 * @param array<int, object> $reviews
1392 */
1393 private function averageRatingFromReviewRows(array $reviews): float
1394 {
1395 if ($reviews === []) {
1396 return 0.0;
1397 }
1398
1399 $total = 0.0;
1400 foreach ($reviews as $review) {
1401 $total += (float) ($review->rating ?? 0);
1402 }
1403
1404 return round($total / count($reviews), 1);
1405 }
1406
1407 /**
1408 * Get featured image URL
1409 *
1410 * @param string|int $image Image ID or URL
1411 * @return string Image URL
1412 */
1413 private function getFeaturedImageUrl($image): string
1414 {
1415 if (empty($image)) {
1416 return '';
1417 }
1418
1419 // If it's a numeric ID, get the attachment URL
1420 if (is_numeric($image)) {
1421 $url = wp_get_attachment_url((int) $image);
1422 return $url ?: '';
1423 }
1424
1425 // If it's already a URL, return it
1426 if (filter_var($image, FILTER_VALIDATE_URL)) {
1427 return $image;
1428 }
1429
1430 return '';
1431 }
1432
1433 /**
1434 * Get itinerary days from database tables
1435 *
1436 * @param int $trip_id Trip ID
1437 * @return array Itinerary days with entries
1438 */
1439 private function getItineraryDays(int $trip_id): array
1440 {
1441 // Using proper table classes for itinerary system with classification integration
1442 // Note: Items and Item Types now use ClassificationsTable with unified approach
1443 $table_days = \Yatra\Database\Tables\TripItineraryDaysTable::getTableName();
1444 $table_entries = \Yatra\Database\Tables\TripItineraryDayEntryTable::getTableName();
1445 $table_classifications = ClassificationsTable::getTableName();
1446
1447 // Get all days for this trip
1448 $days = $this->wpdb->get_results(
1449 $this->wpdb->prepare(
1450 "SELECT * FROM {$table_days}
1451 WHERE trip_id = %d
1452 ORDER BY day_number ASC",
1453 $trip_id
1454 )
1455 );
1456
1457 if (empty($days)) {
1458 return [];
1459 }
1460
1461 $itinerary = [];
1462 foreach ($days as $day) {
1463 // Get entries for this day
1464 $entries = $this->wpdb->get_results(
1465 $this->wpdb->prepare(
1466 "SELECT e.*,
1467 i.name as item_name,
1468 it.name as item_type_name,
1469 it.icon as item_type_icon,
1470 it.color as item_type_color
1471 FROM {$table_entries} e
1472 LEFT JOIN {$table_classifications} i ON e.item_id = i.id AND i.type = 'item'
1473 LEFT JOIN {$table_classifications} it ON e.item_type_id = it.id AND it.type = 'item_type'
1474 WHERE e.day_id = %d
1475 ORDER BY e.order ASC, e.id ASC",
1476 $day->id
1477 )
1478 );
1479
1480 $formatted_entries = [];
1481 foreach ($entries as $entry) {
1482 $iconPicker = null;
1483 if (!empty($entry->item_type_icon)) {
1484 $rawIcon = $entry->item_type_icon;
1485 // Classification `icon` column may store a serialized array from the icon picker.
1486 // Decode it into the array shape expected by yatra_stored_picker_icon_markup().
1487 $maybe = is_string($rawIcon) ? maybe_unserialize($rawIcon) : $rawIcon;
1488 if (is_array($maybe) && isset($maybe['type'])) {
1489 $iconPicker = $maybe;
1490 } elseif (is_string($rawIcon) && $rawIcon !== '') {
1491 // Backward compatibility: treat as yatra svg slug.
1492 $iconPicker = [
1493 'type' => 'icon',
1494 'value' => (string) $rawIcon,
1495 'provider' => 'yatra',
1496 ];
1497 }
1498 }
1499
1500 $formatted_entries[] = [
1501 'title' => $entry->title ?: $entry->item_name,
1502 'description' => $entry->description ?: '',
1503 'item_type' => $entry->item_type_name ?: 'Activity',
1504 'icon_picker' => $iconPicker,
1505 'item_type_color' => !empty($entry->item_type_color) ? (string) $entry->item_type_color : '',
1506 'start_time' => $entry->start_time ?: '',
1507 'end_time' => $entry->end_time ?: '',
1508 // The public template needs time_type to know whether to render
1509 // exact times, the duration-only label, or "Flexible". Without
1510 // this, all rows fell through to the start_time branch and an
1511 // entry intended as "duration / flexible" still showed clock
1512 // values pulled from stale defaults.
1513 'time_type' => $entry->time_type ?: 'exact',
1514 'location' => $entry->location ?: '',
1515 'duration' => $entry->duration ?: '',
1516 'cost' => !empty($entry->cost) ? (float) $entry->cost : null,
1517 'cost_per_person' => !empty($entry->cost_per_person) ? true : false,
1518 'included' => !empty($entry->included_items) ? json_decode($entry->included_items, true) : [],
1519 'excluded' => !empty($entry->excluded_items) ? json_decode($entry->excluded_items, true) : [],
1520 'gallery' => !empty($entry->gallery) ? $this->decodeGallery($entry->gallery) : [],
1521 'video_url' => $entry->video_url ?: '',
1522 // The admin "Notes / Instructions" textarea ("Additional notes
1523 // or special instructions for this activity") was stored but
1524 // never reached the public template — the array key was
1525 // simply absent. Without this, operators saw their notes
1526 // discarded silently on the live trip page.
1527 'notes' => (string) ($entry->notes ?? ''),
1528 ];
1529 }
1530
1531 $itinerary[] = [
1532 'day' => (int) $day->day_number,
1533 /* translators: %d: itinerary day number. */
1534 'day_title' => $day->title ?: sprintf(__('Day %d', 'yatra'), $day->day_number),
1535 'day_description' => $day->description ?: '',
1536 'entries' => $formatted_entries,
1537 ];
1538 }
1539
1540 return $itinerary;
1541 }
1542
1543 /**
1544 * Format time from 24h to 12h format
1545 *
1546 * @param string $time Time in 24h format (e.g., "14:00")
1547 * @return string Time in 12h format (e.g., "2:00 PM")
1548 */
1549 public static function formatTime(string $time): string
1550 {
1551 if (empty($time) || $time === 'Flexible') {
1552 return $time;
1553 }
1554
1555 // Try to parse and format the time
1556 $timestamp = strtotime($time);
1557 if ($timestamp !== false) {
1558 return date('g:i A', $timestamp);
1559 }
1560
1561 return $time;
1562 }
1563
1564 /**
1565 * Decode gallery JSON data and convert attachment IDs to URLs
1566 *
1567 * @param string|null $galleryJson JSON string from database
1568 * @return array Gallery items with URLs
1569 */
1570 private function decodeGallery(?string $galleryJson): array
1571 {
1572 if (empty($galleryJson)) {
1573 return [];
1574 }
1575
1576 $gallery = json_decode($galleryJson, true);
1577 if (!is_array($gallery)) {
1578 return [];
1579 }
1580
1581 // Convert attachment IDs to URLs for frontend compatibility
1582 foreach ($gallery as &$item) {
1583 if (isset($item['attachment_id']) && $item['attachment_id'] > 0) {
1584 // Get attachment URL from WordPress
1585 $attachment_url = wp_get_attachment_url($item['attachment_id']);
1586 if ($attachment_url) {
1587 $item['url'] = $attachment_url;
1588 }
1589
1590 // Get thumbnail URL for images
1591 if (isset($item['type']) && $item['type'] === 'image') {
1592 $thumbnail_url = wp_get_attachment_image_src($item['attachment_id'], 'medium');
1593 if ($thumbnail_url) {
1594 $item['thumbnail_url'] = $thumbnail_url[0];
1595 }
1596 }
1597 }
1598 }
1599
1600 return $gallery;
1601 }
1602
1603 /**
1604 * Get booking URL for a trip
1605 *
1606 * @param string $slug Trip slug
1607 * @return string Booking URL
1608 */
1609 public static function getBookingUrl(string $slug): string
1610 {
1611 if (function_exists('yatra_get_booking_url')) {
1612 return yatra_get_booking_url($slug);
1613 }
1614
1615 $booking_base = get_option('yatra_booking_base', 'book');
1616 return home_url("/{$booking_base}/{$slug}/");
1617 }
1618
1619 /**
1620 * Render tabs based on frontend_tabs configuration
1621 *
1622 * @param object $trip Trip object with frontend_tabs data
1623 * @return void
1624 */
1625 public static function renderFrontendTabs($trip)
1626 {
1627 $frontend_tabs = isset($trip->frontend_tabs) ? $trip->frontend_tabs : [];
1628
1629 // Use the same merge logic as getStickyNavigationItems to ensure consistency
1630 if (!empty($frontend_tabs)) {
1631 $frontend_tabs = self::mergeFrontendTabsWithDefaults($frontend_tabs);
1632 } else {
1633 // Use default tabs if no database data exists
1634 $frontend_tabs = [
1635 // Core sections (always present)
1636 (object) ['id' => 'overview', 'label' => 'Overview', 'enabled' => true, 'order' => 1, 'content_type' => 'overview', 'icon' => 'book'],
1637 (object) ['id' => 'itinerary', 'label' => 'Itinerary', 'enabled' => true, 'order' => 2, 'content_type' => 'itinerary', 'icon' => 'calendar'],
1638 (object) ['id' => 'included', 'label' => 'Included', 'enabled' => true, 'order' => 3, 'content_type' => 'included_excluded', 'icon' => 'check'],
1639 (object) ['id' => 'location', 'label' => 'Location', 'enabled' => true, 'order' => 4, 'content_type' => 'location', 'icon' => 'map-pin'],
1640 (object) ['id' => 'important_info', 'label' => 'Important Info', 'enabled' => true, 'order' => 5, 'content_type' => 'important_info', 'icon' => 'info'],
1641
1642 // Conditional sections (enabled by default, shown conditionally on frontend)
1643 (object) ['id' => 'downloads', 'label' => 'Downloads', 'enabled' => true, 'order' => 6, 'content_type' => 'downloads', 'icon' => 'download'],
1644 (object) ['id' => 'faq', 'label' => 'FAQ', 'enabled' => true, 'order' => 7, 'content_type' => 'faq', 'icon' => 'help-circle'],
1645 (object) ['id' => 'trip_story', 'label' => 'Story', 'enabled' => true, 'order' => 8, 'content_type' => 'trip_story', 'custom_content' => '', 'icon' => 'book'],
1646 (object) ['id' => 'what_makes_special', 'label' => 'Special', 'enabled' => true, 'order' => 9, 'content_type' => 'what_makes_special', 'custom_content' => '', 'icon' => 'star'],
1647 (object) ['id' => 'testimonials', 'label' => 'Testimonials', 'enabled' => true, 'order' => 10, 'content_type' => 'testimonials', 'icon' => 'message-circle'],
1648 (object) ['id' => 'gallery', 'label' => 'Gallery', 'enabled' => false, 'order' => 11, 'content_type' => 'gallery', 'icon' => 'camera'],
1649 ];
1650 }
1651
1652 // Sort tabs by order and filter enabled tabs
1653 $enabled_tabs = array_filter($frontend_tabs, function($tab) {
1654 return isset($tab->enabled) && filter_var($tab->enabled, FILTER_VALIDATE_BOOLEAN);
1655 });
1656
1657 usort($enabled_tabs, function($a, $b) {
1658 return ($a->order ?? 999) - ($b->order ?? 999);
1659 });
1660
1661 // Render each enabled tab
1662 foreach ($enabled_tabs as $tab) {
1663 self::renderTabContent($tab, $trip);
1664 }
1665 }
1666
1667 /**
1668 * Render individual tab content based on content type
1669 *
1670 * @param object $tab Tab configuration
1671 * @param object $trip Trip object
1672 * @return void
1673 */
1674 private static function renderTabContent($tab, $trip)
1675 {
1676 switch ($tab->content_type) {
1677 case 'overview':
1678 yatra_get_template('partials/single-trip/content-overview', ['trip' => $trip, 'tab' => $tab, 'has_traveler_pricing' => true, 'has_availability' => true, 'base_price' => $trip->original_price]);
1679 break;
1680
1681 case 'itinerary':
1682 yatra_get_template('partials/single-trip/content-itinerary', ['trip' => $trip, 'tab' => $tab]);
1683 break;
1684
1685 case 'included_excluded':
1686 yatra_get_template('partials/single-trip/content-included-excluded', ['trip' => $trip, 'tab' => $tab]);
1687 break;
1688
1689 case 'location':
1690 // Get itinerary entries with coordinates for map display
1691 $itinerary_repository = new \Yatra\Repositories\ItineraryRepository();
1692 $itinerary_entries = $itinerary_repository->getEntriesWithCoordinatesForMap((int) $trip->id);
1693
1694 yatra_get_template('partials/single-trip/content-location', [
1695 'trip' => $trip,
1696 'tab' => $tab,
1697 'itinerary_entries' => $itinerary_entries
1698 ]);
1699 break;
1700
1701 case 'gallery':
1702 yatra_get_template('partials/single-trip/content-gallery', ['trip' => $trip, 'tab' => $tab]);
1703 break;
1704
1705 case 'important_info':
1706 yatra_get_template('partials/single-trip/content-important-info', ['trip' => $trip, 'tab' => $tab]);
1707 break;
1708
1709 case 'downloads':
1710 yatra_get_template('partials/single-trip/content-downloads', ['trip' => $trip, 'tab' => $tab]);
1711 break;
1712
1713 case 'faq':
1714 yatra_get_template('partials/single-trip/content-faq', ['trip' => $trip, 'tab' => $tab]);
1715 break;
1716
1717 case 'trip_story':
1718 yatra_get_template('partials/single-trip/content-trip-story', ['trip' => $trip, 'tab' => $tab]);
1719 break;
1720
1721 case 'what_makes_special':
1722 yatra_get_template('partials/single-trip/content-whats-make-special', ['trip' => $trip, 'tab' => $tab]);
1723 break;
1724
1725 case 'testimonials':
1726 yatra_get_template('partials/single-trip/content-testimonials', ['trip' => $trip, 'tab' => $tab]);
1727 break;
1728
1729 case 'custom':
1730 // Delegated to a partial so the admin-chosen icon (and label, content)
1731 // flow through the same yatra_render_tab_icon() pipeline as every other
1732 // tab type. Previously this branch hardcoded yatra_svg_icon('book')
1733 // which silently dropped the icon admins selected in Trip Builder.
1734 yatra_get_template('partials/single-trip/content-custom', [
1735 'trip' => $trip,
1736 'tab' => $tab,
1737 ]);
1738 break;
1739 }
1740 }
1741
1742 /**
1743 * Get sticky navigation items based on frontend_tabs configuration
1744 *
1745 * @param object $trip Trip object with frontend_tabs data
1746 * @return array Navigation items
1747 */
1748 public static function getStickyNavigationItems($trip)
1749 {
1750 $frontend_tabs = isset($trip->frontend_tabs) ? $trip->frontend_tabs : [];
1751
1752 // Use the same merge logic as renderFrontendTabs to ensure consistency
1753 if (!empty($frontend_tabs)) {
1754 $frontend_tabs = self::mergeFrontendTabsWithDefaults($frontend_tabs);
1755 } else {
1756 // Use default tabs if no database data exists
1757 $frontend_tabs = [
1758 // Core sections (always present)
1759 (object) ['id' => 'overview', 'label' => 'Overview', 'enabled' => true, 'order' => 1, 'content_type' => 'overview', 'icon' => 'book'],
1760 (object) ['id' => 'itinerary', 'label' => 'Itinerary', 'enabled' => true, 'order' => 2, 'content_type' => 'itinerary', 'icon' => 'calendar'],
1761 (object) ['id' => 'included', 'label' => 'Included', 'enabled' => true, 'order' => 3, 'content_type' => 'included_excluded', 'icon' => 'check'],
1762 (object) ['id' => 'location', 'label' => 'Location', 'enabled' => true, 'order' => 4, 'content_type' => 'location', 'icon' => 'map-pin'],
1763 (object) ['id' => 'important_info', 'label' => 'Important Info', 'enabled' => true, 'order' => 5, 'content_type' => 'important_info', 'icon' => 'info'],
1764
1765 // Conditional sections (enabled by default, shown conditionally on frontend)
1766 (object) ['id' => 'downloads', 'label' => 'Downloads', 'enabled' => true, 'order' => 6, 'content_type' => 'downloads', 'icon' => 'download'],
1767 (object) ['id' => 'faq', 'label' => 'FAQ', 'enabled' => true, 'order' => 7, 'content_type' => 'faq', 'icon' => 'help-circle'],
1768 (object) ['id' => 'trip_story', 'label' => 'Story', 'enabled' => true, 'order' => 8, 'content_type' => 'trip_story', 'custom_content' => '', 'icon' => 'book'],
1769 (object) ['id' => 'what_makes_special', 'label' => 'Special', 'enabled' => true, 'order' => 9, 'content_type' => 'what_makes_special', 'custom_content' => '', 'icon' => 'star'],
1770 (object) ['id' => 'testimonials', 'label' => 'Testimonials', 'enabled' => true, 'order' => 10, 'content_type' => 'testimonials', 'icon' => 'message-circle'],
1771 (object) ['id' => 'gallery', 'label' => 'Gallery', 'enabled' => false, 'order' => 11, 'content_type' => 'gallery', 'icon' => 'camera'],
1772 ];
1773 }
1774
1775 // Sort tabs by order and filter enabled tabs
1776 $enabled_tabs = array_filter($frontend_tabs, function($tab) {
1777 return isset($tab->enabled) && $tab->enabled;
1778 });
1779
1780 usort($enabled_tabs, function($a, $b) {
1781 return ($a->order ?? 999) - ($b->order ?? 999);
1782 });
1783
1784 $navigation_items = [];
1785
1786 foreach ($enabled_tabs as $tab) {
1787 $nav_item = self::getNavigationItemForTab($tab, $trip);
1788 if ($nav_item) {
1789 $navigation_items[] = $nav_item;
1790 }
1791 }
1792
1793 return $navigation_items;
1794 }
1795
1796 /**
1797 * Prepare traveler selector data for common component
1798 *
1799 * @param object $trip Trip object
1800 * @param string $context Context for IDs (sidebar, availability, enquiry)
1801 * @return array Traveler selector data
1802 */
1803 public static function prepareTravelerSelectorData($trip, string $context = 'sidebar'): array
1804 {
1805 $trip_pricing_type = $trip->pricing_type ?? '';
1806 $has_traveler_pricing = ($trip_pricing_type === 'traveler_based' && !empty($trip->price_types));
1807 $traveler_rows = [];
1808
1809 if ($has_traveler_pricing) {
1810 // Determine which price type should be selected by default (admin-selected default; else first)
1811 $default_index = 0;
1812 foreach ((array) $trip->price_types as $i => $pt_candidate) {
1813 $pt_candidate = is_array($pt_candidate) ? (object) $pt_candidate : $pt_candidate;
1814 if (!empty($pt_candidate->is_default)) {
1815 $default_index = (int) $i;
1816 break;
1817 }
1818 }
1819
1820 // Traveler-Based Pricing: Show dynamic categories
1821 foreach ($trip->price_types as $index => $price_type) {
1822 // Normalize to object if array
1823 $price_type = is_array($price_type) ? (object) $price_type : $price_type;
1824
1825 $pricing_mode = $price_type->pricing_mode ?? 'per_person';
1826 $is_per_group = ($pricing_mode === 'per_group');
1827 $pricing_label = '';
1828 if ($is_per_group) {
1829 if (!empty($price_type->min_pax) && !empty($price_type->max_pax)) {
1830 /* translators: 1: minimum pax for the group price, 2: maximum pax. */
1831 $pricing_label = sprintf(__('per group (%1$d-%2$d pax)', 'yatra'), $price_type->min_pax, $price_type->max_pax);
1832 } elseif (!empty($price_type->max_pax)) {
1833 /* translators: %d: maximum pax for the group price. */
1834 $pricing_label = sprintf(__('per group (up to %d pax)', 'yatra'), $price_type->max_pax);
1835 } elseif (!empty($price_type->min_pax)) {
1836 /* translators: %d: minimum pax for the group price. */
1837 $pricing_label = sprintf(__('per group (%d+ pax)', 'yatra'), $price_type->min_pax);
1838 } else {
1839 $pricing_label = __('per group', 'yatra');
1840 }
1841 }
1842
1843 $pt_arr = (array) $price_type;
1844 $eff_before_dp = (float) ($price_type->effective_price ?? \Yatra\Services\TripPricingService::resolveCategoryEffectivePrice($pt_arr));
1845 $display_price_type = $eff_before_dp;
1846 if (apply_filters('yatra_dynamic_pricing_enabled', false)) {
1847 $trip_id = is_object($trip) && method_exists($trip, 'getId') ? $trip->getId() : ($trip->id ?? 0);
1848 $pt_orig_dp = (float) ($price_type->original_price ?? 0);
1849 $pt_disc_dp = (float) ($price_type->discounted_price ?? $price_type->sale_price ?? 0);
1850 if ($pt_disc_dp <= 0) {
1851 $pt_disc_dp = $eff_before_dp;
1852 }
1853 $display_price_type = apply_filters('yatra_trip_display_price', $eff_before_dp, $trip_id, [
1854 'departure_date' => null,
1855 'spots_remaining' => null,
1856 'price_type_id' => $price_type->id ?? null,
1857 'original_price' => $pt_orig_dp > 0 ? $pt_orig_dp : $eff_before_dp,
1858 'discounted_price' => $pt_disc_dp > 0 ? $pt_disc_dp : $eff_before_dp,
1859 ]);
1860 }
1861
1862 $age_info = '';
1863 $age_min = $price_type->age_min ?? null;
1864 $age_max = $price_type->age_max ?? null;
1865 if ($age_min !== null || $age_max !== null) {
1866 if ($age_min !== null && $age_max !== null) {
1867 /* translators: 1: minimum age, 2: maximum age. */
1868 $age_info = sprintf(__('(Age %1$d-%2$d)', 'yatra'), $age_min, $age_max);
1869 } elseif ($age_min !== null) {
1870 /* translators: %d: minimum age. */
1871 $age_info = sprintf(__('(Age %d+)', 'yatra'), $age_min);
1872 } else {
1873 /* translators: %d: maximum age. */
1874 $age_info = sprintf(__('(Up to age %d)', 'yatra'), $age_max);
1875 }
1876 }
1877
1878 $price_html = '<div class="yatra-quantity-price-wrapper">';
1879 $price_html .= '<span class="yatra-quantity-price">' . yatra_format_price((float) $display_price_type) . '</span>';
1880 if ($is_per_group) {
1881 $price_html .= '<span class="yatra-pricing-mode-label yatra-pricing-mode-group">' . esc_html($pricing_label) . '</span>';
1882 }
1883 $price_html .= '</div>';
1884
1885 $input_id = 'traveler_' . $price_type->category_id;
1886 $max_travelers = is_object($trip) && method_exists($trip, 'getMaxTravelers') ? $trip->getMaxTravelers() : ($trip->max_travelers ?? 20);
1887 $pt_max_qty = (int) ($price_type->max_quantity ?: $max_travelers);
1888 $pt_value = ($index === $default_index) ? 1 : 0;
1889
1890 $traveler_rows[] = [
1891 'label' => $price_type->category_label ?: __('Traveler', 'yatra'),
1892 'subtitle' => $age_info,
1893 'price_html' => $price_html,
1894 'row_attrs' => [
1895 'data-category-id' => $price_type->category_id,
1896 'data-price' => $price_type->effective_price,
1897 'data-pricing-mode' => $pricing_mode,
1898 ],
1899 'minus_disabled' => ($index !== $default_index),
1900 'plus_disabled' => false,
1901 'minus_attrs' => [
1902 'data-target' => $input_id,
1903 /* translators: %s: traveler category label (e.g. "Adult", "Child"). */
1904 'aria-label' => sprintf(__('Decrease %s', 'yatra'), $price_type->category_label),
1905 ],
1906 'plus_attrs' => [
1907 'data-target' => $input_id,
1908 /* translators: %s: traveler category label (e.g. "Adult", "Child"). */
1909 'aria-label' => sprintf(__('Increase %s', 'yatra'), $price_type->category_label),
1910 ],
1911 'input_attrs' => [
1912 'id' => $input_id,
1913 'name' => 'travelers[' . $price_type->category_id . ']',
1914 'value' => $pt_value,
1915 'min' => 0,
1916 'max' => $pt_max_qty,
1917 'data-category' => $price_type->category_id,
1918 'data-category-label' => $price_type->category_label,
1919 'data-price' => $price_type->effective_price,
1920 'data-pricing-mode' => $pricing_mode,
1921 ],
1922 ];
1923 }
1924
1925 // Generate display text with all categories and their default values
1926 $display_parts = [];
1927 foreach ($trip->price_types as $index => $price_type) {
1928 $category_label = $price_type->category_label ?? __('Traveler', 'yatra');
1929 $default_value = ($index === $default_index) ? 1 : 0;
1930
1931 if ($default_value > 0) {
1932 $display_parts[] = $category_label . ' x ' . $default_value;
1933 }
1934 }
1935
1936 $traveler_display_text = !empty($display_parts) ? implode(', ', $display_parts) : __('Select travelers', 'yatra');
1937 } else {
1938 // Regular Pricing: Simple adult/children setup
1939 $traveler_rows = [
1940 [
1941 'label' => __('Adult', 'yatra'),
1942 'subtitle' => __('(Age 13-99)', 'yatra'),
1943 'price_html' => '',
1944 'row_attrs' => [],
1945 'minus_disabled' => false,
1946 'plus_disabled' => false,
1947 'minus_attrs' => [
1948 'data-target' => $context . '_adults',
1949 'aria-label' => __('Decrease adults', 'yatra'),
1950 ],
1951 'plus_attrs' => [
1952 'data-target' => $context . '_adults',
1953 'aria-label' => __('Increase adults', 'yatra'),
1954 ],
1955 'input_attrs' => [
1956 'id' => $context . '_adults',
1957 'name' => $context . '_adults',
1958 'value' => 1,
1959 'min' => 1,
1960 'max' => 20,
1961 ],
1962 ],
1963 [
1964 'label' => __('Child', 'yatra'),
1965 'subtitle' => __('(Age 4-12)', 'yatra'),
1966 'price_html' => '',
1967 'row_attrs' => [],
1968 'minus_disabled' => true,
1969 'plus_disabled' => false,
1970 'minus_attrs' => [
1971 'data-target' => $context . '_children',
1972 'aria-label' => __('Decrease children', 'yatra'),
1973 ],
1974 'plus_attrs' => [
1975 'data-target' => $context . '_children',
1976 'aria-label' => __('Increase children', 'yatra'),
1977 ],
1978 'input_attrs' => [
1979 'id' => $context . '_children',
1980 'name' => $context . '_children',
1981 'value' => 0,
1982 'min' => 0,
1983 'max' => 10,
1984 ],
1985 ],
1986 ];
1987
1988 $traveler_display_text = __('Adult x 1', 'yatra');
1989 }
1990
1991 return [
1992 'has_traveler_pricing' => $has_traveler_pricing,
1993 'traveler_rows' => $traveler_rows,
1994 'traveler_display_text' => $traveler_display_text,
1995 'context' => $context,
1996 ];
1997 }
1998
1999 /**
2000 * Prepare traveler selector data for availability section
2001 *
2002 * @param object $trip Trip data
2003 * @param array $card Availability card data
2004 * @param string $item_id Item ID
2005 * @param int $trip_id Trip ID
2006 * @param int $seats_available Seats available
2007 * @param int $max_travelers Max travelers
2008 * @param bool $dp_enabled Dynamic pricing enabled
2009 * @param array $initial_travelers Initial traveler counts from request (category_id => count)
2010 * @return array Traveler selector data
2011 */
2012 public static function prepareAvailabilityTravelerSelectorData($trip, array $card, string $item_id, int $trip_id, int $seats_available, int $max_travelers, bool $dp_enabled, array $initial_travelers = []): array
2013 {
2014 $card_pricing_type = $card['pricing_type'] ?? 'regular';
2015 $card_price_types = [];
2016
2017 // Always use enriched trip-level price_types for traveler-based pricing
2018 if ($card_pricing_type === 'traveler_based' && !empty($trip->price_types)) {
2019 $card_price_types = $trip->price_types;
2020 }
2021
2022 $has_traveler_pricing = ($card_pricing_type === 'traveler_based' && !empty($card_price_types));
2023 $traveler_rows = [];
2024
2025 if ($has_traveler_pricing) {
2026 // Normalize price_types
2027 $normalized_price_types = [];
2028 foreach ($card_price_types as $pt) {
2029 if (is_array($pt)) {
2030 $normalized_price_types[] = (object) $pt;
2031 } else {
2032 $normalized_price_types[] = $pt;
2033 }
2034 }
2035
2036 // Build display text from initial travelers if provided
2037 $display_parts = [];
2038
2039 // Default selection: if no initial travelers, pick admin-default category (else first)
2040 $default_category_id = null;
2041 foreach ($normalized_price_types as $pt_candidate) {
2042 if (!is_object($pt_candidate)) {
2043 continue;
2044 }
2045 if (!empty($pt_candidate->is_default) && !empty($pt_candidate->category_id)) {
2046 $default_category_id = (int) $pt_candidate->category_id;
2047 break;
2048 }
2049 }
2050
2051 foreach ($normalized_price_types as $pt_index => $pt) {
2052 $pt_min = isset($pt->age_min) ? (int) $pt->age_min : 0;
2053 $pt_max = isset($pt->age_max) ? (int) $pt->age_max : 99;
2054 $pt_label = $pt->category_label ?? $pt->label ?? __('Traveler', 'yatra');
2055 /* translators: 1: minimum age, 2: maximum age. */
2056 $pt_age_text = ($pt_min > 0 || $pt_max < 99) ? sprintf(__('(Age %1$d-%2$d)', 'yatra'), $pt_min, $pt_max) : '';
2057
2058 // Use initial traveler count if provided, otherwise use default
2059 $pt_category_id = $pt->category_id ?? $pt_index;
2060 $pt_default = isset($initial_travelers[$pt_category_id])
2061 ? (int) $initial_travelers[$pt_category_id]
2062 : (($default_category_id !== null && (int) $pt_category_id === (int) $default_category_id) ? 1 : (($default_category_id === null && $pt_index === 0) ? 1 : 0));
2063
2064 $pt_price = 0;
2065 if (isset($pt->effective_price) && $pt->effective_price > 0) {
2066 $pt_price = (float) $pt->effective_price;
2067 } elseif (isset($pt->sale_price) && $pt->sale_price > 0) {
2068 $pt_price = (float) $pt->sale_price;
2069 } elseif (isset($pt->discounted_price) && $pt->discounted_price > 0) {
2070 $pt_price = (float) $pt->discounted_price;
2071 } elseif (isset($pt->original_price) && $pt->original_price > 0) {
2072 $pt_price = (float) $pt->original_price;
2073 }
2074
2075 // Apply dynamic pricing to traveler category prices
2076 if ($dp_enabled && $pt_price > 0) {
2077 $pt_orig_dp = (float) ($pt->original_price ?? 0);
2078 $pt_disc_dp = (float) ($pt->discounted_price ?? $pt->sale_price ?? $pt->effective_price ?? 0);
2079 if ($pt_disc_dp <= 0) {
2080 $pt_disc_dp = $pt_price;
2081 }
2082 $pt_price = apply_filters('yatra_availability_price', $pt_price, $trip_id, [
2083 'departure_date' => $card['date'] ?? null,
2084 'spots_remaining' => $card['spots_remaining'] ?? null,
2085 'availability_id' => $item_id,
2086 'price_type_id' => $pt->id ?? ($pt->price_type_id ?? null),
2087 'original_price' => $pt_orig_dp > 0 ? $pt_orig_dp : $pt_price,
2088 'discounted_price' => $pt_disc_dp > 0 ? $pt_disc_dp : $pt_price,
2089 ]);
2090 }
2091
2092 $pt_category_id = $pt->category_id ?? $pt_index;
2093 $pt_min_qty = 0;
2094 $pt_max_qty = (int) min($seats_available, $max_travelers);
2095 $pt_pricing_mode = $pt->pricing_mode ?? 'per_person';
2096 $pt_is_per_group = ($pt_pricing_mode === 'per_group');
2097
2098 // Build pricing label
2099 $pricing_label = '';
2100 if ($pt_is_per_group) {
2101 if (!empty($pt->min_pax) && !empty($pt->max_pax)) {
2102 /* translators: 1: minimum pax for the group price, 2: maximum pax. */
2103 $pricing_label = sprintf(__('per group (%1$d-%2$d pax)', 'yatra'), $pt->min_pax, $pt->max_pax);
2104 } elseif (!empty($pt->max_pax)) {
2105 /* translators: %d: maximum pax for the group price. */
2106 $pricing_label = sprintf(__('per group (up to %d pax)', 'yatra'), $pt->max_pax);
2107 } elseif (!empty($pt->min_pax)) {
2108 /* translators: %d: minimum pax for the group price. */
2109 $pricing_label = sprintf(__('per group (%d+ pax)', 'yatra'), $pt->min_pax);
2110 } else {
2111 $pricing_label = __('per group', 'yatra');
2112 }
2113 }
2114
2115 $price_html = '<div class="yatra-quantity-price-wrapper">';
2116 $price_html .= '<span class="yatra-quantity-price">' . yatra_format_price($pt_price) . '</span>';
2117 if ($pt_is_per_group) {
2118 $price_html .= '<span class="yatra-pricing-mode-label yatra-pricing-mode-group">' . esc_html($pricing_label) . '</span>';
2119 }
2120 $price_html .= '</div>';
2121
2122 $traveler_rows[] = [
2123 'label' => $pt_label,
2124 'subtitle' => $pt_age_text,
2125 'price_html' => $price_html,
2126 'row_attrs' => [
2127 'data-category-id' => $pt_category_id,
2128 'data-price' => $pt_price,
2129 'data-pricing-mode' => $pt_pricing_mode,
2130 ],
2131 'minus_disabled' => ($pt_default <= 0),
2132 'plus_disabled' => false,
2133 'minus_attrs' => [
2134 'data-target' => 'traveler_' . $pt_category_id . '_' . $item_id,
2135 /* translators: %s: traveler category label (e.g. "Adult", "Child"). */
2136 'aria-label' => sprintf(__('Decrease %s', 'yatra'), $pt_label),
2137 ],
2138 'plus_attrs' => [
2139 'data-target' => 'traveler_' . $pt_category_id . '_' . $item_id,
2140 /* translators: %s: traveler category label (e.g. "Adult", "Child"). */
2141 'aria-label' => sprintf(__('Increase %s', 'yatra'), $pt_label),
2142 ],
2143 'input_attrs' => [
2144 'data-item' => $item_id,
2145 'data-category' => $pt_category_id,
2146 'data-price' => $pt_price,
2147 'data-pricing-mode' => $pt_pricing_mode,
2148 'value' => $pt_default,
2149 'min' => $pt_min_qty,
2150 'max' => $pt_max_qty,
2151 ],
2152 ];
2153
2154 // Build display text parts
2155 if ($pt_default > 0) {
2156 $display_parts[] = $pt_label . ' x ' . $pt_default;
2157 }
2158 }
2159
2160 // Generate display text from parts
2161 $traveler_display_text = !empty($display_parts) ? implode(', ', $display_parts) : __('Select travelers', 'yatra');
2162 }
2163
2164 return [
2165 'has_traveler_pricing' => $has_traveler_pricing,
2166 'traveler_rows' => $traveler_rows,
2167 'traveler_display_text' => $traveler_display_text ?? __('Adult x 1', 'yatra'),
2168 'item_id' => $item_id,
2169 ];
2170 }
2171
2172 /**
2173 * Get navigation item for a specific tab
2174 *
2175 * @param object $tab Tab configuration
2176 * @param object $trip Trip object
2177 * @return array|null Navigation item data
2178 */
2179 private static function getNavigationItemForTab($tab, $trip)
2180 {
2181 // Only check if tab is enabled - content existence is handled by templates
2182 // All tabs should appear in navigation if enabled, regardless of content
2183
2184 // Map content types to icons and hrefs
2185 $icon_map = [
2186 'overview' => 'book',
2187 'itinerary' => 'calendar',
2188 'included_excluded' => 'check',
2189 'location' => 'map-pin',
2190 'gallery' => 'camera',
2191 'important_info' => 'info',
2192 'downloads' => 'download',
2193 'faq' => 'help-circle',
2194 'trip_story' => 'book',
2195 'what_makes_special' => 'star',
2196 'testimonials' => 'message-circle',
2197 'custom' => 'book'
2198 ];
2199
2200 $href_map = [
2201 'overview' => '#overview',
2202 'itinerary' => '#itinerary',
2203 'included' => '#included',
2204 'location' => '#location',
2205 'gallery' => '#gallery',
2206 'important_info' => '#important-info',
2207 'downloads' => '#downloads',
2208 'faq' => '#faq',
2209 'trip_story' => '#trip-story',
2210 'what_makes_special' => '#what-makes-special',
2211 'testimonials' => '#testimonials',
2212 'custom' => '#custom'
2213 ];
2214
2215 // For custom tabs, use the actual tab ID to ensure unique anchors
2216 $href = isset($href_map[$tab->id]) ? $href_map[$tab->id] : '#' . $tab->id;
2217
2218 // Use custom icon if available, otherwise fallback to default icon mapping
2219 $icon = 'book'; // default fallback
2220 $icon_data = null;
2221
2222 if (isset($tab->icon) && !empty($tab->icon)) {
2223 // Handle both array and object formats for icon
2224 if (is_array($tab->icon)) {
2225 $icon_data = $tab->icon;
2226 } elseif (is_object($tab->icon)) {
2227 $icon_data = (array) $tab->icon;
2228 } elseif (is_string($tab->icon)) {
2229 // Convert string to icon data structure
2230 $icon_data = ['type' => 'icon', 'value' => $tab->icon];
2231 }
2232 } else {
2233 // Fallback to default icon mapping
2234 $icon_data = ['type' => 'icon', 'value' => $icon_map[$tab->content_type] ?? 'book'];
2235 }
2236
2237 return [
2238 'id' => $tab->id,
2239 'label' => $tab->label,
2240 'href' => $href,
2241 'icon' => $icon_data
2242 ];
2243 }
2244
2245 /**
2246 * Merge database frontend_tabs with complete default array
2247 * Ensures all sections are always available in the backend
2248 *
2249 * @param array $db_tabs Database frontend_tabs data
2250 * @return array | object Merged frontend_tabs with all sections
2251 */
2252 private static function mergeFrontendTabsWithDefaults($db_tabs)
2253 {
2254
2255
2256 $default_tabs = [
2257 // Core sections (always present)
2258 (object) ['id' => 'overview', 'label' => 'Overview', 'enabled' => true, 'order' => 1, 'content_type' => 'overview', 'icon' => 'book'],
2259 (object) ['id' => 'itinerary', 'label' => 'Itinerary', 'enabled' => true, 'order' => 2, 'content_type' => 'itinerary', 'icon' => 'calendar'],
2260 (object) ['id' => 'included', 'label' => 'Included', 'enabled' => true, 'order' => 3, 'content_type' => 'included_excluded', 'icon' => 'check'],
2261 (object) ['id' => 'location', 'label' => 'Location', 'enabled' => true, 'order' => 4, 'content_type' => 'location', 'icon' => 'map-pin'],
2262 (object) ['id' => 'important_info', 'label' => 'Important Info', 'enabled' => true, 'order' => 5, 'content_type' => 'important_info', 'icon' => 'info'],
2263
2264 // Conditional sections (enabled by default, shown conditionally on frontend)
2265 (object) ['id' => 'downloads', 'label' => 'Downloads', 'enabled' => true, 'order' => 6, 'content_type' => 'downloads', 'icon' => 'download'],
2266 (object) ['id' => 'faq', 'label' => 'FAQ', 'enabled' => true, 'order' => 7, 'content_type' => 'faq', 'icon' => 'help-circle'],
2267 (object) ['id' => 'trip_story', 'label' => 'Story', 'enabled' => true, 'order' => 8, 'content_type' => 'trip_story', 'custom_content' => '', 'icon' => 'book'],
2268 (object) ['id' => 'what_makes_special', 'label' => 'Special', 'enabled' => true, 'order' => 9, 'content_type' => 'what_makes_special', 'custom_content' => '', 'icon' => 'star'],
2269 (object) ['id' => 'testimonials', 'label' => 'Testimonials', 'enabled' => true, 'order' => 10, 'content_type' => 'testimonials', 'icon' => 'message-circle'],
2270 (object) ['id' => 'gallery', 'label' => 'Gallery', 'enabled' => false, 'order' => 11, 'content_type' => 'gallery', 'icon' => 'camera'],
2271 ];
2272
2273 // If no database data, return defaults
2274 if (empty($db_tabs) || !is_array($db_tabs)) {
2275 return $default_tabs;
2276 }
2277
2278 // Create a map of database tabs by ID for easy lookup
2279 $db_tabs_map = [];
2280 foreach ($db_tabs as $db_tab) {
2281 // Handle both arrays and objects
2282 $tab_id = null;
2283 if (is_array($db_tab) && isset($db_tab['id'])) {
2284 $tab_id = $db_tab['id'];
2285 } elseif (is_object($db_tab) && isset($db_tab->id)) {
2286 $tab_id = $db_tab->id;
2287 }
2288
2289 if ($tab_id) {
2290 $db_tabs_map[$tab_id] = $db_tab;
2291 }
2292 }
2293
2294 // Merge defaults with database data
2295 $merged_tabs = [];
2296 foreach ($default_tabs as $default_tab) {
2297 $tab_id = $default_tab->id;
2298
2299 if (isset($db_tabs_map[$tab_id])) {
2300 // Use database data, but ensure all required fields exist
2301 $db_tab = $db_tabs_map[$tab_id];
2302
2303 // Handle both arrays and objects
2304 $db_label = is_array($db_tab) ? ($db_tab['label'] ?? null) : ($db_tab->label ?? null);
2305 $db_enabled = is_array($db_tab) ? ($db_tab['enabled'] ?? null) : ($db_tab->enabled ?? null);
2306 $db_order = is_array($db_tab) ? ($db_tab['order'] ?? null) : ($db_tab->order ?? null);
2307 $db_custom_content = is_array($db_tab) ? ($db_tab['custom_content'] ?? null) : ($db_tab->custom_content ?? null);
2308 $db_icon = is_array($db_tab) ? ($db_tab['icon'] ?? null) : ($db_tab->icon ?? null);
2309
2310 // Preserve full icon data structure
2311 $icon_data = null;
2312 if ($db_icon) {
2313 if (is_array($db_icon)) {
2314 $icon_data = $db_icon;
2315 } elseif (is_object($db_icon)) {
2316 $icon_data = (array) $db_icon;
2317 } elseif (is_string($db_icon)) {
2318 // Convert string to icon data structure
2319 $icon_data = ['type' => 'icon', 'value' => $db_icon];
2320 }
2321 } else {
2322 // Use default icon as data structure
2323 $icon_data = ['type' => 'icon', 'value' => $default_tab->icon];
2324 }
2325
2326 $merged_tab = [
2327 'id' => $tab_id,
2328 'label' => $db_label ?? $default_tab->label,
2329 'enabled' => $db_enabled !== null ? filter_var($db_enabled, FILTER_VALIDATE_BOOLEAN) : $default_tab->enabled,
2330 'order' => $db_order !== null ? (int) $db_order : $default_tab->order,
2331 // Always use the new content type from defaults to ensure consistency
2332 'content_type' => $default_tab->content_type,
2333 'icon' => $icon_data
2334 ];
2335 // Add custom_content for custom tabs if exists
2336 if ($default_tab->content_type === 'custom') {
2337 $merged_tab['custom_content'] = $db_custom_content ?? $default_tab->custom_content ?? '';
2338 }
2339
2340 $merged_tabs[] = (object) $merged_tab;
2341 } else {
2342 // Use default for missing tabs
2343 $merged_tabs[] = (object) $default_tab;
2344 }
2345 }
2346
2347 // Add custom tabs from database that aren't in the defaults
2348 foreach ($db_tabs_map as $tab_id => $db_tab) {
2349 // Skip if this tab was already processed in the defaults
2350 $found_in_defaults = false;
2351 foreach ($default_tabs as $default_tab) {
2352 if ($default_tab->id === $tab_id) {
2353 $found_in_defaults = true;
2354 break;
2355 }
2356 }
2357
2358 // Handle both arrays and objects for content_type check
2359 $content_type = is_array($db_tab) ? ($db_tab['content_type'] ?? null) : ($db_tab->content_type ?? null);
2360
2361 // If not found in defaults and it's a custom tab, add it
2362 if (!$found_in_defaults && $content_type === 'custom') {
2363 // Handle both arrays and objects for field access
2364 $db_label = is_array($db_tab) ? ($db_tab['label'] ?? null) : ($db_tab->label ?? null);
2365 $db_enabled = is_array($db_tab) ? ($db_tab['enabled'] ?? null) : ($db_tab->enabled ?? null);
2366 $db_order = is_array($db_tab) ? ($db_tab['order'] ?? null) : ($db_tab->order ?? null);
2367 $db_custom_content = is_array($db_tab) ? ($db_tab['custom_content'] ?? null) : ($db_tab->custom_content ?? null);
2368 $db_icon = is_array($db_tab) ? ($db_tab['icon'] ?? null) : ($db_tab->icon ?? null);
2369
2370 $custom_tab = [
2371 'id' => $tab_id,
2372 'label' => $db_label ?? __('Custom Tab', 'yatra'),
2373 'enabled' => $db_enabled !== null ? filter_var($db_enabled, FILTER_VALIDATE_BOOLEAN) : true,
2374 'order' => $db_order !== null ? (int) $db_order : 999,
2375 'content_type' => 'custom',
2376 'custom_content' => $db_custom_content ?? '',
2377 'icon' => $db_icon
2378 ];
2379 $merged_tabs[] = (object) $custom_tab;
2380 }
2381 }
2382
2383 return $merged_tabs;
2384 }
2385 }
2386
2387