| @@ -5,20 +5,43 @@ | ||
| 5 | 5 | /* jshint esversion: 6 */ |
| 6 | 6 | |
| 7 | 7 | // Translation helpers (booking.js installs the same shims if it runs first; |
| 8 | 8 | // idempotent setup so either load order works). |
| 9 | +// Parse a numeric min/max attribute while preserving a legitimate 0. | |
| 10 | +// `parseInt(el.getAttribute('max')) || 99` treated max="0" — what a sold-out | |
| 11 | +// departure renders — as "unset" and fell back to the placeholder ceiling, so | |
| 12 | +// the guest selector stayed usable and ignored the departure's real capacity. | |
| 13 | +// Only an absent or non-numeric value may fall back. Installed once and shared | |
| 14 | +// by every frontend script, so load order does not matter. | |
| 15 | +if (typeof window.yatraNumOr !== 'function') { | |
| 16 | + window.yatraNumOr = function (raw, fallback) { | |
| 17 | + var parsed = parseInt(raw, 10); | |
| 18 | + return isNaN(parsed) ? fallback : parsed; | |
| 19 | + }; | |
| 20 | +} | |
| 21 | + | |
| 9 | 22 | (function () { |
| 10 | - if (typeof window.__ === 'function') return; | |
| 11 | - if (window.wp && window.wp.i18n && typeof window.wp.i18n.__ === 'function') { | |
| 12 | - window.__ = function (text, domain) { return window.wp.i18n.__(text, domain || 'yatra'); }; | |
| 13 | - window._n = function (s, p, n, domain) { return window.wp.i18n._n(s, p, n, domain || 'yatra'); }; | |
| 14 | - window._x = function (text, ctx, domain) { return window.wp.i18n._x(text, ctx, domain || 'yatra'); }; | |
| 15 | - window.sprintf = window.sprintf || (window.wp.i18n.sprintf || function (fmt) { return fmt; }); | |
| 23 | + var i18n = (window.wp && window.wp.i18n) ? window.wp.i18n : null; | |
| 24 | + if (i18n && typeof i18n.__ === 'function') { | |
| 25 | + window.__ = function (text, domain) { return i18n.__(text, domain || 'yatra'); }; | |
| 26 | + window._n = function (s, p, n, domain) { return i18n._n(s, p, n, domain || 'yatra'); }; | |
| 27 | + window._x = function (text, ctx, domain) { return i18n._x(text, ctx, domain || 'yatra'); }; | |
| 28 | + // Always bind WordPress's own sprintf (it substitutes %d/%s/%1$s). | |
| 29 | + // Do NOT defer to a pre-existing window.sprintf: a theme or other | |
| 30 | + // plugin can define an incompatible global that leaves placeholders | |
| 31 | + // unsubstituted (the cause of literal "%d"/"%s" in the UI). | |
| 32 | + if (typeof i18n.sprintf === 'function') { | |
| 33 | + window.sprintf = function () { return i18n.sprintf.apply(i18n, arguments); }; | |
| 34 | + } else if (typeof window.sprintf !== 'function') { | |
| 35 | + window.sprintf = function (fmt) { return fmt; }; | |
| 36 | + } | |
| 16 | 37 | } else { |
| 17 | - window.__ = function (text) { return text; }; | |
| 18 | - window._n = function (s, p, n) { return n === 1 ? s : p; }; | |
| 19 | - window._x = function (text) { return text; }; | |
| 20 | - window.sprintf = window.sprintf || function (fmt) { return fmt; }; | |
| 38 | + // wp.i18n not present — install passthroughs only if nothing usable | |
| 39 | + // exists yet (don't clobber a real binding installed earlier). | |
| 40 | + if (typeof window.__ !== 'function') window.__ = function (text) { return text; }; | |
| 41 | + if (typeof window._n !== 'function') window._n = function (s, p, n) { return n === 1 ? s : p; }; | |
| 42 | + if (typeof window._x !== 'function') window._x = function (text) { return text; }; | |
| 43 | + if (typeof window.sprintf !== 'function') window.sprintf = function (fmt) { return fmt; }; | |
| 21 | 44 | } |
| 22 | 45 | })(); |
| 23 | 46 | |
| 24 | 47 | (function() { |
| @@ -182,9 +205,14 @@ | ||
| 182 | 205 | const decimalSep = (typeof cfg.decimalSeparator === 'string') ? cfg.decimalSeparator : '.'; |
| 183 | 206 | |
| 184 | 207 | const num = Number(amount) || 0; |
| 185 | 208 | |
| 186 | - const formatted = new Intl.NumberFormat(undefined, { | |
| 209 | + // Format with a FIXED 'en-US' base (grouping ',', decimal '.') so the | |
| 210 | + // replace-with-configured-separators below is deterministic. With the | |
| 211 | + // viewer's locale (undefined), a European browser already yields | |
| 212 | + // '1.234,56' and the naive comma/dot replacement SWAPS the separators — | |
| 213 | + // making the configured decimal separator appear ignored. | |
| 214 | + const formatted = new Intl.NumberFormat('en-US', { | |
| 187 | 215 | minimumFractionDigits: decimals, |
| 188 | 216 | maximumFractionDigits: decimals, |
| 189 | 217 | }).format(num) |
| 190 | 218 | .replace(/,/g, 'TEMP_THOUSAND') |
| @@ -1259,11 +1287,11 @@ | ||
| 1259 | 1287 | container.appendChild(overlay); |
| 1260 | 1288 | } |
| 1261 | 1289 | |
| 1262 | 1290 | formatFileSize(bytes) { |
| 1263 | - if (bytes === 0) return '0 Bytes'; | |
| 1291 | + if (bytes === 0) return '0 ' + __('Bytes', 'yatra'); | |
| 1264 | 1292 | const k = 1024; |
| 1265 | - const sizes = ['Bytes', 'KB', 'MB', 'GB']; | |
| 1293 | + const sizes = [__('Bytes', 'yatra'), 'KB', 'MB', 'GB']; | |
| 1266 | 1294 | const i = Math.floor(Math.log(bytes) / Math.log(k)); |
| 1267 | 1295 | return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; |
| 1268 | 1296 | } |
| 1269 | 1297 | |
| @@ -1581,10 +1609,10 @@ | ||
| 1581 | 1609 | if (!minusBtn || !plusBtn) return; |
| 1582 | 1610 | |
| 1583 | 1611 | const updateButtonStates = () => { |
| 1584 | 1612 | const current = parseInt(input.value) || 1; |
| 1585 | - const min = parseInt(input.getAttribute('min')) || 1; | |
| 1586 | - const max = parseInt(input.getAttribute('max')) || 20; | |
| 1613 | + const min = window.yatraNumOr(input.getAttribute('min'), 1); | |
| 1614 | + const max = window.yatraNumOr(input.getAttribute('max'), 20); | |
| 1587 | 1615 | |
| 1588 | 1616 | minusBtn.disabled = current <= min; |
| 1589 | 1617 | plusBtn.disabled = current >= max; |
| 1590 | 1618 | }; |
| @@ -1594,9 +1622,9 @@ | ||
| 1594 | 1622 | e.preventDefault(); |
| 1595 | 1623 | e.stopPropagation(); |
| 1596 | 1624 | |
| 1597 | 1625 | const current = parseInt(input.value) || 1; |
| 1598 | - const min = parseInt(input.getAttribute('min')) || 1; | |
| 1626 | + const min = window.yatraNumOr(input.getAttribute('min'), 1); | |
| 1599 | 1627 | |
| 1600 | 1628 | if (current > min) { |
| 1601 | 1629 | input.value = current - 1; |
| 1602 | 1630 | updateButtonStates(); |
| @@ -1609,9 +1637,9 @@ | ||
| 1609 | 1637 | e.preventDefault(); |
| 1610 | 1638 | e.stopPropagation(); |
| 1611 | 1639 | |
| 1612 | 1640 | const current = parseInt(input.value) || 1; |
| 1613 | - const max = parseInt(input.getAttribute('max')) || 20; | |
| 1641 | + const max = window.yatraNumOr(input.getAttribute('max'), 20); | |
| 1614 | 1642 | |
| 1615 | 1643 | if (current < max) { |
| 1616 | 1644 | input.value = current + 1; |
| 1617 | 1645 | updateButtonStates(); |
| @@ -2355,15 +2383,15 @@ | ||
| 2355 | 2383 | const row = travelerSelector.querySelector('.yatra-quantity-row[data-category-id="' + categoryId + '"]'); |
| 2356 | 2384 | if (row) { |
| 2357 | 2385 | const cardInput = row.querySelector('input[type="number"]'); |
| 2358 | 2386 | if (cardInput) { |
| 2359 | - const max = parseInt(cardInput.getAttribute('max')) || 99; | |
| 2387 | + const max = window.yatraNumOr(cardInput.getAttribute('max'), 99); | |
| 2360 | 2388 | cardInput.value = Math.min(value, max); |
| 2361 | 2389 | |
| 2362 | 2390 | // Update button states |
| 2363 | 2391 | const minusBtn = row.querySelector('.yatra-quantity-minus'); |
| 2364 | 2392 | const plusBtn = row.querySelector('.yatra-quantity-plus'); |
| 2365 | - const min = parseInt(cardInput.getAttribute('min')) || 0; | |
| 2393 | + const min = window.yatraNumOr(cardInput.getAttribute('min'), 0); | |
| 2366 | 2394 | if (minusBtn) minusBtn.disabled = cardInput.value <= min; |
| 2367 | 2395 | if (plusBtn) plusBtn.disabled = cardInput.value >= max; |
| 2368 | 2396 | } |
| 2369 | 2397 | } |
| @@ -2374,9 +2402,9 @@ | ||
| 2374 | 2402 | |
| 2375 | 2403 | } else if (simpleTravelers && numTravelersInput) { |
| 2376 | 2404 | // Simple pricing - sync total count |
| 2377 | 2405 | const totalCount = parseInt(numTravelersInput.value) || 1; |
| 2378 | - const max = parseInt(simpleTravelers.getAttribute('max')) || 99; | |
| 2406 | + const max = window.yatraNumOr(simpleTravelers.getAttribute('max'), 99); | |
| 2379 | 2407 | simpleTravelers.value = Math.min(totalCount, max); |
| 2380 | 2408 | |
| 2381 | 2409 | // Update button states |
| 2382 | 2410 | const controls = simpleTravelers.closest('.yatra-quantity-controls-inline'); |
| @@ -2382,9 +2410,9 @@ | ||
| 2382 | 2410 | const controls = simpleTravelers.closest('.yatra-quantity-controls-inline'); |
| 2383 | 2411 | if (controls) { |
| 2384 | 2412 | const minusBtn = controls.querySelector('.yatra-quantity-minus'); |
| 2385 | 2413 | const plusBtn = controls.querySelector('.yatra-quantity-plus'); |
| 2386 | - const min = parseInt(simpleTravelers.getAttribute('min')) || 1; | |
| 2414 | + const min = window.yatraNumOr(simpleTravelers.getAttribute('min'), 1); | |
| 2387 | 2415 | if (minusBtn) minusBtn.disabled = simpleTravelers.value <= min; |
| 2388 | 2416 | if (plusBtn) plusBtn.disabled = simpleTravelers.value >= max; |
| 2389 | 2417 | } |
| 2390 | 2418 | } |
| @@ -2447,13 +2475,17 @@ | ||
| 2447 | 2475 | const input = row.querySelector('input[type="number"]'); |
| 2448 | 2476 | const price = parseFloat(row.getAttribute('data-price')) || 0; |
| 2449 | 2477 | const count = parseInt(input?.value) || 0; |
| 2450 | 2478 | const pricingMode = row.getAttribute('data-pricing-mode') || (input ? input.getAttribute('data-pricing-mode') : '') || 'per_person'; |
| 2451 | - | |
| 2479 | + const groupOverflow = row.getAttribute('data-group-overflow') || (input ? input.getAttribute('data-group-overflow') : '') || 'block'; | |
| 2480 | + const maxPax = parseInt(row.getAttribute('data-max-pax') || (input ? input.getAttribute('data-max-pax') : '')) || 0; | |
| 2481 | + | |
| 2452 | 2482 | if (pricingMode === 'per_group') { |
| 2453 | - // Per group: charge flat price once if any travelers | |
| 2483 | + // Per group: one flat price, or per block of maxPax when "per_block". | |
| 2454 | 2484 | if (count > 0) { |
| 2455 | - total += price; | |
| 2485 | + total += (groupOverflow === 'per_block' && maxPax > 0) | |
| 2486 | + ? price * Math.ceil(count / maxPax) | |
| 2487 | + : price; | |
| 2456 | 2488 | } |
| 2457 | 2489 | } else { |
| 2458 | 2490 | // Per person: charge per traveler |
| 2459 | 2491 | total += price * count; |
| @@ -2832,16 +2864,28 @@ | ||
| 2832 | 2864 | const enquiriesUrl = baseUrlRaw.includes('/yatra/v1') |
| 2833 | 2865 | ? `${baseUrlRaw}/enquiries` |
| 2834 | 2866 | : `${baseUrlRaw}/yatra/v1/enquiries`; |
| 2835 | 2867 | |
| 2836 | - fetch(enquiriesUrl, { | |
| 2837 | - method: 'POST', | |
| 2838 | - headers: { | |
| 2839 | - 'Content-Type': 'application/json', | |
| 2840 | - }, | |
| 2841 | - credentials: 'omit', // public endpoint | |
| 2842 | - body: JSON.stringify(payload), | |
| 2843 | - }) | |
| 2868 | + // reCAPTCHA v3: attach a fresh token when the enquiry form is protected. | |
| 2869 | + const yatraRc = window.yatraRecaptcha; | |
| 2870 | + const enquiryTokenPromise = (yatraRc && yatraRc.protects && yatraRc.protects('enquiry')) | |
| 2871 | + ? yatraRc.execute('enquiry') | |
| 2872 | + : Promise.resolve(''); | |
| 2873 | + | |
| 2874 | + enquiryTokenPromise | |
| 2875 | + .then(recaptchaToken => { | |
| 2876 | + if (recaptchaToken) { | |
| 2877 | + payload.recaptcha_token = recaptchaToken; | |
| 2878 | + } | |
| 2879 | + return fetch(enquiriesUrl, { | |
| 2880 | + method: 'POST', | |
| 2881 | + headers: { | |
| 2882 | + 'Content-Type': 'application/json', | |
| 2883 | + }, | |
| 2884 | + credentials: 'omit', // public endpoint | |
| 2885 | + body: JSON.stringify(payload), | |
| 2886 | + }); | |
| 2887 | + }) | |
| 2844 | 2888 | .then(response => response.json()) |
| 2845 | 2889 | .then(result => { |
| 2846 | 2890 | if (result.success) { |
| 2847 | 2891 | this.showMessage('success', result.message || result.data?.message || __('Thank you for your enquiry! We will get back to you soon.', 'yatra')); |
| @@ -3534,9 +3578,9 @@ | ||
| 3534 | 3578 | currency: window.yatraBookingData?.currency || 'USD', |
| 3535 | 3579 | num_items: sessionPayload.travelers || 1 |
| 3536 | 3580 | }); |
| 3537 | 3581 | |
| 3538 | - console.log('[Facebook Pixel] InitiateCheckout tracked for trip:', sessionPayload.trip_id); | |
| 3582 | + window.YATRA_DEBUG && console.log('[Facebook Pixel] InitiateCheckout tracked for trip:', sessionPayload.trip_id); | |
| 3539 | 3583 | } |
| 3540 | 3584 | |
| 3541 | 3585 | // Track Book Now click as begin_checkout (Google Analytics 4) |
| 3542 | 3586 | if (typeof gtag !== 'undefined' && window.yatraAdmin?.googleAnalytics?.measurementId) { |
| @@ -3554,9 +3598,9 @@ | ||
| 3554 | 3598 | quantity: sessionPayload.travelers || 1 |
| 3555 | 3599 | }] |
| 3556 | 3600 | }); |
| 3557 | 3601 | |
| 3558 | - console.log('[Google Analytics] begin_checkout tracked for trip:', sessionPayload.trip_id); | |
| 3602 | + window.YATRA_DEBUG && console.log('[Google Analytics] begin_checkout tracked for trip:', sessionPayload.trip_id); | |
| 3559 | 3603 | } |
| 3560 | 3604 | |
| 3561 | 3605 | // Set booking session via REST API |
| 3562 | 3606 | // credentials: 'same-origin' is required to send cookies for session |
| @@ -3664,10 +3708,10 @@ | ||
| 3664 | 3708 | const isMinus = btn.classList.contains('yatra-quantity-minus'); |
| 3665 | 3709 | const isPlus = btn.classList.contains('yatra-quantity-plus'); |
| 3666 | 3710 | |
| 3667 | 3711 | let current = parseInt(input.value) || 0; |
| 3668 | - const min = parseInt(input.getAttribute('min')) || 0; | |
| 3669 | - const max = parseInt(input.getAttribute('max')) || 999; | |
| 3712 | + const min = window.yatraNumOr(input.getAttribute('min'), 0); | |
| 3713 | + const max = window.yatraNumOr(input.getAttribute('max'), 999); | |
| 3670 | 3714 | let newValue = current; |
| 3671 | 3715 | |
| 3672 | 3716 | if (isPlus && current < max) { |
| 3673 | 3717 | newValue = current + 1; |
| @@ -3739,10 +3783,10 @@ | ||
| 3739 | 3783 | const isMinus = btn.classList.contains('yatra-quantity-minus'); |
| 3740 | 3784 | const isPlus = btn.classList.contains('yatra-quantity-plus'); |
| 3741 | 3785 | |
| 3742 | 3786 | let current = parseInt(input.value) || 1; |
| 3743 | - const min = parseInt(input.getAttribute('min')) || 1; | |
| 3744 | - const max = parseInt(input.getAttribute('max')) || 999; | |
| 3787 | + const min = window.yatraNumOr(input.getAttribute('min'), 1); | |
| 3788 | + const max = window.yatraNumOr(input.getAttribute('max'), 999); | |
| 3745 | 3789 | let newValue = current; |
| 3746 | 3790 | |
| 3747 | 3791 | if (isPlus && current < max) { |
| 3748 | 3792 | newValue = current + 1; |
| @@ -3852,8 +3896,9 @@ | ||
| 3852 | 3896 | // Check for traveler-based pricing first |
| 3853 | 3897 | const categoryInputs = this.section.querySelectorAll(`.yatra-availability-category[data-item="${itemIndex}"]`); |
| 3854 | 3898 | let totalPrice = 0; |
| 3855 | 3899 | let totalTravelers = 0; |
| 3900 | + let totalGroups = 0; // per-group categories: number of group prices charged | |
| 3856 | 3901 | |
| 3857 | 3902 | if (categoryInputs.length > 0) { |
| 3858 | 3903 | // Traveler-based pricing: sum up each category's price * quantity (respecting pricing mode) |
| 3859 | 3904 | categoryInputs.forEach((input) => { |
| @@ -3860,12 +3905,16 @@ | ||
| 3860 | 3905 | const quantity = parseInt(input.value) || 0; |
| 3861 | 3906 | const row = input.closest('.yatra-quantity-row'); |
| 3862 | 3907 | const price = row ? parseFloat(row.getAttribute('data-price')) || 0 : 0; |
| 3863 | 3908 | const pricingMode = (row ? row.getAttribute('data-pricing-mode') : null) || input.getAttribute('data-pricing-mode') || 'per_person'; |
| 3864 | - | |
| 3909 | + const groupOverflow = (row ? row.getAttribute('data-group-overflow') : null) || input.getAttribute('data-group-overflow') || 'block'; | |
| 3910 | + const maxPax = parseInt((row ? row.getAttribute('data-max-pax') : null) || input.getAttribute('data-max-pax')) || 0; | |
| 3911 | + | |
| 3865 | 3912 | if (pricingMode === 'per_group') { |
| 3866 | 3913 | if (quantity > 0) { |
| 3867 | - totalPrice += price; | |
| 3914 | + const blocks = (groupOverflow === 'per_block' && maxPax > 0) ? Math.ceil(quantity / maxPax) : 1; | |
| 3915 | + totalPrice += price * blocks; | |
| 3916 | + totalGroups += blocks; | |
| 3868 | 3917 | } |
| 3869 | 3918 | } else { |
| 3870 | 3919 | totalPrice += quantity * price; |
| 3871 | 3920 | } |
| @@ -3893,14 +3942,21 @@ | ||
| 3893 | 3942 | } else { |
| 3894 | 3943 | totalAmountElement.textContent = String(finalPrice.toFixed(2)); |
| 3895 | 3944 | } |
| 3896 | 3945 | |
| 3897 | - // Header "per person" must match chargable average (row totals + group discount), not static PHP. | |
| 3946 | + // Header price must match the chargeable amount (row totals + group discount), not static PHP. | |
| 3947 | + // Its unit follows the card's price label: "per person" → average per traveller; | |
| 3948 | + // "per group" → the price per group actually charged (never divided by headcount, | |
| 3949 | + // which would turn a flat $1,100 group price into "$550 per group" for two travellers). | |
| 3898 | 3950 | const cardRoot = this.section.querySelector('.yatra-availability-card[data-item="' + itemIndex + '"]'); |
| 3899 | - const headerSaleEl = cardRoot ? cardRoot.querySelector('.yatra-card-header-price .yatra-sale-price') : null; | |
| 3951 | + const headerPriceWrap = cardRoot ? cardRoot.querySelector('.yatra-card-header-price') : null; | |
| 3952 | + const headerSaleEl = headerPriceWrap ? headerPriceWrap.querySelector('.yatra-sale-price') : null; | |
| 3900 | 3953 | if (headerSaleEl) { |
| 3954 | + const headerUnit = headerPriceWrap.getAttribute('data-price-unit') || 'per_person'; | |
| 3901 | 3955 | let perPerson = 0; |
| 3902 | - if (totalTravelers > 0) { | |
| 3956 | + if (headerUnit === 'per_group') { | |
| 3957 | + perPerson = totalGroups > 0 ? finalPrice / totalGroups : (totalPrice > 0 ? totalPrice : 0); | |
| 3958 | + } else if (totalTravelers > 0) { | |
| 3903 | 3959 | perPerson = finalPrice / totalTravelers; |
| 3904 | 3960 | } else if (totalPrice > 0) { |
| 3905 | 3961 | perPerson = totalPrice; |
| 3906 | 3962 | } |
| @@ -5187,9 +5243,9 @@ | ||
| 5187 | 5243 | // Extract video ID from URL |
| 5188 | 5244 | let videoId = ''; |
| 5189 | 5245 | if (videoUrl.includes('youtube.com') || videoUrl.includes('youtu.be')) { |
| 5190 | 5246 | // YouTube video ID extraction |
| 5191 | - const matches = videoUrl.match(/(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/)([^&\n?#]+)/); | |
| 5247 | + const matches = videoUrl.match(/(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/|youtube\.com\/shorts\/|youtube\.com\/live\/)([^&\n?#\/]+)/); | |
| 5192 | 5248 | videoId = matches ? matches[1] : ''; |
| 5193 | 5249 | } else if (videoUrl.includes('vimeo.com')) { |
| 5194 | 5250 | // Vimeo video ID extraction |
| 5195 | 5251 | const matches = videoUrl.match(/vimeo\.com\/(?:.*#|.\/videos\/)?([0-9]+)/); |