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 +462 -197 3.0.3 → 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) ||
@@ -148,9 +205,14 @@
148 205 const decimalSep = (typeof cfg.decimalSeparator === 'string') ? cfg.decimalSeparator : '.';
149 206
150 207 const num = Number(amount) || 0;
151 208
152 - 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', {
153 215 minimumFractionDigits: decimals,
154 216 maximumFractionDigits: decimals,
155 217 }).format(num)
156 218 .replace(/,/g, 'TEMP_THOUSAND')
@@ -276,9 +338,17 @@
276 338 const mediaData = JSON.parse(mediaScript.textContent || '{}');
277 339 const imageUrls = Array.isArray(mediaData.images) ? mediaData.images : [];
278 340 imageUrls.forEach((url, index) => {
279 341 if (url && typeof url === 'string' && !this.images.some(i => i.src === url)) {
280 - 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 + });
281 351 }
282 352 });
283 353 } catch (e) {
284 354 // JSON parse error – fall through to DOM-based collection.
@@ -286,23 +356,24 @@
286 356 }
287 357
288 358 // 2. Collect from hero slides (covers any images not in the JSON blob).
289 359 document.querySelectorAll('.yatra-trip-hero-slide img').forEach((img, index) => {
290 - 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)));
291 362 });
292 363
293 364 // 3. Collect from gallery section (hero gallery only, NOT itinerary).
294 365 document.querySelectorAll('.yatra-trip-gallery .yatra-gallery-item img').forEach((img) => {
295 - add(img, 'Gallery Image');
366 + add(img, __('Gallery Image', 'yatra'));
296 367 });
297 368
298 369 // 4. Collect from hero main image.
299 370 const heroMainImg = document.querySelector('.yatra-hero-main-img');
300 - if (heroMainImg) add(heroMainImg, 'Main Image');
371 + if (heroMainImg) add(heroMainImg, __('Main Image', 'yatra'));
301 372
302 373 // 5. Collect from side images.
303 374 document.querySelectorAll('.yatra-side-image-item img').forEach((img) => {
304 - add(img, 'Side Image');
375 + add(img, __('Side Image', 'yatra'));
305 376 });
306 377 }
307 378
308 379 createThumbnails() {
@@ -496,14 +567,21 @@
496 567 }
497 568
498 569 /**
499 570 * Itinerary Gallery Modal Class
500 - * 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.
501 579 */
502 580 class ItineraryGalleryModal {
503 581 constructor() {
504 582 this.modal = null;
505 - this.images = [];
583 + this.images = []; // currently-visible image set (the active scope)
506 584 this.currentIndex = 0;
507 585 this.init();
508 586 }
509 587
@@ -520,43 +598,69 @@
520 598 if (!this.modal) {
521 599 console.warn('Itinerary gallery modal not found');
522 600 return;
523 601 }
524 -
525 - console.log('Itinerary gallery modal found, collecting images...');
526 - this.collectImages();
527 - console.log('Itinerary gallery images collected:', this.images.length);
528 - this.createThumbnails();
529 602 this.attachEventListeners();
530 603 }
531 604
532 - collectImages() {
533 - 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 = [];
534 613
535 614 const add = (img, fallbackAlt) => {
536 615 const src = img.getAttribute('data-full-size') || getImageSrc(img);
537 616 if (!src) return;
538 - if (!this.images.some(i => i.src === src)) {
539 - this.images.push({ src, alt: img.alt || fallbackAlt });
617 + if (!images.some(i => i.src === src)) {
618 + images.push({ src, alt: img.alt || fallbackAlt });
540 619 }
541 620 };
542 621
543 - // Collect from itinerary gallery section (hidden gallery)
544 - document.querySelectorAll('.yatra-itinerary-trip-gallery .yatra-gallery-item img').forEach((img) => {
545 - 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 + ));
546 629 });
547 630
548 - // Collect from visible itinerary gallery items
549 - document.querySelectorAll('.yatra-entry-gallery .yatra-media-card img').forEach((img) => {
550 - 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 + ));
551 638 });
552 639
553 - // Collect from itinerary video items
554 - document.querySelectorAll('.yatra-entry-video .yatra-media-card img, .yatra-entry-gallery .yatra-itinerary-video-link img').forEach((img) => {
555 - add(img, `Itinerary Video Image ${this.images.length + 1}`);
556 - });
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;
557 654 }
558 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 +
559 663 createThumbnails() {
560 664 const container = this.modal.querySelector('.yatra-gallery-thumbnails-container');
561 665 if (!container) return;
562 666
@@ -677,30 +781,57 @@
677 781 if (e.key === 'ArrowLeft') this.prev();
678 782 if (e.key === 'ArrowRight') this.next();
679 783 });
680 784
681 - // Attach click handlers to itinerary gallery links
682 - const galleryLinks = document.querySelectorAll('.yatra-itinerary-gallery-link');
683 - console.log('Attaching listeners to itinerary gallery links:', galleryLinks.length);
684 - galleryLinks.forEach((link) => {
685 - link.addEventListener('click', (e) => {
686 - e.preventDefault();
687 - const imageIndex = parseInt(link.getAttribute('data-image-index') || '0');
688 - console.log('Itinerary gallery link clicked, opening at index:', imageIndex);
689 - this.open(imageIndex);
690 - });
691 - });
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;
692 797
693 - // 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.
694 820 const galleryItems = document.querySelectorAll('.yatra-itinerary-trip-gallery .yatra-gallery-item');
695 821 galleryItems.forEach((item, index) => {
696 822 item.addEventListener('click', () => {
823 + const allImages = this.collectImagesFrom(null); // fall back to global
824 + if (allImages.length === 0) return;
697 825 const img = item.querySelector('img');
698 - if (img) {
699 - const src = img.getAttribute('data-full-size') || getImageSrc(img);
700 - const imageIndex = this.images.findIndex(i => i.src === src);
701 - this.open(imageIndex >= 0 ? imageIndex : index);
702 - }
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);
703 834 });
704 835 });
705 836 }
706 837 }
@@ -855,9 +986,9 @@
855 986 }
856 987
857 988 if (images.length > 0) {
858 989 img.src = images[0];
859 - img.alt = 'Trip Image 1';
990 + img.alt = __('Trip Image 1', 'yatra');
860 991 if (link) {
861 992 link.href = '#';
862 993 link.dataset.imageIndex = '0';
863 994 }
@@ -1075,8 +1206,12 @@
1075 1206 if (existingOverlay) existingOverlay.remove();
1076 1207
1077 1208 const overlay = document.createElement('div');
1078 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');
1079 1214 overlay.innerHTML = `
1080 1215 <div class="yatra-tour-info">
1081 1216 <div class="yatra-tour-icon">
1082 1217 <svg width="80" height="80" viewBox="0 0 100 100" fill="none">
@@ -1089,11 +1224,11 @@
1089 1224 <text x="50" y="58" text-anchor="middle" fill="white" font-size="24" font-weight="bold">360°</text>
1090 1225 </svg>
1091 1226 </div>
1092 1227 <div class="yatra-tour-details">
1093 - <div class="yatra-tour-title">${tour.title || '360° Virtual Tour'}</div>
1094 - <div class="yatra-tour-type">${tour.tour_type || '360°'} Experience</div>
1095 - <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>
1096 1231 </div>
1097 1232 </div>
1098 1233 `;
1099 1234
@@ -1131,8 +1266,9 @@
1131 1266 if (existingOverlay) existingOverlay.remove();
1132 1267
1133 1268 const overlay = document.createElement('div');
1134 1269 overlay.className = 'yatra-media-overlay yatra-media-document-overlay';
1270 + const documentFallbackTitle = __('Document', 'yatra');
1135 1271 overlay.innerHTML = `
1136 1272 <div class="yatra-document-info">
1137 1273 <div class="yatra-document-icon">
1138 1274 <svg width="48" height="48" viewBox="0 0 24 24" fill="none">
@@ -1142,9 +1278,9 @@
1142 1278 <path d="M16 17H8" stroke="#374151" stroke-width="2"/>
1143 1279 </svg>
1144 1280 </div>
1145 1281 <div class="yatra-document-details">
1146 - <div class="yatra-document-title">${doc.title || 'Document'}</div>
1282 + <div class="yatra-document-title">${doc.title || documentFallbackTitle}</div>
1147 1283 <div class="yatra-document-size">${this.formatFileSize(doc.file_size || 0)}</div>
1148 1284 </div>
1149 1285 </div>
1150 1286 `;
@@ -1151,11 +1287,11 @@
1151 1287 container.appendChild(overlay);
1152 1288 }
1153 1289
1154 1290 formatFileSize(bytes) {
1155 - if (bytes === 0) return '0 Bytes';
1291 + if (bytes === 0) return '0 ' + __('Bytes', 'yatra');
1156 1292 const k = 1024;
1157 - const sizes = ['Bytes', 'KB', 'MB', 'GB'];
1293 + const sizes = [__('Bytes', 'yatra'), 'KB', 'MB', 'GB'];
1158 1294 const i = Math.floor(Math.log(bytes) / Math.log(k));
1159 1295 return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
1160 1296 }
1161 1297
@@ -1185,9 +1321,10 @@
1185 1321 sideImages.forEach((item, index) => {
1186 1322 const img = item.querySelector('img');
1187 1323 if (img && images[index + 1]) {
1188 1324 img.src = images[index + 1];
1189 - 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);
1190 1327 }
1191 1328 });
1192 1329 }
1193 1330
@@ -1192,8 +1329,9 @@
1192 1329 }
1193 1330
1194 1331 updateSideVideos(sideImages) {
1195 1332 const videos = this.mediaData.videos || [];
1333 + const fallbackVideoTitle = __('Trip Video', 'yatra');
1196 1334 sideImages.forEach((item, index) => {
1197 1335 const img = item.querySelector('img');
1198 1336 if (img && videos[index + 1]) {
1199 1337 const video = videos[index + 1];
@@ -1199,9 +1337,9 @@
1199 1337 const video = videos[index + 1];
1200 1338 if (video.thumbnail) {
1201 1339 img.src = video.thumbnail;
1202 1340 }
1203 - img.alt = video.title || 'Trip Video';
1341 + img.alt = video.title || fallbackVideoTitle;
1204 1342 }
1205 1343 });
1206 1344 }
1207 1345
@@ -1206,8 +1344,9 @@
1206 1344 }
1207 1345
1208 1346 updateSideYoutubeVideos(sideImages) {
1209 1347 const youtubeVideos = this.mediaData.youtube_videos || [];
1348 + const fallbackYoutubeTitle = __('YouTube Video', 'yatra');
1210 1349 sideImages.forEach((item, index) => {
1211 1350 const img = item.querySelector('img');
1212 1351 if (img && youtubeVideos[index + 1]) {
1213 1352 const video = youtubeVideos[index + 1];
@@ -1213,9 +1352,9 @@
1213 1352 const video = youtubeVideos[index + 1];
1214 1353 if (video.thumbnail) {
1215 1354 img.src = video.thumbnail;
1216 1355 }
1217 - img.alt = video.title || 'YouTube Video';
1356 + img.alt = video.title || fallbackYoutubeTitle;
1218 1357 }
1219 1358 });
1220 1359 }
1221 1360
@@ -1220,8 +1359,9 @@
1220 1359 }
1221 1360
1222 1361 updateSideTours(sideImages) {
1223 1362 const virtualTours = this.mediaData.virtual_tours || [];
1363 + const fallbackTourTitle = __('360° Virtual Tour', 'yatra');
1224 1364 sideImages.forEach((item, index) => {
1225 1365 const img = item.querySelector('img');
1226 1366 if (img && virtualTours[index + 1]) {
1227 1367 const tour = virtualTours[index + 1];
@@ -1227,9 +1367,9 @@
1227 1367 const tour = virtualTours[index + 1];
1228 1368 if (tour.thumbnail) {
1229 1369 img.src = tour.thumbnail;
1230 1370 }
1231 - img.alt = tour.title || '360° Virtual Tour';
1371 + img.alt = tour.title || fallbackTourTitle;
1232 1372 }
1233 1373 });
1234 1374 }
1235 1375
@@ -1234,14 +1374,15 @@
1234 1374 }
1235 1375
1236 1376 updateSideDocuments(sideImages) {
1237 1377 const documents = this.mediaData.documents || [];
1378 + const fallbackDocumentTitle = __('Trip Document', 'yatra');
1238 1379 sideImages.forEach((item, index) => {
1239 1380 const img = item.querySelector('img');
1240 1381 if (img && documents[index + 1]) {
1241 1382 const doc = documents[index + 1];
1242 1383 // Keep existing image for documents
1243 - img.alt = doc.title || 'Trip Document';
1384 + img.alt = doc.title || fallbackDocumentTitle;
1244 1385 }
1245 1386 });
1246 1387 }
1247 1388
@@ -1354,10 +1495,29 @@
1354 1495 }
1355 1496 }
1356 1497
1357 1498 initDateField() {
1358 - if (!this.dateInput || typeof flatpickr === 'undefined') return;
1499 + if (!this.dateInput) return;
1359 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 +
1360 1520 const today = new Date();
1361 1521 today.setHours(0, 0, 0, 0);
1362 1522
1363 1523 // Format today's date as Y-m-d
@@ -1366,9 +1526,9 @@
1366 1526 String(today.getDate()).padStart(2, '0');
1367 1527
1368 1528 // Set today's date as default value
1369 1529 this.dateInput.value = todayStr;
1370 -
1530 +
1371 1531 // Enable button since we have a default date
1372 1532 if (this.checkAvailabilityBtn) {
1373 1533 this.checkAvailabilityBtn.disabled = false;
1374 1534 this.checkAvailabilityBtn.classList.remove('disabled');
@@ -1393,11 +1553,9 @@
1393 1553 allowInput: false,
1394 1554 static: false,
1395 1555 monthSelectorType: 'static',
1396 1556 animate: true,
1397 - locale: {
1398 - firstDayOfWeek: 1
1399 - },
1557 + locale: getFlatpickrLocale(),
1400 1558 onChange: (selectedDates, dateStr) => {
1401 1559 this.dateInput.value = dateStr;
1402 1560
1403 1561 // Enable Check Availability button when date is selected
@@ -1451,10 +1609,10 @@
1451 1609 if (!minusBtn || !plusBtn) return;
1452 1610
1453 1611 const updateButtonStates = () => {
1454 1612 const current = parseInt(input.value) || 1;
1455 - const min = parseInt(input.getAttribute('min')) || 1;
1456 - 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);
1457 1615
1458 1616 minusBtn.disabled = current <= min;
1459 1617 plusBtn.disabled = current >= max;
1460 1618 };
@@ -1464,9 +1622,9 @@
1464 1622 e.preventDefault();
1465 1623 e.stopPropagation();
1466 1624
1467 1625 const current = parseInt(input.value) || 1;
1468 - const min = parseInt(input.getAttribute('min')) || 1;
1626 + const min = window.yatraNumOr(input.getAttribute('min'), 1);
1469 1627
1470 1628 if (current > min) {
1471 1629 input.value = current - 1;
1472 1630 updateButtonStates();
@@ -1479,9 +1637,9 @@
1479 1637 e.preventDefault();
1480 1638 e.stopPropagation();
1481 1639
1482 1640 const current = parseInt(input.value) || 1;
1483 - const max = parseInt(input.getAttribute('max')) || 20;
1641 + const max = window.yatraNumOr(input.getAttribute('max'), 20);
1484 1642
1485 1643 if (current < max) {
1486 1644 input.value = current + 1;
1487 1645 updateButtonStates();
@@ -1555,21 +1713,25 @@
1555 1713 const parts = [];
1556 1714 travelerInputs.forEach((input) => {
1557 1715 const value = parseInt(input.value || 0);
1558 1716 if (value > 0) {
1559 - const categoryLabel = input.getAttribute('data-category-label') || 'Traveler';
1717 + const categoryLabel = input.getAttribute('data-category-label') || __('Traveler', 'yatra');
1560 1718 parts.push(`${categoryLabel} x ${value}`);
1561 1719 }
1562 1720 });
1563 1721
1564 1722 if (parts.length === 0) {
1565 - // 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.
1566 1727 const firstInput = travelerInputs[0];
1567 1728 if (firstInput) {
1568 - const categoryLabel = firstInput.getAttribute('data-category-label') || 'Traveler';
1569 - 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);
1570 1732 } else {
1571 - display.textContent = '1 Traveler';
1733 + display.textContent = __('1 Traveler', 'yatra');
1572 1734 }
1573 1735 } else {
1574 1736 display.textContent = parts.join(', ');
1575 1737 }
@@ -1663,9 +1825,9 @@
1663 1825 categoryInputs.forEach((catInput) => {
1664 1826 const catValue = parseInt(catInput.value) || 0;
1665 1827 if (catValue > 0) {
1666 1828 const catRow = catInput.closest('.yatra-quantity-row');
1667 - const categoryLabel = catRow ? catRow.querySelector('.yatra-quantity-title')?.textContent : 'Traveler';
1829 + const categoryLabel = catRow ? catRow.querySelector('.yatra-quantity-title')?.textContent : __('Traveler', 'yatra');
1668 1830 parts.push(`${categoryLabel} x ${catValue}`);
1669 1831 }
1670 1832 });
1671 1833 if (parts.length > 0) {
@@ -1740,14 +1902,14 @@
1740 1902
1741 1903
1742 1904 // Basic validation
1743 1905 if (!date) {
1744 - alert('Please select a travel date');
1906 + alert(__('Please select a travel date', 'yatra'));
1745 1907 return;
1746 1908 }
1747 1909
1748 1910 if (totalTravelers === 0) {
1749 - alert('Please select number of travelers');
1911 + alert(__('Please select number of travelers', 'yatra'));
1750 1912 return;
1751 1913 }
1752 1914
1753 1915 // Show loading state
@@ -1754,9 +1916,17 @@
1754 1916 if (this.checkAvailabilityBtn && !this.checkAvailabilityBtn.dataset.originalHtml) {
1755 1917 this.checkAvailabilityBtn.dataset.originalHtml = this.checkAvailabilityBtn.innerHTML;
1756 1918 }
1757 1919 if (this.checkAvailabilityBtn) {
1758 - 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');
1759 1929 this.checkAvailabilityBtn.disabled = true;
1760 1930 }
1761 1931
1762 1932 // Get trip ID from data attribute or global variable
@@ -1796,11 +1966,12 @@
1796 1966 if (!response.ok) {
1797 1967 // Try to get error message from response
1798 1968 return response.json().then(err => {
1799 1969 console.error('API Error:', err);
1800 - throw new Error(err.message || 'Failed to fetch availability');
1970 + throw new Error(err.message || __('Failed to fetch availability', 'yatra'));
1801 1971 }).catch(() => {
1802 - 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));
1803 1974 });
1804 1975 }
1805 1976 return response.json();
1806 1977 })
@@ -1864,9 +2035,9 @@
1864 2035 this.resetButton();
1865 2036 })
1866 2037 .catch(error => {
1867 2038 console.error('Error loading availability:', error);
1868 - alert('Failed to load availability. Please try again.');
2039 + alert(__('Failed to load availability. Please try again.', 'yatra'));
1869 2040 this.resetButton();
1870 2041 });
1871 2042 }
1872 2043
@@ -1915,13 +2086,14 @@
1915 2086 // Create new menu item
1916 2087 const newItem = document.createElement('a');
1917 2088 newItem.href = '#availability';
1918 2089 newItem.className = 'yatra-sticky-nav-item';
2090 + const availabilityLabel = __('Availability', 'yatra');
1919 2091 newItem.innerHTML = `
1920 2092 <svg class="yatra-icon-sm" fill="none" stroke="currentColor" viewBox="0 0 24 24">
1921 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"/>
1922 2094 </svg>
1923 - <span>Availability</span>
2095 + <span>${availabilityLabel}</span>
1924 2096 `;
1925 2097
1926 2098 // Insert before the target item (or after "What's Included" if no target)
1927 2099 if (insertBeforeItem && insertBeforeItem.parentNode) {
@@ -1988,9 +2160,9 @@
1988 2160 if (this.checkAvailabilityBtn && !this.checkAvailabilityBtn.dataset.originalHtml) {
1989 2161 this.checkAvailabilityBtn.dataset.originalHtml = this.checkAvailabilityBtn.innerHTML;
1990 2162 }
1991 2163 if (this.checkAvailabilityBtn) {
1992 - 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');
1993 2165 this.checkAvailabilityBtn.disabled = true;
1994 2166 }
1995 2167
1996 2168 const availSection = document.getElementById('availability');
@@ -2014,9 +2186,9 @@
2014 2186 credentials: 'omit',
2015 2187 })
2016 2188 .then(response => {
2017 2189 if (!response.ok) {
2018 - throw new Error('Failed to fetch availability');
2190 + throw new Error(__('Failed to fetch availability', 'yatra'));
2019 2191 }
2020 2192 return response.json();
2021 2193 })
2022 2194 .then(data => {
@@ -2051,9 +2223,9 @@
2051 2223 this.resetButton();
2052 2224 })
2053 2225 .catch(error => {
2054 2226 console.error('Error reloading availability:', error);
2055 - alert('Failed to reload availability. Please try again.');
2227 + alert(__('Failed to reload availability. Please try again.', 'yatra'));
2056 2228 this.resetButton();
2057 2229 });
2058 2230 }
2059 2231
@@ -2151,9 +2323,13 @@
2151 2323 if (data && data.success) {
2152 2324 // Update departures available text
2153 2325 const departuresText = document.querySelector('.yatra-departures-available-text');
2154 2326 if (departuresText && data.departures_count !== undefined) {
2155 - 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 + );
2156 2332 }
2157 2333
2158 2334 // Update traveler dropdown HTML
2159 2335 const quantitySelector = document.getElementById('quantity-selector');
@@ -2207,15 +2383,15 @@
2207 2383 const row = travelerSelector.querySelector('.yatra-quantity-row[data-category-id="' + categoryId + '"]');
2208 2384 if (row) {
2209 2385 const cardInput = row.querySelector('input[type="number"]');
2210 2386 if (cardInput) {
2211 - const max = parseInt(cardInput.getAttribute('max')) || 99;
2387 + const max = window.yatraNumOr(cardInput.getAttribute('max'), 99);
2212 2388 cardInput.value = Math.min(value, max);
2213 2389
2214 2390 // Update button states
2215 2391 const minusBtn = row.querySelector('.yatra-quantity-minus');
2216 2392 const plusBtn = row.querySelector('.yatra-quantity-plus');
2217 - const min = parseInt(cardInput.getAttribute('min')) || 0;
2393 + const min = window.yatraNumOr(cardInput.getAttribute('min'), 0);
2218 2394 if (minusBtn) minusBtn.disabled = cardInput.value <= min;
2219 2395 if (plusBtn) plusBtn.disabled = cardInput.value >= max;
2220 2396 }
2221 2397 }
@@ -2226,9 +2402,9 @@
2226 2402
2227 2403 } else if (simpleTravelers && numTravelersInput) {
2228 2404 // Simple pricing - sync total count
2229 2405 const totalCount = parseInt(numTravelersInput.value) || 1;
2230 - const max = parseInt(simpleTravelers.getAttribute('max')) || 99;
2406 + const max = window.yatraNumOr(simpleTravelers.getAttribute('max'), 99);
2231 2407 simpleTravelers.value = Math.min(totalCount, max);
2232 2408
2233 2409 // Update button states
2234 2410 const controls = simpleTravelers.closest('.yatra-quantity-controls-inline');
@@ -2234,9 +2410,9 @@
2234 2410 const controls = simpleTravelers.closest('.yatra-quantity-controls-inline');
2235 2411 if (controls) {
2236 2412 const minusBtn = controls.querySelector('.yatra-quantity-minus');
2237 2413 const plusBtn = controls.querySelector('.yatra-quantity-plus');
2238 - const min = parseInt(simpleTravelers.getAttribute('min')) || 1;
2414 + const min = window.yatraNumOr(simpleTravelers.getAttribute('min'), 1);
2239 2415 if (minusBtn) minusBtn.disabled = simpleTravelers.value <= min;
2240 2416 if (plusBtn) plusBtn.disabled = simpleTravelers.value >= max;
2241 2417 }
2242 2418 }
@@ -2269,9 +2445,9 @@
2269 2445 }
2270 2446 }
2271 2447 });
2272 2448
2273 - display.textContent = parts.length > 0 ? parts.join(', ') : '0 Travelers';
2449 + display.textContent = parts.length > 0 ? parts.join(', ') : __('0 Travelers', 'yatra');
2274 2450 }
2275 2451
2276 2452 /**
2277 2453 * Update availability card total price
@@ -2299,13 +2475,17 @@
2299 2475 const input = row.querySelector('input[type="number"]');
2300 2476 const price = parseFloat(row.getAttribute('data-price')) || 0;
2301 2477 const count = parseInt(input?.value) || 0;
2302 2478 const pricingMode = row.getAttribute('data-pricing-mode') || (input ? input.getAttribute('data-pricing-mode') : '') || 'per_person';
2303 -
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 +
2304 2482 if (pricingMode === 'per_group') {
2305 - // Per group: charge flat price once if any travelers
2483 + // Per group: one flat price, or per block of maxPax when "per_block".
2306 2484 if (count > 0) {
2307 - total += price;
2485 + total += (groupOverflow === 'per_block' && maxPax > 0)
2486 + ? price * Math.ceil(count / maxPax)
2487 + : price;
2308 2488 }
2309 2489 } else {
2310 2490 // Per person: charge per traveler
2311 2491 total += price * count;
@@ -2334,9 +2514,10 @@
2334 2514 totalAmountEl.textContent = String(total.toFixed(2));
2335 2515 }
2336 2516
2337 2517 if (totalNoteEl) {
2338 - 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);
2339 2520 }
2340 2521
2341 2522 // Update the card participants display
2342 2523 this.updateAvailabilityCardDisplay(itemId);
@@ -2481,11 +2662,9 @@
2481 2662 allowInput: false,
2482 2663 static: false,
2483 2664 monthSelectorType: 'static',
2484 2665 animate: true,
2485 - locale: {
2486 - firstDayOfWeek: 1
2487 - },
2666 + locale: getFlatpickrLocale(),
2488 2667 onChange: (selectedDates, dateStr) => {
2489 2668 this.dateInput.value = dateStr;
2490 2669 }
2491 2670 });
@@ -2539,17 +2718,22 @@
2539 2718 // Update display function
2540 2719 const updateDisplay = () => {
2541 2720 const adults = parseInt(adultsInput.value || 1);
2542 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);
2543 2726 let text = '';
2544 2727 if (adults > 0 && children > 0) {
2545 - 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);
2546 2730 } else if (adults > 0) {
2547 - text = `Adult x ${adults}`;
2731 + text = adultPart;
2548 2732 } else if (children > 0) {
2549 - text = `Child x ${children}`;
2733 + text = childPart;
2550 2734 } else {
2551 - text = 'Adult x 1';
2735 + text = sprintf(_n('Adult x %d', 'Adult x %d', 1, 'yatra'), 1);
2552 2736 }
2553 2737 display.textContent = text;
2554 2738 };
2555 2739
@@ -2635,9 +2819,9 @@
2635 2819 const email = formData.get('email');
2636 2820 const message = formData.get('message');
2637 2821
2638 2822 if (!name || !email || !message) {
2639 - this.showMessage('error', 'Please fill in all required fields.');
2823 + this.showMessage('error', __('Please fill in all required fields.', 'yatra'));
2640 2824 return;
2641 2825 }
2642 2826
2643 2827 // Basic email validation
@@ -2642,22 +2826,22 @@
2642 2826
2643 2827 // Basic email validation
2644 2828 const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
2645 2829 if (!emailRegex.test(email)) {
2646 - this.showMessage('error', 'Please enter a valid email address.');
2830 + this.showMessage('error', __('Please enter a valid email address.', 'yatra'));
2647 2831 return;
2648 2832 }
2649 2833
2650 2834 if (message.length < 10) {
2651 - this.showMessage('error', 'Please enter a message (at least 10 characters).');
2835 + this.showMessage('error', __('Please enter a message (at least 10 characters).', 'yatra'));
2652 2836 return;
2653 2837 }
2654 2838
2655 2839 // Show loading
2656 2840 const submitBtn = this.form.querySelector('.yatra-enquiry-submit');
2657 - const originalText = submitBtn ? submitBtn.textContent : 'Send Enquiry';
2841 + const originalText = submitBtn ? submitBtn.textContent : __('Send Enquiry', 'yatra');
2658 2842 if (submitBtn) {
2659 - submitBtn.textContent = 'Sending...';
2843 + submitBtn.textContent = __('Sending...', 'yatra');
2660 2844 submitBtn.disabled = true;
2661 2845 }
2662 2846
2663 2847 // Hide any existing messages
@@ -2666,8 +2850,15 @@
2666 2850 }
2667 2851
2668 2852 // Submit via REST API (public endpoint)
2669 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 + }
2670 2861
2671 2862 // Build REST URL
2672 2863 const baseUrlRaw = (window.yatraTripData?.apiUrl || window.yatraTripData?.restUrl || window.wpApiSettings?.root || '/wp-json').replace(/\/$/, '');
2673 2864 const enquiriesUrl = baseUrlRaw.includes('/yatra/v1')
@@ -2673,26 +2864,38 @@
2673 2864 const enquiriesUrl = baseUrlRaw.includes('/yatra/v1')
2674 2865 ? `${baseUrlRaw}/enquiries`
2675 2866 : `${baseUrlRaw}/yatra/v1/enquiries`;
2676 2867
2677 - fetch(enquiriesUrl, {
2678 - method: 'POST',
2679 - headers: {
2680 - 'Content-Type': 'application/json',
2681 - },
2682 - credentials: 'omit', // public endpoint
2683 - body: JSON.stringify(payload),
2684 - })
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 + })
2685 2888 .then(response => response.json())
2686 2889 .then(result => {
2687 2890 if (result.success) {
2688 - 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'));
2689 2892 this.form.reset();
2690 2893
2691 2894 // Reset the travelers display
2692 2895 const display = document.getElementById('enquiry-participants-display');
2693 2896 if (display) {
2694 - display.textContent = 'Adult x 1';
2897 + display.textContent = __('Adult x 1', 'yatra');
2695 2898 }
2696 2899 const adultsInput = document.getElementById('enquiry-adults');
2697 2900 const childrenInput = document.getElementById('enquiry-children');
2698 2901 if (adultsInput) adultsInput.value = 1;
@@ -2705,14 +2908,14 @@
2705 2908 messageBox.style.display = 'none';
2706 2909 }
2707 2910 }, 3000);
2708 2911 } else {
2709 - 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'));
2710 2913 }
2711 2914 })
2712 2915 .catch(error => {
2713 2916 console.error('Enquiry submission error:', error);
2714 - this.showMessage('error', 'An error occurred. Please try again later.');
2917 + this.showMessage('error', __('An error occurred. Please try again later.', 'yatra'));
2715 2918 })
2716 2919 .finally(() => {
2717 2920 if (submitBtn) {
2718 2921 submitBtn.textContent = originalText;
@@ -2777,21 +2980,33 @@
2777 2980 this.updateSidebarPosition(); // Initial check
2778 2981 }
2779 2982
2780 2983 getActualSections() {
2781 - // Base sections that are always present in order
2782 - const baseSections = ['overview', 'itinerary', 'included', 'location', 'important-info'];
2783 -
2784 - // Conditional sections that may or may not be present
2785 - const conditionalSections = ['downloads', 'faq', 'trip-story', 'what-makes-special', 'testimonials'];
2786 -
2787 - // Combine all sections and filter to only include those that actually exist on the page
2788 - const allSections = [...baseSections, ...conditionalSections];
2789 -
2790 - return allSections.filter(sectionId => {
2791 - const element = document.getElementById(sectionId);
2792 - 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 + }
2793 3007 });
3008 + return ids;
2794 3009 }
2795 3010
2796 3011 attachEventListeners() {
2797 3012 // Handle clicks on nav items
@@ -2963,8 +3178,15 @@
2963 3178 this.lastScrollTop = scrollTop;
2964 3179 }
2965 3180
2966 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.
2967 3189 const scrollPos = window.scrollY + 150;
2968 3190 let activeSectionId = null;
2969 3191
2970 3192 // Find which section is currently in view
@@ -2970,10 +3192,11 @@
2970 3192 // Find which section is currently in view
2971 3193 this.sections.forEach((sectionId) => {
2972 3194 const section = document.getElementById(sectionId);
2973 3195 if (section) {
2974 - const sectionTop = section.offsetTop;
2975 - const sectionBottom = sectionTop + section.offsetHeight;
3196 + const rect = section.getBoundingClientRect();
3197 + const sectionTop = rect.top + window.pageYOffset;
3198 + const sectionBottom = sectionTop + rect.height;
2976 3199
2977 3200 if (scrollPos >= sectionTop && scrollPos < sectionBottom) {
2978 3201 activeSectionId = sectionId;
2979 3202 }
@@ -3120,14 +3343,14 @@
3120 3343 }
3121 3344 }
3122 3345
3123 3346 if (travelers < 1) {
3124 - this.showBookingError(btn, 'Please select at least 1 traveler to continue.');
3347 + this.showBookingError(btn, __('Please select at least 1 traveler to continue.', 'yatra'));
3125 3348 return;
3126 3349 }
3127 3350
3128 3351 const originalText = btn.innerHTML;
3129 - 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');
3130 3353 btn.disabled = true;
3131 3354
3132 3355 if (!tripId || isNaN(parseInt(tripId)) || parseInt(tripId) <= 0) {
3133 3356 console.error('Invalid trip ID:', tripId);
@@ -3132,9 +3355,9 @@
3132 3355 if (!tripId || isNaN(parseInt(tripId)) || parseInt(tripId) <= 0) {
3133 3356 console.error('Invalid trip ID:', tripId);
3134 3357 btn.innerHTML = originalText;
3135 3358 btn.disabled = false;
3136 - this.showBookingError(btn, 'Unable to process booking. Invalid trip data.');
3359 + this.showBookingError(btn, __('Unable to process booking. Invalid trip data.', 'yatra'));
3137 3360 return;
3138 3361 }
3139 3362
3140 3363 const pricingType = btn.getAttribute('data-pricing-type') || 'regular';
@@ -3355,9 +3578,9 @@
3355 3578 currency: window.yatraBookingData?.currency || 'USD',
3356 3579 num_items: sessionPayload.travelers || 1
3357 3580 });
3358 3581
3359 - 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);
3360 3583 }
3361 3584
3362 3585 // Track Book Now click as begin_checkout (Google Analytics 4)
3363 3586 if (typeof gtag !== 'undefined' && window.yatraAdmin?.googleAnalytics?.measurementId) {
@@ -3375,9 +3598,9 @@
3375 3598 quantity: sessionPayload.travelers || 1
3376 3599 }]
3377 3600 });
3378 3601
3379 - 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);
3380 3603 }
3381 3604
3382 3605 // Set booking session via REST API
3383 3606 // credentials: 'same-origin' is required to send cookies for session
@@ -3408,9 +3631,9 @@
3408 3631 // Error - show message and reset button
3409 3632 console.error('Booking session error:', data);
3410 3633 btn.innerHTML = originalText;
3411 3634 btn.disabled = false;
3412 - 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'));
3413 3636 }
3414 3637 })
3415 3638 .catch(error => {
3416 3639 console.error('Error setting booking session:', error);
@@ -3415,9 +3638,9 @@
3415 3638 .catch(error => {
3416 3639 console.error('Error setting booking session:', error);
3417 3640 btn.innerHTML = originalText;
3418 3641 btn.disabled = false;
3419 - this.showBookingError(btn, 'Unable to process booking. Please try again.');
3642 + this.showBookingError(btn, __('Unable to process booking. Please try again.', 'yatra'));
3420 3643 });
3421 3644 }
3422 3645
3423 3646 initTravelerSelectors() {
@@ -3449,18 +3672,21 @@
3449 3672 categoryInputs.forEach((input) => {
3450 3673 const value = parseInt(input.value) || 0;
3451 3674 if (value > 0) {
3452 3675 const row = input.closest('.yatra-quantity-row');
3453 - const categoryLabel = row ? row.querySelector('.yatra-quantity-title')?.textContent : 'Traveler';
3676 + const categoryLabel = row ? row.querySelector('.yatra-quantity-title')?.textContent : __('Traveler', 'yatra');
3454 3677 parts.push(`${categoryLabel} x ${value}`);
3455 3678 }
3456 3679 });
3457 3680
3458 3681 if (parts.length === 0) {
3459 - // 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.
3460 3685 const firstRow = categoryInputs[0]?.closest('.yatra-quantity-row');
3461 - const firstLabel = firstRow ? firstRow.querySelector('.yatra-quantity-title')?.textContent : 'Traveler';
3462 - 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);
3463 3689 } else {
3464 3690 display.textContent = parts.join(', ');
3465 3691 }
3466 3692 };
@@ -3482,10 +3708,10 @@
3482 3708 const isMinus = btn.classList.contains('yatra-quantity-minus');
3483 3709 const isPlus = btn.classList.contains('yatra-quantity-plus');
3484 3710
3485 3711 let current = parseInt(input.value) || 0;
3486 - const min = parseInt(input.getAttribute('min')) || 0;
3487 - 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);
3488 3714 let newValue = current;
3489 3715
3490 3716 if (isPlus && current < max) {
3491 3717 newValue = current + 1;
@@ -3557,10 +3783,10 @@
3557 3783 const isMinus = btn.classList.contains('yatra-quantity-minus');
3558 3784 const isPlus = btn.classList.contains('yatra-quantity-plus');
3559 3785
3560 3786 let current = parseInt(input.value) || 1;
3561 - const min = parseInt(input.getAttribute('min')) || 1;
3562 - 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);
3563 3789 let newValue = current;
3564 3790
3565 3791 if (isPlus && current < max) {
3566 3792 newValue = current + 1;
@@ -3632,9 +3858,9 @@
3632 3858 categoryInputs.forEach((input) => {
3633 3859 const value = parseInt(input.value) || 0;
3634 3860 if (value > 0) {
3635 3861 const row = input.closest('.yatra-quantity-row');
3636 - const categoryLabel = row ? row.querySelector('.yatra-quantity-title')?.textContent : 'Traveler';
3862 + const categoryLabel = row ? row.querySelector('.yatra-quantity-title')?.textContent : __('Traveler', 'yatra');
3637 3863 parts.push(`${categoryLabel} x ${value}`);
3638 3864
3639 3865 // Also update sidebar inputs if they exist
3640 3866 const categoryId = input.getAttribute('data-category');
@@ -3670,8 +3896,9 @@
3670 3896 // Check for traveler-based pricing first
3671 3897 const categoryInputs = this.section.querySelectorAll(`.yatra-availability-category[data-item="${itemIndex}"]`);
3672 3898 let totalPrice = 0;
3673 3899 let totalTravelers = 0;
3900 + let totalGroups = 0; // per-group categories: number of group prices charged
3674 3901
3675 3902 if (categoryInputs.length > 0) {
3676 3903 // Traveler-based pricing: sum up each category's price * quantity (respecting pricing mode)
3677 3904 categoryInputs.forEach((input) => {
@@ -3678,12 +3905,16 @@
3678 3905 const quantity = parseInt(input.value) || 0;
3679 3906 const row = input.closest('.yatra-quantity-row');
3680 3907 const price = row ? parseFloat(row.getAttribute('data-price')) || 0 : 0;
3681 3908 const pricingMode = (row ? row.getAttribute('data-pricing-mode') : null) || input.getAttribute('data-pricing-mode') || 'per_person';
3682 -
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 +
3683 3912 if (pricingMode === 'per_group') {
3684 3913 if (quantity > 0) {
3685 - totalPrice += price;
3914 + const blocks = (groupOverflow === 'per_block' && maxPax > 0) ? Math.ceil(quantity / maxPax) : 1;
3915 + totalPrice += price * blocks;
3916 + totalGroups += blocks;
3686 3917 }
3687 3918 } else {
3688 3919 totalPrice += quantity * price;
3689 3920 }
@@ -3711,14 +3942,21 @@
3711 3942 } else {
3712 3943 totalAmountElement.textContent = String(finalPrice.toFixed(2));
3713 3944 }
3714 3945
3715 - // Header "per person" must match chargable average (row totals + group discount), not static PHP.
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).
3716 3950 const cardRoot = this.section.querySelector('.yatra-availability-card[data-item="' + itemIndex + '"]');
3717 - const headerSaleEl = cardRoot ? cardRoot.querySelector('.yatra-card-header-price .yatra-sale-price') : null;
3951 + const headerPriceWrap = cardRoot ? cardRoot.querySelector('.yatra-card-header-price') : null;
3952 + const headerSaleEl = headerPriceWrap ? headerPriceWrap.querySelector('.yatra-sale-price') : null;
3718 3953 if (headerSaleEl) {
3954 + const headerUnit = headerPriceWrap.getAttribute('data-price-unit') || 'per_person';
3719 3955 let perPerson = 0;
3720 - if (totalTravelers > 0) {
3956 + if (headerUnit === 'per_group') {
3957 + perPerson = totalGroups > 0 ? finalPrice / totalGroups : (totalPrice > 0 ? totalPrice : 0);
3958 + } else if (totalTravelers > 0) {
3721 3959 perPerson = finalPrice / totalTravelers;
3722 3960 } else if (totalPrice > 0) {
3723 3961 perPerson = totalPrice;
3724 3962 }
@@ -3732,11 +3970,13 @@
3732 3970 const noteElement = this.section.querySelector(
3733 3971 `.yatra-card-total-note[data-item="${itemIndex}"]`
3734 3972 );
3735 3973 if (noteElement) {
3736 - let travelerText = totalTravelers === 1
3737 - ? 'for 1 traveler'
3738 - : `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 + );
3739 3979
3740 3980 // Add group discount info if applied
3741 3981 if (groupDiscountApplied && groupDiscountAmount > 0) {
3742 3982 const discountText = (typeof yatra_format_price_js === 'function')
@@ -3741,9 +3981,13 @@
3741 3981 if (groupDiscountApplied && groupDiscountAmount > 0) {
3742 3982 const discountText = (typeof yatra_format_price_js === 'function')
3743 3983 ? yatra_format_price_js(groupDiscountAmount)
3744 3984 : String(groupDiscountAmount.toFixed(2));
3745 - travelerText += ` (Group discount: -${discountText})`;
3985 + travelerText += sprintf(
3986 + /* translators: %s: formatted discount amount. */
3987 + __(' (Group discount: -%s)', 'yatra'),
3988 + discountText
3989 + );
3746 3990 }
3747 3991 noteElement.textContent = travelerText;
3748 3992 }
3749 3993
@@ -3858,10 +4102,14 @@
3858 4102 }
3859 4103
3860 4104 // Update badge content
3861 4105 const discountText = discountResult.discountType === 'percentage'
3862 - ? `${discountResult.discountPercentage}% group discount applied!`
3863 - : `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');
3864 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}`;
3865 4113 } else if (badge) {
3866 4114 badge.remove();
3867 4115 }
@@ -3941,9 +4189,9 @@
3941 4189 }
3942 4190
3943 4191 // Show success feedback
3944 4192 const originalText = button.textContent;
3945 - button.textContent = 'Selected!';
4193 + button.textContent = __('Selected!', 'yatra');
3946 4194 button.style.background = '#10b981';
3947 4195 setTimeout(() => {
3948 4196 button.textContent = originalText;
3949 4197 button.style.background = '';
@@ -3968,9 +4216,11 @@
3968 4216 const travelDate = travelDateInput && travelDateInput.value ? travelDateInput.value : undefined;
3969 4217
3970 4218 const originalHTML = btn.innerHTML;
3971 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> ';
3972 - 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');
3973 4223 btn.disabled = true;
3974 4224
3975 4225 const url = buildAvailabilityTemplateUrl(tripId, {
3976 4226 date: travelDate,
@@ -3989,9 +4239,9 @@
3989 4239 credentials: 'omit',
3990 4240 })
3991 4241 .then((resp) => {
3992 4242 if (!resp.ok) {
3993 - throw new Error('Load more failed');
4243 + throw new Error(__('Load more failed', 'yatra'));
3994 4244 }
3995 4245 return resp.json();
3996 4246 })
3997 4247 .then((data) => {
@@ -4028,9 +4278,11 @@
4028 4278 return;
4029 4279 }
4030 4280
4031 4281 const remaining = Math.max(0, total - displayed);
4032 - 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}`;
4033 4285 btn.disabled = false;
4034 4286
4035 4287 loadMoreContainer.dataset.currentPage = String(nextPage);
4036 4288 loadMoreContainer.dataset.loaded = String(displayed);
@@ -4036,9 +4288,17 @@
4036 4288 loadMoreContainer.dataset.loaded = String(displayed);
4037 4289 loadMoreContainer.dataset.total = String(total);
4038 4290
4039 4291 if (countInfo) {
4040 - 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 + );
4041 4301 }
4042 4302 })
4043 4303 .catch((err) => {
4044 4304 console.error('[Yatra] Load more failed:', err);
@@ -4202,9 +4462,9 @@
4202 4462 if (!this.toggleAllBtn) return;
4203 4463 this.toggleAllBtn.classList.toggle('is-all-expanded', isExpanded);
4204 4464 const toggleText = this.toggleAllBtn.querySelector('.toggle-text');
4205 4465 if (toggleText) {
4206 - toggleText.textContent = isExpanded ? 'Collapse All' : 'Expand All';
4466 + toggleText.textContent = isExpanded ? __('Collapse All', 'yatra') : __('Expand All', 'yatra');
4207 4467 }
4208 4468 }
4209 4469 }
4210 4470
@@ -4317,9 +4577,10 @@
4317 4577 const originalText = submitBtn.innerHTML;
4318 4578
4319 4579 // Disable button and show loading
4320 4580 submitBtn.disabled = true;
4321 - submitBtn.innerHTML = '<span class="yatra-loading-spinner"></span> Submitting...';
4581 + submitBtn.innerHTML =
4582 + '<span class="yatra-loading-spinner"></span> ' + __('Submitting...', 'yatra');
4322 4583
4323 4584 try {
4324 4585 const formData = new FormData(this.form);
4325 4586 formData.append('action', 'yatra_submit_review');
@@ -4333,9 +4594,9 @@
4333 4594 const result = await response.json();
4334 4595
4335 4596 if (result.success) {
4336 4597 // Show success message
4337 - this.showMessage('success', result.data?.message || 'Thank you for your review!');
4598 + this.showMessage('success', result.data?.message || __('Thank you for your review!', 'yatra'));
4338 4599
4339 4600 // Check if this was an edit or new submission
4340 4601 const isEdit = this.form.querySelector('input[name="action_type"]')?.value === 'edit';
4341 4602
@@ -4352,13 +4613,13 @@
4352 4613 ratingLabels.forEach(label => label.classList.add('selected'));
4353 4614 }
4354 4615 }
4355 4616 } else {
4356 - 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'));
4357 4618 }
4358 4619 } catch (error) {
4359 4620 console.error('Review submission error:', error);
4360 - this.showMessage('error', 'An error occurred. Please try again.');
4621 + this.showMessage('error', __('An error occurred. Please try again.', 'yatra'));
4361 4622 } finally {
4362 4623 submitBtn.disabled = false;
4363 4624 submitBtn.innerHTML = originalText;
4364 4625 }
@@ -4532,16 +4793,13 @@
4532 4793 * Handles save/remove trip from wishlist functionality
4533 4794 */
4534 4795 class WishlistHandler {
4535 4796 constructor() {
4536 - if (!window.yatraTripData?.wishlistEnabled) {
4537 - return;
4538 - }
4539 - this.apiUrl = window.yatraTripData?.apiUrl || '/wp-json/yatra/v1';
4540 - this.nonce = window.yatraTripData?.nonce || '';
4541 - this.tripId = window.yatraTripData?.tripId || 0;
4542 - this.isLoggedIn = window.yatraTripData?.isLoggedIn || false;
4543 - 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;
4544 4802 }
4545 4803
4546 4804 init() {
4547 4805 // Check if user is logged in and update button states
@@ -4624,15 +4882,15 @@
4624 4882
4625 4883 const data = await response.json();
4626 4884 if (data.success) {
4627 4885 this.updateButtonState(true, tid);
4628 - this.showMessage(data.message || 'Trip saved to wishlist', 'success', btn);
4886 + this.showMessage(data.message || __('Trip saved to wishlist', 'yatra'), 'success', btn);
4629 4887 } else {
4630 - this.showMessage(data.message || 'Failed to save trip', 'error', btn);
4888 + this.showMessage(data.message || __('Failed to save trip', 'yatra'), 'error', btn);
4631 4889 }
4632 4890 } catch (error) {
4633 4891 console.error('Error saving trip:', error);
4634 - this.showMessage('An error occurred. Please try again.', 'error', btn);
4892 + this.showMessage(__('An error occurred. Please try again.', 'yatra'), 'error', btn);
4635 4893 } finally {
4636 4894 btn.disabled = false;
4637 4895 btn.classList.remove('loading');
4638 4896 }
@@ -4654,15 +4912,15 @@
4654 4912
4655 4913 const data = await response.json();
4656 4914 if (data.success) {
4657 4915 this.updateButtonState(false, tid);
4658 - this.showMessage(data.message || 'Trip removed from wishlist', 'success', btn);
4916 + this.showMessage(data.message || __('Trip removed from wishlist', 'yatra'), 'success', btn);
4659 4917 } else {
4660 - this.showMessage(data.message || 'Failed to remove trip', 'error', btn);
4918 + this.showMessage(data.message || __('Failed to remove trip', 'yatra'), 'error', btn);
4661 4919 }
4662 4920 } catch (error) {
4663 4921 console.error('Error removing trip:', error);
4664 - this.showMessage('An error occurred. Please try again.', 'error', btn);
4922 + this.showMessage(__('An error occurred. Please try again.', 'yatra'), 'error', btn);
4665 4923 } finally {
4666 4924 btn.disabled = false;
4667 4925 btn.classList.remove('loading');
4668 4926 }
@@ -4676,9 +4934,9 @@
4676 4934 const buttons = document.querySelectorAll(selector);
4677 4935 buttons.forEach(btn => {
4678 4936 if (isSaved) {
4679 4937 btn.classList.add('saved', 'is-saved');
4680 - btn.setAttribute('aria-label', 'Remove from favorites');
4938 + btn.setAttribute('aria-label', __('Remove from favorites', 'yatra'));
4681 4939 // Fill the heart icon and change color
4682 4940 const svg = btn.querySelector('svg');
4683 4941 if (svg) {
4684 4942 svg.style.fill = 'currentColor';
@@ -4686,9 +4944,9 @@
4686 4944 btn.style.color = '#ef4444';
4687 4945 }
4688 4946 } else {
4689 4947 btn.classList.remove('saved', 'is-saved');
4690 - btn.setAttribute('aria-label', 'Add to favorites');
4948 + btn.setAttribute('aria-label', __('Add to favorites', 'yatra'));
4691 4949 // Unfill the heart icon
4692 4950 const svg = btn.querySelector('svg');
4693 4951 if (svg) {
4694 4952 svg.style.fill = 'none';
@@ -4712,13 +4970,17 @@
4712 4970 if (modalContent) {
4713 4971 const loginMessage = document.createElement('div');
4714 4972 loginMessage.className = 'yatra-login-prompt';
4715 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');
4716 4978 loginMessage.innerHTML = `
4717 - <h3 style="margin-bottom: 20px;">Login Required</h3>
4718 - <p style="margin-bottom: 30px;">Please login to save trips to your wishlist.</p>
4719 - <a href="${window.yatraTripData?.loginUrl || '/wp-login.php'}" class="yatra-btn" style="display: inline-block; margin-right: 10px;">Login</a>
4720 - <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>
4721 4983 `;
4722 4984 modalContent.innerHTML = '';
4723 4985 modalContent.appendChild(loginMessage);
4724 4986 }
@@ -4723,9 +4985,9 @@
4723 4985 modalContent.appendChild(loginMessage);
4724 4986 }
4725 4987 } else {
4726 4988 // Fallback: show alert and redirect to login
4727 - 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'))) {
4728 4990 window.location.href = window.yatraTripData?.loginUrl || '/wp-login.php';
4729 4991 }
4730 4992 }
4731 4993 }
@@ -4975,15 +5237,15 @@
4975 5237 const videoUrl = link.getAttribute('href');
4976 5238 if (!videoUrl || videoUrl === '#') return;
4977 5239
4978 5240 const img = overlay.closest('.yatra-media-card').querySelector('img');
4979 - const videoTitle = img ? img.alt : 'Itinerary Video';
5241 + const videoTitle = img ? img.alt : __('Itinerary Video', 'yatra');
4980 5242
4981 5243 // Extract video ID from URL
4982 5244 let videoId = '';
4983 5245 if (videoUrl.includes('youtube.com') || videoUrl.includes('youtu.be')) {
4984 5246 // YouTube video ID extraction
4985 - 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?#\/]+)/);
4986 5248 videoId = matches ? matches[1] : '';
4987 5249 } else if (videoUrl.includes('vimeo.com')) {
4988 5250 // Vimeo video ID extraction
4989 5251 const matches = videoUrl.match(/vimeo\.com\/(?:.*#|.\/videos\/)?([0-9]+)/);
@@ -5096,9 +5358,9 @@
5096 5358 try {
5097 5359 // Get download URL with signature using centralized helper
5098 5360 const response = await window.YatraApiHelper.getDownloadInfo(downloadId);
5099 5361 if (!response.ok) {
5100 - throw new Error('Download failed');
5362 + throw new Error(__('Download failed', 'yatra'));
5101 5363 }
5102 5364
5103 5365 const data = await response.json();
5104 5366
@@ -5115,9 +5377,9 @@
5115 5377 }
5116 5378 } catch (error) {
5117 5379 console.error('Download error:', error);
5118 5380 // Show error message to user
5119 - this.showNotification('Download failed. Please try again.', 'error');
5381 + this.showNotification(__('Download failed. Please try again.', 'yatra'), 'error');
5120 5382 }
5121 5383 }
5122 5384
5123 5385 async previewFile(downloadId) {
@@ -5127,15 +5389,15 @@
5127 5389
5128 5390 // Get download info using centralized helper
5129 5391 const response = await window.YatraApiHelper.getDownloadInfo(downloadId);
5130 5392 if (!response.ok) {
5131 - throw new Error('Failed to get file info');
5393 + throw new Error(__('Failed to get file info', 'yatra'));
5132 5394 }
5133 5395
5134 5396 const data = await response.json();
5135 5397
5136 5398 if (data.download_url) {
5137 - this.loadPreview(data.download_url, data.title || 'Preview');
5399 + this.loadPreview(data.download_url, data.title || __('Preview', 'yatra'));
5138 5400 } else {
5139 5401 throw new Error('File URL not found');
5140 5402 }
5141 5403 } catch (error) {
@@ -5140,9 +5402,9 @@
5140 5402 }
5141 5403 } catch (error) {
5142 5404 console.error('Preview error:', error);
5143 5405 this.closePreviewModal();
5144 - this.showNotification('Failed to load preview. Please try again.', 'error');
5406 + this.showNotification(__('Failed to load preview. Please try again.', 'yatra'), 'error');
5145 5407 }
5146 5408 }
5147 5409
5148 5410 loadPreview(fileUrl, title) {
@@ -5170,13 +5432,15 @@
5170 5432 previewContent.innerHTML = `
5171 5433 <iframe src="${fileUrl}" style="width: 100%; height: 500px; border: none; border-radius: 8px;"></iframe>
5172 5434 `;
5173 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');
5174 5438 previewContent.innerHTML = `
5175 5439 <div style="text-align: center; padding: 40px;">
5176 5440 <div style="font-size: 48px; margin-bottom: 16px; opacity: 0.5;">📄</div>
5177 - <p style="color: #6b7280; margin-bottom: 16px;">Preview not available for this file type</p>
5178 - <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>
5179 5443 </div>
5180 5444 `;
5181 5445 }
5182 5446 }
@@ -5198,12 +5462,13 @@
5198 5462
5199 5463 // Reset loading state
5200 5464 const previewContent = document.getElementById('preview-content');
5201 5465 if (previewContent) {
5466 + const loadingPreviewLabel = __('Loading preview...', 'yatra');
5202 5467 previewContent.innerHTML = `
5203 5468 <div class="yatra-preview-loading">
5204 5469 <div class="yatra-preview-spinner"></div>
5205 - <p>Loading preview...</p>
5470 + <p>${loadingPreviewLabel}</p>
5206 5471 </div>
5207 5472 `;
5208 5473 }
5209 5474 }