| @@ -11,22 +11,45 @@ | ||
| 11 | 11 | // runtime is enqueued (it is — `yatra-booking` declares `wp-i18n` as a |
| 12 | 12 | // dependency in FrontendAssetsProvider). Falls back to the source string if |
| 13 | 13 | // wp.i18n is unavailable so the page never breaks on older environments. |
| 14 | 14 | (function () { |
| 15 | - if (typeof window.__ === 'function') return; | |
| 16 | - if (window.wp && window.wp.i18n && typeof window.wp.i18n.__ === 'function') { | |
| 17 | - window.__ = function (text, domain) { return window.wp.i18n.__(text, domain || 'yatra'); }; | |
| 18 | - window._n = function (s, p, n, domain) { return window.wp.i18n._n(s, p, n, domain || 'yatra'); }; | |
| 19 | - window._x = function (text, ctx, domain) { return window.wp.i18n._x(text, ctx, domain || 'yatra'); }; | |
| 20 | - window.sprintf = window.sprintf || (window.wp.i18n.sprintf || function (fmt) { return fmt; }); | |
| 15 | + var i18n = (window.wp && window.wp.i18n) ? window.wp.i18n : null; | |
| 16 | + if (i18n && typeof i18n.__ === 'function') { | |
| 17 | + window.__ = function (text, domain) { return i18n.__(text, domain || 'yatra'); }; | |
| 18 | + window._n = function (s, p, n, domain) { return i18n._n(s, p, n, domain || 'yatra'); }; | |
| 19 | + window._x = function (text, ctx, domain) { return i18n._x(text, ctx, domain || 'yatra'); }; | |
| 20 | + // Always bind WordPress's own sprintf (it substitutes %d/%s/%1$s). | |
| 21 | + // Do NOT defer to a pre-existing window.sprintf: a theme or other | |
| 22 | + // plugin can define an incompatible global that leaves placeholders | |
| 23 | + // unsubstituted (the cause of literal "%d"/"%s" in the UI). | |
| 24 | + if (typeof i18n.sprintf === 'function') { | |
| 25 | + window.sprintf = function () { return i18n.sprintf.apply(i18n, arguments); }; | |
| 26 | + } else if (typeof window.sprintf !== 'function') { | |
| 27 | + window.sprintf = function (fmt) { return fmt; }; | |
| 28 | + } | |
| 21 | 29 | } else { |
| 22 | - window.__ = function (text) { return text; }; | |
| 23 | - window._n = function (s, p, n) { return n === 1 ? s : p; }; | |
| 24 | - window._x = function (text) { return text; }; | |
| 25 | - window.sprintf = window.sprintf || function (fmt) { return fmt; }; | |
| 30 | + // wp.i18n not present — install passthroughs only if nothing usable | |
| 31 | + // exists yet (don't clobber a real binding installed earlier). | |
| 32 | + if (typeof window.__ !== 'function') window.__ = function (text) { return text; }; | |
| 33 | + if (typeof window._n !== 'function') window._n = function (s, p, n) { return n === 1 ? s : p; }; | |
| 34 | + if (typeof window._x !== 'function') window._x = function (text) { return text; }; | |
| 35 | + if (typeof window.sprintf !== 'function') window.sprintf = function (fmt) { return fmt; }; | |
| 26 | 36 | } |
| 27 | 37 | })(); |
| 28 | 38 | |
| 39 | +// Parse a numeric min/max attribute while preserving a legitimate 0. | |
| 40 | +// `parseInt(el.getAttribute('max')) || 20` treated max="0" — what a sold-out | |
| 41 | +// departure renders — as "unset" and fell back to the placeholder ceiling, so | |
| 42 | +// the guest selector stayed usable and ignored the departure's real capacity. | |
| 43 | +// Only an absent or non-numeric value may fall back. Installed once and shared | |
| 44 | +// by every frontend script, so load order does not matter. | |
| 45 | +if (typeof window.yatraNumOr !== 'function') { | |
| 46 | + window.yatraNumOr = function (raw, fallback) { | |
| 47 | + var parsed = parseInt(raw, 10); | |
| 48 | + return isNaN(parsed) ? fallback : parsed; | |
| 49 | + }; | |
| 50 | +} | |
| 51 | + | |
| 29 | 52 | (function($) { |
| 30 | 53 | 'use strict'; |
| 31 | 54 | |
| 32 | 55 | // API configuration - available throughout the module |
| @@ -54,8 +77,43 @@ | ||
| 54 | 77 | |
| 55 | 78 | $(document).ready(function() { |
| 56 | 79 | const $form = $('#yatra-booking-form'); |
| 57 | 80 | const $submitBtn = $('#yatra-submit-booking'); |
| 81 | + | |
| 82 | + // --- Guest "Create an account" reveal --- | |
| 83 | + // The optional account-creation block (#yatra-account-fields) ships hidden | |
| 84 | + // with non-required password inputs. Without this handler the guest can | |
| 85 | + // tick "Create an account" but never see a password field, so either no | |
| 86 | + // account is created (empty password) or autofill populates the hidden | |
| 87 | + // field asymmetrically and the server rejects with "Passwords do not | |
| 88 | + // match" — with no visible field to correct. Reveal + require the fields | |
| 89 | + // only when opted in; hide + clear + un-require otherwise so a pure-guest | |
| 90 | + // submit is never blocked and no stale/autofilled password is sent. | |
| 91 | + (function initCreateAccountToggle() { | |
| 92 | + const $accountFields = $('#yatra-account-fields'); | |
| 93 | + const $checkbox = $('#create-account'); | |
| 94 | + if (!$accountFields.length || !$checkbox.length) { | |
| 95 | + return; | |
| 96 | + } | |
| 97 | + const $pw = $accountFields.find('#account_password'); | |
| 98 | + const $pwConfirm = $accountFields.find('#account_password_confirm'); | |
| 99 | + | |
| 100 | + const applyState = function (checked) { | |
| 101 | + if (checked) { | |
| 102 | + $accountFields.show(); | |
| 103 | + $pw.add($pwConfirm).attr('required', 'required'); | |
| 104 | + } else { | |
| 105 | + $accountFields.hide(); | |
| 106 | + $pw.add($pwConfirm).removeAttr('required').val(''); | |
| 107 | + } | |
| 108 | + }; | |
| 109 | + | |
| 110 | + // Honor the current state on load (covers browser-restored checkboxes). | |
| 111 | + applyState($checkbox.is(':checked')); | |
| 112 | + $checkbox.on('change', function () { | |
| 113 | + applyState($(this).is(':checked')); | |
| 114 | + }); | |
| 115 | + })(); | |
| 58 | 116 | const isRemainingPayment = Boolean(window.yatraBookingData?.isRemainingPayment) || ($form.data('is-remaining-payment') === 'yes'); |
| 59 | 117 | const remainingAmount = isRemainingPayment |
| 60 | 118 | ? parseFloat(window.yatraBookingData?.remainingAmount ?? $form.data('payment-due')) || 0 |
| 61 | 119 | : 0; |
| @@ -153,14 +211,20 @@ | ||
| 153 | 211 | let total = 0; |
| 154 | 212 | $('.yatra-qty-input[data-category-id]').each(function() { |
| 155 | 213 | const count = parseInt($(this).val()) || 0; |
| 156 | 214 | const price = parseFloat($(this).data('price')) || 0; |
| 157 | - const pricingMode = $(this).data('pricing-mode') || $(this).closest('.yatra-quantity-row').data('pricing-mode') || 'per_person'; | |
| 158 | - | |
| 215 | + const $row = $(this).closest('.yatra-quantity-row'); | |
| 216 | + const pricingMode = $(this).data('pricing-mode') || $row.data('pricing-mode') || 'per_person'; | |
| 217 | + const groupOverflow = $(this).data('group-overflow') || $row.data('group-overflow') || 'block'; | |
| 218 | + const maxPax = parseInt($(this).data('max-pax') || $row.data('max-pax')) || 0; | |
| 219 | + | |
| 159 | 220 | if (pricingMode === 'per_group') { |
| 160 | - // Per group: charge flat price once if any travelers in this category | |
| 221 | + // Per group: one flat price for the whole group, or one flat | |
| 222 | + // price per block of maxPax people when overflow is "per_block". | |
| 161 | 223 | if (count > 0) { |
| 162 | - total += price; | |
| 224 | + total += (groupOverflow === 'per_block' && maxPax > 0) | |
| 225 | + ? price * Math.ceil(count / maxPax) | |
| 226 | + : price; | |
| 163 | 227 | } |
| 164 | 228 | } else { |
| 165 | 229 | // Per person: charge per traveler |
| 166 | 230 | total += count * price; |
| @@ -248,13 +312,31 @@ | ||
| 248 | 312 | .split('.').join('__DECIMAL__') |
| 249 | 313 | .replace(/__THOUSAND__/g, thousandSeparator) |
| 250 | 314 | .replace(/__DECIMAL__/g, decimalSeparator); |
| 251 | 315 | |
| 252 | - // Position currency based on settings (before or after) | |
| 253 | - if (currencyPosition === 'right' || currencyPosition === 'after') { | |
| 316 | + // Placement must match PHP yatra_format_price() exactly: the Settings | |
| 317 | + // UI uses left / right / left_space / right_space; legacy values are | |
| 318 | + // before (= left_space) and after (= right_space). Only *_space adds a | |
| 319 | + // space, so a "left" setting renders "$208.35", not "$ 208.35". | |
| 320 | + let pos = String(currencyPosition || '').toLowerCase().trim(); | |
| 321 | + if (pos === 'before') { | |
| 322 | + pos = 'left_space'; | |
| 323 | + } else if (pos === 'after') { | |
| 324 | + pos = 'right_space'; | |
| 325 | + } | |
| 326 | + if (['left', 'right', 'left_space', 'right_space'].indexOf(pos) === -1) { | |
| 327 | + pos = 'left_space'; | |
| 328 | + } | |
| 329 | + | |
| 330 | + if (pos === 'right') { | |
| 331 | + return formattedAmount + symbol; | |
| 332 | + } | |
| 333 | + if (pos === 'right_space') { | |
| 254 | 334 | return formattedAmount + ' ' + symbol; |
| 255 | 335 | } |
| 256 | - | |
| 336 | + if (pos === 'left') { | |
| 337 | + return symbol + formattedAmount; | |
| 338 | + } | |
| 257 | 339 | return symbol + ' ' + formattedAmount; |
| 258 | 340 | } |
| 259 | 341 | |
| 260 | 342 | /** |
| @@ -331,9 +413,9 @@ | ||
| 331 | 413 | } |
| 332 | 414 | const flexDue = paymentMethod === 'deposit' || paymentMethod === 'partial'; |
| 333 | 415 | |
| 334 | 416 | if (isOffline) { |
| 335 | - $buttonText.text(__('Complete Booking', 'yatra')); | |
| 417 | + $buttonText.text(window.yatraBookingData?.i18n?.complete_booking || __('Complete Booking', 'yatra')); | |
| 336 | 418 | const showAmount = flexDue ? due : totalSafe; |
| 337 | 419 | if (showAmount > 0) { |
| 338 | 420 | $payAmount.text(formatCurrency(showAmount, currency)).show(); |
| 339 | 421 | } else { |
| @@ -339,9 +421,9 @@ | ||
| 339 | 421 | } else { |
| 340 | 422 | $payAmount.hide(); |
| 341 | 423 | } |
| 342 | 424 | } else { |
| 343 | - $buttonText.text(__('Pay Now', 'yatra')); | |
| 425 | + $buttonText.text(window.yatraBookingData?.i18n?.pay_now || __('Pay Now', 'yatra')); | |
| 344 | 426 | const payVal = flexDue && due > 0 ? due : totalSafe; |
| 345 | 427 | if (payVal > 0) { |
| 346 | 428 | $payAmount.text(formatCurrency(payVal, currency)).show(); |
| 347 | 429 | } else { |
| @@ -361,9 +443,15 @@ | ||
| 361 | 443 | if (!$firstTraveler.length) return; |
| 362 | 444 | |
| 363 | 445 | // Clone the first traveler form |
| 364 | 446 | const $newTraveler = $firstTraveler.clone(); |
| 365 | - | |
| 447 | + | |
| 448 | + // Additional travelers are never the lead, so drop any fields the | |
| 449 | + // operator marked "Lead traveler only" (data-applies-to="lead"). | |
| 450 | + // Traveler 1 is rendered server-side with these; clones must not | |
| 451 | + // carry them. ('all'/unset fields stay.) | |
| 452 | + $newTraveler.find('[data-applies-to="lead"]').remove(); | |
| 453 | + | |
| 366 | 454 | // Update index references |
| 367 | 455 | $newTraveler.attr('data-traveler-index', index); |
| 368 | 456 | $newTraveler.find('.yatra-traveler-title').text( |
| 369 | 457 | /* translators: %d: traveler sequence number. */ |
| @@ -492,11 +580,22 @@ | ||
| 492 | 580 | |
| 493 | 581 | // Validate all required fields |
| 494 | 582 | $form.find('input[required], select[required], textarea[required]').each(function() { |
| 495 | 583 | const $field = $(this); |
| 496 | - const value = $field.val(); | |
| 497 | - | |
| 498 | - if (!value || value.trim() === '') { | |
| 584 | + const type = ($field.attr('type') || '').toLowerCase(); | |
| 585 | + | |
| 586 | + // Checkbox/radio: .val() returns the value attribute regardless of | |
| 587 | + // checked state, so an unchecked required box would wrongly pass. | |
| 588 | + // Test the checked state instead. | |
| 589 | + let invalid; | |
| 590 | + if (type === 'checkbox' || type === 'radio') { | |
| 591 | + invalid = !$field.is(':checked'); | |
| 592 | + } else { | |
| 593 | + const value = $field.val(); | |
| 594 | + invalid = !value || value.trim() === ''; | |
| 595 | + } | |
| 596 | + | |
| 597 | + if (invalid) { | |
| 499 | 598 | isValid = false; |
| 500 | 599 | $field.addClass('error'); |
| 501 | 600 | const label = $field.closest('.yatra-form-group').find('label').text().replace('*', '').trim(); |
| 502 | 601 | errors.push(sprintf( |
| @@ -584,9 +683,14 @@ | ||
| 584 | 683 | '<p style="font-size: 18px; color: #6b7280; margin-bottom: 8px;">' + message + '</p>' + |
| 585 | 684 | '<p style="font-size: 16px; color: #111827; font-weight: 600;">' + referenceText + '</p>' + |
| 586 | 685 | footerHtml + |
| 587 | 686 | '</div>'); |
| 588 | - | |
| 687 | + | |
| 688 | + // The booking-form header ("Complete Your Booking" + subtitle) is a | |
| 689 | + // SIBLING of the form, not inside it — replacing the form alone left | |
| 690 | + // that header showing above the confirmation. Hide it so the success | |
| 691 | + // screen doesn't repeat the booking-form heading. | |
| 692 | + $form.siblings('.yatra-booking-header').hide(); | |
| 589 | 693 | $form.html($success); |
| 590 | 694 | } |
| 591 | 695 | |
| 592 | 696 | // ===================== |
| @@ -626,10 +730,10 @@ | ||
| 626 | 730 | |
| 627 | 731 | const $btn = $(this); |
| 628 | 732 | const $input = $('#number-of-travelers'); |
| 629 | 733 | let currentValue = parseInt($input.val()) || 1; |
| 630 | - const min = parseInt($input.attr('min')) || 1; | |
| 631 | - const max = parseInt($input.attr('max')) || 20; | |
| 734 | + const min = window.yatraNumOr($input.attr('min'), 1); | |
| 735 | + const max = window.yatraNumOr($input.attr('max'), 20); | |
| 632 | 736 | |
| 633 | 737 | if ($btn.hasClass('yatra-qty-plus')) { |
| 634 | 738 | if (currentValue < max) { |
| 635 | 739 | currentValue++; |
| @@ -659,10 +763,10 @@ | ||
| 659 | 763 | const $btn = $(this); |
| 660 | 764 | const $row = $btn.closest('.yatra-quantity-row'); |
| 661 | 765 | const $input = $row.find('.yatra-qty-input'); |
| 662 | 766 | let currentValue = parseInt($input.val()) || 0; |
| 663 | - const min = parseInt($input.attr('min')) || 0; | |
| 664 | - const max = parseInt($input.attr('max')) || 20; | |
| 767 | + const min = window.yatraNumOr($input.attr('min'), 0); | |
| 768 | + const max = window.yatraNumOr($input.attr('max'), 20); | |
| 665 | 769 | |
| 666 | 770 | if ($btn.hasClass('yatra-qty-plus')) { |
| 667 | 771 | if (currentValue < max) { |
| 668 | 772 | currentValue++; |
| @@ -1048,8 +1152,25 @@ | ||
| 1048 | 1152 | /** |
| 1049 | 1153 | * Submit booking to server |
| 1050 | 1154 | */ |
| 1051 | 1155 | function submitBookingToServer(bookingData, originalBtnHtml) { |
| 1156 | + // reCAPTCHA v3: attach a token when the booking form is protected, | |
| 1157 | + // then run the real submit. Wraps every call path (direct submit and | |
| 1158 | + // the gateway `proceedWithSubmission` hook). No-op unless the operator | |
| 1159 | + // opted booking into reCAPTCHA (off by default), so payment flows are | |
| 1160 | + // unchanged otherwise. | |
| 1161 | + const yatraRc = window.yatraRecaptcha; | |
| 1162 | + if (yatraRc && yatraRc.protects && yatraRc.protects('booking') && !bookingData.recaptcha_token) { | |
| 1163 | + yatraRc.execute('booking').then(function (recaptchaToken) { | |
| 1164 | + if (recaptchaToken) { bookingData.recaptcha_token = recaptchaToken; } | |
| 1165 | + submitBookingToServerInner(bookingData, originalBtnHtml); | |
| 1166 | + }); | |
| 1167 | + return; | |
| 1168 | + } | |
| 1169 | + submitBookingToServerInner(bookingData, originalBtnHtml); | |
| 1170 | + } | |
| 1171 | + | |
| 1172 | + function submitBookingToServerInner(bookingData, originalBtnHtml) { | |
| 1052 | 1173 | const { base: restBase, isPlain } = getRestBase(); |
| 1053 | 1174 | const siteBase = (window.yatraBookingData?.siteUrl || window.location.origin || '').replace(/\/$/, ''); |
| 1054 | 1175 | let createUrl; |
| 1055 | 1176 | if (isPlain) { |
| @@ -1786,8 +1907,24 @@ | ||
| 1786 | 1907 | password: password, |
| 1787 | 1908 | confirm_password: confirmPassword |
| 1788 | 1909 | }; |
| 1789 | 1910 | |
| 1911 | + // reCAPTCHA v3: this form posts straight to /auth/register, so it must | |
| 1912 | + // carry its own token when the operator protects registration — the | |
| 1913 | + // server rejects an empty one outright, whatever the score threshold. | |
| 1914 | + // Resolves to '' when reCAPTCHA is off or unavailable, so the normal | |
| 1915 | + // (unprotected) flow is completely unchanged. | |
| 1916 | + const yatraRcReg = window.yatraRecaptcha; | |
| 1917 | + const regTokenPromise = (yatraRcReg && typeof yatraRcReg.protects === 'function' | |
| 1918 | + && yatraRcReg.protects('registration') && typeof yatraRcReg.execute === 'function') | |
| 1919 | + ? yatraRcReg.execute('registration') | |
| 1920 | + : Promise.resolve(''); | |
| 1921 | + | |
| 1922 | + regTokenPromise.catch(function () { return ''; }).then(function (regToken) { | |
| 1923 | + if (regToken) { | |
| 1924 | + registerData.recaptcha_token = regToken; | |
| 1925 | + } | |
| 1926 | + | |
| 1790 | 1927 | fetch(apiUrl + '/auth/register', { |
| 1791 | 1928 | method: 'POST', |
| 1792 | 1929 | headers: { |
| 1793 | 1930 | 'Content-Type': 'application/json', |
| @@ -1851,8 +1988,72 @@ | ||
| 1851 | 1988 | $btn.prop('disabled', false); |
| 1852 | 1989 | $btnText.show(); |
| 1853 | 1990 | $btnLoading.hide(); |
| 1854 | 1991 | }); |
| 1992 | + }); // end reCAPTCHA token wrapper | |
| 1855 | 1993 | }); |
| 1994 | + | |
| 1995 | + // --------------------------------------------------------------------- | |
| 1996 | + // Date field picker upgrade (fast year navigation for Date of Birth) | |
| 1997 | + // --------------------------------------------------------------------- | |
| 1998 | + // A native <input type="date"> popup only steps one month at a time, so | |
| 1999 | + // reaching a birth year like 1990 takes many clicks. Where flatpickr is | |
| 2000 | + // available (and on non-touch devices, since mobile native pickers already | |
| 2001 | + // have good year wheels) we upgrade `.yatra-js-date` inputs to a flatpickr | |
| 2002 | + // whose header year is directly typeable/scrollable. Fully guarded: with no | |
| 2003 | + // flatpickr, or on mobile, the native input is left untouched and working. | |
| 2004 | + var yatraIsMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent || ''); | |
| 2005 | + | |
| 2006 | + function yatraUpgradeDateField(input) { | |
| 2007 | + if (!input || input.dataset.yatraFp === '1') return; | |
| 2008 | + if (typeof window.flatpickr === 'undefined') return; | |
| 2009 | + if (yatraIsMobile) return; // native mobile date wheels already navigate years well | |
| 2010 | + input.dataset.yatraFp = '1'; | |
| 2011 | + var isDob = input.getAttribute('data-yatra-dob') === '1'; | |
| 2012 | + // Turn off the native date UI so flatpickr is the only picker; the | |
| 2013 | + // submitted value stays ISO (Y-m-d) so FormData/back-end are unchanged. | |
| 2014 | + try { input.type = 'text'; } catch (e) {} | |
| 2015 | + window.flatpickr(input, { | |
| 2016 | + dateFormat: 'Y-m-d', | |
| 2017 | + allowInput: true, | |
| 2018 | + maxDate: isDob ? 'today' : null, | |
| 2019 | + defaultDate: input.value || null | |
| 2020 | + }); | |
| 2021 | + } | |
| 2022 | + | |
| 2023 | + function yatraUpgradeDateFields(root) { | |
| 2024 | + var scope = (root && root.querySelectorAll) ? root : document; | |
| 2025 | + var list = scope.querySelectorAll('input.yatra-js-date'); | |
| 2026 | + for (var i = 0; i < list.length; i++) { | |
| 2027 | + yatraUpgradeDateField(list[i]); | |
| 2028 | + } | |
| 2029 | + } | |
| 2030 | + | |
| 2031 | + function yatraInitDatePickers() { | |
| 2032 | + yatraUpgradeDateFields(document); | |
| 2033 | + // The traveler form is injected via AJAX, so watch for inserted inputs. | |
| 2034 | + if (typeof MutationObserver === 'undefined' || !document.body) return; | |
| 2035 | + var obs = new MutationObserver(function (mutations) { | |
| 2036 | + for (var m = 0; m < mutations.length; m++) { | |
| 2037 | + var added = mutations[m].addedNodes; | |
| 2038 | + for (var n = 0; n < added.length; n++) { | |
| 2039 | + var node = added[n]; | |
| 2040 | + if (!node || node.nodeType !== 1) continue; | |
| 2041 | + if (node.matches && node.matches('input.yatra-js-date')) { | |
| 2042 | + yatraUpgradeDateField(node); | |
| 2043 | + } else if (node.querySelectorAll) { | |
| 2044 | + yatraUpgradeDateFields(node); | |
| 2045 | + } | |
| 2046 | + } | |
| 2047 | + } | |
| 2048 | + }); | |
| 2049 | + obs.observe(document.body, { childList: true, subtree: true }); | |
| 2050 | + } | |
| 2051 | + | |
| 2052 | + if (document.readyState === 'loading') { | |
| 2053 | + document.addEventListener('DOMContentLoaded', yatraInitDatePickers); | |
| 2054 | + } else { | |
| 2055 | + yatraInitDatePickers(); | |
| 2056 | + } | |
| 1856 | 2057 | |
| 1857 | 2058 | // End of document ready |
| 1858 | 2059 | })(jQuery); |