PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.3
Yatra – Travel Booking & Tour Operator Software v3.0.3
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.3, at app/Controllers/SingleTripController.php

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