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

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

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