PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.10
Yatra – Travel Booking & Tour Operator Software v3.0.10
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.10, at includes/helpers.php

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