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

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