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 +598 -214 3.0.2.9 → 3.0.16 View file →
@@ -3,8 +3,48 @@
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
10 50 /**
@@ -64,8 +104,25 @@
64 104 const php = cfg.dateFormat || cfg.date_format || 'Y-m-d';
65 105 return phpDateFormatToFlatpickr(php);
66 106 };
67 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 +
68 125 const getRestBase = () => {
69 126 const siteUrl =
70 127 (window.yatraTripData && window.yatraTripData.siteUrl) ||
71 128 (window.yatraAdmin && window.yatraAdmin.siteUrl) ||
@@ -137,9 +194,13 @@
137 194 const symbol = (typeof cfg.currencySymbol === 'string' && cfg.currencySymbol.length)
138 195 ? cfg.currencySymbol
139 196 : (typeof cfg.currency === 'string' ? cfg.currency : '$');
140 197
141 - const position = (cfg.currencyPosition || 'before').toString();
198 + const position = (
199 + cfg.currencyPosition ||
200 + cfg.currency_position ||
201 + 'before'
202 + ).toString().toLowerCase().trim();
142 203 const decimals = Number.isFinite(Number(cfg.decimalPlaces)) ? Number(cfg.decimalPlaces) : 2;
143 204 const thousandSep = (typeof cfg.thousandSeparator === 'string') ? cfg.thousandSeparator : ',';
144 205 const decimalSep = (typeof cfg.decimalSeparator === 'string') ? cfg.decimalSeparator : '.';
145 206
@@ -144,9 +205,14 @@
144 205 const decimalSep = (typeof cfg.decimalSeparator === 'string') ? cfg.decimalSeparator : '.';
145 206
146 207 const num = Number(amount) || 0;
147 208
148 - 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', {
149 215 minimumFractionDigits: decimals,
150 216 maximumFractionDigits: decimals,
151 217 }).format(num)
152 218 .replace(/,/g, 'TEMP_THOUSAND')
@@ -153,13 +219,24 @@
153 219 .replace(/\./g, 'TEMP_DECIMAL')
154 220 .replace(/TEMP_THOUSAND/g, thousandSep)
155 221 .replace(/TEMP_DECIMAL/g, decimalSep);
156 222
157 - // Accept common variants: before/left vs after/right
158 - if (position === 'after' || position === 'right') {
159 - return `${formatted} ${symbol}`;
223 + var pos = position;
224 + if (pos === 'before') {
225 + pos = 'left_space';
226 + } else if (pos === 'after') {
227 + pos = 'right_space';
160 228 }
161 - 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;
162 239 };
163 240 }
164 241
165 242 /**
@@ -261,9 +338,17 @@
261 338 const mediaData = JSON.parse(mediaScript.textContent || '{}');
262 339 const imageUrls = Array.isArray(mediaData.images) ? mediaData.images : [];
263 340 imageUrls.forEach((url, index) => {
264 341 if (url && typeof url === 'string' && !this.images.some(i => i.src === url)) {
265 - this.images.push({ src: url, alt: `Gallery Image ${index + 1}` });
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 + });
266 351 }
267 352 });
268 353 } catch (e) {
269 354 // JSON parse error – fall through to DOM-based collection.
@@ -271,23 +356,24 @@
271 356 }
272 357
273 358 // 2. Collect from hero slides (covers any images not in the JSON blob).
274 359 document.querySelectorAll('.yatra-trip-hero-slide img').forEach((img, index) => {
275 - add(img, `Gallery Image ${index + 1}`);
360 + /* translators: %d is the 1-based image index in the gallery. */
361 + add(img, __('Gallery Image %d', 'yatra').replace('%d', String(index + 1)));
276 362 });
277 363
278 364 // 3. Collect from gallery section (hero gallery only, NOT itinerary).
279 365 document.querySelectorAll('.yatra-trip-gallery .yatra-gallery-item img').forEach((img) => {
280 - add(img, 'Gallery Image');
366 + add(img, __('Gallery Image', 'yatra'));
281 367 });
282 368
283 369 // 4. Collect from hero main image.
284 370 const heroMainImg = document.querySelector('.yatra-hero-main-img');
285 - if (heroMainImg) add(heroMainImg, 'Main Image');
371 + if (heroMainImg) add(heroMainImg, __('Main Image', 'yatra'));
286 372
287 373 // 5. Collect from side images.
288 374 document.querySelectorAll('.yatra-side-image-item img').forEach((img) => {
289 - add(img, 'Side Image');
375 + add(img, __('Side Image', 'yatra'));
290 376 });
291 377 }
292 378
293 379 createThumbnails() {
@@ -481,14 +567,21 @@
481 567 }
482 568
483 569 /**
484 570 * Itinerary Gallery Modal Class
485 - * 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.
486 579 */
487 580 class ItineraryGalleryModal {
488 581 constructor() {
489 582 this.modal = null;
490 - this.images = [];
583 + this.images = []; // currently-visible image set (the active scope)
491 584 this.currentIndex = 0;
492 585 this.init();
493 586 }
494 587
@@ -505,43 +598,69 @@
505 598 if (!this.modal) {
506 599 console.warn('Itinerary gallery modal not found');
507 600 return;
508 601 }
509 -
510 - console.log('Itinerary gallery modal found, collecting images...');
511 - this.collectImages();
512 - console.log('Itinerary gallery images collected:', this.images.length);
513 - this.createThumbnails();
514 602 this.attachEventListeners();
515 603 }
516 604
517 - collectImages() {
518 - 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 = [];
519 613
520 614 const add = (img, fallbackAlt) => {
521 615 const src = img.getAttribute('data-full-size') || getImageSrc(img);
522 616 if (!src) return;
523 - if (!this.images.some(i => i.src === src)) {
524 - this.images.push({ src, alt: img.alt || fallbackAlt });
617 + if (!images.some(i => i.src === src)) {
618 + images.push({ src, alt: img.alt || fallbackAlt });
525 619 }
526 620 };
527 621
528 - // Collect from itinerary gallery section (hidden gallery)
529 - document.querySelectorAll('.yatra-itinerary-trip-gallery .yatra-gallery-item img').forEach((img) => {
530 - add(img, `Itinerary Image ${this.images.length + 1}`);
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 + ));
531 629 });
532 630
533 - // Collect from visible itinerary gallery items
534 - document.querySelectorAll('.yatra-entry-gallery .yatra-media-card img').forEach((img) => {
535 - add(img, `Itinerary Gallery Image ${this.images.length + 1}`);
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 + ));
536 638 });
537 639
538 - // Collect from itinerary video items
539 - document.querySelectorAll('.yatra-entry-video .yatra-media-card img, .yatra-entry-gallery .yatra-itinerary-video-link img').forEach((img) => {
540 - add(img, `Itinerary Video Image ${this.images.length + 1}`);
541 - });
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;
542 654 }
543 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 +
544 663 createThumbnails() {
545 664 const container = this.modal.querySelector('.yatra-gallery-thumbnails-container');
546 665 if (!container) return;
547 666
@@ -662,30 +781,57 @@
662 781 if (e.key === 'ArrowLeft') this.prev();
663 782 if (e.key === 'ArrowRight') this.next();
664 783 });
665 784
666 - // Attach click handlers to itinerary gallery links
667 - const galleryLinks = document.querySelectorAll('.yatra-itinerary-gallery-link');
668 - console.log('Attaching listeners to itinerary gallery links:', galleryLinks.length);
669 - galleryLinks.forEach((link) => {
670 - link.addEventListener('click', (e) => {
671 - e.preventDefault();
672 - const imageIndex = parseInt(link.getAttribute('data-image-index') || '0');
673 - console.log('Itinerary gallery link clicked, opening at index:', imageIndex);
674 - this.open(imageIndex);
675 - });
676 - });
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;
677 797
678 - // 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.
679 820 const galleryItems = document.querySelectorAll('.yatra-itinerary-trip-gallery .yatra-gallery-item');
680 821 galleryItems.forEach((item, index) => {
681 822 item.addEventListener('click', () => {
823 + const allImages = this.collectImagesFrom(null); // fall back to global
824 + if (allImages.length === 0) return;
682 825 const img = item.querySelector('img');
683 - if (img) {
684 - const src = img.getAttribute('data-full-size') || getImageSrc(img);
685 - const imageIndex = this.images.findIndex(i => i.src === src);
686 - this.open(imageIndex >= 0 ? imageIndex : index);
687 - }
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);
688 834 });
689 835 });
690 836 }
691 837 }
@@ -840,9 +986,9 @@
840 986 }
841 987
842 988 if (images.length > 0) {
843 989 img.src = images[0];
844 - img.alt = 'Trip Image 1';
990 + img.alt = __('Trip Image 1', 'yatra');
845 991 if (link) {
846 992 link.href = '#';
847 993 link.dataset.imageIndex = '0';
848 994 }
@@ -1060,8 +1206,12 @@
1060 1206 if (existingOverlay) existingOverlay.remove();
1061 1207
1062 1208 const overlay = document.createElement('div');
1063 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');
1064 1214 overlay.innerHTML = `
1065 1215 <div class="yatra-tour-info">
1066 1216 <div class="yatra-tour-icon">
1067 1217 <svg width="80" height="80" viewBox="0 0 100 100" fill="none">
@@ -1074,11 +1224,11 @@
1074 1224 <text x="50" y="58" text-anchor="middle" fill="white" font-size="24" font-weight="bold">360°</text>
1075 1225 </svg>
1076 1226 </div>
1077 1227 <div class="yatra-tour-details">
1078 - <div class="yatra-tour-title">${tour.title || '360° Virtual Tour'}</div>
1079 - <div class="yatra-tour-type">${tour.tour_type || '360°'} Experience</div>
1080 - <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>
1081 1231 </div>
1082 1232 </div>
1083 1233 `;
1084 1234
@@ -1116,8 +1266,9 @@
1116 1266 if (existingOverlay) existingOverlay.remove();
1117 1267
1118 1268 const overlay = document.createElement('div');
1119 1269 overlay.className = 'yatra-media-overlay yatra-media-document-overlay';
1270 + const documentFallbackTitle = __('Document', 'yatra');
1120 1271 overlay.innerHTML = `
1121 1272 <div class="yatra-document-info">
1122 1273 <div class="yatra-document-icon">
1123 1274 <svg width="48" height="48" viewBox="0 0 24 24" fill="none">
@@ -1127,9 +1278,9 @@
1127 1278 <path d="M16 17H8" stroke="#374151" stroke-width="2"/>
1128 1279 </svg>
1129 1280 </div>
1130 1281 <div class="yatra-document-details">
1131 - <div class="yatra-document-title">${doc.title || 'Document'}</div>
1282 + <div class="yatra-document-title">${doc.title || documentFallbackTitle}</div>
1132 1283 <div class="yatra-document-size">${this.formatFileSize(doc.file_size || 0)}</div>
1133 1284 </div>
1134 1285 </div>
1135 1286 `;
@@ -1136,11 +1287,11 @@
1136 1287 container.appendChild(overlay);
1137 1288 }
1138 1289
1139 1290 formatFileSize(bytes) {
1140 - if (bytes === 0) return '0 Bytes';
1291 + if (bytes === 0) return '0 ' + __('Bytes', 'yatra');
1141 1292 const k = 1024;
1142 - const sizes = ['Bytes', 'KB', 'MB', 'GB'];
1293 + const sizes = [__('Bytes', 'yatra'), 'KB', 'MB', 'GB'];
1143 1294 const i = Math.floor(Math.log(bytes) / Math.log(k));
1144 1295 return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
1145 1296 }
1146 1297
@@ -1170,9 +1321,10 @@
1170 1321 sideImages.forEach((item, index) => {
1171 1322 const img = item.querySelector('img');
1172 1323 if (img && images[index + 1]) {
1173 1324 img.src = images[index + 1];
1174 - 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);
1175 1327 }
1176 1328 });
1177 1329 }
1178 1330
@@ -1177,8 +1329,9 @@
1177 1329 }
1178 1330
1179 1331 updateSideVideos(sideImages) {
1180 1332 const videos = this.mediaData.videos || [];
1333 + const fallbackVideoTitle = __('Trip Video', 'yatra');
1181 1334 sideImages.forEach((item, index) => {
1182 1335 const img = item.querySelector('img');
1183 1336 if (img && videos[index + 1]) {
1184 1337 const video = videos[index + 1];
@@ -1184,9 +1337,9 @@
1184 1337 const video = videos[index + 1];
1185 1338 if (video.thumbnail) {
1186 1339 img.src = video.thumbnail;
1187 1340 }
1188 - img.alt = video.title || 'Trip Video';
1341 + img.alt = video.title || fallbackVideoTitle;
1189 1342 }
1190 1343 });
1191 1344 }
1192 1345
@@ -1191,8 +1344,9 @@
1191 1344 }
1192 1345
1193 1346 updateSideYoutubeVideos(sideImages) {
1194 1347 const youtubeVideos = this.mediaData.youtube_videos || [];
1348 + const fallbackYoutubeTitle = __('YouTube Video', 'yatra');
1195 1349 sideImages.forEach((item, index) => {
1196 1350 const img = item.querySelector('img');
1197 1351 if (img && youtubeVideos[index + 1]) {
1198 1352 const video = youtubeVideos[index + 1];
@@ -1198,9 +1352,9 @@
1198 1352 const video = youtubeVideos[index + 1];
1199 1353 if (video.thumbnail) {
1200 1354 img.src = video.thumbnail;
1201 1355 }
1202 - img.alt = video.title || 'YouTube Video';
1356 + img.alt = video.title || fallbackYoutubeTitle;
1203 1357 }
1204 1358 });
1205 1359 }
1206 1360
@@ -1205,8 +1359,9 @@
1205 1359 }
1206 1360
1207 1361 updateSideTours(sideImages) {
1208 1362 const virtualTours = this.mediaData.virtual_tours || [];
1363 + const fallbackTourTitle = __('360° Virtual Tour', 'yatra');
1209 1364 sideImages.forEach((item, index) => {
1210 1365 const img = item.querySelector('img');
1211 1366 if (img && virtualTours[index + 1]) {
1212 1367 const tour = virtualTours[index + 1];
@@ -1212,9 +1367,9 @@
1212 1367 const tour = virtualTours[index + 1];
1213 1368 if (tour.thumbnail) {
1214 1369 img.src = tour.thumbnail;
1215 1370 }
1216 - img.alt = tour.title || '360° Virtual Tour';
1371 + img.alt = tour.title || fallbackTourTitle;
1217 1372 }
1218 1373 });
1219 1374 }
1220 1375
@@ -1219,14 +1374,15 @@
1219 1374 }
1220 1375
1221 1376 updateSideDocuments(sideImages) {
1222 1377 const documents = this.mediaData.documents || [];
1378 + const fallbackDocumentTitle = __('Trip Document', 'yatra');
1223 1379 sideImages.forEach((item, index) => {
1224 1380 const img = item.querySelector('img');
1225 1381 if (img && documents[index + 1]) {
1226 1382 const doc = documents[index + 1];
1227 1383 // Keep existing image for documents
1228 - img.alt = doc.title || 'Trip Document';
1384 + img.alt = doc.title || fallbackDocumentTitle;
1229 1385 }
1230 1386 });
1231 1387 }
1232 1388
@@ -1339,10 +1495,29 @@
1339 1495 }
1340 1496 }
1341 1497
1342 1498 initDateField() {
1343 - if (!this.dateInput || typeof flatpickr === 'undefined') return;
1499 + if (!this.dateInput) return;
1344 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 +
1345 1520 const today = new Date();
1346 1521 today.setHours(0, 0, 0, 0);
1347 1522
1348 1523 // Format today's date as Y-m-d
@@ -1351,9 +1526,9 @@
1351 1526 String(today.getDate()).padStart(2, '0');
1352 1527
1353 1528 // Set today's date as default value
1354 1529 this.dateInput.value = todayStr;
1355 -
1530 +
1356 1531 // Enable button since we have a default date
1357 1532 if (this.checkAvailabilityBtn) {
1358 1533 this.checkAvailabilityBtn.disabled = false;
1359 1534 this.checkAvailabilityBtn.classList.remove('disabled');
@@ -1378,11 +1553,9 @@
1378 1553 allowInput: false,
1379 1554 static: false,
1380 1555 monthSelectorType: 'static',
1381 1556 animate: true,
1382 - locale: {
1383 - firstDayOfWeek: 1
1384 - },
1557 + locale: getFlatpickrLocale(),
1385 1558 onChange: (selectedDates, dateStr) => {
1386 1559 this.dateInput.value = dateStr;
1387 1560
1388 1561 // Enable Check Availability button when date is selected
@@ -1436,10 +1609,10 @@
1436 1609 if (!minusBtn || !plusBtn) return;
1437 1610
1438 1611 const updateButtonStates = () => {
1439 1612 const current = parseInt(input.value) || 1;
1440 - const min = parseInt(input.getAttribute('min')) || 1;
1441 - 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);
1442 1615
1443 1616 minusBtn.disabled = current <= min;
1444 1617 plusBtn.disabled = current >= max;
1445 1618 };
@@ -1449,9 +1622,9 @@
1449 1622 e.preventDefault();
1450 1623 e.stopPropagation();
1451 1624
1452 1625 const current = parseInt(input.value) || 1;
1453 - const min = parseInt(input.getAttribute('min')) || 1;
1626 + const min = window.yatraNumOr(input.getAttribute('min'), 1);
1454 1627
1455 1628 if (current > min) {
1456 1629 input.value = current - 1;
1457 1630 updateButtonStates();
@@ -1464,9 +1637,9 @@
1464 1637 e.preventDefault();
1465 1638 e.stopPropagation();
1466 1639
1467 1640 const current = parseInt(input.value) || 1;
1468 - const max = parseInt(input.getAttribute('max')) || 20;
1641 + const max = window.yatraNumOr(input.getAttribute('max'), 20);
1469 1642
1470 1643 if (current < max) {
1471 1644 input.value = current + 1;
1472 1645 updateButtonStates();
@@ -1540,21 +1713,25 @@
1540 1713 const parts = [];
1541 1714 travelerInputs.forEach((input) => {
1542 1715 const value = parseInt(input.value || 0);
1543 1716 if (value > 0) {
1544 - const categoryLabel = input.getAttribute('data-category-label') || 'Traveler';
1717 + const categoryLabel = input.getAttribute('data-category-label') || __('Traveler', 'yatra');
1545 1718 parts.push(`${categoryLabel} x ${value}`);
1546 1719 }
1547 1720 });
1548 1721
1549 1722 if (parts.length === 0) {
1550 - // 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.
1551 1727 const firstInput = travelerInputs[0];
1552 1728 if (firstInput) {
1553 - const categoryLabel = firstInput.getAttribute('data-category-label') || 'Traveler';
1554 - 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);
1555 1732 } else {
1556 - display.textContent = '1 Traveler';
1733 + display.textContent = __('1 Traveler', 'yatra');
1557 1734 }
1558 1735 } else {
1559 1736 display.textContent = parts.join(', ');
1560 1737 }
@@ -1648,9 +1825,9 @@
1648 1825 categoryInputs.forEach((catInput) => {
1649 1826 const catValue = parseInt(catInput.value) || 0;
1650 1827 if (catValue > 0) {
1651 1828 const catRow = catInput.closest('.yatra-quantity-row');
1652 - const categoryLabel = catRow ? catRow.querySelector('.yatra-quantity-title')?.textContent : 'Traveler';
1829 + const categoryLabel = catRow ? catRow.querySelector('.yatra-quantity-title')?.textContent : __('Traveler', 'yatra');
1653 1830 parts.push(`${categoryLabel} x ${catValue}`);
1654 1831 }
1655 1832 });
1656 1833 if (parts.length > 0) {
@@ -1659,8 +1836,11 @@
1659 1836 }
1660 1837 }
1661 1838 });
1662 1839 });
1840 + if (window.availabilitySection && typeof window.availabilitySection.updateAllCardTotals === 'function') {
1841 + window.availabilitySection.updateAllCardTotals();
1842 + }
1663 1843 };
1664 1844
1665 1845 // Close dropdown when clicking outside
1666 1846 const closeHandler = (e) => {
@@ -1722,14 +1902,14 @@
1722 1902
1723 1903
1724 1904 // Basic validation
1725 1905 if (!date) {
1726 - alert('Please select a travel date');
1906 + alert(__('Please select a travel date', 'yatra'));
1727 1907 return;
1728 1908 }
1729 1909
1730 1910 if (totalTravelers === 0) {
1731 - alert('Please select number of travelers');
1911 + alert(__('Please select number of travelers', 'yatra'));
1732 1912 return;
1733 1913 }
1734 1914
1735 1915 // Show loading state
@@ -1736,9 +1916,17 @@
1736 1916 if (this.checkAvailabilityBtn && !this.checkAvailabilityBtn.dataset.originalHtml) {
1737 1917 this.checkAvailabilityBtn.dataset.originalHtml = this.checkAvailabilityBtn.innerHTML;
1738 1918 }
1739 1919 if (this.checkAvailabilityBtn) {
1740 - 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');
1741 1929 this.checkAvailabilityBtn.disabled = true;
1742 1930 }
1743 1931
1744 1932 // Get trip ID from data attribute or global variable
@@ -1778,11 +1966,12 @@
1778 1966 if (!response.ok) {
1779 1967 // Try to get error message from response
1780 1968 return response.json().then(err => {
1781 1969 console.error('API Error:', err);
1782 - throw new Error(err.message || 'Failed to fetch availability');
1970 + throw new Error(err.message || __('Failed to fetch availability', 'yatra'));
1783 1971 }).catch(() => {
1784 - 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));
1785 1974 });
1786 1975 }
1787 1976 return response.json();
1788 1977 })
@@ -1846,9 +2035,9 @@
1846 2035 this.resetButton();
1847 2036 })
1848 2037 .catch(error => {
1849 2038 console.error('Error loading availability:', error);
1850 - alert('Failed to load availability. Please try again.');
2039 + alert(__('Failed to load availability. Please try again.', 'yatra'));
1851 2040 this.resetButton();
1852 2041 });
1853 2042 }
1854 2043
@@ -1897,13 +2086,14 @@
1897 2086 // Create new menu item
1898 2087 const newItem = document.createElement('a');
1899 2088 newItem.href = '#availability';
1900 2089 newItem.className = 'yatra-sticky-nav-item';
2090 + const availabilityLabel = __('Availability', 'yatra');
1901 2091 newItem.innerHTML = `
1902 2092 <svg class="yatra-icon-sm" fill="none" stroke="currentColor" viewBox="0 0 24 24">
1903 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"/>
1904 2094 </svg>
1905 - <span>Availability</span>
2095 + <span>${availabilityLabel}</span>
1906 2096 `;
1907 2097
1908 2098 // Insert before the target item (or after "What's Included" if no target)
1909 2099 if (insertBeforeItem && insertBeforeItem.parentNode) {
@@ -1970,9 +2160,9 @@
1970 2160 if (this.checkAvailabilityBtn && !this.checkAvailabilityBtn.dataset.originalHtml) {
1971 2161 this.checkAvailabilityBtn.dataset.originalHtml = this.checkAvailabilityBtn.innerHTML;
1972 2162 }
1973 2163 if (this.checkAvailabilityBtn) {
1974 - 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');
1975 2165 this.checkAvailabilityBtn.disabled = true;
1976 2166 }
1977 2167
1978 2168 const availSection = document.getElementById('availability');
@@ -1996,9 +2186,9 @@
1996 2186 credentials: 'omit',
1997 2187 })
1998 2188 .then(response => {
1999 2189 if (!response.ok) {
2000 - throw new Error('Failed to fetch availability');
2190 + throw new Error(__('Failed to fetch availability', 'yatra'));
2001 2191 }
2002 2192 return response.json();
2003 2193 })
2004 2194 .then(data => {
@@ -2033,9 +2223,9 @@
2033 2223 this.resetButton();
2034 2224 })
2035 2225 .catch(error => {
2036 2226 console.error('Error reloading availability:', error);
2037 - alert('Failed to reload availability. Please try again.');
2227 + alert(__('Failed to reload availability. Please try again.', 'yatra'));
2038 2228 this.resetButton();
2039 2229 });
2040 2230 }
2041 2231
@@ -2133,9 +2323,13 @@
2133 2323 if (data && data.success) {
2134 2324 // Update departures available text
2135 2325 const departuresText = document.querySelector('.yatra-departures-available-text');
2136 2326 if (departuresText && data.departures_count !== undefined) {
2137 - 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 + );
2138 2332 }
2139 2333
2140 2334 // Update traveler dropdown HTML
2141 2335 const quantitySelector = document.getElementById('quantity-selector');
@@ -2189,15 +2383,15 @@
2189 2383 const row = travelerSelector.querySelector('.yatra-quantity-row[data-category-id="' + categoryId + '"]');
2190 2384 if (row) {
2191 2385 const cardInput = row.querySelector('input[type="number"]');
2192 2386 if (cardInput) {
2193 - const max = parseInt(cardInput.getAttribute('max')) || 99;
2387 + const max = window.yatraNumOr(cardInput.getAttribute('max'), 99);
2194 2388 cardInput.value = Math.min(value, max);
2195 2389
2196 2390 // Update button states
2197 2391 const minusBtn = row.querySelector('.yatra-quantity-minus');
2198 2392 const plusBtn = row.querySelector('.yatra-quantity-plus');
2199 - const min = parseInt(cardInput.getAttribute('min')) || 0;
2393 + const min = window.yatraNumOr(cardInput.getAttribute('min'), 0);
2200 2394 if (minusBtn) minusBtn.disabled = cardInput.value <= min;
2201 2395 if (plusBtn) plusBtn.disabled = cardInput.value >= max;
2202 2396 }
2203 2397 }
@@ -2208,9 +2402,9 @@
2208 2402
2209 2403 } else if (simpleTravelers && numTravelersInput) {
2210 2404 // Simple pricing - sync total count
2211 2405 const totalCount = parseInt(numTravelersInput.value) || 1;
2212 - const max = parseInt(simpleTravelers.getAttribute('max')) || 99;
2406 + const max = window.yatraNumOr(simpleTravelers.getAttribute('max'), 99);
2213 2407 simpleTravelers.value = Math.min(totalCount, max);
2214 2408
2215 2409 // Update button states
2216 2410 const controls = simpleTravelers.closest('.yatra-quantity-controls-inline');
@@ -2216,9 +2410,9 @@
2216 2410 const controls = simpleTravelers.closest('.yatra-quantity-controls-inline');
2217 2411 if (controls) {
2218 2412 const minusBtn = controls.querySelector('.yatra-quantity-minus');
2219 2413 const plusBtn = controls.querySelector('.yatra-quantity-plus');
2220 - const min = parseInt(simpleTravelers.getAttribute('min')) || 1;
2414 + const min = window.yatraNumOr(simpleTravelers.getAttribute('min'), 1);
2221 2415 if (minusBtn) minusBtn.disabled = simpleTravelers.value <= min;
2222 2416 if (plusBtn) plusBtn.disabled = simpleTravelers.value >= max;
2223 2417 }
2224 2418 }
@@ -2251,9 +2445,9 @@
2251 2445 }
2252 2446 }
2253 2447 });
2254 2448
2255 - display.textContent = parts.length > 0 ? parts.join(', ') : '0 Travelers';
2449 + display.textContent = parts.length > 0 ? parts.join(', ') : __('0 Travelers', 'yatra');
2256 2450 }
2257 2451
2258 2452 /**
2259 2453 * Update availability card total price
@@ -2258,8 +2452,13 @@
2258 2452 /**
2259 2453 * Update availability card total price
2260 2454 */
2261 2455 updateAvailabilityCardTotals(itemId) {
2456 + if (window.availabilitySection && typeof window.availabilitySection.updateTotalPrice === 'function') {
2457 + window.availabilitySection.updateTotalPrice(itemId);
2458 + return;
2459 + }
2460 +
2262 2461 const totalAmountEl = document.querySelector('.yatra-card-total-amount[data-item="' + itemId + '"]');
2263 2462 const totalNoteEl = document.querySelector('.yatra-card-total-note[data-item="' + itemId + '"]');
2264 2463 if (!totalAmountEl) return;
2265 2464
@@ -2276,13 +2475,17 @@
2276 2475 const input = row.querySelector('input[type="number"]');
2277 2476 const price = parseFloat(row.getAttribute('data-price')) || 0;
2278 2477 const count = parseInt(input?.value) || 0;
2279 2478 const pricingMode = row.getAttribute('data-pricing-mode') || (input ? input.getAttribute('data-pricing-mode') : '') || 'per_person';
2280 -
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 +
2281 2482 if (pricingMode === 'per_group') {
2282 - // Per group: charge flat price once if any travelers
2483 + // Per group: one flat price, or per block of maxPax when "per_block".
2283 2484 if (count > 0) {
2284 - total += price;
2485 + total += (groupOverflow === 'per_block' && maxPax > 0)
2486 + ? price * Math.ceil(count / maxPax)
2487 + : price;
2285 2488 }
2286 2489 } else {
2287 2490 // Per person: charge per traveler
2288 2491 total += price * count;
@@ -2311,9 +2514,10 @@
2311 2514 totalAmountEl.textContent = String(total.toFixed(2));
2312 2515 }
2313 2516
2314 2517 if (totalNoteEl) {
2315 - 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);
2316 2520 }
2317 2521
2318 2522 // Update the card participants display
2319 2523 this.updateAvailabilityCardDisplay(itemId);
@@ -2458,11 +2662,9 @@
2458 2662 allowInput: false,
2459 2663 static: false,
2460 2664 monthSelectorType: 'static',
2461 2665 animate: true,
2462 - locale: {
2463 - firstDayOfWeek: 1
2464 - },
2666 + locale: getFlatpickrLocale(),
2465 2667 onChange: (selectedDates, dateStr) => {
2466 2668 this.dateInput.value = dateStr;
2467 2669 }
2468 2670 });
@@ -2516,17 +2718,22 @@
2516 2718 // Update display function
2517 2719 const updateDisplay = () => {
2518 2720 const adults = parseInt(adultsInput.value || 1);
2519 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);
2520 2726 let text = '';
2521 2727 if (adults > 0 && children > 0) {
2522 - 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);
2523 2730 } else if (adults > 0) {
2524 - text = `Adult x ${adults}`;
2731 + text = adultPart;
2525 2732 } else if (children > 0) {
2526 - text = `Child x ${children}`;
2733 + text = childPart;
2527 2734 } else {
2528 - text = 'Adult x 1';
2735 + text = sprintf(_n('Adult x %d', 'Adult x %d', 1, 'yatra'), 1);
2529 2736 }
2530 2737 display.textContent = text;
2531 2738 };
2532 2739
@@ -2612,9 +2819,9 @@
2612 2819 const email = formData.get('email');
2613 2820 const message = formData.get('message');
2614 2821
2615 2822 if (!name || !email || !message) {
2616 - this.showMessage('error', 'Please fill in all required fields.');
2823 + this.showMessage('error', __('Please fill in all required fields.', 'yatra'));
2617 2824 return;
2618 2825 }
2619 2826
2620 2827 // Basic email validation
@@ -2619,22 +2826,22 @@
2619 2826
2620 2827 // Basic email validation
2621 2828 const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
2622 2829 if (!emailRegex.test(email)) {
2623 - this.showMessage('error', 'Please enter a valid email address.');
2830 + this.showMessage('error', __('Please enter a valid email address.', 'yatra'));
2624 2831 return;
2625 2832 }
2626 2833
2627 2834 if (message.length < 10) {
2628 - this.showMessage('error', 'Please enter a message (at least 10 characters).');
2835 + this.showMessage('error', __('Please enter a message (at least 10 characters).', 'yatra'));
2629 2836 return;
2630 2837 }
2631 2838
2632 2839 // Show loading
2633 2840 const submitBtn = this.form.querySelector('.yatra-enquiry-submit');
2634 - const originalText = submitBtn ? submitBtn.textContent : 'Send Enquiry';
2841 + const originalText = submitBtn ? submitBtn.textContent : __('Send Enquiry', 'yatra');
2635 2842 if (submitBtn) {
2636 - submitBtn.textContent = 'Sending...';
2843 + submitBtn.textContent = __('Sending...', 'yatra');
2637 2844 submitBtn.disabled = true;
2638 2845 }
2639 2846
2640 2847 // Hide any existing messages
@@ -2643,8 +2850,15 @@
2643 2850 }
2644 2851
2645 2852 // Submit via REST API (public endpoint)
2646 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 + }
2647 2861
2648 2862 // Build REST URL
2649 2863 const baseUrlRaw = (window.yatraTripData?.apiUrl || window.yatraTripData?.restUrl || window.wpApiSettings?.root || '/wp-json').replace(/\/$/, '');
2650 2864 const enquiriesUrl = baseUrlRaw.includes('/yatra/v1')
@@ -2650,26 +2864,38 @@
2650 2864 const enquiriesUrl = baseUrlRaw.includes('/yatra/v1')
2651 2865 ? `${baseUrlRaw}/enquiries`
2652 2866 : `${baseUrlRaw}/yatra/v1/enquiries`;
2653 2867
2654 - fetch(enquiriesUrl, {
2655 - method: 'POST',
2656 - headers: {
2657 - 'Content-Type': 'application/json',
2658 - },
2659 - credentials: 'omit', // public endpoint
2660 - body: JSON.stringify(payload),
2661 - })
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 + })
2662 2888 .then(response => response.json())
2663 2889 .then(result => {
2664 2890 if (result.success) {
2665 - 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'));
2666 2892 this.form.reset();
2667 2893
2668 2894 // Reset the travelers display
2669 2895 const display = document.getElementById('enquiry-participants-display');
2670 2896 if (display) {
2671 - display.textContent = 'Adult x 1';
2897 + display.textContent = __('Adult x 1', 'yatra');
2672 2898 }
2673 2899 const adultsInput = document.getElementById('enquiry-adults');
2674 2900 const childrenInput = document.getElementById('enquiry-children');
2675 2901 if (adultsInput) adultsInput.value = 1;
@@ -2682,14 +2908,14 @@
2682 2908 messageBox.style.display = 'none';
2683 2909 }
2684 2910 }, 3000);
2685 2911 } else {
2686 - 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'));
2687 2913 }
2688 2914 })
2689 2915 .catch(error => {
2690 2916 console.error('Enquiry submission error:', error);
2691 - this.showMessage('error', 'An error occurred. Please try again later.');
2917 + this.showMessage('error', __('An error occurred. Please try again later.', 'yatra'));
2692 2918 })
2693 2919 .finally(() => {
2694 2920 if (submitBtn) {
2695 2921 submitBtn.textContent = originalText;
@@ -2754,21 +2980,33 @@
2754 2980 this.updateSidebarPosition(); // Initial check
2755 2981 }
2756 2982
2757 2983 getActualSections() {
2758 - // Base sections that are always present in order
2759 - const baseSections = ['overview', 'itinerary', 'included', 'location', 'important-info'];
2760 -
2761 - // Conditional sections that may or may not be present
2762 - const conditionalSections = ['downloads', 'faq', 'trip-story', 'what-makes-special', 'testimonials'];
2763 -
2764 - // Combine all sections and filter to only include those that actually exist on the page
2765 - const allSections = [...baseSections, ...conditionalSections];
2766 -
2767 - return allSections.filter(sectionId => {
2768 - const element = document.getElementById(sectionId);
2769 - 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 + }
2770 3007 });
3008 + return ids;
2771 3009 }
2772 3010
2773 3011 attachEventListeners() {
2774 3012 // Handle clicks on nav items
@@ -2940,8 +3178,15 @@
2940 3178 this.lastScrollTop = scrollTop;
2941 3179 }
2942 3180
2943 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.
2944 3189 const scrollPos = window.scrollY + 150;
2945 3190 let activeSectionId = null;
2946 3191
2947 3192 // Find which section is currently in view
@@ -2947,10 +3192,11 @@
2947 3192 // Find which section is currently in view
2948 3193 this.sections.forEach((sectionId) => {
2949 3194 const section = document.getElementById(sectionId);
2950 3195 if (section) {
2951 - const sectionTop = section.offsetTop;
2952 - const sectionBottom = sectionTop + section.offsetHeight;
3196 + const rect = section.getBoundingClientRect();
3197 + const sectionTop = rect.top + window.pageYOffset;
3198 + const sectionBottom = sectionTop + rect.height;
2953 3199
2954 3200 if (scrollPos >= sectionTop && scrollPos < sectionBottom) {
2955 3201 activeSectionId = sectionId;
2956 3202 }
@@ -3097,14 +3343,14 @@
3097 3343 }
3098 3344 }
3099 3345
3100 3346 if (travelers < 1) {
3101 - this.showBookingError(btn, 'Please select at least 1 traveler to continue.');
3347 + this.showBookingError(btn, __('Please select at least 1 traveler to continue.', 'yatra'));
3102 3348 return;
3103 3349 }
3104 3350
3105 3351 const originalText = btn.innerHTML;
3106 - 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');
3107 3353 btn.disabled = true;
3108 3354
3109 3355 if (!tripId || isNaN(parseInt(tripId)) || parseInt(tripId) <= 0) {
3110 3356 console.error('Invalid trip ID:', tripId);
@@ -3109,9 +3355,9 @@
3109 3355 if (!tripId || isNaN(parseInt(tripId)) || parseInt(tripId) <= 0) {
3110 3356 console.error('Invalid trip ID:', tripId);
3111 3357 btn.innerHTML = originalText;
3112 3358 btn.disabled = false;
3113 - this.showBookingError(btn, 'Unable to process booking. Invalid trip data.');
3359 + this.showBookingError(btn, __('Unable to process booking. Invalid trip data.', 'yatra'));
3114 3360 return;
3115 3361 }
3116 3362
3117 3363 const pricingType = btn.getAttribute('data-pricing-type') || 'regular';
@@ -3332,9 +3578,9 @@
3332 3578 currency: window.yatraBookingData?.currency || 'USD',
3333 3579 num_items: sessionPayload.travelers || 1
3334 3580 });
3335 3581
3336 - 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);
3337 3583 }
3338 3584
3339 3585 // Track Book Now click as begin_checkout (Google Analytics 4)
3340 3586 if (typeof gtag !== 'undefined' && window.yatraAdmin?.googleAnalytics?.measurementId) {
@@ -3352,9 +3598,9 @@
3352 3598 quantity: sessionPayload.travelers || 1
3353 3599 }]
3354 3600 });
3355 3601
3356 - 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);
3357 3603 }
3358 3604
3359 3605 // Set booking session via REST API
3360 3606 // credentials: 'same-origin' is required to send cookies for session
@@ -3385,9 +3631,9 @@
3385 3631 // Error - show message and reset button
3386 3632 console.error('Booking session error:', data);
3387 3633 btn.innerHTML = originalText;
3388 3634 btn.disabled = false;
3389 - 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'));
3390 3636 }
3391 3637 })
3392 3638 .catch(error => {
3393 3639 console.error('Error setting booking session:', error);
@@ -3392,9 +3638,9 @@
3392 3638 .catch(error => {
3393 3639 console.error('Error setting booking session:', error);
3394 3640 btn.innerHTML = originalText;
3395 3641 btn.disabled = false;
3396 - this.showBookingError(btn, 'Unable to process booking. Please try again.');
3642 + this.showBookingError(btn, __('Unable to process booking. Please try again.', 'yatra'));
3397 3643 });
3398 3644 }
3399 3645
3400 3646 initTravelerSelectors() {
@@ -3426,18 +3672,21 @@
3426 3672 categoryInputs.forEach((input) => {
3427 3673 const value = parseInt(input.value) || 0;
3428 3674 if (value > 0) {
3429 3675 const row = input.closest('.yatra-quantity-row');
3430 - const categoryLabel = row ? row.querySelector('.yatra-quantity-title')?.textContent : 'Traveler';
3676 + const categoryLabel = row ? row.querySelector('.yatra-quantity-title')?.textContent : __('Traveler', 'yatra');
3431 3677 parts.push(`${categoryLabel} x ${value}`);
3432 3678 }
3433 3679 });
3434 3680
3435 3681 if (parts.length === 0) {
3436 - // 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.
3437 3685 const firstRow = categoryInputs[0]?.closest('.yatra-quantity-row');
3438 - const firstLabel = firstRow ? firstRow.querySelector('.yatra-quantity-title')?.textContent : 'Traveler';
3439 - 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);
3440 3689 } else {
3441 3690 display.textContent = parts.join(', ');
3442 3691 }
3443 3692 };
@@ -3459,10 +3708,10 @@
3459 3708 const isMinus = btn.classList.contains('yatra-quantity-minus');
3460 3709 const isPlus = btn.classList.contains('yatra-quantity-plus');
3461 3710
3462 3711 let current = parseInt(input.value) || 0;
3463 - const min = parseInt(input.getAttribute('min')) || 0;
3464 - 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);
3465 3714 let newValue = current;
3466 3715
3467 3716 if (isPlus && current < max) {
3468 3717 newValue = current + 1;
@@ -3534,10 +3783,10 @@
3534 3783 const isMinus = btn.classList.contains('yatra-quantity-minus');
3535 3784 const isPlus = btn.classList.contains('yatra-quantity-plus');
3536 3785
3537 3786 let current = parseInt(input.value) || 1;
3538 - const min = parseInt(input.getAttribute('min')) || 1;
3539 - 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);
3540 3789 let newValue = current;
3541 3790
3542 3791 if (isPlus && current < max) {
3543 3792 newValue = current + 1;
@@ -3609,9 +3858,9 @@
3609 3858 categoryInputs.forEach((input) => {
3610 3859 const value = parseInt(input.value) || 0;
3611 3860 if (value > 0) {
3612 3861 const row = input.closest('.yatra-quantity-row');
3613 - const categoryLabel = row ? row.querySelector('.yatra-quantity-title')?.textContent : 'Traveler';
3862 + const categoryLabel = row ? row.querySelector('.yatra-quantity-title')?.textContent : __('Traveler', 'yatra');
3614 3863 parts.push(`${categoryLabel} x ${value}`);
3615 3864
3616 3865 // Also update sidebar inputs if they exist
3617 3866 const categoryId = input.getAttribute('data-category');
@@ -3647,8 +3896,9 @@
3647 3896 // Check for traveler-based pricing first
3648 3897 const categoryInputs = this.section.querySelectorAll(`.yatra-availability-category[data-item="${itemIndex}"]`);
3649 3898 let totalPrice = 0;
3650 3899 let totalTravelers = 0;
3900 + let totalGroups = 0; // per-group categories: number of group prices charged
3651 3901
3652 3902 if (categoryInputs.length > 0) {
3653 3903 // Traveler-based pricing: sum up each category's price * quantity (respecting pricing mode)
3654 3904 categoryInputs.forEach((input) => {
@@ -3655,12 +3905,16 @@
3655 3905 const quantity = parseInt(input.value) || 0;
3656 3906 const row = input.closest('.yatra-quantity-row');
3657 3907 const price = row ? parseFloat(row.getAttribute('data-price')) || 0 : 0;
3658 3908 const pricingMode = (row ? row.getAttribute('data-pricing-mode') : null) || input.getAttribute('data-pricing-mode') || 'per_person';
3659 -
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 +
3660 3912 if (pricingMode === 'per_group') {
3661 3913 if (quantity > 0) {
3662 - totalPrice += price;
3914 + const blocks = (groupOverflow === 'per_block' && maxPax > 0) ? Math.ceil(quantity / maxPax) : 1;
3915 + totalPrice += price * blocks;
3916 + totalGroups += blocks;
3663 3917 }
3664 3918 } else {
3665 3919 totalPrice += quantity * price;
3666 3920 }
@@ -3688,16 +3942,41 @@
3688 3942 } else {
3689 3943 totalAmountElement.textContent = String(finalPrice.toFixed(2));
3690 3944 }
3691 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 +
3692 3969 // Update the note text
3693 3970 const noteElement = this.section.querySelector(
3694 3971 `.yatra-card-total-note[data-item="${itemIndex}"]`
3695 3972 );
3696 3973 if (noteElement) {
3697 - let travelerText = totalTravelers === 1
3698 - ? 'for 1 traveler'
3699 - : `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 + );
3700 3979
3701 3980 // Add group discount info if applied
3702 3981 if (groupDiscountApplied && groupDiscountAmount > 0) {
3703 3982 const discountText = (typeof yatra_format_price_js === 'function')
@@ -3702,9 +3981,13 @@
3702 3981 if (groupDiscountApplied && groupDiscountAmount > 0) {
3703 3982 const discountText = (typeof yatra_format_price_js === 'function')
3704 3983 ? yatra_format_price_js(groupDiscountAmount)
3705 3984 : String(groupDiscountAmount.toFixed(2));
3706 - travelerText += ` (Group discount: -${discountText})`;
3985 + travelerText += sprintf(
3986 + /* translators: %s: formatted discount amount. */
3987 + __(' (Group discount: -%s)', 'yatra'),
3988 + discountText
3989 + );
3707 3990 }
3708 3991 noteElement.textContent = travelerText;
3709 3992 }
3710 3993
@@ -3723,23 +4006,9 @@
3723 4006 discountPercentage: 0,
3724 4007 discountType: null
3725 4008 };
3726 4009
3727 - // Get group discounts from the booking card data attribute
3728 - const bookingCard = document.querySelector('.yatra-booking-card');
3729 - if (!bookingCard) return result;
3730 -
3731 - const groupDiscountsData = bookingCard.getAttribute('data-group-discounts');
3732 - if (!groupDiscountsData) return result;
3733 -
3734 - let groupDiscounts;
3735 - try {
3736 - groupDiscounts = JSON.parse(groupDiscountsData);
3737 - } catch (e) {
3738 - console.error('Failed to parse group discounts:', e);
3739 - return result;
3740 - }
3741 -
4010 + const groupDiscounts = window.yatraTripData && window.yatraTripData.sidebarGroupDiscounts;
3742 4011 if (!Array.isArray(groupDiscounts) || groupDiscounts.length === 0) return result;
3743 4012
3744 4013 // Find the applicable discount based on total travelers
3745 4014 let applicableDiscount = null;
@@ -3794,8 +4063,23 @@
3794 4063 return result;
3795 4064 }
3796 4065
3797 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 + /**
3798 4082 * Update group discount badge visibility
3799 4083 */
3800 4084 updateGroupDiscountBadge(itemIndex, discountApplied, discountResult) {
3801 4085 // Find or create the group discount applied badge for this item
@@ -3818,10 +4102,14 @@
3818 4102 }
3819 4103
3820 4104 // Update badge content
3821 4105 const discountText = discountResult.discountType === 'percentage'
3822 - ? `${discountResult.discountPercentage}% group discount applied!`
3823 - : `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');
3824 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}`;
3825 4113 } else if (badge) {
3826 4114 badge.remove();
3827 4115 }
@@ -3901,9 +4189,9 @@
3901 4189 }
3902 4190
3903 4191 // Show success feedback
3904 4192 const originalText = button.textContent;
3905 - button.textContent = 'Selected!';
4193 + button.textContent = __('Selected!', 'yatra');
3906 4194 button.style.background = '#10b981';
3907 4195 setTimeout(() => {
3908 4196 button.textContent = originalText;
3909 4197 button.style.background = '';
@@ -3928,9 +4216,11 @@
3928 4216 const travelDate = travelDateInput && travelDateInput.value ? travelDateInput.value : undefined;
3929 4217
3930 4218 const originalHTML = btn.innerHTML;
3931 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> ';
3932 - 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');
3933 4223 btn.disabled = true;
3934 4224
3935 4225 const url = buildAvailabilityTemplateUrl(tripId, {
3936 4226 date: travelDate,
@@ -3949,9 +4239,9 @@
3949 4239 credentials: 'omit',
3950 4240 })
3951 4241 .then((resp) => {
3952 4242 if (!resp.ok) {
3953 - throw new Error('Load more failed');
4243 + throw new Error(__('Load more failed', 'yatra'));
3954 4244 }
3955 4245 return resp.json();
3956 4246 })
3957 4247 .then((data) => {
@@ -3988,9 +4278,11 @@
3988 4278 return;
3989 4279 }
3990 4280
3991 4281 const remaining = Math.max(0, total - displayed);
3992 - 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}`;
3993 4285 btn.disabled = false;
3994 4286
3995 4287 loadMoreContainer.dataset.currentPage = String(nextPage);
3996 4288 loadMoreContainer.dataset.loaded = String(displayed);
@@ -3996,9 +4288,17 @@
3996 4288 loadMoreContainer.dataset.loaded = String(displayed);
3997 4289 loadMoreContainer.dataset.total = String(total);
3998 4290
3999 4291 if (countInfo) {
4000 - 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 + );
4001 4301 }
4002 4302 })
4003 4303 .catch((err) => {
4004 4304 console.error('[Yatra] Load more failed:', err);
@@ -4162,9 +4462,9 @@
4162 4462 if (!this.toggleAllBtn) return;
4163 4463 this.toggleAllBtn.classList.toggle('is-all-expanded', isExpanded);
4164 4464 const toggleText = this.toggleAllBtn.querySelector('.toggle-text');
4165 4465 if (toggleText) {
4166 - toggleText.textContent = isExpanded ? 'Collapse All' : 'Expand All';
4466 + toggleText.textContent = isExpanded ? __('Collapse All', 'yatra') : __('Expand All', 'yatra');
4167 4467 }
4168 4468 }
4169 4469 }
4170 4470
@@ -4277,9 +4577,10 @@
4277 4577 const originalText = submitBtn.innerHTML;
4278 4578
4279 4579 // Disable button and show loading
4280 4580 submitBtn.disabled = true;
4281 - submitBtn.innerHTML = '<span class="yatra-loading-spinner"></span> Submitting...';
4581 + submitBtn.innerHTML =
4582 + '<span class="yatra-loading-spinner"></span> ' + __('Submitting...', 'yatra');
4282 4583
4283 4584 try {
4284 4585 const formData = new FormData(this.form);
4285 4586 formData.append('action', 'yatra_submit_review');
@@ -4293,9 +4594,9 @@
4293 4594 const result = await response.json();
4294 4595
4295 4596 if (result.success) {
4296 4597 // Show success message
4297 - this.showMessage('success', result.data?.message || 'Thank you for your review!');
4598 + this.showMessage('success', result.data?.message || __('Thank you for your review!', 'yatra'));
4298 4599
4299 4600 // Check if this was an edit or new submission
4300 4601 const isEdit = this.form.querySelector('input[name="action_type"]')?.value === 'edit';
4301 4602
@@ -4312,13 +4613,13 @@
4312 4613 ratingLabels.forEach(label => label.classList.add('selected'));
4313 4614 }
4314 4615 }
4315 4616 } else {
4316 - 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'));
4317 4618 }
4318 4619 } catch (error) {
4319 4620 console.error('Review submission error:', error);
4320 - this.showMessage('error', 'An error occurred. Please try again.');
4621 + this.showMessage('error', __('An error occurred. Please try again.', 'yatra'));
4321 4622 } finally {
4322 4623 submitBtn.disabled = false;
4323 4624 submitBtn.innerHTML = originalText;
4324 4625 }
@@ -4492,16 +4793,13 @@
4492 4793 * Handles save/remove trip from wishlist functionality
4493 4794 */
4494 4795 class WishlistHandler {
4495 4796 constructor() {
4496 - if (!window.yatraTripData?.wishlistEnabled) {
4497 - return;
4498 - }
4499 - this.apiUrl = window.yatraTripData?.apiUrl || '/wp-json/yatra/v1';
4500 - this.nonce = window.yatraTripData?.nonce || '';
4501 - this.tripId = window.yatraTripData?.tripId || 0;
4502 - this.isLoggedIn = window.yatraTripData?.isLoggedIn || false;
4503 - 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;
4504 4802 }
4505 4803
4506 4804 init() {
4507 4805 // Check if user is logged in and update button states
@@ -4584,15 +4882,15 @@
4584 4882
4585 4883 const data = await response.json();
4586 4884 if (data.success) {
4587 4885 this.updateButtonState(true, tid);
4588 - this.showMessage(data.message || 'Trip saved to wishlist', 'success', btn);
4886 + this.showMessage(data.message || __('Trip saved to wishlist', 'yatra'), 'success', btn);
4589 4887 } else {
4590 - this.showMessage(data.message || 'Failed to save trip', 'error', btn);
4888 + this.showMessage(data.message || __('Failed to save trip', 'yatra'), 'error', btn);
4591 4889 }
4592 4890 } catch (error) {
4593 4891 console.error('Error saving trip:', error);
4594 - this.showMessage('An error occurred. Please try again.', 'error', btn);
4892 + this.showMessage(__('An error occurred. Please try again.', 'yatra'), 'error', btn);
4595 4893 } finally {
4596 4894 btn.disabled = false;
4597 4895 btn.classList.remove('loading');
4598 4896 }
@@ -4614,15 +4912,15 @@
4614 4912
4615 4913 const data = await response.json();
4616 4914 if (data.success) {
4617 4915 this.updateButtonState(false, tid);
4618 - this.showMessage(data.message || 'Trip removed from wishlist', 'success', btn);
4916 + this.showMessage(data.message || __('Trip removed from wishlist', 'yatra'), 'success', btn);
4619 4917 } else {
4620 - this.showMessage(data.message || 'Failed to remove trip', 'error', btn);
4918 + this.showMessage(data.message || __('Failed to remove trip', 'yatra'), 'error', btn);
4621 4919 }
4622 4920 } catch (error) {
4623 4921 console.error('Error removing trip:', error);
4624 - this.showMessage('An error occurred. Please try again.', 'error', btn);
4922 + this.showMessage(__('An error occurred. Please try again.', 'yatra'), 'error', btn);
4625 4923 } finally {
4626 4924 btn.disabled = false;
4627 4925 btn.classList.remove('loading');
4628 4926 }
@@ -4636,9 +4934,9 @@
4636 4934 const buttons = document.querySelectorAll(selector);
4637 4935 buttons.forEach(btn => {
4638 4936 if (isSaved) {
4639 4937 btn.classList.add('saved', 'is-saved');
4640 - btn.setAttribute('aria-label', 'Remove from favorites');
4938 + btn.setAttribute('aria-label', __('Remove from favorites', 'yatra'));
4641 4939 // Fill the heart icon and change color
4642 4940 const svg = btn.querySelector('svg');
4643 4941 if (svg) {
4644 4942 svg.style.fill = 'currentColor';
@@ -4646,9 +4944,9 @@
4646 4944 btn.style.color = '#ef4444';
4647 4945 }
4648 4946 } else {
4649 4947 btn.classList.remove('saved', 'is-saved');
4650 - btn.setAttribute('aria-label', 'Add to favorites');
4948 + btn.setAttribute('aria-label', __('Add to favorites', 'yatra'));
4651 4949 // Unfill the heart icon
4652 4950 const svg = btn.querySelector('svg');
4653 4951 if (svg) {
4654 4952 svg.style.fill = 'none';
@@ -4672,13 +4970,17 @@
4672 4970 if (modalContent) {
4673 4971 const loginMessage = document.createElement('div');
4674 4972 loginMessage.className = 'yatra-login-prompt';
4675 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');
4676 4978 loginMessage.innerHTML = `
4677 - <h3 style="margin-bottom: 20px;">Login Required</h3>
4678 - <p style="margin-bottom: 30px;">Please login to save trips to your wishlist.</p>
4679 - <a href="${window.yatraTripData?.loginUrl || '/wp-login.php'}" class="yatra-btn" style="display: inline-block; margin-right: 10px;">Login</a>
4680 - <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>
4681 4983 `;
4682 4984 modalContent.innerHTML = '';
4683 4985 modalContent.appendChild(loginMessage);
4684 4986 }
@@ -4683,9 +4985,9 @@
4683 4985 modalContent.appendChild(loginMessage);
4684 4986 }
4685 4987 } else {
4686 4988 // Fallback: show alert and redirect to login
4687 - 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'))) {
4688 4990 window.location.href = window.yatraTripData?.loginUrl || '/wp-login.php';
4689 4991 }
4690 4992 }
4691 4993 }
@@ -4774,12 +5076,91 @@
4774 5076 this.exposeGlobals();
4775 5077
4776 5078 // Extra page-level behaviors
4777 5079 this.initHeroSmoothScroll();
5080 + this.initGroupDiscountPopovers();
4778 5081
4779 5082 return this.instances;
4780 5083 }
4781 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 +
4782 5163 exposeGlobals() {
4783 5164 Object.entries(this.instances).forEach(([key, instance]) => {
4784 5165 window[key] = instance;
4785 5166 });
@@ -4856,15 +5237,15 @@
4856 5237 const videoUrl = link.getAttribute('href');
4857 5238 if (!videoUrl || videoUrl === '#') return;
4858 5239
4859 5240 const img = overlay.closest('.yatra-media-card').querySelector('img');
4860 - const videoTitle = img ? img.alt : 'Itinerary Video';
5241 + const videoTitle = img ? img.alt : __('Itinerary Video', 'yatra');
4861 5242
4862 5243 // Extract video ID from URL
4863 5244 let videoId = '';
4864 5245 if (videoUrl.includes('youtube.com') || videoUrl.includes('youtu.be')) {
4865 5246 // YouTube video ID extraction
4866 - 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?#\/]+)/);
4867 5248 videoId = matches ? matches[1] : '';
4868 5249 } else if (videoUrl.includes('vimeo.com')) {
4869 5250 // Vimeo video ID extraction
4870 5251 const matches = videoUrl.match(/vimeo\.com\/(?:.*#|.\/videos\/)?([0-9]+)/);
@@ -4977,9 +5358,9 @@
4977 5358 try {
4978 5359 // Get download URL with signature using centralized helper
4979 5360 const response = await window.YatraApiHelper.getDownloadInfo(downloadId);
4980 5361 if (!response.ok) {
4981 - throw new Error('Download failed');
5362 + throw new Error(__('Download failed', 'yatra'));
4982 5363 }
4983 5364
4984 5365 const data = await response.json();
4985 5366
@@ -4996,9 +5377,9 @@
4996 5377 }
4997 5378 } catch (error) {
4998 5379 console.error('Download error:', error);
4999 5380 // Show error message to user
5000 - this.showNotification('Download failed. Please try again.', 'error');
5381 + this.showNotification(__('Download failed. Please try again.', 'yatra'), 'error');
5001 5382 }
5002 5383 }
5003 5384
5004 5385 async previewFile(downloadId) {
@@ -5008,15 +5389,15 @@
5008 5389
5009 5390 // Get download info using centralized helper
5010 5391 const response = await window.YatraApiHelper.getDownloadInfo(downloadId);
5011 5392 if (!response.ok) {
5012 - throw new Error('Failed to get file info');
5393 + throw new Error(__('Failed to get file info', 'yatra'));
5013 5394 }
5014 5395
5015 5396 const data = await response.json();
5016 5397
5017 5398 if (data.download_url) {
5018 - this.loadPreview(data.download_url, data.title || 'Preview');
5399 + this.loadPreview(data.download_url, data.title || __('Preview', 'yatra'));
5019 5400 } else {
5020 5401 throw new Error('File URL not found');
5021 5402 }
5022 5403 } catch (error) {
@@ -5021,9 +5402,9 @@
5021 5402 }
5022 5403 } catch (error) {
5023 5404 console.error('Preview error:', error);
5024 5405 this.closePreviewModal();
5025 - this.showNotification('Failed to load preview. Please try again.', 'error');
5406 + this.showNotification(__('Failed to load preview. Please try again.', 'yatra'), 'error');
5026 5407 }
5027 5408 }
5028 5409
5029 5410 loadPreview(fileUrl, title) {
@@ -5051,13 +5432,15 @@
5051 5432 previewContent.innerHTML = `
5052 5433 <iframe src="${fileUrl}" style="width: 100%; height: 500px; border: none; border-radius: 8px;"></iframe>
5053 5434 `;
5054 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');
5055 5438 previewContent.innerHTML = `
5056 5439 <div style="text-align: center; padding: 40px;">
5057 5440 <div style="font-size: 48px; margin-bottom: 16px; opacity: 0.5;">📄</div>
5058 - <p style="color: #6b7280; margin-bottom: 16px;">Preview not available for this file type</p>
5059 - <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>
5060 5443 </div>
5061 5444 `;
5062 5445 }
5063 5446 }
@@ -5079,12 +5462,13 @@
5079 5462
5080 5463 // Reset loading state
5081 5464 const previewContent = document.getElementById('preview-content');
5082 5465 if (previewContent) {
5466 + const loadingPreviewLabel = __('Loading preview...', 'yatra');
5083 5467 previewContent.innerHTML = `
5084 5468 <div class="yatra-preview-loading">
5085 5469 <div class="yatra-preview-spinner"></div>
5086 - <p>Loading preview...</p>
5470 + <p>${loadingPreviewLabel}</p>
5087 5471 </div>
5088 5472 `;
5089 5473 }
5090 5474 }