PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.2.9
Yatra – Travel Booking & Tour Operator Software v3.0.2.9
3.0.15 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 All 83 releases
yatra / app / Controllers / SingleTripController.php

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

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