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

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