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

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

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