PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.14.1
Yatra – Travel Booking & Tour Operator Software v3.0.14.1
3.0.16 3.0.15 3.0.14 3.0.14.1 3.0.14.2 3.0.12 3.0.13 3.0.11 3.0.10 3.0.9 3.0.8 3.0.7 3.0.6 3.0.5 3.0.5.1 3.0.4 3.0.3 3.0.2.9 3.0.2.7 3.0.2.8 3.0.2.6 trunk 1.0.0 2.0.0 2.0.1 All 84 releases
yatra / assets / js / booking.js

booking.js in Yatra – Travel Booking & Tour Operator Software 3.0.14.1, at assets/js/booking.js

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