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 +634 -217 3.0.2.8 → 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
@@ -1268,8 +1424,9 @@
1268 1424 class BookingSidebar {
1269 1425 constructor() {
1270 1426 this.checkAvailabilityBtn = null;
1271 1427 this.makeEnquiryBtn = null;
1428 + this.heroBookNowBtn = null;
1272 1429 this.dateInput = null;
1273 1430 this.participantsSelect = null;
1274 1431 this.datepickerInstance = null;
1275 1432 this.init();
@@ -1285,8 +1442,9 @@
1285 1442
1286 1443 setup() {
1287 1444 this.checkAvailabilityBtn = document.getElementById('check-availability-btn');
1288 1445 this.makeEnquiryBtn = document.getElementById('open-enquiry-modal');
1446 + this.heroBookNowBtn = document.getElementById('yatra-hero-book-now-btn');
1289 1447 this.dateInput = document.getElementById('travel_date');
1290 1448 this.participantsSelect = document.querySelector('.yatra-participants-select');
1291 1449
1292 1450 if (!this.checkAvailabilityBtn) {
@@ -1306,8 +1464,29 @@
1306 1464 this.handleCheckAvailability();
1307 1465 });
1308 1466 }
1309 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 +
1310 1489 // Make Enquiry button
1311 1490 if (this.makeEnquiryBtn) {
1312 1491 this.makeEnquiryBtn.addEventListener('click', (e) => {
1313 1492 e.preventDefault();
@@ -1316,10 +1495,29 @@
1316 1495 }
1317 1496 }
1318 1497
1319 1498 initDateField() {
1320 - if (!this.dateInput || typeof flatpickr === 'undefined') return;
1499 + if (!this.dateInput) return;
1321 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 +
1322 1520 const today = new Date();
1323 1521 today.setHours(0, 0, 0, 0);
1324 1522
1325 1523 // Format today's date as Y-m-d
@@ -1328,9 +1526,9 @@
1328 1526 String(today.getDate()).padStart(2, '0');
1329 1527
1330 1528 // Set today's date as default value
1331 1529 this.dateInput.value = todayStr;
1332 -
1530 +
1333 1531 // Enable button since we have a default date
1334 1532 if (this.checkAvailabilityBtn) {
1335 1533 this.checkAvailabilityBtn.disabled = false;
1336 1534 this.checkAvailabilityBtn.classList.remove('disabled');
@@ -1355,11 +1553,9 @@
1355 1553 allowInput: false,
1356 1554 static: false,
1357 1555 monthSelectorType: 'static',
1358 1556 animate: true,
1359 - locale: {
1360 - firstDayOfWeek: 1
1361 - },
1557 + locale: getFlatpickrLocale(),
1362 1558 onChange: (selectedDates, dateStr) => {
1363 1559 this.dateInput.value = dateStr;
1364 1560
1365 1561 // Enable Check Availability button when date is selected
@@ -1413,10 +1609,10 @@
1413 1609 if (!minusBtn || !plusBtn) return;
1414 1610
1415 1611 const updateButtonStates = () => {
1416 1612 const current = parseInt(input.value) || 1;
1417 - const min = parseInt(input.getAttribute('min')) || 1;
1418 - 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);
1419 1615
1420 1616 minusBtn.disabled = current <= min;
1421 1617 plusBtn.disabled = current >= max;
1422 1618 };
@@ -1426,9 +1622,9 @@
1426 1622 e.preventDefault();
1427 1623 e.stopPropagation();
1428 1624
1429 1625 const current = parseInt(input.value) || 1;
1430 - const min = parseInt(input.getAttribute('min')) || 1;
1626 + const min = window.yatraNumOr(input.getAttribute('min'), 1);
1431 1627
1432 1628 if (current > min) {
1433 1629 input.value = current - 1;
1434 1630 updateButtonStates();
@@ -1441,9 +1637,9 @@
1441 1637 e.preventDefault();
1442 1638 e.stopPropagation();
1443 1639
1444 1640 const current = parseInt(input.value) || 1;
1445 - const max = parseInt(input.getAttribute('max')) || 20;
1641 + const max = window.yatraNumOr(input.getAttribute('max'), 20);
1446 1642
1447 1643 if (current < max) {
1448 1644 input.value = current + 1;
1449 1645 updateButtonStates();
@@ -1495,9 +1691,9 @@
1495 1691
1496 1692 if (!display || !selector) return;
1497 1693
1498 1694 // Get all traveler category inputs dynamically
1499 - const travelerInputs = selector.querySelectorAll('input[id^="traveler_"]');
1695 + const travelerInputs = selector.querySelectorAll('input[id^="traveler_"], input[id^="mobile_traveler_"]');
1500 1696
1501 1697 if (travelerInputs.length === 0) return;
1502 1698
1503 1699 // Make display clickable
@@ -1517,21 +1713,25 @@
1517 1713 const parts = [];
1518 1714 travelerInputs.forEach((input) => {
1519 1715 const value = parseInt(input.value || 0);
1520 1716 if (value > 0) {
1521 - const categoryLabel = input.getAttribute('data-category-label') || 'Traveler';
1717 + const categoryLabel = input.getAttribute('data-category-label') || __('Traveler', 'yatra');
1522 1718 parts.push(`${categoryLabel} x ${value}`);
1523 1719 }
1524 1720 });
1525 1721
1526 1722 if (parts.length === 0) {
1527 - // 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.
1528 1727 const firstInput = travelerInputs[0];
1529 1728 if (firstInput) {
1530 - const categoryLabel = firstInput.getAttribute('data-category-label') || 'Traveler';
1531 - 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);
1532 1732 } else {
1533 - display.textContent = '1 Traveler';
1733 + display.textContent = __('1 Traveler', 'yatra');
1534 1734 }
1535 1735 } else {
1536 1736 display.textContent = parts.join(', ');
1537 1737 }
@@ -1545,9 +1745,19 @@
1545 1745 e.preventDefault();
1546 1746 e.stopPropagation();
1547 1747
1548 1748 const targetId = btn.getAttribute('data-target');
1549 - 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 + }
1550 1760 if (!input) return;
1551 1761
1552 1762 const current = parseInt(input.value || 0);
1553 1763 const min = parseInt(input.getAttribute('min') || 0);
@@ -1585,9 +1795,9 @@
1585 1795 const availabilitySection = document.getElementById('availability');
1586 1796 if (!availabilitySection) return;
1587 1797
1588 1798 inputs.forEach((input) => {
1589 - const categoryId = input.id.replace('traveler_', '');
1799 + const categoryId = input.id.replace(/^mobile_/, '').replace('traveler_', '');
1590 1800 const value = parseInt(input.value) || 0;
1591 1801
1592 1802 // Find all matching inputs in availability cards
1593 1803 const availInputs = availabilitySection.querySelectorAll(`.yatra-availability-category[data-category="${categoryId}"]`);
@@ -1615,9 +1825,9 @@
1615 1825 categoryInputs.forEach((catInput) => {
1616 1826 const catValue = parseInt(catInput.value) || 0;
1617 1827 if (catValue > 0) {
1618 1828 const catRow = catInput.closest('.yatra-quantity-row');
1619 - const categoryLabel = catRow ? catRow.querySelector('.yatra-quantity-title')?.textContent : 'Traveler';
1829 + const categoryLabel = catRow ? catRow.querySelector('.yatra-quantity-title')?.textContent : __('Traveler', 'yatra');
1620 1830 parts.push(`${categoryLabel} x ${catValue}`);
1621 1831 }
1622 1832 });
1623 1833 if (parts.length > 0) {
@@ -1626,8 +1836,11 @@
1626 1836 }
1627 1837 }
1628 1838 });
1629 1839 });
1840 + if (window.availabilitySection && typeof window.availabilitySection.updateAllCardTotals === 'function') {
1841 + window.availabilitySection.updateAllCardTotals();
1842 + }
1630 1843 };
1631 1844
1632 1845 // Close dropdown when clicking outside
1633 1846 const closeHandler = (e) => {
@@ -1689,14 +1902,14 @@
1689 1902
1690 1903
1691 1904 // Basic validation
1692 1905 if (!date) {
1693 - alert('Please select a travel date');
1906 + alert(__('Please select a travel date', 'yatra'));
1694 1907 return;
1695 1908 }
1696 1909
1697 1910 if (totalTravelers === 0) {
1698 - alert('Please select number of travelers');
1911 + alert(__('Please select number of travelers', 'yatra'));
1699 1912 return;
1700 1913 }
1701 1914
1702 1915 // Show loading state
@@ -1703,9 +1916,17 @@
1703 1916 if (this.checkAvailabilityBtn && !this.checkAvailabilityBtn.dataset.originalHtml) {
1704 1917 this.checkAvailabilityBtn.dataset.originalHtml = this.checkAvailabilityBtn.innerHTML;
1705 1918 }
1706 1919 if (this.checkAvailabilityBtn) {
1707 - 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');
1708 1929 this.checkAvailabilityBtn.disabled = true;
1709 1930 }
1710 1931
1711 1932 // Get trip ID from data attribute or global variable
@@ -1745,11 +1966,12 @@
1745 1966 if (!response.ok) {
1746 1967 // Try to get error message from response
1747 1968 return response.json().then(err => {
1748 1969 console.error('API Error:', err);
1749 - throw new Error(err.message || 'Failed to fetch availability');
1970 + throw new Error(err.message || __('Failed to fetch availability', 'yatra'));
1750 1971 }).catch(() => {
1751 - 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));
1752 1974 });
1753 1975 }
1754 1976 return response.json();
1755 1977 })
@@ -1813,9 +2035,9 @@
1813 2035 this.resetButton();
1814 2036 })
1815 2037 .catch(error => {
1816 2038 console.error('Error loading availability:', error);
1817 - alert('Failed to load availability. Please try again.');
2039 + alert(__('Failed to load availability. Please try again.', 'yatra'));
1818 2040 this.resetButton();
1819 2041 });
1820 2042 }
1821 2043
@@ -1864,13 +2086,14 @@
1864 2086 // Create new menu item
1865 2087 const newItem = document.createElement('a');
1866 2088 newItem.href = '#availability';
1867 2089 newItem.className = 'yatra-sticky-nav-item';
2090 + const availabilityLabel = __('Availability', 'yatra');
1868 2091 newItem.innerHTML = `
1869 2092 <svg class="yatra-icon-sm" fill="none" stroke="currentColor" viewBox="0 0 24 24">
1870 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"/>
1871 2094 </svg>
1872 - <span>Availability</span>
2095 + <span>${availabilityLabel}</span>
1873 2096 `;
1874 2097
1875 2098 // Insert before the target item (or after "What's Included" if no target)
1876 2099 if (insertBeforeItem && insertBeforeItem.parentNode) {
@@ -1937,9 +2160,9 @@
1937 2160 if (this.checkAvailabilityBtn && !this.checkAvailabilityBtn.dataset.originalHtml) {
1938 2161 this.checkAvailabilityBtn.dataset.originalHtml = this.checkAvailabilityBtn.innerHTML;
1939 2162 }
1940 2163 if (this.checkAvailabilityBtn) {
1941 - 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');
1942 2165 this.checkAvailabilityBtn.disabled = true;
1943 2166 }
1944 2167
1945 2168 const availSection = document.getElementById('availability');
@@ -1963,9 +2186,9 @@
1963 2186 credentials: 'omit',
1964 2187 })
1965 2188 .then(response => {
1966 2189 if (!response.ok) {
1967 - throw new Error('Failed to fetch availability');
2190 + throw new Error(__('Failed to fetch availability', 'yatra'));
1968 2191 }
1969 2192 return response.json();
1970 2193 })
1971 2194 .then(data => {
@@ -2000,9 +2223,9 @@
2000 2223 this.resetButton();
2001 2224 })
2002 2225 .catch(error => {
2003 2226 console.error('Error reloading availability:', error);
2004 - alert('Failed to reload availability. Please try again.');
2227 + alert(__('Failed to reload availability. Please try again.', 'yatra'));
2005 2228 this.resetButton();
2006 2229 });
2007 2230 }
2008 2231
@@ -2100,9 +2323,13 @@
2100 2323 if (data && data.success) {
2101 2324 // Update departures available text
2102 2325 const departuresText = document.querySelector('.yatra-departures-available-text');
2103 2326 if (departuresText && data.departures_count !== undefined) {
2104 - 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 + );
2105 2332 }
2106 2333
2107 2334 // Update traveler dropdown HTML
2108 2335 const quantitySelector = document.getElementById('quantity-selector');
@@ -2156,15 +2383,15 @@
2156 2383 const row = travelerSelector.querySelector('.yatra-quantity-row[data-category-id="' + categoryId + '"]');
2157 2384 if (row) {
2158 2385 const cardInput = row.querySelector('input[type="number"]');
2159 2386 if (cardInput) {
2160 - const max = parseInt(cardInput.getAttribute('max')) || 99;
2387 + const max = window.yatraNumOr(cardInput.getAttribute('max'), 99);
2161 2388 cardInput.value = Math.min(value, max);
2162 2389
2163 2390 // Update button states
2164 2391 const minusBtn = row.querySelector('.yatra-quantity-minus');
2165 2392 const plusBtn = row.querySelector('.yatra-quantity-plus');
2166 - const min = parseInt(cardInput.getAttribute('min')) || 0;
2393 + const min = window.yatraNumOr(cardInput.getAttribute('min'), 0);
2167 2394 if (minusBtn) minusBtn.disabled = cardInput.value <= min;
2168 2395 if (plusBtn) plusBtn.disabled = cardInput.value >= max;
2169 2396 }
2170 2397 }
@@ -2175,9 +2402,9 @@
2175 2402
2176 2403 } else if (simpleTravelers && numTravelersInput) {
2177 2404 // Simple pricing - sync total count
2178 2405 const totalCount = parseInt(numTravelersInput.value) || 1;
2179 - const max = parseInt(simpleTravelers.getAttribute('max')) || 99;
2406 + const max = window.yatraNumOr(simpleTravelers.getAttribute('max'), 99);
2180 2407 simpleTravelers.value = Math.min(totalCount, max);
2181 2408
2182 2409 // Update button states
2183 2410 const controls = simpleTravelers.closest('.yatra-quantity-controls-inline');
@@ -2183,9 +2410,9 @@
2183 2410 const controls = simpleTravelers.closest('.yatra-quantity-controls-inline');
2184 2411 if (controls) {
2185 2412 const minusBtn = controls.querySelector('.yatra-quantity-minus');
2186 2413 const plusBtn = controls.querySelector('.yatra-quantity-plus');
2187 - const min = parseInt(simpleTravelers.getAttribute('min')) || 1;
2414 + const min = window.yatraNumOr(simpleTravelers.getAttribute('min'), 1);
2188 2415 if (minusBtn) minusBtn.disabled = simpleTravelers.value <= min;
2189 2416 if (plusBtn) plusBtn.disabled = simpleTravelers.value >= max;
2190 2417 }
2191 2418 }
@@ -2218,9 +2445,9 @@
2218 2445 }
2219 2446 }
2220 2447 });
2221 2448
2222 - display.textContent = parts.length > 0 ? parts.join(', ') : '0 Travelers';
2449 + display.textContent = parts.length > 0 ? parts.join(', ') : __('0 Travelers', 'yatra');
2223 2450 }
2224 2451
2225 2452 /**
2226 2453 * Update availability card total price
@@ -2225,8 +2452,13 @@
2225 2452 /**
2226 2453 * Update availability card total price
2227 2454 */
2228 2455 updateAvailabilityCardTotals(itemId) {
2456 + if (window.availabilitySection && typeof window.availabilitySection.updateTotalPrice === 'function') {
2457 + window.availabilitySection.updateTotalPrice(itemId);
2458 + return;
2459 + }
2460 +
2229 2461 const totalAmountEl = document.querySelector('.yatra-card-total-amount[data-item="' + itemId + '"]');
2230 2462 const totalNoteEl = document.querySelector('.yatra-card-total-note[data-item="' + itemId + '"]');
2231 2463 if (!totalAmountEl) return;
2232 2464
@@ -2243,13 +2475,17 @@
2243 2475 const input = row.querySelector('input[type="number"]');
2244 2476 const price = parseFloat(row.getAttribute('data-price')) || 0;
2245 2477 const count = parseInt(input?.value) || 0;
2246 2478 const pricingMode = row.getAttribute('data-pricing-mode') || (input ? input.getAttribute('data-pricing-mode') : '') || 'per_person';
2247 -
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 +
2248 2482 if (pricingMode === 'per_group') {
2249 - // Per group: charge flat price once if any travelers
2483 + // Per group: one flat price, or per block of maxPax when "per_block".
2250 2484 if (count > 0) {
2251 - total += price;
2485 + total += (groupOverflow === 'per_block' && maxPax > 0)
2486 + ? price * Math.ceil(count / maxPax)
2487 + : price;
2252 2488 }
2253 2489 } else {
2254 2490 // Per person: charge per traveler
2255 2491 total += price * count;
@@ -2278,9 +2514,10 @@
2278 2514 totalAmountEl.textContent = String(total.toFixed(2));
2279 2515 }
2280 2516
2281 2517 if (totalNoteEl) {
2282 - 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);
2283 2520 }
2284 2521
2285 2522 // Update the card participants display
2286 2523 this.updateAvailabilityCardDisplay(itemId);
@@ -2425,11 +2662,9 @@
2425 2662 allowInput: false,
2426 2663 static: false,
2427 2664 monthSelectorType: 'static',
2428 2665 animate: true,
2429 - locale: {
2430 - firstDayOfWeek: 1
2431 - },
2666 + locale: getFlatpickrLocale(),
2432 2667 onChange: (selectedDates, dateStr) => {
2433 2668 this.dateInput.value = dateStr;
2434 2669 }
2435 2670 });
@@ -2483,17 +2718,22 @@
2483 2718 // Update display function
2484 2719 const updateDisplay = () => {
2485 2720 const adults = parseInt(adultsInput.value || 1);
2486 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);
2487 2726 let text = '';
2488 2727 if (adults > 0 && children > 0) {
2489 - 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);
2490 2730 } else if (adults > 0) {
2491 - text = `Adult x ${adults}`;
2731 + text = adultPart;
2492 2732 } else if (children > 0) {
2493 - text = `Child x ${children}`;
2733 + text = childPart;
2494 2734 } else {
2495 - text = 'Adult x 1';
2735 + text = sprintf(_n('Adult x %d', 'Adult x %d', 1, 'yatra'), 1);
2496 2736 }
2497 2737 display.textContent = text;
2498 2738 };
2499 2739
@@ -2579,9 +2819,9 @@
2579 2819 const email = formData.get('email');
2580 2820 const message = formData.get('message');
2581 2821
2582 2822 if (!name || !email || !message) {
2583 - this.showMessage('error', 'Please fill in all required fields.');
2823 + this.showMessage('error', __('Please fill in all required fields.', 'yatra'));
2584 2824 return;
2585 2825 }
2586 2826
2587 2827 // Basic email validation
@@ -2586,22 +2826,22 @@
2586 2826
2587 2827 // Basic email validation
2588 2828 const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
2589 2829 if (!emailRegex.test(email)) {
2590 - this.showMessage('error', 'Please enter a valid email address.');
2830 + this.showMessage('error', __('Please enter a valid email address.', 'yatra'));
2591 2831 return;
2592 2832 }
2593 2833
2594 2834 if (message.length < 10) {
2595 - this.showMessage('error', 'Please enter a message (at least 10 characters).');
2835 + this.showMessage('error', __('Please enter a message (at least 10 characters).', 'yatra'));
2596 2836 return;
2597 2837 }
2598 2838
2599 2839 // Show loading
2600 2840 const submitBtn = this.form.querySelector('.yatra-enquiry-submit');
2601 - const originalText = submitBtn ? submitBtn.textContent : 'Send Enquiry';
2841 + const originalText = submitBtn ? submitBtn.textContent : __('Send Enquiry', 'yatra');
2602 2842 if (submitBtn) {
2603 - submitBtn.textContent = 'Sending...';
2843 + submitBtn.textContent = __('Sending...', 'yatra');
2604 2844 submitBtn.disabled = true;
2605 2845 }
2606 2846
2607 2847 // Hide any existing messages
@@ -2610,8 +2850,15 @@
2610 2850 }
2611 2851
2612 2852 // Submit via REST API (public endpoint)
2613 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 + }
2614 2861
2615 2862 // Build REST URL
2616 2863 const baseUrlRaw = (window.yatraTripData?.apiUrl || window.yatraTripData?.restUrl || window.wpApiSettings?.root || '/wp-json').replace(/\/$/, '');
2617 2864 const enquiriesUrl = baseUrlRaw.includes('/yatra/v1')
@@ -2617,26 +2864,38 @@
2617 2864 const enquiriesUrl = baseUrlRaw.includes('/yatra/v1')
2618 2865 ? `${baseUrlRaw}/enquiries`
2619 2866 : `${baseUrlRaw}/yatra/v1/enquiries`;
2620 2867
2621 - fetch(enquiriesUrl, {
2622 - method: 'POST',
2623 - headers: {
2624 - 'Content-Type': 'application/json',
2625 - },
2626 - credentials: 'omit', // public endpoint
2627 - body: JSON.stringify(payload),
2628 - })
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 + })
2629 2888 .then(response => response.json())
2630 2889 .then(result => {
2631 2890 if (result.success) {
2632 - 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'));
2633 2892 this.form.reset();
2634 2893
2635 2894 // Reset the travelers display
2636 2895 const display = document.getElementById('enquiry-participants-display');
2637 2896 if (display) {
2638 - display.textContent = 'Adult x 1';
2897 + display.textContent = __('Adult x 1', 'yatra');
2639 2898 }
2640 2899 const adultsInput = document.getElementById('enquiry-adults');
2641 2900 const childrenInput = document.getElementById('enquiry-children');
2642 2901 if (adultsInput) adultsInput.value = 1;
@@ -2649,14 +2908,14 @@
2649 2908 messageBox.style.display = 'none';
2650 2909 }
2651 2910 }, 3000);
2652 2911 } else {
2653 - 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'));
2654 2913 }
2655 2914 })
2656 2915 .catch(error => {
2657 2916 console.error('Enquiry submission error:', error);
2658 - this.showMessage('error', 'An error occurred. Please try again later.');
2917 + this.showMessage('error', __('An error occurred. Please try again later.', 'yatra'));
2659 2918 })
2660 2919 .finally(() => {
2661 2920 if (submitBtn) {
2662 2921 submitBtn.textContent = originalText;
@@ -2721,21 +2980,33 @@
2721 2980 this.updateSidebarPosition(); // Initial check
2722 2981 }
2723 2982
2724 2983 getActualSections() {
2725 - // Base sections that are always present in order
2726 - const baseSections = ['overview', 'itinerary', 'included', 'location', 'important-info'];
2727 -
2728 - // Conditional sections that may or may not be present
2729 - const conditionalSections = ['downloads', 'faq', 'trip-story', 'what-makes-special', 'testimonials'];
2730 -
2731 - // Combine all sections and filter to only include those that actually exist on the page
2732 - const allSections = [...baseSections, ...conditionalSections];
2733 -
2734 - return allSections.filter(sectionId => {
2735 - const element = document.getElementById(sectionId);
2736 - 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 + }
2737 3007 });
3008 + return ids;
2738 3009 }
2739 3010
2740 3011 attachEventListeners() {
2741 3012 // Handle clicks on nav items
@@ -2907,8 +3178,15 @@
2907 3178 this.lastScrollTop = scrollTop;
2908 3179 }
2909 3180
2910 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.
2911 3189 const scrollPos = window.scrollY + 150;
2912 3190 let activeSectionId = null;
2913 3191
2914 3192 // Find which section is currently in view
@@ -2914,10 +3192,11 @@
2914 3192 // Find which section is currently in view
2915 3193 this.sections.forEach((sectionId) => {
2916 3194 const section = document.getElementById(sectionId);
2917 3195 if (section) {
2918 - const sectionTop = section.offsetTop;
2919 - const sectionBottom = sectionTop + section.offsetHeight;
3196 + const rect = section.getBoundingClientRect();
3197 + const sectionTop = rect.top + window.pageYOffset;
3198 + const sectionBottom = sectionTop + rect.height;
2920 3199
2921 3200 if (scrollPos >= sectionTop && scrollPos < sectionBottom) {
2922 3201 activeSectionId = sectionId;
2923 3202 }
@@ -3064,14 +3343,14 @@
3064 3343 }
3065 3344 }
3066 3345
3067 3346 if (travelers < 1) {
3068 - this.showBookingError(btn, 'Please select at least 1 traveler to continue.');
3347 + this.showBookingError(btn, __('Please select at least 1 traveler to continue.', 'yatra'));
3069 3348 return;
3070 3349 }
3071 3350
3072 3351 const originalText = btn.innerHTML;
3073 - 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');
3074 3353 btn.disabled = true;
3075 3354
3076 3355 if (!tripId || isNaN(parseInt(tripId)) || parseInt(tripId) <= 0) {
3077 3356 console.error('Invalid trip ID:', tripId);
@@ -3076,9 +3355,9 @@
3076 3355 if (!tripId || isNaN(parseInt(tripId)) || parseInt(tripId) <= 0) {
3077 3356 console.error('Invalid trip ID:', tripId);
3078 3357 btn.innerHTML = originalText;
3079 3358 btn.disabled = false;
3080 - this.showBookingError(btn, 'Unable to process booking. Invalid trip data.');
3359 + this.showBookingError(btn, __('Unable to process booking. Invalid trip data.', 'yatra'));
3081 3360 return;
3082 3361 }
3083 3362
3084 3363 const pricingType = btn.getAttribute('data-pricing-type') || 'regular';
@@ -3299,9 +3578,9 @@
3299 3578 currency: window.yatraBookingData?.currency || 'USD',
3300 3579 num_items: sessionPayload.travelers || 1
3301 3580 });
3302 3581
3303 - 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);
3304 3583 }
3305 3584
3306 3585 // Track Book Now click as begin_checkout (Google Analytics 4)
3307 3586 if (typeof gtag !== 'undefined' && window.yatraAdmin?.googleAnalytics?.measurementId) {
@@ -3319,9 +3598,9 @@
3319 3598 quantity: sessionPayload.travelers || 1
3320 3599 }]
3321 3600 });
3322 3601
3323 - 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);
3324 3603 }
3325 3604
3326 3605 // Set booking session via REST API
3327 3606 // credentials: 'same-origin' is required to send cookies for session
@@ -3352,9 +3631,9 @@
3352 3631 // Error - show message and reset button
3353 3632 console.error('Booking session error:', data);
3354 3633 btn.innerHTML = originalText;
3355 3634 btn.disabled = false;
3356 - 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'));
3357 3636 }
3358 3637 })
3359 3638 .catch(error => {
3360 3639 console.error('Error setting booking session:', error);
@@ -3359,9 +3638,9 @@
3359 3638 .catch(error => {
3360 3639 console.error('Error setting booking session:', error);
3361 3640 btn.innerHTML = originalText;
3362 3641 btn.disabled = false;
3363 - this.showBookingError(btn, 'Unable to process booking. Please try again.');
3642 + this.showBookingError(btn, __('Unable to process booking. Please try again.', 'yatra'));
3364 3643 });
3365 3644 }
3366 3645
3367 3646 initTravelerSelectors() {
@@ -3393,18 +3672,21 @@
3393 3672 categoryInputs.forEach((input) => {
3394 3673 const value = parseInt(input.value) || 0;
3395 3674 if (value > 0) {
3396 3675 const row = input.closest('.yatra-quantity-row');
3397 - const categoryLabel = row ? row.querySelector('.yatra-quantity-title')?.textContent : 'Traveler';
3676 + const categoryLabel = row ? row.querySelector('.yatra-quantity-title')?.textContent : __('Traveler', 'yatra');
3398 3677 parts.push(`${categoryLabel} x ${value}`);
3399 3678 }
3400 3679 });
3401 3680
3402 3681 if (parts.length === 0) {
3403 - // 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.
3404 3685 const firstRow = categoryInputs[0]?.closest('.yatra-quantity-row');
3405 - const firstLabel = firstRow ? firstRow.querySelector('.yatra-quantity-title')?.textContent : 'Traveler';
3406 - 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);
3407 3689 } else {
3408 3690 display.textContent = parts.join(', ');
3409 3691 }
3410 3692 };
@@ -3426,10 +3708,10 @@
3426 3708 const isMinus = btn.classList.contains('yatra-quantity-minus');
3427 3709 const isPlus = btn.classList.contains('yatra-quantity-plus');
3428 3710
3429 3711 let current = parseInt(input.value) || 0;
3430 - const min = parseInt(input.getAttribute('min')) || 0;
3431 - 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);
3432 3714 let newValue = current;
3433 3715
3434 3716 if (isPlus && current < max) {
3435 3717 newValue = current + 1;
@@ -3501,10 +3783,10 @@
3501 3783 const isMinus = btn.classList.contains('yatra-quantity-minus');
3502 3784 const isPlus = btn.classList.contains('yatra-quantity-plus');
3503 3785
3504 3786 let current = parseInt(input.value) || 1;
3505 - const min = parseInt(input.getAttribute('min')) || 1;
3506 - 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);
3507 3789 let newValue = current;
3508 3790
3509 3791 if (isPlus && current < max) {
3510 3792 newValue = current + 1;
@@ -3576,9 +3858,9 @@
3576 3858 categoryInputs.forEach((input) => {
3577 3859 const value = parseInt(input.value) || 0;
3578 3860 if (value > 0) {
3579 3861 const row = input.closest('.yatra-quantity-row');
3580 - const categoryLabel = row ? row.querySelector('.yatra-quantity-title')?.textContent : 'Traveler';
3862 + const categoryLabel = row ? row.querySelector('.yatra-quantity-title')?.textContent : __('Traveler', 'yatra');
3581 3863 parts.push(`${categoryLabel} x ${value}`);
3582 3864
3583 3865 // Also update sidebar inputs if they exist
3584 3866 const categoryId = input.getAttribute('data-category');
@@ -3614,8 +3896,9 @@
3614 3896 // Check for traveler-based pricing first
3615 3897 const categoryInputs = this.section.querySelectorAll(`.yatra-availability-category[data-item="${itemIndex}"]`);
3616 3898 let totalPrice = 0;
3617 3899 let totalTravelers = 0;
3900 + let totalGroups = 0; // per-group categories: number of group prices charged
3618 3901
3619 3902 if (categoryInputs.length > 0) {
3620 3903 // Traveler-based pricing: sum up each category's price * quantity (respecting pricing mode)
3621 3904 categoryInputs.forEach((input) => {
@@ -3622,12 +3905,16 @@
3622 3905 const quantity = parseInt(input.value) || 0;
3623 3906 const row = input.closest('.yatra-quantity-row');
3624 3907 const price = row ? parseFloat(row.getAttribute('data-price')) || 0 : 0;
3625 3908 const pricingMode = (row ? row.getAttribute('data-pricing-mode') : null) || input.getAttribute('data-pricing-mode') || 'per_person';
3626 -
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 +
3627 3912 if (pricingMode === 'per_group') {
3628 3913 if (quantity > 0) {
3629 - totalPrice += price;
3914 + const blocks = (groupOverflow === 'per_block' && maxPax > 0) ? Math.ceil(quantity / maxPax) : 1;
3915 + totalPrice += price * blocks;
3916 + totalGroups += blocks;
3630 3917 }
3631 3918 } else {
3632 3919 totalPrice += quantity * price;
3633 3920 }
@@ -3655,16 +3942,41 @@
3655 3942 } else {
3656 3943 totalAmountElement.textContent = String(finalPrice.toFixed(2));
3657 3944 }
3658 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 +
3659 3969 // Update the note text
3660 3970 const noteElement = this.section.querySelector(
3661 3971 `.yatra-card-total-note[data-item="${itemIndex}"]`
3662 3972 );
3663 3973 if (noteElement) {
3664 - let travelerText = totalTravelers === 1
3665 - ? 'for 1 traveler'
3666 - : `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 + );
3667 3979
3668 3980 // Add group discount info if applied
3669 3981 if (groupDiscountApplied && groupDiscountAmount > 0) {
3670 3982 const discountText = (typeof yatra_format_price_js === 'function')
@@ -3669,9 +3981,13 @@
3669 3981 if (groupDiscountApplied && groupDiscountAmount > 0) {
3670 3982 const discountText = (typeof yatra_format_price_js === 'function')
3671 3983 ? yatra_format_price_js(groupDiscountAmount)
3672 3984 : String(groupDiscountAmount.toFixed(2));
3673 - travelerText += ` (Group discount: -${discountText})`;
3985 + travelerText += sprintf(
3986 + /* translators: %s: formatted discount amount. */
3987 + __(' (Group discount: -%s)', 'yatra'),
3988 + discountText
3989 + );
3674 3990 }
3675 3991 noteElement.textContent = travelerText;
3676 3992 }
3677 3993
@@ -3690,23 +4006,9 @@
3690 4006 discountPercentage: 0,
3691 4007 discountType: null
3692 4008 };
3693 4009
3694 - // Get group discounts from the booking card data attribute
3695 - const bookingCard = document.querySelector('.yatra-booking-card');
3696 - if (!bookingCard) return result;
3697 -
3698 - const groupDiscountsData = bookingCard.getAttribute('data-group-discounts');
3699 - if (!groupDiscountsData) return result;
3700 -
3701 - let groupDiscounts;
3702 - try {
3703 - groupDiscounts = JSON.parse(groupDiscountsData);
3704 - } catch (e) {
3705 - console.error('Failed to parse group discounts:', e);
3706 - return result;
3707 - }
3708 -
4010 + const groupDiscounts = window.yatraTripData && window.yatraTripData.sidebarGroupDiscounts;
3709 4011 if (!Array.isArray(groupDiscounts) || groupDiscounts.length === 0) return result;
3710 4012
3711 4013 // Find the applicable discount based on total travelers
3712 4014 let applicableDiscount = null;
@@ -3761,8 +4063,23 @@
3761 4063 return result;
3762 4064 }
3763 4065
3764 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 + /**
3765 4082 * Update group discount badge visibility
3766 4083 */
3767 4084 updateGroupDiscountBadge(itemIndex, discountApplied, discountResult) {
3768 4085 // Find or create the group discount applied badge for this item
@@ -3785,10 +4102,14 @@
3785 4102 }
3786 4103
3787 4104 // Update badge content
3788 4105 const discountText = discountResult.discountType === 'percentage'
3789 - ? `${discountResult.discountPercentage}% group discount applied!`
3790 - : `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');
3791 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}`;
3792 4113 } else if (badge) {
3793 4114 badge.remove();
3794 4115 }
@@ -3868,9 +4189,9 @@
3868 4189 }
3869 4190
3870 4191 // Show success feedback
3871 4192 const originalText = button.textContent;
3872 - button.textContent = 'Selected!';
4193 + button.textContent = __('Selected!', 'yatra');
3873 4194 button.style.background = '#10b981';
3874 4195 setTimeout(() => {
3875 4196 button.textContent = originalText;
3876 4197 button.style.background = '';
@@ -3895,9 +4216,11 @@
3895 4216 const travelDate = travelDateInput && travelDateInput.value ? travelDateInput.value : undefined;
3896 4217
3897 4218 const originalHTML = btn.innerHTML;
3898 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> ';
3899 - 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');
3900 4223 btn.disabled = true;
3901 4224
3902 4225 const url = buildAvailabilityTemplateUrl(tripId, {
3903 4226 date: travelDate,
@@ -3916,9 +4239,9 @@
3916 4239 credentials: 'omit',
3917 4240 })
3918 4241 .then((resp) => {
3919 4242 if (!resp.ok) {
3920 - throw new Error('Load more failed');
4243 + throw new Error(__('Load more failed', 'yatra'));
3921 4244 }
3922 4245 return resp.json();
3923 4246 })
3924 4247 .then((data) => {
@@ -3955,9 +4278,11 @@
3955 4278 return;
3956 4279 }
3957 4280
3958 4281 const remaining = Math.max(0, total - displayed);
3959 - 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}`;
3960 4285 btn.disabled = false;
3961 4286
3962 4287 loadMoreContainer.dataset.currentPage = String(nextPage);
3963 4288 loadMoreContainer.dataset.loaded = String(displayed);
@@ -3963,9 +4288,17 @@
3963 4288 loadMoreContainer.dataset.loaded = String(displayed);
3964 4289 loadMoreContainer.dataset.total = String(total);
3965 4290
3966 4291 if (countInfo) {
3967 - 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 + );
3968 4301 }
3969 4302 })
3970 4303 .catch((err) => {
3971 4304 console.error('[Yatra] Load more failed:', err);
@@ -4129,9 +4462,9 @@
4129 4462 if (!this.toggleAllBtn) return;
4130 4463 this.toggleAllBtn.classList.toggle('is-all-expanded', isExpanded);
4131 4464 const toggleText = this.toggleAllBtn.querySelector('.toggle-text');
4132 4465 if (toggleText) {
4133 - toggleText.textContent = isExpanded ? 'Collapse All' : 'Expand All';
4466 + toggleText.textContent = isExpanded ? __('Collapse All', 'yatra') : __('Expand All', 'yatra');
4134 4467 }
4135 4468 }
4136 4469 }
4137 4470
@@ -4244,9 +4577,10 @@
4244 4577 const originalText = submitBtn.innerHTML;
4245 4578
4246 4579 // Disable button and show loading
4247 4580 submitBtn.disabled = true;
4248 - submitBtn.innerHTML = '<span class="yatra-loading-spinner"></span> Submitting...';
4581 + submitBtn.innerHTML =
4582 + '<span class="yatra-loading-spinner"></span> ' + __('Submitting...', 'yatra');
4249 4583
4250 4584 try {
4251 4585 const formData = new FormData(this.form);
4252 4586 formData.append('action', 'yatra_submit_review');
@@ -4260,9 +4594,9 @@
4260 4594 const result = await response.json();
4261 4595
4262 4596 if (result.success) {
4263 4597 // Show success message
4264 - this.showMessage('success', result.data?.message || 'Thank you for your review!');
4598 + this.showMessage('success', result.data?.message || __('Thank you for your review!', 'yatra'));
4265 4599
4266 4600 // Check if this was an edit or new submission
4267 4601 const isEdit = this.form.querySelector('input[name="action_type"]')?.value === 'edit';
4268 4602
@@ -4279,13 +4613,13 @@
4279 4613 ratingLabels.forEach(label => label.classList.add('selected'));
4280 4614 }
4281 4615 }
4282 4616 } else {
4283 - 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'));
4284 4618 }
4285 4619 } catch (error) {
4286 4620 console.error('Review submission error:', error);
4287 - this.showMessage('error', 'An error occurred. Please try again.');
4621 + this.showMessage('error', __('An error occurred. Please try again.', 'yatra'));
4288 4622 } finally {
4289 4623 submitBtn.disabled = false;
4290 4624 submitBtn.innerHTML = originalText;
4291 4625 }
@@ -4459,16 +4793,13 @@
4459 4793 * Handles save/remove trip from wishlist functionality
4460 4794 */
4461 4795 class WishlistHandler {
4462 4796 constructor() {
4463 - if (!window.yatraTripData?.wishlistEnabled) {
4464 - return;
4465 - }
4466 - this.apiUrl = window.yatraTripData?.apiUrl || '/wp-json/yatra/v1';
4467 - this.nonce = window.yatraTripData?.nonce || '';
4468 - this.tripId = window.yatraTripData?.tripId || 0;
4469 - this.isLoggedIn = window.yatraTripData?.isLoggedIn || false;
4470 - 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;
4471 4802 }
4472 4803
4473 4804 init() {
4474 4805 // Check if user is logged in and update button states
@@ -4551,15 +4882,15 @@
4551 4882
4552 4883 const data = await response.json();
4553 4884 if (data.success) {
4554 4885 this.updateButtonState(true, tid);
4555 - this.showMessage(data.message || 'Trip saved to wishlist', 'success', btn);
4886 + this.showMessage(data.message || __('Trip saved to wishlist', 'yatra'), 'success', btn);
4556 4887 } else {
4557 - this.showMessage(data.message || 'Failed to save trip', 'error', btn);
4888 + this.showMessage(data.message || __('Failed to save trip', 'yatra'), 'error', btn);
4558 4889 }
4559 4890 } catch (error) {
4560 4891 console.error('Error saving trip:', error);
4561 - this.showMessage('An error occurred. Please try again.', 'error', btn);
4892 + this.showMessage(__('An error occurred. Please try again.', 'yatra'), 'error', btn);
4562 4893 } finally {
4563 4894 btn.disabled = false;
4564 4895 btn.classList.remove('loading');
4565 4896 }
@@ -4581,15 +4912,15 @@
4581 4912
4582 4913 const data = await response.json();
4583 4914 if (data.success) {
4584 4915 this.updateButtonState(false, tid);
4585 - this.showMessage(data.message || 'Trip removed from wishlist', 'success', btn);
4916 + this.showMessage(data.message || __('Trip removed from wishlist', 'yatra'), 'success', btn);
4586 4917 } else {
4587 - this.showMessage(data.message || 'Failed to remove trip', 'error', btn);
4918 + this.showMessage(data.message || __('Failed to remove trip', 'yatra'), 'error', btn);
4588 4919 }
4589 4920 } catch (error) {
4590 4921 console.error('Error removing trip:', error);
4591 - this.showMessage('An error occurred. Please try again.', 'error', btn);
4922 + this.showMessage(__('An error occurred. Please try again.', 'yatra'), 'error', btn);
4592 4923 } finally {
4593 4924 btn.disabled = false;
4594 4925 btn.classList.remove('loading');
4595 4926 }
@@ -4603,9 +4934,9 @@
4603 4934 const buttons = document.querySelectorAll(selector);
4604 4935 buttons.forEach(btn => {
4605 4936 if (isSaved) {
4606 4937 btn.classList.add('saved', 'is-saved');
4607 - btn.setAttribute('aria-label', 'Remove from favorites');
4938 + btn.setAttribute('aria-label', __('Remove from favorites', 'yatra'));
4608 4939 // Fill the heart icon and change color
4609 4940 const svg = btn.querySelector('svg');
4610 4941 if (svg) {
4611 4942 svg.style.fill = 'currentColor';
@@ -4613,9 +4944,9 @@
4613 4944 btn.style.color = '#ef4444';
4614 4945 }
4615 4946 } else {
4616 4947 btn.classList.remove('saved', 'is-saved');
4617 - btn.setAttribute('aria-label', 'Add to favorites');
4948 + btn.setAttribute('aria-label', __('Add to favorites', 'yatra'));
4618 4949 // Unfill the heart icon
4619 4950 const svg = btn.querySelector('svg');
4620 4951 if (svg) {
4621 4952 svg.style.fill = 'none';
@@ -4639,13 +4970,17 @@
4639 4970 if (modalContent) {
4640 4971 const loginMessage = document.createElement('div');
4641 4972 loginMessage.className = 'yatra-login-prompt';
4642 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');
4643 4978 loginMessage.innerHTML = `
4644 - <h3 style="margin-bottom: 20px;">Login Required</h3>
4645 - <p style="margin-bottom: 30px;">Please login to save trips to your wishlist.</p>
4646 - <a href="${window.yatraTripData?.loginUrl || '/wp-login.php'}" class="yatra-btn" style="display: inline-block; margin-right: 10px;">Login</a>
4647 - <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>
4648 4983 `;
4649 4984 modalContent.innerHTML = '';
4650 4985 modalContent.appendChild(loginMessage);
4651 4986 }
@@ -4650,9 +4985,9 @@
4650 4985 modalContent.appendChild(loginMessage);
4651 4986 }
4652 4987 } else {
4653 4988 // Fallback: show alert and redirect to login
4654 - 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'))) {
4655 4990 window.location.href = window.yatraTripData?.loginUrl || '/wp-login.php';
4656 4991 }
4657 4992 }
4658 4993 }
@@ -4741,12 +5076,91 @@
4741 5076 this.exposeGlobals();
4742 5077
4743 5078 // Extra page-level behaviors
4744 5079 this.initHeroSmoothScroll();
5080 + this.initGroupDiscountPopovers();
4745 5081
4746 5082 return this.instances;
4747 5083 }
4748 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 +
4749 5163 exposeGlobals() {
4750 5164 Object.entries(this.instances).forEach(([key, instance]) => {
4751 5165 window[key] = instance;
4752 5166 });
@@ -4823,15 +5237,15 @@
4823 5237 const videoUrl = link.getAttribute('href');
4824 5238 if (!videoUrl || videoUrl === '#') return;
4825 5239
4826 5240 const img = overlay.closest('.yatra-media-card').querySelector('img');
4827 - const videoTitle = img ? img.alt : 'Itinerary Video';
5241 + const videoTitle = img ? img.alt : __('Itinerary Video', 'yatra');
4828 5242
4829 5243 // Extract video ID from URL
4830 5244 let videoId = '';
4831 5245 if (videoUrl.includes('youtube.com') || videoUrl.includes('youtu.be')) {
4832 5246 // YouTube video ID extraction
4833 - 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?#\/]+)/);
4834 5248 videoId = matches ? matches[1] : '';
4835 5249 } else if (videoUrl.includes('vimeo.com')) {
4836 5250 // Vimeo video ID extraction
4837 5251 const matches = videoUrl.match(/vimeo\.com\/(?:.*#|.\/videos\/)?([0-9]+)/);
@@ -4944,9 +5358,9 @@
4944 5358 try {
4945 5359 // Get download URL with signature using centralized helper
4946 5360 const response = await window.YatraApiHelper.getDownloadInfo(downloadId);
4947 5361 if (!response.ok) {
4948 - throw new Error('Download failed');
5362 + throw new Error(__('Download failed', 'yatra'));
4949 5363 }
4950 5364
4951 5365 const data = await response.json();
4952 5366
@@ -4963,9 +5377,9 @@
4963 5377 }
4964 5378 } catch (error) {
4965 5379 console.error('Download error:', error);
4966 5380 // Show error message to user
4967 - this.showNotification('Download failed. Please try again.', 'error');
5381 + this.showNotification(__('Download failed. Please try again.', 'yatra'), 'error');
4968 5382 }
4969 5383 }
4970 5384
4971 5385 async previewFile(downloadId) {
@@ -4975,15 +5389,15 @@
4975 5389
4976 5390 // Get download info using centralized helper
4977 5391 const response = await window.YatraApiHelper.getDownloadInfo(downloadId);
4978 5392 if (!response.ok) {
4979 - throw new Error('Failed to get file info');
5393 + throw new Error(__('Failed to get file info', 'yatra'));
4980 5394 }
4981 5395
4982 5396 const data = await response.json();
4983 5397
4984 5398 if (data.download_url) {
4985 - this.loadPreview(data.download_url, data.title || 'Preview');
5399 + this.loadPreview(data.download_url, data.title || __('Preview', 'yatra'));
4986 5400 } else {
4987 5401 throw new Error('File URL not found');
4988 5402 }
4989 5403 } catch (error) {
@@ -4988,9 +5402,9 @@
4988 5402 }
4989 5403 } catch (error) {
4990 5404 console.error('Preview error:', error);
4991 5405 this.closePreviewModal();
4992 - this.showNotification('Failed to load preview. Please try again.', 'error');
5406 + this.showNotification(__('Failed to load preview. Please try again.', 'yatra'), 'error');
4993 5407 }
4994 5408 }
4995 5409
4996 5410 loadPreview(fileUrl, title) {
@@ -5018,13 +5432,15 @@
5018 5432 previewContent.innerHTML = `
5019 5433 <iframe src="${fileUrl}" style="width: 100%; height: 500px; border: none; border-radius: 8px;"></iframe>
5020 5434 `;
5021 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');
5022 5438 previewContent.innerHTML = `
5023 5439 <div style="text-align: center; padding: 40px;">
5024 5440 <div style="font-size: 48px; margin-bottom: 16px; opacity: 0.5;">📄</div>
5025 - <p style="color: #6b7280; margin-bottom: 16px;">Preview not available for this file type</p>
5026 - <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>
5027 5443 </div>
5028 5444 `;
5029 5445 }
5030 5446 }
@@ -5046,12 +5462,13 @@
5046 5462
5047 5463 // Reset loading state
5048 5464 const previewContent = document.getElementById('preview-content');
5049 5465 if (previewContent) {
5466 + const loadingPreviewLabel = __('Loading preview...', 'yatra');
5050 5467 previewContent.innerHTML = `
5051 5468 <div class="yatra-preview-loading">
5052 5469 <div class="yatra-preview-spinner"></div>
5053 - <p>Loading preview...</p>
5470 + <p>${loadingPreviewLabel}</p>
5054 5471 </div>
5055 5472 `;
5056 5473 }
5057 5474 }