| 1 |
/** |
| 2 |
* Booking Page JavaScript |
| 3 |
* |
| 4 |
* Uses FormData API to automatically collect all form fields |
| 5 |
* and submit via REST API |
| 6 |
* |
| 7 |
* @package Yatra |
| 8 |
*/ |
| 9 |
|
| 10 |
// Translation helper. Resolves through `wp.i18n.__` when WordPress's i18n |
| 11 |
// runtime is enqueued (it is — `yatra-booking` declares `wp-i18n` as a |
| 12 |
// dependency in FrontendAssetsProvider). Falls back to the source string if |
| 13 |
// wp.i18n is unavailable so the page never breaks on older environments. |
| 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; }); |
| 21 |
} 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; }; |
| 26 |
} |
| 27 |
})(); |
| 28 |
|
| 29 |
(function($) { |
| 30 |
'use strict'; |
| 31 |
|
| 32 |
// API configuration - available throughout the module |
| 33 |
const apiUrl = window.yatraBookingData?.apiUrl || '/wp-json/yatra/v1'; |
| 34 |
const nonce = window.yatraBookingData?.nonce || ''; |
| 35 |
|
| 36 |
// Unified REST base resolver with plain-permalink support |
| 37 |
const getRestBase = () => { |
| 38 |
const siteUrl = window.yatraBookingData?.siteUrl || window.location.origin || ''; |
| 39 |
let base = |
| 40 |
window.yatraBookingData?.apiUrl || |
| 41 |
window.yatraBookingData?.restUrl || |
| 42 |
(window.wpApiSettings && window.wpApiSettings.root) || |
| 43 |
`${siteUrl.replace(/\/$/, '')}/wp-json`; |
| 44 |
base = base.replace(/\/$/, ''); |
| 45 |
const permalinkStructure = |
| 46 |
window.yatraBookingData?.permalinkStructure || |
| 47 |
window.yatraTripData?.permalinkStructure || |
| 48 |
window.yatraAdmin?.permalinkStructure || |
| 49 |
''; |
| 50 |
// Default to plain when structure is unknown to avoid 404s under Plain permalinks |
| 51 |
const isPlain = permalinkStructure === 'plain' || !permalinkStructure; |
| 52 |
return { base, isPlain }; |
| 53 |
}; |
| 54 |
|
| 55 |
$(document).ready(function() { |
| 56 |
const $form = $('#yatra-booking-form'); |
| 57 |
const $submitBtn = $('#yatra-submit-booking'); |
| 58 |
const isRemainingPayment = Boolean(window.yatraBookingData?.isRemainingPayment) || ($form.data('is-remaining-payment') === 'yes'); |
| 59 |
const remainingAmount = isRemainingPayment |
| 60 |
? parseFloat(window.yatraBookingData?.remainingAmount ?? $form.data('payment-due')) || 0 |
| 61 |
: 0; |
| 62 |
const totalRemainingAmount = isRemainingPayment |
| 63 |
? parseFloat(window.yatraBookingData?.totalAmount ?? (remainingAmount + (window.yatraBookingData?.amountPaid || 0))) || remainingAmount |
| 64 |
: null; |
| 65 |
|
| 66 |
// Get pricing data |
| 67 |
const pricePerPerson = parseFloat($('input[name="trip_price"]').val()) || window.yatraBookingData?.tripPrice || 0; |
| 68 |
// Use global currency from settings (prioritize yatraBookingData which uses global currency) |
| 69 |
const currency = window.yatraBookingData?.currency || $('input[name="currency"]').val() || 'USD'; |
| 70 |
const depositPercentage = window.yatraBookingData?.depositPercentage || 20; |
| 71 |
const partialPercentage = window.yatraBookingData?.partialPercentage || 30; |
| 72 |
|
| 73 |
window.yatraBookingSummary = window.yatraBookingSummary || {}; |
| 74 |
|
| 75 |
// Coupon state |
| 76 |
let appliedCoupon = null; |
| 77 |
|
| 78 |
// Group discount state (used for dynamic updates when traveler count changes) |
| 79 |
let appliedGroupDiscount = null; |
| 80 |
const groupDiscounts = window.yatraBookingData?.groupDiscounts || []; |
| 81 |
|
| 82 |
// Country options for dynamically added travelers |
| 83 |
const countryOptions = generateCountryOptions(); |
| 84 |
|
| 85 |
/** |
| 86 |
* Generate country select options HTML. |
| 87 |
* |
| 88 |
* Each English country name is wrapped in `__()` so it's |
| 89 |
* extractable and translatable per WordPress conventions. |
| 90 |
* Keys (ISO 3166-1 alpha-2 codes) are untranslated, as they |
| 91 |
* should be — they're identifiers, not user-facing text. |
| 92 |
*/ |
| 93 |
function generateCountryOptions() { |
| 94 |
const countries = { |
| 95 |
'AF': __('Afghanistan', 'yatra'), 'AL': __('Albania', 'yatra'), 'DZ': __('Algeria', 'yatra'), 'AR': __('Argentina', 'yatra'), |
| 96 |
'AU': __('Australia', 'yatra'), 'AT': __('Austria', 'yatra'), 'BD': __('Bangladesh', 'yatra'), 'BE': __('Belgium', 'yatra'), |
| 97 |
'BR': __('Brazil', 'yatra'), 'BT': __('Bhutan', 'yatra'), 'CA': __('Canada', 'yatra'), 'CN': __('China', 'yatra'), |
| 98 |
'CO': __('Colombia', 'yatra'), 'CZ': __('Czech Republic', 'yatra'), 'DK': __('Denmark', 'yatra'), 'EG': __('Egypt', 'yatra'), |
| 99 |
'FI': __('Finland', 'yatra'), 'FR': __('France', 'yatra'), 'DE': __('Germany', 'yatra'), 'GR': __('Greece', 'yatra'), |
| 100 |
'HK': __('Hong Kong', 'yatra'), 'HU': __('Hungary', 'yatra'), 'IS': __('Iceland', 'yatra'), 'IN': __('India', 'yatra'), |
| 101 |
'ID': __('Indonesia', 'yatra'), 'IE': __('Ireland', 'yatra'), 'IL': __('Israel', 'yatra'), 'IT': __('Italy', 'yatra'), |
| 102 |
'JP': __('Japan', 'yatra'), 'KE': __('Kenya', 'yatra'), 'KR': __('South Korea', 'yatra'), 'MY': __('Malaysia', 'yatra'), |
| 103 |
'MV': __('Maldives', 'yatra'), 'MX': __('Mexico', 'yatra'), 'NL': __('Netherlands', 'yatra'), 'NZ': __('New Zealand', 'yatra'), |
| 104 |
'NP': __('Nepal', 'yatra'), 'NO': __('Norway', 'yatra'), 'PK': __('Pakistan', 'yatra'), 'PE': __('Peru', 'yatra'), |
| 105 |
'PH': __('Philippines', 'yatra'), 'PL': __('Poland', 'yatra'), 'PT': __('Portugal', 'yatra'), 'RO': __('Romania', 'yatra'), |
| 106 |
'RU': __('Russia', 'yatra'), 'SA': __('Saudi Arabia', 'yatra'), 'SG': __('Singapore', 'yatra'), 'ZA': __('South Africa', 'yatra'), |
| 107 |
'ES': __('Spain', 'yatra'), 'LK': __('Sri Lanka', 'yatra'), 'SE': __('Sweden', 'yatra'), 'CH': __('Switzerland', 'yatra'), |
| 108 |
'TW': __('Taiwan', 'yatra'), 'TH': __('Thailand', 'yatra'), 'TR': __('Turkey', 'yatra'), 'AE': __('United Arab Emirates', 'yatra'), |
| 109 |
'GB': __('United Kingdom', 'yatra'), 'US': __('United States', 'yatra'), 'VN': __('Vietnam', 'yatra') |
| 110 |
}; |
| 111 |
|
| 112 |
let options = '<option value="">' + __('Select Country', 'yatra') + '</option>'; |
| 113 |
for (const [code, name] of Object.entries(countries)) { |
| 114 |
options += `<option value="${code}">${name}</option>`; |
| 115 |
} |
| 116 |
return options; |
| 117 |
} |
| 118 |
|
| 119 |
/** |
| 120 |
* Get current traveler count |
| 121 |
*/ |
| 122 |
function getTravelerCount() { |
| 123 |
// Check if we have category-based pricing (dropdown style) |
| 124 |
const categoryInputs = $('.yatra-qty-input[data-category-id]'); |
| 125 |
if (categoryInputs.length > 0) { |
| 126 |
let total = 0; |
| 127 |
categoryInputs.each(function() { |
| 128 |
total += parseInt($(this).val()) || 0; |
| 129 |
}); |
| 130 |
return total || 1; |
| 131 |
} |
| 132 |
|
| 133 |
// Fallback to simple number input or traveler forms |
| 134 |
const simpleInput = $('#number-of-travelers').val(); |
| 135 |
if (simpleInput) { |
| 136 |
return parseInt(simpleInput) || 1; |
| 137 |
} |
| 138 |
|
| 139 |
return $('.yatra-traveler-form').length || 1; |
| 140 |
} |
| 141 |
|
| 142 |
/** |
| 143 |
* Check if using traveler-based pricing |
| 144 |
*/ |
| 145 |
function isTravelerBasedPricing() { |
| 146 |
return $('.yatra-summary-pricing').data('pricing-type') === 'traveler_based'; |
| 147 |
} |
| 148 |
|
| 149 |
/** |
| 150 |
* Calculate total for traveler-based pricing |
| 151 |
*/ |
| 152 |
function calculateTravelerBasedTotal() { |
| 153 |
let total = 0; |
| 154 |
$('.yatra-qty-input[data-category-id]').each(function() { |
| 155 |
const count = parseInt($(this).val()) || 0; |
| 156 |
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 |
|
| 159 |
if (pricingMode === 'per_group') { |
| 160 |
// Per group: charge flat price once if any travelers in this category |
| 161 |
if (count > 0) { |
| 162 |
total += price; |
| 163 |
} |
| 164 |
} else { |
| 165 |
// Per person: charge per traveler |
| 166 |
total += count * price; |
| 167 |
} |
| 168 |
}); |
| 169 |
return total; |
| 170 |
} |
| 171 |
|
| 172 |
/** |
| 173 |
* Debounce timer for AJAX calls |
| 174 |
*/ |
| 175 |
let summaryDebounceTimer = null; |
| 176 |
|
| 177 |
/** |
| 178 |
* Update the dropdown display text for traveler-based pricing |
| 179 |
*/ |
| 180 |
function updateTravelersDisplayText() { |
| 181 |
const parts = []; |
| 182 |
$('.yatra-qty-input[data-category-id]').each(function() { |
| 183 |
const count = parseInt($(this).val()) || 0; |
| 184 |
const categoryLabel = $(this).closest('.yatra-quantity-row').find('.yatra-quantity-title').text(); |
| 185 |
if (count > 0) { |
| 186 |
parts.push(sprintf( |
| 187 |
/* translators: 1: traveler category label, 2: count. */ |
| 188 |
__('%1$s x %2$d', 'yatra'), |
| 189 |
categoryLabel, |
| 190 |
count |
| 191 |
)); |
| 192 |
} |
| 193 |
}); |
| 194 |
const displayText = parts.length > 0 ? parts.join(', ') : __('Select travelers', 'yatra'); |
| 195 |
$('#yatra-travelers-display').text(displayText); |
| 196 |
} |
| 197 |
|
| 198 |
/** |
| 199 |
* Update display text for regular pricing |
| 200 |
*/ |
| 201 |
function updateRegularTravelersDisplayText(count) { |
| 202 |
$('#yatra-travelers-display-regular').text(sprintf( |
| 203 |
/* translators: %d: number of travelers on this booking. */ |
| 204 |
_n('%d traveler', '%d travelers', count, 'yatra'), |
| 205 |
count |
| 206 |
)); |
| 207 |
} |
| 208 |
|
| 209 |
|
| 210 |
/** |
| 211 |
* Format currency |
| 212 |
*/ |
| 213 |
function formatCurrency(amount, currencyCode) { |
| 214 |
// Get currency formatting settings from global settings |
| 215 |
const currencyPosition = window.yatraBookingData?.currencyPosition || 'before'; |
| 216 |
let decimalPlaces = parseInt(String(window.yatraBookingData?.decimalPlaces ?? '2'), 10); |
| 217 |
if (Number.isNaN(decimalPlaces)) { |
| 218 |
decimalPlaces = 2; |
| 219 |
} |
| 220 |
decimalPlaces = Math.max(0, Math.min(4, decimalPlaces)); |
| 221 |
const thousandSeparator = window.yatraBookingData?.thousandSeparator || ','; |
| 222 |
const decimalSeparator = window.yatraBookingData?.decimalSeparator || '.'; |
| 223 |
|
| 224 |
// Currency symbols matching PHP yatra_get_currency_symbol function |
| 225 |
const symbols = { |
| 226 |
'USD': '$', 'EUR': '€', 'GBP': '£', 'JPY': '¥', 'CNY': '¥', |
| 227 |
'INR': '₹', 'NPR': 'Rs', 'AUD': 'A$', 'CAD': 'C$', 'CHF': 'CHF', |
| 228 |
'NZD': 'NZ$', 'SGD': 'S$', 'HKD': 'HK$', 'KRW': '₩', 'THB': '฿', |
| 229 |
'MYR': 'RM', 'PHP': '₱', 'IDR': 'Rp', 'VND': '₫', 'BRL': 'R$', |
| 230 |
'MXN': 'MX$', 'RUB': '₽', 'ZAR': 'R', 'AED': 'د.إ', 'SAR': '﷼', |
| 231 |
'TRY': '₺', 'SEK': 'kr', 'NOK': 'kr', 'DKK': 'kr', 'PLN': 'zł', |
| 232 |
'CZK': 'Kč', 'HUF': 'Ft', 'ILS': '₪', 'TWD': 'NT$', 'PKR': '₨', |
| 233 |
'BDT': '৳', 'LKR': 'Rs', 'EGP': 'E£', 'NGN': '₦', 'KES': 'KSh', |
| 234 |
'GHS': 'GH₵', 'ARS': 'AR$', 'CLP': 'CL$', 'COP': 'CO$', 'PEN': 'S/' |
| 235 |
}; |
| 236 |
|
| 237 |
const symbol = symbols[currencyCode?.toUpperCase()] || (currencyCode ? currencyCode + ' ' : ''); |
| 238 |
|
| 239 |
// Format amount with proper separators |
| 240 |
// First format with standard separators, then replace with custom ones |
| 241 |
const tempFormatted = amount.toLocaleString('en-US', { |
| 242 |
minimumFractionDigits: decimalPlaces, |
| 243 |
maximumFractionDigits: decimalPlaces |
| 244 |
}); |
| 245 |
// Replace thousand separator |
| 246 |
const formattedAmount = tempFormatted |
| 247 |
.split(',').join('__THOUSAND__') |
| 248 |
.split('.').join('__DECIMAL__') |
| 249 |
.replace(/__THOUSAND__/g, thousandSeparator) |
| 250 |
.replace(/__DECIMAL__/g, decimalSeparator); |
| 251 |
|
| 252 |
// Position currency based on settings (before or after) |
| 253 |
if (currencyPosition === 'right' || currencyPosition === 'after') { |
| 254 |
return formattedAmount + ' ' + symbol; |
| 255 |
} |
| 256 |
|
| 257 |
return symbol + ' ' + formattedAmount; |
| 258 |
} |
| 259 |
|
| 260 |
/** |
| 261 |
* Build JSON body for /booking/summary (payment method, travelers, services). |
| 262 |
* Keeps sidebar + window.yatraBookingSummary aligned with Pro flexible payments. |
| 263 |
*/ |
| 264 |
function buildPricingSummaryPayload() { |
| 265 |
const payload = {}; |
| 266 |
const pm = $('input[name="payment_method"]:checked').val(); |
| 267 |
if (pm) { |
| 268 |
payload.payment_method = pm; |
| 269 |
} |
| 270 |
const travelDate = $('#travel-date').val() || $('input[name="travel_date"]').first().val(); |
| 271 |
if (travelDate) { |
| 272 |
payload.travel_date = travelDate; |
| 273 |
} |
| 274 |
const depTime = $('input[name="departure_time"]').first().val(); |
| 275 |
if (depTime) { |
| 276 |
payload.departure_time = depTime; |
| 277 |
} |
| 278 |
if ($('.yatra-qty-input[data-category-id]').length) { |
| 279 |
const travelerCounts = {}; |
| 280 |
$('.yatra-qty-input[data-category-id]').each(function() { |
| 281 |
const catId = $(this).data('category-id'); |
| 282 |
travelerCounts[catId] = parseInt($(this).val(), 10) || 0; |
| 283 |
}); |
| 284 |
payload.traveler_counts = travelerCounts; |
| 285 |
} |
| 286 |
const serviceIds = []; |
| 287 |
$('#yatra-additional-services input[type="checkbox"]:checked').each(function() { |
| 288 |
const id = parseInt($(this).closest('.yatra-service-item').data('service-id'), 10); |
| 289 |
if (!Number.isNaN(id)) { |
| 290 |
serviceIds.push(id); |
| 291 |
} |
| 292 |
}); |
| 293 |
if (serviceIds.length) { |
| 294 |
payload.additional_services = serviceIds; |
| 295 |
} |
| 296 |
const pricingType = $('#yatra-summary-pricing').data('pricing-type'); |
| 297 |
if (pricingType) { |
| 298 |
payload.pricing_type = pricingType; |
| 299 |
} |
| 300 |
// Carry the page URL's booking_token into the summary refresh so |
| 301 |
// the REST controller can rehydrate the session from the matching |
| 302 |
// transient when PHPSESSID isn't carried into REST scope. Without |
| 303 |
// this, /booking/summary returns 400 "No active booking session |
| 304 |
// found." on every recalculation. |
| 305 |
try { |
| 306 |
const tok = new URLSearchParams(window.location.search).get('booking_token'); |
| 307 |
if (tok) payload.booking_token = tok; |
| 308 |
} catch (e) { /* URLSearchParams unavailable — skip */ } |
| 309 |
return payload; |
| 310 |
} |
| 311 |
|
| 312 |
/** |
| 313 |
* Update pay button label and amount (offline gateways still show amount due for deposit/partial). |
| 314 |
*/ |
| 315 |
function updateCheckoutButtonState() { |
| 316 |
const $buttonText = $('#pay-button-text'); |
| 317 |
const $payAmount = $('#pay-amount'); |
| 318 |
const gateway = $('input[name="payment_gateway"]:checked').val() || 'pay_later'; |
| 319 |
const offlineGateways = ['pay_later', 'bank_transfer']; |
| 320 |
const isOffline = offlineGateways.includes(gateway); |
| 321 |
const paymentMethod = $('input[name="payment_method"]:checked').val() || 'full'; |
| 322 |
const summary = window.yatraBookingSummary || {}; |
| 323 |
let due = parseFloat(summary.amountDue); |
| 324 |
const total = parseFloat(summary.totalAmount); |
| 325 |
if (Number.isNaN(due)) { |
| 326 |
due = parseFloat($form.attr('data-payment-due')) || 0; |
| 327 |
} |
| 328 |
let totalSafe = total; |
| 329 |
if (Number.isNaN(totalSafe)) { |
| 330 |
totalSafe = parseFloat($form.attr('data-payment-due')) || 0; |
| 331 |
} |
| 332 |
const flexDue = paymentMethod === 'deposit' || paymentMethod === 'partial'; |
| 333 |
|
| 334 |
if (isOffline) { |
| 335 |
$buttonText.text(__('Complete Booking', 'yatra')); |
| 336 |
const showAmount = flexDue ? due : totalSafe; |
| 337 |
if (showAmount > 0) { |
| 338 |
$payAmount.text(formatCurrency(showAmount, currency)).show(); |
| 339 |
} else { |
| 340 |
$payAmount.hide(); |
| 341 |
} |
| 342 |
} else { |
| 343 |
$buttonText.text(__('Pay Now', 'yatra')); |
| 344 |
const payVal = flexDue && due > 0 ? due : totalSafe; |
| 345 |
if (payVal > 0) { |
| 346 |
$payAmount.text(formatCurrency(payVal, currency)).show(); |
| 347 |
} else { |
| 348 |
$payAmount.hide(); |
| 349 |
} |
| 350 |
} |
| 351 |
} |
| 352 |
|
| 353 |
/** |
| 354 |
* Add a new traveler form dynamically |
| 355 |
*/ |
| 356 |
function addTravelerForm(index) { |
| 357 |
const $container = $('#yatra-travelers-container'); |
| 358 |
|
| 359 |
// Get field configuration from the first traveler form |
| 360 |
const $firstTraveler = $container.find('.yatra-traveler-form').first(); |
| 361 |
if (!$firstTraveler.length) return; |
| 362 |
|
| 363 |
// Clone the first traveler form |
| 364 |
const $newTraveler = $firstTraveler.clone(); |
| 365 |
|
| 366 |
// Update index references |
| 367 |
$newTraveler.attr('data-traveler-index', index); |
| 368 |
$newTraveler.find('.yatra-traveler-title').text( |
| 369 |
/* translators: %d: traveler sequence number. */ |
| 370 |
sprintf(__('Traveler %d', 'yatra'), index) |
| 371 |
); |
| 372 |
|
| 373 |
// Add "Additional traveler" note. The class wrapper is |
| 374 |
// markup (not translatable); only the visible label runs |
| 375 |
// through __() so translators see a clean msgid in the |
| 376 |
// pot rather than HTML noise. |
| 377 |
if (!$newTraveler.find('.yatra-traveler-note').length) { |
| 378 |
$newTraveler.find('.yatra-traveler-header').append( |
| 379 |
'<span class="yatra-traveler-note">' + __('Additional traveler', 'yatra') + '</span>' |
| 380 |
); |
| 381 |
} |
| 382 |
|
| 383 |
// Update all field IDs and names |
| 384 |
$newTraveler.find('input, select, textarea').each(function() { |
| 385 |
const $field = $(this); |
| 386 |
const oldId = $field.attr('id'); |
| 387 |
const oldName = $field.attr('name'); |
| 388 |
|
| 389 |
if (oldId) { |
| 390 |
// Replace traveler-1- with traveler-{index}- |
| 391 |
const newId = oldId.replace(/traveler-\d+-/, 'traveler-' + index + '-'); |
| 392 |
$field.attr('id', newId); |
| 393 |
} |
| 394 |
|
| 395 |
if (oldName) { |
| 396 |
// Replace travelers[1] with travelers[{index}] |
| 397 |
const newName = oldName.replace(/travelers\[\d+\]/, 'travelers[' + index + ']'); |
| 398 |
$field.attr('name', newName); |
| 399 |
} |
| 400 |
|
| 401 |
// Clear values |
| 402 |
$field.val(''); |
| 403 |
$field.removeClass('error'); |
| 404 |
}); |
| 405 |
|
| 406 |
// Update associated labels |
| 407 |
$newTraveler.find('label').each(function() { |
| 408 |
const $label = $(this); |
| 409 |
const forAttr = $label.attr('for'); |
| 410 |
if (forAttr) { |
| 411 |
const newFor = forAttr.replace(/traveler-\d+-/, 'traveler-' + index + '-'); |
| 412 |
$label.attr('for', newFor); |
| 413 |
} |
| 414 |
}); |
| 415 |
|
| 416 |
$container.append($newTraveler); |
| 417 |
} |
| 418 |
|
| 419 |
/** |
| 420 |
* Remove the last traveler form |
| 421 |
*/ |
| 422 |
function removeTravelerForm(index) { |
| 423 |
const $container = $('#yatra-travelers-container'); |
| 424 |
$container.find(`.yatra-traveler-form[data-traveler-index="${index}"]`).remove(); |
| 425 |
} |
| 426 |
|
| 427 |
/** |
| 428 |
* Convert FormData to nested object structure |
| 429 |
*/ |
| 430 |
function formDataToObject(formData) { |
| 431 |
const obj = {}; |
| 432 |
|
| 433 |
for (const [key, value] of formData.entries()) { |
| 434 |
// Handle array notation like travelers[1][first_name] |
| 435 |
const matches = key.match(/^([^\[]+)(?:\[([^\]]*)\])?(?:\[([^\]]*)\])?$/); |
| 436 |
|
| 437 |
if (matches) { |
| 438 |
const [, base, index, field] = matches; |
| 439 |
|
| 440 |
if (index !== undefined && field !== undefined) { |
| 441 |
// Array of objects: travelers[1][first_name] |
| 442 |
if (!obj[base]) obj[base] = {}; |
| 443 |
if (!obj[base][index]) obj[base][index] = {}; |
| 444 |
obj[base][index][field] = value; |
| 445 |
} else if (index !== undefined) { |
| 446 |
// Array notation. PHP/HTML produces two flavours: |
| 447 |
// items[0], items[1] → keyed indices |
| 448 |
// items[] → push to end (multi-checkbox) |
| 449 |
// The empty-string variant is what `<input name="additional_services[]">` |
| 450 |
// sends when several checkboxes are selected. Previously |
| 451 |
// we did `obj[base][""] = value` which silently overwrote |
| 452 |
// on each entry and JSON-serialised to `[]` — so the |
| 453 |
// server saw no selected services, even when boxes were |
| 454 |
// checked. |
| 455 |
if (!obj[base]) obj[base] = []; |
| 456 |
if (index === '') { |
| 457 |
obj[base].push(value); |
| 458 |
} else { |
| 459 |
obj[base][index] = value; |
| 460 |
} |
| 461 |
} else { |
| 462 |
// Simple key |
| 463 |
obj[key] = value; |
| 464 |
} |
| 465 |
} else { |
| 466 |
obj[key] = value; |
| 467 |
} |
| 468 |
} |
| 469 |
|
| 470 |
// Convert travelers object to array |
| 471 |
if (obj.travelers && typeof obj.travelers === 'object') { |
| 472 |
obj.travelers = Object.values(obj.travelers); |
| 473 |
} |
| 474 |
|
| 475 |
return obj; |
| 476 |
} |
| 477 |
|
| 478 |
/** |
| 479 |
* Validate form before submission |
| 480 |
*/ |
| 481 |
function validateForm() { |
| 482 |
if (isRemainingPayment) { |
| 483 |
return true; |
| 484 |
} |
| 485 |
|
| 486 |
let isValid = true; |
| 487 |
const errors = []; |
| 488 |
|
| 489 |
// Remove previous error states |
| 490 |
$form.find('.error').removeClass('error'); |
| 491 |
$('.yatra-form-error').remove(); |
| 492 |
|
| 493 |
// Validate all required fields |
| 494 |
$form.find('input[required], select[required], textarea[required]').each(function() { |
| 495 |
const $field = $(this); |
| 496 |
const value = $field.val(); |
| 497 |
|
| 498 |
if (!value || value.trim() === '') { |
| 499 |
isValid = false; |
| 500 |
$field.addClass('error'); |
| 501 |
const label = $field.closest('.yatra-form-group').find('label').text().replace('*', '').trim(); |
| 502 |
errors.push(sprintf( |
| 503 |
/* translators: %s: form field label. */ |
| 504 |
__('%s is required', 'yatra'), |
| 505 |
label |
| 506 |
)); |
| 507 |
} |
| 508 |
}); |
| 509 |
|
| 510 |
// Validate email format |
| 511 |
const emailFields = $form.find('input[type="email"]'); |
| 512 |
emailFields.each(function() { |
| 513 |
const $field = $(this); |
| 514 |
const value = $field.val(); |
| 515 |
if (value && !isValidEmail(value)) { |
| 516 |
isValid = false; |
| 517 |
$field.addClass('error'); |
| 518 |
errors.push(__('Please enter a valid email address', 'yatra')); |
| 519 |
} |
| 520 |
}); |
| 521 |
|
| 522 |
// Validate terms checkbox |
| 523 |
if (!$('input[name="accept_terms"]').is(':checked')) { |
| 524 |
isValid = false; |
| 525 |
errors.push(__('Please accept the Terms and Conditions', 'yatra')); |
| 526 |
} |
| 527 |
|
| 528 |
// Validate privacy checkbox |
| 529 |
if (!$('input[name="accept_privacy"]').is(':checked')) { |
| 530 |
isValid = false; |
| 531 |
errors.push(__('Please accept the Privacy Policy', 'yatra')); |
| 532 |
} |
| 533 |
|
| 534 |
if (!isValid && errors.length > 0) { |
| 535 |
showFormError(errors[0]); |
| 536 |
} |
| 537 |
|
| 538 |
return isValid; |
| 539 |
} |
| 540 |
|
| 541 |
/** |
| 542 |
* Validate email format |
| 543 |
*/ |
| 544 |
function isValidEmail(email) { |
| 545 |
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email); |
| 546 |
} |
| 547 |
|
| 548 |
/** |
| 549 |
* Show form error message |
| 550 |
*/ |
| 551 |
function showFormError(message) { |
| 552 |
const $errorContainer = $('#yatra-booking-form-errors'); |
| 553 |
if ($errorContainer.length) { |
| 554 |
$errorContainer.html('<div class="yatra-form-error" role="alert">' + message + '</div>'); |
| 555 |
$('html, body').animate({ scrollTop: $errorContainer.offset().top - 100 }, 300); |
| 556 |
} else { |
| 557 |
alert(message); |
| 558 |
} |
| 559 |
} |
| 560 |
|
| 561 |
/** |
| 562 |
* Show success message |
| 563 |
* @param {string} message |
| 564 |
* @param {string} reference |
| 565 |
* @param {{ heading?: string, footerHtml?: string, iconColor?: string }|undefined} opts |
| 566 |
*/ |
| 567 |
function showSuccessMessage(message, reference, opts) { |
| 568 |
opts = opts || {}; |
| 569 |
const heading = opts.heading || __('Booking Confirmed!', 'yatra'); |
| 570 |
const iconColor = opts.iconColor || '#22c55e'; |
| 571 |
const confirmationEmailText = __('A confirmation email has been sent to your email address.', 'yatra'); |
| 572 |
const footerHtml = opts.footerHtml !== undefined |
| 573 |
? opts.footerHtml |
| 574 |
: '<p style="margin-top: 24px; color: #6b7280;">' + confirmationEmailText + '</p>'; |
| 575 |
const referenceText = sprintf( |
| 576 |
/* translators: %s: booking reference number. */ |
| 577 |
__('Reference: %s', 'yatra'), |
| 578 |
reference |
| 579 |
); |
| 580 |
const $success = $('<div class="yatra-booking-success" style="text-align: center; padding: 60px 20px;">' + |
| 581 |
'<svg style="width: 80px; height: 80px; color: ' + iconColor + '; margin-bottom: 24px;" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">' + |
| 582 |
'<circle cx="12" cy="12" r="10"></circle><polyline points="9,12 12,15 16,10"></polyline></svg>' + |
| 583 |
'<h2 style="font-size: 28px; margin-bottom: 12px; color: #111827;">' + heading + '</h2>' + |
| 584 |
'<p style="font-size: 18px; color: #6b7280; margin-bottom: 8px;">' + message + '</p>' + |
| 585 |
'<p style="font-size: 16px; color: #111827; font-weight: 600;">' + referenceText + '</p>' + |
| 586 |
footerHtml + |
| 587 |
'</div>'); |
| 588 |
|
| 589 |
$form.html($success); |
| 590 |
} |
| 591 |
|
| 592 |
// ===================== |
| 593 |
// Event Handlers |
| 594 |
// ===================== |
| 595 |
|
| 596 |
// Dropdown toggle for traveler selector |
| 597 |
$(document).on('click', '.yatra-booking-participants-select', function(e) { |
| 598 |
// Don't toggle if clicking on buttons or inputs inside |
| 599 |
if ($(e.target).closest('.yatra-qty-btn, .yatra-qty-input, .yatra-quantity-controls').length) { |
| 600 |
return; |
| 601 |
} |
| 602 |
|
| 603 |
const $select = $(this); |
| 604 |
const wasActive = $select.hasClass('active'); |
| 605 |
|
| 606 |
// Close all dropdowns first |
| 607 |
$('.yatra-booking-participants-select').removeClass('active'); |
| 608 |
|
| 609 |
// Toggle this one |
| 610 |
if (!wasActive) { |
| 611 |
$select.addClass('active'); |
| 612 |
} |
| 613 |
}); |
| 614 |
|
| 615 |
// Close dropdown when clicking outside |
| 616 |
$(document).on('click', function(e) { |
| 617 |
if (!$(e.target).closest('.yatra-booking-participants-select').length) { |
| 618 |
$('.yatra-booking-participants-select').removeClass('active'); |
| 619 |
} |
| 620 |
}); |
| 621 |
|
| 622 |
// Quantity selector for regular pricing (dropdown style) |
| 623 |
$(document).on('click', '.yatra-qty-btn[data-field="travelers"]', function(e) { |
| 624 |
e.preventDefault(); |
| 625 |
e.stopPropagation(); |
| 626 |
|
| 627 |
const $btn = $(this); |
| 628 |
const $input = $('#number-of-travelers'); |
| 629 |
let currentValue = parseInt($input.val()) || 1; |
| 630 |
const min = parseInt($input.attr('min')) || 1; |
| 631 |
const max = parseInt($input.attr('max')) || 20; |
| 632 |
|
| 633 |
if ($btn.hasClass('yatra-qty-plus')) { |
| 634 |
if (currentValue < max) { |
| 635 |
currentValue++; |
| 636 |
addTravelerForm(currentValue); |
| 637 |
} |
| 638 |
} else if ($btn.hasClass('yatra-qty-minus')) { |
| 639 |
if (currentValue > min) { |
| 640 |
removeTravelerForm(currentValue); |
| 641 |
currentValue--; |
| 642 |
} |
| 643 |
} |
| 644 |
|
| 645 |
$input.val(currentValue); |
| 646 |
|
| 647 |
// Update button disabled states |
| 648 |
$btn.closest('.yatra-quantity-controls').find('.yatra-qty-minus').prop('disabled', currentValue <= min); |
| 649 |
$btn.closest('.yatra-quantity-controls').find('.yatra-qty-plus').prop('disabled', currentValue >= max); |
| 650 |
|
| 651 |
updateBookingSession(); |
| 652 |
}); |
| 653 |
|
| 654 |
// Quantity selector for traveler-based category pricing (dropdown style) |
| 655 |
$(document).on('click', '.yatra-qty-btn[data-category]', function(e) { |
| 656 |
e.preventDefault(); |
| 657 |
e.stopPropagation(); |
| 658 |
|
| 659 |
const $btn = $(this); |
| 660 |
const $row = $btn.closest('.yatra-quantity-row'); |
| 661 |
const $input = $row.find('.yatra-qty-input'); |
| 662 |
let currentValue = parseInt($input.val()) || 0; |
| 663 |
const min = parseInt($input.attr('min')) || 0; |
| 664 |
const max = parseInt($input.attr('max')) || 20; |
| 665 |
|
| 666 |
if ($btn.hasClass('yatra-qty-plus')) { |
| 667 |
if (currentValue < max) { |
| 668 |
currentValue++; |
| 669 |
} |
| 670 |
} else if ($btn.hasClass('yatra-qty-minus')) { |
| 671 |
if (currentValue > min) { |
| 672 |
currentValue--; |
| 673 |
} |
| 674 |
} |
| 675 |
|
| 676 |
$input.val(currentValue); |
| 677 |
|
| 678 |
// Update button disabled states |
| 679 |
$row.find('.yatra-qty-minus').prop('disabled', currentValue <= min); |
| 680 |
$row.find('.yatra-qty-plus').prop('disabled', currentValue >= max); |
| 681 |
|
| 682 |
// Update traveler forms based on total count |
| 683 |
updateTravelerFormsForCategories(); |
| 684 |
|
| 685 |
// Update travelers display text immediately |
| 686 |
updateTravelersDisplayText(); |
| 687 |
|
| 688 |
|
| 689 |
updateBookingSession(); |
| 690 |
}); |
| 691 |
|
| 692 |
/** |
| 693 |
* Update traveler forms based on category selections |
| 694 |
*/ |
| 695 |
function updateTravelerFormsForCategories() { |
| 696 |
const targetCount = getTravelerCount(); |
| 697 |
const currentFormCount = $('.yatra-traveler-form').length; |
| 698 |
|
| 699 |
if (targetCount > currentFormCount) { |
| 700 |
// Add forms |
| 701 |
for (let i = currentFormCount + 1; i <= targetCount; i++) { |
| 702 |
addTravelerForm(i); |
| 703 |
} |
| 704 |
} else if (targetCount < currentFormCount && targetCount > 0) { |
| 705 |
// Remove forms |
| 706 |
for (let i = currentFormCount; i > targetCount; i--) { |
| 707 |
removeTravelerForm(i); |
| 708 |
} |
| 709 |
} |
| 710 |
} |
| 711 |
|
| 712 |
/** |
| 713 |
* Update booking session with new traveler counts |
| 714 |
* This ensures checkout/confirmation reflects the updated travelers |
| 715 |
*/ |
| 716 |
function updateBookingSession() { |
| 717 |
const sessionPayload = { |
| 718 |
travelers: getTravelerCount() |
| 719 |
}; |
| 720 |
|
| 721 |
// Add traveler_counts for category-based pricing |
| 722 |
if (isTravelerBasedPricing()) { |
| 723 |
const travelerCounts = {}; |
| 724 |
$('.yatra-qty-input[data-category-id]').each(function() { |
| 725 |
const categoryId = $(this).data('category-id'); |
| 726 |
const count = parseInt($(this).val()) || 0; |
| 727 |
if (categoryId !== undefined) { |
| 728 |
travelerCounts[categoryId] = count; |
| 729 |
} |
| 730 |
}); |
| 731 |
sessionPayload.traveler_counts = travelerCounts; |
| 732 |
} |
| 733 |
|
| 734 |
// Update session via REST API (needs session cookie, but no nonce) |
| 735 |
const { base: restBase, isPlain } = getRestBase(); |
| 736 |
const siteBase = (window.yatraBookingData?.siteUrl || window.location.origin || '').replace(/\/$/, ''); |
| 737 |
let sessionUrl; |
| 738 |
if (isPlain) { |
| 739 |
sessionUrl = `${siteBase}/?rest_route=/yatra/v1/booking/session`; |
| 740 |
} else if (restBase.includes('/yatra/v1')) { |
| 741 |
sessionUrl = `${restBase}/booking/session`; |
| 742 |
} else { |
| 743 |
sessionUrl = `${restBase}/yatra/v1/booking/session`; |
| 744 |
} |
| 745 |
|
| 746 |
fetch(sessionUrl, { |
| 747 |
method: 'POST', |
| 748 |
credentials: 'same-origin', |
| 749 |
headers: { |
| 750 |
'Content-Type': 'application/json', |
| 751 |
'X-WP-Nonce': nonce |
| 752 |
}, |
| 753 |
body: JSON.stringify(sessionPayload) |
| 754 |
}) |
| 755 |
.then(response => response.json()) |
| 756 |
.then(data => { |
| 757 |
if (!data.success) { |
| 758 |
console.warn('Failed to update booking session:', data.message); |
| 759 |
return; |
| 760 |
} |
| 761 |
schedulePricingSummaryRefresh(); |
| 762 |
}) |
| 763 |
.catch(error => { |
| 764 |
console.error('Error updating booking session:', error); |
| 765 |
}); |
| 766 |
} |
| 767 |
|
| 768 |
// Initial summary figures from server-rendered sidebar (before any AJAX) |
| 769 |
if (!isRemainingPayment) { |
| 770 |
const $sbInit = $('.yatra-booking-summary').first(); |
| 771 |
if ($sbInit.length) { |
| 772 |
const t = parseFloat($sbInit.attr('data-summary-total')); |
| 773 |
const du = parseFloat($sbInit.attr('data-summary-due')); |
| 774 |
window.yatraBookingSummary = { |
| 775 |
totalAmount: Number.isNaN(t) ? null : t, |
| 776 |
amountDue: Number.isNaN(du) ? null : du, |
| 777 |
amountPaid: 0 |
| 778 |
}; |
| 779 |
} |
| 780 |
updateCheckoutButtonState(); |
| 781 |
} |
| 782 |
|
| 783 |
$(document).on('change', 'input[name="payment_method"]', function() { |
| 784 |
schedulePricingSummaryRefresh(); |
| 785 |
}); |
| 786 |
|
| 787 |
// Update when gateway changes |
| 788 |
$('input[name="payment_gateway"]').on('change', function() { |
| 789 |
|
| 790 |
// Show gateway-specific info |
| 791 |
const gateway = $(this).val(); |
| 792 |
const $gatewayInfo = $('#yatra-gateway-info'); |
| 793 |
const $gatewayDetails = $('#yatra-gateway-details'); |
| 794 |
|
| 795 |
const gatewayMessages = { |
| 796 |
'pay_later': '<p>' + __('Your booking will be reserved. Full payment is required before the trip date.', 'yatra') + '</p>', |
| 797 |
'bank_transfer': '<p>' + __('After completing your booking, you will receive bank details via email. Your booking will be confirmed once payment is received.', 'yatra') + '</p>', |
| 798 |
'stripe': '<p>' + __('You will be securely redirected to complete your payment with credit or debit card.', 'yatra') + '</p>', |
| 799 |
'paypal': '<p>' + __('You will be redirected to PayPal to complete your payment securely.', 'yatra') + '</p>', |
| 800 |
'razorpay': '<p>' + __('You will be redirected to Razorpay to complete your payment.', 'yatra') + '</p>', |
| 801 |
'esewa': '<p>' + __('You will be redirected to eSewa to complete your payment.', 'yatra') + '</p>', |
| 802 |
'khalti': '<p>' + __('You will be redirected to Khalti to complete your payment.', 'yatra') + '</p>', |
| 803 |
}; |
| 804 |
|
| 805 |
if (gatewayMessages[gateway] && $gatewayDetails.length) { |
| 806 |
$gatewayDetails.html(gatewayMessages[gateway]); |
| 807 |
$gatewayInfo.show(); |
| 808 |
} else if ($gatewayInfo.length) { |
| 809 |
$gatewayInfo.hide(); |
| 810 |
} |
| 811 |
|
| 812 |
updateCheckoutButtonState(); |
| 813 |
|
| 814 |
// Toggle gateway-specific content containers |
| 815 |
$('.yatra-gateway-extra').removeClass('active'); |
| 816 |
const $gatewayExtra = $('#yatra-gateway-extra-' + gateway); |
| 817 |
// Only show if it has content (children elements) |
| 818 |
if ($gatewayExtra.length && $gatewayExtra.children().length > 0) { |
| 819 |
$gatewayExtra.addClass('active'); |
| 820 |
} |
| 821 |
}); |
| 822 |
|
| 823 |
// Ensure default-selected gateway shows its content on load |
| 824 |
const $defaultGateway = $('input[name="payment_gateway"]:checked'); |
| 825 |
if ($defaultGateway.length) { |
| 826 |
$defaultGateway.trigger('change'); |
| 827 |
} |
| 828 |
|
| 829 |
/** |
| 830 |
* ============================================ |
| 831 |
* CENTRALIZED PAYMENT GATEWAY HOOK SYSTEM |
| 832 |
* ============================================ |
| 833 |
* |
| 834 |
* Events: |
| 835 |
* - yatra_booking_submit: Fired when form is submitted, gateways can intercept |
| 836 |
* - yatra_payment_response: Fired when server responds with payment data |
| 837 |
* - yatra_payment_success: Fired when payment completes successfully |
| 838 |
* - yatra_payment_failed: Fired when payment fails |
| 839 |
* - yatra_payment_cancelled: Fired when user cancels payment |
| 840 |
*/ |
| 841 |
|
| 842 |
// Store for registered gateway handlers |
| 843 |
window.yatraPaymentGateways = window.yatraPaymentGateways || {}; |
| 844 |
|
| 845 |
/** |
| 846 |
* Register a payment gateway handler |
| 847 |
* @param {string} gatewayId - Gateway identifier (e.g., 'stripe', 'razorpay') |
| 848 |
* @param {object} handler - Handler object with methods: canHandle, handlePayment |
| 849 |
*/ |
| 850 |
window.yatraRegisterPaymentGateway = function(gatewayId, handler) { |
| 851 |
|
| 852 |
window.yatraPaymentGateways[gatewayId] = handler; |
| 853 |
}; |
| 854 |
|
| 855 |
/** |
| 856 |
* Handle payment response from server |
| 857 |
*/ |
| 858 |
function handlePaymentResponse(response, originalBtnHtml) { |
| 859 |
|
| 860 |
|
| 861 |
|
| 862 |
if (!response.success) { |
| 863 |
const errMsg = response.message || __('An error occurred. Please try again.', 'yatra'); |
| 864 |
showFormError(errMsg); |
| 865 |
$submitBtn.prop('disabled', false).html(originalBtnHtml); |
| 866 |
return; |
| 867 |
} |
| 868 |
|
| 869 |
// Guest email verification path — booking is held in |
| 870 |
// pending_verification until the customer clicks the |
| 871 |
// magic link in their inbox. Render a "check your email" |
| 872 |
// success screen and DO NOT redirect to payment. |
| 873 |
if (response.code === 'email_verification_required') { |
| 874 |
const data = response.data || {}; |
| 875 |
const reference = data.reference || __('N/A', 'yatra'); |
| 876 |
const email = data.email || ''; |
| 877 |
showSuccessMessage( |
| 878 |
response.message || __("We've sent a verification email. Click the link to complete your booking.", 'yatra'), |
| 879 |
reference, |
| 880 |
{ |
| 881 |
heading: __('Check your email', 'yatra'), |
| 882 |
iconColor: '#2563eb', |
| 883 |
footerHtml: |
| 884 |
'<p style="margin-top: 20px; color: #374151;">' + |
| 885 |
__('We sent a verification link to:', 'yatra') + |
| 886 |
' <strong>' + (email || __('your email', 'yatra')) + '</strong>' + |
| 887 |
'</p>' + |
| 888 |
'<p style="margin-top: 12px; color: #6b7280; font-size: 14px;">' + |
| 889 |
__("Didn't see it? Check your spam folder. The link expires in 48 hours.", 'yatra') + |
| 890 |
'</p>' |
| 891 |
} |
| 892 |
); |
| 893 |
return; |
| 894 |
} |
| 895 |
|
| 896 |
const data = response.data || {}; |
| 897 |
|
| 898 |
if (data.waitlist) { |
| 899 |
const reference = data.reference || __('N/A', 'yatra'); |
| 900 |
showSuccessMessage( |
| 901 |
response.message || __("You're on the waitlist for this departure.", 'yatra'), |
| 902 |
reference, |
| 903 |
{ |
| 904 |
heading: __("You're on the waitlist", 'yatra'), |
| 905 |
iconColor: '#ca8a04', |
| 906 |
footerHtml: '<p style="margin-top: 24px; color: #6b7280;">' + __('We will contact you if a space opens up.', 'yatra') + '</p>' |
| 907 |
} |
| 908 |
); |
| 909 |
return; |
| 910 |
} |
| 911 |
|
| 912 |
|
| 913 |
// Check for redirect URLs first (PayPal, eSewa, Khalti, etc.) |
| 914 |
if (data.payment_url) { |
| 915 |
|
| 916 |
window.location.href = data.payment_url; |
| 917 |
return; |
| 918 |
} |
| 919 |
|
| 920 |
if (data.redirect_url && !data.requires_action) { |
| 921 |
|
| 922 |
window.location.href = data.redirect_url; |
| 923 |
return; |
| 924 |
} |
| 925 |
|
| 926 |
// Check for client-side payment actions (Stripe, Razorpay, Square, etc.) |
| 927 |
if (data.requires_action) { |
| 928 |
|
| 929 |
|
| 930 |
// Dispatch unified payment event |
| 931 |
const paymentEvent = new CustomEvent('yatra_payment_response', { |
| 932 |
detail: { |
| 933 |
...data, |
| 934 |
originalBtnHtml, |
| 935 |
resetButton: () => $submitBtn.prop('disabled', false).html(originalBtnHtml) |
| 936 |
} |
| 937 |
}); |
| 938 |
document.dispatchEvent(paymentEvent); |
| 939 |
|
| 940 |
// Setup failure/cancel listeners |
| 941 |
const cleanup = () => { |
| 942 |
document.removeEventListener('yatra_payment_failed', onFailed); |
| 943 |
document.removeEventListener('yatra_payment_cancelled', onCancelled); |
| 944 |
}; |
| 945 |
const onFailed = (e) => { |
| 946 |
showFormError(e.detail?.error || __('Payment failed. Please try again.', 'yatra')); |
| 947 |
$submitBtn.prop('disabled', false).html(originalBtnHtml); |
| 948 |
cleanup(); |
| 949 |
}; |
| 950 |
const onCancelled = () => { |
| 951 |
$submitBtn.prop('disabled', false).html(originalBtnHtml); |
| 952 |
cleanup(); |
| 953 |
}; |
| 954 |
document.addEventListener('yatra_payment_failed', onFailed, { once: true }); |
| 955 |
document.addEventListener('yatra_payment_cancelled', onCancelled, { once: true }); |
| 956 |
return; |
| 957 |
} |
| 958 |
|
| 959 |
// Default: show success message |
| 960 |
const reference = data.reference || __('N/A', 'yatra'); |
| 961 |
showSuccessMessage(response.message || __('Success!', 'yatra'), reference); |
| 962 |
} |
| 963 |
|
| 964 |
// Form submission - using FormData API |
| 965 |
$form.on('submit', function(e) { |
| 966 |
e.preventDefault(); |
| 967 |
|
| 968 |
// Validate form |
| 969 |
if (!validateForm()) { |
| 970 |
return; |
| 971 |
} |
| 972 |
|
| 973 |
const originalBtnHtml = $submitBtn.html(); |
| 974 |
const selectedGateway = $('input[name="payment_gateway"]:checked').val(); |
| 975 |
|
| 976 |
// Collect form data using FormData API |
| 977 |
const formData = new FormData(this); |
| 978 |
|
| 979 |
// Convert to structured object |
| 980 |
const bookingData = formDataToObject(formData); |
| 981 |
const summary = window.yatraBookingSummary || {}; |
| 982 |
|
| 983 |
// Add checkbox values explicitly (unchecked checkboxes aren't in FormData) |
| 984 |
bookingData.accept_terms = $('input[name="accept_terms"]').is(':checked'); |
| 985 |
bookingData.accept_privacy = $('input[name="accept_privacy"]').is(':checked'); |
| 986 |
bookingData.subscribe_newsletter = $('input[name="subscribe_newsletter"]').is(':checked'); |
| 987 |
bookingData.payment_due = parseFloat($form.attr('data-payment-due')) || window.yatraBookingData?.paymentDue || null; |
| 988 |
// The pay button uses a fallback chain (summary.totalAmount → data-summary-total |
| 989 |
// attribute → data-payment-due) so it can show "$279" even before the async |
| 990 |
// pricing refresh populates `summary.totalAmount`. The submit path must walk |
| 991 |
// the same chain — otherwise the button label and the request body disagree |
| 992 |
// and BookingService rejects with "Total amount must be greater than zero." |
| 993 |
const $summaryEl = $('.yatra-booking-summary').first(); |
| 994 |
const resolveAmount = (preferred, attr) => { |
| 995 |
const n = Number(preferred); |
| 996 |
if (Number.isFinite(n) && n > 0) return n; |
| 997 |
const a = parseFloat($summaryEl.attr(attr)); |
| 998 |
if (Number.isFinite(a) && a > 0) return a; |
| 999 |
return null; |
| 1000 |
}; |
| 1001 |
bookingData.total_amount = |
| 1002 |
resolveAmount(summary.totalAmount, 'data-summary-total') ?? |
| 1003 |
resolveAmount(summary.amountDue, 'data-summary-due') ?? |
| 1004 |
(parseFloat($form.attr('data-payment-due')) || null); |
| 1005 |
bookingData.amount_due = |
| 1006 |
resolveAmount(summary.amountDue, 'data-summary-due') ?? |
| 1007 |
(parseFloat($form.attr('data-payment-due')) || null); |
| 1008 |
bookingData.amount_paid = summary.amountPaid ?? null; |
| 1009 |
bookingData.currency = window.yatraBookingData?.currency || bookingData.currency || $('input[name="currency"]').val() || 'USD'; |
| 1010 |
// Carry the booking_token from the page URL into the REST request. |
| 1011 |
// Without this the server's `create_booking` cannot rehydrate the |
| 1012 |
// PHP session for the user (REST runs in a separate request and |
| 1013 |
// PHPSESSID propagation isn't guaranteed). Server falls back to |
| 1014 |
// looking up the transient by this token to recover `traveler_counts`, |
| 1015 |
// `pricing_type`, `price_types` — without those, `calculatePricing` |
| 1016 |
// returns 0 for `traveler_based` trips and the booking gets |
| 1017 |
// rejected with "Total amount must be greater than zero." |
| 1018 |
try { |
| 1019 |
const urlToken = new URLSearchParams(window.location.search).get('booking_token'); |
| 1020 |
if (urlToken) { |
| 1021 |
bookingData.booking_token = urlToken; |
| 1022 |
} |
| 1023 |
} catch (e) { /* URLSearchParams missing — ignore */ } |
| 1024 |
|
| 1025 |
// Dispatch unified booking submit event - gateways can intercept |
| 1026 |
const submitEvent = new CustomEvent('yatra_booking_submit', { |
| 1027 |
detail: { |
| 1028 |
gateway: selectedGateway, |
| 1029 |
form: this, |
| 1030 |
bookingData, |
| 1031 |
submitButton: $submitBtn[0], |
| 1032 |
originalBtnHtml, |
| 1033 |
// Helper to proceed with default submission |
| 1034 |
proceedWithSubmission: () => submitBookingToServer(bookingData, originalBtnHtml) |
| 1035 |
}, |
| 1036 |
cancelable: true |
| 1037 |
}); |
| 1038 |
|
| 1039 |
|
| 1040 |
const shouldProceed = document.dispatchEvent(submitEvent); |
| 1041 |
|
| 1042 |
// If event was not cancelled, proceed with server submission |
| 1043 |
if (shouldProceed) { |
| 1044 |
submitBookingToServer(bookingData, originalBtnHtml); |
| 1045 |
} |
| 1046 |
}); |
| 1047 |
|
| 1048 |
/** |
| 1049 |
* Submit booking to server |
| 1050 |
*/ |
| 1051 |
function submitBookingToServer(bookingData, originalBtnHtml) { |
| 1052 |
const { base: restBase, isPlain } = getRestBase(); |
| 1053 |
const siteBase = (window.yatraBookingData?.siteUrl || window.location.origin || '').replace(/\/$/, ''); |
| 1054 |
let createUrl; |
| 1055 |
if (isPlain) { |
| 1056 |
createUrl = `${siteBase}/?rest_route=/yatra/v1/booking/create`; |
| 1057 |
} else if (restBase.includes('/yatra/v1')) { |
| 1058 |
createUrl = `${restBase}/booking/create`; |
| 1059 |
} else { |
| 1060 |
createUrl = `${restBase}/yatra/v1/booking/create`; |
| 1061 |
} |
| 1062 |
|
| 1063 |
// Show loading state |
| 1064 |
$submitBtn.prop('disabled', true).html( |
| 1065 |
'<svg class="animate-spin" style="display: inline-block; width: 20px; height: 20px; margin-right: 8px; animation: spin 1s linear infinite;" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">' + |
| 1066 |
'<circle cx="12" cy="12" r="10" stroke-dasharray="32" stroke-dashoffset="12"></circle></svg>' + |
| 1067 |
'<span>' + __('Processing...', 'yatra') + '</span>' |
| 1068 |
); |
| 1069 |
|
| 1070 |
// CSRF protection — the booking REST endpoint's |
| 1071 |
// permission_callback intentionally bypasses WP's default |
| 1072 |
// cookie/nonce check (so guests can hit it at all). The |
| 1073 |
// server validates this booking-scoped action nonce |
| 1074 |
// instead. Falls back to the hidden form field on the |
| 1075 |
// booking page when the localized var isn't present (e.g. |
| 1076 |
// older cached scripts). |
| 1077 |
const bookingNonce = window.yatraBookingData?.bookingNonce |
| 1078 |
|| $('input[name="yatra_booking_nonce"]').val() |
| 1079 |
|| ''; |
| 1080 |
|
| 1081 |
fetch(createUrl, { |
| 1082 |
method: 'POST', |
| 1083 |
headers: { |
| 1084 |
'Content-Type': 'application/json', |
| 1085 |
'X-WP-Nonce': nonce, |
| 1086 |
'X-Yatra-Booking-Nonce': bookingNonce |
| 1087 |
}, |
| 1088 |
credentials: 'same-origin', |
| 1089 |
body: JSON.stringify(bookingData) |
| 1090 |
}) |
| 1091 |
.then(response => response.json()) |
| 1092 |
.then(response => handlePaymentResponse(response, originalBtnHtml)) |
| 1093 |
.catch(error => { |
| 1094 |
console.error('Error:', error); |
| 1095 |
showFormError(__('An error occurred. Please try again.', 'yatra')); |
| 1096 |
$submitBtn.prop('disabled', false).html(originalBtnHtml); |
| 1097 |
}); |
| 1098 |
} |
| 1099 |
|
| 1100 |
// Toggle coupon form visibility |
| 1101 |
$('#yatra-coupon-toggle-btn').on('click', function() { |
| 1102 |
const $form = $('#yatra-coupon-form'); |
| 1103 |
const $chevron = $(this).find('.yatra-coupon-chevron'); |
| 1104 |
|
| 1105 |
if ($form.is(':visible')) { |
| 1106 |
$form.slideUp(200); |
| 1107 |
$chevron.css('transform', 'rotate(0deg)'); |
| 1108 |
} else { |
| 1109 |
$form.slideDown(200); |
| 1110 |
$chevron.css('transform', 'rotate(180deg)'); |
| 1111 |
// Focus the input field |
| 1112 |
setTimeout(() => $('#yatra-coupon-code').focus(), 200); |
| 1113 |
} |
| 1114 |
}); |
| 1115 |
|
| 1116 |
// Apply coupon |
| 1117 |
$('#yatra-apply-coupon').on('click', function() { |
| 1118 |
applyCoupon(); |
| 1119 |
}); |
| 1120 |
|
| 1121 |
// Apply coupon on Enter key |
| 1122 |
$('#yatra-coupon-code').on('keypress', function(e) { |
| 1123 |
if (e.which === 13) { |
| 1124 |
e.preventDefault(); |
| 1125 |
applyCoupon(); |
| 1126 |
} |
| 1127 |
}); |
| 1128 |
|
| 1129 |
// Remove coupon |
| 1130 |
$('#yatra-remove-coupon').on('click', function() { |
| 1131 |
removeCoupon(); |
| 1132 |
}); |
| 1133 |
|
| 1134 |
function applyCoupon() { |
| 1135 |
const code = $('#yatra-coupon-code').val().trim().toUpperCase(); |
| 1136 |
const $btn = $('#yatra-apply-coupon'); |
| 1137 |
const $message = $('#yatra-coupon-message'); |
| 1138 |
|
| 1139 |
if (!code) { |
| 1140 |
showCouponMessage(__('Please enter a coupon code.', 'yatra'), 'error'); |
| 1141 |
return; |
| 1142 |
} |
| 1143 |
|
| 1144 |
// Show loading state |
| 1145 |
$btn.prop('disabled', true).text(__('Applying...', 'yatra')); |
| 1146 |
$message.hide(); |
| 1147 |
|
| 1148 |
// Carry booking_token so the REST endpoint can rehydrate the |
| 1149 |
// session in contexts where PHPSESSID isn't passed through — |
| 1150 |
// same pattern as the service-toggle and summary refresh. |
| 1151 |
const couponPayload = { code: code }; |
| 1152 |
try { |
| 1153 |
const tok = new URLSearchParams(window.location.search).get('booking_token'); |
| 1154 |
if (tok) couponPayload.booking_token = tok; |
| 1155 |
} catch (e) { /* URLSearchParams unavailable — skip */ } |
| 1156 |
|
| 1157 |
fetch(apiUrl + '/booking/coupon/apply', { |
| 1158 |
method: 'POST', |
| 1159 |
headers: { |
| 1160 |
'Content-Type': 'application/json', |
| 1161 |
'X-WP-Nonce': nonce |
| 1162 |
}, |
| 1163 |
credentials: 'same-origin', |
| 1164 |
body: JSON.stringify(couponPayload) |
| 1165 |
}) |
| 1166 |
.then(response => response.json()) |
| 1167 |
.then(response => { |
| 1168 |
$btn.prop('disabled', false).text(__('Apply', 'yatra')); |
| 1169 |
|
| 1170 |
if (response.success) { |
| 1171 |
appliedCoupon = response.data; |
| 1172 |
|
| 1173 |
// Hide the form and show applied coupon |
| 1174 |
$('#yatra-coupon-form').hide(); |
| 1175 |
$('#yatra-coupon-toggle-btn').hide(); |
| 1176 |
|
| 1177 |
// Show applied coupon display |
| 1178 |
const $applied = $('#yatra-applied-coupon'); |
| 1179 |
$applied.find('.yatra-coupon-code-display').text(response.data.code); |
| 1180 |
$applied.find('.yatra-coupon-discount').text('-' + response.data.discount_formatted); |
| 1181 |
$applied.show(); |
| 1182 |
|
| 1183 |
// Update price display |
| 1184 |
updatePriceWithDiscount(response.data); |
| 1185 |
|
| 1186 |
showCouponMessage(response.message, 'success'); |
| 1187 |
} else { |
| 1188 |
showCouponMessage(response.message || __('Invalid coupon code.', 'yatra'), 'error'); |
| 1189 |
} |
| 1190 |
}) |
| 1191 |
.catch(error => { |
| 1192 |
console.error('Coupon error:', error); |
| 1193 |
$btn.prop('disabled', false).text(__('Apply', 'yatra')); |
| 1194 |
showCouponMessage(__('An error occurred. Please try again.', 'yatra'), 'error'); |
| 1195 |
}); |
| 1196 |
} |
| 1197 |
|
| 1198 |
function removeCoupon() { |
| 1199 |
const $btn = $('#yatra-remove-coupon'); |
| 1200 |
|
| 1201 |
$btn.prop('disabled', true); |
| 1202 |
|
| 1203 |
const removePayload = {}; |
| 1204 |
try { |
| 1205 |
const tok = new URLSearchParams(window.location.search).get('booking_token'); |
| 1206 |
if (tok) removePayload.booking_token = tok; |
| 1207 |
} catch (e) { /* URLSearchParams unavailable — skip */ } |
| 1208 |
|
| 1209 |
fetch(apiUrl + '/booking/coupon/remove', { |
| 1210 |
method: 'POST', |
| 1211 |
headers: { |
| 1212 |
'Content-Type': 'application/json', |
| 1213 |
'X-WP-Nonce': nonce |
| 1214 |
}, |
| 1215 |
credentials: 'same-origin', |
| 1216 |
body: JSON.stringify(removePayload) |
| 1217 |
}) |
| 1218 |
.then(response => response.json()) |
| 1219 |
.then(response => { |
| 1220 |
$btn.prop('disabled', false); |
| 1221 |
|
| 1222 |
if (response.success) { |
| 1223 |
appliedCoupon = null; |
| 1224 |
|
| 1225 |
// Hide applied coupon and show toggle button |
| 1226 |
$('#yatra-applied-coupon').hide(); |
| 1227 |
$('#yatra-coupon-toggle-btn').show(); |
| 1228 |
$('#yatra-coupon-code').val(''); |
| 1229 |
$('#yatra-coupon-message').hide(); |
| 1230 |
|
| 1231 |
// Refresh pricing summary via AJAX |
| 1232 |
refreshPricingSummary(); |
| 1233 |
} |
| 1234 |
}) |
| 1235 |
.catch(error => { |
| 1236 |
console.error('Remove coupon error:', error); |
| 1237 |
$btn.prop('disabled', false); |
| 1238 |
}); |
| 1239 |
} |
| 1240 |
|
| 1241 |
function showCouponMessage(message, type) { |
| 1242 |
const $message = $('#yatra-coupon-message'); |
| 1243 |
$message.removeClass('success error').addClass(type); |
| 1244 |
$message.text(message).show(); |
| 1245 |
|
| 1246 |
// Auto-hide success messages |
| 1247 |
if (type === 'success') { |
| 1248 |
setTimeout(() => $message.fadeOut(), 3000); |
| 1249 |
} |
| 1250 |
} |
| 1251 |
|
| 1252 |
function applySummaryResponse(response) { |
| 1253 |
if (!response.success || !response.data) { |
| 1254 |
return; |
| 1255 |
} |
| 1256 |
const d = response.data; |
| 1257 |
if (d.pricing_html) { |
| 1258 |
$('#yatra-summary-pricing').html(d.pricing_html); |
| 1259 |
} |
| 1260 |
window.yatraBookingSummary = { |
| 1261 |
totalAmount: typeof d.total_amount === 'number' ? d.total_amount : parseFloat(d.total_amount), |
| 1262 |
amountDue: typeof d.amount_due === 'number' ? d.amount_due : parseFloat(d.amount_due), |
| 1263 |
amountPaid: typeof d.amount_paid === 'number' ? d.amount_paid : parseFloat(d.amount_paid || 0) || 0 |
| 1264 |
}; |
| 1265 |
const $sb = $('.yatra-booking-summary').first(); |
| 1266 |
if ($sb.length && !Number.isNaN(window.yatraBookingSummary.totalAmount)) { |
| 1267 |
$sb.attr('data-summary-total', String(window.yatraBookingSummary.totalAmount)); |
| 1268 |
} |
| 1269 |
if ($sb.length && !Number.isNaN(window.yatraBookingSummary.amountDue)) { |
| 1270 |
$sb.attr('data-summary-due', String(window.yatraBookingSummary.amountDue)); |
| 1271 |
$form.attr('data-payment-due', String(window.yatraBookingSummary.amountDue)); |
| 1272 |
} |
| 1273 |
updateCheckoutButtonState(); |
| 1274 |
} |
| 1275 |
|
| 1276 |
function refreshPricingSummary(extraPayload) { |
| 1277 |
const { base: restBase, isPlain } = getRestBase(); |
| 1278 |
const siteBase = (window.yatraBookingData?.siteUrl || window.location.origin || '').replace(/\/$/, ''); |
| 1279 |
let summaryUrl; |
| 1280 |
if (isPlain) { |
| 1281 |
summaryUrl = `${siteBase}/?rest_route=/yatra/v1/booking/summary`; |
| 1282 |
} else if (restBase.includes('/yatra/v1')) { |
| 1283 |
summaryUrl = `${restBase}/booking/summary`; |
| 1284 |
} else { |
| 1285 |
summaryUrl = `${restBase}/yatra/v1/booking/summary`; |
| 1286 |
} |
| 1287 |
|
| 1288 |
const body = Object.assign(buildPricingSummaryPayload(), extraPayload || {}); |
| 1289 |
|
| 1290 |
fetch(summaryUrl, { |
| 1291 |
method: 'POST', |
| 1292 |
headers: { |
| 1293 |
'Content-Type': 'application/json', |
| 1294 |
'X-WP-Nonce': nonce |
| 1295 |
}, |
| 1296 |
credentials: 'same-origin', |
| 1297 |
body: JSON.stringify(body) |
| 1298 |
}) |
| 1299 |
.then(response => response.json()) |
| 1300 |
.then(response => { |
| 1301 |
applySummaryResponse(response); |
| 1302 |
}) |
| 1303 |
.catch(error => { |
| 1304 |
console.error('Error refreshing pricing summary:', error); |
| 1305 |
}); |
| 1306 |
} |
| 1307 |
|
| 1308 |
function schedulePricingSummaryRefresh() { |
| 1309 |
if (isRemainingPayment) { |
| 1310 |
return; |
| 1311 |
} |
| 1312 |
clearTimeout(summaryDebounceTimer); |
| 1313 |
summaryDebounceTimer = setTimeout(function() { |
| 1314 |
refreshPricingSummary(); |
| 1315 |
}, 250); |
| 1316 |
} |
| 1317 |
|
| 1318 |
function updatePriceWithDiscount(couponData) { |
| 1319 |
// Refresh pricing summary after coupon applied |
| 1320 |
refreshPricingSummary(); |
| 1321 |
} |
| 1322 |
|
| 1323 |
/** |
| 1324 |
* Load existing coupon from session and display it |
| 1325 |
*/ |
| 1326 |
function loadCouponFromSession() { |
| 1327 |
const { base: restBase, isPlain } = getRestBase(); |
| 1328 |
const siteBase = (window.yatraBookingData?.siteUrl || window.location.origin || '').replace(/\/$/, ''); |
| 1329 |
let sessionUrl; |
| 1330 |
if (isPlain) { |
| 1331 |
sessionUrl = `${siteBase}/?rest_route=/yatra/v1/booking/session`; |
| 1332 |
} else if (restBase.includes('/yatra/v1')) { |
| 1333 |
sessionUrl = `${restBase}/booking/session`; |
| 1334 |
} else { |
| 1335 |
sessionUrl = `${restBase}/yatra/v1/booking/session`; |
| 1336 |
} |
| 1337 |
|
| 1338 |
// Append booking_token from the page URL so the REST endpoint can |
| 1339 |
// rehydrate the session via transient when PHPSESSID hasn't been |
| 1340 |
// carried over — otherwise this GET returns "No active session" |
| 1341 |
// and we never reveal the applied-coupon UI (and its remove |
| 1342 |
// button) after a page refresh. |
| 1343 |
try { |
| 1344 |
const tok = new URLSearchParams(window.location.search).get('booking_token'); |
| 1345 |
if (tok) { |
| 1346 |
const sep = sessionUrl.includes('?') ? '&' : '?'; |
| 1347 |
sessionUrl = sessionUrl + sep + 'booking_token=' + encodeURIComponent(tok); |
| 1348 |
} |
| 1349 |
} catch (e) { /* URLSearchParams unavailable — skip */ } |
| 1350 |
|
| 1351 |
fetch(sessionUrl, { |
| 1352 |
method: 'GET', |
| 1353 |
credentials: 'same-origin', |
| 1354 |
headers: { |
| 1355 |
'Content-Type': 'application/json', |
| 1356 |
'X-WP-Nonce': nonce |
| 1357 |
}, |
| 1358 |
}) |
| 1359 |
.then(response => response.json()) |
| 1360 |
.then(response => { |
| 1361 |
if (response.success && response.data && response.data.coupon) { |
| 1362 |
const coupon = response.data.coupon; |
| 1363 |
// Calculate discount formatted if not present |
| 1364 |
const discountFormatted = coupon.discount_formatted || formatCurrency(coupon.discount_amount || 0, currency); |
| 1365 |
|
| 1366 |
appliedCoupon = { |
| 1367 |
code: coupon.code, |
| 1368 |
type: coupon.type, |
| 1369 |
discount_amount: parseFloat(coupon.discount_amount) || 0, |
| 1370 |
discount_formatted: discountFormatted, |
| 1371 |
new_total: coupon.new_total || 0, |
| 1372 |
new_total_formatted: coupon.new_total_formatted || '' |
| 1373 |
}; |
| 1374 |
|
| 1375 |
// Hide the form and show applied coupon |
| 1376 |
$('#yatra-coupon-form').hide(); |
| 1377 |
$('#yatra-coupon-toggle-btn').hide(); |
| 1378 |
|
| 1379 |
// Show applied coupon display |
| 1380 |
const $applied = $('#yatra-applied-coupon'); |
| 1381 |
$applied.find('.yatra-coupon-code-display').text(coupon.code); |
| 1382 |
$applied.find('.yatra-coupon-discount').text('-' + appliedCoupon.discount_formatted); |
| 1383 |
$applied.show(); |
| 1384 |
|
| 1385 |
// Update price display |
| 1386 |
updatePriceWithDiscount(appliedCoupon); |
| 1387 |
} |
| 1388 |
}) |
| 1389 |
.catch(error => { |
| 1390 |
console.error('Error loading coupon from session:', error); |
| 1391 |
}); |
| 1392 |
} |
| 1393 |
|
| 1394 |
/** |
| 1395 |
* Check URL for coupon parameter and auto-apply |
| 1396 |
*/ |
| 1397 |
function checkUrlForCoupon() { |
| 1398 |
const urlParams = new URLSearchParams(window.location.search); |
| 1399 |
const couponCode = urlParams.get('coupon'); |
| 1400 |
|
| 1401 |
if (couponCode && couponCode.trim()) { |
| 1402 |
// Set the coupon code in the input |
| 1403 |
$('#yatra-coupon-code').val(couponCode.trim().toUpperCase()); |
| 1404 |
|
| 1405 |
// Auto-apply the coupon |
| 1406 |
applyCoupon(); |
| 1407 |
|
| 1408 |
// Remove coupon parameter from URL without page reload |
| 1409 |
urlParams.delete('coupon'); |
| 1410 |
const newUrl = window.location.pathname + (urlParams.toString() ? '?' + urlParams.toString() : ''); |
| 1411 |
window.history.replaceState({}, '', newUrl); |
| 1412 |
} |
| 1413 |
} |
| 1414 |
|
| 1415 |
// Check URL for coupon parameter first (takes priority) |
| 1416 |
// If URL has coupon, it will be applied and session updated |
| 1417 |
// If no URL coupon, load existing coupon from session |
| 1418 |
const urlParams = new URLSearchParams(window.location.search); |
| 1419 |
const urlCoupon = urlParams.get('coupon'); |
| 1420 |
|
| 1421 |
if (urlCoupon && urlCoupon.trim()) { |
| 1422 |
// URL coupon takes priority - apply it |
| 1423 |
checkUrlForCoupon(); |
| 1424 |
} else { |
| 1425 |
// No URL coupon - load from session |
| 1426 |
loadCouponFromSession(); |
| 1427 |
} |
| 1428 |
|
| 1429 |
// Trigger gateway change to show initial info |
| 1430 |
$('input[name="payment_gateway"]:checked').trigger('change'); |
| 1431 |
|
| 1432 |
// ===================== |
| 1433 |
// Additional Services Selection |
| 1434 |
// ===================== |
| 1435 |
|
| 1436 |
// Handle additional service checkbox changes |
| 1437 |
$(document).on('change', '#yatra-additional-services input[type="checkbox"]', function() { |
| 1438 |
const $checkbox = $(this); |
| 1439 |
const $serviceItem = $checkbox.closest('.yatra-service-item'); |
| 1440 |
const serviceId = $serviceItem.data('service-id'); |
| 1441 |
const servicePrice = parseFloat($serviceItem.data('service-price')) || 0; |
| 1442 |
const servicePricePer = $serviceItem.data('service-price-per') || 'booking'; |
| 1443 |
const isRequired = $serviceItem.data('is-required') === 1 || $serviceItem.data('is-required') === '1'; |
| 1444 |
const isChecked = $checkbox.is(':checked'); |
| 1445 |
|
| 1446 |
// Prevent unchecking required services |
| 1447 |
if (isRequired && !isChecked) { |
| 1448 |
$checkbox.prop('checked', true); |
| 1449 |
return; |
| 1450 |
} |
| 1451 |
|
| 1452 |
// Update visual state |
| 1453 |
if (isChecked) { |
| 1454 |
$serviceItem.addClass('yatra-service-selected'); |
| 1455 |
} else { |
| 1456 |
$serviceItem.removeClass('yatra-service-selected'); |
| 1457 |
} |
| 1458 |
|
| 1459 |
// Update booking session with selected services |
| 1460 |
updateServicesInSession(); |
| 1461 |
}); |
| 1462 |
|
| 1463 |
/** |
| 1464 |
* Get selected additional services |
| 1465 |
*/ |
| 1466 |
function getSelectedServices() { |
| 1467 |
const services = []; |
| 1468 |
$('#yatra-additional-services input[type="checkbox"]:checked').each(function() { |
| 1469 |
const $serviceItem = $(this).closest('.yatra-service-item'); |
| 1470 |
services.push({ |
| 1471 |
id: parseInt($serviceItem.data('service-id')), |
| 1472 |
price: parseFloat($serviceItem.data('service-price')) || 0, |
| 1473 |
price_per: $serviceItem.data('service-price-per') || 'booking' |
| 1474 |
}); |
| 1475 |
}); |
| 1476 |
return services; |
| 1477 |
} |
| 1478 |
|
| 1479 |
/** |
| 1480 |
* Update services in booking session |
| 1481 |
*/ |
| 1482 |
function updateServicesInSession() { |
| 1483 |
const selectedServices = getSelectedServices(); |
| 1484 |
const serviceIds = selectedServices.map(s => s.id); |
| 1485 |
|
| 1486 |
const { base: restBase, isPlain } = getRestBase(); |
| 1487 |
let sessionUrl; |
| 1488 |
if (isPlain && !restBase.includes('/yatra/v1')) { |
| 1489 |
sessionUrl = `${restBase}/?rest_route=/yatra/v1/booking/session`; |
| 1490 |
} else if (restBase.includes('/yatra/v1')) { |
| 1491 |
sessionUrl = `${restBase}/booking/session`; |
| 1492 |
} else { |
| 1493 |
sessionUrl = `${restBase}/yatra/v1/booking/session`; |
| 1494 |
} |
| 1495 |
|
| 1496 |
// Carry booking_token from URL so the REST endpoint can rehydrate |
| 1497 |
// the session even when PHPSESSID hasn't propagated to the REST |
| 1498 |
// context (root cause of the "POST /booking/session 400" we saw |
| 1499 |
// on service-toggle). Matches the same pattern the /booking/create |
| 1500 |
// submit uses. |
| 1501 |
const sessionPayload = { additional_services: serviceIds }; |
| 1502 |
try { |
| 1503 |
const tok = new URLSearchParams(window.location.search).get('booking_token'); |
| 1504 |
if (tok) sessionPayload.booking_token = tok; |
| 1505 |
} catch (e) { /* URLSearchParams unavailable — skip */ } |
| 1506 |
|
| 1507 |
fetch(sessionUrl, { |
| 1508 |
method: 'POST', |
| 1509 |
credentials: 'same-origin', |
| 1510 |
headers: { |
| 1511 |
'Content-Type': 'application/json', |
| 1512 |
'X-WP-Nonce': nonce |
| 1513 |
}, |
| 1514 |
body: JSON.stringify(sessionPayload) |
| 1515 |
}) |
| 1516 |
.then(response => response.json()) |
| 1517 |
.then(response => { |
| 1518 |
if (response.success) { |
| 1519 |
schedulePricingSummaryRefresh(); |
| 1520 |
} |
| 1521 |
}) |
| 1522 |
.catch(error => { |
| 1523 |
console.error('Error updating services in session:', error); |
| 1524 |
}); |
| 1525 |
} |
| 1526 |
}); |
| 1527 |
|
| 1528 |
// Add CSS for spinner animation |
| 1529 |
const style = document.createElement('style'); |
| 1530 |
style.textContent = '@keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }'; |
| 1531 |
document.head.appendChild(style); |
| 1532 |
|
| 1533 |
// ===================== |
| 1534 |
// Auth Forms (Login/Register) |
| 1535 |
// ===================== |
| 1536 |
|
| 1537 |
// Tab switching |
| 1538 |
$(document).on('click', '.yatra-auth-tab, .yatra-switch-tab', function() { |
| 1539 |
const tab = $(this).data('tab'); |
| 1540 |
|
| 1541 |
// Update tabs |
| 1542 |
$('.yatra-auth-tab').removeClass('active'); |
| 1543 |
$(`.yatra-auth-tab[data-tab="${tab}"]`).addClass('active'); |
| 1544 |
|
| 1545 |
// Update content |
| 1546 |
$('.yatra-auth-content').hide(); |
| 1547 |
$(`#yatra-auth-${tab}`).show(); |
| 1548 |
}); |
| 1549 |
|
| 1550 |
// Toggle password visibility |
| 1551 |
$(document).on('click', '.yatra-toggle-password', function() { |
| 1552 |
const $input = $(this).parent().find('input'); |
| 1553 |
const $eyeOpen = $(this).find('.eye-open'); |
| 1554 |
const $eyeClosed = $(this).find('.eye-closed'); |
| 1555 |
|
| 1556 |
if ($input.attr('type') === 'password') { |
| 1557 |
$input.attr('type', 'text'); |
| 1558 |
$eyeOpen.hide(); |
| 1559 |
$eyeClosed.show(); |
| 1560 |
} else { |
| 1561 |
$input.attr('type', 'password'); |
| 1562 |
$eyeOpen.show(); |
| 1563 |
$eyeClosed.hide(); |
| 1564 |
} |
| 1565 |
}); |
| 1566 |
|
| 1567 |
// Password strength indicator |
| 1568 |
$(document).on('input', '#yatra-reg-password', function() { |
| 1569 |
const password = $(this).val(); |
| 1570 |
const $strengthMeter = $('#yatra-password-strength'); |
| 1571 |
let strength = 0; |
| 1572 |
|
| 1573 |
if (password.length >= 8) strength++; |
| 1574 |
if (password.match(/[a-z]/) && password.match(/[A-Z]/)) strength++; |
| 1575 |
if (password.match(/[0-9]/)) strength++; |
| 1576 |
if (password.match(/[^a-zA-Z0-9]/)) strength++; |
| 1577 |
|
| 1578 |
$strengthMeter.removeClass('weak fair good strong'); |
| 1579 |
if (password.length > 0) { |
| 1580 |
if (strength <= 1) $strengthMeter.addClass('weak'); |
| 1581 |
else if (strength === 2) $strengthMeter.addClass('fair'); |
| 1582 |
else if (strength === 3) $strengthMeter.addClass('good'); |
| 1583 |
else $strengthMeter.addClass('strong'); |
| 1584 |
} |
| 1585 |
}); |
| 1586 |
|
| 1587 |
// Login form submission via REST API |
| 1588 |
$(document).on('submit', '#yatra-login-form', function(e) { |
| 1589 |
e.preventDefault(); |
| 1590 |
|
| 1591 |
const $form = $(this); |
| 1592 |
const $btn = $form.find('.yatra-auth-submit'); |
| 1593 |
const $btnText = $btn.find('.btn-text'); |
| 1594 |
const $btnLoading = $btn.find('.btn-loading'); |
| 1595 |
const $messageEl = $('#yatra-login-message'); |
| 1596 |
|
| 1597 |
// Remove any existing resend link |
| 1598 |
$('.yatra-resend-verification').remove(); |
| 1599 |
|
| 1600 |
// Show loading |
| 1601 |
$btn.prop('disabled', true); |
| 1602 |
$btnText.hide(); |
| 1603 |
$btnLoading.css('display', 'flex'); |
| 1604 |
$messageEl.hide(); |
| 1605 |
|
| 1606 |
// Prepare data for REST API |
| 1607 |
const loginData = { |
| 1608 |
username: $('#yatra-login-email').val(), |
| 1609 |
password: $('#yatra-login-password').val(), |
| 1610 |
remember: $('input[name="rememberme"]').is(':checked') |
| 1611 |
}; |
| 1612 |
|
| 1613 |
fetch(apiUrl + '/auth/login', { |
| 1614 |
method: 'POST', |
| 1615 |
headers: { |
| 1616 |
'Content-Type': 'application/json', |
| 1617 |
'X-WP-Nonce': nonce |
| 1618 |
}, |
| 1619 |
credentials: 'same-origin', |
| 1620 |
body: JSON.stringify(loginData) |
| 1621 |
}) |
| 1622 |
.then(response => response.json()) |
| 1623 |
.then(response => { |
| 1624 |
if (response.success) { |
| 1625 |
$messageEl.removeClass('error').addClass('success'); |
| 1626 |
$messageEl.text(response.message || __('Login successful! Redirecting...', 'yatra')); |
| 1627 |
$messageEl.show(); |
| 1628 |
|
| 1629 |
// Reload page to show booking form |
| 1630 |
setTimeout(function() { |
| 1631 |
window.location.reload(); |
| 1632 |
}, 1000); |
| 1633 |
} else { |
| 1634 |
$messageEl.removeClass('success').addClass('error'); |
| 1635 |
$messageEl.html(response.message || __('Invalid credentials. Please try again.', 'yatra')); |
| 1636 |
$messageEl.show(); |
| 1637 |
|
| 1638 |
// If email needs verification, show resend option |
| 1639 |
if (response.needs_verification && response.email) { |
| 1640 |
const didntReceiveText = __("Didn't receive the email?", 'yatra'); |
| 1641 |
const resendLinkText = __('Resend verification link', 'yatra'); |
| 1642 |
const $resendLink = $('<div class="yatra-resend-verification" style="margin-top: 12px; text-align: center;">' + |
| 1643 |
'<span style="color: #6b7280; font-size: 14px;">' + didntReceiveText + ' </span>' + |
| 1644 |
'<button type="button" class="yatra-resend-btn" data-email="' + response.email + '" style="background: none; border: none; color: #3b82f6; font-size: 14px; font-weight: 600; cursor: pointer; text-decoration: underline;">' + resendLinkText + '</button>' + |
| 1645 |
'</div>'); |
| 1646 |
$messageEl.after($resendLink); |
| 1647 |
} |
| 1648 |
|
| 1649 |
$btn.prop('disabled', false); |
| 1650 |
$btnText.show(); |
| 1651 |
$btnLoading.hide(); |
| 1652 |
} |
| 1653 |
}) |
| 1654 |
.catch(function() { |
| 1655 |
$messageEl.removeClass('success').addClass('error'); |
| 1656 |
$messageEl.text(__('An error occurred. Please try again.', 'yatra')); |
| 1657 |
$messageEl.show(); |
| 1658 |
|
| 1659 |
$btn.prop('disabled', false); |
| 1660 |
$btnText.show(); |
| 1661 |
$btnLoading.hide(); |
| 1662 |
}); |
| 1663 |
}); |
| 1664 |
|
| 1665 |
// Resend verification email |
| 1666 |
$(document).on('click', '.yatra-resend-btn', function(e) { |
| 1667 |
e.preventDefault(); |
| 1668 |
|
| 1669 |
const $btn = $(this); |
| 1670 |
const email = $btn.data('email'); |
| 1671 |
const $messageEl = $('#yatra-login-message'); |
| 1672 |
|
| 1673 |
// Don't allow click if countdown is active |
| 1674 |
if ($btn.data('countdown-active')) { |
| 1675 |
return; |
| 1676 |
} |
| 1677 |
|
| 1678 |
// Disable button and show loading |
| 1679 |
$btn.prop('disabled', true).text(__('Sending...', 'yatra')); |
| 1680 |
|
| 1681 |
fetch(apiUrl + '/auth/resend-verification', { |
| 1682 |
method: 'POST', |
| 1683 |
headers: { |
| 1684 |
'Content-Type': 'application/json', |
| 1685 |
'X-WP-Nonce': nonce |
| 1686 |
}, |
| 1687 |
credentials: 'same-origin', |
| 1688 |
body: JSON.stringify({ email: email }) |
| 1689 |
}) |
| 1690 |
.then(response => response.json()) |
| 1691 |
.then(response => { |
| 1692 |
if (response.success) { |
| 1693 |
$messageEl.removeClass('error').addClass('success'); |
| 1694 |
$messageEl.text(response.message); |
| 1695 |
$messageEl.show(); |
| 1696 |
$('.yatra-resend-verification').remove(); |
| 1697 |
} else if (response.rate_limited && response.remaining_seconds) { |
| 1698 |
// Start countdown timer |
| 1699 |
startResendCountdown($btn, response.remaining_seconds); |
| 1700 |
$messageEl.removeClass('success').addClass('error'); |
| 1701 |
$messageEl.text(__('Please wait before requesting another email.', 'yatra')); |
| 1702 |
$messageEl.show(); |
| 1703 |
} else { |
| 1704 |
$messageEl.removeClass('success').addClass('error'); |
| 1705 |
$messageEl.text(response.message); |
| 1706 |
$messageEl.show(); |
| 1707 |
$btn.prop('disabled', false).text(__('Resend verification link', 'yatra')); |
| 1708 |
} |
| 1709 |
}) |
| 1710 |
.catch(function() { |
| 1711 |
$messageEl.removeClass('success').addClass('error'); |
| 1712 |
$messageEl.text(__('An error occurred. Please try again.', 'yatra')); |
| 1713 |
$messageEl.show(); |
| 1714 |
$btn.prop('disabled', false).text(__('Resend verification link', 'yatra')); |
| 1715 |
}); |
| 1716 |
}); |
| 1717 |
|
| 1718 |
// Countdown timer for resend button |
| 1719 |
function startResendCountdown($btn, seconds) { |
| 1720 |
$btn.data('countdown-active', true); |
| 1721 |
$btn.prop('disabled', true); |
| 1722 |
|
| 1723 |
const updateCountdown = () => { |
| 1724 |
if (seconds <= 0) { |
| 1725 |
$btn.data('countdown-active', false); |
| 1726 |
$btn.prop('disabled', false); |
| 1727 |
$btn.html(__('Resend verification link', 'yatra')); |
| 1728 |
return; |
| 1729 |
} |
| 1730 |
|
| 1731 |
const mins = Math.floor(seconds / 60); |
| 1732 |
const secs = seconds % 60; |
| 1733 |
const timeStr = mins > 0 |
| 1734 |
? `${mins}:${secs.toString().padStart(2, '0')}` |
| 1735 |
: `${secs}s`; |
| 1736 |
|
| 1737 |
// Resend-OTP countdown label. The %s placeholder is the |
| 1738 |
// remaining time as MM:SS — we wrap the literal so the |
| 1739 |
// whole sentence translates, then slot the timer in via |
| 1740 |
// replace() to avoid a dynamic msgid that gettext can't |
| 1741 |
// extract. |
| 1742 |
/* translators: %s is the remaining time (e.g. "0:45") before the verification code can be resent. */ |
| 1743 |
const resendTxt = __('Resend in %s', 'yatra').replace('%s', timeStr); |
| 1744 |
$btn.html(`<span style="color: #9ca3af;">${resendTxt}</span>`); |
| 1745 |
|
| 1746 |
seconds--; |
| 1747 |
setTimeout(updateCountdown, 1000); |
| 1748 |
}; |
| 1749 |
|
| 1750 |
updateCountdown(); |
| 1751 |
} |
| 1752 |
|
| 1753 |
// Registration form submission via REST API |
| 1754 |
$(document).on('submit', '#yatra-register-form', function(e) { |
| 1755 |
e.preventDefault(); |
| 1756 |
|
| 1757 |
const $form = $(this); |
| 1758 |
const $btn = $form.find('.yatra-auth-submit'); |
| 1759 |
const $btnText = $btn.find('.btn-text'); |
| 1760 |
const $btnLoading = $btn.find('.btn-loading'); |
| 1761 |
const $messageEl = $('#yatra-register-message'); |
| 1762 |
|
| 1763 |
// Validate passwords match |
| 1764 |
const password = $('#yatra-reg-password').val(); |
| 1765 |
const confirmPassword = $('#yatra-reg-confirm-password').val(); |
| 1766 |
|
| 1767 |
if (password !== confirmPassword) { |
| 1768 |
$messageEl.removeClass('success').addClass('error'); |
| 1769 |
$messageEl.text(__('Passwords do not match.', 'yatra')); |
| 1770 |
$messageEl.show(); |
| 1771 |
return; |
| 1772 |
} |
| 1773 |
|
| 1774 |
// Show loading |
| 1775 |
$btn.prop('disabled', true); |
| 1776 |
$btnText.hide(); |
| 1777 |
$btnLoading.css('display', 'flex'); |
| 1778 |
$messageEl.hide(); |
| 1779 |
|
| 1780 |
// Prepare data for REST API |
| 1781 |
const registerData = { |
| 1782 |
first_name: $('#yatra-reg-first-name').val(), |
| 1783 |
last_name: $('#yatra-reg-last-name').val(), |
| 1784 |
email: $('#yatra-reg-email').val(), |
| 1785 |
phone: $('#yatra-reg-phone').val(), |
| 1786 |
password: password, |
| 1787 |
confirm_password: confirmPassword |
| 1788 |
}; |
| 1789 |
|
| 1790 |
fetch(apiUrl + '/auth/register', { |
| 1791 |
method: 'POST', |
| 1792 |
headers: { |
| 1793 |
'Content-Type': 'application/json', |
| 1794 |
'X-WP-Nonce': nonce |
| 1795 |
}, |
| 1796 |
credentials: 'same-origin', |
| 1797 |
body: JSON.stringify(registerData) |
| 1798 |
}) |
| 1799 |
.then(response => response.json()) |
| 1800 |
.then(response => { |
| 1801 |
if (response.success) { |
| 1802 |
$messageEl.removeClass('error').addClass('success'); |
| 1803 |
$messageEl.text(response.message || __('Account created! Please check your email to verify.', 'yatra')); |
| 1804 |
$messageEl.show(); |
| 1805 |
|
| 1806 |
// Reset form |
| 1807 |
$form[0].reset(); |
| 1808 |
$('#yatra-password-strength').removeClass('weak fair good strong'); |
| 1809 |
|
| 1810 |
// If verification is required, switch to login tab after a delay |
| 1811 |
if (response.require_verification) { |
| 1812 |
$btn.prop('disabled', false); |
| 1813 |
$btnText.show(); |
| 1814 |
$btnLoading.hide(); |
| 1815 |
|
| 1816 |
// Show message and switch to login tab after 3 seconds |
| 1817 |
setTimeout(function() { |
| 1818 |
// Switch to login tab |
| 1819 |
$('.yatra-auth-tab').removeClass('active'); |
| 1820 |
$('.yatra-auth-tab[data-tab="login"]').addClass('active'); |
| 1821 |
$('.yatra-auth-content').hide(); |
| 1822 |
$('#yatra-auth-login').show(); |
| 1823 |
|
| 1824 |
// Show info message on login tab |
| 1825 |
const $loginMessage = $('#yatra-login-message'); |
| 1826 |
$loginMessage.removeClass('error').addClass('success'); |
| 1827 |
$loginMessage.text(__('Please check your email and click the verification link, then login here.', 'yatra')); |
| 1828 |
$loginMessage.show(); |
| 1829 |
}, 3000); |
| 1830 |
} else { |
| 1831 |
// Reload page if no verification required (shouldn't happen normally) |
| 1832 |
setTimeout(function() { |
| 1833 |
window.location.reload(); |
| 1834 |
}, 1000); |
| 1835 |
} |
| 1836 |
} else { |
| 1837 |
$messageEl.removeClass('success').addClass('error'); |
| 1838 |
$messageEl.text(response.message || __('Registration failed. Please try again.', 'yatra')); |
| 1839 |
$messageEl.show(); |
| 1840 |
|
| 1841 |
$btn.prop('disabled', false); |
| 1842 |
$btnText.show(); |
| 1843 |
$btnLoading.hide(); |
| 1844 |
} |
| 1845 |
}) |
| 1846 |
.catch(function() { |
| 1847 |
$messageEl.removeClass('success').addClass('error'); |
| 1848 |
$messageEl.text(__('An error occurred. Please try again.', 'yatra')); |
| 1849 |
$messageEl.show(); |
| 1850 |
|
| 1851 |
$btn.prop('disabled', false); |
| 1852 |
$btnText.show(); |
| 1853 |
$btnLoading.hide(); |
| 1854 |
}); |
| 1855 |
}); |
| 1856 |
|
| 1857 |
// End of document ready |
| 1858 |
})(jQuery); |
| 1859 |
|