| @@ -9,8 +9,49 @@ | ||
| 9 | 9 | * |
| 10 | 10 | * @package Yatra |
| 11 | 11 | */ |
| 12 | 12 | |
| 13 | +// Translation helpers — idempotent shim matching booking.js / trip.js so | |
| 14 | +// `__()` resolves through wp.i18n when wp-i18n is enqueued, otherwise it | |
| 15 | +// falls back to the source string. | |
| 16 | +// Parse a numeric min/max attribute while preserving a legitimate 0. | |
| 17 | +// `parseInt(el.getAttribute('max')) || 99` treated max="0" — what a sold-out | |
| 18 | +// departure renders — as "unset" and fell back to the placeholder ceiling, so | |
| 19 | +// the guest selector stayed usable and ignored the departure's real capacity. | |
| 20 | +// Only an absent or non-numeric value may fall back. Installed once and shared | |
| 21 | +// by every frontend script, so load order does not matter. | |
| 22 | +if (typeof window.yatraNumOr !== 'function') { | |
| 23 | + window.yatraNumOr = function (raw, fallback) { | |
| 24 | + var parsed = parseInt(raw, 10); | |
| 25 | + return isNaN(parsed) ? fallback : parsed; | |
| 26 | + }; | |
| 27 | +} | |
| 28 | + | |
| 29 | +(function () { | |
| 30 | + var i18n = (window.wp && window.wp.i18n) ? window.wp.i18n : null; | |
| 31 | + if (i18n && typeof i18n.__ === 'function') { | |
| 32 | + window.__ = function (text, domain) { return i18n.__(text, domain || 'yatra'); }; | |
| 33 | + window._n = function (s, p, n, domain) { return i18n._n(s, p, n, domain || 'yatra'); }; | |
| 34 | + window._x = function (text, ctx, domain) { return i18n._x(text, ctx, domain || 'yatra'); }; | |
| 35 | + // Always bind WordPress's own sprintf (it substitutes %d/%s/%1$s). | |
| 36 | + // Do NOT defer to a pre-existing window.sprintf: a theme or other | |
| 37 | + // plugin can define an incompatible global that leaves placeholders | |
| 38 | + // unsubstituted (the cause of literal "%d"/"%s" in the UI). | |
| 39 | + if (typeof i18n.sprintf === 'function') { | |
| 40 | + window.sprintf = function () { return i18n.sprintf.apply(i18n, arguments); }; | |
| 41 | + } else if (typeof window.sprintf !== 'function') { | |
| 42 | + window.sprintf = function (fmt) { return fmt; }; | |
| 43 | + } | |
| 44 | + } else { | |
| 45 | + // wp.i18n not present — install passthroughs only if nothing usable | |
| 46 | + // exists yet (don't clobber a real binding installed earlier). | |
| 47 | + if (typeof window.__ !== 'function') window.__ = function (text) { return text; }; | |
| 48 | + if (typeof window._n !== 'function') window._n = function (s, p, n) { return n === 1 ? s : p; }; | |
| 49 | + if (typeof window._x !== 'function') window._x = function (text) { return text; }; | |
| 50 | + if (typeof window.sprintf !== 'function') window.sprintf = function (fmt) { return fmt; }; | |
| 51 | + } | |
| 52 | +})(); | |
| 53 | + | |
| 13 | 54 | document.addEventListener('DOMContentLoaded', function() { |
| 14 | 55 | function yatraGetFlatpickrLocale() { |
| 15 | 56 | var l = window.yatraTripData && window.yatraTripData.flatpickrLocale; |
| 16 | 57 | if (!l || typeof l !== 'object') { |
| @@ -22,9 +63,72 @@ | ||
| 22 | 63 | } |
| 23 | 64 | return out; |
| 24 | 65 | } |
| 25 | 66 | |
| 67 | + /** | |
| 68 | + * Convert a PHP date() format string to its Flatpickr equivalent. | |
| 69 | + * | |
| 70 | + * The two format spec tables overlap heavily — d/j/D/l/m/n/M/F/Y/y | |
| 71 | + * mean the same thing in both — but a few PHP tokens map to a | |
| 72 | + * different Flatpickr token (e.g. PHP `s` = seconds → Flatpickr | |
| 73 | + * `S`; PHP `a`/`A` AM-PM → Flatpickr `K`). Anything outside the | |
| 74 | + * map (literal punctuation, slashes, spaces) is passed through. | |
| 75 | + * | |
| 76 | + * Used to populate flatpickr's `altFormat`. We keep `dateFormat` | |
| 77 | + * itself locked to `Y-m-d` so the value submitted to the backend | |
| 78 | + * stays stable regardless of the operator's display preference; | |
| 79 | + * only the user-visible input swaps to their configured format. | |
| 80 | + * | |
| 81 | + * Kept in lockstep with the helper of the same shape in trip.js | |
| 82 | + * and the inline mapping in single-trip-sidebar.js — three call | |
| 83 | + * sites because each file ships as its own enqueued bundle and | |
| 84 | + * sharing across them would require a separate dep file. | |
| 85 | + * | |
| 86 | + * @param {string} php PHP date() format from Settings → General → Date format | |
| 87 | + * @returns {string} Flatpickr-compatible format string | |
| 88 | + */ | |
| 89 | + function yatraPhpDateFormatToFlatpickr(php) { | |
| 90 | + if (!php || typeof php !== 'string') return 'F j, Y'; | |
| 91 | + var map = { | |
| 92 | + // Day | |
| 93 | + d: 'd', j: 'j', D: 'D', l: 'l', | |
| 94 | + // Month | |
| 95 | + m: 'm', n: 'n', M: 'M', F: 'F', | |
| 96 | + // Year | |
| 97 | + Y: 'Y', y: 'y', | |
| 98 | + // Time | |
| 99 | + H: 'H', G: 'H', h: 'h', g: 'h', | |
| 100 | + i: 'i', s: 'S', a: 'K', A: 'K' | |
| 101 | + }; | |
| 102 | + var out = ''; | |
| 103 | + var esc = false; | |
| 104 | + for (var i = 0; i < php.length; i++) { | |
| 105 | + var ch = php[i]; | |
| 106 | + if (esc) { out += ch; esc = false; continue; } | |
| 107 | + if (ch === '\\') { esc = true; continue; } | |
| 108 | + out += map[ch] || ch; | |
| 109 | + } | |
| 110 | + return out || 'F j, Y'; | |
| 111 | + } | |
| 112 | + | |
| 113 | + /** | |
| 114 | + * Resolve the operator's configured display format and convert it | |
| 115 | + * for Flatpickr. Falls back to `F j, Y` (e.g. "Jan 5, 2026") when | |
| 116 | + * settings haven't shipped a value. | |
| 117 | + */ | |
| 118 | + function yatraResolveAltFormat() { | |
| 119 | + var cfg = window.yatraTripData || {}; | |
| 120 | + // Pre-converted value wins (set server-side in some bundles); a | |
| 121 | + // raw PHP string is converted on the fly. | |
| 122 | + if (cfg.flatpickrAltFormat || cfg.altDateFormat) { | |
| 123 | + return cfg.flatpickrAltFormat || cfg.altDateFormat; | |
| 124 | + } | |
| 125 | + var php = cfg.dateFormat || cfg.date_format || ''; | |
| 126 | + return yatraPhpDateFormatToFlatpickr(php); | |
| 127 | + } | |
| 128 | + | |
| 26 | 129 | var fpLocale = yatraGetFlatpickrLocale(); |
| 130 | + var fpAltFormat = yatraResolveAltFormat(); | |
| 27 | 131 | |
| 28 | 132 | // Initialize date pickers when DOM is ready. |
| 29 | 133 | // |
| 30 | 134 | // Priority order applied here MUST mirror the server-side |
| @@ -61,9 +165,16 @@ | ||
| 61 | 165 | : []; |
| 62 | 166 | |
| 63 | 167 | if (availabilityDates.length > 0) { |
| 64 | 168 | flatpickr(bookingDateInput, { |
| 169 | + // Backend value stays as Y-m-d (what the booking | |
| 170 | + // form submits); only the user-visible input flips | |
| 171 | + // to the operator's configured Date Format via | |
| 172 | + // altInput/altFormat. Previously hardcoded which | |
| 173 | + // ignored Settings → General → Date format. | |
| 65 | 174 | dateFormat: 'Y-m-d', |
| 175 | + altInput: true, | |
| 176 | + altFormat: fpAltFormat, | |
| 66 | 177 | minDate: 'today', |
| 67 | 178 | enable: availabilityDates, |
| 68 | 179 | disableMobile: false, |
| 69 | 180 | locale: fpLocale |
| @@ -73,9 +184,16 @@ | ||
| 73 | 184 | // the merged date list is empty (e.g. all rules expired). |
| 74 | 185 | // Disable the input rather than silently falling back to |
| 75 | 186 | // "any date", which would violate the configured priority. |
| 76 | 187 | bookingDateInput.setAttribute('disabled', 'disabled'); |
| 77 | - bookingDateInput.setAttribute('placeholder', bookingDateInput.getAttribute('data-empty-text') || 'No departure dates available'); | |
| 188 | + // Fallback placeholder when the template hasn't supplied | |
| 189 | + // `data-empty-text`. Wrapped in __() so the literal string | |
| 190 | + // can be translated; the data-attribute fallback is a | |
| 191 | + // server-rendered string that's already localised. | |
| 192 | + bookingDateInput.setAttribute( | |
| 193 | + 'placeholder', | |
| 194 | + bookingDateInput.getAttribute('data-empty-text') || __('No departure dates available', 'yatra') | |
| 195 | + ); | |
| 78 | 196 | } |
| 79 | 197 | } else { |
| 80 | 198 | // Flexible booking: any future date inside the trip's available |
| 81 | 199 | // window (available_from / available_to are surfaced as |
| @@ -82,10 +200,15 @@ | ||
| 82 | 200 | // data-min-date / data-max-date by the sidebar template). |
| 83 | 201 | const minDate = bookingDateInput.getAttribute('data-min-date') || 'today'; |
| 84 | 202 | const maxDate = bookingDateInput.getAttribute('data-max-date'); |
| 85 | 203 | |
| 204 | + // Flexible-date picker. Same altInput/altFormat pattern | |
| 205 | + // as the availability-restricted branch above — backend | |
| 206 | + // value stable, display matches operator preference. | |
| 86 | 207 | const config = { |
| 87 | 208 | dateFormat: 'Y-m-d', |
| 209 | + altInput: true, | |
| 210 | + altFormat: fpAltFormat, | |
| 88 | 211 | minDate: minDate, |
| 89 | 212 | disableMobile: false, |
| 90 | 213 | locale: fpLocale |
| 91 | 214 | }; |
| @@ -96,14 +219,19 @@ | ||
| 96 | 219 | |
| 97 | 220 | flatpickr(bookingDateInput, config); |
| 98 | 221 | } |
| 99 | 222 | } |
| 100 | - | |
| 101 | - // Enquiry modal date picker (always flexible) | |
| 223 | + | |
| 224 | + // Enquiry modal date picker (always flexible). Same altFormat | |
| 225 | + // treatment so the enquiry form respects the operator's date | |
| 226 | + // format too — previously hardcoded to Y-m-d which jarred with | |
| 227 | + // any non-ISO display preference. | |
| 102 | 228 | const enquiryDateInput = document.getElementById('enquiry-travel-date'); |
| 103 | 229 | if (enquiryDateInput) { |
| 104 | 230 | flatpickr(enquiryDateInput, { |
| 105 | 231 | dateFormat: 'Y-m-d', |
| 232 | + altInput: true, | |
| 233 | + altFormat: fpAltFormat, | |
| 106 | 234 | minDate: 'today', |
| 107 | 235 | disableMobile: false, |
| 108 | 236 | locale: fpLocale |
| 109 | 237 | }); |
| @@ -246,10 +374,16 @@ | ||
| 246 | 374 | |
| 247 | 375 | updatePricing(); |
| 248 | 376 | }); |
| 249 | 377 | |
| 250 | - // Set today's date by default if available, otherwise use trip fallback pricing | |
| 251 | - const today = new Date().toISOString().split('T')[0]; | |
| 378 | + // Set today's date by default if available, otherwise use trip fallback pricing. | |
| 379 | + // Build LOCAL YYYY-MM-DD, not toISOString() (which is UTC and can land on | |
| 380 | + // the wrong calendar day — mis-matching the availability's departure_date | |
| 381 | + // and pre-selecting the wrong date near midnight). | |
| 382 | + const _now = new Date(); | |
| 383 | + const today = _now.getFullYear() + '-' | |
| 384 | + + String(_now.getMonth() + 1).padStart(2, '0') + '-' | |
| 385 | + + String(_now.getDate()).padStart(2, '0'); | |
| 252 | 386 | if (!dateInput.value) { |
| 253 | 387 | const availabilities = getSidebarAvailabilities(); |
| 254 | 388 | if (availabilities.length > 0) { |
| 255 | 389 | const todayAvailability = availabilities.find(a => a.departure_date === today); |
| @@ -277,13 +411,18 @@ | ||
| 277 | 411 | categoryInputs.forEach(input => { |
| 278 | 412 | const count = parseInt(input.value) || 0; |
| 279 | 413 | const price = parseFloat(input.dataset.price) || 0; |
| 280 | 414 | const pricingMode = input.dataset.pricingMode || 'per_person'; |
| 281 | - | |
| 415 | + const groupOverflow = input.dataset.groupOverflow || 'block'; | |
| 416 | + const maxPax = parseInt(input.dataset.maxPax) || 0; | |
| 417 | + | |
| 282 | 418 | if (pricingMode === 'per_group') { |
| 283 | - // Per group: charge flat price once if any travelers | |
| 419 | + // Per group: one flat price, or per block of maxPax people | |
| 420 | + // when overflow is "per_block". | |
| 284 | 421 | if (count > 0) { |
| 285 | - subtotal += price; | |
| 422 | + subtotal += (groupOverflow === 'per_block' && maxPax > 0) | |
| 423 | + ? price * Math.ceil(count / maxPax) | |
| 424 | + : price; | |
| 286 | 425 | } |
| 287 | 426 | } else { |
| 288 | 427 | // Per person: charge per traveler |
| 289 | 428 | subtotal += price * count; |
| @@ -331,9 +470,9 @@ | ||
| 331 | 470 | <div class="yatra-group-discount-badge"> |
| 332 | 471 | <svg width="14" height="14" fill="currentColor" viewBox="0 0 20 20"> |
| 333 | 472 | <path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/> |
| 334 | 473 | </svg> |
| 335 | - Group discount applied: -${window.yatraTripData?.currencySymbol || '$'}${discountAmount.toFixed(2)} | |
| 474 | + Group discount applied: -${typeof window.yatra_format_price_js === 'function' ? window.yatra_format_price_js(discountAmount) : (window.yatraTripData?.currencySymbol || '$') + discountAmount.toFixed(2)} | |
| 336 | 475 | </div> |
| 337 | 476 | `; |
| 338 | 477 | } else if (indicator) { |
| 339 | 478 | indicator.remove(); |
| @@ -374,12 +513,19 @@ | ||
| 374 | 513 | // Traveler-based pricing - set adults to group size |
| 375 | 514 | const adultInput = document.querySelector('input[name*="travelers[adults]"]'); |
| 376 | 515 | if (adultInput) { |
| 377 | 516 | adultInput.value = groupSize; |
| 378 | - // Update display | |
| 517 | + // Update display. The literal "Adult x %d" | |
| 518 | + // is kept as the msgid so translators can | |
| 519 | + // localise both the noun and the format | |
| 520 | + // (some locales put the multiplier first). | |
| 521 | + // Comment phrasing matches the other call | |
| 522 | + // sites in trip.js so WP-CLI sees ONE | |
| 523 | + // canonical translators-comment per msgid. | |
| 379 | 524 | const display = document.querySelector('.yatra-participants-display'); |
| 380 | 525 | if (display) { |
| 381 | - display.textContent = `Adult x ${groupSize}`; | |
| 526 | + // translators: %d: number of adults | |
| 527 | + display.textContent = __('Adult x %d', 'yatra').replace('%d', String(groupSize)); | |
| 382 | 528 | } |
| 383 | 529 | } |
| 384 | 530 | } |
| 385 | 531 | |
| @@ -426,10 +572,10 @@ | ||
| 426 | 572 | } |
| 427 | 573 | |
| 428 | 574 | if (!input) return; |
| 429 | 575 | |
| 430 | - const min = parseInt(input.getAttribute('min')) || 0; | |
| 431 | - const max = parseInt(input.getAttribute('max')) || 99; | |
| 576 | + const min = window.yatraNumOr(input.getAttribute('min'), 0); | |
| 577 | + const max = window.yatraNumOr(input.getAttribute('max'), 99); | |
| 432 | 578 | let value = parseInt(input.value) || 0; |
| 433 | 579 | |
| 434 | 580 | // Update value based on button type |
| 435 | 581 | if (btn.classList.contains('yatra-quantity-plus')) { |
| @@ -485,9 +631,9 @@ | ||
| 485 | 631 | // Regular pricing - sync all num_travelers and enquiry_adults inputs |
| 486 | 632 | const regularInputs = document.querySelectorAll('#num_travelers, #enquiry_adults'); |
| 487 | 633 | regularInputs.forEach(input => { |
| 488 | 634 | if (input !== sourceInput) { |
| 489 | - const max = parseInt(input.getAttribute('max')) || 99; | |
| 635 | + const max = window.yatraNumOr(input.getAttribute('max'), 99); | |
| 490 | 636 | input.value = Math.min(sourceValue, max); |
| 491 | 637 | } |
| 492 | 638 | }); |
| 493 | 639 | |
| @@ -494,9 +640,9 @@ | ||
| 494 | 640 | // Also sync to availability section inputs |
| 495 | 641 | const availabilityInputs = document.querySelectorAll('input[id^="num-travelers-"]'); |
| 496 | 642 | availabilityInputs.forEach(input => { |
| 497 | 643 | if (input !== sourceInput) { |
| 498 | - const max = parseInt(input.getAttribute('max')) || 99; | |
| 644 | + const max = window.yatraNumOr(input.getAttribute('max'), 99); | |
| 499 | 645 | input.value = Math.min(sourceValue, max); |
| 500 | 646 | } |
| 501 | 647 | }); |
| 502 | 648 | |
| @@ -521,15 +667,15 @@ | ||
| 521 | 667 | ); |
| 522 | 668 | |
| 523 | 669 | categoryInputs.forEach(input => { |
| 524 | 670 | if (input !== sourceInput) { |
| 525 | - const max = parseInt(input.getAttribute('max')) || 99; | |
| 671 | + const max = window.yatraNumOr(input.getAttribute('max'), 99); | |
| 526 | 672 | input.value = Math.min(sourceValue, max); |
| 527 | 673 | |
| 528 | 674 | // Update button states for this input |
| 529 | 675 | const row = input.closest('.yatra-quantity-row'); |
| 530 | 676 | if (row) { |
| 531 | - const min = parseInt(input.getAttribute('min')) || 0; | |
| 677 | + const min = window.yatraNumOr(input.getAttribute('min'), 0); | |
| 532 | 678 | const minusBtn = row.querySelector('.yatra-quantity-minus'); |
| 533 | 679 | const plusBtn = row.querySelector('.yatra-quantity-plus'); |
| 534 | 680 | if (minusBtn) minusBtn.disabled = input.value <= min; |
| 535 | 681 | if (plusBtn) plusBtn.disabled = input.value >= max; |
| @@ -563,10 +709,15 @@ | ||
| 563 | 709 | |
| 564 | 710 | inputs.forEach(input => { |
| 565 | 711 | const value = parseInt(input.value) || 0; |
| 566 | 712 | if (value > 0) { |
| 567 | - const label = input.getAttribute('data-category-label') || 'Traveler'; | |
| 568 | - parts.push(`${label} x ${value}`); | |
| 713 | + const label = input.getAttribute('data-category-label') || __('Traveler', 'yatra'); | |
| 714 | + parts.push(sprintf( | |
| 715 | + /* translators: 1: traveler category label, 2: count. */ | |
| 716 | + __('%1$s x %2$d', 'yatra'), | |
| 717 | + label, | |
| 718 | + value | |
| 719 | + )); | |
| 569 | 720 | total += value; |
| 570 | 721 | } |
| 571 | 722 | }); |
| 572 | 723 | |
| @@ -572,9 +723,9 @@ | ||
| 572 | 723 | |
| 573 | 724 | if (parts.length > 0) { |
| 574 | 725 | display.textContent = parts.join(', '); |
| 575 | 726 | } else { |
| 576 | - display.textContent = 'Select travelers'; | |
| 727 | + display.textContent = __('Select travelers', 'yatra'); | |
| 577 | 728 | } |
| 578 | 729 | }); |
| 579 | 730 | } |
| 580 | 731 | }); |