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

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