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

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

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