PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.5.1
Yatra – Travel Booking & Tour Operator Software v3.0.5.1
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 2.0.10 All 83 releases
yatra / assets / js / booking.js

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

1,859 lines 80.4 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 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': '',
232 'CZK': '', 'HUF': 'Ft', 'ILS': '', 'TWD': 'NT$', 'PKR': '',
233 'BDT': '', 'LKR': 'Rs', 'EGP': '', '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