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

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

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