| @@ -1,13 +1,55 @@ | ||
| 1 | 1 | /** |
| 2 | 2 | * Booking Page JavaScript |
| 3 | - * | |
| 3 | + * | |
| 4 | 4 | * Uses FormData API to automatically collect all form fields |
| 5 | 5 | * and submit via REST API |
| 6 | - * | |
| 6 | + * | |
| 7 | 7 | * @package Yatra |
| 8 | 8 | */ |
| 9 | 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 | + | |
| 10 | 52 | (function($) { |
| 11 | 53 | 'use strict'; |
| 12 | 54 | |
| 13 | 55 | // API configuration - available throughout the module |
| @@ -35,8 +77,43 @@ | ||
| 35 | 77 | |
| 36 | 78 | $(document).ready(function() { |
| 37 | 79 | const $form = $('#yatra-booking-form'); |
| 38 | 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 | + })(); | |
| 39 | 116 | const isRemainingPayment = Boolean(window.yatraBookingData?.isRemainingPayment) || ($form.data('is-remaining-payment') === 'yes'); |
| 40 | 117 | const remainingAmount = isRemainingPayment |
| 41 | 118 | ? parseFloat(window.yatraBookingData?.remainingAmount ?? $form.data('payment-due')) || 0 |
| 42 | 119 | : 0; |
| @@ -63,30 +140,35 @@ | ||
| 63 | 140 | // Country options for dynamically added travelers |
| 64 | 141 | const countryOptions = generateCountryOptions(); |
| 65 | 142 | |
| 66 | 143 | /** |
| 67 | - * Generate country select options HTML | |
| 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. | |
| 68 | 150 | */ |
| 69 | 151 | function generateCountryOptions() { |
| 70 | 152 | const countries = { |
| 71 | - 'AF': 'Afghanistan', 'AL': 'Albania', 'DZ': 'Algeria', 'AR': 'Argentina', | |
| 72 | - 'AU': 'Australia', 'AT': 'Austria', 'BD': 'Bangladesh', 'BE': 'Belgium', | |
| 73 | - 'BR': 'Brazil', 'BT': 'Bhutan', 'CA': 'Canada', 'CN': 'China', | |
| 74 | - 'CO': 'Colombia', 'CZ': 'Czech Republic', 'DK': 'Denmark', 'EG': 'Egypt', | |
| 75 | - 'FI': 'Finland', 'FR': 'France', 'DE': 'Germany', 'GR': 'Greece', | |
| 76 | - 'HK': 'Hong Kong', 'HU': 'Hungary', 'IS': 'Iceland', 'IN': 'India', | |
| 77 | - 'ID': 'Indonesia', 'IE': 'Ireland', 'IL': 'Israel', 'IT': 'Italy', | |
| 78 | - 'JP': 'Japan', 'KE': 'Kenya', 'KR': 'South Korea', 'MY': 'Malaysia', | |
| 79 | - 'MV': 'Maldives', 'MX': 'Mexico', 'NL': 'Netherlands', 'NZ': 'New Zealand', | |
| 80 | - 'NP': 'Nepal', 'NO': 'Norway', 'PK': 'Pakistan', 'PE': 'Peru', | |
| 81 | - 'PH': 'Philippines', 'PL': 'Poland', 'PT': 'Portugal', 'RO': 'Romania', | |
| 82 | - 'RU': 'Russia', 'SA': 'Saudi Arabia', 'SG': 'Singapore', 'ZA': 'South Africa', | |
| 83 | - 'ES': 'Spain', 'LK': 'Sri Lanka', 'SE': 'Sweden', 'CH': 'Switzerland', | |
| 84 | - 'TW': 'Taiwan', 'TH': 'Thailand', 'TR': 'Turkey', 'AE': 'United Arab Emirates', | |
| 85 | - 'GB': 'United Kingdom', 'US': 'United States', 'VN': 'Vietnam' | |
| 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') | |
| 86 | 168 | }; |
| 87 | - | |
| 88 | - let options = '<option value="">Select Country</option>'; | |
| 169 | + | |
| 170 | + let options = '<option value="">' + __('Select Country', 'yatra') + '</option>'; | |
| 89 | 171 | for (const [code, name] of Object.entries(countries)) { |
| 90 | 172 | options += `<option value="${code}">${name}</option>`; |
| 91 | 173 | } |
| 92 | 174 | return options; |
| @@ -129,14 +211,20 @@ | ||
| 129 | 211 | let total = 0; |
| 130 | 212 | $('.yatra-qty-input[data-category-id]').each(function() { |
| 131 | 213 | const count = parseInt($(this).val()) || 0; |
| 132 | 214 | const price = parseFloat($(this).data('price')) || 0; |
| 133 | - const pricingMode = $(this).data('pricing-mode') || $(this).closest('.yatra-quantity-row').data('pricing-mode') || 'per_person'; | |
| 134 | - | |
| 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 | + | |
| 135 | 220 | if (pricingMode === 'per_group') { |
| 136 | - // Per group: charge flat price once if any travelers in this category | |
| 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". | |
| 137 | 223 | if (count > 0) { |
| 138 | - total += price; | |
| 224 | + total += (groupOverflow === 'per_block' && maxPax > 0) | |
| 225 | + ? price * Math.ceil(count / maxPax) | |
| 226 | + : price; | |
| 139 | 227 | } |
| 140 | 228 | } else { |
| 141 | 229 | // Per person: charge per traveler |
| 142 | 230 | total += count * price; |
| @@ -158,12 +246,17 @@ | ||
| 158 | 246 | $('.yatra-qty-input[data-category-id]').each(function() { |
| 159 | 247 | const count = parseInt($(this).val()) || 0; |
| 160 | 248 | const categoryLabel = $(this).closest('.yatra-quantity-row').find('.yatra-quantity-title').text(); |
| 161 | 249 | if (count > 0) { |
| 162 | - parts.push(categoryLabel + ' x ' + count); | |
| 250 | + parts.push(sprintf( | |
| 251 | + /* translators: 1: traveler category label, 2: count. */ | |
| 252 | + __('%1$s x %2$d', 'yatra'), | |
| 253 | + categoryLabel, | |
| 254 | + count | |
| 255 | + )); | |
| 163 | 256 | } |
| 164 | 257 | }); |
| 165 | - const displayText = parts.length > 0 ? parts.join(', ') : 'Select travelers'; | |
| 258 | + const displayText = parts.length > 0 ? parts.join(', ') : __('Select travelers', 'yatra'); | |
| 166 | 259 | $('#yatra-travelers-display').text(displayText); |
| 167 | 260 | } |
| 168 | 261 | |
| 169 | 262 | /** |
| @@ -169,10 +262,13 @@ | ||
| 169 | 262 | /** |
| 170 | 263 | * Update display text for regular pricing |
| 171 | 264 | */ |
| 172 | 265 | function updateRegularTravelersDisplayText(count) { |
| 173 | - const label = count === 1 ? 'traveler' : 'travelers'; | |
| 174 | - $('#yatra-travelers-display-regular').text(count + ' ' + label); | |
| 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 | + )); | |
| 175 | 271 | } |
| 176 | 272 | |
| 177 | 273 | |
| 178 | 274 | /** |
| @@ -216,13 +312,31 @@ | ||
| 216 | 312 | .split('.').join('__DECIMAL__') |
| 217 | 313 | .replace(/__THOUSAND__/g, thousandSeparator) |
| 218 | 314 | .replace(/__DECIMAL__/g, decimalSeparator); |
| 219 | 315 | |
| 220 | - // Position currency based on settings (before or after) | |
| 221 | - if (currencyPosition === 'right' || currencyPosition === 'after') { | |
| 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') { | |
| 222 | 334 | return formattedAmount + ' ' + symbol; |
| 223 | 335 | } |
| 224 | - | |
| 336 | + if (pos === 'left') { | |
| 337 | + return symbol + formattedAmount; | |
| 338 | + } | |
| 225 | 339 | return symbol + ' ' + formattedAmount; |
| 226 | 340 | } |
| 227 | 341 | |
| 228 | 342 | /** |
| @@ -264,8 +378,17 @@ | ||
| 264 | 378 | const pricingType = $('#yatra-summary-pricing').data('pricing-type'); |
| 265 | 379 | if (pricingType) { |
| 266 | 380 | payload.pricing_type = pricingType; |
| 267 | 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 */ } | |
| 268 | 391 | return payload; |
| 269 | 392 | } |
| 270 | 393 | |
| 271 | 394 | /** |
| @@ -290,9 +413,9 @@ | ||
| 290 | 413 | } |
| 291 | 414 | const flexDue = paymentMethod === 'deposit' || paymentMethod === 'partial'; |
| 292 | 415 | |
| 293 | 416 | if (isOffline) { |
| 294 | - $buttonText.text('Complete Booking'); | |
| 417 | + $buttonText.text(window.yatraBookingData?.i18n?.complete_booking || __('Complete Booking', 'yatra')); | |
| 295 | 418 | const showAmount = flexDue ? due : totalSafe; |
| 296 | 419 | if (showAmount > 0) { |
| 297 | 420 | $payAmount.text(formatCurrency(showAmount, currency)).show(); |
| 298 | 421 | } else { |
| @@ -298,9 +421,9 @@ | ||
| 298 | 421 | } else { |
| 299 | 422 | $payAmount.hide(); |
| 300 | 423 | } |
| 301 | 424 | } else { |
| 302 | - $buttonText.text('Pay Now'); | |
| 425 | + $buttonText.text(window.yatraBookingData?.i18n?.pay_now || __('Pay Now', 'yatra')); | |
| 303 | 426 | const payVal = flexDue && due > 0 ? due : totalSafe; |
| 304 | 427 | if (payVal > 0) { |
| 305 | 428 | $payAmount.text(formatCurrency(payVal, currency)).show(); |
| 306 | 429 | } else { |
| @@ -320,16 +443,30 @@ | ||
| 320 | 443 | if (!$firstTraveler.length) return; |
| 321 | 444 | |
| 322 | 445 | // Clone the first traveler form |
| 323 | 446 | const $newTraveler = $firstTraveler.clone(); |
| 324 | - | |
| 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 | + | |
| 325 | 454 | // Update index references |
| 326 | 455 | $newTraveler.attr('data-traveler-index', index); |
| 327 | - $newTraveler.find('.yatra-traveler-title').text('Traveler ' + index); | |
| 456 | + $newTraveler.find('.yatra-traveler-title').text( | |
| 457 | + /* translators: %d: traveler sequence number. */ | |
| 458 | + sprintf(__('Traveler %d', 'yatra'), index) | |
| 459 | + ); | |
| 328 | 460 | |
| 329 | - // Add "Additional traveler" note | |
| 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. | |
| 330 | 465 | if (!$newTraveler.find('.yatra-traveler-note').length) { |
| 331 | - $newTraveler.find('.yatra-traveler-header').append('<span class="yatra-traveler-note">Additional traveler</span>'); | |
| 466 | + $newTraveler.find('.yatra-traveler-header').append( | |
| 467 | + '<span class="yatra-traveler-note">' + __('Additional traveler', 'yatra') + '</span>' | |
| 468 | + ); | |
| 332 | 469 | } |
| 333 | 470 | |
| 334 | 471 | // Update all field IDs and names |
| 335 | 472 | $newTraveler.find('input, select, textarea').each(function() { |
| @@ -379,16 +516,16 @@ | ||
| 379 | 516 | * Convert FormData to nested object structure |
| 380 | 517 | */ |
| 381 | 518 | function formDataToObject(formData) { |
| 382 | 519 | const obj = {}; |
| 383 | - | |
| 520 | + | |
| 384 | 521 | for (const [key, value] of formData.entries()) { |
| 385 | 522 | // Handle array notation like travelers[1][first_name] |
| 386 | 523 | const matches = key.match(/^([^\[]+)(?:\[([^\]]*)\])?(?:\[([^\]]*)\])?$/); |
| 387 | - | |
| 524 | + | |
| 388 | 525 | if (matches) { |
| 389 | 526 | const [, base, index, field] = matches; |
| 390 | - | |
| 527 | + | |
| 391 | 528 | if (index !== undefined && field !== undefined) { |
| 392 | 529 | // Array of objects: travelers[1][first_name] |
| 393 | 530 | if (!obj[base]) obj[base] = {}; |
| 394 | 531 | if (!obj[base][index]) obj[base][index] = {}; |
| @@ -393,11 +530,23 @@ | ||
| 393 | 530 | if (!obj[base]) obj[base] = {}; |
| 394 | 531 | if (!obj[base][index]) obj[base][index] = {}; |
| 395 | 532 | obj[base][index][field] = value; |
| 396 | 533 | } else if (index !== undefined) { |
| 397 | - // Simple array: items[0] | |
| 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. | |
| 398 | 543 | if (!obj[base]) obj[base] = []; |
| 399 | - obj[base][index] = value; | |
| 544 | + if (index === '') { | |
| 545 | + obj[base].push(value); | |
| 546 | + } else { | |
| 547 | + obj[base][index] = value; | |
| 548 | + } | |
| 400 | 549 | } else { |
| 401 | 550 | // Simple key |
| 402 | 551 | obj[key] = value; |
| 403 | 552 | } |
| @@ -431,15 +580,30 @@ | ||
| 431 | 580 | |
| 432 | 581 | // Validate all required fields |
| 433 | 582 | $form.find('input[required], select[required], textarea[required]').each(function() { |
| 434 | 583 | const $field = $(this); |
| 435 | - const value = $field.val(); | |
| 436 | - | |
| 437 | - if (!value || value.trim() === '') { | |
| 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) { | |
| 438 | 598 | isValid = false; |
| 439 | 599 | $field.addClass('error'); |
| 440 | 600 | const label = $field.closest('.yatra-form-group').find('label').text().replace('*', '').trim(); |
| 441 | - errors.push(label + ' is required'); | |
| 601 | + errors.push(sprintf( | |
| 602 | + /* translators: %s: form field label. */ | |
| 603 | + __('%s is required', 'yatra'), | |
| 604 | + label | |
| 605 | + )); | |
| 442 | 606 | } |
| 443 | 607 | }); |
| 444 | 608 | |
| 445 | 609 | // Validate email format |
| @@ -449,9 +613,9 @@ | ||
| 449 | 613 | const value = $field.val(); |
| 450 | 614 | if (value && !isValidEmail(value)) { |
| 451 | 615 | isValid = false; |
| 452 | 616 | $field.addClass('error'); |
| 453 | - errors.push('Please enter a valid email address'); | |
| 617 | + errors.push(__('Please enter a valid email address', 'yatra')); | |
| 454 | 618 | } |
| 455 | 619 | }); |
| 456 | 620 | |
| 457 | 621 | // Validate terms checkbox |
| @@ -456,15 +620,15 @@ | ||
| 456 | 620 | |
| 457 | 621 | // Validate terms checkbox |
| 458 | 622 | if (!$('input[name="accept_terms"]').is(':checked')) { |
| 459 | 623 | isValid = false; |
| 460 | - errors.push('Please accept the Terms and Conditions'); | |
| 624 | + errors.push(__('Please accept the Terms and Conditions', 'yatra')); | |
| 461 | 625 | } |
| 462 | 626 | |
| 463 | 627 | // Validate privacy checkbox |
| 464 | 628 | if (!$('input[name="accept_privacy"]').is(':checked')) { |
| 465 | 629 | isValid = false; |
| 466 | - errors.push('Please accept the Privacy Policy'); | |
| 630 | + errors.push(__('Please accept the Privacy Policy', 'yatra')); | |
| 467 | 631 | } |
| 468 | 632 | |
| 469 | 633 | if (!isValid && errors.length > 0) { |
| 470 | 634 | showFormError(errors[0]); |
| @@ -500,22 +664,33 @@ | ||
| 500 | 664 | * @param {{ heading?: string, footerHtml?: string, iconColor?: string }|undefined} opts |
| 501 | 665 | */ |
| 502 | 666 | function showSuccessMessage(message, reference, opts) { |
| 503 | 667 | opts = opts || {}; |
| 504 | - const heading = opts.heading || 'Booking Confirmed!'; | |
| 668 | + const heading = opts.heading || __('Booking Confirmed!', 'yatra'); | |
| 505 | 669 | const iconColor = opts.iconColor || '#22c55e'; |
| 670 | + const confirmationEmailText = __('A confirmation email has been sent to your email address.', 'yatra'); | |
| 506 | 671 | const footerHtml = opts.footerHtml !== undefined |
| 507 | 672 | ? opts.footerHtml |
| 508 | - : '<p style="margin-top: 24px; color: #6b7280;">A confirmation email has been sent to your email address.</p>'; | |
| 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 | + ); | |
| 509 | 679 | const $success = $('<div class="yatra-booking-success" style="text-align: center; padding: 60px 20px;">' + |
| 510 | 680 | '<svg style="width: 80px; height: 80px; color: ' + iconColor + '; margin-bottom: 24px;" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">' + |
| 511 | 681 | '<circle cx="12" cy="12" r="10"></circle><polyline points="9,12 12,15 16,10"></polyline></svg>' + |
| 512 | 682 | '<h2 style="font-size: 28px; margin-bottom: 12px; color: #111827;">' + heading + '</h2>' + |
| 513 | 683 | '<p style="font-size: 18px; color: #6b7280; margin-bottom: 8px;">' + message + '</p>' + |
| 514 | - '<p style="font-size: 16px; color: #111827; font-weight: 600;">Reference: ' + reference + '</p>' + | |
| 684 | + '<p style="font-size: 16px; color: #111827; font-weight: 600;">' + referenceText + '</p>' + | |
| 515 | 685 | footerHtml + |
| 516 | 686 | '</div>'); |
| 517 | - | |
| 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(); | |
| 518 | 693 | $form.html($success); |
| 519 | 694 | } |
| 520 | 695 | |
| 521 | 696 | // ===================== |
| @@ -555,10 +730,10 @@ | ||
| 555 | 730 | |
| 556 | 731 | const $btn = $(this); |
| 557 | 732 | const $input = $('#number-of-travelers'); |
| 558 | 733 | let currentValue = parseInt($input.val()) || 1; |
| 559 | - const min = parseInt($input.attr('min')) || 1; | |
| 560 | - const max = parseInt($input.attr('max')) || 20; | |
| 734 | + const min = window.yatraNumOr($input.attr('min'), 1); | |
| 735 | + const max = window.yatraNumOr($input.attr('max'), 20); | |
| 561 | 736 | |
| 562 | 737 | if ($btn.hasClass('yatra-qty-plus')) { |
| 563 | 738 | if (currentValue < max) { |
| 564 | 739 | currentValue++; |
| @@ -588,10 +763,10 @@ | ||
| 588 | 763 | const $btn = $(this); |
| 589 | 764 | const $row = $btn.closest('.yatra-quantity-row'); |
| 590 | 765 | const $input = $row.find('.yatra-qty-input'); |
| 591 | 766 | let currentValue = parseInt($input.val()) || 0; |
| 592 | - const min = parseInt($input.attr('min')) || 0; | |
| 593 | - const max = parseInt($input.attr('max')) || 20; | |
| 767 | + const min = window.yatraNumOr($input.attr('min'), 0); | |
| 768 | + const max = window.yatraNumOr($input.attr('max'), 20); | |
| 594 | 769 | |
| 595 | 770 | if ($btn.hasClass('yatra-qty-plus')) { |
| 596 | 771 | if (currentValue < max) { |
| 597 | 772 | currentValue++; |
| @@ -721,15 +896,15 @@ | ||
| 721 | 896 | const $gatewayInfo = $('#yatra-gateway-info'); |
| 722 | 897 | const $gatewayDetails = $('#yatra-gateway-details'); |
| 723 | 898 | |
| 724 | 899 | const gatewayMessages = { |
| 725 | - 'pay_later': '<p>Your booking will be reserved. Full payment is required before the trip date.</p>', | |
| 726 | - 'bank_transfer': '<p>After completing your booking, you will receive bank details via email. Your booking will be confirmed once payment is received.</p>', | |
| 727 | - 'stripe': '<p>You will be securely redirected to complete your payment with credit or debit card.</p>', | |
| 728 | - 'paypal': '<p>You will be redirected to PayPal to complete your payment securely.</p>', | |
| 729 | - 'razorpay': '<p>You will be redirected to Razorpay to complete your payment.</p>', | |
| 730 | - 'esewa': '<p>You will be redirected to eSewa to complete your payment.</p>', | |
| 731 | - 'khalti': '<p>You will be redirected to Khalti to complete your payment.</p>', | |
| 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>', | |
| 732 | 907 | }; |
| 733 | 908 | |
| 734 | 909 | if (gatewayMessages[gateway] && $gatewayDetails.length) { |
| 735 | 910 | $gatewayDetails.html(gatewayMessages[gateway]); |
| @@ -784,29 +959,56 @@ | ||
| 784 | 959 | /** |
| 785 | 960 | * Handle payment response from server |
| 786 | 961 | */ |
| 787 | 962 | function handlePaymentResponse(response, originalBtnHtml) { |
| 788 | - | |
| 789 | - | |
| 790 | - | |
| 963 | + | |
| 964 | + | |
| 965 | + | |
| 791 | 966 | if (!response.success) { |
| 792 | - const errMsg = response.message || 'An error occurred. Please try again.'; | |
| 967 | + const errMsg = response.message || __('An error occurred. Please try again.', 'yatra'); | |
| 793 | 968 | showFormError(errMsg); |
| 794 | 969 | $submitBtn.prop('disabled', false).html(originalBtnHtml); |
| 795 | 970 | return; |
| 796 | 971 | } |
| 797 | - | |
| 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 | + | |
| 798 | 1000 | const data = response.data || {}; |
| 799 | 1001 | |
| 800 | 1002 | if (data.waitlist) { |
| 801 | - const reference = data.reference || 'N/A'; | |
| 1003 | + const reference = data.reference || __('N/A', 'yatra'); | |
| 802 | 1004 | showSuccessMessage( |
| 803 | - response.message || "You're on the waitlist for this departure.", | |
| 1005 | + response.message || __("You're on the waitlist for this departure.", 'yatra'), | |
| 804 | 1006 | reference, |
| 805 | 1007 | { |
| 806 | - heading: "You're on the waitlist", | |
| 1008 | + heading: __("You're on the waitlist", 'yatra'), | |
| 807 | 1009 | iconColor: '#ca8a04', |
| 808 | - footerHtml: '<p style="margin-top: 24px; color: #6b7280;">We will contact you if a space opens up.</p>' | |
| 1010 | + footerHtml: '<p style="margin-top: 24px; color: #6b7280;">' + __('We will contact you if a space opens up.', 'yatra') + '</p>' | |
| 809 | 1011 | } |
| 810 | 1012 | ); |
| 811 | 1013 | return; |
| 812 | 1014 | } |
| @@ -844,9 +1046,9 @@ | ||
| 844 | 1046 | document.removeEventListener('yatra_payment_failed', onFailed); |
| 845 | 1047 | document.removeEventListener('yatra_payment_cancelled', onCancelled); |
| 846 | 1048 | }; |
| 847 | 1049 | const onFailed = (e) => { |
| 848 | - showFormError(e.detail?.error || 'Payment failed. Please try again.'); | |
| 1050 | + showFormError(e.detail?.error || __('Payment failed. Please try again.', 'yatra')); | |
| 849 | 1051 | $submitBtn.prop('disabled', false).html(originalBtnHtml); |
| 850 | 1052 | cleanup(); |
| 851 | 1053 | }; |
| 852 | 1054 | const onCancelled = () => { |
| @@ -858,10 +1060,10 @@ | ||
| 858 | 1060 | return; |
| 859 | 1061 | } |
| 860 | 1062 | |
| 861 | 1063 | // Default: show success message |
| 862 | - const reference = data.reference || 'N/A'; | |
| 863 | - showSuccessMessage(response.message || 'Success!', reference); | |
| 1064 | + const reference = data.reference || __('N/A', 'yatra'); | |
| 1065 | + showSuccessMessage(response.message || __('Success!', 'yatra'), reference); | |
| 864 | 1066 | } |
| 865 | 1067 | |
| 866 | 1068 | // Form submission - using FormData API |
| 867 | 1069 | $form.on('submit', function(e) { |
| @@ -886,12 +1088,44 @@ | ||
| 886 | 1088 | bookingData.accept_terms = $('input[name="accept_terms"]').is(':checked'); |
| 887 | 1089 | bookingData.accept_privacy = $('input[name="accept_privacy"]').is(':checked'); |
| 888 | 1090 | bookingData.subscribe_newsletter = $('input[name="subscribe_newsletter"]').is(':checked'); |
| 889 | 1091 | bookingData.payment_due = parseFloat($form.attr('data-payment-due')) || window.yatraBookingData?.paymentDue || null; |
| 890 | - bookingData.total_amount = summary.totalAmount ?? null; | |
| 891 | - bookingData.amount_due = summary.amountDue ?? 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); | |
| 892 | 1112 | bookingData.amount_paid = summary.amountPaid ?? null; |
| 893 | 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 */ } | |
| 894 | 1128 | |
| 895 | 1129 | // Dispatch unified booking submit event - gateways can intercept |
| 896 | 1130 | const submitEvent = new CustomEvent('yatra_booking_submit', { |
| 897 | 1131 | detail: { |
| @@ -918,8 +1152,25 @@ | ||
| 918 | 1152 | /** |
| 919 | 1153 | * Submit booking to server |
| 920 | 1154 | */ |
| 921 | 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) { | |
| 922 | 1173 | const { base: restBase, isPlain } = getRestBase(); |
| 923 | 1174 | const siteBase = (window.yatraBookingData?.siteUrl || window.location.origin || '').replace(/\/$/, ''); |
| 924 | 1175 | let createUrl; |
| 925 | 1176 | if (isPlain) { |
| @@ -933,16 +1184,28 @@ | ||
| 933 | 1184 | // Show loading state |
| 934 | 1185 | $submitBtn.prop('disabled', true).html( |
| 935 | 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">' + |
| 936 | 1187 | '<circle cx="12" cy="12" r="10" stroke-dasharray="32" stroke-dashoffset="12"></circle></svg>' + |
| 937 | - '<span>Processing...</span>' | |
| 1188 | + '<span>' + __('Processing...', 'yatra') + '</span>' | |
| 938 | 1189 | ); |
| 939 | 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 | + | |
| 940 | 1202 | fetch(createUrl, { |
| 941 | 1203 | method: 'POST', |
| 942 | 1204 | headers: { |
| 943 | 1205 | 'Content-Type': 'application/json', |
| 944 | - 'X-WP-Nonce': nonce | |
| 1206 | + 'X-WP-Nonce': nonce, | |
| 1207 | + 'X-Yatra-Booking-Nonce': bookingNonce | |
| 945 | 1208 | }, |
| 946 | 1209 | credentials: 'same-origin', |
| 947 | 1210 | body: JSON.stringify(bookingData) |
| 948 | 1211 | }) |
| @@ -949,9 +1212,9 @@ | ||
| 949 | 1212 | .then(response => response.json()) |
| 950 | 1213 | .then(response => handlePaymentResponse(response, originalBtnHtml)) |
| 951 | 1214 | .catch(error => { |
| 952 | 1215 | console.error('Error:', error); |
| 953 | - showFormError('An error occurred. Please try again.'); | |
| 1216 | + showFormError(__('An error occurred. Please try again.', 'yatra')); | |
| 954 | 1217 | $submitBtn.prop('disabled', false).html(originalBtnHtml); |
| 955 | 1218 | }); |
| 956 | 1219 | } |
| 957 | 1220 | |
| @@ -994,16 +1257,25 @@ | ||
| 994 | 1257 | const $btn = $('#yatra-apply-coupon'); |
| 995 | 1258 | const $message = $('#yatra-coupon-message'); |
| 996 | 1259 | |
| 997 | 1260 | if (!code) { |
| 998 | - showCouponMessage('Please enter a coupon code.', 'error'); | |
| 1261 | + showCouponMessage(__('Please enter a coupon code.', 'yatra'), 'error'); | |
| 999 | 1262 | return; |
| 1000 | 1263 | } |
| 1001 | 1264 | |
| 1002 | 1265 | // Show loading state |
| 1003 | - $btn.prop('disabled', true).text('Applying...'); | |
| 1266 | + $btn.prop('disabled', true).text(__('Applying...', 'yatra')); | |
| 1004 | 1267 | $message.hide(); |
| 1005 | 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 | + | |
| 1006 | 1278 | fetch(apiUrl + '/booking/coupon/apply', { |
| 1007 | 1279 | method: 'POST', |
| 1008 | 1280 | headers: { |
| 1009 | 1281 | 'Content-Type': 'application/json', |
| @@ -1009,13 +1281,13 @@ | ||
| 1009 | 1281 | 'Content-Type': 'application/json', |
| 1010 | 1282 | 'X-WP-Nonce': nonce |
| 1011 | 1283 | }, |
| 1012 | 1284 | credentials: 'same-origin', |
| 1013 | - body: JSON.stringify({ code: code }) | |
| 1285 | + body: JSON.stringify(couponPayload) | |
| 1014 | 1286 | }) |
| 1015 | 1287 | .then(response => response.json()) |
| 1016 | 1288 | .then(response => { |
| 1017 | - $btn.prop('disabled', false).text('Apply'); | |
| 1289 | + $btn.prop('disabled', false).text(__('Apply', 'yatra')); | |
| 1018 | 1290 | |
| 1019 | 1291 | if (response.success) { |
| 1020 | 1292 | appliedCoupon = response.data; |
| 1021 | 1293 | |
| @@ -1033,15 +1305,15 @@ | ||
| 1033 | 1305 | updatePriceWithDiscount(response.data); |
| 1034 | 1306 | |
| 1035 | 1307 | showCouponMessage(response.message, 'success'); |
| 1036 | 1308 | } else { |
| 1037 | - showCouponMessage(response.message || 'Invalid coupon code.', 'error'); | |
| 1309 | + showCouponMessage(response.message || __('Invalid coupon code.', 'yatra'), 'error'); | |
| 1038 | 1310 | } |
| 1039 | 1311 | }) |
| 1040 | 1312 | .catch(error => { |
| 1041 | 1313 | console.error('Coupon error:', error); |
| 1042 | - $btn.prop('disabled', false).text('Apply'); | |
| 1043 | - showCouponMessage('An error occurred. Please try again.', 'error'); | |
| 1314 | + $btn.prop('disabled', false).text(__('Apply', 'yatra')); | |
| 1315 | + showCouponMessage(__('An error occurred. Please try again.', 'yatra'), 'error'); | |
| 1044 | 1316 | }); |
| 1045 | 1317 | } |
| 1046 | 1318 | |
| 1047 | 1319 | function removeCoupon() { |
| @@ -1048,8 +1320,14 @@ | ||
| 1048 | 1320 | const $btn = $('#yatra-remove-coupon'); |
| 1049 | 1321 | |
| 1050 | 1322 | $btn.prop('disabled', true); |
| 1051 | 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 | + | |
| 1052 | 1330 | fetch(apiUrl + '/booking/coupon/remove', { |
| 1053 | 1331 | method: 'POST', |
| 1054 | 1332 | headers: { |
| 1055 | 1333 | 'Content-Type': 'application/json', |
| @@ -1055,9 +1333,9 @@ | ||
| 1055 | 1333 | 'Content-Type': 'application/json', |
| 1056 | 1334 | 'X-WP-Nonce': nonce |
| 1057 | 1335 | }, |
| 1058 | 1336 | credentials: 'same-origin', |
| 1059 | - body: JSON.stringify({}) | |
| 1337 | + body: JSON.stringify(removePayload) | |
| 1060 | 1338 | }) |
| 1061 | 1339 | .then(response => response.json()) |
| 1062 | 1340 | .then(response => { |
| 1063 | 1341 | $btn.prop('disabled', false); |
| @@ -1177,8 +1455,21 @@ | ||
| 1177 | 1455 | } else { |
| 1178 | 1456 | sessionUrl = `${restBase}/yatra/v1/booking/session`; |
| 1179 | 1457 | } |
| 1180 | 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 | + | |
| 1181 | 1472 | fetch(sessionUrl, { |
| 1182 | 1473 | method: 'GET', |
| 1183 | 1474 | credentials: 'same-origin', |
| 1184 | 1475 | headers: { |
| @@ -1322,8 +1613,19 @@ | ||
| 1322 | 1613 | } else { |
| 1323 | 1614 | sessionUrl = `${restBase}/yatra/v1/booking/session`; |
| 1324 | 1615 | } |
| 1325 | 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 | + | |
| 1326 | 1628 | fetch(sessionUrl, { |
| 1327 | 1629 | method: 'POST', |
| 1328 | 1630 | credentials: 'same-origin', |
| 1329 | 1631 | headers: { |
| @@ -1329,11 +1631,9 @@ | ||
| 1329 | 1631 | headers: { |
| 1330 | 1632 | 'Content-Type': 'application/json', |
| 1331 | 1633 | 'X-WP-Nonce': nonce |
| 1332 | 1634 | }, |
| 1333 | - body: JSON.stringify({ | |
| 1334 | - additional_services: serviceIds | |
| 1335 | - }) | |
| 1635 | + body: JSON.stringify(sessionPayload) | |
| 1336 | 1636 | }) |
| 1337 | 1637 | .then(response => response.json()) |
| 1338 | 1638 | .then(response => { |
| 1339 | 1639 | if (response.success) { |
| @@ -1443,9 +1743,9 @@ | ||
| 1443 | 1743 | .then(response => response.json()) |
| 1444 | 1744 | .then(response => { |
| 1445 | 1745 | if (response.success) { |
| 1446 | 1746 | $messageEl.removeClass('error').addClass('success'); |
| 1447 | - $messageEl.text(response.message || 'Login successful! Redirecting...'); | |
| 1747 | + $messageEl.text(response.message || __('Login successful! Redirecting...', 'yatra')); | |
| 1448 | 1748 | $messageEl.show(); |
| 1449 | 1749 | |
| 1450 | 1750 | // Reload page to show booking form |
| 1451 | 1751 | setTimeout(function() { |
| @@ -1452,16 +1752,18 @@ | ||
| 1452 | 1752 | window.location.reload(); |
| 1453 | 1753 | }, 1000); |
| 1454 | 1754 | } else { |
| 1455 | 1755 | $messageEl.removeClass('success').addClass('error'); |
| 1456 | - $messageEl.html(response.message || 'Invalid credentials. Please try again.'); | |
| 1756 | + $messageEl.html(response.message || __('Invalid credentials. Please try again.', 'yatra')); | |
| 1457 | 1757 | $messageEl.show(); |
| 1458 | 1758 | |
| 1459 | 1759 | // If email needs verification, show resend option |
| 1460 | 1760 | if (response.needs_verification && response.email) { |
| 1761 | + const didntReceiveText = __("Didn't receive the email?", 'yatra'); | |
| 1762 | + const resendLinkText = __('Resend verification link', 'yatra'); | |
| 1461 | 1763 | const $resendLink = $('<div class="yatra-resend-verification" style="margin-top: 12px; text-align: center;">' + |
| 1462 | - '<span style="color: #6b7280; font-size: 14px;">Didn\'t receive the email? </span>' + | |
| 1463 | - '<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;">Resend verification link</button>' + | |
| 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>' + | |
| 1464 | 1766 | '</div>'); |
| 1465 | 1767 | $messageEl.after($resendLink); |
| 1466 | 1768 | } |
| 1467 | 1769 | |
| @@ -1471,9 +1773,9 @@ | ||
| 1471 | 1773 | } |
| 1472 | 1774 | }) |
| 1473 | 1775 | .catch(function() { |
| 1474 | 1776 | $messageEl.removeClass('success').addClass('error'); |
| 1475 | - $messageEl.text('An error occurred. Please try again.'); | |
| 1777 | + $messageEl.text(__('An error occurred. Please try again.', 'yatra')); | |
| 1476 | 1778 | $messageEl.show(); |
| 1477 | 1779 | |
| 1478 | 1780 | $btn.prop('disabled', false); |
| 1479 | 1781 | $btnText.show(); |
| @@ -1494,9 +1796,9 @@ | ||
| 1494 | 1796 | return; |
| 1495 | 1797 | } |
| 1496 | 1798 | |
| 1497 | 1799 | // Disable button and show loading |
| 1498 | - $btn.prop('disabled', true).text('Sending...'); | |
| 1800 | + $btn.prop('disabled', true).text(__('Sending...', 'yatra')); | |
| 1499 | 1801 | |
| 1500 | 1802 | fetch(apiUrl + '/auth/resend-verification', { |
| 1501 | 1803 | method: 'POST', |
| 1502 | 1804 | headers: { |
| @@ -1516,22 +1818,22 @@ | ||
| 1516 | 1818 | } else if (response.rate_limited && response.remaining_seconds) { |
| 1517 | 1819 | // Start countdown timer |
| 1518 | 1820 | startResendCountdown($btn, response.remaining_seconds); |
| 1519 | 1821 | $messageEl.removeClass('success').addClass('error'); |
| 1520 | - $messageEl.text('Please wait before requesting another email.'); | |
| 1822 | + $messageEl.text(__('Please wait before requesting another email.', 'yatra')); | |
| 1521 | 1823 | $messageEl.show(); |
| 1522 | 1824 | } else { |
| 1523 | 1825 | $messageEl.removeClass('success').addClass('error'); |
| 1524 | 1826 | $messageEl.text(response.message); |
| 1525 | 1827 | $messageEl.show(); |
| 1526 | - $btn.prop('disabled', false).text('Resend verification link'); | |
| 1828 | + $btn.prop('disabled', false).text(__('Resend verification link', 'yatra')); | |
| 1527 | 1829 | } |
| 1528 | 1830 | }) |
| 1529 | 1831 | .catch(function() { |
| 1530 | 1832 | $messageEl.removeClass('success').addClass('error'); |
| 1531 | - $messageEl.text('An error occurred. Please try again.'); | |
| 1833 | + $messageEl.text(__('An error occurred. Please try again.', 'yatra')); | |
| 1532 | 1834 | $messageEl.show(); |
| 1533 | - $btn.prop('disabled', false).text('Resend verification link'); | |
| 1835 | + $btn.prop('disabled', false).text(__('Resend verification link', 'yatra')); | |
| 1534 | 1836 | }); |
| 1535 | 1837 | }); |
| 1536 | 1838 | |
| 1537 | 1839 | // Countdown timer for resend button |
| @@ -1542,9 +1844,9 @@ | ||
| 1542 | 1844 | const updateCountdown = () => { |
| 1543 | 1845 | if (seconds <= 0) { |
| 1544 | 1846 | $btn.data('countdown-active', false); |
| 1545 | 1847 | $btn.prop('disabled', false); |
| 1546 | - $btn.html('Resend verification link'); | |
| 1848 | + $btn.html(__('Resend verification link', 'yatra')); | |
| 1547 | 1849 | return; |
| 1548 | 1850 | } |
| 1549 | 1851 | |
| 1550 | 1852 | const mins = Math.floor(seconds / 60); |
| @@ -1552,9 +1854,16 @@ | ||
| 1552 | 1854 | const timeStr = mins > 0 |
| 1553 | 1855 | ? `${mins}:${secs.toString().padStart(2, '0')}` |
| 1554 | 1856 | : `${secs}s`; |
| 1555 | 1857 | |
| 1556 | - $btn.html(`<span style="color: #9ca3af;">Resend in </span><span style="color: #3b82f6; font-weight: 700;">${timeStr}</span>`); | |
| 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>`); | |
| 1557 | 1866 | |
| 1558 | 1867 | seconds--; |
| 1559 | 1868 | setTimeout(updateCountdown, 1000); |
| 1560 | 1869 | }; |
| @@ -1577,9 +1886,9 @@ | ||
| 1577 | 1886 | const confirmPassword = $('#yatra-reg-confirm-password').val(); |
| 1578 | 1887 | |
| 1579 | 1888 | if (password !== confirmPassword) { |
| 1580 | 1889 | $messageEl.removeClass('success').addClass('error'); |
| 1581 | - $messageEl.text('Passwords do not match.'); | |
| 1890 | + $messageEl.text(__('Passwords do not match.', 'yatra')); | |
| 1582 | 1891 | $messageEl.show(); |
| 1583 | 1892 | return; |
| 1584 | 1893 | } |
| 1585 | 1894 | |
| @@ -1598,8 +1907,24 @@ | ||
| 1598 | 1907 | password: password, |
| 1599 | 1908 | confirm_password: confirmPassword |
| 1600 | 1909 | }; |
| 1601 | 1910 | |
| 1911 | + // reCAPTCHA v3: this form posts straight to /auth/register, so it must | |
| 1912 | + // carry its own token when the operator protects registration — the | |
| 1913 | + // server rejects an empty one outright, whatever the score threshold. | |
| 1914 | + // Resolves to '' when reCAPTCHA is off or unavailable, so the normal | |
| 1915 | + // (unprotected) flow is completely unchanged. | |
| 1916 | + const yatraRcReg = window.yatraRecaptcha; | |
| 1917 | + const regTokenPromise = (yatraRcReg && typeof yatraRcReg.protects === 'function' | |
| 1918 | + && yatraRcReg.protects('registration') && typeof yatraRcReg.execute === 'function') | |
| 1919 | + ? yatraRcReg.execute('registration') | |
| 1920 | + : Promise.resolve(''); | |
| 1921 | + | |
| 1922 | + regTokenPromise.catch(function () { return ''; }).then(function (regToken) { | |
| 1923 | + if (regToken) { | |
| 1924 | + registerData.recaptcha_token = regToken; | |
| 1925 | + } | |
| 1926 | + | |
| 1602 | 1927 | fetch(apiUrl + '/auth/register', { |
| 1603 | 1928 | method: 'POST', |
| 1604 | 1929 | headers: { |
| 1605 | 1930 | 'Content-Type': 'application/json', |
| @@ -1611,9 +1936,9 @@ | ||
| 1611 | 1936 | .then(response => response.json()) |
| 1612 | 1937 | .then(response => { |
| 1613 | 1938 | if (response.success) { |
| 1614 | 1939 | $messageEl.removeClass('error').addClass('success'); |
| 1615 | - $messageEl.text(response.message || 'Account created! Please check your email to verify.'); | |
| 1940 | + $messageEl.text(response.message || __('Account created! Please check your email to verify.', 'yatra')); | |
| 1616 | 1941 | $messageEl.show(); |
| 1617 | 1942 | |
| 1618 | 1943 | // Reset form |
| 1619 | 1944 | $form[0].reset(); |
| @@ -1635,9 +1960,9 @@ | ||
| 1635 | 1960 | |
| 1636 | 1961 | // Show info message on login tab |
| 1637 | 1962 | const $loginMessage = $('#yatra-login-message'); |
| 1638 | 1963 | $loginMessage.removeClass('error').addClass('success'); |
| 1639 | - $loginMessage.text('Please check your email and click the verification link, then login here.'); | |
| 1964 | + $loginMessage.text(__('Please check your email and click the verification link, then login here.', 'yatra')); | |
| 1640 | 1965 | $loginMessage.show(); |
| 1641 | 1966 | }, 3000); |
| 1642 | 1967 | } else { |
| 1643 | 1968 | // Reload page if no verification required (shouldn't happen normally) |
| @@ -1646,9 +1971,9 @@ | ||
| 1646 | 1971 | }, 1000); |
| 1647 | 1972 | } |
| 1648 | 1973 | } else { |
| 1649 | 1974 | $messageEl.removeClass('success').addClass('error'); |
| 1650 | - $messageEl.text(response.message || 'Registration failed. Please try again.'); | |
| 1975 | + $messageEl.text(response.message || __('Registration failed. Please try again.', 'yatra')); | |
| 1651 | 1976 | $messageEl.show(); |
| 1652 | 1977 | |
| 1653 | 1978 | $btn.prop('disabled', false); |
| 1654 | 1979 | $btnText.show(); |
| @@ -1656,9 +1981,9 @@ | ||
| 1656 | 1981 | } |
| 1657 | 1982 | }) |
| 1658 | 1983 | .catch(function() { |
| 1659 | 1984 | $messageEl.removeClass('success').addClass('error'); |
| 1660 | - $messageEl.text('An error occurred. Please try again.'); | |
| 1985 | + $messageEl.text(__('An error occurred. Please try again.', 'yatra')); | |
| 1661 | 1986 | $messageEl.show(); |
| 1662 | 1987 | |
| 1663 | 1988 | $btn.prop('disabled', false); |
| 1664 | 1989 | $btnText.show(); |
| @@ -1663,8 +1988,72 @@ | ||
| 1663 | 1988 | $btn.prop('disabled', false); |
| 1664 | 1989 | $btnText.show(); |
| 1665 | 1990 | $btnLoading.hide(); |
| 1666 | 1991 | }); |
| 1992 | + }); // end reCAPTCHA token wrapper | |
| 1667 | 1993 | }); |
| 1994 | + | |
| 1995 | + // --------------------------------------------------------------------- | |
| 1996 | + // Date field picker upgrade (fast year navigation for Date of Birth) | |
| 1997 | + // --------------------------------------------------------------------- | |
| 1998 | + // A native <input type="date"> popup only steps one month at a time, so | |
| 1999 | + // reaching a birth year like 1990 takes many clicks. Where flatpickr is | |
| 2000 | + // available (and on non-touch devices, since mobile native pickers already | |
| 2001 | + // have good year wheels) we upgrade `.yatra-js-date` inputs to a flatpickr | |
| 2002 | + // whose header year is directly typeable/scrollable. Fully guarded: with no | |
| 2003 | + // flatpickr, or on mobile, the native input is left untouched and working. | |
| 2004 | + var yatraIsMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent || ''); | |
| 2005 | + | |
| 2006 | + function yatraUpgradeDateField(input) { | |
| 2007 | + if (!input || input.dataset.yatraFp === '1') return; | |
| 2008 | + if (typeof window.flatpickr === 'undefined') return; | |
| 2009 | + if (yatraIsMobile) return; // native mobile date wheels already navigate years well | |
| 2010 | + input.dataset.yatraFp = '1'; | |
| 2011 | + var isDob = input.getAttribute('data-yatra-dob') === '1'; | |
| 2012 | + // Turn off the native date UI so flatpickr is the only picker; the | |
| 2013 | + // submitted value stays ISO (Y-m-d) so FormData/back-end are unchanged. | |
| 2014 | + try { input.type = 'text'; } catch (e) {} | |
| 2015 | + window.flatpickr(input, { | |
| 2016 | + dateFormat: 'Y-m-d', | |
| 2017 | + allowInput: true, | |
| 2018 | + maxDate: isDob ? 'today' : null, | |
| 2019 | + defaultDate: input.value || null | |
| 2020 | + }); | |
| 2021 | + } | |
| 2022 | + | |
| 2023 | + function yatraUpgradeDateFields(root) { | |
| 2024 | + var scope = (root && root.querySelectorAll) ? root : document; | |
| 2025 | + var list = scope.querySelectorAll('input.yatra-js-date'); | |
| 2026 | + for (var i = 0; i < list.length; i++) { | |
| 2027 | + yatraUpgradeDateField(list[i]); | |
| 2028 | + } | |
| 2029 | + } | |
| 2030 | + | |
| 2031 | + function yatraInitDatePickers() { | |
| 2032 | + yatraUpgradeDateFields(document); | |
| 2033 | + // The traveler form is injected via AJAX, so watch for inserted inputs. | |
| 2034 | + if (typeof MutationObserver === 'undefined' || !document.body) return; | |
| 2035 | + var obs = new MutationObserver(function (mutations) { | |
| 2036 | + for (var m = 0; m < mutations.length; m++) { | |
| 2037 | + var added = mutations[m].addedNodes; | |
| 2038 | + for (var n = 0; n < added.length; n++) { | |
| 2039 | + var node = added[n]; | |
| 2040 | + if (!node || node.nodeType !== 1) continue; | |
| 2041 | + if (node.matches && node.matches('input.yatra-js-date')) { | |
| 2042 | + yatraUpgradeDateField(node); | |
| 2043 | + } else if (node.querySelectorAll) { | |
| 2044 | + yatraUpgradeDateFields(node); | |
| 2045 | + } | |
| 2046 | + } | |
| 2047 | + } | |
| 2048 | + }); | |
| 2049 | + obs.observe(document.body, { childList: true, subtree: true }); | |
| 2050 | + } | |
| 2051 | + | |
| 2052 | + if (document.readyState === 'loading') { | |
| 2053 | + document.addEventListener('DOMContentLoaded', yatraInitDatePickers); | |
| 2054 | + } else { | |
| 2055 | + yatraInitDatePickers(); | |
| 2056 | + } | |
| 1668 | 2057 | |
| 1669 | 2058 | // End of document ready |
| 1670 | 2059 | })(jQuery); |