PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.13
Yatra – Travel Booking & Tour Operator Software v3.0.13
3.0.16 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 All 84 releases
yatra / includes / helpers.php

helpers.php in Yatra – Travel Booking & Tour Operator Software 3.0.13, at includes/helpers.php

3,116 lines 99.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Yatra Helper Functions
4 *
5 * @package Yatra
6 */
7
8 // Prevent direct access
9 if (!defined('ABSPATH')) {
10 exit;
11 }
12
13 use Yatra\Database\Tables\BookingsTable;
14 use Yatra\Database\Tables\ClassificationsTable;
15 use Yatra\Database\Tables\ReviewsTable;
16 use Yatra\Database\Tables\TripsTable;
17 use Yatra\Services\SettingsService;
18 use Yatra\Constants\ClassificationTypes;
19
20 /**
21 * Get a plugin setting value
22 *
23 * @param string $key Setting key
24 * @param mixed $default Default value
25 * @return mixed
26 */
27 function yatra_get_setting(string $key, $default = null)
28 {
29 return SettingsService::get($key, $default);
30 }
31
32 /**
33 * Check if a setting is enabled
34 *
35 * @param string $key Setting key
36 * @return bool
37 */
38 function yatra_setting_enabled(string $key): bool
39 {
40 return SettingsService::isEnabled($key);
41 }
42
43 /**
44 * Check if reviews are enabled
45 *
46 * @return bool
47 */
48 function yatra_reviews_enabled(): bool
49 {
50 return SettingsService::reviewsEnabled();
51 }
52
53 /**
54 * Get booking form configuration
55 *
56 * @return array
57 */
58 function yatra_get_booking_form_config(): array
59 {
60 // Check if Dynamic Form Field module is enabled via Pro plugin
61 $is_dynamic_enabled = apply_filters('yatra_dynamic_form_field_enabled', false);
62
63 if ($is_dynamic_enabled) {
64 // Pro module is active — merged config from options (filtered in SettingsService::getBookingFormConfig)
65 return SettingsService::getBookingFormConfig();
66 }
67
68 // Module off: still allow filters to adjust defaults (tests / edge integrations)
69 return apply_filters(
70 'yatra_booking_form_config',
71 SettingsService::getDefaultBookingFormConfig()
72 );
73 }
74
75 /**
76 * Translate a booking-form display string (label / title / description /
77 * placeholder / option label) at render time.
78 *
79 * The default booking-form strings are registered for translation in
80 * SettingsService::getDefaultBookingFormConfig() (literal __() calls, so they
81 * land in the .pot for Loco Translate). This runtime pass additionally lets a
82 * SAVED or CUSTOM label (Pro Dynamic Form module) resolve against the active
83 * locale when a matching translation exists, and returns the original string
84 * unchanged otherwise. Safe for empty/non-string input.
85 *
86 * @param mixed $string
87 * @return string
88 */
89 function yatra_translate_form_string($string): string
90 {
91 $string = is_scalar($string) ? (string) $string : '';
92 if ($string === '') {
93 return '';
94 }
95
96 // Dynamic gettext: the literal source strings are registered for extraction
97 // in SettingsService; this resolves them (and any matching custom label) at
98 // runtime against the loaded 'yatra' text domain.
99 return __($string, 'yatra'); // phpcs:ignore WordPress.WP.I18n.NonSingularStringLiteralText, WordPress.WP.I18n.NonSingularStringLiteralDomain
100 }
101
102 /**
103 * Check if user can leave a review for a trip
104 *
105 * @param int $trip_id Trip ID
106 * @param int|null $user_id User ID (defaults to current user)
107 * @return bool
108 */
109 function yatra_can_review(int $trip_id, ?int $user_id = null): bool
110 {
111 // Reviews must be enabled
112 if (!SettingsService::reviewsEnabled()) {
113 return false;
114 }
115
116 // Get user ID
117 if ($user_id === null) {
118 $user_id = get_current_user_id();
119 }
120
121 // If booking required, check if user has booked this trip
122 if (SettingsService::requireBookingForReview()) {
123 if ($user_id === 0) {
124 return false; // Guest can't review if booking required
125 }
126
127 // Check if user has a completed booking for this trip
128 global $wpdb;
129 $table = BookingsTable::getTableName();
130 $has_booking = $wpdb->get_var($wpdb->prepare(
131 "SELECT COUNT(*) FROM {$table}
132 WHERE trip_id = %d AND customer_id = %d AND status = 'completed'",
133 $trip_id,
134 $user_id
135 ));
136
137 if (!$has_booking) {
138 return false;
139 }
140 }
141
142 // Check if user already reviewed this trip (but allow if within edit window)
143 if ($user_id > 0) {
144 $existing_review = yatra_get_user_review($trip_id, $user_id);
145 if ($existing_review && !yatra_can_edit_review($existing_review)) {
146 return false;
147 }
148 }
149
150 return true;
151 }
152
153 /**
154 * Get user's existing review for a trip
155 *
156 * @param int $trip_id Trip ID
157 * @param int|null $user_id User ID (defaults to current user)
158 * @return object|null Review object or null
159 */
160 function yatra_get_user_review(int $trip_id, ?int $user_id = null): ?object
161 {
162 if ($user_id === null) {
163 $user_id = get_current_user_id();
164 }
165
166 if ($user_id === 0) {
167 return null;
168 }
169
170 global $wpdb;
171 $table = ReviewsTable::getTableName();
172 $review = $wpdb->get_row($wpdb->prepare(
173 "SELECT * FROM {$table} WHERE trip_id = %d AND user_id = %d ORDER BY created_at DESC LIMIT 1",
174 $trip_id,
175 $user_id
176 ));
177
178 return $review ?: null;
179 }
180
181 /**
182 * Check if a review can be edited (within 24 hours of creation and not approved)
183 *
184 * @param object $review Review object with created_at and status fields
185 * @return bool
186 */
187 function yatra_can_edit_review(object $review): bool
188 {
189 if (empty($review->created_at)) {
190 return false;
191 }
192
193 // Don't allow editing if review is approved
194 if (isset($review->status) && $review->status === 'approved') {
195 return false;
196 }
197
198 $created_time = strtotime($review->created_at);
199 $current_time = current_time('timestamp');
200 $hours_since_creation = ($current_time - $created_time) / 3600;
201
202 // Allow editing within 24 hours (only for pending/rejected reviews)
203 return $hours_since_creation <= 24;
204 }
205
206 /**
207 * Get time remaining to edit a review
208 *
209 * @param object $review Review object with created_at field
210 * @return string Human-readable time remaining (e.g., "5 hours", "30 minutes")
211 */
212 function yatra_get_review_edit_time_remaining(object $review): string
213 {
214 if (empty($review->created_at)) {
215 return '';
216 }
217
218 $created_time = strtotime($review->created_at);
219 $current_time = current_time('timestamp');
220 $seconds_since_creation = $current_time - $created_time;
221 $seconds_remaining = (24 * 3600) - $seconds_since_creation;
222
223 if ($seconds_remaining <= 0) {
224 return '';
225 }
226
227 $hours = floor($seconds_remaining / 3600);
228 $minutes = floor(($seconds_remaining % 3600) / 60);
229
230 if ($hours > 0) {
231 /* translators: %d: number of hours remaining. */
232 return sprintf(_n('%d hour', '%d hours', $hours, 'yatra'), $hours);
233 }
234
235 /* translators: %d: number of minutes remaining. */
236 return sprintf(_n('%d minute', '%d minutes', $minutes, 'yatra'), $minutes);
237 }
238
239 /**
240 * Get the booking URL for a trip
241 *
242 * @param string $trip_slug The trip slug
243 * @param array $params Optional URL parameters (date, adults, children, price)
244 * @return string The booking URL
245 */
246 function yatra_get_booking_url(string $trip_slug, array $params = []): string
247 {
248 $permalink_structure = get_option('permalink_structure');
249 $is_plain = empty($permalink_structure);
250
251 // Check if using custom booking page via SettingsService
252 if (SettingsService::useCustomBookingPage()) {
253 $page_url = get_permalink(SettingsService::getBookingPageId());
254 if ($page_url) {
255 $params['trip'] = $trip_slug;
256 return add_query_arg($params, $page_url);
257 }
258 }
259
260 // Using default dynamic URL
261 $booking_base = SettingsService::getBookingBase();
262 if ($is_plain) {
263 $params['trip'] = $trip_slug;
264
265 return add_query_arg(
266 array_merge(['yatra_page' => $booking_base], $params),
267 home_url('/')
268 );
269 }
270
271 $url = home_url('/' . $booking_base . '/' . $trip_slug);
272
273 if (!empty($params)) {
274 $url = add_query_arg($params, $url);
275 }
276
277 return $url;
278 }
279
280 /**
281 * Normalized Dynamic Pricing display toggles (listing, trip page, availability).
282 *
283 * @return array{show_original_price: bool, show_savings_badge: bool, show_urgency_messages: bool}
284 */
285 if (!function_exists('yatra_get_dynamic_pricing_display_flags')) {
286 function yatra_get_dynamic_pricing_display_flags(): array
287 {
288 $s = apply_filters('yatra_get_dynamic_pricing_display_settings', [
289 'show_original_price' => true,
290 'show_savings_badge' => true,
291 'show_urgency_messages' => false,
292 ]);
293
294 return [
295 'show_original_price' => filter_var($s['show_original_price'] ?? true, FILTER_VALIDATE_BOOLEAN),
296 'show_savings_badge' => filter_var($s['show_savings_badge'] ?? true, FILTER_VALIDATE_BOOLEAN),
297 'show_urgency_messages' => filter_var($s['show_urgency_messages'] ?? false, FILTER_VALIDATE_BOOLEAN),
298 ];
299 }
300 }
301
302 /**
303 * Urgency lines for a trip surface (listing card, sidebar, similar trips). Pro fills via yatra_trip_card_dynamic_pricing_meta.
304 *
305 * @param array<string, mixed> $context base_sale_price, base_original_price, departure_date, spots_remaining, …
306 * @return array<int, string>
307 */
308 if (!function_exists('yatra_trip_card_dynamic_pricing_urgency_lines')) {
309 function yatra_trip_card_dynamic_pricing_urgency_lines(int $trip_id, array $context = []): array
310 {
311 if ($trip_id <= 0) {
312 return [];
313 }
314
315 $flags = yatra_get_dynamic_pricing_display_flags();
316 if (!$flags['show_urgency_messages']) {
317 return [];
318 }
319
320 $meta = apply_filters(
321 'yatra_trip_card_dynamic_pricing_meta',
322 ['urgency_messages' => []],
323 array_merge($context, [
324 'trip_id' => $trip_id,
325 'display' => $flags,
326 ])
327 );
328
329 if (!is_array($meta) || empty($meta['urgency_messages']) || !is_array($meta['urgency_messages'])) {
330 return [];
331 }
332
333 $out = [];
334 foreach ($meta['urgency_messages'] as $line) {
335 $line = sanitize_text_field((string) $line);
336 if ($line !== '') {
337 $out[] = $line;
338 }
339 }
340
341 return array_values(array_unique($out));
342 }
343 }
344
345 /**
346 * Format price with currency
347 *
348 * @param float $amount The amount to format
349 * @param string|null $currency The currency code (optional, uses global setting if not provided)
350 * @param bool $zero_is_unknown When true (default), 0 is shown as "Contact for pricing" (trip/listing).
351 * Set false for checkout, payments, and invoices where 0 is a real amount.
352 * @return string Formatted price
353 */
354 if (!function_exists('yatra_format_price')) {
355 function yatra_format_price(float $amount, ?string $currency = null, bool $zero_is_unknown = true): string
356 {
357 if ($zero_is_unknown && (empty($amount) || $amount == 0)) {
358 return __('Contact for pricing', 'yatra');
359 }
360
361 // Get currency from global settings if not provided
362 if (empty($currency)) {
363 $currency = SettingsService::getCurrency();
364 }
365
366 // Get formatting settings from global settings
367 $currency_position = SettingsService::getCurrencyPosition();
368 // Single source of truth: honors the admin "Number of decimals" field and
369 // stays in sync with the JS price formatter (already clamped to 0–4).
370 $decimal_places = SettingsService::getPriceDecimals();
371 $thousand_separator = SettingsService::getString('thousand_separator', ',');
372 $decimal_separator = SettingsService::getString('decimal_separator', '.');
373
374 // Format the amount with proper separators
375 $formatted_amount = number_format($amount, $decimal_places, $decimal_separator, $thousand_separator);
376
377 // Get currency symbol
378 $currency_symbol = yatra_get_currency_symbol($currency);
379
380 // Placement: Settings UI uses left, right, left_space, right_space; legacy uses before/after.
381 $raw = strtolower(trim((string) $currency_position));
382 if ($raw === 'before') {
383 $pos = 'left_space';
384 } elseif ($raw === 'after') {
385 $pos = 'right_space';
386 } else {
387 $pos = $raw;
388 }
389 $allowed = ['left', 'right', 'left_space', 'right_space'];
390 if (!in_array($pos, $allowed, true)) {
391 $pos = 'left_space';
392 }
393
394 if ($pos === 'right') {
395 return $formatted_amount . $currency_symbol;
396 }
397 if ($pos === 'right_space') {
398 return $formatted_amount . ' ' . $currency_symbol;
399 }
400 if ($pos === 'left') {
401 return $currency_symbol . $formatted_amount;
402 }
403
404 return $currency_symbol . ' ' . $formatted_amount;
405 }
406 }
407
408 /**
409 * Get currency symbol from currency code
410 *
411 * @param string $currency_code The currency code (e.g., 'USD', 'EUR', 'NPR')
412 * @return string The currency symbol or code
413 */
414 if (!function_exists('yatra_get_currency_symbol')) {
415 function yatra_get_currency_symbol(string $currency_code): string
416 {
417 $symbols = [
418 'USD' => '$',
419 'EUR' => '€',
420 'GBP' => '£',
421 'JPY' => '¥',
422 'CNY' => '¥',
423 'INR' => '₹',
424 'NPR' => 'Rs',
425 'AUD' => 'A$',
426 'CAD' => 'C$',
427 'CHF' => 'CHF',
428 'NZD' => 'NZ$',
429 'SGD' => 'S$',
430 'HKD' => 'HK$',
431 'KRW' => '₩',
432 'THB' => '฿',
433 'MYR' => 'RM',
434 'PHP' => '₱',
435 'IDR' => 'Rp',
436 'VND' => '₫',
437 'BRL' => 'R$',
438 'MXN' => 'MX$',
439 'RUB' => '₽',
440 'ZAR' => 'R',
441 'AED' => 'د.إ',
442 'SAR' => '﷼',
443 'TRY' => '₺',
444 'SEK' => 'kr',
445 'NOK' => 'kr',
446 'DKK' => 'kr',
447 'PLN' => 'zł',
448 'CZK' => 'Kč',
449 'HUF' => 'Ft',
450 'ILS' => '₪',
451 'TWD' => 'NT$',
452 'PKR' => '₨',
453 'BDT' => '৳',
454 'LKR' => 'Rs',
455 'EGP' => 'E£',
456 'NGN' => '₦',
457 'KES' => 'KSh',
458 // Ghanaian cedi – use plain symbol without the GH prefix
459 'GHS' => '₵',
460 'GHC' => '₵',
461 'ARS' => 'AR$',
462 'CLP' => 'CL$',
463 'COP' => 'CO$',
464 'PEN' => 'S/',
465 ];
466
467 return $symbols[strtoupper($currency_code)] ?? $currency_code;
468 }
469 }
470
471 /**
472 * Format duration (days/nights)
473 *
474 * @param int $days Number of days
475 * @param int|null $nights Number of nights (optional)
476 * @return string Formatted duration
477 */
478 if (!function_exists('yatra_format_duration')) {
479 function yatra_format_duration(int $days, ?int $nights = null): string
480 {
481 if ($days > 0 && $nights !== null && $nights > 0) {
482 /* translators: 1: number of days, 2: number of nights. */
483 return sprintf(__('%1$d days / %2$d nights', 'yatra'), $days, $nights);
484 }
485 if ($days > 0) {
486 return sprintf(
487 /* translators: %d: number of days. */
488 _n('%d day', '%d days', $days, 'yatra'),
489 $days
490 );
491 }
492 return __('Flexible', 'yatra');
493 }
494 }
495
496 /**
497 * Render SVG icon
498 *
499 * @param string $icon_name Icon name
500 * @param string $class Optional CSS class
501 * @return string SVG markup
502 */
503 if (!function_exists('yatra_svg_icon')) {
504 function yatra_svg_icon(string $icon_name, string $class = ''): string
505 {
506 static $icons = null;
507
508 // Load icons from JSON file once
509 if ($icons === null) {
510 $icons_file = dirname(__FILE__) . '/icons.json';
511 if (file_exists($icons_file)) {
512 $icons_data = json_decode(file_get_contents($icons_file), true);
513 $icons = [];
514
515 // Convert JSON data to PHP array
516 foreach ($icons_data as $name => $data) {
517 if (isset($data['svg'])) {
518 $icons[$name] = (string) $data['svg'];
519 }
520 }
521 } else {
522 $icons = [];
523 }
524 }
525
526 $svg = $icons[$icon_name] ?? '';
527
528 if ($svg === '' || !is_string($svg)) {
529 return '';
530 }
531
532 if ($class !== '') {
533 $class_attr = esc_attr($class);
534
535 if (preg_match('/<svg[^>]*\sclass="([^"]*)"/i', $svg, $m)) {
536 $existing = trim((string) ($m[1] ?? ''));
537 $merged = trim($existing . ' ' . $class_attr);
538 $svg = preg_replace('/(<svg[^>]*\sclass=")([^"]*)(")/i', '$1' . $merged . '$3', $svg, 1);
539 } else {
540 $svg = preg_replace('/<svg\b/i', '<svg class="' . $class_attr . '"', $svg, 1);
541 }
542 }
543
544 return (string) $svg;
545 }
546 }
547
548 /**
549 * Allowed Font Awesome Free icon name (maps to fa-{name} class).
550 */
551 if (!function_exists('yatra_sanitize_fa_icon_slug')) {
552 function yatra_sanitize_fa_icon_slug(string $name): string
553 {
554 $n = strtolower(trim($name));
555 if ($n === '' || strlen($n) > 64) {
556 return '';
557 }
558 if (!preg_match('/^[a-z0-9-]+$/', $n)) {
559 return '';
560 }
561
562 return $n;
563 }
564 }
565
566 /**
567 * Normalize icon picker payload before storing (REST / services).
568 *
569 * @param array<string, mixed> $icon
570 * @return array{type: string, value: string|int, provider?: string}
571 */
572 if (!function_exists('yatra_normalize_icon_picker_for_storage')) {
573 function yatra_normalize_icon_picker_for_storage(array $icon): array
574 {
575 $type = isset($icon['type']) && $icon['type'] === 'image' ? 'image' : 'icon';
576 $value = $icon['value'] ?? '';
577 if ($type === 'image') {
578 return [
579 'type' => 'image',
580 'value' => is_numeric($value) ? (int) $value : sanitize_text_field((string) $value),
581 ];
582 }
583 $provider = isset($icon['provider']) ? sanitize_key((string) $icon['provider']) : 'yatra';
584 if (!in_array($provider, ['yatra', 'fa-solid', 'fa-regular'], true)) {
585 $provider = 'yatra';
586 }
587
588 return [
589 'type' => 'icon',
590 'value' => sanitize_text_field((string) $value),
591 'provider' => $provider,
592 ];
593 }
594 }
595
596 /**
597 * Markup for a stored icon picker value (Yatra SVG registry, Font Awesome, or image).
598 *
599 * @param array<string, mixed>|string|null $picker Serialized JSON string, array, or null.
600 * @return string Safe HTML (empty string if nothing renderable).
601 */
602 if (!function_exists('yatra_stored_picker_icon_markup')) {
603 function yatra_stored_picker_icon_markup($picker, string $default_yatra_slug = 'mountain', string $class = ''): string
604 {
605 $class = trim($class);
606 $class_attr = $class !== '' ? ' ' . esc_attr($class) : '';
607
608 if ($picker === null || $picker === '') {
609 return function_exists('yatra_svg_icon') ? yatra_svg_icon($default_yatra_slug, $class) : '';
610 }
611 if (is_string($picker) && strpos($picker, '{') === 0) {
612 $picker = json_decode($picker, true);
613 }
614 if (!is_array($picker) || !isset($picker['type'])) {
615 if (is_string($picker)) {
616 $slug = trim($picker);
617
618 return $slug !== '' && function_exists('yatra_svg_icon')
619 ? yatra_svg_icon($slug, $class)
620 : yatra_svg_icon($default_yatra_slug, $class);
621 }
622
623 return function_exists('yatra_svg_icon') ? yatra_svg_icon($default_yatra_slug, $class) : '';
624 }
625
626 if ($picker['type'] === 'image' && !empty($picker['value'])) {
627 $image_url = is_numeric($picker['value'])
628 ? wp_get_attachment_url((int) $picker['value'])
629 : (string) $picker['value'];
630 if ($image_url) {
631 $style = 'width:24px;height:24px;object-fit:cover;border-radius:4px;';
632
633 return '<img src="' . esc_url($image_url) . '" alt="" class="' . esc_attr(trim('yatra-picker-img-icon ' . $class)) . '" style="' . esc_attr($style) . '" loading="lazy" decoding="async" />';
634 }
635
636 return function_exists('yatra_svg_icon') ? yatra_svg_icon('image', $class) : '';
637 }
638
639 if ($picker['type'] === 'icon' && !empty($picker['value'])) {
640 $provider = isset($picker['provider']) ? sanitize_key((string) $picker['provider']) : 'yatra';
641 if ($provider === 'fa-solid' || $provider === 'fa-regular') {
642 $slug = yatra_sanitize_fa_icon_slug((string) $picker['value']);
643 if ($slug === '') {
644 return function_exists('yatra_svg_icon') ? yatra_svg_icon($default_yatra_slug, $class) : '';
645 }
646 $fa_prefix = $provider === 'fa-regular' ? 'fa-regular' : 'fa-solid';
647
648 return '<i class="' . esc_attr($fa_prefix . ' fa-' . $slug . $class_attr) . '" aria-hidden="true"></i>';
649 }
650
651 return function_exists('yatra_svg_icon')
652 ? yatra_svg_icon((string) $picker['value'], $class)
653 : '';
654 }
655
656 return function_exists('yatra_svg_icon') ? yatra_svg_icon($default_yatra_slug, $class) : '';
657 }
658 }
659
660 /**
661 * Translated display label for trip meal_plan stored slug (matches admin Trip Builder options).
662 *
663 * @param string|null $slug Raw value from DB (e.g. half_board, "Half Board").
664 */
665 if (!function_exists('yatra_meal_plan_label')) {
666 function yatra_meal_plan_label(?string $slug): string
667 {
668 if ($slug === null || $slug === '') {
669 return '';
670 }
671 $s = strtolower(trim(preg_replace('/[\s\-]+/', '_', $slug), " \t\n\r\0\x0B_-"));
672 switch ($s) {
673 case 'breakfast':
674 return __('Breakfast Only', 'yatra');
675 case 'half_board':
676 return __('Half Board (Breakfast + Dinner)', 'yatra');
677 case 'full_board':
678 return __('Full Board (All Meals)', 'yatra');
679 case 'all_inclusive':
680 return __('All Inclusive', 'yatra');
681 case 'none':
682 return __('No Meals Included', 'yatra');
683 default:
684 return ucwords(str_replace('_', ' ', $s));
685 }
686 }
687 }
688
689 /**
690 * Translated itinerary entry item type label for frontend (matches admin item type names).
691 */
692 if (!function_exists('yatra_itinerary_item_type_label')) {
693 function yatra_itinerary_item_type_label(string $type): string
694 {
695 $t = trim($type);
696 switch ($t) {
697 case 'Meal':
698 return __('Meal', 'yatra');
699 case 'Activity':
700 return __('Activity', 'yatra');
701 case 'Accommodation':
702 return __('Accommodation', 'yatra');
703 case 'Transportation':
704 return __('Transportation', 'yatra');
705 case 'Rest':
706 return __('Rest', 'yatra');
707 default:
708 return $t;
709 }
710 }
711 }
712
713 /**
714 * Extract SVG icon slug from a stored icon field (same shape as admin / archive cards).
715 *
716 * @param mixed $icon Raw value from DB (serialized array with type/value, URL, attachment id, or legacy slug string).
717 */
718 function yatra_icon_slug_from_stored_field($icon): string
719 {
720 if ($icon === null || $icon === '') {
721 return '';
722 }
723
724 $icon = maybe_unserialize($icon);
725
726 if (is_array($icon)) {
727 $type = $icon['type'] ?? $icon[0] ?? '';
728 $value = $icon['value'] ?? $icon[1] ?? '';
729 if ($type === 'icon' && !empty($value) && is_string($value)) {
730 $provider = isset($icon['provider']) ? sanitize_key((string) $icon['provider']) : 'yatra';
731 if ($provider === 'fa-solid' || $provider === 'fa-regular') {
732 return '';
733 }
734
735 return $value;
736 }
737
738 return '';
739 }
740
741 if (is_string($icon)) {
742 if (filter_var($icon, FILTER_VALIDATE_URL)) {
743 return '';
744 }
745 $slug = trim($icon);
746
747 return $slug !== '' ? $slug : '';
748 }
749
750 return '';
751 }
752
753 /**
754 * SVG markup for archive listing CTAs: use admin icon when present and valid in icons.json; else default slug.
755 *
756 * @param string $resolved_icon_slug From the listing loop (same source as card hero icon when type is "icon").
757 * @param string $default_slug icons.json key when no admin icon.
758 */
759 function yatra_archive_listing_cta_icon_markup(string $resolved_icon_slug, string $default_slug, string $class = 'yatra-btn-icon'): string
760 {
761 $slug = trim($resolved_icon_slug);
762 if ($slug !== '' && function_exists('yatra_svg_icon')) {
763 $out = yatra_svg_icon($slug, $class);
764 if ($out !== '') {
765 return $out;
766 }
767 }
768
769 $fallback = trim($default_slug);
770 if ($fallback !== '' && function_exists('yatra_svg_icon')) {
771 return yatra_svg_icon($fallback, $class);
772 }
773
774 return '';
775 }
776
777 /**
778 * Icon slug for trip listing card "View Details" — category, then destination, then difficulty (backend order).
779 *
780 * @param array<int, object|array<string, mixed>> $categories Trip categories from getCategories()
781 * @param array<int, object|array<string, mixed>> $destinations Trip destinations from getDestinations()
782 * @param array<string, mixed> $difficulty From Trip::getDifficulty()
783 */
784 function yatra_trip_listing_card_cta_icon_slug(array $categories, array $destinations, array $difficulty): string
785 {
786 foreach ($categories as $row) {
787 if (empty($row)) {
788 continue;
789 }
790 $raw = is_object($row) ? ($row->icon ?? null) : ($row['icon'] ?? null);
791 $slug = yatra_icon_slug_from_stored_field($raw);
792 if ($slug !== '') {
793 return $slug;
794 }
795 }
796
797 foreach ($destinations as $row) {
798 if (empty($row)) {
799 continue;
800 }
801 $raw = is_object($row) ? ($row->icon ?? null) : ($row['icon'] ?? null);
802 $slug = yatra_icon_slug_from_stored_field($raw);
803 if ($slug !== '') {
804 return $slug;
805 }
806 }
807
808 if (!empty($difficulty['icon']) && is_string($difficulty['icon'])) {
809 $try = trim($difficulty['icon']);
810 if ($try !== '') {
811 return $try;
812 }
813 }
814
815 return '';
816 }
817
818 /**
819 * Get booking base URL slug
820 *
821 * @return string The booking base slug
822 */
823 function yatra_get_booking_base(): string
824 {
825 // Check if using custom booking page
826 if (SettingsService::useCustomBookingPage()) {
827 $booking_page_id = SettingsService::getBookingPageId();
828 if ($booking_page_id > 0) {
829 $page = get_post($booking_page_id);
830 if ($page) {
831 return $page->post_name;
832 }
833 }
834 }
835
836 return SettingsService::getBookingBase();
837 }
838
839 /**
840 * Check if the current page is a booking page
841 *
842 * @return bool
843 */
844 function yatra_is_booking_page(): bool
845 {
846 global $wp_query;
847
848 // Check for custom booking page
849 if (SettingsService::useCustomBookingPage()) {
850 $booking_page_id = SettingsService::getBookingPageId();
851 if ($booking_page_id > 0 && is_page($booking_page_id)) {
852 return true;
853 }
854 }
855
856 $booking_base = SettingsService::getBookingBase();
857 if (!empty($wp_query->get('yatra_page')) && (string) $wp_query->get('yatra_page') === $booking_base) {
858 return true;
859 }
860
861 // Check for dynamic booking URL
862 return !empty($wp_query->get('yatra_booking_trip_slug'));
863 }
864
865 /**
866 * Get the global trip object
867 *
868 * Similar to WordPress get_post(), this function returns the current trip object
869 * when on a single trip page.
870 *
871 * @return object|null The trip object or null if not on a trip page
872 */
873 function yatra_get_trip(): ?object
874 {
875 global $trip;
876 return $trip ?? null;
877 }
878
879 /**
880 * Check if we're on a single trip page
881 *
882 * @return bool True if on a single trip page
883 */
884 function yatra_is_single_trip(): bool
885 {
886
887 global $wp_query;
888 return !empty($wp_query->get('yatra_trip_id'));
889 }
890
891 /**
892 * Get a trip field value with default fallback
893 *
894 * @param string $field The field name
895 * @param mixed $default Default value if field is empty
896 * @return mixed The field value or default
897 */
898 function yatra_get_trip_field(string $field, $default = '')
899 {
900 global $trip;
901
902 if (!$trip || !isset($trip->$field)) {
903 return $default;
904 }
905
906 return $trip->$field ?: $default;
907 }
908
909 /**
910 * Echo a trip field value with escaping
911 *
912 * @param string $field The field name
913 * @param string $escape Escape function: 'html', 'attr', 'url', 'js', 'none'
914 * @param mixed $default Default value if field is empty
915 */
916 function yatra_trip_field(string $field, string $escape = 'html', $default = ''): void
917 {
918 $value = yatra_get_trip_field($field, $default);
919
920 switch ($escape) {
921 case 'html':
922 echo esc_html($value);
923 break;
924 case 'attr':
925 echo esc_attr($value);
926 break;
927 case 'url':
928 echo esc_url($value);
929 break;
930 case 'js':
931 echo esc_js($value);
932 break;
933 case 'none':
934 case 'kses':
935 echo wp_kses_post($value);
936 break;
937 default:
938 echo esc_html($value);
939 }
940 }
941
942 /**
943 * ============================================
944 * BRAND / WHITE LABEL HELPERS (THIN WRAPPERS)
945 * ============================================
946 *
947 * The free plugin owns the function NAMES (so callers in plugin row meta,
948 * admin menu, PDF templates, etc. work without conditional `function_exists`
949 * checks), but every override lives in Yatra Pro's White Label module.
950 *
951 * Each helper here just applies a filter; Pro's WhiteLabel module registers
952 * filter callbacks when the module is enabled AND an Agency-tier license is
953 * active. Without Pro, every filter no-ops and these return the defaults —
954 * which is the correct behavior for a free-only install.
955 *
956 * Option storage, REST endpoints, sanitization, plugin-list rebranding,
957 * brand-color CSS injection, and dependency-link rewriting all live in
958 * yatra-pro/app/Modules/WhiteLabel/ — NOT here.
959 */
960
961 /**
962 * Public URL for the Yatra brand icon (admin menu + React sidebar).
963 * Defaults to the bundled `yatra-icon.png`; Pro overrides via the
964 * `yatra_brand_icon_url` filter when a White Label logo is configured.
965 */
966 function yatra_get_brand_icon_url(): string
967 {
968 $default = '';
969 if (defined('YATRA_PLUGIN_PATH') && defined('YATRA_PLUGIN_URL')) {
970 $candidates = [
971 'assets/images/yatra-icon.png',
972 'assets/images/yara-icon.png',
973 ];
974 foreach ($candidates as $relative) {
975 $file = YATRA_PLUGIN_PATH . $relative;
976 if (!is_readable($file)) {
977 continue;
978 }
979 $default = add_query_arg(
980 'ver',
981 (string) filemtime($file),
982 YATRA_PLUGIN_URL . $relative
983 );
984 break;
985 }
986 }
987
988 return (string) apply_filters('yatra_brand_icon_url', $default);
989 }
990
991 /**
992 * Whether the Agency White Label module is active and may override branding.
993 * Pro returns true via the `yatra_white_label_active` filter when its
994 * WhiteLabel module is enabled AND the license tier is Agency.
995 */
996 function yatra_is_white_label_active(): bool
997 {
998 return (bool) apply_filters('yatra_white_label_active', false);
999 }
1000
1001 /**
1002 * Read a single white-label setting with a default fallback. Backed by a
1003 * filter so option access stays in Pro.
1004 *
1005 * @param mixed $default
1006 * @return mixed
1007 */
1008 function yatra_get_white_label_setting(string $key, $default = '')
1009 {
1010 return apply_filters('yatra_white_label_setting', $default, $key);
1011 }
1012
1013 /**
1014 * @return array<string, mixed>
1015 */
1016 function yatra_get_white_label_settings(): array
1017 {
1018 $value = apply_filters('yatra_white_label_settings', []);
1019 return is_array($value) ? $value : [];
1020 }
1021
1022 /**
1023 * Branding for generated PDFs (invoice, voucher, itinerary).
1024 *
1025 * Free ships an unbranded default — the header keeps whatever colour the
1026 * document already used and no logo is shown — so nothing changes for a site
1027 * without Yatra Pro. The White Label module hooks these filters to supply the
1028 * operator's own logo and colour, exactly as it already does for
1029 * `yatra_brand_icon_url` and friends.
1030 *
1031 * Kept as filters rather than reading White Label options directly so free never
1032 * depends on Pro, and so a site can brand its PDFs from a theme or snippet
1033 * without the module at all.
1034 *
1035 * @param string $defaultHeaderColor The document's existing header colour, so
1036 * each PDF keeps its own look when unbranded.
1037 * @return array{logo_url: string, header_color: string}
1038 */
1039 function yatra_get_pdf_branding(string $defaultHeaderColor): array
1040 {
1041 $logo = (string) apply_filters('yatra_pdf_branding_logo_url', '');
1042 $color = (string) apply_filters('yatra_pdf_branding_header_color', $defaultHeaderColor);
1043
1044 // Only accept a well-formed hex colour; anything else falls back to the
1045 // document default rather than emitting broken CSS into the PDF.
1046 if (!preg_match('/^#[0-9a-fA-F]{3}(?:[0-9a-fA-F]{3})?$/', $color)) {
1047 $color = $defaultHeaderColor;
1048 }
1049
1050 $logo = esc_url_raw(trim($logo));
1051
1052 return [
1053 'logo_url' => $logo,
1054 'header_color' => $color,
1055 ];
1056 }
1057
1058 /**
1059 * Are partial payments possible on this site at all?
1060 *
1061 * True when deposits or partial payments are switched on globally. Used to
1062 * decide whether part-payment specific features (such as the separate
1063 * "part payment received" email template) are relevant — there is no point
1064 * showing them to an operator who only ever takes payment in full.
1065 */
1066 function yatra_partial_payments_enabled(): bool
1067 {
1068 $enabled = \Yatra\Services\SettingsService::isEnabled('partial_payment')
1069 || \Yatra\Services\SettingsService::isEnabled('enable_deposit')
1070 || \Yatra\Services\SettingsService::isEnabled('deposit_required');
1071
1072 return (bool) apply_filters('yatra_partial_payments_enabled', $enabled);
1073 }
1074
1075 /**
1076 * How many stars to draw for an average rating.
1077 *
1078 * Rounds to the NEAREST half star rather than flooring. Flooring made a 4.9
1079 * average draw four-and-a-half stars, which reads as a mistake sitting next to
1080 * the printed "4.9" — a 4.9 is five stars to anyone looking at it.
1081 *
1082 * 4.9 -> 5 4.7 -> 4.5 4.4 -> 4.5 4.2 -> 4
1083 *
1084 * Returns the number of solid stars and whether a half star follows them, so
1085 * every surface (confirmation page, reviews block, listing cards) draws the
1086 * same rating identically.
1087 *
1088 * @return array{full:int, half:bool}
1089 */
1090 function yatra_rating_star_parts($rating): array
1091 {
1092 $rating = max(0.0, min(5.0, (float) $rating));
1093
1094 // Work in half-star units so the rounding is a single, obvious step.
1095 $halves = (int) round($rating * 2);
1096
1097 return [
1098 'full' => intdiv($halves, 2),
1099 'half' => ($halves % 2) === 1,
1100 ];
1101 }
1102
1103 /**
1104 * Branded plugin name shown in admin menu, plugin list, and PDFs.
1105 */
1106 function yatra_get_brand_name(): string
1107 {
1108 return (string) apply_filters('yatra_brand_name', 'Yatra');
1109 }
1110
1111 /**
1112 * Branded company/author name (replaces "MantraBrain").
1113 */
1114 function yatra_get_brand_company(): string
1115 {
1116 return (string) apply_filters('yatra_brand_company', 'MantraBrain');
1117 }
1118
1119 /**
1120 * Public website URL for the branded product.
1121 */
1122 function yatra_get_brand_website_url(): string
1123 {
1124 $url = (string) apply_filters('yatra_brand_website_url', 'https://wpyatra.com/');
1125 return $url !== '' ? esc_url_raw($url) : 'https://wpyatra.com/';
1126 }
1127
1128 /**
1129 * Support URL surfaced in admin notices and the plugin row.
1130 */
1131 function yatra_get_brand_support_url(): string
1132 {
1133 $url = (string) apply_filters(
1134 'yatra_brand_support_url',
1135 'https://wordpress.org/support/plugin/yatra/reviews/?filter=5'
1136 );
1137 return $url !== '' ? esc_url_raw($url) : 'https://wordpress.org/support/plugin/yatra/reviews/?filter=5';
1138 }
1139
1140
1141 /**
1142 * ============================================
1143 * BOOKING SESSION MANAGEMENT
1144 * ============================================
1145 */
1146
1147 /**
1148 * Start WordPress session if not already started
1149 */
1150 function yatra_start_session(): void
1151 {
1152 // Start output buffering to prevent accidental output from breaking sessions
1153 if (!ob_get_level()) {
1154 ob_start();
1155 }
1156
1157 if (session_status() === PHP_SESSION_NONE && !headers_sent()) {
1158 // Set session cookie parameters for better compatibility
1159 if (PHP_VERSION_ID >= 70300) {
1160 session_set_cookie_params([
1161 'lifetime' => 0,
1162 'path' => defined('COOKIEPATH') ? COOKIEPATH : '/',
1163 'domain' => defined('COOKIE_DOMAIN') ? COOKIE_DOMAIN : '',
1164 'secure' => is_ssl(),
1165 'httponly' => true,
1166 'samesite' => 'Lax'
1167 ]);
1168 }
1169 session_start();
1170 }
1171 }
1172
1173 /**
1174 * Set booking session data
1175 *
1176 * @param array $data Booking data to store
1177 */
1178 function yatra_set_booking_session(array $data): void
1179 {
1180 yatra_start_session();
1181
1182 // Clear any existing remaining payment session to avoid conflicts
1183 unset($_SESSION['yatra_remaining']);
1184
1185 $session_data = array_merge(
1186 $_SESSION['yatra_booking'] ?? [],
1187 $data,
1188 ['timestamp' => time()]
1189 );
1190
1191 $_SESSION['yatra_booking'] = $session_data;
1192
1193 // ALWAYS store in transient as backup (not just for REST API)
1194 // This ensures data persists across all request types
1195 // Generate or reuse booking token
1196 $booking_token = $_SESSION['yatra_booking_token'] ?? 'yatra_booking_' . wp_generate_password(32, false);
1197 $_SESSION['yatra_booking_token'] = $booking_token;
1198 $session_data['booking_token'] = $booking_token;
1199
1200 // Store in transient (expires in 30 minutes)
1201 try {
1202 $transient_set = set_transient($booking_token, $session_data, 1800);
1203 } catch (Exception $e) {
1204 // Continue without transient - session fallback will be used
1205 }
1206
1207 }
1208
1209 /**
1210 * Get booking session data
1211 *
1212 * @param string|null $key Specific key to retrieve, or null for all data
1213 * @param mixed $default Default value if key not found
1214 * @return mixed
1215 */
1216 function yatra_get_booking_session(?string $key = null, $default = null)
1217 {
1218 yatra_start_session();
1219
1220 $booking_data = $_SESSION['yatra_booking'] ?? [];
1221
1222 // If session is empty, try to restore from transient (REST API → page load transition)
1223 if (empty($booking_data) || empty($booking_data['trip_id'])) {
1224 // Check for booking token in URL or session
1225 $booking_token = $_GET['booking_token'] ?? $_SESSION['yatra_booking_token'] ?? null;
1226
1227 if ($booking_token) {
1228 try {
1229 $transient_data = get_transient($booking_token);
1230
1231 if ($transient_data && is_array($transient_data) && !empty($transient_data['trip_id'])) {
1232 // Validate transient data integrity
1233 if (isset($transient_data['timestamp']) && (time() - $transient_data['timestamp']) < 1800) {
1234 $booking_data = $transient_data;
1235 // Restore to session
1236 $_SESSION['yatra_booking'] = $booking_data;
1237 $_SESSION['yatra_booking_token'] = $booking_token;
1238 }
1239 }
1240 } catch (Exception $e) {
1241 // Continue without transient data
1242 }
1243 }
1244 }
1245
1246 // Check if session is expired (30 minutes)
1247 if (!empty($booking_data['timestamp'])) {
1248 $session_age = time() - $booking_data['timestamp'];
1249 if ($session_age > 1800) { // 30 minutes
1250 yatra_clear_booking_session();
1251 return $key ? $default : [];
1252 }
1253 }
1254
1255 if ($key === null) {
1256 return $booking_data;
1257 }
1258
1259 return $booking_data[$key] ?? $default;
1260 }
1261
1262 /**
1263 * Clear booking session data (PHP session, booking token, and REST backup transient).
1264 *
1265 * Without removing the token and transient, yatra_get_booking_session() can repopulate
1266 * checkout data from the transient on the next request after a completed booking.
1267 */
1268 function yatra_clear_booking_session(): void
1269 {
1270 yatra_start_session();
1271
1272 $token = $_SESSION['yatra_booking_token'] ?? null;
1273 if (is_string($token) && $token !== '') {
1274 delete_transient($token);
1275 }
1276
1277 unset($_SESSION['yatra_booking'], $_SESSION['yatra_booking_token']);
1278 }
1279
1280 /**
1281 * Check if booking session exists and is valid
1282 *
1283 * @return bool
1284 */
1285 function yatra_has_booking_session(): bool
1286 {
1287 $booking_data = yatra_get_booking_session();
1288 return !empty($booking_data) && !empty($booking_data['trip_id']);
1289 }
1290
1291 /**
1292 * Fire {@see 'yatra_booking_confirmed'} when a booking reaches `confirmed` from a non-confirmed status.
1293 *
1294 * Core always fired `yatra_booking_status_changed`; Pro modules (Trip Consent, Google Calendar) listen
1295 * on this dedicated action. Call this after any code path that sets a booking to `confirmed` without
1296 * going through {@see \Yatra\Services\BookingService::updateStatus()}.
1297 *
1298 * @param int $bookingId Booking ID.
1299 * @param string $previousStatus Booking status in the database immediately before confirming.
1300 */
1301 function yatra_trigger_booking_confirmed(int $bookingId, string $previousStatus): void
1302 {
1303 if ($bookingId < 1 || $previousStatus === 'confirmed') {
1304 return;
1305 }
1306
1307 $repo = new \Yatra\Repositories\BookingRepository();
1308 $booking = $repo->findWithTrip($bookingId);
1309
1310 if (!$booking || ($booking->status ?? '') !== 'confirmed') {
1311 return;
1312 }
1313
1314 /**
1315 * Booking reached confirmed status (was not confirmed before this transition).
1316 *
1317 * @param int $bookingId Booking ID.
1318 * @param object $booking Row from {@see \Yatra\Repositories\BookingRepository::findWithTrip()}.
1319 */
1320 do_action('yatra_booking_confirmed', $bookingId, $booking);
1321 }
1322
1323 /**
1324 * Decide whether a successful payment should auto-confirm the booking.
1325 *
1326 * A booking auto-confirms on payment only when the operator has enabled
1327 * "Auto-Confirm Bookings", OR the booking is now fully paid. A deposit /
1328 * partial payment must NOT confirm the booking while auto-confirm is off — the
1329 * operator confirms it manually. Previously the synchronous-gateway, Stripe and
1330 * PayPal completion paths force-confirmed on any payment, so deposit bookings
1331 * were confirmed immediately regardless of the setting.
1332 *
1333 * @param bool $fullyPaid Whether the booking's balance is now zero.
1334 * @param int $bookingId Booking ID (passed to the filter for context).
1335 * @return bool True to set the booking to `confirmed`.
1336 */
1337 function yatra_should_confirm_booking_on_payment(bool $fullyPaid, int $bookingId = 0): bool
1338 {
1339 $autoConfirm = (bool) \Yatra\Services\SettingsService::isEnabled('auto_confirm_bookings');
1340 $shouldConfirm = $autoConfirm || $fullyPaid;
1341
1342 /**
1343 * Filter whether a completed payment auto-confirms the booking.
1344 *
1345 * @param bool $shouldConfirm Default: auto-confirm setting is on OR fully paid.
1346 * @param bool $fullyPaid Whether the balance is now zero.
1347 * @param int $bookingId Booking ID.
1348 * @param bool $autoConfirm The `auto_confirm_bookings` setting value.
1349 */
1350 return (bool) apply_filters('yatra_confirm_booking_on_payment', $shouldConfirm, $fullyPaid, $bookingId, $autoConfirm);
1351 }
1352
1353 /**
1354 * ============================================
1355 * REMAINING PAYMENT SESSION MANAGEMENT
1356 * ============================================
1357 */
1358
1359 /**
1360 * Set remaining payment session data
1361 *
1362 * @param array $data Remaining payment data to store
1363 */
1364 function yatra_set_remaining_session(array $data): void
1365 {
1366 yatra_start_session();
1367
1368 // Clear checkout session fully (including token + transient) before remaining-payment flow
1369 yatra_clear_booking_session();
1370
1371 $_SESSION['yatra_remaining'] = array_merge(
1372 $data,
1373 ['timestamp' => time()]
1374 );
1375
1376 // Ensure session data is written to storage immediately
1377 if (session_status() === PHP_SESSION_ACTIVE) {
1378 session_write_close();
1379 }
1380 }
1381
1382 /**
1383 * Get remaining payment session data
1384 *
1385 * @param string|null $key Specific key to retrieve, or null for all data
1386 * @param mixed $default Default value if key not found
1387 * @return mixed
1388 */
1389 function yatra_get_remaining_session(?string $key = null, $default = null)
1390 {
1391 yatra_start_session();
1392
1393 $remaining_data = $_SESSION['yatra_remaining'] ?? [];
1394
1395 // Check if session is expired (30 minutes)
1396 if (!empty($remaining_data['timestamp'])) {
1397 $session_age = time() - $remaining_data['timestamp'];
1398 if ($session_age > 1800) { // 30 minutes
1399 yatra_clear_remaining_session();
1400 return $key ? $default : [];
1401 }
1402 }
1403
1404 if ($key === null) {
1405 return $remaining_data;
1406 }
1407
1408 return $remaining_data[$key] ?? $default;
1409 }
1410
1411 /**
1412 * Clear remaining payment session data
1413 */
1414 function yatra_clear_remaining_session(): void
1415 {
1416 yatra_start_session();
1417 unset($_SESSION['yatra_remaining']);
1418 }
1419
1420 /**
1421 * Check if remaining payment session exists and is valid
1422 *
1423 * @return bool
1424 */
1425 function yatra_has_remaining_session(): bool
1426 {
1427 $remaining_data = yatra_get_remaining_session();
1428 return !empty($remaining_data) && !empty($remaining_data['booking_id']);
1429 }
1430
1431 /**
1432 * Get the active checkout session type
1433 *
1434 * @return string|null 'remaining' if remaining session exists, 'booking' if booking session exists, null if neither
1435 */
1436 function yatra_get_checkout_session_type(): ?string
1437 {
1438 if (yatra_has_remaining_session()) {
1439 return 'remaining';
1440 }
1441
1442 if (yatra_has_booking_session()) {
1443 return 'booking';
1444 }
1445
1446 return null;
1447 }
1448
1449 /**
1450 * Get the active checkout session data (remaining or booking)
1451 *
1452 * @return array Session data with 'type' key indicating session type
1453 */
1454 function yatra_get_active_checkout_session(): array
1455 {
1456 if (yatra_has_remaining_session()) {
1457 $data = yatra_get_remaining_session();
1458 $data['session_type'] = 'remaining';
1459 return $data;
1460 }
1461
1462 if (yatra_has_booking_session()) {
1463 $data = yatra_get_booking_session();
1464 $data['session_type'] = 'booking';
1465 return $data;
1466 }
1467
1468 return [];
1469 }
1470
1471 /**
1472 * Get booking/checkout URL
1473 *
1474 * Logic:
1475 * 1. If custom booking page is set → return that page's URL
1476 * 2. Otherwise → return dynamic URL using booking_base from settings (e.g., /bookings/)
1477 *
1478 * @return string Booking URL
1479 */
1480 function yatra_get_checkout_url(): string
1481 {
1482 $permalink_structure = get_option('permalink_structure');
1483 $is_plain = empty($permalink_structure);
1484
1485 // Check if custom booking page is set via SettingsService
1486 if (SettingsService::useCustomBookingPage()) {
1487 $page_id = SettingsService::getBookingPageId();
1488 if ($page_id > 0) {
1489 return get_permalink($page_id);
1490 }
1491 }
1492
1493 // Default dynamic URL using booking base from settings
1494 $base = SettingsService::getBookingBase();
1495 if ($is_plain) {
1496 return add_query_arg(['yatra_page' => $base], home_url('/'));
1497 }
1498
1499 return home_url('/' . $base . '/');
1500 }
1501
1502 /**
1503 * Front-end URL for booking confirmation for a given reference.
1504 *
1505 * Booking confirmation is pageless: Yatra serves it via rewrite rules and query vars,
1506 * not a WordPress page permalink. Pretty URLs use /{booking_base}/confirmation/{reference}/.
1507 * Plain permalinks use ?yatra_booking_confirmation={reference}.
1508 *
1509 * To use a real WordPress page as the base (advanced), filter {@see 'yatra_booking_confirmation_base_url'}.
1510 * Legacy /booking-confirmation/{reference}/ remains registered in rewrites for old links.
1511 *
1512 * @param string $reference Booking reference segment (may be empty for base URL only).
1513 * @return string Full URL.
1514 */
1515 function yatra_get_booking_confirmation_url(string $reference = ''): string
1516 {
1517 $reference = (string) $reference;
1518 $permalink_structure = get_option('permalink_structure');
1519 $is_plain = empty($permalink_structure);
1520
1521 if ($is_plain) {
1522 if ($reference === '') {
1523 $url = home_url('/');
1524 } else {
1525 $url = add_query_arg('yatra_booking_confirmation', $reference, home_url('/'));
1526 }
1527 } else {
1528 $booking_base = trim((string) SettingsService::getBookingBase(), '/');
1529 if ($booking_base === '') {
1530 $booking_base = 'book';
1531 }
1532 $confirmSeg = trim((string) SettingsService::getPermalinkBases()['booking_flow_confirmation_segment'], '/');
1533 if ($confirmSeg === '') {
1534 $confirmSeg = 'confirmation';
1535 }
1536 $virtual_base = home_url('/' . $booking_base . '/' . $confirmSeg . '/');
1537
1538 /**
1539 * Override the base URL for booking confirmation (before the reference path segment).
1540 * Return a non-empty string to use a custom base (e.g. get_permalink( $page_id )).
1541 * Default null keeps the pageless virtual URL from Settings → booking base.
1542 *
1543 * @param string|null $base_url Custom base, or null to use virtual URL.
1544 * @param string $reference Booking reference (may be empty).
1545 */
1546 $base_url = apply_filters('yatra_booking_confirmation_base_url', null, $reference);
1547 if (!is_string($base_url) || $base_url === '') {
1548 $base_url = $virtual_base;
1549 }
1550
1551 if ($reference === '') {
1552 $url = trailingslashit($base_url);
1553 } else {
1554 $url = trailingslashit($base_url) . $reference . '/';
1555 }
1556 }
1557
1558 /**
1559 * Filter the booking confirmation URL.
1560 *
1561 * @param string $url Built URL.
1562 * @param string $reference Booking reference (may be empty).
1563 */
1564 return (string) apply_filters('yatra_booking_confirmation_url', $url, $reference);
1565 }
1566
1567 /**
1568 * Front-end URL to verify a customer email (checkout registration / account).
1569 *
1570 * Pretty permalinks: /yatra-verify-email/{token}/ (rewrite + query var).
1571 * Plain permalinks: ?yatra_verify_email={token} on the home URL (same as {@see \Yatra\Core\Routing\PermalinkCanonical}).
1572 *
1573 * @param string $secure_token URL-safe token (base64-derived; only [A-Za-z0-9_-] used in the path/query).
1574 */
1575 function yatra_get_email_verification_url(string $secure_token): string
1576 {
1577 $t = preg_replace('/[^a-zA-Z0-9_-]/', '', (string) $secure_token) ?? '';
1578 if ($t === '') {
1579 return home_url('/');
1580 }
1581
1582 $permalink_structure = get_option('permalink_structure');
1583 $is_plain = empty($permalink_structure);
1584
1585 if ($is_plain) {
1586 $url = add_query_arg('yatra_verify_email', $t, home_url('/'));
1587 } else {
1588 $prefix = SettingsService::getPermalinkBases()['email_verification_prefix'];
1589 $url = trailingslashit(home_url('/' . $prefix . '/' . $t . '/'));
1590 }
1591
1592 /**
1593 * Filter the customer email verification URL.
1594 *
1595 * @param string $url Full verification URL.
1596 * @param string $token Sanitized token segment.
1597 */
1598 return (string) apply_filters('yatra_email_verification_url', $url, $t);
1599 }
1600
1601 /**
1602 * ============================================
1603 * ARCHIVE LISTING (plain permalinks pagination)
1604 * ============================================
1605 */
1606
1607 /**
1608 * Items per page from WordPress Reading settings ("Blog pages show at most").
1609 * Used for Yatra front-end listings (trips, taxonomies, activity/destination/category archives).
1610 *
1611 * @return int At least 1.
1612 */
1613 function yatra_get_posts_per_page(): int
1614 {
1615 $n = absint((int) get_option('posts_per_page', 10));
1616
1617 return (int) apply_filters('yatra_posts_per_page', max(1, $n));
1618 }
1619
1620 /**
1621 * Current page number for Yatra archive templates (activity, destination, trip category).
1622 * Handles plain URLs where WordPress may use {@see 'paged'} or {@see 'page'} on the front page.
1623 */
1624 function yatra_get_archive_listing_paged(): int
1625 {
1626 if (isset($_GET['paged']) && $_GET['paged'] !== '') {
1627 return max(1, absint(wp_unslash($_GET['paged'])));
1628 }
1629
1630 if (!empty($_GET['yatra_page']) && isset($_GET['page']) && $_GET['page'] !== '') {
1631 return max(1, absint(wp_unslash($_GET['page'])));
1632 }
1633
1634 $p = (int) get_query_var('paged');
1635 if ($p > 0) {
1636 return max(1, $p);
1637 }
1638
1639 $p = (int) get_query_var('page');
1640
1641 return max(1, $p);
1642 }
1643
1644 /**
1645 * Result summary for destination / activity / trip-category browse pages (parity with trip grid header).
1646 *
1647 * @param string $items_label Plural noun, e.g. translated "destinations".
1648 */
1649 function yatra_archive_browse_results_line(int $start, int $end, int $total, int $page, int $pages, string $items_label): string
1650 {
1651 if ($total <= 0) {
1652 return '';
1653 }
1654
1655 return sprintf(
1656 /* translators: 1–2: range, 3: total, 4: item type, 5–6: pagination */
1657 __('Showing %1$d–%2$d of %3$d %4$s (page %5$d of %6$d)', 'yatra'),
1658 $start,
1659 $end,
1660 $total,
1661 $items_label,
1662 $page,
1663 $pages
1664 );
1665 }
1666
1667 /**
1668 * Request path (leading slash, no query string) for same-page links. Strips /page/N/ pagination segments.
1669 */
1670 function yatra_get_current_request_path_for_query_urls(): string
1671 {
1672 $request_uri = isset($_SERVER['REQUEST_URI']) ? (string) wp_unslash($_SERVER['REQUEST_URI']) : '/';
1673 $base_path = strtok($request_uri, '?') ?: '/';
1674 $base_path = rtrim((string) $base_path, '/');
1675 $base_path = preg_replace('#/page/[0-9]+#', '', $base_path);
1676 $base_path = rtrim($base_path, '/');
1677
1678 if ($base_path === '') {
1679 return '/';
1680 }
1681
1682 return $base_path[0] === '/' ? $base_path : '/' . $base_path;
1683 }
1684
1685 /**
1686 * Full URL for the same archive request with a different page (preserves yatra_page and other args).
1687 * Uses the current request path so /destination/, /activity/, /trip-category/ stay on the same listing.
1688 */
1689 function yatra_build_archive_listing_url(int $page_num): string
1690 {
1691 $params = !empty($_GET) && is_array($_GET) ? wp_unslash($_GET) : [];
1692
1693 $qvYatra = (string) get_query_var('yatra_page');
1694 if ($qvYatra !== '' && (!isset($params['yatra_page']) || $params['yatra_page'] === '')) {
1695 $params['yatra_page'] = $qvYatra;
1696 }
1697
1698 if (!empty($params['yatra_page']) || isset($params['yatra_trip'])) {
1699 unset($params['page']);
1700 }
1701
1702 if ($page_num > 1) {
1703 $params['paged'] = (string) $page_num;
1704 } else {
1705 unset($params['paged'], $params['page']);
1706 }
1707
1708 $path = yatra_get_current_request_path_for_query_urls();
1709 $query = http_build_query($params);
1710
1711 return esc_url($path . ($query !== '' ? '?' . $query : ''));
1712 }
1713
1714 /**
1715 * Same request path with a different paged query arg (strips an existing /page/N/ segment first).
1716 * For taxonomy trip lists and other templates not rooted at home_url('/').
1717 */
1718 function yatra_build_current_request_paged_url(int $page_num): string
1719 {
1720 $page_num = max(1, $page_num);
1721 $params = !empty($_GET) && is_array($_GET) ? wp_unslash($_GET) : [];
1722
1723 if ($page_num > 1) {
1724 $params['paged'] = (string) $page_num;
1725 } else {
1726 unset($params['paged'], $params['page']);
1727 }
1728
1729 $path = yatra_get_current_request_path_for_query_urls();
1730 $query = http_build_query($params);
1731
1732 return esc_url($path . ($query !== '' ? '?' . $query : ''));
1733 }
1734
1735 /**
1736 * Same request path with trip sort (TripRepository / TripListingService). Resets pagination.
1737 *
1738 * @param string $sort Allowed: '' (recommended), most_popular, price_low, price_high, rating_high, duration_short, duration_long.
1739 */
1740 function yatra_build_current_request_sort_url(string $sort): string
1741 {
1742 $allowed = ['', 'most_popular', 'price_low', 'price_high', 'rating_high', 'duration_short', 'duration_long'];
1743 if (!in_array($sort, $allowed, true)) {
1744 $sort = '';
1745 }
1746
1747 $params = !empty($_GET) && is_array($_GET) ? wp_unslash($_GET) : [];
1748 unset($params['paged'], $params['page']);
1749 if ($sort !== '') {
1750 $params['sort'] = $sort;
1751 } else {
1752 unset($params['sort']);
1753 }
1754
1755 $path = yatra_get_current_request_path_for_query_urls();
1756 $query = http_build_query($params);
1757
1758 return esc_url($path . ($query !== '' ? '?' . $query : ''));
1759 }
1760
1761 /**
1762 * Compare two archive listing rows (activity, destination, or category) by sort key.
1763 */
1764 function yatra_compare_archive_listing_row_pair(object $a, object $b, string $sort): int
1765 {
1766 $nameA = isset($a->name) ? strtolower((string) $a->name) : '';
1767 $nameB = isset($b->name) ? strtolower((string) $b->name) : '';
1768 $tripsA = isset($a->trips_count) ? (int) $a->trips_count : 0;
1769 $tripsB = isset($b->trips_count) ? (int) $b->trips_count : 0;
1770 $ratingA = isset($a->avg_rating) ? (float) $a->avg_rating : 0.0;
1771 $ratingB = isset($b->avg_rating) ? (float) $b->avg_rating : 0.0;
1772
1773 switch ($sort) {
1774 case 'trips_desc':
1775 return $tripsB <=> $tripsA;
1776 case 'trips_asc':
1777 return $tripsA <=> $tripsB;
1778 case 'name_asc':
1779 return $nameA <=> $nameB;
1780 case 'name_desc':
1781 return $nameB <=> $nameA;
1782 case 'rating_desc':
1783 default:
1784 $cmp = $ratingB <=> $ratingA;
1785 if (0 === $cmp) {
1786 return $tripsB <=> $tripsA;
1787 }
1788
1789 return $cmp;
1790 }
1791 }
1792
1793 /**
1794 * Invokable comparator for {@see yatra_sort_archive_listing_stats_rows()}.
1795 *
1796 * @internal
1797 */
1798 final class Yatra_Archive_Listing_Stats_Comparator
1799 {
1800 /** @var string */
1801 private $sort;
1802
1803 public function __construct(string $sort)
1804 {
1805 $this->sort = $sort;
1806 }
1807
1808 /**
1809 * @param object $a
1810 * @param object $b
1811 */
1812 public function __invoke($a, $b): int
1813 {
1814 return yatra_compare_archive_listing_row_pair($a, $b, $this->sort);
1815 }
1816 }
1817
1818 /**
1819 * Sort archive listing rows in place (stats objects from repository).
1820 */
1821 function yatra_sort_archive_listing_stats_rows(array &$items, string $sort): void
1822 {
1823 if (empty($items)) {
1824 return;
1825 }
1826
1827 usort($items, new Yatra_Archive_Listing_Stats_Comparator($sort));
1828 }
1829
1830 /**
1831 * Sort dropdown URL: same archive, page reset to 1, yatra_sort applied (preserves yatra_page etc.).
1832 */
1833 function yatra_build_archive_listing_sort_url(string $yatra_sort): string
1834 {
1835 $params = !empty($_GET) && is_array($_GET) ? wp_unslash($_GET) : [];
1836 unset($params['paged'], $params['page']);
1837 if (!empty($params['yatra_page']) || isset($params['yatra_trip'])) {
1838 unset($params['page']);
1839 }
1840 $params['yatra_sort'] = $yatra_sort;
1841
1842 $path = yatra_get_current_request_path_for_query_urls();
1843 $query = http_build_query($params);
1844
1845 return esc_url($path . ($query !== '' ? '?' . $query : ''));
1846 }
1847
1848 /**
1849 * ============================================
1850 * PERMALINK HELPERS
1851 * ============================================
1852 */
1853
1854 /**
1855 * Get destination permalink
1856 *
1857 * @param object|int $destination Destination object with slug property, or destination ID
1858 * @return string Destination permalink URL
1859 */
1860 function yatra_get_destination_permalink($destination): string
1861 {
1862 $original = $destination;
1863
1864 if (is_numeric($destination)) {
1865 global $wpdb;
1866 $table = ClassificationsTable::getTableName();
1867 $destination = $wpdb->get_row($wpdb->prepare(
1868 "SELECT slug FROM {$table} WHERE id = %d AND type = %s",
1869 (int) $destination,
1870 ClassificationTypes::DESTINATION
1871 ));
1872 }
1873
1874 $slug = is_object($destination) ? ($destination->slug ?? '') : '';
1875
1876 if (empty($slug)) {
1877 return '';
1878 }
1879
1880 $base = SettingsService::getDestinationBase();
1881 $permalink_structure = get_option('permalink_structure');
1882 $is_plain = empty($permalink_structure);
1883
1884 if ($is_plain) {
1885 $key = preg_replace('/[^a-z0-9_-]/i', '', $base) ?: 'destination';
1886
1887 $url = add_query_arg([$key => $slug], home_url('/'));
1888 } else {
1889 $url = home_url('/' . $base . '/' . $slug . '/');
1890 }
1891
1892 /** @var string $url Override full destination URL or path (plain/pretty handled above). Third arg: slug. */
1893 return (string) apply_filters('yatra_destination_permalink', $url, $original, $slug);
1894 }
1895
1896 /**
1897 * Get activity permalink
1898 *
1899 * @param object|int $activity Activity object with slug property, or activity ID
1900 * @return string Activity permalink URL
1901 */
1902 function yatra_get_activity_permalink($activity): string
1903 {
1904 $original = $activity;
1905
1906 if (is_numeric($activity)) {
1907 global $wpdb;
1908 $table = ClassificationsTable::getTableName();
1909 $activity = $wpdb->get_row($wpdb->prepare(
1910 "SELECT slug FROM {$table} WHERE id = %d AND type = %s",
1911 (int) $activity,
1912 ClassificationTypes::ACTIVITY
1913 ));
1914 }
1915
1916 $slug = is_object($activity) ? ($activity->slug ?? '') : '';
1917
1918 if (empty($slug)) {
1919 return '';
1920 }
1921
1922 $base = SettingsService::getActivityBase();
1923 $permalink_structure = get_option('permalink_structure');
1924 $is_plain = empty($permalink_structure);
1925
1926 if ($is_plain) {
1927 $key = preg_replace('/[^a-z0-9_-]/i', '', $base) ?: 'activity';
1928
1929 $url = add_query_arg([$key => $slug], home_url('/'));
1930 } else {
1931 $url = home_url('/' . $base . '/' . $slug . '/');
1932 }
1933
1934 /** @var string $url Override full activity URL. Third arg: slug. */
1935 return (string) apply_filters('yatra_activity_permalink', $url, $original, $slug);
1936 }
1937
1938 /**
1939 * Get trip category permalink
1940 *
1941 * @param object|int $category Category object with slug property, or category ID
1942 * @return string Category permalink URL
1943 */
1944 function yatra_get_category_permalink($category): string
1945 {
1946 $original = $category;
1947
1948 if (is_numeric($category)) {
1949 global $wpdb;
1950 $table = ClassificationsTable::getTableName();
1951 $category = $wpdb->get_row($wpdb->prepare(
1952 "SELECT slug FROM {$table} WHERE id = %d AND type = %s",
1953 (int) $category,
1954 ClassificationTypes::CATEGORY
1955 ));
1956 }
1957
1958 $slug = is_object($category) ? ($category->slug ?? '') : '';
1959
1960 if (empty($slug)) {
1961 return '';
1962 }
1963
1964 $base = SettingsService::getTripCategoryBase();
1965 $permalink_structure = get_option('permalink_structure');
1966 $is_plain = empty($permalink_structure);
1967
1968 if ($is_plain) {
1969 $key = preg_replace('/[^a-z0-9_-]/i', '', $base) ?: 'trip-category';
1970
1971 $url = add_query_arg([$key => $slug], home_url('/'));
1972 } else {
1973 $url = home_url('/' . $base . '/' . $slug . '/');
1974 }
1975
1976 /** @var string $url Override full trip-category URL. Third arg: slug. */
1977 return (string) apply_filters('yatra_category_permalink', $url, $original, $slug);
1978 }
1979
1980 /**
1981 * Get trip permalink
1982 *
1983 * @param object|int $trip Trip object with slug property, or trip ID
1984 * @return string Trip permalink URL
1985 */
1986 function yatra_get_trip_permalink($trip): string
1987 {
1988 $original = $trip;
1989
1990 if (is_numeric($trip)) {
1991 global $wpdb;
1992 $table = TripsTable::getTableName();
1993 $trip = $wpdb->get_row($wpdb->prepare(
1994 "SELECT slug FROM {$table} WHERE id = %d",
1995 (int) $trip
1996 ));
1997 }
1998
1999 $slug = is_object($trip) ? ($trip->slug ?? '') : '';
2000
2001 if (empty($slug)) {
2002 return '';
2003 }
2004
2005 $base = SettingsService::getTripBase();
2006 $permalink_structure = get_option('permalink_structure');
2007 $is_plain = empty($permalink_structure);
2008
2009 if ($is_plain) {
2010 $key = preg_replace('/[^a-z0-9_-]/i', '', $base) ?: 'trip';
2011
2012 $url = add_query_arg([$key => $slug], home_url('/'));
2013 } else {
2014 $url = home_url('/' . $base . '/' . $slug . '/');
2015 }
2016
2017 /** @var string $url Override full trip URL. Third arg: slug. */
2018 return (string) apply_filters('yatra_trip_permalink', $url, $original, $slug);
2019 }
2020
2021 /**
2022 * Canonical URL for the trip archive / filter listing (respects Settings trip base).
2023 * Plain permalinks use ?yatra_page={base}; pretty permalinks use /{base}/.
2024 */
2025 function yatra_get_trip_listing_url(): string
2026 {
2027 $base = SettingsService::getTripBase();
2028 $base = preg_replace('/[^a-zA-Z0-9_-]/', '', (string) $base) ?: 'trip';
2029 $permalink_structure = (string) get_option('permalink_structure', '');
2030
2031 if ($permalink_structure === '') {
2032 $url = esc_url(add_query_arg('yatra_page', $base, home_url('/')));
2033 } else {
2034 $url = trailingslashit(home_url('/' . $base . '/'));
2035 }
2036
2037 return (string) apply_filters('yatra_trip_listing_url', $url, $base);
2038 }
2039
2040 /**
2041 * Canonical URL for browse-all taxonomy listings (destinations, activities, trip categories).
2042 * Plain permalinks use ?yatra_page={base}; pretty permalinks use /{base}/.
2043 *
2044 * @param string $listing_type One of: destination, activity, category
2045 */
2046 function yatra_get_taxonomy_listing_url(string $listing_type): string
2047 {
2048 $map = [
2049 'destination' => SettingsService::getDestinationBase(),
2050 'activity' => SettingsService::getActivityBase(),
2051 'category' => SettingsService::getTripCategoryBase(),
2052 ];
2053 $base = $map[$listing_type] ?? '';
2054 $base = preg_replace('/[^a-zA-Z0-9_-]/', '', (string) $base) ?: 'destination';
2055 $permalink_structure = (string) get_option('permalink_structure', '');
2056
2057 if ($permalink_structure === '') {
2058 $url = esc_url(add_query_arg('yatra_page', $base, home_url('/')));
2059 } else {
2060 $url = trailingslashit(home_url('/' . $base . '/'));
2061 }
2062
2063 return (string) apply_filters('yatra_taxonomy_listing_url', $url, $listing_type, $base);
2064 }
2065
2066 /**
2067 * Decode trips.price_types for listing-card logic (DB may store JSON string or array).
2068 *
2069 * @return array<int, array<string, mixed>>
2070 */
2071 function yatra_trip_listing_decode_price_types(object $trip): array
2072 {
2073 $pts = $trip->price_types ?? null;
2074 if (is_string($pts) && $pts !== '') {
2075 $decoded = json_decode($pts, true);
2076 $pts = is_array($decoded) ? $decoded : [];
2077 } elseif (!is_array($pts)) {
2078 $pts = [];
2079 }
2080 if ($pts === [] && method_exists($trip, 'getPriceTypes')) {
2081 $got = $trip->getPriceTypes();
2082 $pts = is_array($got) ? $got : [];
2083 }
2084
2085 return $pts;
2086 }
2087
2088 /**
2089 * Lowercase keys for traveler tier labels (used to strip mis-tagged classifications).
2090 *
2091 * @return array<string, true>
2092 */
2093 function yatra_trip_listing_traveler_tier_label_keys(object $trip): array
2094 {
2095 if (($trip->pricing_type ?? '') !== 'traveler_based') {
2096 return [];
2097 }
2098 $keys = [];
2099 foreach (yatra_trip_listing_decode_price_types($trip) as $pt) {
2100 if (!is_array($pt)) {
2101 continue;
2102 }
2103 foreach (['label', 'category_label', 'title'] as $k) {
2104 if (!empty($pt[$k]) && is_string($pt[$k])) {
2105 $t = strtolower(trim($pt[$k]));
2106 if ($t !== '') {
2107 $keys[$t] = true;
2108 }
2109 break;
2110 }
2111 }
2112 }
2113
2114 return $keys;
2115 }
2116
2117 /**
2118 * Ordered unique labels for the listing card “Traveler types” row.
2119 *
2120 * @return list<string>
2121 */
2122 function yatra_trip_listing_traveler_type_labels_for_card(object $trip): array
2123 {
2124 if (($trip->pricing_type ?? '') !== 'traveler_based') {
2125 return [];
2126 }
2127 $labels = [];
2128 $seen = [];
2129 foreach (yatra_trip_listing_decode_price_types($trip) as $pt) {
2130 if (!is_array($pt)) {
2131 continue;
2132 }
2133 foreach (['label', 'category_label', 'title'] as $k) {
2134 if (!empty($pt[$k]) && is_string($pt[$k])) {
2135 $lab = trim($pt[$k]);
2136 if ($lab === '') {
2137 break;
2138 }
2139 $lk = strtolower($lab);
2140 if (!isset($seen[$lk])) {
2141 $seen[$lk] = true;
2142 $labels[] = $lab;
2143 }
2144 break;
2145 }
2146 }
2147 }
2148
2149 return $labels;
2150 }
2151
2152 /**
2153 * Format start → end for listing cards; avoids repeating the same country when both
2154 * strings are "City, Country".
2155 */
2156 function yatra_format_trip_listing_route_line(string $start, string $end): string
2157 {
2158 $start = trim($start);
2159 $end = trim($end);
2160 if ($start === '') {
2161 return $end;
2162 }
2163 if ($end === '') {
2164 return $start;
2165 }
2166 if (strcasecmp($start, $end) === 0) {
2167 return $start;
2168 }
2169 if (strpos($start, ',') !== false && strpos($end, ',') !== false) {
2170 $s_parts = array_map('trim', explode(',', $start, 2));
2171 $e_parts = array_map('trim', explode(',', $end, 2));
2172 if (count($s_parts) === 2 && count($e_parts) === 2
2173 && strcasecmp($s_parts[1], $e_parts[1]) === 0) {
2174 return $s_parts[0] . ' → ' . $e_parts[0] . ', ' . $s_parts[1];
2175 }
2176 }
2177
2178 return $start . ' → ' . $end;
2179 }
2180
2181 /**
2182 * Human label for trip_type column (listing card meta).
2183 */
2184 function yatra_trip_listing_trip_type_label(?string $trip_type): string
2185 {
2186 $t = (string) $trip_type;
2187 $map = [
2188 'single_day' => __('Single day', 'yatra'),
2189 'multi_day' => __('Multi-day', 'yatra'),
2190 'flexible' => __('Flexible', 'yatra'),
2191 ];
2192
2193 return $map[$t] ?? '';
2194 }
2195
2196 /**
2197 * Rating block for listing cards: prefers SQL aggregates (average_rating, review_count)
2198 * when the hydrated reviews array is empty.
2199 *
2200 * @param array{has_rating: bool, average_rating: float, review_count: int, formatted_rating: string} $from_reviews
2201 * @return array{has_rating: bool, average_rating: float, review_count: int, formatted_rating: string}
2202 */
2203 function yatra_trip_listing_card_rating_data(object $trip, array $from_reviews): array
2204 {
2205 $has = !empty($from_reviews['has_rating']);
2206 $avg = (float) ($from_reviews['average_rating'] ?? 0);
2207 $cnt = (int) ($from_reviews['review_count'] ?? 0);
2208 $fmt = (string) ($from_reviews['formatted_rating'] ?? '0.0');
2209
2210 if ($cnt === 0 || !$has || $avg <= 0) {
2211 $q_avg = isset($trip->average_rating) ? (float) $trip->average_rating : null;
2212 $q_cnt = isset($trip->review_count) ? (int) $trip->review_count : null;
2213 if (($q_cnt === null || $q_cnt === 0) && isset($trip->reviews_count)) {
2214 $q_cnt = (int) $trip->reviews_count;
2215 }
2216 if ($q_cnt !== null && $q_cnt > 0 && $q_avg !== null && $q_avg > 0) {
2217 $avg = round($q_avg, 1);
2218 $cnt = $q_cnt;
2219 $fmt = number_format($avg, 1);
2220 $has = true;
2221 }
2222 }
2223
2224 return [
2225 'has_rating' => $has && $avg > 0 && $cnt > 0,
2226 'average_rating' => $avg,
2227 'review_count' => $cnt,
2228 'formatted_rating' => $fmt,
2229 ];
2230 }
2231
2232 /**
2233 * Avoid repeating the same classification label in the destination, activity, and category
2234 * rows on listing cards (traveler tier labels wrongly linked as classifications, or same
2235 * term attached in multiple roles).
2236 *
2237 * @param array<int, object> $destinations
2238 * @param array<int, object> $activities
2239 * @param array<int, object> $categories
2240 * @return array{0: array<int, object>, 1: array<int, object>, 2: array<int, object>}
2241 */
2242 function yatra_trip_listing_filter_classification_duplicates(array $destinations, array $activities, array $categories, object $trip): array
2243 {
2244 $tier_keys = yatra_trip_listing_traveler_tier_label_keys($trip);
2245
2246 $strip_tiers = static function (array $items) use ($tier_keys): array {
2247 if ($tier_keys === []) {
2248 return $items;
2249 }
2250
2251 return array_values(array_filter($items, static function ($item) use ($tier_keys) {
2252 $n = strtolower(trim((string) ($item->name ?? '')));
2253
2254 return $n === '' || !isset($tier_keys[$n]);
2255 }));
2256 };
2257
2258 $destinations = $strip_tiers($destinations);
2259 $activities = $strip_tiers($activities);
2260 $categories = $strip_tiers($categories);
2261
2262 $seen = [];
2263 $dedupe = static function (array $items) use (&$seen): array {
2264 $out = [];
2265 foreach ($items as $item) {
2266 $n = strtolower(trim((string) ($item->name ?? '')));
2267 if ($n === '') {
2268 $out[] = $item;
2269 continue;
2270 }
2271 if (isset($seen[$n])) {
2272 continue;
2273 }
2274 $seen[$n] = true;
2275 $out[] = $item;
2276 }
2277
2278 return $out;
2279 };
2280
2281 $destinations = $dedupe($destinations);
2282 $activities = $dedupe($activities);
2283 $categories = $dedupe($categories);
2284
2285 return [$destinations, $activities, $categories];
2286 }
2287
2288 /**
2289 * Check if we're on a trip listing page
2290 *
2291 * @return bool True if on a trip listing page
2292 */
2293 function yatra_is_trip_listing(): bool
2294 {
2295 global $yatra_trip_list;
2296
2297 // Check for trip list context (base trip listing page)
2298 if (!empty($yatra_trip_list)) {
2299 return true;
2300 }
2301
2302 // Check if we're on the main trips listing page
2303 $trip_base = SettingsService::getTripBase();
2304 $request_uri = $_SERVER['REQUEST_URI'] ?? '';
2305 $parsed_url = parse_url($request_uri, PHP_URL_PATH);
2306
2307 if ($parsed_url && strpos($parsed_url, '/' . $trip_base) === 0) {
2308 $path_parts = array_values(array_filter(explode('/', trim($parsed_url, '/'))));
2309 if ($path_parts === [] || ($path_parts[0] ?? '') !== $trip_base) {
2310 return false;
2311 }
2312 // /trip/ or /trip/page/2/ (WordPress paged archives)
2313 if (count($path_parts) === 1) {
2314 return true;
2315 }
2316 if (count($path_parts) === 3 && ($path_parts[1] ?? '') === 'page' && ctype_digit((string) ($path_parts[2] ?? ''))) {
2317 return true;
2318 }
2319 }
2320
2321 return false;
2322 }
2323
2324 /**
2325 * Check if we're on a taxonomy page (destination, activity, category)
2326 *
2327 * @return bool True if on a taxonomy page
2328 */
2329 function yatra_is_taxonomy_page(): bool
2330 {
2331 global $yatra_taxonomy_data;
2332 return !empty($yatra_taxonomy_data);
2333 }
2334
2335 /**
2336 * Check if we're on an activity listing page
2337 *
2338 * @return bool True if on an activity listing page
2339 */
2340 function yatra_is_activity_listing(): bool
2341 {
2342 return isset($_GET['yatra_page_type']) && $_GET['yatra_page_type'] === 'activities';
2343 }
2344
2345 /**
2346 * Check if we're on a destination listing page
2347 *
2348 * @return bool True if on a destination listing page
2349 */
2350 function yatra_is_destination_listing(): bool
2351 {
2352 return isset($_GET['yatra_page_type']) && $_GET['yatra_page_type'] === 'destinations';
2353 }
2354
2355 /**
2356 * Check if we're on an account page
2357 *
2358 * @return bool True if on an account page
2359 */
2360 function yatra_is_account_page(): bool
2361 {
2362 if (!empty($GLOBALS['yatra_loading_react_account_page'])) {
2363 return true;
2364 }
2365
2366 if ((string) get_query_var('yatra_account_page') !== '') {
2367 return true;
2368 }
2369
2370 global $post;
2371 if ($post && function_exists('has_shortcode') && isset($post->post_content)
2372 && has_shortcode((string) $post->post_content, 'yatra_my_account')) {
2373 return true;
2374 }
2375
2376 if (!$post) {
2377 return false;
2378 }
2379
2380 $accountPageId = get_option('yatra_my_account_page');
2381 return $accountPageId && (int) $post->ID === (int) $accountPageId;
2382 }
2383
2384 /**
2385 * Get difficulty level permalink
2386 *
2387 * @param object|int $difficulty Difficulty object with slug property, or difficulty ID
2388 * @return string Difficulty permalink URL
2389 */
2390 function yatra_get_difficulty_permalink($difficulty): string
2391 {
2392 if (is_numeric($difficulty)) {
2393 global $wpdb;
2394 $table = ClassificationsTable::getTableName();
2395 $difficulty = $wpdb->get_row($wpdb->prepare(
2396 "SELECT slug FROM {$table} WHERE id = %d AND type = %s",
2397 (int) $difficulty,
2398 ClassificationTypes::DIFFICULTY
2399 ));
2400 }
2401
2402 $slug = is_object($difficulty) ? ($difficulty->slug ?? '') : '';
2403
2404 if (empty($slug)) {
2405 return '';
2406 }
2407
2408 $base = SettingsService::getString('difficulty_base', 'difficulty');
2409
2410 return home_url('/' . $base . '/' . $slug . '/');
2411 }
2412
2413 /**
2414 * Load a template file with theme override support
2415 *
2416 * This function allows themes to override plugin templates by placing them in:
2417 * theme/yatra/template-name.php
2418 *
2419 * If no theme override exists, loads from plugin templates directory.
2420 *
2421 * @param string $template_name Template file name (without .php extension)
2422 * @param array $args Arguments to extract and make available in template
2423 * @param string $template_path Template path within plugin (default: 'templates/')
2424 * @param array $data Alternative data array (won't be extracted, available as $data)
2425 * @return void
2426 */
2427 function yatra_get_template(string $template_name, array $args = [], string $template_path = 'templates/', array $data = []): void
2428 {
2429 $template_name = ltrim($template_name, '/');
2430
2431 // Check if theme has override
2432 $theme_template = locate_template([
2433 'yatra/' . $template_name . '.php',
2434 'yatra/' . $template_name
2435 ]);
2436
2437 if ($theme_template) {
2438 // Load from theme
2439 $template_file = $theme_template;
2440 } else {
2441 // Load from plugin
2442 $template_file = YATRA_PLUGIN_PATH . ltrim($template_path, '/') . '/' . $template_name . '.php';
2443 }
2444
2445 // Extract arguments to make them available as individual variables
2446 if (!empty($args)) {
2447 extract($args);
2448 }
2449
2450 // Make data available as $data array (not extracted)
2451 if (!empty($data)) {
2452 $data = $data;
2453 }
2454
2455 // Include the template
2456 if (file_exists($template_file)) {
2457 include $template_file;
2458 }
2459 }
2460
2461 /**
2462 * Enqueue single trip scripts and styles
2463 *
2464 * @return void
2465 */
2466 function yatra_enqueue_single_trip_scripts(): void
2467 {
2468 // Only enqueue on single trip pages
2469 if (!is_single() || get_post_type() !== 'trip') {
2470 return;
2471 }
2472
2473 // Enqueue the single trip JavaScript
2474 wp_enqueue_script(
2475 'yatra-single-trip',
2476 YATRA_PLUGIN_URL . 'assets/js/single-trip.js',
2477 ['jquery', 'yatra-trip'],
2478 YATRA_VERSION,
2479 true
2480 );
2481
2482 // Localize script data
2483 global $trip;
2484 if ($trip) {
2485 wp_localize_script(
2486 'yatra-single-trip',
2487 'yatraSingleTripData',
2488 [
2489 'tripId' => (int) $trip->id,
2490 'basePrice' => (float) ($trip->base_price ?? 0),
2491 'currencySymbol' => yatra_get_currency_symbol(\Yatra\Services\SettingsService::getCurrency()),
2492 'apiUrls' => [
2493 'groupDiscounts' => rest_url('yatra/v1/discounts/group-discounts')
2494 ]
2495 ]
2496 );
2497 }
2498 }
2499
2500 /**
2501 * Calculate base price for single trip display using CalculationService
2502 *
2503 * @param object $trip Trip object
2504 * @return array Pricing data including base_price, has_availability, has_traveler_pricing, pricing_type
2505 */
2506 function yatra_single_trip_calculate_base_price($trip) {
2507 // Check if availability dates exist (PRIORITY)
2508 $has_availability = !empty($trip->availability_dates) && is_array($trip->availability_dates) && count($trip->availability_dates) > 0;
2509
2510 // Determine pricing type from trip settings
2511 $pricing_type = $trip->pricing_type ?? 'regular';
2512 $has_traveler_pricing = ($pricing_type === 'traveler_based' && !empty($trip->price_types));
2513
2514 // Use CalculationService for consistent pricing
2515 $calculationService = new \Yatra\Services\CalculationService();
2516
2517 // Determine base price using CalculationService logic
2518 $trip_price = 0;
2519
2520 if ($has_availability) {
2521 // Page-load pricing priority (traveler-based):
2522 // - If a default category is marked at trip-level, use that as the base price.
2523 // - Otherwise fall back to lowest price across availability (legacy behavior).
2524 $default_trip_price = 0.0;
2525 if ($has_traveler_pricing && !empty($trip->price_types) && is_array($trip->price_types)) {
2526 $default_price_type = null;
2527 foreach ($trip->price_types as $pt) {
2528 if (is_array($pt)) {
2529 $pt = (object) $pt;
2530 }
2531 if (!empty($pt->is_default)) {
2532 $default_price_type = $pt;
2533 break;
2534 }
2535 }
2536 if ($default_price_type) {
2537 $default_trip_price = (float) ($default_price_type->effective_price
2538 ?? $default_price_type->discounted_price
2539 ?? $default_price_type->original_price
2540 ?? 0);
2541 }
2542 }
2543
2544 if ($default_trip_price > 0) {
2545 $trip_price = $default_trip_price;
2546 } else {
2547 // Get the lowest price from availability dates
2548 $min_price = PHP_FLOAT_MAX;
2549 foreach ($trip->availability_dates as $avail) {
2550 $avail_price = $avail->effective_price ?? $avail->original_price ?? 0;
2551 if ($avail_price > 0 && $avail_price < $min_price) {
2552 $min_price = $avail_price;
2553 }
2554
2555 // Also check price_types within availability if traveler-based
2556 if (!empty($avail->price_types) && is_array($avail->price_types)) {
2557 foreach ($avail->price_types as $pt) {
2558 $pt = (object)$pt;
2559 $pt_price = (float)($pt->effective_price ?? $pt->discounted_price ?? $pt->original_price ?? 0);
2560 if ($pt_price > 0 && $pt_price < $min_price) {
2561 $min_price = $pt_price;
2562 }
2563 }
2564 }
2565 }
2566
2567 // If no price found from availability, check traveler-based pricing
2568 if ($min_price >= PHP_FLOAT_MAX && $has_traveler_pricing) {
2569 foreach ($trip->price_types as $pt) {
2570 $pt = is_array($pt) ? (object) $pt : $pt;
2571 $pt_price = (float)($pt->effective_price ?? $pt->discounted_price ?? $pt->original_price ?? 0);
2572 if ($pt_price > 0 && $pt_price < $min_price) {
2573 $min_price = $pt_price;
2574 }
2575 }
2576 }
2577
2578 $trip_price = ($min_price < PHP_FLOAT_MAX) ? $min_price : ($trip->sale_price ?: $trip->original_price);
2579 }
2580 } elseif ($has_traveler_pricing) {
2581 // Get default or first traveler category price
2582 $default_price_type = null;
2583 foreach ($trip->price_types as $pt) {
2584 if (!empty($pt->is_default)) {
2585 $default_price_type = $pt;
2586 break;
2587 }
2588 }
2589 if (!$default_price_type && !empty($trip->price_types)) {
2590 $default_price_type = $trip->price_types[0];
2591 }
2592
2593 // Get the price from the price type - check multiple possible fields
2594 if ($default_price_type) {
2595 $trip_price = 0;
2596 // Try effective_price first, then discounted_price, then original_price
2597 if (!empty($default_price_type->effective_price) && $default_price_type->effective_price > 0) {
2598 $trip_price = (float)$default_price_type->effective_price;
2599 } elseif (!empty($default_price_type->discounted_price) && $default_price_type->discounted_price > 0) {
2600 $trip_price = (float)$default_price_type->discounted_price;
2601 } elseif (!empty($default_price_type->original_price) && $default_price_type->original_price > 0) {
2602 $trip_price = (float)$default_price_type->original_price;
2603 } elseif (!empty($default_price_type->sale_price) && $default_price_type->sale_price > 0) {
2604 $trip_price = (float)$default_price_type->sale_price;
2605 }
2606
2607 // If still no price, try to get the minimum from all price types
2608 if ($trip_price <= 0) {
2609 foreach ($trip->price_types as $pt) {
2610 $pt_price = (float)($pt->effective_price ?? $pt->discounted_price ?? $pt->original_price ?? 0);
2611 if ($pt_price > 0 && ($trip_price <= 0 || $pt_price < $trip_price)) {
2612 $trip_price = $pt_price;
2613 }
2614 }
2615 }
2616 } else {
2617 $trip_price = $trip->sale_price ?: $trip->original_price;
2618 }
2619 } else {
2620 // Regular pricing
2621 $trip_price = $trip->sale_price > 0 ? $trip->sale_price : $trip->original_price;
2622 }
2623
2624 // Apply CalculationService filter for dynamic pricing (pro plugins)
2625 $base_price = apply_filters('yatra_calculate_base_amount', $trip_price, [
2626 'trip_price' => $trip_price,
2627 'travelers_count' => 1,
2628 'traveler_counts' => ['default' => 1],
2629 'pricing_type' => $pricing_type,
2630 'price_types' => $trip->price_types ?? [],
2631 'trip_id' => $trip->id ?? 0
2632 ]);
2633
2634 return [
2635 'base_price' => $base_price,
2636 'has_availability' => $has_availability,
2637 'has_traveler_pricing' => $has_traveler_pricing,
2638 'pricing_type' => $pricing_type
2639 ];
2640 }
2641
2642 /**
2643 * Get group discounts data for single trip
2644 *
2645 * @param int $trip_id Trip ID
2646 * @return array Group discounts data including has_group_discounts and group_discounts_data
2647 */
2648 function yatra_single_trip_get_group_discounts($trip_id) {
2649 $has_group_discounts = false;
2650 $group_discounts_data = [];
2651 $trip_id = (int) $trip_id;
2652
2653 if ($trip_id <= 0) {
2654 return [
2655 'has_group_discounts' => false,
2656 'group_discounts_data' => [],
2657 ];
2658 }
2659
2660 try {
2661 // Direct controller path avoids rest_do_request / loopback issues on single-trip templates.
2662 if (class_exists(\Yatra\Controllers\DiscountController::class)) {
2663 $ctrl = new \Yatra\Controllers\DiscountController();
2664 $payload = $ctrl->getPublicGroupDiscountDiscoverabilityForTrip($trip_id);
2665 $discounts = isset($payload['discounts']) && is_array($payload['discounts']) ? $payload['discounts'] : [];
2666 if (!empty($payload['has_group_discounts']) && $discounts !== []) {
2667 return [
2668 'has_group_discounts' => true,
2669 'group_discounts_data' => $discounts,
2670 ];
2671 }
2672 }
2673
2674 $row = null;
2675
2676 // Fallback: internal REST then HTTP (e.g. if controller unavailable).
2677 if (class_exists('\WP_REST_Request') && function_exists('rest_do_request')) {
2678 $request = new \WP_REST_Request('GET', '/yatra/v1/discounts/group-discounts');
2679 $request->set_param('trip_ids', [$trip_id]);
2680 $rest_response = rest_do_request($request);
2681 if ($rest_response instanceof \WP_REST_Response && $rest_response->get_status() === 200) {
2682 $row = yatra_single_trip_parse_group_discounts_payload($rest_response->get_data(), $trip_id);
2683 }
2684 }
2685
2686 if (!is_array($row)) {
2687 $api_url = add_query_arg(
2688 ['trip_ids' => [$trip_id]],
2689 rest_url('yatra/v1/discounts/group-discounts')
2690 );
2691 $response = wp_remote_get($api_url, [
2692 'timeout' => 6,
2693 'headers' => [
2694 'Accept' => 'application/json',
2695 ],
2696 ]);
2697
2698 if (!is_wp_error($response) && wp_remote_retrieve_response_code($response) === 200) {
2699 $data = json_decode(wp_remote_retrieve_body($response), true);
2700 $row = yatra_single_trip_parse_group_discounts_payload($data, $trip_id);
2701 }
2702 }
2703
2704 if (is_array($row) && !empty($row['has_group_discounts']) && !empty($row['discounts']) && is_array($row['discounts'])) {
2705 $has_group_discounts = true;
2706 $group_discounts_data = $row['discounts'];
2707 }
2708 } catch (Exception $e) {
2709 $has_group_discounts = false;
2710 }
2711
2712 return [
2713 'has_group_discounts' => $has_group_discounts,
2714 'group_discounts_data' => $group_discounts_data,
2715 ];
2716 }
2717
2718 /**
2719 * Extract the per-trip object from a group-discounts REST payload (handles optional wrappers).
2720 *
2721 * @param mixed $data
2722 * @return array<string, mixed>|null
2723 */
2724 function yatra_single_trip_parse_group_discounts_payload($data, int $trip_id): ?array {
2725 if (!is_array($data)) {
2726 return null;
2727 }
2728 if (isset($data['data']) && is_array($data['data'])) {
2729 $data = $data['data'];
2730 }
2731 $keyStr = (string) $trip_id;
2732 $row = $data[$trip_id] ?? $data[$keyStr] ?? null;
2733
2734 return is_array($row) ? $row : null;
2735 }
2736
2737 /**
2738 * Payload for single-trip booking UI JS (sidebar date/traveler pricing + group tiers).
2739 * Kept in yatraTripData instead of large HTML data-* attributes on .yatra-booking-card.
2740 *
2741 * @param object $trip Trip model
2742 * @return array{pricingType: string, sidebarAvailability: array<int, array<string, mixed>>, sidebarGroupDiscounts: array<int, array<string, mixed>>}
2743 */
2744 function yatra_single_trip_get_client_booking_payload($trip): array {
2745 $empty = [
2746 'pricingType' => 'regular',
2747 'sidebarAvailability' => [],
2748 'sidebarGroupDiscounts' => [],
2749 ];
2750
2751 if (!is_object($trip) || empty($trip->id)) {
2752 return $empty;
2753 }
2754
2755 $pricing_data = function_exists('yatra_single_trip_calculate_base_price')
2756 ? yatra_single_trip_calculate_base_price($trip)
2757 : ['has_availability' => false, 'pricing_type' => $trip->pricing_type ?? 'regular'];
2758
2759 $pricing_type = (string) ($pricing_data['pricing_type'] ?? ($trip->pricing_type ?? 'regular'));
2760 $has_availability = !empty($pricing_data['has_availability']);
2761
2762 $availability = [];
2763 if ($has_availability && method_exists($trip, 'getAvailabilityDates')) {
2764 foreach ($trip->getAvailabilityDates() as $avail) {
2765 if (!is_object($avail)) {
2766 continue;
2767 }
2768 $price_types_raw = !empty($avail->price_types) && is_array($avail->price_types) ? $avail->price_types : [];
2769 $price_types = [];
2770 foreach ($price_types_raw as $pt) {
2771 if (is_object($pt)) {
2772 $decoded = json_decode(wp_json_encode($pt), true);
2773 $price_types[] = is_array($decoded) ? $decoded : [];
2774 } elseif (is_array($pt)) {
2775 $price_types[] = $pt;
2776 }
2777 }
2778
2779 $availability[] = [
2780 'id' => (int) ($avail->id ?? 0),
2781 'date' => $avail->departure_date ?? '',
2782 'departure_date' => $avail->departure_date ?? '',
2783 'return_date' => (isset($avail->return_date) && $avail->return_date !== '')
2784 ? $avail->return_date
2785 : (isset($avail->arrival_date) ? $avail->arrival_date : null),
2786 'price' => $avail->effective_price ?? $avail->original_price ?? 0,
2787 'original_price' => $avail->original_price ?? 0,
2788 'discounted_price' => $avail->discounted_price ?? null,
2789 'seats_available' => $avail->seats_available ?? 0,
2790 'seats_total' => $avail->seats_total ?? 0,
2791 'status' => $avail->status ?? '',
2792 'is_limited' => (bool) ($avail->is_limited ?? false),
2793 'is_sold_out' => (bool) ($avail->is_sold_out ?? false),
2794 'pricing_type' => $price_types !== [] ? 'traveler_based' : $pricing_type,
2795 'price_types' => $price_types,
2796 ];
2797 }
2798 }
2799
2800 $sidebar_group_discounts = [];
2801 if (function_exists('yatra_single_trip_get_group_discounts')) {
2802 $gd = yatra_single_trip_get_group_discounts((int) $trip->id);
2803 $cards = isset($gd['group_discounts_data']) && is_array($gd['group_discounts_data'])
2804 ? $gd['group_discounts_data']
2805 : [];
2806 $sidebar_group_discounts = apply_filters('yatra_advanced_discount_enabled', false) ? $cards : [];
2807 $sidebar_group_discounts = array_values(array_map(static function ($row) {
2808 if (is_object($row)) {
2809 $decoded = json_decode(wp_json_encode($row), true);
2810
2811 return is_array($decoded) ? $decoded : [];
2812 }
2813
2814 return $row;
2815 }, $sidebar_group_discounts));
2816 }
2817
2818 return [
2819 'pricingType' => $pricing_type,
2820 'sidebarAvailability' => $availability,
2821 'sidebarGroupDiscounts' => $sidebar_group_discounts,
2822 ];
2823 }
2824
2825 // Hook into WordPress enqueue system
2826 add_action('wp_enqueue_scripts', 'yatra_enqueue_single_trip_scripts');
2827
2828 // Yatra page type detection functions
2829 if (!function_exists('yatra_is_trip_page')) {
2830 function yatra_is_trip_page() {
2831 global $trip;
2832 return isset($trip) && !empty($trip);
2833 }
2834 }
2835
2836 if (!function_exists('yatra_is_destination_page')) {
2837 function yatra_is_destination_page() {
2838 global $destination, $yatra_taxonomy_data;
2839
2840 // Check direct global first
2841 if (isset($destination) && !empty($destination)) {
2842 return true;
2843 }
2844
2845 // Check taxonomy data
2846 if (isset($yatra_taxonomy_data) && !empty($yatra_taxonomy_data) && $yatra_taxonomy_data->type === 'destination') {
2847 return true;
2848 }
2849
2850 return false;
2851 }
2852 }
2853
2854 if (!function_exists('yatra_is_activity_page')) {
2855 function yatra_is_activity_page() {
2856 global $activity, $yatra_taxonomy_data;
2857
2858 // Check direct global first
2859 if (isset($activity) && !empty($activity)) {
2860 return true;
2861 }
2862
2863 // Check taxonomy data
2864 if (isset($yatra_taxonomy_data) && !empty($yatra_taxonomy_data) && $yatra_taxonomy_data->type === 'activity') {
2865 return true;
2866 }
2867
2868 return false;
2869 }
2870 }
2871
2872 if (!function_exists('yatra_is_category_page')) {
2873 function yatra_is_category_page() {
2874 global $category, $yatra_taxonomy_data;
2875
2876 // Check direct global first
2877 if (isset($category) && !empty($category)) {
2878 return true;
2879 }
2880
2881 // Check taxonomy data
2882 if (isset($yatra_taxonomy_data) && !empty($yatra_taxonomy_data) && $yatra_taxonomy_data->type === 'category') {
2883 return true;
2884 }
2885
2886 return false;
2887 }
2888 }
2889
2890 if (!function_exists('yatra_is_trip_archive_page')) {
2891 function yatra_is_trip_archive_page() {
2892 $current_url = $_SERVER['REQUEST_URI'] ?? '';
2893 $current_path = parse_url($current_url, PHP_URL_PATH) ?? '';
2894 $trip_base = \Yatra\Services\SettingsService::getTripBase();
2895
2896 // Check for both /trip/ and /trip patterns
2897 $pattern1 = '/' . $trip_base . '/';
2898 $pattern2 = '/' . $trip_base;
2899
2900 return (strpos($current_path, $pattern1) !== false || $current_path === $pattern2) && !yatra_is_trip_page();
2901 }
2902 }
2903
2904 // Yatra only has trip archive pages - no destination/activity/category archive pages
2905
2906 if (!function_exists('yatra_is_listing_page')) {
2907 function yatra_is_listing_page() {
2908 $current_url = $_SERVER['REQUEST_URI'] ?? '';
2909 $current_path = parse_url($current_url, PHP_URL_PATH) ?? '';
2910 return strpos($current_path, '/listing-') !== false;
2911 }
2912 }
2913
2914 if (!function_exists('yatra_is_yatra_page')) {
2915 function yatra_is_yatra_page() {
2916 return yatra_is_trip_page() ||
2917 yatra_is_destination_page() ||
2918 yatra_is_activity_page() ||
2919 yatra_is_category_page() ||
2920 yatra_is_trip_archive_page() ||
2921 yatra_is_listing_page();
2922 }
2923 }
2924
2925 if ( ! function_exists( 'yatra_get_header' ) ) {
2926
2927 function yatra_get_header( $header_name = null ) {
2928 global $wp_version;
2929
2930 // When the template is being rendered as the body of the yatra/page-content
2931 // server block inside a block-template canvas, the canvas already emits the
2932 // doctype/html/head/body and the site header template part. Re-emitting them
2933 // here would nest <html>/<body> and duplicate the header — so we no-op.
2934 if (
2935 class_exists( '\\Yatra\\Core\\Template\\FseTemplates' )
2936 && \Yatra\Core\Template\FseTemplates::isRenderingInsideCanvas()
2937 ) {
2938 return;
2939 }
2940
2941 if (
2942 version_compare( $wp_version, '5.9', '>=' ) &&
2943 function_exists( 'wp_is_block_theme' ) &&
2944 wp_is_block_theme()
2945 ) {
2946 /*
2947 * Full-site editing themes often omit add_theme_support( 'title-tag' ); the document title is
2948 * injected via template canvas using _block_template_render_title_tag (unconditional). Yatra
2949 * renders this minimal head instead of canvas, so _wp_render_title_tag would no-op and the
2950 * page would have no <title>. Mirror canvas: print title here and drop duplicate core hooks.
2951 */
2952 remove_action( 'wp_head', '_wp_render_title_tag', 1 );
2953 remove_action( 'wp_head', '_block_template_render_title_tag', 1 );
2954 ?>
2955 <!doctype html>
2956 <html <?php language_attributes(); ?>>
2957 <head>
2958 <meta charset="<?php bloginfo( 'charset' ); ?>">
2959 <title><?php echo esc_html( wp_get_document_title() ); ?></title>
2960 <?php wp_head(); ?>
2961 </head>
2962
2963 <body <?php body_class(); ?>>
2964 <?php wp_body_open(); ?>
2965 <div class="wp-site-blocks">
2966 <header class="wp-block-template-part site-header">
2967 <?php block_header_area(); ?>
2968 </header>
2969 <?php
2970 } else {
2971 get_header( $header_name );
2972 }
2973 }
2974 }
2975
2976 if ( ! function_exists( 'yatra_block_support_styles' ) ) {
2977 function yatra_block_support_styles() {
2978 // Bail early if function does not exists.
2979 if ( ! function_exists( 'wp_style_engine_get_stylesheet_from_context' ) ) {
2980 return;
2981 }
2982
2983 $core_styles_keys = array( 'block-supports' );
2984
2985 $compiled_core_stylesheet = '';
2986
2987 foreach ( $core_styles_keys as $style_key ) {
2988 $compiled_core_stylesheet .= wp_style_engine_get_stylesheet_from_context( $style_key, array() );
2989 }
2990
2991 if ( empty( $compiled_core_stylesheet ) ) {
2992 return;
2993 }
2994
2995 wp_register_style( 'yatra-block-supports', false );
2996 wp_enqueue_style( 'yatra-block-supports' );
2997 wp_add_inline_style( 'yatra-block-supports', $compiled_core_stylesheet );
2998 }
2999 }
3000
3001 if ( ! function_exists( 'yatra_get_footer' ) ) {
3002
3003 function yatra_get_footer( $footer_name = null ) {
3004 global $wp_version;
3005
3006 // Mirror of yatra_get_header(): when rendered inside the FSE canvas via
3007 // the yatra/page-content block, the canvas already emits the footer
3008 // template part and closes <body>/<html>. No-op here to avoid duplicates.
3009 if (
3010 class_exists( '\\Yatra\\Core\\Template\\FseTemplates' )
3011 && \Yatra\Core\Template\FseTemplates::isRenderingInsideCanvas()
3012 ) {
3013 return;
3014 }
3015
3016 if (
3017 version_compare( $wp_version, '5.9', '>=' ) &&
3018 function_exists( 'wp_is_block_theme' ) &&
3019 wp_is_block_theme()
3020 ) {
3021 ?>
3022 <footer class="wp-block-template-part site-footer">
3023 <?php block_footer_area(); ?>
3024 </footer>
3025 </div>
3026 <?php yatra_block_support_styles(); ?>
3027 <?php wp_footer(); ?>
3028 </body>
3029 </html>
3030 <?php
3031 } else {
3032 get_footer( $footer_name );
3033 }
3034 }
3035 }
3036
3037 /**
3038 * Render tab icon (supports both SVG icons and images)
3039 *
3040 * @param mixed $icon_data Icon data (string, array, or object)
3041 * @param string $default_icon Default icon name
3042 * @param string $css_class CSS class for the icon
3043 * @param string $label Label for alt text
3044 * @return void Echoes the icon HTML
3045 */
3046 if (!function_exists('yatra_render_tab_icon')) {
3047 function yatra_render_tab_icon($icon_data, $default_icon = 'book', $css_class = '', $label = '') {
3048 if (empty($icon_data)) {
3049 echo function_exists('yatra_svg_icon') ? yatra_svg_icon($default_icon, $css_class) : '';
3050
3051 return;
3052 }
3053 if (is_string($icon_data) && strpos($icon_data, '{') === 0) {
3054 $icon_data = json_decode($icon_data, true);
3055 }
3056 if (is_object($icon_data)) {
3057 $icon_data = (array) $icon_data;
3058 }
3059 if (is_array($icon_data) && isset($icon_data['type']) && $icon_data['type'] === 'image' && !empty($icon_data['value'])) {
3060 $image_url = is_numeric($icon_data['value'])
3061 ? wp_get_attachment_url((int) $icon_data['value'])
3062 : $icon_data['value'];
3063 if ($image_url) {
3064 $size_style = strpos($css_class, 'sticky-nav') !== false ? 'width: 18px; height: 18px;' : 'width: 24px; height: 24px;';
3065 echo '<img src="' . esc_url($image_url) . '" alt="' . esc_attr($label) . '" class="' . esc_attr($css_class) . '" style="' . esc_attr($size_style) . ' object-fit: cover; border-radius: 4px;">';
3066
3067 return;
3068 }
3069 }
3070 // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- markup built from sanitized picker / SVG registry
3071 echo yatra_stored_picker_icon_markup($icon_data, $default_icon, $css_class);
3072 }
3073 }
3074
3075 if (!function_exists('yatra_listing_sidebar_filter_visible_cap')) {
3076 /**
3077 * How many sidebar checkbox rows to show before "Show more" on the trip listing.
3078 *
3079 * Filter: {@see 'yatra_listing_sidebar_filter_visible_count'} — default 8, clamped 3–40.
3080 *
3081 * @return int
3082 */
3083 function yatra_listing_sidebar_filter_visible_cap(): int
3084 {
3085 $n = (int) apply_filters('yatra_listing_sidebar_filter_visible_count', 8);
3086
3087 return max(3, min(40, $n));
3088 }
3089 }
3090
3091 if (!function_exists('yatra_wishlist_enabled')) {
3092 /**
3093 * Whether wishlist UI and REST should be active (Yatra Pro + setting).
3094 */
3095 function yatra_wishlist_enabled(): bool
3096 {
3097 return \Yatra\Services\SettingsService::wishlistEnabled();
3098 }
3099 }
3100
3101 if (!function_exists('yatra_usage_track_event')) {
3102 /**
3103 * Record an anonymous product telemetry event (requires opt-in).
3104 *
3105 * @param string $event Event key (sanitized).
3106 * @param int $delta Counter increment.
3107 */
3108 function yatra_usage_track_event(string $event, int $delta = 1): void
3109 {
3110 if (!class_exists(\Yatra\Services\StatsUsage::class)) {
3111 return;
3112 }
3113 \Yatra\Services\StatsUsage::instance()->record_event($event, $delta);
3114 }
3115 }
3116