/** * Single Trip Page JavaScript * * Handles frontend interactions for the single trip page including: * - Group discount calculations * - Traveler count management * - Pricing updates * - Flatpickr initialization * * @package Yatra */ // Translation helpers — idempotent shim matching booking.js / trip.js so // `__()` resolves through wp.i18n when wp-i18n is enqueued, otherwise it // falls back to the source string. // Parse a numeric min/max attribute while preserving a legitimate 0. // `parseInt(el.getAttribute('max')) || 99` treated max="0" — what a sold-out // departure renders — as "unset" and fell back to the placeholder ceiling, so // the guest selector stayed usable and ignored the departure's real capacity. // Only an absent or non-numeric value may fall back. Installed once and shared // by every frontend script, so load order does not matter. if (typeof window.yatraNumOr !== 'function') { window.yatraNumOr = function (raw, fallback) { var parsed = parseInt(raw, 10); return isNaN(parsed) ? fallback : parsed; }; } (function () { var i18n = (window.wp && window.wp.i18n) ? window.wp.i18n : null; if (i18n && typeof i18n.__ === 'function') { window.__ = function (text, domain) { return i18n.__(text, domain || 'yatra'); }; window._n = function (s, p, n, domain) { return i18n._n(s, p, n, domain || 'yatra'); }; window._x = function (text, ctx, domain) { return i18n._x(text, ctx, domain || 'yatra'); }; // Always bind WordPress's own sprintf (it substitutes %d/%s/%1$s). // Do NOT defer to a pre-existing window.sprintf: a theme or other // plugin can define an incompatible global that leaves placeholders // unsubstituted (the cause of literal "%d"/"%s" in the UI). if (typeof i18n.sprintf === 'function') { window.sprintf = function () { return i18n.sprintf.apply(i18n, arguments); }; } else if (typeof window.sprintf !== 'function') { window.sprintf = function (fmt) { return fmt; }; } } else { // wp.i18n not present — install passthroughs only if nothing usable // exists yet (don't clobber a real binding installed earlier). if (typeof window.__ !== 'function') window.__ = function (text) { return text; }; if (typeof window._n !== 'function') window._n = function (s, p, n) { return n === 1 ? s : p; }; if (typeof window._x !== 'function') window._x = function (text) { return text; }; if (typeof window.sprintf !== 'function') window.sprintf = function (fmt) { return fmt; }; } })(); document.addEventListener('DOMContentLoaded', function() { function yatraGetFlatpickrLocale() { var l = window.yatraTripData && window.yatraTripData.flatpickrLocale; if (!l || typeof l !== 'object') { return { firstDayOfWeek: 1 }; } var out = Object.assign({}, l); if (typeof out.firstDayOfWeek !== 'number' || isNaN(out.firstDayOfWeek)) { out.firstDayOfWeek = 1; } return out; } /** * Convert a PHP date() format string to its Flatpickr equivalent. * * The two format spec tables overlap heavily — d/j/D/l/m/n/M/F/Y/y * mean the same thing in both — but a few PHP tokens map to a * different Flatpickr token (e.g. PHP `s` = seconds → Flatpickr * `S`; PHP `a`/`A` AM-PM → Flatpickr `K`). Anything outside the * map (literal punctuation, slashes, spaces) is passed through. * * Used to populate flatpickr's `altFormat`. We keep `dateFormat` * itself locked to `Y-m-d` so the value submitted to the backend * stays stable regardless of the operator's display preference; * only the user-visible input swaps to their configured format. * * Kept in lockstep with the helper of the same shape in trip.js * and the inline mapping in single-trip-sidebar.js — three call * sites because each file ships as its own enqueued bundle and * sharing across them would require a separate dep file. * * @param {string} php PHP date() format from Settings → General → Date format * @returns {string} Flatpickr-compatible format string */ function yatraPhpDateFormatToFlatpickr(php) { if (!php || typeof php !== 'string') return 'F j, Y'; var map = { // Day d: 'd', j: 'j', D: 'D', l: 'l', // Month m: 'm', n: 'n', M: 'M', F: 'F', // Year Y: 'Y', y: 'y', // Time H: 'H', G: 'H', h: 'h', g: 'h', i: 'i', s: 'S', a: 'K', A: 'K' }; var out = ''; var esc = false; for (var i = 0; i < php.length; i++) { var ch = php[i]; if (esc) { out += ch; esc = false; continue; } if (ch === '\\') { esc = true; continue; } out += map[ch] || ch; } return out || 'F j, Y'; } /** * Resolve the operator's configured display format and convert it * for Flatpickr. Falls back to `F j, Y` (e.g. "Jan 5, 2026") when * settings haven't shipped a value. */ function yatraResolveAltFormat() { var cfg = window.yatraTripData || {}; // Pre-converted value wins (set server-side in some bundles); a // raw PHP string is converted on the fly. if (cfg.flatpickrAltFormat || cfg.altDateFormat) { return cfg.flatpickrAltFormat || cfg.altDateFormat; } var php = cfg.dateFormat || cfg.date_format || ''; return yatraPhpDateFormatToFlatpickr(php); } var fpLocale = yatraGetFlatpickrLocale(); var fpAltFormat = yatraResolveAltFormat(); // Initialize date pickers when DOM is ready. // // Priority order applied here MUST mirror the server-side // AvailabilityResolutionService: // 1. Specific Availability Dates (per-date rows) ← highest // 2. Recurring Rules (pattern-generated dates) // 3. Trip default flexible window (available_from / available_to) ← lowest // // Server has already merged (1) and (2) into yatraTripData.availabilityDates. // When the trip is in date-specific mode (or any availability-restricted // mode the template might use) we lock the date picker to that merged list. // Otherwise we fall back to the flexible window encoded in data-min-date / // data-max-date. const bookingDateInput = document.getElementById('travel_date'); if (bookingDateInput) { const form = bookingDateInput.closest('.yatra-booking-form'); const bookingMode = form ? form.getAttribute('data-booking-mode') : 'regular'; // Accept any of the historic / current values that signal // "restrict to specific availability dates". The PHP template now // emits "date_specific"; older builds used "availability". Both // must trigger the restricted picker so the priority chain holds. const isDateSpecific = ( bookingMode === 'date_specific' || bookingMode === 'availability' || bookingMode === 'date-specific' ); if (isDateSpecific) { // Availability-based: only the dates resolved by the priority // chain (specific dates first, then recurring rules) are pickable. const availabilityDates = (window.yatraTripData && Array.isArray(window.yatraTripData.availabilityDates)) ? window.yatraTripData.availabilityDates : []; if (availabilityDates.length > 0) { flatpickr(bookingDateInput, { // Backend value stays as Y-m-d (what the booking // form submits); only the user-visible input flips // to the operator's configured Date Format via // altInput/altFormat. Previously hardcoded which // ignored Settings → General → Date format. dateFormat: 'Y-m-d', altInput: true, altFormat: fpAltFormat, minDate: 'today', enable: availabilityDates, disableMobile: false, locale: fpLocale }); } else { // Edge case: template believes the trip is date-specific but // the merged date list is empty (e.g. all rules expired). // Disable the input rather than silently falling back to // "any date", which would violate the configured priority. bookingDateInput.setAttribute('disabled', 'disabled'); // Fallback placeholder when the template hasn't supplied // `data-empty-text`. Wrapped in __() so the literal string // can be translated; the data-attribute fallback is a // server-rendered string that's already localised. bookingDateInput.setAttribute( 'placeholder', bookingDateInput.getAttribute('data-empty-text') || __('No departure dates available', 'yatra') ); } } else { // Flexible booking: any future date inside the trip's available // window (available_from / available_to are surfaced as // data-min-date / data-max-date by the sidebar template). const minDate = bookingDateInput.getAttribute('data-min-date') || 'today'; const maxDate = bookingDateInput.getAttribute('data-max-date'); // Flexible-date picker. Same altInput/altFormat pattern // as the availability-restricted branch above — backend // value stable, display matches operator preference. const config = { dateFormat: 'Y-m-d', altInput: true, altFormat: fpAltFormat, minDate: minDate, disableMobile: false, locale: fpLocale }; if (maxDate) { config.maxDate = maxDate; } flatpickr(bookingDateInput, config); } } // Enquiry modal date picker (always flexible). Same altFormat // treatment so the enquiry form respects the operator's date // format too — previously hardcoded to Y-m-d which jarred with // any non-ISO display preference. const enquiryDateInput = document.getElementById('enquiry-travel-date'); if (enquiryDateInput) { flatpickr(enquiryDateInput, { dateFormat: 'Y-m-d', altInput: true, altFormat: fpAltFormat, minDate: 'today', disableMobile: false, locale: fpLocale }); } }); // Group Discount Calculation for Booking Widget document.addEventListener('DOMContentLoaded', function () { const bookingForm = document.querySelector('.yatra-booking-form'); const totalAmountElement = document.getElementById('total-amount'); const travelerInputs = document.querySelectorAll('input[name*="travelers["], input[name="num_travelers"]'); const dateInput = document.getElementById('travel_date'); let currentGroupDiscounts = null; let selectedAvailability = null; // Load group discounts for this trip async function loadGroupDiscounts() { try { const response = await fetch(window.yatraTripData?.groupDiscountsUrl || '', { method: 'GET', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ trip_ids: [window.yatraTripData?.tripId] }) }); if (response.ok) { const data = await response.json(); if (data[window.yatraTripData?.tripId] && data[window.yatraTripData?.tripId].has_group_discounts) { currentGroupDiscounts = data[window.yatraTripData?.tripId].discounts; } } } catch (error) { console.warn('Failed to load group discounts:', error); } } // Calculate total travelers function calculateTotalTravelers() { let total = 0; // Handle traveler-based pricing (multiple categories) const travelerCategoryInputs = document.querySelectorAll('input[name*="travelers["]'); travelerCategoryInputs.forEach(input => { total += parseInt(input.value) || 0; }); // Handle simple pricing (single traveler count) const simpleTravelerInput = document.querySelector('input[name="num_travelers"]'); if (simpleTravelerInput) { total = parseInt(simpleTravelerInput.value) || 0; } return total; } // Calculate group discount for current traveler count function calculateGroupDiscount(totalTravelers, basePrice) { if (!currentGroupDiscounts || totalTravelers < 2) { return 0; } // Find applicable discount for this group size let applicableDiscount = null; for (const discount of currentGroupDiscounts) { const minSize = discount.min_group_size; const maxSize = discount.max_group_size; if (totalTravelers >= minSize && (!maxSize || totalTravelers <= maxSize)) { applicableDiscount = discount; break; } } if (!applicableDiscount) { return 0; } let discountAmount = 0; if (applicableDiscount.discount_mode === 'category_based' && applicableDiscount.category_discounts) { // Calculate category-based discount const travelerCounts = getTravelerCountsByCategory(); for (const [category, discountConfig] of Object.entries(applicableDiscount.category_discounts)) { const count = travelerCounts[category] || 0; if (count > 0) { // Calculate category-specific price const categoryMultiplier = getCategoryMultiplier(category); const categoryPrice = basePrice * categoryMultiplier; const categoryTotal = categoryPrice * count; if (discountConfig.type === 'percentage') { discountAmount += categoryTotal * (discountConfig.amount / 100); } else { // Fixed amount per person discountAmount += Math.min(discountConfig.amount * count, categoryTotal); } } } } else { // Total-based discount if (applicableDiscount.discount_type === 'percentage') { discountAmount = basePrice * totalTravelers * (applicableDiscount.discount_amount / 100); } else { discountAmount = Math.min(applicableDiscount.discount_amount, basePrice * totalTravelers); } } return discountAmount; } function getSidebarAvailabilities() { const raw = window.yatraTripData && window.yatraTripData.sidebarAvailability; return Array.isArray(raw) ? raw : []; } // Update pricing when date changes if (dateInput) { dateInput.addEventListener('change', function() { const selectedDate = this.value; const availabilities = getSidebarAvailabilities(); if (availabilities.length > 0) { selectedAvailability = availabilities.find(a => a.departure_date === selectedDate); if (selectedAvailability && selectedAvailability.pricing_type === 'traveler_based' && selectedAvailability.price_types) { selectedAvailability.price_types.forEach((pt) => { const input = document.querySelector(`input[name="travelers[${pt.category_id}]"]`); if (input) { const price = pt.discounted_price || pt.original_price || 0; input.setAttribute('data-price', price); } }); } } else { selectedAvailability = null; } updatePricing(); }); // Set today's date by default if available, otherwise use trip fallback pricing. // Build LOCAL YYYY-MM-DD, not toISOString() (which is UTC and can land on // the wrong calendar day — mis-matching the availability's departure_date // and pre-selecting the wrong date near midnight). const _now = new Date(); const today = _now.getFullYear() + '-' + String(_now.getMonth() + 1).padStart(2, '0') + '-' + String(_now.getDate()).padStart(2, '0'); if (!dateInput.value) { const availabilities = getSidebarAvailabilities(); if (availabilities.length > 0) { const todayAvailability = availabilities.find(a => a.departure_date === today); if (todayAvailability) { dateInput.value = today; dateInput.dispatchEvent(new Event('change')); } else { updatePricing(); } } } } // Update pricing display function updatePricing() { const totalTravelers = calculateTotalTravelers(); let basePrice = window.yatraTripData?.basePrice || 0; // Calculate subtotal let subtotal = 0; const categoryInputs = document.querySelectorAll('input[name*="travelers["]'); if (categoryInputs.length > 0) { // Traveler-based pricing: read actual prices from data attributes (updated by date change) categoryInputs.forEach(input => { const count = parseInt(input.value) || 0; const price = parseFloat(input.dataset.price) || 0; const pricingMode = input.dataset.pricingMode || 'per_person'; const groupOverflow = input.dataset.groupOverflow || 'block'; const maxPax = parseInt(input.dataset.maxPax) || 0; if (pricingMode === 'per_group') { // Per group: one flat price, or per block of maxPax people // when overflow is "per_block". if (count > 0) { subtotal += (groupOverflow === 'per_block' && maxPax > 0) ? price * Math.ceil(count / maxPax) : price; } } else { // Per person: charge per traveler subtotal += price * count; } }); } else { // Simple pricing - use selected availability price if available if (selectedAvailability && selectedAvailability.price) { basePrice = parseFloat(selectedAvailability.price); } subtotal = basePrice * totalTravelers; } // Calculate group discount const groupDiscount = calculateGroupDiscount(totalTravelers, basePrice); const finalTotal = subtotal - groupDiscount; // Update display (total element has been removed from sidebar) // The total price display is no longer shown in the sidebar // This code is kept for reference but will not execute as totalAmountElement is null if (totalAmountElement) { totalAmountElement.textContent = (window.yatraTripData?.currencySymbol || '$') + finalTotal.toFixed(2); } // Show/hide group discount indicator updateGroupDiscountIndicator(totalTravelers, groupDiscount); } // Update group discount indicator (total element has been removed) function updateGroupDiscountIndicator(totalTravelers, discountAmount) { let indicator = document.querySelector('.yatra-group-discount-indicator'); if (discountAmount > 0) { if (!indicator) { indicator = document.createElement('div'); indicator.className = 'yatra-group-discount-indicator'; // Find a suitable parent to append the indicator const bookingForm = document.querySelector('.yatra-booking-form'); if (bookingForm) { bookingForm.appendChild(indicator); } } indicator.innerHTML = `