PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.16
Yatra – Travel Booking & Tour Operator Software v3.0.16
3.0.16 3.0.15 3.0.14 3.0.14.1 3.0.14.2 3.0.12 3.0.13 3.0.11 3.0.10 3.0.9 3.0.8 3.0.7 3.0.6 3.0.5 3.0.5.1 3.0.4 3.0.3 3.0.2.9 3.0.2.7 3.0.2.8 3.0.2.6 trunk 1.0.0 2.0.0 2.0.1 All 84 releases
← All changes | assets/js/trip.js +810 -285 3.0.2.6 → 3.0.16 View file →
@@ -3,11 +3,126 @@
3 3 * Class-based implementation for trip page features
4 4 */
5 5 /* jshint esversion: 6 */
6 6
7 +// Translation helpers (booking.js installs the same shims if it runs first;
8 +// idempotent setup so either load order works).
9 +// Parse a numeric min/max attribute while preserving a legitimate 0.
10 +// `parseInt(el.getAttribute('max')) || 99` treated max="0" — what a sold-out
11 +// departure renders — as "unset" and fell back to the placeholder ceiling, so
12 +// the guest selector stayed usable and ignored the departure's real capacity.
13 +// Only an absent or non-numeric value may fall back. Installed once and shared
14 +// by every frontend script, so load order does not matter.
15 +if (typeof window.yatraNumOr !== 'function') {
16 + window.yatraNumOr = function (raw, fallback) {
17 + var parsed = parseInt(raw, 10);
18 + return isNaN(parsed) ? fallback : parsed;
19 + };
20 +}
21 +
22 +(function () {
23 + var i18n = (window.wp && window.wp.i18n) ? window.wp.i18n : null;
24 + if (i18n && typeof i18n.__ === 'function') {
25 + window.__ = function (text, domain) { return i18n.__(text, domain || 'yatra'); };
26 + window._n = function (s, p, n, domain) { return i18n._n(s, p, n, domain || 'yatra'); };
27 + window._x = function (text, ctx, domain) { return i18n._x(text, ctx, domain || 'yatra'); };
28 + // Always bind WordPress's own sprintf (it substitutes %d/%s/%1$s).
29 + // Do NOT defer to a pre-existing window.sprintf: a theme or other
30 + // plugin can define an incompatible global that leaves placeholders
31 + // unsubstituted (the cause of literal "%d"/"%s" in the UI).
32 + if (typeof i18n.sprintf === 'function') {
33 + window.sprintf = function () { return i18n.sprintf.apply(i18n, arguments); };
34 + } else if (typeof window.sprintf !== 'function') {
35 + window.sprintf = function (fmt) { return fmt; };
36 + }
37 + } else {
38 + // wp.i18n not present — install passthroughs only if nothing usable
39 + // exists yet (don't clobber a real binding installed earlier).
40 + if (typeof window.__ !== 'function') window.__ = function (text) { return text; };
41 + if (typeof window._n !== 'function') window._n = function (s, p, n) { return n === 1 ? s : p; };
42 + if (typeof window._x !== 'function') window._x = function (text) { return text; };
43 + if (typeof window.sprintf !== 'function') window.sprintf = function (fmt) { return fmt; };
44 + }
45 +})();
46 +
7 47 (function() {
8 48 'use strict';
9 49
50 + /**
51 + * Convert PHP date format (WordPress/Yatra) to Flatpickr format.
52 + * Covers the common tokens exposed in Yatra Settings UI.
53 + *
54 + * @param {string} php
55 + * @returns {string}
56 + */
57 + const phpDateFormatToFlatpickr = (php) => {
58 + if (!php || typeof php !== 'string') return 'F j, Y';
59 + const map = {
60 + // Day
61 + d: 'd',
62 + j: 'j',
63 + D: 'D',
64 + l: 'l',
65 + // Month
66 + m: 'm',
67 + n: 'n',
68 + M: 'M',
69 + F: 'F',
70 + // Year
71 + Y: 'Y',
72 + y: 'y',
73 + // Time (for any datetime pickers)
74 + H: 'H',
75 + G: 'H',
76 + h: 'h',
77 + g: 'h',
78 + i: 'i',
79 + s: 'S',
80 + a: 'K',
81 + A: 'K'
82 + };
83 +
84 + let out = '';
85 + let escaping = false;
86 + for (let i = 0; i < php.length; i++) {
87 + const ch = php[i];
88 + if (escaping) {
89 + out += ch;
90 + escaping = false;
91 + continue;
92 + }
93 + if (ch === '\\') {
94 + escaping = true;
95 + continue;
96 + }
97 + out += map[ch] || ch;
98 + }
99 + return out || 'F j, Y';
100 + };
101 +
102 + const getTripAltDateFormat = () => {
103 + const cfg = window.yatraTripData || {};
104 + const php = cfg.dateFormat || cfg.date_format || 'Y-m-d';
105 + return phpDateFormatToFlatpickr(php);
106 + };
107 +
108 + /**
109 + * Flatpickr locale from PHP ({@see FrontendAssetsProvider::buildFlatpickrLocalePayload}).
110 + * @returns {Record<string, unknown>}
111 + */
112 + const getFlatpickrLocale = () => {
113 + const cfg = window.yatraTripData || {};
114 + const l = cfg.flatpickrLocale;
115 + if (!l || typeof l !== 'object') {
116 + return { firstDayOfWeek: 1 };
117 + }
118 + const out = Object.assign({}, l);
119 + if (typeof out.firstDayOfWeek !== 'number' || Number.isNaN(out.firstDayOfWeek)) {
120 + out.firstDayOfWeek = 1;
121 + }
122 + return out;
123 + };
124 +
10 125 const getRestBase = () => {
11 126 const siteUrl =
12 127 (window.yatraTripData && window.yatraTripData.siteUrl) ||
13 128 (window.yatraAdmin && window.yatraAdmin.siteUrl) ||
@@ -79,9 +194,13 @@
79 194 const symbol = (typeof cfg.currencySymbol === 'string' && cfg.currencySymbol.length)
80 195 ? cfg.currencySymbol
81 196 : (typeof cfg.currency === 'string' ? cfg.currency : '$');
82 197
83 - const position = (cfg.currencyPosition || 'before').toString();
198 + const position = (
199 + cfg.currencyPosition ||
200 + cfg.currency_position ||
201 + 'before'
202 + ).toString().toLowerCase().trim();
84 203 const decimals = Number.isFinite(Number(cfg.decimalPlaces)) ? Number(cfg.decimalPlaces) : 2;
85 204 const thousandSep = (typeof cfg.thousandSeparator === 'string') ? cfg.thousandSeparator : ',';
86 205 const decimalSep = (typeof cfg.decimalSeparator === 'string') ? cfg.decimalSeparator : '.';
87 206
@@ -86,9 +205,14 @@
86 205 const decimalSep = (typeof cfg.decimalSeparator === 'string') ? cfg.decimalSeparator : '.';
87 206
88 207 const num = Number(amount) || 0;
89 208
90 - const formatted = new Intl.NumberFormat(undefined, {
209 + // Format with a FIXED 'en-US' base (grouping ',', decimal '.') so the
210 + // replace-with-configured-separators below is deterministic. With the
211 + // viewer's locale (undefined), a European browser already yields
212 + // '1.234,56' and the naive comma/dot replacement SWAPS the separators —
213 + // making the configured decimal separator appear ignored.
214 + const formatted = new Intl.NumberFormat('en-US', {
91 215 minimumFractionDigits: decimals,
92 216 maximumFractionDigits: decimals,
93 217 }).format(num)
94 218 .replace(/,/g, 'TEMP_THOUSAND')
@@ -95,17 +219,66 @@
95 219 .replace(/\./g, 'TEMP_DECIMAL')
96 220 .replace(/TEMP_THOUSAND/g, thousandSep)
97 221 .replace(/TEMP_DECIMAL/g, decimalSep);
98 222
99 - // Accept common variants: before/left vs after/right
100 - if (position === 'after' || position === 'right') {
101 - return `${formatted} ${symbol}`;
223 + var pos = position;
224 + if (pos === 'before') {
225 + pos = 'left_space';
226 + } else if (pos === 'after') {
227 + pos = 'right_space';
102 228 }
103 - return `${symbol}${formatted}`;
229 + if (pos === 'right') {
230 + return formatted + symbol;
231 + }
232 + if (pos === 'right_space') {
233 + return formatted + ' ' + symbol;
234 + }
235 + if (pos === 'left') {
236 + return symbol + formatted;
237 + }
238 + return symbol + ' ' + formatted;
104 239 };
105 240 }
106 241
107 242 /**
243 + * Resolve the real image URL from an <img> element.
244 + *
245 + * Caching / performance plugins (LiteSpeed Cache, WP Rocket, W3 Total Cache,
246 + * Autoptimize, etc.) replace the `src` attribute with a tiny base64
247 + * placeholder and store the real URL in a data-* attribute. We check our
248 + * own `data-yatra-src` first (added in PHP templates and never touched by
249 + * any third-party plugin), then fall back to the most common lazy-load
250 + * attribute names, and finally fall back to `img.src` while filtering out
251 + * obvious placeholders so we never pass a base64 GIF to the modal.
252 + */
253 + function getImageSrc(img) {
254 + // 1. Our own reliable attribute – always present on Yatra gallery images.
255 + const yatra = img.getAttribute('data-yatra-src');
256 + if (yatra) return yatra;
257 +
258 + // 2. Common lazy-load attributes used by popular caching plugins.
259 + const lazyAttrs = [
260 + 'data-src', // LiteSpeed Cache, WP Rocket, Smush, BJ Lazy Load
261 + 'data-lazyload', // LiteSpeed Cache (older attribute)
262 + 'data-lazy-src', // WordPress core lazy-load
263 + 'data-original', // jQuery.lazyLoad plugin
264 + 'data-pagespeed-lazy-src', // Google PageSpeed / mod_pagespeed
265 + 'data-lazy', // Jetpack
266 + 'data-full-size', // Yatra itinerary images
267 + ];
268 + for (const attr of lazyAttrs) {
269 + const val = img.getAttribute(attr);
270 + if (val && !val.startsWith('data:')) return val;
271 + }
272 +
273 + // 3. Native src – only use if it is not a placeholder.
274 + const src = img.src || '';
275 + if (src && !src.startsWith('data:')) return src;
276 +
277 + return '';
278 + }
279 +
280 + /**
108 281 * Gallery Modal Class
109 282 * Handles image gallery modal functionality
110 283 */
111 284 class GalleryModal {
@@ -148,55 +321,60 @@
148 321
149 322 collectImages() {
150 323 this.images = [];
151 324
152 - // Collect from hero slides
153 - const heroSlides = document.querySelectorAll('.yatra-trip-hero-slide img');
154 - console.log('Hero slides found:', heroSlides.length);
155 - heroSlides.forEach((img, index) => {
156 - this.images.push({
157 - src: img.src,
158 - alt: img.alt || `Gallery Image ${index + 1}`
159 - });
160 - });
325 + // Helper: deduplicate by resolved URL.
326 + const add = (img, fallbackAlt) => {
327 + const src = getImageSrc(img);
328 + if (!src) return;
329 + if (!this.images.some(i => i.src === src)) {
330 + this.images.push({ src, alt: img.alt || fallbackAlt });
331 + }
332 + };
161 333
162 - // Collect from gallery section (hero gallery only, NOT itinerary)
163 - const galleryItems = document.querySelectorAll('.yatra-trip-gallery .yatra-gallery-item img');
164 - console.log('Gallery items found:', galleryItems.length);
165 - galleryItems.forEach((img) => {
166 - const exists = this.images.some(i => i.src === img.src);
167 - if (!exists) {
168 - this.images.push({
169 - src: img.src,
170 - alt: img.alt || 'Gallery Image'
334 + // 1. Prefer the JSON media-data blob (never touched by caching plugins).
335 + const mediaScript = document.getElementById('yatra-hero-media-data');
336 + if (mediaScript) {
337 + try {
338 + const mediaData = JSON.parse(mediaScript.textContent || '{}');
339 + const imageUrls = Array.isArray(mediaData.images) ? mediaData.images : [];
340 + imageUrls.forEach((url, index) => {
341 + if (url && typeof url === 'string' && !this.images.some(i => i.src === url)) {
342 + this.images.push({
343 + src: url,
344 + // Indexed alt text — sprintf isn't guaranteed
345 + // available on every page, so we replace() the
346 + // %d placeholder ourselves. Keeps the msgid a
347 + // literal so makepot extracts cleanly.
348 + /* translators: %d is the 1-based image index in the gallery. */
349 + alt: __('Gallery Image %d', 'yatra').replace('%d', String(index + 1)),
350 + });
351 + }
171 352 });
353 + } catch (e) {
354 + // JSON parse error – fall through to DOM-based collection.
172 355 }
356 + }
357 +
358 + // 2. Collect from hero slides (covers any images not in the JSON blob).
359 + document.querySelectorAll('.yatra-trip-hero-slide img').forEach((img, index) => {
360 + /* translators: %d is the 1-based image index in the gallery. */
361 + add(img, __('Gallery Image %d', 'yatra').replace('%d', String(index + 1)));
173 362 });
174 363
175 - // Collect from hero main image
364 + // 3. Collect from gallery section (hero gallery only, NOT itinerary).
365 + document.querySelectorAll('.yatra-trip-gallery .yatra-gallery-item img').forEach((img) => {
366 + add(img, __('Gallery Image', 'yatra'));
367 + });
368 +
369 + // 4. Collect from hero main image.
176 370 const heroMainImg = document.querySelector('.yatra-hero-main-img');
177 - console.log('Hero main image found:', !!heroMainImg);
178 - if (heroMainImg && !this.images.some(i => i.src === heroMainImg.src)) {
179 - this.images.push({
180 - src: heroMainImg.src,
181 - alt: heroMainImg.alt || 'Main Image'
182 - });
183 - }
371 + if (heroMainImg) add(heroMainImg, __('Main Image', 'yatra'));
184 372
185 - // Collect from side images
186 - const sideImages = document.querySelectorAll('.yatra-side-image-item img');
187 - console.log('Side images found:', sideImages.length);
188 - sideImages.forEach((img) => {
189 - const exists = this.images.some(i => i.src === img.src);
190 - if (!exists) {
191 - this.images.push({
192 - src: img.src,
193 - alt: img.alt || 'Side Image'
194 - });
195 - }
373 + // 5. Collect from side images.
374 + document.querySelectorAll('.yatra-side-image-item img').forEach((img) => {
375 + add(img, __('Side Image', 'yatra'));
196 376 });
197 -
198 - console.log('Total gallery images collected:', this.images.length);
199 377 }
200 378
201 379 createThumbnails() {
202 380 const container = this.modal.querySelector('.yatra-gallery-thumbnails-container');
@@ -348,9 +526,10 @@
348 526 galleryItems.forEach((item, index) => {
349 527 item.addEventListener('click', () => {
350 528 const img = item.querySelector('img');
351 529 if (img) {
352 - const imageIndex = this.images.findIndex(i => i.src === img.src);
530 + const src = getImageSrc(img);
531 + const imageIndex = this.images.findIndex(i => i.src === src);
353 532 this.open(imageIndex >= 0 ? imageIndex : index);
354 533 }
355 534 });
356 535 });
@@ -363,9 +542,10 @@
363 542 return;
364 543 }
365 544 const img = item.querySelector('img');
366 545 if (img) {
367 - const imageIndex = this.images.findIndex(i => i.src === img.src);
546 + const src = getImageSrc(img);
547 + const imageIndex = this.images.findIndex(i => i.src === src);
368 548 this.open(imageIndex >= 0 ? imageIndex : 0);
369 549 }
370 550 });
371 551 });
@@ -387,14 +567,21 @@
387 567 }
388 568
389 569 /**
390 570 * Itinerary Gallery Modal Class
391 - * Separate gallery modal for itinerary section - independent from hero gallery
571 + * Separate gallery modal for itinerary section - independent from hero gallery.
572 + *
573 + * Scoping: a single modal element is reused, but the image set is RECOMPUTED on
574 + * every click from the *closest* `.yatra-entry-gallery` ancestor. Clicking a
575 + * thumbnail in Day 2 must open only Day 2's images (starting at the clicked one) —
576 + * never the global pool of every day's images. The hidden
577 + * `.yatra-itinerary-trip-gallery` div on the page is for SEO / structured data
578 + * only; it intentionally no longer drives the modal.
392 579 */
393 580 class ItineraryGalleryModal {
394 581 constructor() {
395 582 this.modal = null;
396 - this.images = [];
583 + this.images = []; // currently-visible image set (the active scope)
397 584 this.currentIndex = 0;
398 585 this.init();
399 586 }
400 587
@@ -411,56 +598,69 @@
411 598 if (!this.modal) {
412 599 console.warn('Itinerary gallery modal not found');
413 600 return;
414 601 }
415 -
416 - console.log('Itinerary gallery modal found, collecting images...');
417 - this.collectImages();
418 - console.log('Itinerary gallery images collected:', this.images.length);
419 - this.createThumbnails();
420 602 this.attachEventListeners();
421 603 }
422 604
423 - collectImages() {
424 - this.images = [];
605 + /**
606 + * Build the image list from a scope element (an `.yatra-entry-gallery` block).
607 + * Falls back to the document if no scope is given (preserves the legacy
608 + * "global pool" behaviour for the SEO-only thumbnails clicker).
609 + */
610 + collectImagesFrom(scope) {
611 + const root = scope || document;
612 + const images = [];
425 613
426 - // Collect from itinerary gallery section (hidden gallery)
427 - const galleryItems = document.querySelectorAll('.yatra-itinerary-trip-gallery .yatra-gallery-item img');
428 - galleryItems.forEach((img) => {
429 - const fullSizeUrl = img.getAttribute('data-full-size') || img.src;
430 - this.images.push({
431 - src: fullSizeUrl,
432 - alt: img.alt || `Itinerary Image ${this.images.length + 1}`
433 - });
614 + const add = (img, fallbackAlt) => {
615 + const src = img.getAttribute('data-full-size') || getImageSrc(img);
616 + if (!src) return;
617 + if (!images.some(i => i.src === src)) {
618 + images.push({ src, alt: img.alt || fallbackAlt });
619 + }
620 + };
621 +
622 + // Visible gallery thumbnails inside the scope
623 + root.querySelectorAll('.yatra-media-card img').forEach((img) => {
624 + add(img, sprintf(
625 + /* translators: %d: gallery image sequence number. */
626 + __('Itinerary Gallery Image %d', 'yatra'),
627 + images.length + 1
628 + ));
434 629 });
435 630
436 - // Collect from visible itinerary gallery items
437 - const itineraryGalleryItems = document.querySelectorAll('.yatra-entry-gallery .yatra-media-card img');
438 - itineraryGalleryItems.forEach((img) => {
439 - const fullSizeUrl = img.getAttribute('data-full-size') || img.src;
440 - const exists = this.images.some(i => i.src === fullSizeUrl);
441 - if (!exists) {
442 - this.images.push({
443 - src: fullSizeUrl,
444 - alt: img.alt || `Itinerary Gallery Image ${this.images.length + 1}`
445 - });
446 - }
631 + // Video links inside the scope (fallback selector)
632 + root.querySelectorAll('.yatra-itinerary-video-link img').forEach((img) => {
633 + add(img, sprintf(
634 + /* translators: %d: itinerary video sequence number. */
635 + __('Itinerary Video %d', 'yatra'),
636 + images.length + 1
637 + ));
447 638 });
448 639
449 - // Collect from itinerary video items
450 - const itineraryVideoItems = document.querySelectorAll('.yatra-entry-video .yatra-media-card img, .yatra-entry-gallery .yatra-itinerary-video-link img');
451 - itineraryVideoItems.forEach((img) => {
452 - const fullSizeUrl = img.getAttribute('data-full-size') || img.src;
453 - const exists = this.images.some(i => i.src === fullSizeUrl);
454 - if (!exists) {
455 - this.images.push({
456 - src: fullSizeUrl,
457 - alt: img.alt || `Itinerary Video Image ${this.images.length + 1}`
458 - });
459 - }
460 - });
640 + // If still empty AND we were given no scope, fall back to the hidden global pool
641 + // so legacy click handlers on `.yatra-itinerary-trip-gallery .yatra-gallery-item`
642 + // continue to function.
643 + if (images.length === 0 && !scope) {
644 + document.querySelectorAll('.yatra-itinerary-trip-gallery .yatra-gallery-item img').forEach((img) => {
645 + add(img, sprintf(
646 + /* translators: %d: itinerary image sequence number. */
647 + __('Itinerary Image %d', 'yatra'),
648 + images.length + 1
649 + ));
650 + });
651 + }
652 +
653 + return images;
461 654 }
462 655
656 + /** Replace the active image set and rebuild thumbnails. */
657 + setActiveImages(images) {
658 + this.images = images;
659 + this.currentIndex = 0;
660 + this.createThumbnails();
661 + }
662 +
463 663 createThumbnails() {
464 664 const container = this.modal.querySelector('.yatra-gallery-thumbnails-container');
465 665 if (!container) return;
466 666
@@ -581,29 +781,57 @@
581 781 if (e.key === 'ArrowLeft') this.prev();
582 782 if (e.key === 'ArrowRight') this.next();
583 783 });
584 784
585 - // Attach click handlers to itinerary gallery links
586 - const galleryLinks = document.querySelectorAll('.yatra-itinerary-gallery-link');
587 - console.log('Attaching listeners to itinerary gallery links:', galleryLinks.length);
588 - galleryLinks.forEach((link) => {
589 - link.addEventListener('click', (e) => {
590 - e.preventDefault();
591 - const imageIndex = parseInt(link.getAttribute('data-image-index') || '0');
592 - console.log('Itinerary gallery link clicked, opening at index:', imageIndex);
593 - this.open(imageIndex);
594 - });
595 - });
785 + // Itinerary day gallery thumbnails — SCOPED to the clicked day.
786 + // Every click recomputes the image set from the closest .yatra-entry-gallery so
787 + // Day 2's modal only ever shows Day 2's photos, starting at the clicked one.
788 + // Video links are intentionally NOT captured here — they have their own handler
789 + // that opens YatraVideoPlayer (see further down in this file).
790 + const onItineraryClick = (e) => {
791 + const link = e.target.closest('.yatra-itinerary-gallery-link');
792 + if (!link) return;
793 + // Don't hijack video link clicks even if they happen to match
794 + if (link.classList.contains('yatra-itinerary-video-link')) return;
795 + const scope = link.closest('.yatra-entry-gallery');
796 + if (!scope) return;
596 797
597 - // Attach click handlers to itinerary gallery items in hidden section
798 + e.preventDefault();
799 +
800 + const scopedImages = this.collectImagesFrom(scope);
801 + if (scopedImages.length === 0) return;
802 +
803 + // Find the clicked image's index within the scoped set
804 + const clickedImg = link.querySelector('img');
805 + const clickedSrc = clickedImg
806 + ? (clickedImg.getAttribute('data-full-size') || getImageSrc(clickedImg))
807 + : null;
808 + const startIndex = clickedSrc
809 + ? Math.max(0, scopedImages.findIndex((i) => i.src === clickedSrc))
810 + : 0;
811 +
812 + this.setActiveImages(scopedImages);
813 + this.open(startIndex);
814 + };
815 + // Delegated listener — works even if entries are added/removed dynamically
816 + document.addEventListener('click', onItineraryClick);
817 +
818 + // Legacy: clicks on the hidden SEO pool (kept for back-compat). These open
819 + // the full pool because there's no per-day scope on the SEO container.
598 820 const galleryItems = document.querySelectorAll('.yatra-itinerary-trip-gallery .yatra-gallery-item');
599 821 galleryItems.forEach((item, index) => {
600 822 item.addEventListener('click', () => {
823 + const allImages = this.collectImagesFrom(null); // fall back to global
824 + if (allImages.length === 0) return;
601 825 const img = item.querySelector('img');
602 - if (img) {
603 - const imageIndex = this.images.findIndex(i => i.src === img.src);
604 - this.open(imageIndex >= 0 ? imageIndex : index);
605 - }
826 + const src = img
827 + ? (img.getAttribute('data-full-size') || getImageSrc(img))
828 + : null;
829 + const startIndex = src
830 + ? Math.max(0, allImages.findIndex((i) => i.src === src))
831 + : index;
832 + this.setActiveImages(allImages);
833 + this.open(startIndex);
606 834 });
607 835 });
608 836 }
609 837 }
@@ -758,9 +986,9 @@
758 986 }
759 987
760 988 if (images.length > 0) {
761 989 img.src = images[0];
762 - img.alt = 'Trip Image 1';
990 + img.alt = __('Trip Image 1', 'yatra');
763 991 if (link) {
764 992 link.href = '#';
765 993 link.dataset.imageIndex = '0';
766 994 }
@@ -978,8 +1206,12 @@
978 1206 if (existingOverlay) existingOverlay.remove();
979 1207
980 1208 const overlay = document.createElement('div');
981 1209 overlay.className = 'yatra-media-overlay yatra-media-tour-overlay';
1210 + const tourFallbackTitle = __('360° Virtual Tour', 'yatra');
1211 + /* translators: %s: tour type (e.g. "360°"). */
1212 + const tourTypeLabel = sprintf(__('%s Experience', 'yatra'), tour.tour_type || '360°');
1213 + const tourActionLabel = __('Click to explore', 'yatra');
982 1214 overlay.innerHTML = `
983 1215 <div class="yatra-tour-info">
984 1216 <div class="yatra-tour-icon">
985 1217 <svg width="80" height="80" viewBox="0 0 100 100" fill="none">
@@ -992,11 +1224,11 @@
992 1224 <text x="50" y="58" text-anchor="middle" fill="white" font-size="24" font-weight="bold">360°</text>
993 1225 </svg>
994 1226 </div>
995 1227 <div class="yatra-tour-details">
996 - <div class="yatra-tour-title">${tour.title || '360° Virtual Tour'}</div>
997 - <div class="yatra-tour-type">${tour.tour_type || '360°'} Experience</div>
998 - <div class="yatra-tour-action">Click to explore</div>
1228 + <div class="yatra-tour-title">${tour.title || tourFallbackTitle}</div>
1229 + <div class="yatra-tour-type">${tourTypeLabel}</div>
1230 + <div class="yatra-tour-action">${tourActionLabel}</div>
999 1231 </div>
1000 1232 </div>
1001 1233 `;
1002 1234
@@ -1034,8 +1266,9 @@
1034 1266 if (existingOverlay) existingOverlay.remove();
1035 1267
1036 1268 const overlay = document.createElement('div');
1037 1269 overlay.className = 'yatra-media-overlay yatra-media-document-overlay';
1270 + const documentFallbackTitle = __('Document', 'yatra');
1038 1271 overlay.innerHTML = `
1039 1272 <div class="yatra-document-info">
1040 1273 <div class="yatra-document-icon">
1041 1274 <svg width="48" height="48" viewBox="0 0 24 24" fill="none">
@@ -1045,9 +1278,9 @@
1045 1278 <path d="M16 17H8" stroke="#374151" stroke-width="2"/>
1046 1279 </svg>
1047 1280 </div>
1048 1281 <div class="yatra-document-details">
1049 - <div class="yatra-document-title">${doc.title || 'Document'}</div>
1282 + <div class="yatra-document-title">${doc.title || documentFallbackTitle}</div>
1050 1283 <div class="yatra-document-size">${this.formatFileSize(doc.file_size || 0)}</div>
1051 1284 </div>
1052 1285 </div>
1053 1286 `;
@@ -1054,11 +1287,11 @@
1054 1287 container.appendChild(overlay);
1055 1288 }
1056 1289
1057 1290 formatFileSize(bytes) {
1058 - if (bytes === 0) return '0 Bytes';
1291 + if (bytes === 0) return '0 ' + __('Bytes', 'yatra');
1059 1292 const k = 1024;
1060 - const sizes = ['Bytes', 'KB', 'MB', 'GB'];
1293 + const sizes = [__('Bytes', 'yatra'), 'KB', 'MB', 'GB'];
1061 1294 const i = Math.floor(Math.log(bytes) / Math.log(k));
1062 1295 return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
1063 1296 }
1064 1297
@@ -1088,9 +1321,10 @@
1088 1321 sideImages.forEach((item, index) => {
1089 1322 const img = item.querySelector('img');
1090 1323 if (img && images[index + 1]) {
1091 1324 img.src = images[index + 1];
1092 - img.alt = `Trip Image ${index + 2}`;
1325 + /* translators: %d: image sequence number in the trip gallery. */
1326 + img.alt = sprintf(__('Trip Image %d', 'yatra'), index + 2);
1093 1327 }
1094 1328 });
1095 1329 }
1096 1330
@@ -1095,8 +1329,9 @@
1095 1329 }
1096 1330
1097 1331 updateSideVideos(sideImages) {
1098 1332 const videos = this.mediaData.videos || [];
1333 + const fallbackVideoTitle = __('Trip Video', 'yatra');
1099 1334 sideImages.forEach((item, index) => {
1100 1335 const img = item.querySelector('img');
1101 1336 if (img && videos[index + 1]) {
1102 1337 const video = videos[index + 1];
@@ -1102,9 +1337,9 @@
1102 1337 const video = videos[index + 1];
1103 1338 if (video.thumbnail) {
1104 1339 img.src = video.thumbnail;
1105 1340 }
1106 - img.alt = video.title || 'Trip Video';
1341 + img.alt = video.title || fallbackVideoTitle;
1107 1342 }
1108 1343 });
1109 1344 }
1110 1345
@@ -1109,8 +1344,9 @@
1109 1344 }
1110 1345
1111 1346 updateSideYoutubeVideos(sideImages) {
1112 1347 const youtubeVideos = this.mediaData.youtube_videos || [];
1348 + const fallbackYoutubeTitle = __('YouTube Video', 'yatra');
1113 1349 sideImages.forEach((item, index) => {
1114 1350 const img = item.querySelector('img');
1115 1351 if (img && youtubeVideos[index + 1]) {
1116 1352 const video = youtubeVideos[index + 1];
@@ -1116,9 +1352,9 @@
1116 1352 const video = youtubeVideos[index + 1];
1117 1353 if (video.thumbnail) {
1118 1354 img.src = video.thumbnail;
1119 1355 }
1120 - img.alt = video.title || 'YouTube Video';
1356 + img.alt = video.title || fallbackYoutubeTitle;
1121 1357 }
1122 1358 });
1123 1359 }
1124 1360
@@ -1123,8 +1359,9 @@
1123 1359 }
1124 1360
1125 1361 updateSideTours(sideImages) {
1126 1362 const virtualTours = this.mediaData.virtual_tours || [];
1363 + const fallbackTourTitle = __('360° Virtual Tour', 'yatra');
1127 1364 sideImages.forEach((item, index) => {
1128 1365 const img = item.querySelector('img');
1129 1366 if (img && virtualTours[index + 1]) {
1130 1367 const tour = virtualTours[index + 1];
@@ -1130,9 +1367,9 @@
1130 1367 const tour = virtualTours[index + 1];
1131 1368 if (tour.thumbnail) {
1132 1369 img.src = tour.thumbnail;
1133 1370 }
1134 - img.alt = tour.title || '360° Virtual Tour';
1371 + img.alt = tour.title || fallbackTourTitle;
1135 1372 }
1136 1373 });
1137 1374 }
1138 1375
@@ -1137,14 +1374,15 @@
1137 1374 }
1138 1375
1139 1376 updateSideDocuments(sideImages) {
1140 1377 const documents = this.mediaData.documents || [];
1378 + const fallbackDocumentTitle = __('Trip Document', 'yatra');
1141 1379 sideImages.forEach((item, index) => {
1142 1380 const img = item.querySelector('img');
1143 1381 if (img && documents[index + 1]) {
1144 1382 const doc = documents[index + 1];
1145 1383 // Keep existing image for documents
1146 - img.alt = doc.title || 'Trip Document';
1384 + img.alt = doc.title || fallbackDocumentTitle;
1147 1385 }
1148 1386 });
1149 1387 }
1150 1388
@@ -1186,8 +1424,9 @@
1186 1424 class BookingSidebar {
1187 1425 constructor() {
1188 1426 this.checkAvailabilityBtn = null;
1189 1427 this.makeEnquiryBtn = null;
1428 + this.heroBookNowBtn = null;
1190 1429 this.dateInput = null;
1191 1430 this.participantsSelect = null;
1192 1431 this.datepickerInstance = null;
1193 1432 this.init();
@@ -1203,8 +1442,9 @@
1203 1442
1204 1443 setup() {
1205 1444 this.checkAvailabilityBtn = document.getElementById('check-availability-btn');
1206 1445 this.makeEnquiryBtn = document.getElementById('open-enquiry-modal');
1446 + this.heroBookNowBtn = document.getElementById('yatra-hero-book-now-btn');
1207 1447 this.dateInput = document.getElementById('travel_date');
1208 1448 this.participantsSelect = document.querySelector('.yatra-participants-select');
1209 1449
1210 1450 if (!this.checkAvailabilityBtn) {
@@ -1224,8 +1464,29 @@
1224 1464 this.handleCheckAvailability();
1225 1465 });
1226 1466 }
1227 1467
1468 + // Hero "Book Now" (should behave like Check Availability)
1469 + if (this.heroBookNowBtn) {
1470 + this.heroBookNowBtn.addEventListener('click', (e) => {
1471 + e.preventDefault();
1472 + const disabled =
1473 + this.heroBookNowBtn.classList.contains('is-disabled') ||
1474 + this.heroBookNowBtn.getAttribute('aria-disabled') === 'true';
1475 + if (disabled) return;
1476 +
1477 + const bookingSidebar = document.getElementById('booking') || document.querySelector('.yatra-trip-sidebar');
1478 + if (bookingSidebar && bookingSidebar.scrollIntoView) {
1479 + bookingSidebar.scrollIntoView({ behavior: 'smooth', block: 'start' });
1480 + }
1481 +
1482 + // Trigger the same availability flow
1483 + if (this.checkAvailabilityBtn) {
1484 + this.handleCheckAvailability();
1485 + }
1486 + });
1487 + }
1488 +
1228 1489 // Make Enquiry button
1229 1490 if (this.makeEnquiryBtn) {
1230 1491 this.makeEnquiryBtn.addEventListener('click', (e) => {
1231 1492 e.preventDefault();
@@ -1234,10 +1495,29 @@
1234 1495 }
1235 1496 }
1236 1497
1237 1498 initDateField() {
1238 - if (!this.dateInput || typeof flatpickr === 'undefined') return;
1499 + if (!this.dateInput) return;
1239 1500
1501 + // Pro "date-as-dropdown" mode renders a <select> instead of a flatpickr
1502 + // input. Wire it up as a native dropdown — emit `change` on selection so
1503 + // the existing Check Availability listener picks the date up — and skip
1504 + // the rest of the flatpickr setup.
1505 + if (this.dateInput.tagName === 'SELECT') {
1506 + const setEnabled = () => {
1507 + if (this.checkAvailabilityBtn) {
1508 + const hasValue = !!this.dateInput.value;
1509 + this.checkAvailabilityBtn.disabled = !hasValue;
1510 + this.checkAvailabilityBtn.classList.toggle('disabled', !hasValue);
1511 + }
1512 + };
1513 + setEnabled();
1514 + this.dateInput.addEventListener('change', setEnabled);
1515 + return;
1516 + }
1517 +
1518 + if (typeof flatpickr === 'undefined') return;
1519 +
1240 1520 const today = new Date();
1241 1521 today.setHours(0, 0, 0, 0);
1242 1522
1243 1523 // Format today's date as Y-m-d
@@ -1246,9 +1526,9 @@
1246 1526 String(today.getDate()).padStart(2, '0');
1247 1527
1248 1528 // Set today's date as default value
1249 1529 this.dateInput.value = todayStr;
1250 -
1530 +
1251 1531 // Enable button since we have a default date
1252 1532 if (this.checkAvailabilityBtn) {
1253 1533 this.checkAvailabilityBtn.disabled = false;
1254 1534 this.checkAvailabilityBtn.classList.remove('disabled');
@@ -1258,10 +1538,15 @@
1258 1538 if (this.dateInput._flatpickr) {
1259 1539 this.dateInput._flatpickr.destroy();
1260 1540 }
1261 1541
1542 + const altFormat = getTripAltDateFormat();
1543 +
1262 1544 this.datepickerInstance = flatpickr(this.dateInput, {
1545 + // Keep backend value stable (Y-m-d), only change display
1263 1546 dateFormat: 'Y-m-d',
1547 + altInput: true,
1548 + altFormat,
1264 1549 minDate: today,
1265 1550 defaultDate: today,
1266 1551 enableTime: false,
1267 1552 clickOpens: true,
@@ -1268,11 +1553,9 @@
1268 1553 allowInput: false,
1269 1554 static: false,
1270 1555 monthSelectorType: 'static',
1271 1556 animate: true,
1272 - locale: {
1273 - firstDayOfWeek: 1
1274 - },
1557 + locale: getFlatpickrLocale(),
1275 1558 onChange: (selectedDates, dateStr) => {
1276 1559 this.dateInput.value = dateStr;
1277 1560
1278 1561 // Enable Check Availability button when date is selected
@@ -1286,18 +1569,30 @@
1286 1569 // Make container clickable
1287 1570 const container = this.dateInput.closest('.yatra-booking-field-select');
1288 1571 if (container) {
1289 1572 container.style.cursor = 'pointer';
1290 - container.onclick = (e) => {
1291 - if (e.target === this.dateInput) return;
1292 - e.preventDefault();
1293 - e.stopPropagation();
1294 - if (this.datepickerInstance.isOpen) {
1295 - this.datepickerInstance.close();
1296 - } else {
1573 + const openPicker = (e) => {
1574 + // Do not interfere with other actionable controls in the same row
1575 + if (e && e.target && e.target.closest && e.target.closest('button, a, select, textarea')) {
1576 + return;
1577 + }
1578 + // Prevent container clicks from triggering other toggles
1579 + if (e) {
1580 + e.preventDefault();
1581 + e.stopPropagation();
1582 + }
1583 + if (this.datepickerInstance && !this.datepickerInstance.isOpen) {
1297 1584 this.datepickerInstance.open();
1298 1585 }
1299 1586 };
1587 +
1588 + // Container click (icon/arrow/background)
1589 + container.addEventListener('click', openPicker);
1590 +
1591 + // Flatpickr altInput is the visible field when altInput:true
1592 + if (this.dateInput && this.dateInput._flatpickr && this.dateInput._flatpickr.altInput) {
1593 + this.dateInput._flatpickr.altInput.addEventListener('click', openPicker);
1594 + }
1300 1595 }
1301 1596 }
1302 1597
1303 1598 initInlineQuantityControls() {
@@ -1314,10 +1609,10 @@
1314 1609 if (!minusBtn || !plusBtn) return;
1315 1610
1316 1611 const updateButtonStates = () => {
1317 1612 const current = parseInt(input.value) || 1;
1318 - const min = parseInt(input.getAttribute('min')) || 1;
1319 - const max = parseInt(input.getAttribute('max')) || 20;
1613 + const min = window.yatraNumOr(input.getAttribute('min'), 1);
1614 + const max = window.yatraNumOr(input.getAttribute('max'), 20);
1320 1615
1321 1616 minusBtn.disabled = current <= min;
1322 1617 plusBtn.disabled = current >= max;
1323 1618 };
@@ -1327,9 +1622,9 @@
1327 1622 e.preventDefault();
1328 1623 e.stopPropagation();
1329 1624
1330 1625 const current = parseInt(input.value) || 1;
1331 - const min = parseInt(input.getAttribute('min')) || 1;
1626 + const min = window.yatraNumOr(input.getAttribute('min'), 1);
1332 1627
1333 1628 if (current > min) {
1334 1629 input.value = current - 1;
1335 1630 updateButtonStates();
@@ -1342,9 +1637,9 @@
1342 1637 e.preventDefault();
1343 1638 e.stopPropagation();
1344 1639
1345 1640 const current = parseInt(input.value) || 1;
1346 - const max = parseInt(input.getAttribute('max')) || 20;
1641 + const max = window.yatraNumOr(input.getAttribute('max'), 20);
1347 1642
1348 1643 if (current < max) {
1349 1644 input.value = current + 1;
1350 1645 updateButtonStates();
@@ -1396,9 +1691,9 @@
1396 1691
1397 1692 if (!display || !selector) return;
1398 1693
1399 1694 // Get all traveler category inputs dynamically
1400 - const travelerInputs = selector.querySelectorAll('input[id^="traveler_"]');
1695 + const travelerInputs = selector.querySelectorAll('input[id^="traveler_"], input[id^="mobile_traveler_"]');
1401 1696
1402 1697 if (travelerInputs.length === 0) return;
1403 1698
1404 1699 // Make display clickable
@@ -1418,21 +1713,25 @@
1418 1713 const parts = [];
1419 1714 travelerInputs.forEach((input) => {
1420 1715 const value = parseInt(input.value || 0);
1421 1716 if (value > 0) {
1422 - const categoryLabel = input.getAttribute('data-category-label') || 'Traveler';
1717 + const categoryLabel = input.getAttribute('data-category-label') || __('Traveler', 'yatra');
1423 1718 parts.push(`${categoryLabel} x ${value}`);
1424 1719 }
1425 1720 });
1426 1721
1427 1722 if (parts.length === 0) {
1428 - // Default to first category with value 1
1723 + // Default to first category with value 1. `data-category-label`
1724 + // is rendered server-side via the PHP textdomain, so it's
1725 + // already translated. The "x 1" suffix is the only piece
1726 + // we need to keep translatable here.
1429 1727 const firstInput = travelerInputs[0];
1430 1728 if (firstInput) {
1431 - const categoryLabel = firstInput.getAttribute('data-category-label') || 'Traveler';
1432 - display.textContent = `${categoryLabel} x 1`;
1729 + const categoryLabel = firstInput.getAttribute('data-category-label') || __('Traveler', 'yatra');
1730 + /* translators: %s is the traveler-category label (e.g. "Adult", "Child"). Renders as "Adult x 1". */
1731 + display.textContent = __('%s x 1', 'yatra').replace('%s', categoryLabel);
1433 1732 } else {
1434 - display.textContent = '1 Traveler';
1733 + display.textContent = __('1 Traveler', 'yatra');
1435 1734 }
1436 1735 } else {
1437 1736 display.textContent = parts.join(', ');
1438 1737 }
@@ -1446,9 +1745,19 @@
1446 1745 e.preventDefault();
1447 1746 e.stopPropagation();
1448 1747
1449 1748 const targetId = btn.getAttribute('data-target');
1450 - const input = document.getElementById(targetId);
1749 + let input = null;
1750 + if (targetId) {
1751 + try {
1752 + input = selector.querySelector('#' + CSS.escape(targetId));
1753 + } catch (_) {
1754 + // ignore
1755 + }
1756 + if (!input) {
1757 + input = document.getElementById(targetId);
1758 + }
1759 + }
1451 1760 if (!input) return;
1452 1761
1453 1762 const current = parseInt(input.value || 0);
1454 1763 const min = parseInt(input.getAttribute('min') || 0);
@@ -1486,9 +1795,9 @@
1486 1795 const availabilitySection = document.getElementById('availability');
1487 1796 if (!availabilitySection) return;
1488 1797
1489 1798 inputs.forEach((input) => {
1490 - const categoryId = input.id.replace('traveler_', '');
1799 + const categoryId = input.id.replace(/^mobile_/, '').replace('traveler_', '');
1491 1800 const value = parseInt(input.value) || 0;
1492 1801
1493 1802 // Find all matching inputs in availability cards
1494 1803 const availInputs = availabilitySection.querySelectorAll(`.yatra-availability-category[data-category="${categoryId}"]`);
@@ -1516,9 +1825,9 @@
1516 1825 categoryInputs.forEach((catInput) => {
1517 1826 const catValue = parseInt(catInput.value) || 0;
1518 1827 if (catValue > 0) {
1519 1828 const catRow = catInput.closest('.yatra-quantity-row');
1520 - const categoryLabel = catRow ? catRow.querySelector('.yatra-quantity-title')?.textContent : 'Traveler';
1829 + const categoryLabel = catRow ? catRow.querySelector('.yatra-quantity-title')?.textContent : __('Traveler', 'yatra');
1521 1830 parts.push(`${categoryLabel} x ${catValue}`);
1522 1831 }
1523 1832 });
1524 1833 if (parts.length > 0) {
@@ -1527,8 +1836,11 @@
1527 1836 }
1528 1837 }
1529 1838 });
1530 1839 });
1840 + if (window.availabilitySection && typeof window.availabilitySection.updateAllCardTotals === 'function') {
1841 + window.availabilitySection.updateAllCardTotals();
1842 + }
1531 1843 };
1532 1844
1533 1845 // Close dropdown when clicking outside
1534 1846 const closeHandler = (e) => {
@@ -1590,14 +1902,14 @@
1590 1902
1591 1903
1592 1904 // Basic validation
1593 1905 if (!date) {
1594 - alert('Please select a travel date');
1906 + alert(__('Please select a travel date', 'yatra'));
1595 1907 return;
1596 1908 }
1597 1909
1598 1910 if (totalTravelers === 0) {
1599 - alert('Please select number of travelers');
1911 + alert(__('Please select number of travelers', 'yatra'));
1600 1912 return;
1601 1913 }
1602 1914
1603 1915 // Show loading state
@@ -1604,9 +1916,17 @@
1604 1916 if (this.checkAvailabilityBtn && !this.checkAvailabilityBtn.dataset.originalHtml) {
1605 1917 this.checkAvailabilityBtn.dataset.originalHtml = this.checkAvailabilityBtn.innerHTML;
1606 1918 }
1607 1919 if (this.checkAvailabilityBtn) {
1608 - this.checkAvailabilityBtn.innerHTML = '<svg class="animate-spin" width="18" height="18" fill="none" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" opacity="0.25"></circle><path fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path></svg> Checking...';
1920 + // Spinner SVG is fixed markup (not translatable). Only the
1921 + // user-visible "Checking..." label runs through __() so it
1922 + // ends up in the .pot with a clean msgid and translates with
1923 + // the rest of the UI.
1924 + this.checkAvailabilityBtn.innerHTML =
1925 + '<svg class="animate-spin" width="18" height="18" fill="none" viewBox="0 0 24 24">' +
1926 + '<circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" opacity="0.25"></circle>' +
1927 + '<path fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>' +
1928 + '</svg> ' + __('Checking...', 'yatra');
1609 1929 this.checkAvailabilityBtn.disabled = true;
1610 1930 }
1611 1931
1612 1932 // Get trip ID from data attribute or global variable
@@ -1646,11 +1966,12 @@
1646 1966 if (!response.ok) {
1647 1967 // Try to get error message from response
1648 1968 return response.json().then(err => {
1649 1969 console.error('API Error:', err);
1650 - throw new Error(err.message || 'Failed to fetch availability');
1970 + throw new Error(err.message || __('Failed to fetch availability', 'yatra'));
1651 1971 }).catch(() => {
1652 - throw new Error('Failed to fetch availability (Status: ' + response.status + ')');
1972 + // translators: %d: HTTP status code
1973 + throw new Error(sprintf(__('Failed to fetch availability (Status: %d)', 'yatra'), response.status));
1653 1974 });
1654 1975 }
1655 1976 return response.json();
1656 1977 })
@@ -1714,9 +2035,9 @@
1714 2035 this.resetButton();
1715 2036 })
1716 2037 .catch(error => {
1717 2038 console.error('Error loading availability:', error);
1718 - alert('Failed to load availability. Please try again.');
2039 + alert(__('Failed to load availability. Please try again.', 'yatra'));
1719 2040 this.resetButton();
1720 2041 });
1721 2042 }
1722 2043
@@ -1765,13 +2086,14 @@
1765 2086 // Create new menu item
1766 2087 const newItem = document.createElement('a');
1767 2088 newItem.href = '#availability';
1768 2089 newItem.className = 'yatra-sticky-nav-item';
2090 + const availabilityLabel = __('Availability', 'yatra');
1769 2091 newItem.innerHTML = `
1770 2092 <svg class="yatra-icon-sm" fill="none" stroke="currentColor" viewBox="0 0 24 24">
1771 2093 <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/>
1772 2094 </svg>
1773 - <span>Availability</span>
2095 + <span>${availabilityLabel}</span>
1774 2096 `;
1775 2097
1776 2098 // Insert before the target item (or after "What's Included" if no target)
1777 2099 if (insertBeforeItem && insertBeforeItem.parentNode) {
@@ -1838,9 +2160,9 @@
1838 2160 if (this.checkAvailabilityBtn && !this.checkAvailabilityBtn.dataset.originalHtml) {
1839 2161 this.checkAvailabilityBtn.dataset.originalHtml = this.checkAvailabilityBtn.innerHTML;
1840 2162 }
1841 2163 if (this.checkAvailabilityBtn) {
1842 - this.checkAvailabilityBtn.innerHTML = '<svg class="animate-spin" width="18" height="18" fill="none" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" opacity="0.25"></circle><path fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path></svg> Loading...';
2164 + this.checkAvailabilityBtn.innerHTML = '<svg class="animate-spin" width="18" height="18" fill="none" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" opacity="0.25"></circle><path fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path></svg> ' + __('Loading...', 'yatra');
1843 2165 this.checkAvailabilityBtn.disabled = true;
1844 2166 }
1845 2167
1846 2168 const availSection = document.getElementById('availability');
@@ -1864,9 +2186,9 @@
1864 2186 credentials: 'omit',
1865 2187 })
1866 2188 .then(response => {
1867 2189 if (!response.ok) {
1868 - throw new Error('Failed to fetch availability');
2190 + throw new Error(__('Failed to fetch availability', 'yatra'));
1869 2191 }
1870 2192 return response.json();
1871 2193 })
1872 2194 .then(data => {
@@ -1901,9 +2223,9 @@
1901 2223 this.resetButton();
1902 2224 })
1903 2225 .catch(error => {
1904 2226 console.error('Error reloading availability:', error);
1905 - alert('Failed to reload availability. Please try again.');
2227 + alert(__('Failed to reload availability. Please try again.', 'yatra'));
1906 2228 this.resetButton();
1907 2229 });
1908 2230 }
1909 2231
@@ -2001,9 +2323,13 @@
2001 2323 if (data && data.success) {
2002 2324 // Update departures available text
2003 2325 const departuresText = document.querySelector('.yatra-departures-available-text');
2004 2326 if (departuresText && data.departures_count !== undefined) {
2005 - departuresText.textContent = data.departures_count + ' departure' + (data.departures_count !== 1 ? 's' : '') + ' available';
2327 + departuresText.textContent = sprintf(
2328 + /* translators: %d: number of departures available. */
2329 + _n('%d departure available', '%d departures available', data.departures_count, 'yatra'),
2330 + data.departures_count
2331 + );
2006 2332 }
2007 2333
2008 2334 // Update traveler dropdown HTML
2009 2335 const quantitySelector = document.getElementById('quantity-selector');
@@ -2057,15 +2383,15 @@
2057 2383 const row = travelerSelector.querySelector('.yatra-quantity-row[data-category-id="' + categoryId + '"]');
2058 2384 if (row) {
2059 2385 const cardInput = row.querySelector('input[type="number"]');
2060 2386 if (cardInput) {
2061 - const max = parseInt(cardInput.getAttribute('max')) || 99;
2387 + const max = window.yatraNumOr(cardInput.getAttribute('max'), 99);
2062 2388 cardInput.value = Math.min(value, max);
2063 2389
2064 2390 // Update button states
2065 2391 const minusBtn = row.querySelector('.yatra-quantity-minus');
2066 2392 const plusBtn = row.querySelector('.yatra-quantity-plus');
2067 - const min = parseInt(cardInput.getAttribute('min')) || 0;
2393 + const min = window.yatraNumOr(cardInput.getAttribute('min'), 0);
2068 2394 if (minusBtn) minusBtn.disabled = cardInput.value <= min;
2069 2395 if (plusBtn) plusBtn.disabled = cardInput.value >= max;
2070 2396 }
2071 2397 }
@@ -2076,9 +2402,9 @@
2076 2402
2077 2403 } else if (simpleTravelers && numTravelersInput) {
2078 2404 // Simple pricing - sync total count
2079 2405 const totalCount = parseInt(numTravelersInput.value) || 1;
2080 - const max = parseInt(simpleTravelers.getAttribute('max')) || 99;
2406 + const max = window.yatraNumOr(simpleTravelers.getAttribute('max'), 99);
2081 2407 simpleTravelers.value = Math.min(totalCount, max);
2082 2408
2083 2409 // Update button states
2084 2410 const controls = simpleTravelers.closest('.yatra-quantity-controls-inline');
@@ -2084,9 +2410,9 @@
2084 2410 const controls = simpleTravelers.closest('.yatra-quantity-controls-inline');
2085 2411 if (controls) {
2086 2412 const minusBtn = controls.querySelector('.yatra-quantity-minus');
2087 2413 const plusBtn = controls.querySelector('.yatra-quantity-plus');
2088 - const min = parseInt(simpleTravelers.getAttribute('min')) || 1;
2414 + const min = window.yatraNumOr(simpleTravelers.getAttribute('min'), 1);
2089 2415 if (minusBtn) minusBtn.disabled = simpleTravelers.value <= min;
2090 2416 if (plusBtn) plusBtn.disabled = simpleTravelers.value >= max;
2091 2417 }
2092 2418 }
@@ -2119,9 +2445,9 @@
2119 2445 }
2120 2446 }
2121 2447 });
2122 2448
2123 - display.textContent = parts.length > 0 ? parts.join(', ') : '0 Travelers';
2449 + display.textContent = parts.length > 0 ? parts.join(', ') : __('0 Travelers', 'yatra');
2124 2450 }
2125 2451
2126 2452 /**
2127 2453 * Update availability card total price
@@ -2126,8 +2452,13 @@
2126 2452 /**
2127 2453 * Update availability card total price
2128 2454 */
2129 2455 updateAvailabilityCardTotals(itemId) {
2456 + if (window.availabilitySection && typeof window.availabilitySection.updateTotalPrice === 'function') {
2457 + window.availabilitySection.updateTotalPrice(itemId);
2458 + return;
2459 + }
2460 +
2130 2461 const totalAmountEl = document.querySelector('.yatra-card-total-amount[data-item="' + itemId + '"]');
2131 2462 const totalNoteEl = document.querySelector('.yatra-card-total-note[data-item="' + itemId + '"]');
2132 2463 if (!totalAmountEl) return;
2133 2464
@@ -2144,13 +2475,17 @@
2144 2475 const input = row.querySelector('input[type="number"]');
2145 2476 const price = parseFloat(row.getAttribute('data-price')) || 0;
2146 2477 const count = parseInt(input?.value) || 0;
2147 2478 const pricingMode = row.getAttribute('data-pricing-mode') || (input ? input.getAttribute('data-pricing-mode') : '') || 'per_person';
2148 -
2479 + const groupOverflow = row.getAttribute('data-group-overflow') || (input ? input.getAttribute('data-group-overflow') : '') || 'block';
2480 + const maxPax = parseInt(row.getAttribute('data-max-pax') || (input ? input.getAttribute('data-max-pax') : '')) || 0;
2481 +
2149 2482 if (pricingMode === 'per_group') {
2150 - // Per group: charge flat price once if any travelers
2483 + // Per group: one flat price, or per block of maxPax when "per_block".
2151 2484 if (count > 0) {
2152 - total += price;
2485 + total += (groupOverflow === 'per_block' && maxPax > 0)
2486 + ? price * Math.ceil(count / maxPax)
2487 + : price;
2153 2488 }
2154 2489 } else {
2155 2490 // Per person: charge per traveler
2156 2491 total += price * count;
@@ -2179,9 +2514,10 @@
2179 2514 totalAmountEl.textContent = String(total.toFixed(2));
2180 2515 }
2181 2516
2182 2517 if (totalNoteEl) {
2183 - totalNoteEl.textContent = 'for ' + totalTravelers + ' traveler' + (totalTravelers !== 1 ? 's' : '');
2518 + /* translators: %d is the number of travelers. Singular form is "for 1 traveler", plural "for N travelers". */
2519 + totalNoteEl.textContent = sprintf(_n('for %d traveler', 'for %d travelers', totalTravelers, 'yatra'), totalTravelers);
2184 2520 }
2185 2521
2186 2522 // Update the card participants display
2187 2523 this.updateAvailabilityCardDisplay(itemId);
@@ -2317,8 +2653,10 @@
2317 2653 today.setHours(0, 0, 0, 0);
2318 2654
2319 2655 this.datepickerInstance = flatpickr(this.dateInput, {
2320 2656 dateFormat: 'Y-m-d',
2657 + altInput: true,
2658 + altFormat: getTripAltDateFormat(),
2321 2659 minDate: today,
2322 2660 enableTime: false,
2323 2661 clickOpens: true,
2324 2662 allowInput: false,
@@ -2324,11 +2662,9 @@
2324 2662 allowInput: false,
2325 2663 static: false,
2326 2664 monthSelectorType: 'static',
2327 2665 animate: true,
2328 - locale: {
2329 - firstDayOfWeek: 1
2330 - },
2666 + locale: getFlatpickrLocale(),
2331 2667 onChange: (selectedDates, dateStr) => {
2332 2668 this.dateInput.value = dateStr;
2333 2669 }
2334 2670 });
@@ -2336,18 +2672,25 @@
2336 2672 // Make container clickable
2337 2673 const container = this.dateInput.closest('.yatra-booking-field-select');
2338 2674 if (container) {
2339 2675 container.style.cursor = 'pointer';
2340 - container.onclick = (e) => {
2341 - if (e.target === this.dateInput) return;
2342 - e.preventDefault();
2343 - e.stopPropagation();
2344 - if (this.datepickerInstance.isOpen) {
2345 - this.datepickerInstance.close();
2346 - } else {
2676 + const openPicker = (e) => {
2677 + if (e && e.target && e.target.closest && e.target.closest('button, a, select, textarea')) {
2678 + return;
2679 + }
2680 + if (e) {
2681 + e.preventDefault();
2682 + e.stopPropagation();
2683 + }
2684 + if (this.datepickerInstance && !this.datepickerInstance.isOpen) {
2347 2685 this.datepickerInstance.open();
2348 2686 }
2349 2687 };
2688 +
2689 + container.addEventListener('click', openPicker);
2690 + if (this.dateInput && this.dateInput._flatpickr && this.dateInput._flatpickr.altInput) {
2691 + this.dateInput._flatpickr.altInput.addEventListener('click', openPicker);
2692 + }
2350 2693 }
2351 2694 }
2352 2695
2353 2696 initTravelersField() {
@@ -2375,17 +2718,22 @@
2375 2718 // Update display function
2376 2719 const updateDisplay = () => {
2377 2720 const adults = parseInt(adultsInput.value || 1);
2378 2721 const children = parseInt(childrenInput.value || 0);
2722 + // translators: %d: number of adults
2723 + const adultPart = sprintf(_n('Adult x %d', 'Adult x %d', adults, 'yatra'), adults);
2724 + // translators: %d: number of children
2725 + const childPart = sprintf(_n('Child x %d', 'Child x %d', children, 'yatra'), children);
2379 2726 let text = '';
2380 2727 if (adults > 0 && children > 0) {
2381 - text = `Adult x ${adults}, Child x ${children}`;
2728 + // translators: %1$s: adult count phrase, %2$s: child count phrase
2729 + text = sprintf(__('%1$s, %2$s', 'yatra'), adultPart, childPart);
2382 2730 } else if (adults > 0) {
2383 - text = `Adult x ${adults}`;
2731 + text = adultPart;
2384 2732 } else if (children > 0) {
2385 - text = `Child x ${children}`;
2733 + text = childPart;
2386 2734 } else {
2387 - text = 'Adult x 1';
2735 + text = sprintf(_n('Adult x %d', 'Adult x %d', 1, 'yatra'), 1);
2388 2736 }
2389 2737 display.textContent = text;
2390 2738 };
2391 2739
@@ -2471,9 +2819,9 @@
2471 2819 const email = formData.get('email');
2472 2820 const message = formData.get('message');
2473 2821
2474 2822 if (!name || !email || !message) {
2475 - this.showMessage('error', 'Please fill in all required fields.');
2823 + this.showMessage('error', __('Please fill in all required fields.', 'yatra'));
2476 2824 return;
2477 2825 }
2478 2826
2479 2827 // Basic email validation
@@ -2478,22 +2826,22 @@
2478 2826
2479 2827 // Basic email validation
2480 2828 const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
2481 2829 if (!emailRegex.test(email)) {
2482 - this.showMessage('error', 'Please enter a valid email address.');
2830 + this.showMessage('error', __('Please enter a valid email address.', 'yatra'));
2483 2831 return;
2484 2832 }
2485 2833
2486 2834 if (message.length < 10) {
2487 - this.showMessage('error', 'Please enter a message (at least 10 characters).');
2835 + this.showMessage('error', __('Please enter a message (at least 10 characters).', 'yatra'));
2488 2836 return;
2489 2837 }
2490 2838
2491 2839 // Show loading
2492 2840 const submitBtn = this.form.querySelector('.yatra-enquiry-submit');
2493 - const originalText = submitBtn ? submitBtn.textContent : 'Send Enquiry';
2841 + const originalText = submitBtn ? submitBtn.textContent : __('Send Enquiry', 'yatra');
2494 2842 if (submitBtn) {
2495 - submitBtn.textContent = 'Sending...';
2843 + submitBtn.textContent = __('Sending...', 'yatra');
2496 2844 submitBtn.disabled = true;
2497 2845 }
2498 2846
2499 2847 // Hide any existing messages
@@ -2502,8 +2850,15 @@
2502 2850 }
2503 2851
2504 2852 // Submit via REST API (public endpoint)
2505 2853 const payload = Object.fromEntries(formData.entries());
2854 + // Ensure trip context is sent even if the hidden input is missing/empty.
2855 + if ((!payload.trip_id || String(payload.trip_id) === '0') && window.yatraTripData?.tripId) {
2856 + payload.trip_id = String(window.yatraTripData.tripId);
2857 + }
2858 + if (!payload.trip_slug && window.yatraTripData?.tripSlug) {
2859 + payload.trip_slug = String(window.yatraTripData.tripSlug);
2860 + }
2506 2861
2507 2862 // Build REST URL
2508 2863 const baseUrlRaw = (window.yatraTripData?.apiUrl || window.yatraTripData?.restUrl || window.wpApiSettings?.root || '/wp-json').replace(/\/$/, '');
2509 2864 const enquiriesUrl = baseUrlRaw.includes('/yatra/v1')
@@ -2509,26 +2864,38 @@
2509 2864 const enquiriesUrl = baseUrlRaw.includes('/yatra/v1')
2510 2865 ? `${baseUrlRaw}/enquiries`
2511 2866 : `${baseUrlRaw}/yatra/v1/enquiries`;
2512 2867
2513 - fetch(enquiriesUrl, {
2514 - method: 'POST',
2515 - headers: {
2516 - 'Content-Type': 'application/json',
2517 - },
2518 - credentials: 'omit', // public endpoint
2519 - body: JSON.stringify(payload),
2520 - })
2868 + // reCAPTCHA v3: attach a fresh token when the enquiry form is protected.
2869 + const yatraRc = window.yatraRecaptcha;
2870 + const enquiryTokenPromise = (yatraRc && yatraRc.protects && yatraRc.protects('enquiry'))
2871 + ? yatraRc.execute('enquiry')
2872 + : Promise.resolve('');
2873 +
2874 + enquiryTokenPromise
2875 + .then(recaptchaToken => {
2876 + if (recaptchaToken) {
2877 + payload.recaptcha_token = recaptchaToken;
2878 + }
2879 + return fetch(enquiriesUrl, {
2880 + method: 'POST',
2881 + headers: {
2882 + 'Content-Type': 'application/json',
2883 + },
2884 + credentials: 'omit', // public endpoint
2885 + body: JSON.stringify(payload),
2886 + });
2887 + })
2521 2888 .then(response => response.json())
2522 2889 .then(result => {
2523 2890 if (result.success) {
2524 - this.showMessage('success', result.message || result.data?.message || 'Thank you for your enquiry! We will get back to you soon.');
2891 + this.showMessage('success', result.message || result.data?.message || __('Thank you for your enquiry! We will get back to you soon.', 'yatra'));
2525 2892 this.form.reset();
2526 2893
2527 2894 // Reset the travelers display
2528 2895 const display = document.getElementById('enquiry-participants-display');
2529 2896 if (display) {
2530 - display.textContent = 'Adult x 1';
2897 + display.textContent = __('Adult x 1', 'yatra');
2531 2898 }
2532 2899 const adultsInput = document.getElementById('enquiry-adults');
2533 2900 const childrenInput = document.getElementById('enquiry-children');
2534 2901 if (adultsInput) adultsInput.value = 1;
@@ -2541,14 +2908,14 @@
2541 2908 messageBox.style.display = 'none';
2542 2909 }
2543 2910 }, 3000);
2544 2911 } else {
2545 - this.showMessage('error', result.message || result.data?.message || 'Failed to submit enquiry. Please try again.');
2912 + this.showMessage('error', result.message || result.data?.message || __('Failed to submit enquiry. Please try again.', 'yatra'));
2546 2913 }
2547 2914 })
2548 2915 .catch(error => {
2549 2916 console.error('Enquiry submission error:', error);
2550 - this.showMessage('error', 'An error occurred. Please try again later.');
2917 + this.showMessage('error', __('An error occurred. Please try again later.', 'yatra'));
2551 2918 })
2552 2919 .finally(() => {
2553 2920 if (submitBtn) {
2554 2921 submitBtn.textContent = originalText;
@@ -2613,21 +2980,33 @@
2613 2980 this.updateSidebarPosition(); // Initial check
2614 2981 }
2615 2982
2616 2983 getActualSections() {
2617 - // Base sections that are always present in order
2618 - const baseSections = ['overview', 'itinerary', 'included', 'location', 'important-info'];
2619 -
2620 - // Conditional sections that may or may not be present
2621 - const conditionalSections = ['downloads', 'faq', 'trip-story', 'what-makes-special', 'testimonials'];
2622 -
2623 - // Combine all sections and filter to only include those that actually exist on the page
2624 - const allSections = [...baseSections, ...conditionalSections];
2625 -
2626 - return allSections.filter(sectionId => {
2627 - const element = document.getElementById(sectionId);
2628 - return element && element.offsetParent !== null; // Check if element exists and is visible
2984 + // Derive section IDs from the sticky nav items themselves rather
2985 + // than hardcoding the list. The previous hardcoded version only knew
2986 + // about the built-in sections (overview, itinerary, included, …) so
2987 + // admin-defined CUSTOM tabs — whose ID is `custom_<timestamp>` — were
2988 + // silently dropped: their nav-link still scrolled, but updateActiveNav
2989 + // never highlighted them as you scrolled past, and the "is this
2990 + // section on-screen" check below was always false for them.
2991 + //
2992 + // Reading from this.navItems means every enabled tab (built-in OR
2993 + // custom) participates in active-state tracking without needing this
2994 + // list updated for each new tab type.
2995 + const seen = new Set();
2996 + const ids = [];
2997 + this.navItems.forEach((item) => {
2998 + const href = item.getAttribute('href');
2999 + if (!href || !href.startsWith('#')) return;
3000 + const id = href.substring(1);
3001 + if (!id || seen.has(id)) return;
3002 + const element = document.getElementById(id);
3003 + if (element && element.offsetParent !== null) {
3004 + seen.add(id);
3005 + ids.push(id);
3006 + }
2629 3007 });
3008 + return ids;
2630 3009 }
2631 3010
2632 3011 attachEventListeners() {
2633 3012 // Handle clicks on nav items
@@ -2799,8 +3178,15 @@
2799 3178 this.lastScrollTop = scrollTop;
2800 3179 }
2801 3180
2802 3181 updateActiveNav() {
3182 + // `offsetTop` is relative to the closest positioned ancestor, not the
3183 + // document. When a section lives inside a `position: relative` wrapper
3184 + // (any of the layout containers on this page), offsetTop is wrong by
3185 + // the wrapper's offset, so the "active" pill never lit up correctly
3186 + // for some tabs — most visibly the newly-added custom tabs that sit
3187 + // further down the DOM. `getBoundingClientRect().top + pageYOffset`
3188 + // always gives the document-relative top regardless of ancestors.
2803 3189 const scrollPos = window.scrollY + 150;
2804 3190 let activeSectionId = null;
2805 3191
2806 3192 // Find which section is currently in view
@@ -2806,10 +3192,11 @@
2806 3192 // Find which section is currently in view
2807 3193 this.sections.forEach((sectionId) => {
2808 3194 const section = document.getElementById(sectionId);
2809 3195 if (section) {
2810 - const sectionTop = section.offsetTop;
2811 - const sectionBottom = sectionTop + section.offsetHeight;
3196 + const rect = section.getBoundingClientRect();
3197 + const sectionTop = rect.top + window.pageYOffset;
3198 + const sectionBottom = sectionTop + rect.height;
2812 3199
2813 3200 if (scrollPos >= sectionTop && scrollPos < sectionBottom) {
2814 3201 activeSectionId = sectionId;
2815 3202 }
@@ -2956,14 +3343,14 @@
2956 3343 }
2957 3344 }
2958 3345
2959 3346 if (travelers < 1) {
2960 - this.showBookingError(btn, 'Please select at least 1 traveler to continue.');
3347 + this.showBookingError(btn, __('Please select at least 1 traveler to continue.', 'yatra'));
2961 3348 return;
2962 3349 }
2963 3350
2964 3351 const originalText = btn.innerHTML;
2965 - btn.innerHTML = '<svg class="animate-spin" width="18" height="18" fill="none" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" opacity="0.25"></circle><path fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path></svg> Processing...';
3352 + btn.innerHTML = '<svg class="animate-spin" width="18" height="18" fill="none" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" opacity="0.25"></circle><path fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path></svg> ' + __('Processing...', 'yatra');
2966 3353 btn.disabled = true;
2967 3354
2968 3355 if (!tripId || isNaN(parseInt(tripId)) || parseInt(tripId) <= 0) {
2969 3356 console.error('Invalid trip ID:', tripId);
@@ -2968,9 +3355,9 @@
2968 3355 if (!tripId || isNaN(parseInt(tripId)) || parseInt(tripId) <= 0) {
2969 3356 console.error('Invalid trip ID:', tripId);
2970 3357 btn.innerHTML = originalText;
2971 3358 btn.disabled = false;
2972 - this.showBookingError(btn, 'Unable to process booking. Invalid trip data.');
3359 + this.showBookingError(btn, __('Unable to process booking. Invalid trip data.', 'yatra'));
2973 3360 return;
2974 3361 }
2975 3362
2976 3363 const pricingType = btn.getAttribute('data-pricing-type') || 'regular';
@@ -3191,9 +3578,9 @@
3191 3578 currency: window.yatraBookingData?.currency || 'USD',
3192 3579 num_items: sessionPayload.travelers || 1
3193 3580 });
3194 3581
3195 - console.log('[Facebook Pixel] InitiateCheckout tracked for trip:', sessionPayload.trip_id);
3582 + window.YATRA_DEBUG && console.log('[Facebook Pixel] InitiateCheckout tracked for trip:', sessionPayload.trip_id);
3196 3583 }
3197 3584
3198 3585 // Track Book Now click as begin_checkout (Google Analytics 4)
3199 3586 if (typeof gtag !== 'undefined' && window.yatraAdmin?.googleAnalytics?.measurementId) {
@@ -3211,9 +3598,9 @@
3211 3598 quantity: sessionPayload.travelers || 1
3212 3599 }]
3213 3600 });
3214 3601
3215 - console.log('[Google Analytics] begin_checkout tracked for trip:', sessionPayload.trip_id);
3602 + window.YATRA_DEBUG && console.log('[Google Analytics] begin_checkout tracked for trip:', sessionPayload.trip_id);
3216 3603 }
3217 3604
3218 3605 // Set booking session via REST API
3219 3606 // credentials: 'same-origin' is required to send cookies for session
@@ -3244,9 +3631,9 @@
3244 3631 // Error - show message and reset button
3245 3632 console.error('Booking session error:', data);
3246 3633 btn.innerHTML = originalText;
3247 3634 btn.disabled = false;
3248 - this.showBookingError(btn, data.message || 'Unable to process booking. Please try again.');
3635 + this.showBookingError(btn, data.message || __('Unable to process booking. Please try again.', 'yatra'));
3249 3636 }
3250 3637 })
3251 3638 .catch(error => {
3252 3639 console.error('Error setting booking session:', error);
@@ -3251,9 +3638,9 @@
3251 3638 .catch(error => {
3252 3639 console.error('Error setting booking session:', error);
3253 3640 btn.innerHTML = originalText;
3254 3641 btn.disabled = false;
3255 - this.showBookingError(btn, 'Unable to process booking. Please try again.');
3642 + this.showBookingError(btn, __('Unable to process booking. Please try again.', 'yatra'));
3256 3643 });
3257 3644 }
3258 3645
3259 3646 initTravelerSelectors() {
@@ -3285,18 +3672,21 @@
3285 3672 categoryInputs.forEach((input) => {
3286 3673 const value = parseInt(input.value) || 0;
3287 3674 if (value > 0) {
3288 3675 const row = input.closest('.yatra-quantity-row');
3289 - const categoryLabel = row ? row.querySelector('.yatra-quantity-title')?.textContent : 'Traveler';
3676 + const categoryLabel = row ? row.querySelector('.yatra-quantity-title')?.textContent : __('Traveler', 'yatra');
3290 3677 parts.push(`${categoryLabel} x ${value}`);
3291 3678 }
3292 3679 });
3293 3680
3294 3681 if (parts.length === 0) {
3295 - // Default to first category
3682 + // Default to first category. The label is read from the
3683 + // DOM (server-rendered → already translated). Only the
3684 + // " x 1" suffix is the literal we have to localise here.
3296 3685 const firstRow = categoryInputs[0]?.closest('.yatra-quantity-row');
3297 - const firstLabel = firstRow ? firstRow.querySelector('.yatra-quantity-title')?.textContent : 'Traveler';
3298 - display.textContent = `${firstLabel} x 1`;
3686 + const firstLabel = firstRow ? firstRow.querySelector('.yatra-quantity-title')?.textContent : __('Traveler', 'yatra');
3687 + /* translators: %s is the traveler-category label (e.g. "Adult", "Child"). Renders as "Adult x 1". */
3688 + display.textContent = __('%s x 1', 'yatra').replace('%s', firstLabel);
3299 3689 } else {
3300 3690 display.textContent = parts.join(', ');
3301 3691 }
3302 3692 };
@@ -3318,10 +3708,10 @@
3318 3708 const isMinus = btn.classList.contains('yatra-quantity-minus');
3319 3709 const isPlus = btn.classList.contains('yatra-quantity-plus');
3320 3710
3321 3711 let current = parseInt(input.value) || 0;
3322 - const min = parseInt(input.getAttribute('min')) || 0;
3323 - const max = parseInt(input.getAttribute('max')) || 999;
3712 + const min = window.yatraNumOr(input.getAttribute('min'), 0);
3713 + const max = window.yatraNumOr(input.getAttribute('max'), 999);
3324 3714 let newValue = current;
3325 3715
3326 3716 if (isPlus && current < max) {
3327 3717 newValue = current + 1;
@@ -3393,10 +3783,10 @@
3393 3783 const isMinus = btn.classList.contains('yatra-quantity-minus');
3394 3784 const isPlus = btn.classList.contains('yatra-quantity-plus');
3395 3785
3396 3786 let current = parseInt(input.value) || 1;
3397 - const min = parseInt(input.getAttribute('min')) || 1;
3398 - const max = parseInt(input.getAttribute('max')) || 999;
3787 + const min = window.yatraNumOr(input.getAttribute('min'), 1);
3788 + const max = window.yatraNumOr(input.getAttribute('max'), 999);
3399 3789 let newValue = current;
3400 3790
3401 3791 if (isPlus && current < max) {
3402 3792 newValue = current + 1;
@@ -3468,9 +3858,9 @@
3468 3858 categoryInputs.forEach((input) => {
3469 3859 const value = parseInt(input.value) || 0;
3470 3860 if (value > 0) {
3471 3861 const row = input.closest('.yatra-quantity-row');
3472 - const categoryLabel = row ? row.querySelector('.yatra-quantity-title')?.textContent : 'Traveler';
3862 + const categoryLabel = row ? row.querySelector('.yatra-quantity-title')?.textContent : __('Traveler', 'yatra');
3473 3863 parts.push(`${categoryLabel} x ${value}`);
3474 3864
3475 3865 // Also update sidebar inputs if they exist
3476 3866 const categoryId = input.getAttribute('data-category');
@@ -3506,8 +3896,9 @@
3506 3896 // Check for traveler-based pricing first
3507 3897 const categoryInputs = this.section.querySelectorAll(`.yatra-availability-category[data-item="${itemIndex}"]`);
3508 3898 let totalPrice = 0;
3509 3899 let totalTravelers = 0;
3900 + let totalGroups = 0; // per-group categories: number of group prices charged
3510 3901
3511 3902 if (categoryInputs.length > 0) {
3512 3903 // Traveler-based pricing: sum up each category's price * quantity (respecting pricing mode)
3513 3904 categoryInputs.forEach((input) => {
@@ -3514,12 +3905,16 @@
3514 3905 const quantity = parseInt(input.value) || 0;
3515 3906 const row = input.closest('.yatra-quantity-row');
3516 3907 const price = row ? parseFloat(row.getAttribute('data-price')) || 0 : 0;
3517 3908 const pricingMode = (row ? row.getAttribute('data-pricing-mode') : null) || input.getAttribute('data-pricing-mode') || 'per_person';
3518 -
3909 + const groupOverflow = (row ? row.getAttribute('data-group-overflow') : null) || input.getAttribute('data-group-overflow') || 'block';
3910 + const maxPax = parseInt((row ? row.getAttribute('data-max-pax') : null) || input.getAttribute('data-max-pax')) || 0;
3911 +
3519 3912 if (pricingMode === 'per_group') {
3520 3913 if (quantity > 0) {
3521 - totalPrice += price;
3914 + const blocks = (groupOverflow === 'per_block' && maxPax > 0) ? Math.ceil(quantity / maxPax) : 1;
3915 + totalPrice += price * blocks;
3916 + totalGroups += blocks;
3522 3917 }
3523 3918 } else {
3524 3919 totalPrice += quantity * price;
3525 3920 }
@@ -3547,16 +3942,41 @@
3547 3942 } else {
3548 3943 totalAmountElement.textContent = String(finalPrice.toFixed(2));
3549 3944 }
3550 3945
3946 + // Header price must match the chargeable amount (row totals + group discount), not static PHP.
3947 + // Its unit follows the card's price label: "per person" → average per traveller;
3948 + // "per group" → the price per group actually charged (never divided by headcount,
3949 + // which would turn a flat $1,100 group price into "$550 per group" for two travellers).
3950 + const cardRoot = this.section.querySelector('.yatra-availability-card[data-item="' + itemIndex + '"]');
3951 + const headerPriceWrap = cardRoot ? cardRoot.querySelector('.yatra-card-header-price') : null;
3952 + const headerSaleEl = headerPriceWrap ? headerPriceWrap.querySelector('.yatra-sale-price') : null;
3953 + if (headerSaleEl) {
3954 + const headerUnit = headerPriceWrap.getAttribute('data-price-unit') || 'per_person';
3955 + let perPerson = 0;
3956 + if (headerUnit === 'per_group') {
3957 + perPerson = totalGroups > 0 ? finalPrice / totalGroups : (totalPrice > 0 ? totalPrice : 0);
3958 + } else if (totalTravelers > 0) {
3959 + perPerson = finalPrice / totalTravelers;
3960 + } else if (totalPrice > 0) {
3961 + perPerson = totalPrice;
3962 + }
3963 + if (perPerson > 0) {
3964 + headerSaleEl.textContent =
3965 + typeof yatra_format_price_js === 'function' ? yatra_format_price_js(perPerson) : String(perPerson.toFixed(2));
3966 + }
3967 + }
3968 +
3551 3969 // Update the note text
3552 3970 const noteElement = this.section.querySelector(
3553 3971 `.yatra-card-total-note[data-item="${itemIndex}"]`
3554 3972 );
3555 3973 if (noteElement) {
3556 - let travelerText = totalTravelers === 1
3557 - ? 'for 1 traveler'
3558 - : `for ${totalTravelers} travelers`;
3974 + let travelerText = sprintf(
3975 + /* translators: %d is the number of travelers. Singular form is "for 1 traveler", plural "for N travelers". */
3976 + _n('for %d traveler', 'for %d travelers', totalTravelers, 'yatra'),
3977 + totalTravelers
3978 + );
3559 3979
3560 3980 // Add group discount info if applied
3561 3981 if (groupDiscountApplied && groupDiscountAmount > 0) {
3562 3982 const discountText = (typeof yatra_format_price_js === 'function')
@@ -3561,9 +3981,13 @@
3561 3981 if (groupDiscountApplied && groupDiscountAmount > 0) {
3562 3982 const discountText = (typeof yatra_format_price_js === 'function')
3563 3983 ? yatra_format_price_js(groupDiscountAmount)
3564 3984 : String(groupDiscountAmount.toFixed(2));
3565 - travelerText += ` (Group discount: -${discountText})`;
3985 + travelerText += sprintf(
3986 + /* translators: %s: formatted discount amount. */
3987 + __(' (Group discount: -%s)', 'yatra'),
3988 + discountText
3989 + );
3566 3990 }
3567 3991 noteElement.textContent = travelerText;
3568 3992 }
3569 3993
@@ -3582,23 +4006,9 @@
3582 4006 discountPercentage: 0,
3583 4007 discountType: null
3584 4008 };
3585 4009
3586 - // Get group discounts from the booking card data attribute
3587 - const bookingCard = document.querySelector('.yatra-booking-card');
3588 - if (!bookingCard) return result;
3589 -
3590 - const groupDiscountsData = bookingCard.getAttribute('data-group-discounts');
3591 - if (!groupDiscountsData) return result;
3592 -
3593 - let groupDiscounts;
3594 - try {
3595 - groupDiscounts = JSON.parse(groupDiscountsData);
3596 - } catch (e) {
3597 - console.error('Failed to parse group discounts:', e);
3598 - return result;
3599 - }
3600 -
4010 + const groupDiscounts = window.yatraTripData && window.yatraTripData.sidebarGroupDiscounts;
3601 4011 if (!Array.isArray(groupDiscounts) || groupDiscounts.length === 0) return result;
3602 4012
3603 4013 // Find the applicable discount based on total travelers
3604 4014 let applicableDiscount = null;
@@ -3653,8 +4063,23 @@
3653 4063 return result;
3654 4064 }
3655 4065
3656 4066 /**
4067 + * Recompute footer + header pricing for every availability card (e.g. after sidebar traveler sync).
4068 + */
4069 + updateAllCardTotals() {
4070 + if (!this.section) return;
4071 + const seen = new Set();
4072 + this.section.querySelectorAll('.yatra-availability-card[data-item]').forEach((card) => {
4073 + const id = card.getAttribute('data-item');
4074 + if (id && !seen.has(id)) {
4075 + seen.add(id);
4076 + this.updateTotalPrice(id);
4077 + }
4078 + });
4079 + }
4080 +
4081 + /**
3657 4082 * Update group discount badge visibility
3658 4083 */
3659 4084 updateGroupDiscountBadge(itemIndex, discountApplied, discountResult) {
3660 4085 // Find or create the group discount applied badge for this item
@@ -3677,10 +4102,14 @@
3677 4102 }
3678 4103
3679 4104 // Update badge content
3680 4105 const discountText = discountResult.discountType === 'percentage'
3681 - ? `${discountResult.discountPercentage}% group discount applied!`
3682 - : `Group discount applied!`;
4106 + ? sprintf(
4107 + /* translators: %s: discount percentage. */
4108 + __('%s%% group discount applied!', 'yatra'),
4109 + discountResult.discountPercentage
4110 + )
4111 + : __('Group discount applied!', 'yatra');
3683 4112 badge.innerHTML = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path><circle cx="9" cy="7" r="4"></circle><path d="M23 21v-2a4 4 0 0 0-3-3.87"></path><path d="M16 3.13a4 4 0 0 1 0 7.75"></path></svg> ${discountText}`;
3684 4113 } else if (badge) {
3685 4114 badge.remove();
3686 4115 }
@@ -3760,9 +4189,9 @@
3760 4189 }
3761 4190
3762 4191 // Show success feedback
3763 4192 const originalText = button.textContent;
3764 - button.textContent = 'Selected!';
4193 + button.textContent = __('Selected!', 'yatra');
3765 4194 button.style.background = '#10b981';
3766 4195 setTimeout(() => {
3767 4196 button.textContent = originalText;
3768 4197 button.style.background = '';
@@ -3787,9 +4216,11 @@
3787 4216 const travelDate = travelDateInput && travelDateInput.value ? travelDateInput.value : undefined;
3788 4217
3789 4218 const originalHTML = btn.innerHTML;
3790 4219 const loadingSvg = '<svg class="yatra-spinner" width="18" height="18" viewBox="0 0 24 24" fill="none"><circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="3" opacity="0.25"></circle><path fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path></svg> ';
3791 - btn.innerHTML = loadingSvg + 'Loading...';
4220 + // Spinner SVG is fixed markup; only the visible label is wrapped
4221 + // so it ends up in the .pot and translates with the rest of the UI.
4222 + btn.innerHTML = loadingSvg + __('Loading...', 'yatra');
3792 4223 btn.disabled = true;
3793 4224
3794 4225 const url = buildAvailabilityTemplateUrl(tripId, {
3795 4226 date: travelDate,
@@ -3808,9 +4239,9 @@
3808 4239 credentials: 'omit',
3809 4240 })
3810 4241 .then((resp) => {
3811 4242 if (!resp.ok) {
3812 - throw new Error('Load more failed');
4243 + throw new Error(__('Load more failed', 'yatra'));
3813 4244 }
3814 4245 return resp.json();
3815 4246 })
3816 4247 .then((data) => {
@@ -3847,9 +4278,11 @@
3847 4278 return;
3848 4279 }
3849 4280
3850 4281 const remaining = Math.max(0, total - displayed);
3851 - btn.innerHTML = `<svg width="18" height="18" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/></svg> Load more departures (${remaining} remaining)`;
4282 + /* translators: %d: number of additional departures still available to load. */
4283 + const loadMoreLabel = sprintf(__('Load more departures (%d remaining)', 'yatra'), remaining);
4284 + btn.innerHTML = `<svg width="18" height="18" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/></svg> ${loadMoreLabel}`;
3852 4285 btn.disabled = false;
3853 4286
3854 4287 loadMoreContainer.dataset.currentPage = String(nextPage);
3855 4288 loadMoreContainer.dataset.loaded = String(displayed);
@@ -3855,9 +4288,17 @@
3855 4288 loadMoreContainer.dataset.loaded = String(displayed);
3856 4289 loadMoreContainer.dataset.total = String(total);
3857 4290
3858 4291 if (countInfo) {
3859 - countInfo.textContent = `Showing ${displayed} of ${total} departures`;
4292 + // Pagination summary. Two placeholders preserved as
4293 + // %1$d / %2$d so translators can reorder them (some
4294 + // locales prefer "Of N, showing M" word order).
4295 + /* translators: %1$d is the number of departures shown so far; %2$d is the total available. */
4296 + countInfo.textContent = sprintf(
4297 + __('Showing %1$d of %2$d departures', 'yatra'),
4298 + displayed,
4299 + total
4300 + );
3860 4301 }
3861 4302 })
3862 4303 .catch((err) => {
3863 4304 console.error('[Yatra] Load more failed:', err);
@@ -4021,9 +4462,9 @@
4021 4462 if (!this.toggleAllBtn) return;
4022 4463 this.toggleAllBtn.classList.toggle('is-all-expanded', isExpanded);
4023 4464 const toggleText = this.toggleAllBtn.querySelector('.toggle-text');
4024 4465 if (toggleText) {
4025 - toggleText.textContent = isExpanded ? 'Collapse All' : 'Expand All';
4466 + toggleText.textContent = isExpanded ? __('Collapse All', 'yatra') : __('Expand All', 'yatra');
4026 4467 }
4027 4468 }
4028 4469 }
4029 4470
@@ -4136,9 +4577,10 @@
4136 4577 const originalText = submitBtn.innerHTML;
4137 4578
4138 4579 // Disable button and show loading
4139 4580 submitBtn.disabled = true;
4140 - submitBtn.innerHTML = '<span class="yatra-loading-spinner"></span> Submitting...';
4581 + submitBtn.innerHTML =
4582 + '<span class="yatra-loading-spinner"></span> ' + __('Submitting...', 'yatra');
4141 4583
4142 4584 try {
4143 4585 const formData = new FormData(this.form);
4144 4586 formData.append('action', 'yatra_submit_review');
@@ -4152,9 +4594,9 @@
4152 4594 const result = await response.json();
4153 4595
4154 4596 if (result.success) {
4155 4597 // Show success message
4156 - this.showMessage('success', result.data?.message || 'Thank you for your review!');
4598 + this.showMessage('success', result.data?.message || __('Thank you for your review!', 'yatra'));
4157 4599
4158 4600 // Check if this was an edit or new submission
4159 4601 const isEdit = this.form.querySelector('input[name="action_type"]')?.value === 'edit';
4160 4602
@@ -4171,13 +4613,13 @@
4171 4613 ratingLabels.forEach(label => label.classList.add('selected'));
4172 4614 }
4173 4615 }
4174 4616 } else {
4175 - this.showMessage('error', result.data?.message || 'Failed to submit review. Please try again.');
4617 + this.showMessage('error', result.data?.message || __('Failed to submit review. Please try again.', 'yatra'));
4176 4618 }
4177 4619 } catch (error) {
4178 4620 console.error('Review submission error:', error);
4179 - this.showMessage('error', 'An error occurred. Please try again.');
4621 + this.showMessage('error', __('An error occurred. Please try again.', 'yatra'));
4180 4622 } finally {
4181 4623 submitBtn.disabled = false;
4182 4624 submitBtn.innerHTML = originalText;
4183 4625 }
@@ -4351,16 +4793,13 @@
4351 4793 * Handles save/remove trip from wishlist functionality
4352 4794 */
4353 4795 class WishlistHandler {
4354 4796 constructor() {
4355 - if (!window.yatraTripData?.wishlistEnabled) {
4356 - return;
4357 - }
4358 - this.apiUrl = window.yatraTripData?.apiUrl || '/wp-json/yatra/v1';
4359 - this.nonce = window.yatraTripData?.nonce || '';
4360 - this.tripId = window.yatraTripData?.tripId || 0;
4361 - this.isLoggedIn = window.yatraTripData?.isLoggedIn || false;
4362 - this.init();
4797 + // Wishlist clicks are now handled exclusively by listing-wishlist.js
4798 + // (which covers every .yatra-favorite-btn on listing, single-trip hero,
4799 + // and similar-trips). Registering this second handler caused every click
4800 + // to fire two REST requests racing against each other.
4801 + return;
4363 4802 }
4364 4803
4365 4804 init() {
4366 4805 // Check if user is logged in and update button states
@@ -4443,15 +4882,15 @@
4443 4882
4444 4883 const data = await response.json();
4445 4884 if (data.success) {
4446 4885 this.updateButtonState(true, tid);
4447 - this.showMessage(data.message || 'Trip saved to wishlist', 'success', btn);
4886 + this.showMessage(data.message || __('Trip saved to wishlist', 'yatra'), 'success', btn);
4448 4887 } else {
4449 - this.showMessage(data.message || 'Failed to save trip', 'error', btn);
4888 + this.showMessage(data.message || __('Failed to save trip', 'yatra'), 'error', btn);
4450 4889 }
4451 4890 } catch (error) {
4452 4891 console.error('Error saving trip:', error);
4453 - this.showMessage('An error occurred. Please try again.', 'error', btn);
4892 + this.showMessage(__('An error occurred. Please try again.', 'yatra'), 'error', btn);
4454 4893 } finally {
4455 4894 btn.disabled = false;
4456 4895 btn.classList.remove('loading');
4457 4896 }
@@ -4473,15 +4912,15 @@
4473 4912
4474 4913 const data = await response.json();
4475 4914 if (data.success) {
4476 4915 this.updateButtonState(false, tid);
4477 - this.showMessage(data.message || 'Trip removed from wishlist', 'success', btn);
4916 + this.showMessage(data.message || __('Trip removed from wishlist', 'yatra'), 'success', btn);
4478 4917 } else {
4479 - this.showMessage(data.message || 'Failed to remove trip', 'error', btn);
4918 + this.showMessage(data.message || __('Failed to remove trip', 'yatra'), 'error', btn);
4480 4919 }
4481 4920 } catch (error) {
4482 4921 console.error('Error removing trip:', error);
4483 - this.showMessage('An error occurred. Please try again.', 'error', btn);
4922 + this.showMessage(__('An error occurred. Please try again.', 'yatra'), 'error', btn);
4484 4923 } finally {
4485 4924 btn.disabled = false;
4486 4925 btn.classList.remove('loading');
4487 4926 }
@@ -4495,9 +4934,9 @@
4495 4934 const buttons = document.querySelectorAll(selector);
4496 4935 buttons.forEach(btn => {
4497 4936 if (isSaved) {
4498 4937 btn.classList.add('saved', 'is-saved');
4499 - btn.setAttribute('aria-label', 'Remove from favorites');
4938 + btn.setAttribute('aria-label', __('Remove from favorites', 'yatra'));
4500 4939 // Fill the heart icon and change color
4501 4940 const svg = btn.querySelector('svg');
4502 4941 if (svg) {
4503 4942 svg.style.fill = 'currentColor';
@@ -4505,9 +4944,9 @@
4505 4944 btn.style.color = '#ef4444';
4506 4945 }
4507 4946 } else {
4508 4947 btn.classList.remove('saved', 'is-saved');
4509 - btn.setAttribute('aria-label', 'Add to favorites');
4948 + btn.setAttribute('aria-label', __('Add to favorites', 'yatra'));
4510 4949 // Unfill the heart icon
4511 4950 const svg = btn.querySelector('svg');
4512 4951 if (svg) {
4513 4952 svg.style.fill = 'none';
@@ -4531,13 +4970,17 @@
4531 4970 if (modalContent) {
4532 4971 const loginMessage = document.createElement('div');
4533 4972 loginMessage.className = 'yatra-login-prompt';
4534 4973 loginMessage.style.cssText = 'padding: 40px; text-align: center;';
4974 + const loginRequiredHeading = __('Login Required', 'yatra');
4975 + const loginRequiredBody = __('Please login to save trips to your wishlist.', 'yatra');
4976 + const loginButtonLabel = __('Login', 'yatra');
4977 + const cancelButtonLabel = __('Cancel', 'yatra');
4535 4978 loginMessage.innerHTML = `
4536 - <h3 style="margin-bottom: 20px;">Login Required</h3>
4537 - <p style="margin-bottom: 30px;">Please login to save trips to your wishlist.</p>
4538 - <a href="${window.yatraTripData?.loginUrl || '/wp-login.php'}" class="yatra-btn" style="display: inline-block; margin-right: 10px;">Login</a>
4539 - <button type="button" class="yatra-btn-secondary" onclick="this.closest('.yatra-enquiry-modal').classList.remove('active'); document.body.style.overflow = '';">Cancel</button>
4979 + <h3 style="margin-bottom: 20px;">${loginRequiredHeading}</h3>
4980 + <p style="margin-bottom: 30px;">${loginRequiredBody}</p>
4981 + <a href="${window.yatraTripData?.loginUrl || '/wp-login.php'}" class="yatra-btn" style="display: inline-block; margin-right: 10px;">${loginButtonLabel}</a>
4982 + <button type="button" class="yatra-btn-secondary" onclick="this.closest('.yatra-enquiry-modal').classList.remove('active'); document.body.style.overflow = '';">${cancelButtonLabel}</button>
4540 4983 `;
4541 4984 modalContent.innerHTML = '';
4542 4985 modalContent.appendChild(loginMessage);
4543 4986 }
@@ -4542,9 +4985,9 @@
4542 4985 modalContent.appendChild(loginMessage);
4543 4986 }
4544 4987 } else {
4545 4988 // Fallback: show alert and redirect to login
4546 - if (confirm('Please login to save trips to your wishlist. Would you like to go to the login page?')) {
4989 + if (confirm(__('Please login to save trips to your wishlist. Would you like to go to the login page?', 'yatra'))) {
4547 4990 window.location.href = window.yatraTripData?.loginUrl || '/wp-login.php';
4548 4991 }
4549 4992 }
4550 4993 }
@@ -4633,12 +5076,91 @@
4633 5076 this.exposeGlobals();
4634 5077
4635 5078 // Extra page-level behaviors
4636 5079 this.initHeroSmoothScroll();
5080 + this.initGroupDiscountPopovers();
4637 5081
4638 5082 return this.instances;
4639 5083 }
4640 5084
5085 + /**
5086 + * Group discount popover in booking sidebar / mobile sticky (tap outside to close, Esc to close).
5087 + */
5088 + initGroupDiscountPopovers() {
5089 + const syncOpenState = (wrap, open) => {
5090 + const btn = wrap.querySelector('.yatra-group-discount-popover__trigger');
5091 + const panel = wrap.querySelector('.yatra-group-discount-popover__panel');
5092 + wrap.classList.toggle('is-open', open);
5093 + if (btn) {
5094 + btn.setAttribute('aria-expanded', open ? 'true' : 'false');
5095 + }
5096 + if (panel) {
5097 + panel.setAttribute('aria-hidden', open ? 'false' : 'true');
5098 + }
5099 + };
5100 +
5101 + const closeAll = (exceptWrap = null) => {
5102 + document.querySelectorAll('[data-yatra-group-discount-popover]').forEach((w) => {
5103 + if (exceptWrap && w === exceptWrap) {
5104 + return;
5105 + }
5106 + syncOpenState(w, false);
5107 + });
5108 + };
5109 +
5110 + const attach = () => {
5111 + document.querySelectorAll('[data-yatra-group-discount-popover]').forEach((wrap) => {
5112 + const btn = wrap.querySelector('.yatra-group-discount-popover__trigger');
5113 + if (!btn || btn.dataset.yatraGdBound === '1') {
5114 + return;
5115 + }
5116 + btn.dataset.yatraGdBound = '1';
5117 + btn.addEventListener('click', (e) => {
5118 + e.preventDefault();
5119 + e.stopPropagation();
5120 + const open = !wrap.classList.contains('is-open');
5121 + if (open) {
5122 + closeAll(wrap);
5123 + syncOpenState(wrap, true);
5124 + } else {
5125 + syncOpenState(wrap, false);
5126 + }
5127 + });
5128 + });
5129 +
5130 + if (document.documentElement.dataset.yatraGroupDiscountPopoverDocBound === '1') {
5131 + return;
5132 + }
5133 + document.documentElement.dataset.yatraGroupDiscountPopoverDocBound = '1';
5134 +
5135 + document.addEventListener('click', (e) => {
5136 + if (!e.target.closest('[data-yatra-group-discount-popover]')) {
5137 + closeAll();
5138 + }
5139 + });
5140 +
5141 + document.addEventListener('keydown', (e) => {
5142 + if (e.key !== 'Escape') {
5143 + return;
5144 + }
5145 + const openWrap = document.querySelector('[data-yatra-group-discount-popover].is-open');
5146 + if (openWrap) {
5147 + syncOpenState(openWrap, false);
5148 + const btn = openWrap.querySelector('.yatra-group-discount-popover__trigger');
5149 + if (btn) {
5150 + btn.focus();
5151 + }
5152 + }
5153 + });
5154 + };
5155 +
5156 + if (document.readyState === 'loading') {
5157 + document.addEventListener('DOMContentLoaded', attach);
5158 + } else {
5159 + attach();
5160 + }
5161 + }
5162 +
4641 5163 exposeGlobals() {
4642 5164 Object.entries(this.instances).forEach(([key, instance]) => {
4643 5165 window[key] = instance;
4644 5166 });
@@ -4715,15 +5237,15 @@
4715 5237 const videoUrl = link.getAttribute('href');
4716 5238 if (!videoUrl || videoUrl === '#') return;
4717 5239
4718 5240 const img = overlay.closest('.yatra-media-card').querySelector('img');
4719 - const videoTitle = img ? img.alt : 'Itinerary Video';
5241 + const videoTitle = img ? img.alt : __('Itinerary Video', 'yatra');
4720 5242
4721 5243 // Extract video ID from URL
4722 5244 let videoId = '';
4723 5245 if (videoUrl.includes('youtube.com') || videoUrl.includes('youtu.be')) {
4724 5246 // YouTube video ID extraction
4725 - const matches = videoUrl.match(/(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/)([^&\n?#]+)/);
5247 + const matches = videoUrl.match(/(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/|youtube\.com\/shorts\/|youtube\.com\/live\/)([^&\n?#\/]+)/);
4726 5248 videoId = matches ? matches[1] : '';
4727 5249 } else if (videoUrl.includes('vimeo.com')) {
4728 5250 // Vimeo video ID extraction
4729 5251 const matches = videoUrl.match(/vimeo\.com\/(?:.*#|.\/videos\/)?([0-9]+)/);
@@ -4836,9 +5358,9 @@
4836 5358 try {
4837 5359 // Get download URL with signature using centralized helper
4838 5360 const response = await window.YatraApiHelper.getDownloadInfo(downloadId);
4839 5361 if (!response.ok) {
4840 - throw new Error('Download failed');
5362 + throw new Error(__('Download failed', 'yatra'));
4841 5363 }
4842 5364
4843 5365 const data = await response.json();
4844 5366
@@ -4855,9 +5377,9 @@
4855 5377 }
4856 5378 } catch (error) {
4857 5379 console.error('Download error:', error);
4858 5380 // Show error message to user
4859 - this.showNotification('Download failed. Please try again.', 'error');
5381 + this.showNotification(__('Download failed. Please try again.', 'yatra'), 'error');
4860 5382 }
4861 5383 }
4862 5384
4863 5385 async previewFile(downloadId) {
@@ -4867,15 +5389,15 @@
4867 5389
4868 5390 // Get download info using centralized helper
4869 5391 const response = await window.YatraApiHelper.getDownloadInfo(downloadId);
4870 5392 if (!response.ok) {
4871 - throw new Error('Failed to get file info');
5393 + throw new Error(__('Failed to get file info', 'yatra'));
4872 5394 }
4873 5395
4874 5396 const data = await response.json();
4875 5397
4876 5398 if (data.download_url) {
4877 - this.loadPreview(data.download_url, data.title || 'Preview');
5399 + this.loadPreview(data.download_url, data.title || __('Preview', 'yatra'));
4878 5400 } else {
4879 5401 throw new Error('File URL not found');
4880 5402 }
4881 5403 } catch (error) {
@@ -4880,9 +5402,9 @@
4880 5402 }
4881 5403 } catch (error) {
4882 5404 console.error('Preview error:', error);
4883 5405 this.closePreviewModal();
4884 - this.showNotification('Failed to load preview. Please try again.', 'error');
5406 + this.showNotification(__('Failed to load preview. Please try again.', 'yatra'), 'error');
4885 5407 }
4886 5408 }
4887 5409
4888 5410 loadPreview(fileUrl, title) {
@@ -4910,13 +5432,15 @@
4910 5432 previewContent.innerHTML = `
4911 5433 <iframe src="${fileUrl}" style="width: 100%; height: 500px; border: none; border-radius: 8px;"></iframe>
4912 5434 `;
4913 5435 } else {
5436 + const previewUnavailableTitle = __('Preview not available for this file type', 'yatra');
5437 + const previewUnavailableHint = __('Please download the file to view its contents', 'yatra');
4914 5438 previewContent.innerHTML = `
4915 5439 <div style="text-align: center; padding: 40px;">
4916 5440 <div style="font-size: 48px; margin-bottom: 16px; opacity: 0.5;">📄</div>
4917 - <p style="color: #6b7280; margin-bottom: 16px;">Preview not available for this file type</p>
4918 - <p style="color: #9ca3af; font-size: 14px;">Please download the file to view its contents</p>
5441 + <p style="color: #6b7280; margin-bottom: 16px;">${previewUnavailableTitle}</p>
5442 + <p style="color: #9ca3af; font-size: 14px;">${previewUnavailableHint}</p>
4919 5443 </div>
4920 5444 `;
4921 5445 }
4922 5446 }
@@ -4938,12 +5462,13 @@
4938 5462
4939 5463 // Reset loading state
4940 5464 const previewContent = document.getElementById('preview-content');
4941 5465 if (previewContent) {
5466 + const loadingPreviewLabel = __('Loading preview...', 'yatra');
4942 5467 previewContent.innerHTML = `
4943 5468 <div class="yatra-preview-loading">
4944 5469 <div class="yatra-preview-spinner"></div>
4945 - <p>Loading preview...</p>
5470 + <p>${loadingPreviewLabel}</p>
4946 5471 </div>
4947 5472 `;
4948 5473 }
4949 5474 }