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

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

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