PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.15
Yatra – Travel Booking & Tour Operator Software v3.0.15
3.0.15 3.0.14 3.0.14.1 3.0.14.2 3.0.12 3.0.13 3.0.11 3.0.10 3.0.9 3.0.8 3.0.7 3.0.6 3.0.5 3.0.5.1 3.0.4 3.0.3 3.0.2.9 3.0.2.7 3.0.2.8 3.0.2.6 trunk 1.0.0 2.0.0 2.0.1 2.0.10 All 83 releases
yatra / app / Controllers / SingleTripController.php

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

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