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
← All changes | includes/helpers.php +1134 -132 3.0.2.9 → 3.0.16 View file →
@@ -54,9 +54,15 @@
54 54 * Get booking form configuration
55 55 *
56 56 * @return array
57 57 */
58 -function yatra_get_booking_form_config(): array
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
59 65 {
60 66 // Check if Dynamic Form Field module is enabled via Pro plugin
61 67 $is_dynamic_enabled = apply_filters('yatra_dynamic_form_field_enabled', false);
62 68
@@ -61,19 +67,47 @@
61 67 $is_dynamic_enabled = apply_filters('yatra_dynamic_form_field_enabled', false);
62 68
63 69 if ($is_dynamic_enabled) {
64 70 // Pro module is active — merged config from options (filtered in SettingsService::getBookingFormConfig)
65 - return SettingsService::getBookingFormConfig();
71 + return SettingsService::getBookingFormConfig($tripId);
66 72 }
67 73
68 74 // Module off: still allow filters to adjust defaults (tests / edge integrations)
69 75 return apply_filters(
70 76 'yatra_booking_form_config',
71 - SettingsService::getDefaultBookingFormConfig()
77 + SettingsService::getDefaultBookingFormConfig(),
78 + $tripId
72 79 );
73 80 }
74 81
75 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 +/**
76 110 * Check if user can leave a review for a trip
77 111 *
78 112 * @param int $trip_id Trip ID
79 113 * @param int|null $user_id User ID (defaults to current user)
@@ -200,11 +234,13 @@
200 234 $hours = floor($seconds_remaining / 3600);
201 235 $minutes = floor(($seconds_remaining % 3600) / 60);
202 236
203 237 if ($hours > 0) {
238 + /* translators: %d: number of hours remaining. */
204 239 return sprintf(_n('%d hour', '%d hours', $hours, 'yatra'), $hours);
205 240 }
206 -
241 +
242 + /* translators: %d: number of minutes remaining. */
207 243 return sprintf(_n('%d minute', '%d minutes', $minutes, 'yatra'), $minutes);
208 244 }
209 245
210 246 /**
@@ -248,8 +284,73 @@
248 284 return $url;
249 285 }
250 286
251 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 +/**
252 353 * Format price with currency
253 354 *
254 355 * @param float $amount The amount to format
255 356 * @param string|null $currency The currency code (optional, uses global setting if not provided)
@@ -270,11 +371,11 @@
270 371 }
271 372
272 373 // Get formatting settings from global settings
273 374 $currency_position = SettingsService::getCurrencyPosition();
274 - $decimal_places = SettingsService::getInt('decimal_places', 2);
275 - // Avoid absurd migrated values (e.g. 7+) breaking storefront display; cap at 4.
276 - $decimal_places = max(0, min(4, $decimal_places));
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();
277 378 $thousand_separator = SettingsService::getString('thousand_separator', ',');
278 379 $decimal_separator = SettingsService::getString('decimal_separator', '.');
279 380
280 381 // Format the amount with proper separators
@@ -281,14 +382,33 @@
281 382 $formatted_amount = number_format($amount, $decimal_places, $decimal_separator, $thousand_separator);
282 383
283 384 // Get currency symbol
284 385 $currency_symbol = yatra_get_currency_symbol($currency);
285 -
286 - // Position currency based on settings
287 - if ($currency_position === 'right' || $currency_position === 'after') {
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') {
288 405 return $formatted_amount . ' ' . $currency_symbol;
289 406 }
290 -
407 + if ($pos === 'left') {
408 + return $currency_symbol . $formatted_amount;
409 + }
410 +
291 411 return $currency_symbol . ' ' . $formatted_amount;
292 412 }
293 413 }
294 414
@@ -362,16 +482,33 @@
362 482 * @param int|null $nights Number of nights (optional)
363 483 * @return string Formatted duration
364 484 */
365 485 if (!function_exists('yatra_format_duration')) {
366 - function yatra_format_duration(int $days, ?int $nights = null): string
486 + function yatra_format_duration(int $days, ?int $nights = null, ?int $hours = null): string
367 487 {
368 - if ($days && $nights) {
369 - return $days . ' Days / ' . $nights . ' Nights';
370 - } elseif ($days) {
371 - return $days . ' Day' . ($days > 1 ? 's' : '');
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 + );
372 497 }
373 - return 'Flexible';
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');
374 511 }
375 512 }
376 513
377 514 /**
@@ -426,8 +563,173 @@
426 563 }
427 564 }
428 565
429 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 +/**
430 732 * Extract SVG icon slug from a stored icon field (same shape as admin / archive cards).
431 733 *
432 734 * @param mixed $icon Raw value from DB (serialized array with type/value, URL, attachment id, or legacy slug string).
433 735 */
@@ -442,8 +744,13 @@
442 744 if (is_array($icon)) {
443 745 $type = $icon['type'] ?? $icon[0] ?? '';
444 746 $value = $icon['value'] ?? $icon[1] ?? '';
445 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 +
446 753 return $value;
447 754 }
448 755
449 756 return '';
@@ -650,36 +957,245 @@
650 957 }
651 958 }
652 959
653 960 /**
654 - * Public URL for the Yatra brand icon (admin menu + React sidebar). Empty if file is missing.
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.
655 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 + */
656 984 function yatra_get_brand_icon_url(): string
657 985 {
658 - if (!defined('YATRA_PLUGIN_PATH') || !defined('YATRA_PLUGIN_URL')) {
659 - return '';
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 + }
660 1004 }
661 1005
662 - $candidates = [
663 - 'assets/images/yatra-icon.png',
664 - 'assets/images/yara-icon.png',
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,
665 1073 ];
1074 +}
666 1075
667 - foreach ($candidates as $relative) {
668 - $file = YATRA_PLUGIN_PATH . $relative;
669 - if (!is_readable($file)) {
670 - continue;
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;
671 1100 }
1101 + }
672 1102
673 - $url = YATRA_PLUGIN_URL . $relative;
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 + }
674 1110
675 - return add_query_arg('ver', (string) filemtime($file), $url);
676 - }
1111 + return ucwords(str_replace(['_', '-'], ' ', $id));
1112 +}
677 1113
678 - return '';
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);
679 1129 }
680 1130
681 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 +/**
682 1198 * ============================================
683 1199 * BOOKING SESSION MANAGEMENT
684 1200 * ============================================
685 1201 */
@@ -828,8 +1344,300 @@
828 1344 return !empty($booking_data) && !empty($booking_data['trip_id']);
829 1345 }
830 1346
831 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 +/**
832 1640 * ============================================
833 1641 * REMAINING PAYMENT SESSION MANAGEMENT
834 1642 * ============================================
835 1643 */
@@ -1006,9 +1814,13 @@
1006 1814 $booking_base = trim((string) SettingsService::getBookingBase(), '/');
1007 1815 if ($booking_base === '') {
1008 1816 $booking_base = 'book';
1009 1817 }
1010 - $virtual_base = home_url('/' . $booking_base . '/confirmation/');
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 . '/');
1011 1823
1012 1824 /**
1013 1825 * Override the base URL for booking confirmation (before the reference path segment).
1014 1826 * Return a non-empty string to use a custom base (e.g. get_permalink( $page_id )).
@@ -1038,8 +1850,42 @@
1038 1850 return (string) apply_filters('yatra_booking_confirmation_url', $url, $reference);
1039 1851 }
1040 1852
1041 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 +/**
1042 1888 * ============================================
1043 1889 * ARCHIVE LISTING (plain permalinks pagination)
1044 1890 * ============================================
1045 1891 */
@@ -1298,8 +2144,10 @@
1298 2144 * @return string Destination permalink URL
1299 2145 */
1300 2146 function yatra_get_destination_permalink($destination): string
1301 2147 {
2148 + $original = $destination;
2149 +
1302 2150 if (is_numeric($destination)) {
1303 2151 global $wpdb;
1304 2152 $table = ClassificationsTable::getTableName();
1305 2153 $destination = $wpdb->get_row($wpdb->prepare(
@@ -1307,16 +2155,16 @@
1307 2155 (int) $destination,
1308 2156 ClassificationTypes::DESTINATION
1309 2157 ));
1310 2158 }
1311 -
2159 +
1312 2160 $slug = is_object($destination) ? ($destination->slug ?? '') : '';
1313 -
2161 +
1314 2162 if (empty($slug)) {
1315 2163 return '';
1316 2164 }
1317 -
1318 - $base = SettingsService::getString('destination_base', 'destination');
2165 +
2166 + $base = SettingsService::getDestinationBase();
1319 2167 $permalink_structure = get_option('permalink_structure');
1320 2168 $is_plain = empty($permalink_structure);
1321 2169
1322 2170 if ($is_plain) {
@@ -1321,12 +2169,15 @@
1321 2169
1322 2170 if ($is_plain) {
1323 2171 $key = preg_replace('/[^a-z0-9_-]/i', '', $base) ?: 'destination';
1324 2172
1325 - return add_query_arg([$key => $slug], home_url('/'));
2173 + $url = add_query_arg([$key => $slug], home_url('/'));
2174 + } else {
2175 + $url = home_url('/' . $base . '/' . $slug . '/');
1326 2176 }
1327 2177
1328 - return home_url('/' . $base . '/' . $slug . '/');
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);
1329 2180 }
1330 2181
1331 2182 /**
1332 2183 * Get activity permalink
@@ -1335,8 +2186,10 @@
1335 2186 * @return string Activity permalink URL
1336 2187 */
1337 2188 function yatra_get_activity_permalink($activity): string
1338 2189 {
2190 + $original = $activity;
2191 +
1339 2192 if (is_numeric($activity)) {
1340 2193 global $wpdb;
1341 2194 $table = ClassificationsTable::getTableName();
1342 2195 $activity = $wpdb->get_row($wpdb->prepare(
@@ -1344,16 +2197,16 @@
1344 2197 (int) $activity,
1345 2198 ClassificationTypes::ACTIVITY
1346 2199 ));
1347 2200 }
1348 -
2201 +
1349 2202 $slug = is_object($activity) ? ($activity->slug ?? '') : '';
1350 -
2203 +
1351 2204 if (empty($slug)) {
1352 2205 return '';
1353 2206 }
1354 -
1355 - $base = SettingsService::getString('activity_base', 'activity');
2207 +
2208 + $base = SettingsService::getActivityBase();
1356 2209 $permalink_structure = get_option('permalink_structure');
1357 2210 $is_plain = empty($permalink_structure);
1358 2211
1359 2212 if ($is_plain) {
@@ -1358,12 +2211,15 @@
1358 2211
1359 2212 if ($is_plain) {
1360 2213 $key = preg_replace('/[^a-z0-9_-]/i', '', $base) ?: 'activity';
1361 2214
1362 - return add_query_arg([$key => $slug], home_url('/'));
2215 + $url = add_query_arg([$key => $slug], home_url('/'));
2216 + } else {
2217 + $url = home_url('/' . $base . '/' . $slug . '/');
1363 2218 }
1364 2219
1365 - return home_url('/' . $base . '/' . $slug . '/');
2220 + /** @var string $url Override full activity URL. Third arg: slug. */
2221 + return (string) apply_filters('yatra_activity_permalink', $url, $original, $slug);
1366 2222 }
1367 2223
1368 2224 /**
1369 2225 * Get trip category permalink
@@ -1372,8 +2228,10 @@
1372 2228 * @return string Category permalink URL
1373 2229 */
1374 2230 function yatra_get_category_permalink($category): string
1375 2231 {
2232 + $original = $category;
2233 +
1376 2234 if (is_numeric($category)) {
1377 2235 global $wpdb;
1378 2236 $table = ClassificationsTable::getTableName();
1379 2237 $category = $wpdb->get_row($wpdb->prepare(
@@ -1381,16 +2239,16 @@
1381 2239 (int) $category,
1382 2240 ClassificationTypes::CATEGORY
1383 2241 ));
1384 2242 }
1385 -
2243 +
1386 2244 $slug = is_object($category) ? ($category->slug ?? '') : '';
1387 -
2245 +
1388 2246 if (empty($slug)) {
1389 2247 return '';
1390 2248 }
1391 -
1392 - $base = SettingsService::getString('trip_category_base', 'trip-category');
2249 +
2250 + $base = SettingsService::getTripCategoryBase();
1393 2251 $permalink_structure = get_option('permalink_structure');
1394 2252 $is_plain = empty($permalink_structure);
1395 2253
1396 2254 if ($is_plain) {
@@ -1395,12 +2253,15 @@
1395 2253
1396 2254 if ($is_plain) {
1397 2255 $key = preg_replace('/[^a-z0-9_-]/i', '', $base) ?: 'trip-category';
1398 2256
1399 - return add_query_arg([$key => $slug], home_url('/'));
2257 + $url = add_query_arg([$key => $slug], home_url('/'));
2258 + } else {
2259 + $url = home_url('/' . $base . '/' . $slug . '/');
1400 2260 }
1401 2261
1402 - return home_url('/' . $base . '/' . $slug . '/');
2262 + /** @var string $url Override full trip-category URL. Third arg: slug. */
2263 + return (string) apply_filters('yatra_category_permalink', $url, $original, $slug);
1403 2264 }
1404 2265
1405 2266 /**
1406 2267 * Get trip permalink
@@ -1409,8 +2270,10 @@
1409 2270 * @return string Trip permalink URL
1410 2271 */
1411 2272 function yatra_get_trip_permalink($trip): string
1412 2273 {
2274 + $original = $trip;
2275 +
1413 2276 if (is_numeric($trip)) {
1414 2277 global $wpdb;
1415 2278 $table = TripsTable::getTableName();
1416 2279 $trip = $wpdb->get_row($wpdb->prepare(
@@ -1417,26 +2280,29 @@
1417 2280 "SELECT slug FROM {$table} WHERE id = %d",
1418 2281 (int) $trip
1419 2282 ));
1420 2283 }
1421 -
2284 +
1422 2285 $slug = is_object($trip) ? ($trip->slug ?? '') : '';
1423 -
2286 +
1424 2287 if (empty($slug)) {
1425 2288 return '';
1426 2289 }
1427 -
2290 +
1428 2291 $base = SettingsService::getTripBase();
1429 2292 $permalink_structure = get_option('permalink_structure');
1430 2293 $is_plain = empty($permalink_structure);
1431 -
2294 +
1432 2295 if ($is_plain) {
1433 2296 $key = preg_replace('/[^a-z0-9_-]/i', '', $base) ?: 'trip';
1434 2297
1435 - return add_query_arg([$key => $slug], home_url('/'));
2298 + $url = add_query_arg([$key => $slug], home_url('/'));
2299 + } else {
2300 + $url = home_url('/' . $base . '/' . $slug . '/');
1436 2301 }
1437 -
1438 - return home_url('/' . $base . '/' . $slug . '/');
2302 +
2303 + /** @var string $url Override full trip URL. Third arg: slug. */
2304 + return (string) apply_filters('yatra_trip_permalink', $url, $original, $slug);
1439 2305 }
1440 2306
1441 2307 /**
1442 2308 * Canonical URL for the trip archive / filter listing (respects Settings trip base).
@@ -1465,11 +2331,11 @@
1465 2331 */
1466 2332 function yatra_get_taxonomy_listing_url(string $listing_type): string
1467 2333 {
1468 2334 $map = [
1469 - 'destination' => SettingsService::getString('destination_base', 'destination'),
1470 - 'activity' => SettingsService::getString('activity_base', 'activity'),
1471 - 'category' => SettingsService::getString('trip_category_base', 'trip-category'),
2335 + 'destination' => SettingsService::getDestinationBase(),
2336 + 'activity' => SettingsService::getActivityBase(),
2337 + 'category' => SettingsService::getTripCategoryBase(),
1472 2338 ];
1473 2339 $base = $map[$listing_type] ?? '';
1474 2340 $base = preg_replace('/[^a-zA-Z0-9_-]/', '', (string) $base) ?: 'destination';
1475 2341 $permalink_structure = (string) get_option('permalink_structure', '');
@@ -1893,9 +2759,9 @@
1893 2759 // Enqueue the single trip JavaScript
1894 2760 wp_enqueue_script(
1895 2761 'yatra-single-trip',
1896 2762 YATRA_PLUGIN_URL . 'assets/js/single-trip.js',
1897 - ['jquery'],
2763 + ['jquery', 'yatra-trip'],
1898 2764 YATRA_VERSION,
1899 2765 true
1900 2766 );
1901 2767
@@ -2067,40 +2933,182 @@
2067 2933 */
2068 2934 function yatra_single_trip_get_group_discounts($trip_id) {
2069 2935 $has_group_discounts = false;
2070 2936 $group_discounts_data = [];
2071 -
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 +
2072 2946 try {
2073 - // Call the group discount API to get detailed discount information
2074 - $api_url = rest_url('yatra/v1/discounts/group-discounts');
2075 - $response = wp_remote_post($api_url, [
2076 - 'method' => 'GET',
2077 - 'body' => [
2078 - 'trip_ids' => [$trip_id]
2079 - ],
2080 - 'headers' => [
2081 - 'Content-Type' => 'application/json',
2082 - ],
2083 - ]);
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 + }
2084 2959
2085 - if (!is_wp_error($response) && wp_remote_retrieve_response_code($response) === 200) {
2086 - $data = json_decode(wp_remote_retrieve_body($response), true);
2087 - if (isset($data[$trip_id]) && $data[$trip_id]['has_group_discounts']) {
2088 - $has_group_discounts = true;
2089 - $group_discounts_data = $data[$trip_id]['discounts'];
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);
2090 2969 }
2091 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 + }
2092 2994 } catch (Exception $e) {
2093 - // Silently fail if API call fails - don't break the page
2094 2995 $has_group_discounts = false;
2095 2996 }
2096 -
2997 +
2097 2998 return [
2098 2999 'has_group_discounts' => $has_group_discounts,
2099 - 'group_discounts_data' => $group_discounts_data
3000 + 'group_discounts_data' => $group_discounts_data,
2100 3001 ];
2101 3002 }
2102 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 +
2103 3111 // Hook into WordPress enqueue system
2104 3112 add_action('wp_enqueue_scripts', 'yatra_enqueue_single_trip_scripts');
2105 3113
2106 3114 // Yatra page type detection functions
@@ -2203,9 +3211,21 @@
2203 3211 if ( ! function_exists( 'yatra_get_header' ) ) {
2204 3212
2205 3213 function yatra_get_header( $header_name = null ) {
2206 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.
2207 3220 if (
3221 + class_exists( '\\Yatra\\Core\\Template\\FseTemplates' )
3222 + && \Yatra\Core\Template\FseTemplates::isRenderingInsideCanvas()
3223 + ) {
3224 + return;
3225 + }
3226 +
3227 + if (
2208 3228 version_compare( $wp_version, '5.9', '>=' ) &&
2209 3229 function_exists( 'wp_is_block_theme' ) &&
2210 3230 wp_is_block_theme()
2211 3231 ) {
@@ -2267,9 +3287,20 @@
2267 3287 if ( ! function_exists( 'yatra_get_footer' ) ) {
2268 3288
2269 3289 function yatra_get_footer( $footer_name = null ) {
2270 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.
2271 3295 if (
3296 + class_exists( '\\Yatra\\Core\\Template\\FseTemplates' )
3297 + && \Yatra\Core\Template\FseTemplates::isRenderingInsideCanvas()
3298 + ) {
3299 + return;
3300 + }
3301 +
3302 + if (
2272 3303 version_compare( $wp_version, '5.9', '>=' ) &&
2273 3304 function_exists( 'wp_is_block_theme' ) &&
2274 3305 wp_is_block_theme()
2275 3306 ) {
@@ -2299,61 +3330,32 @@
2299 3330 * @return void Echoes the icon HTML
2300 3331 */
2301 3332 if (!function_exists('yatra_render_tab_icon')) {
2302 3333 function yatra_render_tab_icon($icon_data, $default_icon = 'book', $css_class = '', $label = '') {
2303 - if (!empty($icon_data)) {
2304 - // Handle JSON string that might not be decoded
2305 - if (is_string($icon_data) && strpos($icon_data, '{') === 0) {
2306 - $icon_data = json_decode($icon_data, true);
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;
2307 3354 }
2308 - if (is_array($icon_data) && isset($icon_data['type'])) {
2309 - if ($icon_data['type'] === 'image' && !empty($icon_data['value'])) {
2310 - // Display image icon
2311 - $image_url = is_numeric($icon_data['value'])
2312 - ? wp_get_attachment_url($icon_data['value'])
2313 - : $icon_data['value'];
2314 - if ($image_url) {
2315 - $size_style = strpos($css_class, 'sticky-nav') !== false ? 'width: 18px; height: 18px;' : 'width: 24px; height: 24px;';
2316 - echo '<img src="' . esc_url($image_url) . '" alt="' . esc_attr($label) . '" class="' . esc_attr($css_class) . '" style="' . $size_style . ' object-fit: cover; border-radius: 4px;">';
2317 - } else {
2318 - echo yatra_svg_icon('image', $css_class);
2319 - }
2320 - } elseif ($icon_data['type'] === 'icon' && !empty($icon_data['value'])) {
2321 - // Display SVG icon
2322 - echo yatra_svg_icon($icon_data['value'], $css_class);
2323 - } else {
2324 - // Fallback to default
2325 - echo yatra_svg_icon($default_icon, $css_class);
2326 - }
2327 - } elseif (is_object($icon_data) && isset($icon_data->type)) {
2328 - // Handle object format
2329 - $icon_array = (array) $icon_data;
2330 - if ($icon_array['type'] === 'image' && !empty($icon_array['value'])) {
2331 - $image_url = is_numeric($icon_array['value'])
2332 - ? wp_get_attachment_url($icon_array['value'])
2333 - : $icon_array['value'];
2334 - if ($image_url) {
2335 - $size_style = strpos($css_class, 'sticky-nav') !== false ? 'width: 18px; height: 18px;' : 'width: 24px; height: 24px;';
2336 - echo '<img src="' . esc_url($image_url) . '" alt="' . esc_attr($label) . '" class="' . esc_attr($css_class) . '" style="' . $size_style . ' object-fit: cover; border-radius: 4px;">';
2337 - } else {
2338 - echo yatra_svg_icon('image', $css_class);
2339 - }
2340 - } elseif ($icon_array['type'] === 'icon' && !empty($icon_array['value'])) {
2341 - echo yatra_svg_icon($icon_array['value'], $css_class);
2342 - } else {
2343 - echo yatra_svg_icon($default_icon, $css_class);
2344 - }
2345 - } elseif (is_string($icon_data)) {
2346 - // Direct icon name (backward compatibility)
2347 - echo yatra_svg_icon($icon_data, $css_class);
2348 - } else {
2349 - // Fallback
2350 - echo yatra_svg_icon($default_icon, $css_class);
2351 - }
2352 - } else {
2353 - // Default fallback
2354 - echo yatra_svg_icon($default_icon, $css_class);
2355 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);
2356 3358 }
2357 3359 }
2358 3360
2359 3361 if (!function_exists('yatra_listing_sidebar_filter_visible_cap')) {