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

2,411 lines 98.3 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);
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 ];
1100
1101 foreach ($patterns as $pattern) {
1102 if (preg_match($pattern, $url, $matches)) {
1103 return $matches[1];
1104 }
1105 }
1106
1107 return null;
1108 }
1109
1110 /**
1111 * Get trip categories for trip
1112 *
1113 * @param int $trip_id Trip ID
1114 * @return array Trip categories
1115 */
1116 private function getTripCategories(int $trip_id): array
1117 {
1118 // Use new Classification tables
1119 $tripClassificationsTable = \Yatra\Database\Tables\TripClassificationsTable::getTableName();
1120 $classificationsTable = \Yatra\Database\Tables\ClassificationsTable::getTableName();
1121
1122 // Check if relation table exists
1123 $table_exists = $this->wpdb->get_var(
1124 $this->wpdb->prepare(
1125 "SHOW TABLES LIKE %s",
1126 $tripClassificationsTable
1127 )
1128 ) === $tripClassificationsTable;
1129
1130 if (!$table_exists) {
1131 return [];
1132 }
1133
1134 return $this->wpdb->get_results(
1135 $this->wpdb->prepare(
1136 "SELECT c.id, c.name, c.slug
1137 FROM {$tripClassificationsTable} tc
1138 LEFT JOIN {$classificationsTable} c ON tc.classification_id = c.id
1139 WHERE tc.trip_id = %d AND c.type = 'category'
1140 ORDER BY tc.sort_order ASC, tc.id ASC",
1141 $trip_id
1142 )
1143 ) ?: [];
1144 }
1145
1146 /**
1147 * Get reviews for trip
1148 *
1149 * @param int $trip_id Trip ID
1150 * @return array Reviews
1151 */
1152 private function getReviews(int $trip_id): array
1153 {
1154 $reviewRepo = new ReviewRepository();
1155 if (!$reviewRepo->tableExists()) {
1156 return [];
1157 }
1158
1159 $approved = $reviewRepo->sqlApprovedReviewsWhere('status');
1160
1161 return $this->wpdb->get_results(
1162 $this->wpdb->prepare(
1163 "SELECT * FROM {$this->table_reviews}
1164 WHERE trip_id = %d
1165 AND {$approved}
1166 ORDER BY created_at DESC
1167 LIMIT 10",
1168 $trip_id
1169 )
1170 ) ?: [];
1171 }
1172
1173 /**
1174 * Get testimonials (selected reviews for this trip)
1175 *
1176 * @param int $trip_id Trip ID
1177 * @return array Testimonial reviews
1178 */
1179 private function getTestimonials(int $trip_id): array
1180 {
1181 // Get testimonial_review_ids from trip data
1182 $testimonial_ids = $this->wpdb->get_var(
1183 $this->wpdb->prepare(
1184 "SELECT testimonial_review_ids FROM {$this->table_trips}
1185 WHERE id = %d
1186 LIMIT 1",
1187 $trip_id
1188 )
1189 );
1190
1191 if (empty($testimonial_ids)) {
1192 return [];
1193 }
1194
1195 // Decode JSON array of IDs
1196 $review_ids = json_decode($testimonial_ids, true);
1197 if (!is_array($review_ids) || empty($review_ids)) {
1198 return [];
1199 }
1200
1201 // Filter out invalid IDs
1202 $review_ids = array_filter($review_ids, 'is_numeric');
1203 if (empty($review_ids)) {
1204 return [];
1205 }
1206
1207 // Get the actual review data
1208 $placeholders = implode(',', array_fill(0, count($review_ids), '%d'));
1209
1210 return $this->wpdb->get_results(
1211 $this->wpdb->prepare(
1212 "SELECT r.*, u.display_name as author_display_name
1213 FROM {$this->table_reviews} r
1214 LEFT JOIN {$this->wpdb->users} u ON r.user_id = u.ID
1215 WHERE r.id IN ($placeholders)
1216 AND " . (new ReviewRepository())->sqlApprovedReviewsWhere('r.status') . "
1217 ORDER BY r.created_at DESC",
1218 ...$review_ids
1219 )
1220 ) ?: [];
1221 }
1222
1223 /**
1224 * Get similar trips
1225 *
1226 * @param object $trip Current trip
1227 * @return array Similar trips
1228 */
1229 private function getSimilarTrips(object $trip): array
1230 {
1231 $trip_id = (int) $trip->id;
1232
1233 // Get similar trips based on category or difficulty
1234 $similar = $this->wpdb->get_results(
1235 $this->wpdb->prepare(
1236 "SELECT id, title, slug, featured_image AS featured_image_id, '' AS featured_image_url, duration_days, duration_nights,
1237 original_price, sale_price, difficulty_level,
1238 short_description
1239 FROM {$this->table_trips} t
1240 WHERE t.id != %d
1241 AND t.status IN ('publish', 'published')
1242 AND (t.deleted_at IS NULL OR t.deleted_at = '0000-00-00 00:00:00')
1243 AND (
1244 EXISTS (
1245 SELECT 1 FROM {$this->table_trip_cat_rel} tc
1246 WHERE tc.trip_id = t.id
1247 AND tc.classification_type = 'category'
1248 AND tc.classification_id IN (
1249 SELECT tc2.classification_id
1250 FROM {$this->table_trip_cat_rel} tc2
1251 WHERE tc2.trip_id = %d
1252 AND tc2.classification_type = 'category'
1253 )
1254 )
1255 OR t.difficulty_level = %s
1256 )
1257 ORDER BY RAND()
1258 LIMIT 4",
1259 $trip_id,
1260 $trip_id,
1261 $trip->difficulty_level ?? ''
1262 )
1263 );
1264
1265 // Fallback: Get any published trips if no similar found
1266 if (empty($similar)) {
1267 $similar = $this->wpdb->get_results(
1268 $this->wpdb->prepare(
1269 "SELECT id, title, slug, featured_image AS featured_image_id, '' AS featured_image_url, duration_days, duration_nights,
1270 original_price, sale_price, difficulty_level,
1271 short_description
1272 FROM {$this->table_trips}
1273 WHERE id != %d
1274 AND status IN ('publish', 'published')
1275 AND (deleted_at IS NULL OR deleted_at = '0000-00-00 00:00:00')
1276 ORDER BY RAND()
1277 LIMIT 4",
1278 $trip_id
1279 )
1280 );
1281 }
1282
1283 // Prepare each similar trip
1284 foreach ($similar as &$s) {
1285 $s->highlights = []; // Empty array as default
1286 $s->duration_days = (int) ($s->duration_days ?? 1);
1287 $s->duration_nights = (int) ($s->duration_nights ?? 0);
1288 $s->original_price = (float) ($s->original_price ?? 0);
1289 $s->sale_price = (float) ($s->sale_price ?? $s->original_price);
1290 $s->currency = $s->currency ?? get_option('yatra_currency', 'USD');
1291 // Handle featured image - use URL if available, otherwise get from ID
1292 if (!empty($s->featured_image_url)) {
1293 // URL is already set, keep it as is
1294 } elseif (!empty($s->featured_image_id)) {
1295 $s->featured_image_url = $this->getFeaturedImageUrl($s->featured_image_id);
1296 } else {
1297 $s->featured_image_url = $this->getFeaturedImageUrl('');
1298 }
1299
1300 // Calculate discount
1301 $s->discount_percentage = 0;
1302 if ($s->original_price > 0 && $s->sale_price < $s->original_price) {
1303 $s->discount_percentage = round((($s->original_price - $s->sale_price) / $s->original_price) * 100);
1304 }
1305 }
1306
1307 return $similar ?: [];
1308 }
1309
1310 /**
1311 * Get trip attributes with their values
1312 *
1313 * @param int $trip_id Trip ID
1314 * @return array Trip attributes with values
1315 */
1316 private function getTripAttributes(int $trip_id): array
1317 {
1318 $repo = new TripAttributeRepository();
1319 $rows = $repo->getTripAttributes($trip_id);
1320
1321 $formatted_attributes = [];
1322 foreach ($rows as $attr) {
1323 if (!$this->isAttributeMetaFlagEnabled($attr->show_on_frontend ?? null)) {
1324 continue;
1325 }
1326
1327 $value = $attr->value;
1328
1329 $field_type = isset($attr->field_type) ? trim((string) $attr->field_type, '"') : 'text';
1330 $field_options = $attr->field_options ?? null;
1331 if (is_string($field_options)) {
1332 $field_options = trim($field_options, '"');
1333 } elseif (is_array($field_options)) {
1334 $field_options = wp_json_encode($field_options);
1335 }
1336
1337 $icon_data = null;
1338 if (!empty($attr->icon)) {
1339 $icon_data = maybe_unserialize($attr->icon);
1340 if (is_array($icon_data) && $icon_data['type'] === 'image' && !empty($icon_data['value'])) {
1341 $icon_value = $icon_data['value'];
1342 $image_url = '';
1343
1344 if (is_numeric($icon_value)) {
1345 $maybe_url = wp_get_attachment_image_url((int) $icon_value, 'large');
1346 if (!empty($maybe_url)) {
1347 $image_url = $maybe_url;
1348 }
1349 } elseif (is_string($icon_value) && filter_var($icon_value, FILTER_VALIDATE_URL)) {
1350 $image_url = $icon_value;
1351 }
1352
1353 $icon_data['value'] = $image_url;
1354 }
1355 }
1356
1357 $formatted_attributes[] = [
1358 'id' => (int) $attr->attribute_id,
1359 'name' => (string) $attr->name,
1360 'field_type' => $field_type,
1361 'field_options' => $field_options,
1362 'value' => $value,
1363 'icon' => $icon_data,
1364 'description' => (string) ($attr->description ?? ''),
1365 ];
1366 }
1367
1368 return $formatted_attributes;
1369 }
1370
1371 /**
1372 * True when admin "Show on Frontend" (or similar) is enabled for JSON_EXTRACT / API values.
1373 */
1374 private function isAttributeMetaFlagEnabled($raw): bool
1375 {
1376 if ($raw === null) {
1377 return false;
1378 }
1379 if (is_bool($raw)) {
1380 return $raw;
1381 }
1382 if (is_numeric($raw)) {
1383 return (int) $raw === 1;
1384 }
1385 $s = strtolower(trim((string) $raw, " \t\n\r\0\x0B\""));
1386
1387 return in_array($s, ['1', 'true', 'yes', 'on'], true);
1388 }
1389
1390 /**
1391 * Average rating from loaded review rows (fallback when SQL AVG returns 0).
1392 *
1393 * @param array<int, object> $reviews
1394 */
1395 private function averageRatingFromReviewRows(array $reviews): float
1396 {
1397 if ($reviews === []) {
1398 return 0.0;
1399 }
1400
1401 $total = 0.0;
1402 foreach ($reviews as $review) {
1403 $total += (float) ($review->rating ?? 0);
1404 }
1405
1406 return round($total / count($reviews), 1);
1407 }
1408
1409 /**
1410 * Get featured image URL
1411 *
1412 * @param string|int $image Image ID or URL
1413 * @return string Image URL
1414 */
1415 private function getFeaturedImageUrl($image): string
1416 {
1417 if (empty($image)) {
1418 return '';
1419 }
1420
1421 // If it's a numeric ID, get the attachment URL
1422 if (is_numeric($image)) {
1423 $url = wp_get_attachment_url((int) $image);
1424 return $url ?: '';
1425 }
1426
1427 // If it's already a URL, return it
1428 if (filter_var($image, FILTER_VALIDATE_URL)) {
1429 return $image;
1430 }
1431
1432 return '';
1433 }
1434
1435 /**
1436 * Get itinerary days from database tables
1437 *
1438 * @param int $trip_id Trip ID
1439 * @return array Itinerary days with entries
1440 */
1441 private function getItineraryDays(int $trip_id): array
1442 {
1443 // Using proper table classes for itinerary system with classification integration
1444 // Note: Items and Item Types now use ClassificationsTable with unified approach
1445 $table_days = \Yatra\Database\Tables\TripItineraryDaysTable::getTableName();
1446 $table_entries = \Yatra\Database\Tables\TripItineraryDayEntryTable::getTableName();
1447 $table_classifications = ClassificationsTable::getTableName();
1448
1449 // Get all days for this trip
1450 $days = $this->wpdb->get_results(
1451 $this->wpdb->prepare(
1452 "SELECT * FROM {$table_days}
1453 WHERE trip_id = %d
1454 ORDER BY day_number ASC",
1455 $trip_id
1456 )
1457 );
1458
1459 if (empty($days)) {
1460 return [];
1461 }
1462
1463 $itinerary = [];
1464 foreach ($days as $day) {
1465 // Get entries for this day
1466 $entries = $this->wpdb->get_results(
1467 $this->wpdb->prepare(
1468 "SELECT e.*,
1469 i.name as item_name,
1470 it.name as item_type_name,
1471 it.icon as item_type_icon,
1472 it.color as item_type_color
1473 FROM {$table_entries} e
1474 LEFT JOIN {$table_classifications} i ON e.item_id = i.id AND i.type = 'item'
1475 LEFT JOIN {$table_classifications} it ON e.item_type_id = it.id AND it.type = 'item_type'
1476 WHERE e.day_id = %d
1477 ORDER BY e.order ASC, e.id ASC",
1478 $day->id
1479 )
1480 );
1481
1482 $formatted_entries = [];
1483 foreach ($entries as $entry) {
1484 $iconPicker = null;
1485 if (!empty($entry->item_type_icon)) {
1486 $rawIcon = $entry->item_type_icon;
1487 // Classification `icon` column may store a serialized array from the icon picker.
1488 // Decode it into the array shape expected by yatra_stored_picker_icon_markup().
1489 $maybe = is_string($rawIcon) ? maybe_unserialize($rawIcon) : $rawIcon;
1490 if (is_array($maybe) && isset($maybe['type'])) {
1491 $iconPicker = $maybe;
1492 } elseif (is_string($rawIcon) && $rawIcon !== '') {
1493 // Backward compatibility: treat as yatra svg slug.
1494 $iconPicker = [
1495 'type' => 'icon',
1496 'value' => (string) $rawIcon,
1497 'provider' => 'yatra',
1498 ];
1499 }
1500 }
1501
1502 $formatted_entries[] = [
1503 'title' => $entry->title ?: $entry->item_name,
1504 'description' => $entry->description ?: '',
1505 'item_type' => $entry->item_type_name ?: 'Activity',
1506 'icon_picker' => $iconPicker,
1507 'item_type_color' => !empty($entry->item_type_color) ? (string) $entry->item_type_color : '',
1508 'start_time' => $entry->start_time ?: '',
1509 'end_time' => $entry->end_time ?: '',
1510 // The public template needs time_type to know whether to render
1511 // exact times, the duration-only label, or "Flexible". Without
1512 // this, all rows fell through to the start_time branch and an
1513 // entry intended as "duration / flexible" still showed clock
1514 // values pulled from stale defaults.
1515 'time_type' => $entry->time_type ?: 'exact',
1516 'location' => $entry->location ?: '',
1517 'duration' => $entry->duration ?: '',
1518 'cost' => !empty($entry->cost) ? (float) $entry->cost : null,
1519 'cost_per_person' => !empty($entry->cost_per_person) ? true : false,
1520 'included' => !empty($entry->included_items) ? json_decode($entry->included_items, true) : [],
1521 'excluded' => !empty($entry->excluded_items) ? json_decode($entry->excluded_items, true) : [],
1522 'gallery' => !empty($entry->gallery) ? $this->decodeGallery($entry->gallery) : [],
1523 'video_url' => $entry->video_url ?: '',
1524 // The admin "Notes / Instructions" textarea ("Additional notes
1525 // or special instructions for this activity") was stored but
1526 // never reached the public template — the array key was
1527 // simply absent. Without this, operators saw their notes
1528 // discarded silently on the live trip page.
1529 'notes' => (string) ($entry->notes ?? ''),
1530 ];
1531 }
1532
1533 $itinerary[] = [
1534 'day' => (int) $day->day_number,
1535 /* translators: %d: itinerary day number. */
1536 'day_title' => $day->title ?: sprintf(__('Day %d', 'yatra'), $day->day_number),
1537 'day_description' => $day->description ?: '',
1538 'entries' => $formatted_entries,
1539 ];
1540 }
1541
1542 return $itinerary;
1543 }
1544
1545 /**
1546 * Format time from 24h to 12h format
1547 *
1548 * @param string $time Time in 24h format (e.g., "14:00")
1549 * @return string Time in 12h format (e.g., "2:00 PM")
1550 */
1551 public static function formatTime(string $time): string
1552 {
1553 if (empty($time) || $time === 'Flexible') {
1554 return $time;
1555 }
1556
1557 // Try to parse and format the time
1558 $timestamp = strtotime($time);
1559 if ($timestamp !== false) {
1560 return date('g:i A', $timestamp);
1561 }
1562
1563 return $time;
1564 }
1565
1566 /**
1567 * Decode gallery JSON data and convert attachment IDs to URLs
1568 *
1569 * @param string|null $galleryJson JSON string from database
1570 * @return array Gallery items with URLs
1571 */
1572 private function decodeGallery(?string $galleryJson): array
1573 {
1574 if (empty($galleryJson)) {
1575 return [];
1576 }
1577
1578 $gallery = json_decode($galleryJson, true);
1579 if (!is_array($gallery)) {
1580 return [];
1581 }
1582
1583 // Convert attachment IDs to URLs for frontend compatibility
1584 foreach ($gallery as &$item) {
1585 if (isset($item['attachment_id']) && $item['attachment_id'] > 0) {
1586 // Get attachment URL from WordPress
1587 $attachment_url = wp_get_attachment_url($item['attachment_id']);
1588 if ($attachment_url) {
1589 $item['url'] = $attachment_url;
1590 }
1591
1592 // Get thumbnail URL for images
1593 if (isset($item['type']) && $item['type'] === 'image') {
1594 $thumbnail_url = wp_get_attachment_image_src($item['attachment_id'], 'medium');
1595 if ($thumbnail_url) {
1596 $item['thumbnail_url'] = $thumbnail_url[0];
1597 }
1598 }
1599 }
1600 }
1601
1602 return $gallery;
1603 }
1604
1605 /**
1606 * Get booking URL for a trip
1607 *
1608 * @param string $slug Trip slug
1609 * @return string Booking URL
1610 */
1611 public static function getBookingUrl(string $slug): string
1612 {
1613 if (function_exists('yatra_get_booking_url')) {
1614 return yatra_get_booking_url($slug);
1615 }
1616
1617 $booking_base = get_option('yatra_booking_base', 'book');
1618 return home_url("/{$booking_base}/{$slug}/");
1619 }
1620
1621 /**
1622 * Render tabs based on frontend_tabs configuration
1623 *
1624 * @param object $trip Trip object with frontend_tabs data
1625 * @return void
1626 */
1627 public static function renderFrontendTabs($trip)
1628 {
1629 $frontend_tabs = isset($trip->frontend_tabs) ? $trip->frontend_tabs : [];
1630
1631 // Use the same merge logic as getStickyNavigationItems to ensure consistency
1632 if (!empty($frontend_tabs)) {
1633 $frontend_tabs = self::mergeFrontendTabsWithDefaults($frontend_tabs);
1634 } else {
1635 // Use default tabs if no database data exists
1636 $frontend_tabs = [
1637 // Core sections (always present)
1638 (object) ['id' => 'overview', 'label' => 'Overview', 'enabled' => true, 'order' => 1, 'content_type' => 'overview', 'icon' => 'book'],
1639 (object) ['id' => 'itinerary', 'label' => 'Itinerary', 'enabled' => true, 'order' => 2, 'content_type' => 'itinerary', 'icon' => 'calendar'],
1640 (object) ['id' => 'included', 'label' => 'Included', 'enabled' => true, 'order' => 3, 'content_type' => 'included_excluded', 'icon' => 'check'],
1641 (object) ['id' => 'location', 'label' => 'Location', 'enabled' => true, 'order' => 4, 'content_type' => 'location', 'icon' => 'map-pin'],
1642 (object) ['id' => 'important_info', 'label' => 'Important Info', 'enabled' => true, 'order' => 5, 'content_type' => 'important_info', 'icon' => 'info'],
1643
1644 // Conditional sections (enabled by default, shown conditionally on frontend)
1645 (object) ['id' => 'downloads', 'label' => 'Downloads', 'enabled' => true, 'order' => 6, 'content_type' => 'downloads', 'icon' => 'download'],
1646 (object) ['id' => 'faq', 'label' => 'FAQ', 'enabled' => true, 'order' => 7, 'content_type' => 'faq', 'icon' => 'help-circle'],
1647 (object) ['id' => 'trip_story', 'label' => 'Story', 'enabled' => true, 'order' => 8, 'content_type' => 'trip_story', 'custom_content' => '', 'icon' => 'book'],
1648 (object) ['id' => 'what_makes_special', 'label' => 'Special', 'enabled' => true, 'order' => 9, 'content_type' => 'what_makes_special', 'custom_content' => '', 'icon' => 'star'],
1649 (object) ['id' => 'testimonials', 'label' => 'Testimonials', 'enabled' => true, 'order' => 10, 'content_type' => 'testimonials', 'icon' => 'message-circle'],
1650 (object) ['id' => 'gallery', 'label' => 'Gallery', 'enabled' => false, 'order' => 11, 'content_type' => 'gallery', 'icon' => 'camera'],
1651 ];
1652 }
1653
1654 // Sort tabs by order and filter enabled tabs
1655 $enabled_tabs = array_filter($frontend_tabs, function($tab) {
1656 return isset($tab->enabled) && filter_var($tab->enabled, FILTER_VALIDATE_BOOLEAN);
1657 });
1658
1659 usort($enabled_tabs, function($a, $b) {
1660 return ($a->order ?? 999) - ($b->order ?? 999);
1661 });
1662
1663 // Render each enabled tab
1664 foreach ($enabled_tabs as $tab) {
1665 self::renderTabContent($tab, $trip);
1666 }
1667 }
1668
1669 /**
1670 * Render individual tab content based on content type
1671 *
1672 * @param object $tab Tab configuration
1673 * @param object $trip Trip object
1674 * @return void
1675 */
1676 private static function renderTabContent($tab, $trip)
1677 {
1678 switch ($tab->content_type) {
1679 case 'overview':
1680 yatra_get_template('partials/single-trip/content-overview', ['trip' => $trip, 'tab' => $tab, 'has_traveler_pricing' => true, 'has_availability' => true, 'base_price' => $trip->original_price]);
1681 break;
1682
1683 case 'itinerary':
1684 yatra_get_template('partials/single-trip/content-itinerary', ['trip' => $trip, 'tab' => $tab]);
1685 break;
1686
1687 case 'included_excluded':
1688 yatra_get_template('partials/single-trip/content-included-excluded', ['trip' => $trip, 'tab' => $tab]);
1689 break;
1690
1691 case 'location':
1692 // Get itinerary entries with coordinates for map display
1693 $itinerary_repository = new \Yatra\Repositories\ItineraryRepository();
1694 $itinerary_entries = $itinerary_repository->getEntriesWithCoordinatesForMap((int) $trip->id);
1695
1696 yatra_get_template('partials/single-trip/content-location', [
1697 'trip' => $trip,
1698 'tab' => $tab,
1699 'itinerary_entries' => $itinerary_entries
1700 ]);
1701 break;
1702
1703 case 'gallery':
1704 yatra_get_template('partials/single-trip/content-gallery', ['trip' => $trip, 'tab' => $tab]);
1705 break;
1706
1707 case 'important_info':
1708 yatra_get_template('partials/single-trip/content-important-info', ['trip' => $trip, 'tab' => $tab]);
1709 break;
1710
1711 case 'downloads':
1712 yatra_get_template('partials/single-trip/content-downloads', ['trip' => $trip, 'tab' => $tab]);
1713 break;
1714
1715 case 'faq':
1716 yatra_get_template('partials/single-trip/content-faq', ['trip' => $trip, 'tab' => $tab]);
1717 break;
1718
1719 case 'trip_story':
1720 yatra_get_template('partials/single-trip/content-trip-story', ['trip' => $trip, 'tab' => $tab]);
1721 break;
1722
1723 case 'what_makes_special':
1724 yatra_get_template('partials/single-trip/content-whats-make-special', ['trip' => $trip, 'tab' => $tab]);
1725 break;
1726
1727 case 'testimonials':
1728 yatra_get_template('partials/single-trip/content-testimonials', ['trip' => $trip, 'tab' => $tab]);
1729 break;
1730
1731 case 'custom':
1732 // Delegated to a partial so the admin-chosen icon (and label, content)
1733 // flow through the same yatra_render_tab_icon() pipeline as every other
1734 // tab type. Previously this branch hardcoded yatra_svg_icon('book')
1735 // which silently dropped the icon admins selected in Trip Builder.
1736 yatra_get_template('partials/single-trip/content-custom', [
1737 'trip' => $trip,
1738 'tab' => $tab,
1739 ]);
1740 break;
1741 }
1742 }
1743
1744 /**
1745 * Get sticky navigation items based on frontend_tabs configuration
1746 *
1747 * @param object $trip Trip object with frontend_tabs data
1748 * @return array Navigation items
1749 */
1750 public static function getStickyNavigationItems($trip)
1751 {
1752 $frontend_tabs = isset($trip->frontend_tabs) ? $trip->frontend_tabs : [];
1753
1754 // Use the same merge logic as renderFrontendTabs to ensure consistency
1755 if (!empty($frontend_tabs)) {
1756 $frontend_tabs = self::mergeFrontendTabsWithDefaults($frontend_tabs);
1757 } else {
1758 // Use default tabs if no database data exists
1759 $frontend_tabs = [
1760 // Core sections (always present)
1761 (object) ['id' => 'overview', 'label' => 'Overview', 'enabled' => true, 'order' => 1, 'content_type' => 'overview', 'icon' => 'book'],
1762 (object) ['id' => 'itinerary', 'label' => 'Itinerary', 'enabled' => true, 'order' => 2, 'content_type' => 'itinerary', 'icon' => 'calendar'],
1763 (object) ['id' => 'included', 'label' => 'Included', 'enabled' => true, 'order' => 3, 'content_type' => 'included_excluded', 'icon' => 'check'],
1764 (object) ['id' => 'location', 'label' => 'Location', 'enabled' => true, 'order' => 4, 'content_type' => 'location', 'icon' => 'map-pin'],
1765 (object) ['id' => 'important_info', 'label' => 'Important Info', 'enabled' => true, 'order' => 5, 'content_type' => 'important_info', 'icon' => 'info'],
1766
1767 // Conditional sections (enabled by default, shown conditionally on frontend)
1768 (object) ['id' => 'downloads', 'label' => 'Downloads', 'enabled' => true, 'order' => 6, 'content_type' => 'downloads', 'icon' => 'download'],
1769 (object) ['id' => 'faq', 'label' => 'FAQ', 'enabled' => true, 'order' => 7, 'content_type' => 'faq', 'icon' => 'help-circle'],
1770 (object) ['id' => 'trip_story', 'label' => 'Story', 'enabled' => true, 'order' => 8, 'content_type' => 'trip_story', 'custom_content' => '', 'icon' => 'book'],
1771 (object) ['id' => 'what_makes_special', 'label' => 'Special', 'enabled' => true, 'order' => 9, 'content_type' => 'what_makes_special', 'custom_content' => '', 'icon' => 'star'],
1772 (object) ['id' => 'testimonials', 'label' => 'Testimonials', 'enabled' => true, 'order' => 10, 'content_type' => 'testimonials', 'icon' => 'message-circle'],
1773 (object) ['id' => 'gallery', 'label' => 'Gallery', 'enabled' => false, 'order' => 11, 'content_type' => 'gallery', 'icon' => 'camera'],
1774 ];
1775 }
1776
1777 // Sort tabs by order and filter enabled tabs
1778 $enabled_tabs = array_filter($frontend_tabs, function($tab) {
1779 return isset($tab->enabled) && $tab->enabled;
1780 });
1781
1782 usort($enabled_tabs, function($a, $b) {
1783 return ($a->order ?? 999) - ($b->order ?? 999);
1784 });
1785
1786 $navigation_items = [];
1787
1788 foreach ($enabled_tabs as $tab) {
1789 $nav_item = self::getNavigationItemForTab($tab, $trip);
1790 if ($nav_item) {
1791 $navigation_items[] = $nav_item;
1792 }
1793 }
1794
1795 return $navigation_items;
1796 }
1797
1798 /**
1799 * Prepare traveler selector data for common component
1800 *
1801 * @param object $trip Trip object
1802 * @param string $context Context for IDs (sidebar, availability, enquiry)
1803 * @return array Traveler selector data
1804 */
1805 public static function prepareTravelerSelectorData($trip, string $context = 'sidebar'): array
1806 {
1807 $trip_pricing_type = $trip->pricing_type ?? '';
1808 $has_traveler_pricing = ($trip_pricing_type === 'traveler_based' && !empty($trip->price_types));
1809 $traveler_rows = [];
1810
1811 if ($has_traveler_pricing) {
1812 // Determine which price type should be selected by default (admin-selected default; else first)
1813 $default_index = 0;
1814 foreach ((array) $trip->price_types as $i => $pt_candidate) {
1815 $pt_candidate = is_array($pt_candidate) ? (object) $pt_candidate : $pt_candidate;
1816 if (!empty($pt_candidate->is_default)) {
1817 $default_index = (int) $i;
1818 break;
1819 }
1820 }
1821
1822 // Traveler-Based Pricing: Show dynamic categories
1823 foreach ($trip->price_types as $index => $price_type) {
1824 // Normalize to object if array
1825 $price_type = is_array($price_type) ? (object) $price_type : $price_type;
1826
1827 $pricing_mode = $price_type->pricing_mode ?? 'per_person';
1828 $is_per_group = ($pricing_mode === 'per_group');
1829 $pricing_label = '';
1830 if ($is_per_group) {
1831 if (!empty($price_type->min_pax) && !empty($price_type->max_pax)) {
1832 /* translators: 1: minimum pax for the group price, 2: maximum pax. */
1833 $pricing_label = sprintf(__('per group (%1$d-%2$d pax)', 'yatra'), $price_type->min_pax, $price_type->max_pax);
1834 } elseif (!empty($price_type->max_pax)) {
1835 /* translators: %d: maximum pax for the group price. */
1836 $pricing_label = sprintf(__('per group (up to %d pax)', 'yatra'), $price_type->max_pax);
1837 } elseif (!empty($price_type->min_pax)) {
1838 /* translators: %d: minimum pax for the group price. */
1839 $pricing_label = sprintf(__('per group (%d+ pax)', 'yatra'), $price_type->min_pax);
1840 } else {
1841 $pricing_label = __('per group', 'yatra');
1842 }
1843 }
1844
1845 $pt_arr = (array) $price_type;
1846 $eff_before_dp = (float) ($price_type->effective_price ?? \Yatra\Services\TripPricingService::resolveCategoryEffectivePrice($pt_arr));
1847 $display_price_type = $eff_before_dp;
1848 if (apply_filters('yatra_dynamic_pricing_enabled', false)) {
1849 $trip_id = is_object($trip) && method_exists($trip, 'getId') ? $trip->getId() : ($trip->id ?? 0);
1850 $pt_orig_dp = (float) ($price_type->original_price ?? 0);
1851 $pt_disc_dp = (float) ($price_type->discounted_price ?? $price_type->sale_price ?? 0);
1852 if ($pt_disc_dp <= 0) {
1853 $pt_disc_dp = $eff_before_dp;
1854 }
1855 $display_price_type = apply_filters('yatra_trip_display_price', $eff_before_dp, $trip_id, [
1856 'departure_date' => null,
1857 'spots_remaining' => null,
1858 'price_type_id' => $price_type->id ?? null,
1859 'original_price' => $pt_orig_dp > 0 ? $pt_orig_dp : $eff_before_dp,
1860 'discounted_price' => $pt_disc_dp > 0 ? $pt_disc_dp : $eff_before_dp,
1861 ]);
1862 }
1863
1864 $age_info = '';
1865 $age_min = $price_type->age_min ?? null;
1866 $age_max = $price_type->age_max ?? null;
1867 if ($age_min !== null || $age_max !== null) {
1868 if ($age_min !== null && $age_max !== null) {
1869 /* translators: 1: minimum age, 2: maximum age. */
1870 $age_info = sprintf(__('(Age %1$d-%2$d)', 'yatra'), $age_min, $age_max);
1871 } elseif ($age_min !== null) {
1872 /* translators: %d: minimum age. */
1873 $age_info = sprintf(__('(Age %d+)', 'yatra'), $age_min);
1874 } else {
1875 /* translators: %d: maximum age. */
1876 $age_info = sprintf(__('(Up to age %d)', 'yatra'), $age_max);
1877 }
1878 }
1879
1880 $price_html = '<div class="yatra-quantity-price-wrapper">';
1881 $price_html .= '<span class="yatra-quantity-price">' . yatra_format_price((float) $display_price_type) . '</span>';
1882 if ($is_per_group) {
1883 $price_html .= '<span class="yatra-pricing-mode-label yatra-pricing-mode-group">' . esc_html($pricing_label) . '</span>';
1884 }
1885 $price_html .= '</div>';
1886
1887 $input_id = 'traveler_' . $price_type->category_id;
1888 $max_travelers = is_object($trip) && method_exists($trip, 'getMaxTravelers') ? $trip->getMaxTravelers() : ($trip->max_travelers ?? 20);
1889 $pt_max_qty = (int) ($price_type->max_quantity ?: $max_travelers);
1890 // A per-group category in "block" overflow mode caps the party at
1891 // the max group size. In "per_block" mode the party may exceed it
1892 // (it just buys additional group blocks), so we keep the trip's
1893 // normal cap there.
1894 if ($is_per_group && !empty($price_type->max_pax)
1895 && (($price_type->group_overflow ?? 'block') !== 'per_block')) {
1896 $pt_max_qty = (int) $price_type->max_pax;
1897 }
1898 $pt_value = ($index === $default_index) ? 1 : 0;
1899
1900 $traveler_rows[] = [
1901 'label' => $price_type->category_label ?: __('Traveler', 'yatra'),
1902 'subtitle' => $age_info,
1903 'price_html' => $price_html,
1904 'row_attrs' => [
1905 'data-category-id' => $price_type->category_id,
1906 'data-price' => $price_type->effective_price,
1907 'data-pricing-mode' => $pricing_mode,
1908 'data-group-overflow' => $price_type->group_overflow ?? 'block',
1909 'data-max-pax' => $price_type->max_pax ?? '',
1910 ],
1911 'minus_disabled' => ($index !== $default_index),
1912 'plus_disabled' => false,
1913 'minus_attrs' => [
1914 'data-target' => $input_id,
1915 /* translators: %s: traveler category label (e.g. "Adult", "Child"). */
1916 'aria-label' => sprintf(__('Decrease %s', 'yatra'), $price_type->category_label),
1917 ],
1918 'plus_attrs' => [
1919 'data-target' => $input_id,
1920 /* translators: %s: traveler category label (e.g. "Adult", "Child"). */
1921 'aria-label' => sprintf(__('Increase %s', 'yatra'), $price_type->category_label),
1922 ],
1923 'input_attrs' => [
1924 'id' => $input_id,
1925 'name' => 'travelers[' . $price_type->category_id . ']',
1926 'value' => $pt_value,
1927 'min' => 0,
1928 'max' => $pt_max_qty,
1929 'data-category' => $price_type->category_id,
1930 'data-category-label' => $price_type->category_label,
1931 'data-price' => $price_type->effective_price,
1932 'data-pricing-mode' => $pricing_mode,
1933 'data-group-overflow' => $price_type->group_overflow ?? 'block',
1934 'data-max-pax' => $price_type->max_pax ?? '',
1935 ],
1936 ];
1937 }
1938
1939 // Generate display text with all categories and their default values
1940 $display_parts = [];
1941 foreach ($trip->price_types as $index => $price_type) {
1942 $category_label = $price_type->category_label ?? __('Traveler', 'yatra');
1943 $default_value = ($index === $default_index) ? 1 : 0;
1944
1945 if ($default_value > 0) {
1946 $display_parts[] = $category_label . ' x ' . $default_value;
1947 }
1948 }
1949
1950 $traveler_display_text = !empty($display_parts) ? implode(', ', $display_parts) : __('Select travelers', 'yatra');
1951 } else {
1952 // Regular Pricing: Simple adult/children setup
1953 $traveler_rows = [
1954 [
1955 'label' => __('Adult', 'yatra'),
1956 'subtitle' => __('(Age 13-99)', 'yatra'),
1957 'price_html' => '',
1958 'row_attrs' => [],
1959 'minus_disabled' => false,
1960 'plus_disabled' => false,
1961 'minus_attrs' => [
1962 'data-target' => $context . '_adults',
1963 'aria-label' => __('Decrease adults', 'yatra'),
1964 ],
1965 'plus_attrs' => [
1966 'data-target' => $context . '_adults',
1967 'aria-label' => __('Increase adults', 'yatra'),
1968 ],
1969 'input_attrs' => [
1970 'id' => $context . '_adults',
1971 'name' => $context . '_adults',
1972 'value' => 1,
1973 'min' => 1,
1974 'max' => 20,
1975 ],
1976 ],
1977 [
1978 'label' => __('Child', 'yatra'),
1979 'subtitle' => __('(Age 4-12)', 'yatra'),
1980 'price_html' => '',
1981 'row_attrs' => [],
1982 'minus_disabled' => true,
1983 'plus_disabled' => false,
1984 'minus_attrs' => [
1985 'data-target' => $context . '_children',
1986 'aria-label' => __('Decrease children', 'yatra'),
1987 ],
1988 'plus_attrs' => [
1989 'data-target' => $context . '_children',
1990 'aria-label' => __('Increase children', 'yatra'),
1991 ],
1992 'input_attrs' => [
1993 'id' => $context . '_children',
1994 'name' => $context . '_children',
1995 'value' => 0,
1996 'min' => 0,
1997 'max' => 10,
1998 ],
1999 ],
2000 ];
2001
2002 $traveler_display_text = __('Adult x 1', 'yatra');
2003 }
2004
2005 return [
2006 'has_traveler_pricing' => $has_traveler_pricing,
2007 'traveler_rows' => $traveler_rows,
2008 'traveler_display_text' => $traveler_display_text,
2009 'context' => $context,
2010 ];
2011 }
2012
2013 /**
2014 * Prepare traveler selector data for availability section
2015 *
2016 * @param object $trip Trip data
2017 * @param array $card Availability card data
2018 * @param string $item_id Item ID
2019 * @param int $trip_id Trip ID
2020 * @param int $seats_available Seats available
2021 * @param int $max_travelers Max travelers
2022 * @param bool $dp_enabled Dynamic pricing enabled
2023 * @param array $initial_travelers Initial traveler counts from request (category_id => count)
2024 * @return array Traveler selector data
2025 */
2026 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
2027 {
2028 $card_pricing_type = $card['pricing_type'] ?? 'regular';
2029 $card_price_types = [];
2030
2031 // Always use enriched trip-level price_types for traveler-based pricing
2032 if ($card_pricing_type === 'traveler_based' && !empty($trip->price_types)) {
2033 $card_price_types = $trip->price_types;
2034 }
2035
2036 $has_traveler_pricing = ($card_pricing_type === 'traveler_based' && !empty($card_price_types));
2037 $traveler_rows = [];
2038
2039 if ($has_traveler_pricing) {
2040 // Normalize price_types
2041 $normalized_price_types = [];
2042 foreach ($card_price_types as $pt) {
2043 if (is_array($pt)) {
2044 $normalized_price_types[] = (object) $pt;
2045 } else {
2046 $normalized_price_types[] = $pt;
2047 }
2048 }
2049
2050 // Build display text from initial travelers if provided
2051 $display_parts = [];
2052
2053 // Default selection: if no initial travelers, pick admin-default category (else first)
2054 $default_category_id = null;
2055 foreach ($normalized_price_types as $pt_candidate) {
2056 if (!is_object($pt_candidate)) {
2057 continue;
2058 }
2059 if (!empty($pt_candidate->is_default) && !empty($pt_candidate->category_id)) {
2060 $default_category_id = (int) $pt_candidate->category_id;
2061 break;
2062 }
2063 }
2064
2065 foreach ($normalized_price_types as $pt_index => $pt) {
2066 $pt_min = isset($pt->age_min) ? (int) $pt->age_min : 0;
2067 $pt_max = isset($pt->age_max) ? (int) $pt->age_max : 99;
2068 $pt_label = $pt->category_label ?? $pt->label ?? __('Traveler', 'yatra');
2069 /* translators: 1: minimum age, 2: maximum age. */
2070 $pt_age_text = ($pt_min > 0 || $pt_max < 99) ? sprintf(__('(Age %1$d-%2$d)', 'yatra'), $pt_min, $pt_max) : '';
2071
2072 // Use initial traveler count if provided, otherwise use default
2073 $pt_category_id = $pt->category_id ?? $pt_index;
2074 $pt_default = isset($initial_travelers[$pt_category_id])
2075 ? (int) $initial_travelers[$pt_category_id]
2076 : (($default_category_id !== null && (int) $pt_category_id === (int) $default_category_id) ? 1 : (($default_category_id === null && $pt_index === 0) ? 1 : 0));
2077
2078 $pt_price = 0;
2079 if (isset($pt->effective_price) && $pt->effective_price > 0) {
2080 $pt_price = (float) $pt->effective_price;
2081 } elseif (isset($pt->sale_price) && $pt->sale_price > 0) {
2082 $pt_price = (float) $pt->sale_price;
2083 } elseif (isset($pt->discounted_price) && $pt->discounted_price > 0) {
2084 $pt_price = (float) $pt->discounted_price;
2085 } elseif (isset($pt->original_price) && $pt->original_price > 0) {
2086 $pt_price = (float) $pt->original_price;
2087 }
2088
2089 // Apply dynamic pricing to traveler category prices
2090 if ($dp_enabled && $pt_price > 0) {
2091 $pt_orig_dp = (float) ($pt->original_price ?? 0);
2092 $pt_disc_dp = (float) ($pt->discounted_price ?? $pt->sale_price ?? $pt->effective_price ?? 0);
2093 if ($pt_disc_dp <= 0) {
2094 $pt_disc_dp = $pt_price;
2095 }
2096 $pt_price = apply_filters('yatra_availability_price', $pt_price, $trip_id, [
2097 'departure_date' => $card['date'] ?? null,
2098 'spots_remaining' => $card['spots_remaining'] ?? null,
2099 'availability_id' => $item_id,
2100 'price_type_id' => $pt->id ?? ($pt->price_type_id ?? null),
2101 'original_price' => $pt_orig_dp > 0 ? $pt_orig_dp : $pt_price,
2102 'discounted_price' => $pt_disc_dp > 0 ? $pt_disc_dp : $pt_price,
2103 ]);
2104 }
2105
2106 $pt_category_id = $pt->category_id ?? $pt_index;
2107 $pt_min_qty = 0;
2108 $pt_max_qty = (int) min($seats_available, $max_travelers);
2109 $pt_pricing_mode = $pt->pricing_mode ?? 'per_person';
2110 $pt_is_per_group = ($pt_pricing_mode === 'per_group');
2111 // Cap a per-group "block" category at its max group size (still
2112 // bounded by seats). "per_block" mode may exceed it, so skip.
2113 if ($pt_is_per_group && !empty($pt->max_pax)
2114 && (($pt->group_overflow ?? 'block') !== 'per_block')) {
2115 $pt_max_qty = (int) min($pt_max_qty, (int) $pt->max_pax);
2116 }
2117
2118 // Build pricing label
2119 $pricing_label = '';
2120 if ($pt_is_per_group) {
2121 if (!empty($pt->min_pax) && !empty($pt->max_pax)) {
2122 /* translators: 1: minimum pax for the group price, 2: maximum pax. */
2123 $pricing_label = sprintf(__('per group (%1$d-%2$d pax)', 'yatra'), $pt->min_pax, $pt->max_pax);
2124 } elseif (!empty($pt->max_pax)) {
2125 /* translators: %d: maximum pax for the group price. */
2126 $pricing_label = sprintf(__('per group (up to %d pax)', 'yatra'), $pt->max_pax);
2127 } elseif (!empty($pt->min_pax)) {
2128 /* translators: %d: minimum pax for the group price. */
2129 $pricing_label = sprintf(__('per group (%d+ pax)', 'yatra'), $pt->min_pax);
2130 } else {
2131 $pricing_label = __('per group', 'yatra');
2132 }
2133 }
2134
2135 $price_html = '<div class="yatra-quantity-price-wrapper">';
2136 $price_html .= '<span class="yatra-quantity-price">' . yatra_format_price($pt_price) . '</span>';
2137 if ($pt_is_per_group) {
2138 $price_html .= '<span class="yatra-pricing-mode-label yatra-pricing-mode-group">' . esc_html($pricing_label) . '</span>';
2139 }
2140 $price_html .= '</div>';
2141
2142 $traveler_rows[] = [
2143 'label' => $pt_label,
2144 'subtitle' => $pt_age_text,
2145 'price_html' => $price_html,
2146 'row_attrs' => [
2147 'data-category-id' => $pt_category_id,
2148 'data-price' => $pt_price,
2149 'data-pricing-mode' => $pt_pricing_mode,
2150 'data-group-overflow' => $pt->group_overflow ?? 'block',
2151 'data-max-pax' => $pt->max_pax ?? '',
2152 ],
2153 'minus_disabled' => ($pt_default <= 0),
2154 'plus_disabled' => false,
2155 'minus_attrs' => [
2156 'data-target' => 'traveler_' . $pt_category_id . '_' . $item_id,
2157 /* translators: %s: traveler category label (e.g. "Adult", "Child"). */
2158 'aria-label' => sprintf(__('Decrease %s', 'yatra'), $pt_label),
2159 ],
2160 'plus_attrs' => [
2161 'data-target' => 'traveler_' . $pt_category_id . '_' . $item_id,
2162 /* translators: %s: traveler category label (e.g. "Adult", "Child"). */
2163 'aria-label' => sprintf(__('Increase %s', 'yatra'), $pt_label),
2164 ],
2165 'input_attrs' => [
2166 'data-item' => $item_id,
2167 'data-category' => $pt_category_id,
2168 'data-price' => $pt_price,
2169 'data-pricing-mode' => $pt_pricing_mode,
2170 'data-group-overflow' => $pt->group_overflow ?? 'block',
2171 'data-max-pax' => $pt->max_pax ?? '',
2172 'value' => $pt_default,
2173 'min' => $pt_min_qty,
2174 'max' => $pt_max_qty,
2175 ],
2176 ];
2177
2178 // Build display text parts
2179 if ($pt_default > 0) {
2180 $display_parts[] = $pt_label . ' x ' . $pt_default;
2181 }
2182 }
2183
2184 // Generate display text from parts
2185 $traveler_display_text = !empty($display_parts) ? implode(', ', $display_parts) : __('Select travelers', 'yatra');
2186 }
2187
2188 return [
2189 'has_traveler_pricing' => $has_traveler_pricing,
2190 'traveler_rows' => $traveler_rows,
2191 'traveler_display_text' => $traveler_display_text ?? __('Adult x 1', 'yatra'),
2192 'item_id' => $item_id,
2193 ];
2194 }
2195
2196 /**
2197 * Get navigation item for a specific tab
2198 *
2199 * @param object $tab Tab configuration
2200 * @param object $trip Trip object
2201 * @return array|null Navigation item data
2202 */
2203 private static function getNavigationItemForTab($tab, $trip)
2204 {
2205 // Only check if tab is enabled - content existence is handled by templates
2206 // All tabs should appear in navigation if enabled, regardless of content
2207
2208 // Map content types to icons and hrefs
2209 $icon_map = [
2210 'overview' => 'book',
2211 'itinerary' => 'calendar',
2212 'included_excluded' => 'check',
2213 'location' => 'map-pin',
2214 'gallery' => 'camera',
2215 'important_info' => 'info',
2216 'downloads' => 'download',
2217 'faq' => 'help-circle',
2218 'trip_story' => 'book',
2219 'what_makes_special' => 'star',
2220 'testimonials' => 'message-circle',
2221 'custom' => 'book'
2222 ];
2223
2224 $href_map = [
2225 'overview' => '#overview',
2226 'itinerary' => '#itinerary',
2227 'included' => '#included',
2228 'location' => '#location',
2229 'gallery' => '#gallery',
2230 'important_info' => '#important-info',
2231 'downloads' => '#downloads',
2232 'faq' => '#faq',
2233 'trip_story' => '#trip-story',
2234 'what_makes_special' => '#what-makes-special',
2235 'testimonials' => '#testimonials',
2236 'custom' => '#custom'
2237 ];
2238
2239 // For custom tabs, use the actual tab ID to ensure unique anchors
2240 $href = isset($href_map[$tab->id]) ? $href_map[$tab->id] : '#' . $tab->id;
2241
2242 // Use custom icon if available, otherwise fallback to default icon mapping
2243 $icon = 'book'; // default fallback
2244 $icon_data = null;
2245
2246 if (isset($tab->icon) && !empty($tab->icon)) {
2247 // Handle both array and object formats for icon
2248 if (is_array($tab->icon)) {
2249 $icon_data = $tab->icon;
2250 } elseif (is_object($tab->icon)) {
2251 $icon_data = (array) $tab->icon;
2252 } elseif (is_string($tab->icon)) {
2253 // Convert string to icon data structure
2254 $icon_data = ['type' => 'icon', 'value' => $tab->icon];
2255 }
2256 } else {
2257 // Fallback to default icon mapping
2258 $icon_data = ['type' => 'icon', 'value' => $icon_map[$tab->content_type] ?? 'book'];
2259 }
2260
2261 return [
2262 'id' => $tab->id,
2263 'label' => $tab->label,
2264 'href' => $href,
2265 'icon' => $icon_data
2266 ];
2267 }
2268
2269 /**
2270 * Merge database frontend_tabs with complete default array
2271 * Ensures all sections are always available in the backend
2272 *
2273 * @param array $db_tabs Database frontend_tabs data
2274 * @return array | object Merged frontend_tabs with all sections
2275 */
2276 private static function mergeFrontendTabsWithDefaults($db_tabs)
2277 {
2278
2279
2280 $default_tabs = [
2281 // Core sections (always present)
2282 (object) ['id' => 'overview', 'label' => 'Overview', 'enabled' => true, 'order' => 1, 'content_type' => 'overview', 'icon' => 'book'],
2283 (object) ['id' => 'itinerary', 'label' => 'Itinerary', 'enabled' => true, 'order' => 2, 'content_type' => 'itinerary', 'icon' => 'calendar'],
2284 (object) ['id' => 'included', 'label' => 'Included', 'enabled' => true, 'order' => 3, 'content_type' => 'included_excluded', 'icon' => 'check'],
2285 (object) ['id' => 'location', 'label' => 'Location', 'enabled' => true, 'order' => 4, 'content_type' => 'location', 'icon' => 'map-pin'],
2286 (object) ['id' => 'important_info', 'label' => 'Important Info', 'enabled' => true, 'order' => 5, 'content_type' => 'important_info', 'icon' => 'info'],
2287
2288 // Conditional sections (enabled by default, shown conditionally on frontend)
2289 (object) ['id' => 'downloads', 'label' => 'Downloads', 'enabled' => true, 'order' => 6, 'content_type' => 'downloads', 'icon' => 'download'],
2290 (object) ['id' => 'faq', 'label' => 'FAQ', 'enabled' => true, 'order' => 7, 'content_type' => 'faq', 'icon' => 'help-circle'],
2291 (object) ['id' => 'trip_story', 'label' => 'Story', 'enabled' => true, 'order' => 8, 'content_type' => 'trip_story', 'custom_content' => '', 'icon' => 'book'],
2292 (object) ['id' => 'what_makes_special', 'label' => 'Special', 'enabled' => true, 'order' => 9, 'content_type' => 'what_makes_special', 'custom_content' => '', 'icon' => 'star'],
2293 (object) ['id' => 'testimonials', 'label' => 'Testimonials', 'enabled' => true, 'order' => 10, 'content_type' => 'testimonials', 'icon' => 'message-circle'],
2294 (object) ['id' => 'gallery', 'label' => 'Gallery', 'enabled' => false, 'order' => 11, 'content_type' => 'gallery', 'icon' => 'camera'],
2295 ];
2296
2297 // If no database data, return defaults
2298 if (empty($db_tabs) || !is_array($db_tabs)) {
2299 return $default_tabs;
2300 }
2301
2302 // Create a map of database tabs by ID for easy lookup
2303 $db_tabs_map = [];
2304 foreach ($db_tabs as $db_tab) {
2305 // Handle both arrays and objects
2306 $tab_id = null;
2307 if (is_array($db_tab) && isset($db_tab['id'])) {
2308 $tab_id = $db_tab['id'];
2309 } elseif (is_object($db_tab) && isset($db_tab->id)) {
2310 $tab_id = $db_tab->id;
2311 }
2312
2313 if ($tab_id) {
2314 $db_tabs_map[$tab_id] = $db_tab;
2315 }
2316 }
2317
2318 // Merge defaults with database data
2319 $merged_tabs = [];
2320 foreach ($default_tabs as $default_tab) {
2321 $tab_id = $default_tab->id;
2322
2323 if (isset($db_tabs_map[$tab_id])) {
2324 // Use database data, but ensure all required fields exist
2325 $db_tab = $db_tabs_map[$tab_id];
2326
2327 // Handle both arrays and objects
2328 $db_label = is_array($db_tab) ? ($db_tab['label'] ?? null) : ($db_tab->label ?? null);
2329 $db_enabled = is_array($db_tab) ? ($db_tab['enabled'] ?? null) : ($db_tab->enabled ?? null);
2330 $db_order = is_array($db_tab) ? ($db_tab['order'] ?? null) : ($db_tab->order ?? null);
2331 $db_custom_content = is_array($db_tab) ? ($db_tab['custom_content'] ?? null) : ($db_tab->custom_content ?? null);
2332 $db_icon = is_array($db_tab) ? ($db_tab['icon'] ?? null) : ($db_tab->icon ?? null);
2333
2334 // Preserve full icon data structure
2335 $icon_data = null;
2336 if ($db_icon) {
2337 if (is_array($db_icon)) {
2338 $icon_data = $db_icon;
2339 } elseif (is_object($db_icon)) {
2340 $icon_data = (array) $db_icon;
2341 } elseif (is_string($db_icon)) {
2342 // Convert string to icon data structure
2343 $icon_data = ['type' => 'icon', 'value' => $db_icon];
2344 }
2345 } else {
2346 // Use default icon as data structure
2347 $icon_data = ['type' => 'icon', 'value' => $default_tab->icon];
2348 }
2349
2350 $merged_tab = [
2351 'id' => $tab_id,
2352 'label' => $db_label ?? $default_tab->label,
2353 'enabled' => $db_enabled !== null ? filter_var($db_enabled, FILTER_VALIDATE_BOOLEAN) : $default_tab->enabled,
2354 'order' => $db_order !== null ? (int) $db_order : $default_tab->order,
2355 // Always use the new content type from defaults to ensure consistency
2356 'content_type' => $default_tab->content_type,
2357 'icon' => $icon_data
2358 ];
2359 // Add custom_content for custom tabs if exists
2360 if ($default_tab->content_type === 'custom') {
2361 $merged_tab['custom_content'] = $db_custom_content ?? $default_tab->custom_content ?? '';
2362 }
2363
2364 $merged_tabs[] = (object) $merged_tab;
2365 } else {
2366 // Use default for missing tabs
2367 $merged_tabs[] = (object) $default_tab;
2368 }
2369 }
2370
2371 // Add custom tabs from database that aren't in the defaults
2372 foreach ($db_tabs_map as $tab_id => $db_tab) {
2373 // Skip if this tab was already processed in the defaults
2374 $found_in_defaults = false;
2375 foreach ($default_tabs as $default_tab) {
2376 if ($default_tab->id === $tab_id) {
2377 $found_in_defaults = true;
2378 break;
2379 }
2380 }
2381
2382 // Handle both arrays and objects for content_type check
2383 $content_type = is_array($db_tab) ? ($db_tab['content_type'] ?? null) : ($db_tab->content_type ?? null);
2384
2385 // If not found in defaults and it's a custom tab, add it
2386 if (!$found_in_defaults && $content_type === 'custom') {
2387 // Handle both arrays and objects for field access
2388 $db_label = is_array($db_tab) ? ($db_tab['label'] ?? null) : ($db_tab->label ?? null);
2389 $db_enabled = is_array($db_tab) ? ($db_tab['enabled'] ?? null) : ($db_tab->enabled ?? null);
2390 $db_order = is_array($db_tab) ? ($db_tab['order'] ?? null) : ($db_tab->order ?? null);
2391 $db_custom_content = is_array($db_tab) ? ($db_tab['custom_content'] ?? null) : ($db_tab->custom_content ?? null);
2392 $db_icon = is_array($db_tab) ? ($db_tab['icon'] ?? null) : ($db_tab->icon ?? null);
2393
2394 $custom_tab = [
2395 'id' => $tab_id,
2396 'label' => $db_label ?? __('Custom Tab', 'yatra'),
2397 'enabled' => $db_enabled !== null ? filter_var($db_enabled, FILTER_VALIDATE_BOOLEAN) : true,
2398 'order' => $db_order !== null ? (int) $db_order : 999,
2399 'content_type' => 'custom',
2400 'custom_content' => $db_custom_content ?? '',
2401 'icon' => $db_icon
2402 ];
2403 $merged_tabs[] = (object) $custom_tab;
2404 }
2405 }
2406
2407 return $merged_tabs;
2408 }
2409 }
2410
2411