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 +332 -154 3.0.4 → 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 /**
@@ -165,9 +205,14 @@
165 205 const decimalSep = (typeof cfg.decimalSeparator === 'string') ? cfg.decimalSeparator : '.';
166 206
167 207 const num = Number(amount) || 0;
168 208
169 - 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', {
170 215 minimumFractionDigits: decimals,
171 216 maximumFractionDigits: decimals,
172 217 }).format(num)
173 218 .replace(/,/g, 'TEMP_THOUSAND')
@@ -293,9 +338,17 @@
293 338 const mediaData = JSON.parse(mediaScript.textContent || '{}');
294 339 const imageUrls = Array.isArray(mediaData.images) ? mediaData.images : [];
295 340 imageUrls.forEach((url, index) => {
296 341 if (url && typeof url === 'string' && !this.images.some(i => i.src === url)) {
297 - 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 + });
298 351 }
299 352 });
300 353 } catch (e) {
301 354 // JSON parse error – fall through to DOM-based collection.
@@ -303,23 +356,24 @@
303 356 }
304 357
305 358 // 2. Collect from hero slides (covers any images not in the JSON blob).
306 359 document.querySelectorAll('.yatra-trip-hero-slide img').forEach((img, index) => {
307 - 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)));
308 362 });
309 363
310 364 // 3. Collect from gallery section (hero gallery only, NOT itinerary).
311 365 document.querySelectorAll('.yatra-trip-gallery .yatra-gallery-item img').forEach((img) => {
312 - add(img, 'Gallery Image');
366 + add(img, __('Gallery Image', 'yatra'));
313 367 });
314 368
315 369 // 4. Collect from hero main image.
316 370 const heroMainImg = document.querySelector('.yatra-hero-main-img');
317 - if (heroMainImg) add(heroMainImg, 'Main Image');
371 + if (heroMainImg) add(heroMainImg, __('Main Image', 'yatra'));
318 372
319 373 // 5. Collect from side images.
320 374 document.querySelectorAll('.yatra-side-image-item img').forEach((img) => {
321 - add(img, 'Side Image');
375 + add(img, __('Side Image', 'yatra'));
322 376 });
323 377 }
324 378
325 379 createThumbnails() {
@@ -566,14 +620,22 @@
566 620 };
567 621
568 622 // Visible gallery thumbnails inside the scope
569 623 root.querySelectorAll('.yatra-media-card img').forEach((img) => {
570 - add(img, `Itinerary Gallery Image ${images.length + 1}`);
624 + add(img, sprintf(
625 + /* translators: %d: gallery image sequence number. */
626 + __('Itinerary Gallery Image %d', 'yatra'),
627 + images.length + 1
628 + ));
571 629 });
572 630
573 631 // Video links inside the scope (fallback selector)
574 632 root.querySelectorAll('.yatra-itinerary-video-link img').forEach((img) => {
575 - add(img, `Itinerary Video ${images.length + 1}`);
633 + add(img, sprintf(
634 + /* translators: %d: itinerary video sequence number. */
635 + __('Itinerary Video %d', 'yatra'),
636 + images.length + 1
637 + ));
576 638 });
577 639
578 640 // If still empty AND we were given no scope, fall back to the hidden global pool
579 641 // so legacy click handlers on `.yatra-itinerary-trip-gallery .yatra-gallery-item`
@@ -579,9 +641,13 @@
579 641 // so legacy click handlers on `.yatra-itinerary-trip-gallery .yatra-gallery-item`
580 642 // continue to function.
581 643 if (images.length === 0 && !scope) {
582 644 document.querySelectorAll('.yatra-itinerary-trip-gallery .yatra-gallery-item img').forEach((img) => {
583 - add(img, `Itinerary Image ${images.length + 1}`);
645 + add(img, sprintf(
646 + /* translators: %d: itinerary image sequence number. */
647 + __('Itinerary Image %d', 'yatra'),
648 + images.length + 1
649 + ));
584 650 });
585 651 }
586 652
587 653 return images;
@@ -920,9 +986,9 @@
920 986 }
921 987
922 988 if (images.length > 0) {
923 989 img.src = images[0];
924 - img.alt = 'Trip Image 1';
990 + img.alt = __('Trip Image 1', 'yatra');
925 991 if (link) {
926 992 link.href = '#';
927 993 link.dataset.imageIndex = '0';
928 994 }
@@ -1140,8 +1206,12 @@
1140 1206 if (existingOverlay) existingOverlay.remove();
1141 1207
1142 1208 const overlay = document.createElement('div');
1143 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');
1144 1214 overlay.innerHTML = `
1145 1215 <div class="yatra-tour-info">
1146 1216 <div class="yatra-tour-icon">
1147 1217 <svg width="80" height="80" viewBox="0 0 100 100" fill="none">
@@ -1154,11 +1224,11 @@
1154 1224 <text x="50" y="58" text-anchor="middle" fill="white" font-size="24" font-weight="bold">360°</text>
1155 1225 </svg>
1156 1226 </div>
1157 1227 <div class="yatra-tour-details">
1158 - <div class="yatra-tour-title">${tour.title || '360° Virtual Tour'}</div>
1159 - <div class="yatra-tour-type">${tour.tour_type || '360°'} Experience</div>
1160 - <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>
1161 1231 </div>
1162 1232 </div>
1163 1233 `;
1164 1234
@@ -1196,8 +1266,9 @@
1196 1266 if (existingOverlay) existingOverlay.remove();
1197 1267
1198 1268 const overlay = document.createElement('div');
1199 1269 overlay.className = 'yatra-media-overlay yatra-media-document-overlay';
1270 + const documentFallbackTitle = __('Document', 'yatra');
1200 1271 overlay.innerHTML = `
1201 1272 <div class="yatra-document-info">
1202 1273 <div class="yatra-document-icon">
1203 1274 <svg width="48" height="48" viewBox="0 0 24 24" fill="none">
@@ -1207,9 +1278,9 @@
1207 1278 <path d="M16 17H8" stroke="#374151" stroke-width="2"/>
1208 1279 </svg>
1209 1280 </div>
1210 1281 <div class="yatra-document-details">
1211 - <div class="yatra-document-title">${doc.title || 'Document'}</div>
1282 + <div class="yatra-document-title">${doc.title || documentFallbackTitle}</div>
1212 1283 <div class="yatra-document-size">${this.formatFileSize(doc.file_size || 0)}</div>
1213 1284 </div>
1214 1285 </div>
1215 1286 `;
@@ -1216,11 +1287,11 @@
1216 1287 container.appendChild(overlay);
1217 1288 }
1218 1289
1219 1290 formatFileSize(bytes) {
1220 - if (bytes === 0) return '0 Bytes';
1291 + if (bytes === 0) return '0 ' + __('Bytes', 'yatra');
1221 1292 const k = 1024;
1222 - const sizes = ['Bytes', 'KB', 'MB', 'GB'];
1293 + const sizes = [__('Bytes', 'yatra'), 'KB', 'MB', 'GB'];
1223 1294 const i = Math.floor(Math.log(bytes) / Math.log(k));
1224 1295 return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
1225 1296 }
1226 1297
@@ -1250,9 +1321,10 @@
1250 1321 sideImages.forEach((item, index) => {
1251 1322 const img = item.querySelector('img');
1252 1323 if (img && images[index + 1]) {
1253 1324 img.src = images[index + 1];
1254 - 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);
1255 1327 }
1256 1328 });
1257 1329 }
1258 1330
@@ -1257,8 +1329,9 @@
1257 1329 }
1258 1330
1259 1331 updateSideVideos(sideImages) {
1260 1332 const videos = this.mediaData.videos || [];
1333 + const fallbackVideoTitle = __('Trip Video', 'yatra');
1261 1334 sideImages.forEach((item, index) => {
1262 1335 const img = item.querySelector('img');
1263 1336 if (img && videos[index + 1]) {
1264 1337 const video = videos[index + 1];
@@ -1264,9 +1337,9 @@
1264 1337 const video = videos[index + 1];
1265 1338 if (video.thumbnail) {
1266 1339 img.src = video.thumbnail;
1267 1340 }
1268 - img.alt = video.title || 'Trip Video';
1341 + img.alt = video.title || fallbackVideoTitle;
1269 1342 }
1270 1343 });
1271 1344 }
1272 1345
@@ -1271,8 +1344,9 @@
1271 1344 }
1272 1345
1273 1346 updateSideYoutubeVideos(sideImages) {
1274 1347 const youtubeVideos = this.mediaData.youtube_videos || [];
1348 + const fallbackYoutubeTitle = __('YouTube Video', 'yatra');
1275 1349 sideImages.forEach((item, index) => {
1276 1350 const img = item.querySelector('img');
1277 1351 if (img && youtubeVideos[index + 1]) {
1278 1352 const video = youtubeVideos[index + 1];
@@ -1278,9 +1352,9 @@
1278 1352 const video = youtubeVideos[index + 1];
1279 1353 if (video.thumbnail) {
1280 1354 img.src = video.thumbnail;
1281 1355 }
1282 - img.alt = video.title || 'YouTube Video';
1356 + img.alt = video.title || fallbackYoutubeTitle;
1283 1357 }
1284 1358 });
1285 1359 }
1286 1360
@@ -1285,8 +1359,9 @@
1285 1359 }
1286 1360
1287 1361 updateSideTours(sideImages) {
1288 1362 const virtualTours = this.mediaData.virtual_tours || [];
1363 + const fallbackTourTitle = __('360° Virtual Tour', 'yatra');
1289 1364 sideImages.forEach((item, index) => {
1290 1365 const img = item.querySelector('img');
1291 1366 if (img && virtualTours[index + 1]) {
1292 1367 const tour = virtualTours[index + 1];
@@ -1292,9 +1367,9 @@
1292 1367 const tour = virtualTours[index + 1];
1293 1368 if (tour.thumbnail) {
1294 1369 img.src = tour.thumbnail;
1295 1370 }
1296 - img.alt = tour.title || '360° Virtual Tour';
1371 + img.alt = tour.title || fallbackTourTitle;
1297 1372 }
1298 1373 });
1299 1374 }
1300 1375
@@ -1299,14 +1374,15 @@
1299 1374 }
1300 1375
1301 1376 updateSideDocuments(sideImages) {
1302 1377 const documents = this.mediaData.documents || [];
1378 + const fallbackDocumentTitle = __('Trip Document', 'yatra');
1303 1379 sideImages.forEach((item, index) => {
1304 1380 const img = item.querySelector('img');
1305 1381 if (img && documents[index + 1]) {
1306 1382 const doc = documents[index + 1];
1307 1383 // Keep existing image for documents
1308 - img.alt = doc.title || 'Trip Document';
1384 + img.alt = doc.title || fallbackDocumentTitle;
1309 1385 }
1310 1386 });
1311 1387 }
1312 1388
@@ -1533,10 +1609,10 @@
1533 1609 if (!minusBtn || !plusBtn) return;
1534 1610
1535 1611 const updateButtonStates = () => {
1536 1612 const current = parseInt(input.value) || 1;
1537 - const min = parseInt(input.getAttribute('min')) || 1;
1538 - 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);
1539 1615
1540 1616 minusBtn.disabled = current <= min;
1541 1617 plusBtn.disabled = current >= max;
1542 1618 };
@@ -1546,9 +1622,9 @@
1546 1622 e.preventDefault();
1547 1623 e.stopPropagation();
1548 1624
1549 1625 const current = parseInt(input.value) || 1;
1550 - const min = parseInt(input.getAttribute('min')) || 1;
1626 + const min = window.yatraNumOr(input.getAttribute('min'), 1);
1551 1627
1552 1628 if (current > min) {
1553 1629 input.value = current - 1;
1554 1630 updateButtonStates();
@@ -1561,9 +1637,9 @@
1561 1637 e.preventDefault();
1562 1638 e.stopPropagation();
1563 1639
1564 1640 const current = parseInt(input.value) || 1;
1565 - const max = parseInt(input.getAttribute('max')) || 20;
1641 + const max = window.yatraNumOr(input.getAttribute('max'), 20);
1566 1642
1567 1643 if (current < max) {
1568 1644 input.value = current + 1;
1569 1645 updateButtonStates();
@@ -1637,21 +1713,25 @@
1637 1713 const parts = [];
1638 1714 travelerInputs.forEach((input) => {
1639 1715 const value = parseInt(input.value || 0);
1640 1716 if (value > 0) {
1641 - const categoryLabel = input.getAttribute('data-category-label') || 'Traveler';
1717 + const categoryLabel = input.getAttribute('data-category-label') || __('Traveler', 'yatra');
1642 1718 parts.push(`${categoryLabel} x ${value}`);
1643 1719 }
1644 1720 });
1645 1721
1646 1722 if (parts.length === 0) {
1647 - // 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.
1648 1727 const firstInput = travelerInputs[0];
1649 1728 if (firstInput) {
1650 - const categoryLabel = firstInput.getAttribute('data-category-label') || 'Traveler';
1651 - 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);
1652 1732 } else {
1653 - display.textContent = '1 Traveler';
1733 + display.textContent = __('1 Traveler', 'yatra');
1654 1734 }
1655 1735 } else {
1656 1736 display.textContent = parts.join(', ');
1657 1737 }
@@ -1745,9 +1825,9 @@
1745 1825 categoryInputs.forEach((catInput) => {
1746 1826 const catValue = parseInt(catInput.value) || 0;
1747 1827 if (catValue > 0) {
1748 1828 const catRow = catInput.closest('.yatra-quantity-row');
1749 - const categoryLabel = catRow ? catRow.querySelector('.yatra-quantity-title')?.textContent : 'Traveler';
1829 + const categoryLabel = catRow ? catRow.querySelector('.yatra-quantity-title')?.textContent : __('Traveler', 'yatra');
1750 1830 parts.push(`${categoryLabel} x ${catValue}`);
1751 1831 }
1752 1832 });
1753 1833 if (parts.length > 0) {
@@ -1822,14 +1902,14 @@
1822 1902
1823 1903
1824 1904 // Basic validation
1825 1905 if (!date) {
1826 - alert('Please select a travel date');
1906 + alert(__('Please select a travel date', 'yatra'));
1827 1907 return;
1828 1908 }
1829 1909
1830 1910 if (totalTravelers === 0) {
1831 - alert('Please select number of travelers');
1911 + alert(__('Please select number of travelers', 'yatra'));
1832 1912 return;
1833 1913 }
1834 1914
1835 1915 // Show loading state
@@ -1836,9 +1916,17 @@
1836 1916 if (this.checkAvailabilityBtn && !this.checkAvailabilityBtn.dataset.originalHtml) {
1837 1917 this.checkAvailabilityBtn.dataset.originalHtml = this.checkAvailabilityBtn.innerHTML;
1838 1918 }
1839 1919 if (this.checkAvailabilityBtn) {
1840 - 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');
1841 1929 this.checkAvailabilityBtn.disabled = true;
1842 1930 }
1843 1931
1844 1932 // Get trip ID from data attribute or global variable
@@ -1878,11 +1966,12 @@
1878 1966 if (!response.ok) {
1879 1967 // Try to get error message from response
1880 1968 return response.json().then(err => {
1881 1969 console.error('API Error:', err);
1882 - throw new Error(err.message || 'Failed to fetch availability');
1970 + throw new Error(err.message || __('Failed to fetch availability', 'yatra'));
1883 1971 }).catch(() => {
1884 - 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));
1885 1974 });
1886 1975 }
1887 1976 return response.json();
1888 1977 })
@@ -1946,9 +2035,9 @@
1946 2035 this.resetButton();
1947 2036 })
1948 2037 .catch(error => {
1949 2038 console.error('Error loading availability:', error);
1950 - alert('Failed to load availability. Please try again.');
2039 + alert(__('Failed to load availability. Please try again.', 'yatra'));
1951 2040 this.resetButton();
1952 2041 });
1953 2042 }
1954 2043
@@ -1997,13 +2086,14 @@
1997 2086 // Create new menu item
1998 2087 const newItem = document.createElement('a');
1999 2088 newItem.href = '#availability';
2000 2089 newItem.className = 'yatra-sticky-nav-item';
2090 + const availabilityLabel = __('Availability', 'yatra');
2001 2091 newItem.innerHTML = `
2002 2092 <svg class="yatra-icon-sm" fill="none" stroke="currentColor" viewBox="0 0 24 24">
2003 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"/>
2004 2094 </svg>
2005 - <span>Availability</span>
2095 + <span>${availabilityLabel}</span>
2006 2096 `;
2007 2097
2008 2098 // Insert before the target item (or after "What's Included" if no target)
2009 2099 if (insertBeforeItem && insertBeforeItem.parentNode) {
@@ -2070,9 +2160,9 @@
2070 2160 if (this.checkAvailabilityBtn && !this.checkAvailabilityBtn.dataset.originalHtml) {
2071 2161 this.checkAvailabilityBtn.dataset.originalHtml = this.checkAvailabilityBtn.innerHTML;
2072 2162 }
2073 2163 if (this.checkAvailabilityBtn) {
2074 - 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');
2075 2165 this.checkAvailabilityBtn.disabled = true;
2076 2166 }
2077 2167
2078 2168 const availSection = document.getElementById('availability');
@@ -2096,9 +2186,9 @@
2096 2186 credentials: 'omit',
2097 2187 })
2098 2188 .then(response => {
2099 2189 if (!response.ok) {
2100 - throw new Error('Failed to fetch availability');
2190 + throw new Error(__('Failed to fetch availability', 'yatra'));
2101 2191 }
2102 2192 return response.json();
2103 2193 })
2104 2194 .then(data => {
@@ -2133,9 +2223,9 @@
2133 2223 this.resetButton();
2134 2224 })
2135 2225 .catch(error => {
2136 2226 console.error('Error reloading availability:', error);
2137 - alert('Failed to reload availability. Please try again.');
2227 + alert(__('Failed to reload availability. Please try again.', 'yatra'));
2138 2228 this.resetButton();
2139 2229 });
2140 2230 }
2141 2231
@@ -2233,9 +2323,13 @@
2233 2323 if (data && data.success) {
2234 2324 // Update departures available text
2235 2325 const departuresText = document.querySelector('.yatra-departures-available-text');
2236 2326 if (departuresText && data.departures_count !== undefined) {
2237 - 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 + );
2238 2332 }
2239 2333
2240 2334 // Update traveler dropdown HTML
2241 2335 const quantitySelector = document.getElementById('quantity-selector');
@@ -2289,15 +2383,15 @@
2289 2383 const row = travelerSelector.querySelector('.yatra-quantity-row[data-category-id="' + categoryId + '"]');
2290 2384 if (row) {
2291 2385 const cardInput = row.querySelector('input[type="number"]');
2292 2386 if (cardInput) {
2293 - const max = parseInt(cardInput.getAttribute('max')) || 99;
2387 + const max = window.yatraNumOr(cardInput.getAttribute('max'), 99);
2294 2388 cardInput.value = Math.min(value, max);
2295 2389
2296 2390 // Update button states
2297 2391 const minusBtn = row.querySelector('.yatra-quantity-minus');
2298 2392 const plusBtn = row.querySelector('.yatra-quantity-plus');
2299 - const min = parseInt(cardInput.getAttribute('min')) || 0;
2393 + const min = window.yatraNumOr(cardInput.getAttribute('min'), 0);
2300 2394 if (minusBtn) minusBtn.disabled = cardInput.value <= min;
2301 2395 if (plusBtn) plusBtn.disabled = cardInput.value >= max;
2302 2396 }
2303 2397 }
@@ -2308,9 +2402,9 @@
2308 2402
2309 2403 } else if (simpleTravelers && numTravelersInput) {
2310 2404 // Simple pricing - sync total count
2311 2405 const totalCount = parseInt(numTravelersInput.value) || 1;
2312 - const max = parseInt(simpleTravelers.getAttribute('max')) || 99;
2406 + const max = window.yatraNumOr(simpleTravelers.getAttribute('max'), 99);
2313 2407 simpleTravelers.value = Math.min(totalCount, max);
2314 2408
2315 2409 // Update button states
2316 2410 const controls = simpleTravelers.closest('.yatra-quantity-controls-inline');
@@ -2316,9 +2410,9 @@
2316 2410 const controls = simpleTravelers.closest('.yatra-quantity-controls-inline');
2317 2411 if (controls) {
2318 2412 const minusBtn = controls.querySelector('.yatra-quantity-minus');
2319 2413 const plusBtn = controls.querySelector('.yatra-quantity-plus');
2320 - const min = parseInt(simpleTravelers.getAttribute('min')) || 1;
2414 + const min = window.yatraNumOr(simpleTravelers.getAttribute('min'), 1);
2321 2415 if (minusBtn) minusBtn.disabled = simpleTravelers.value <= min;
2322 2416 if (plusBtn) plusBtn.disabled = simpleTravelers.value >= max;
2323 2417 }
2324 2418 }
@@ -2351,9 +2445,9 @@
2351 2445 }
2352 2446 }
2353 2447 });
2354 2448
2355 - display.textContent = parts.length > 0 ? parts.join(', ') : '0 Travelers';
2449 + display.textContent = parts.length > 0 ? parts.join(', ') : __('0 Travelers', 'yatra');
2356 2450 }
2357 2451
2358 2452 /**
2359 2453 * Update availability card total price
@@ -2381,13 +2475,17 @@
2381 2475 const input = row.querySelector('input[type="number"]');
2382 2476 const price = parseFloat(row.getAttribute('data-price')) || 0;
2383 2477 const count = parseInt(input?.value) || 0;
2384 2478 const pricingMode = row.getAttribute('data-pricing-mode') || (input ? input.getAttribute('data-pricing-mode') : '') || 'per_person';
2385 -
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 +
2386 2482 if (pricingMode === 'per_group') {
2387 - // Per group: charge flat price once if any travelers
2483 + // Per group: one flat price, or per block of maxPax when "per_block".
2388 2484 if (count > 0) {
2389 - total += price;
2485 + total += (groupOverflow === 'per_block' && maxPax > 0)
2486 + ? price * Math.ceil(count / maxPax)
2487 + : price;
2390 2488 }
2391 2489 } else {
2392 2490 // Per person: charge per traveler
2393 2491 total += price * count;
@@ -2416,9 +2514,10 @@
2416 2514 totalAmountEl.textContent = String(total.toFixed(2));
2417 2515 }
2418 2516
2419 2517 if (totalNoteEl) {
2420 - 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);
2421 2520 }
2422 2521
2423 2522 // Update the card participants display
2424 2523 this.updateAvailabilityCardDisplay(itemId);
@@ -2619,17 +2718,22 @@
2619 2718 // Update display function
2620 2719 const updateDisplay = () => {
2621 2720 const adults = parseInt(adultsInput.value || 1);
2622 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);
2623 2726 let text = '';
2624 2727 if (adults > 0 && children > 0) {
2625 - 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);
2626 2730 } else if (adults > 0) {
2627 - text = `Adult x ${adults}`;
2731 + text = adultPart;
2628 2732 } else if (children > 0) {
2629 - text = `Child x ${children}`;
2733 + text = childPart;
2630 2734 } else {
2631 - text = 'Adult x 1';
2735 + text = sprintf(_n('Adult x %d', 'Adult x %d', 1, 'yatra'), 1);
2632 2736 }
2633 2737 display.textContent = text;
2634 2738 };
2635 2739
@@ -2715,9 +2819,9 @@
2715 2819 const email = formData.get('email');
2716 2820 const message = formData.get('message');
2717 2821
2718 2822 if (!name || !email || !message) {
2719 - this.showMessage('error', 'Please fill in all required fields.');
2823 + this.showMessage('error', __('Please fill in all required fields.', 'yatra'));
2720 2824 return;
2721 2825 }
2722 2826
2723 2827 // Basic email validation
@@ -2722,22 +2826,22 @@
2722 2826
2723 2827 // Basic email validation
2724 2828 const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
2725 2829 if (!emailRegex.test(email)) {
2726 - this.showMessage('error', 'Please enter a valid email address.');
2830 + this.showMessage('error', __('Please enter a valid email address.', 'yatra'));
2727 2831 return;
2728 2832 }
2729 2833
2730 2834 if (message.length < 10) {
2731 - this.showMessage('error', 'Please enter a message (at least 10 characters).');
2835 + this.showMessage('error', __('Please enter a message (at least 10 characters).', 'yatra'));
2732 2836 return;
2733 2837 }
2734 2838
2735 2839 // Show loading
2736 2840 const submitBtn = this.form.querySelector('.yatra-enquiry-submit');
2737 - const originalText = submitBtn ? submitBtn.textContent : 'Send Enquiry';
2841 + const originalText = submitBtn ? submitBtn.textContent : __('Send Enquiry', 'yatra');
2738 2842 if (submitBtn) {
2739 - submitBtn.textContent = 'Sending...';
2843 + submitBtn.textContent = __('Sending...', 'yatra');
2740 2844 submitBtn.disabled = true;
2741 2845 }
2742 2846
2743 2847 // Hide any existing messages
@@ -2760,26 +2864,38 @@
2760 2864 const enquiriesUrl = baseUrlRaw.includes('/yatra/v1')
2761 2865 ? `${baseUrlRaw}/enquiries`
2762 2866 : `${baseUrlRaw}/yatra/v1/enquiries`;
2763 2867
2764 - fetch(enquiriesUrl, {
2765 - method: 'POST',
2766 - headers: {
2767 - 'Content-Type': 'application/json',
2768 - },
2769 - credentials: 'omit', // public endpoint
2770 - body: JSON.stringify(payload),
2771 - })
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 + })
2772 2888 .then(response => response.json())
2773 2889 .then(result => {
2774 2890 if (result.success) {
2775 - 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'));
2776 2892 this.form.reset();
2777 2893
2778 2894 // Reset the travelers display
2779 2895 const display = document.getElementById('enquiry-participants-display');
2780 2896 if (display) {
2781 - display.textContent = 'Adult x 1';
2897 + display.textContent = __('Adult x 1', 'yatra');
2782 2898 }
2783 2899 const adultsInput = document.getElementById('enquiry-adults');
2784 2900 const childrenInput = document.getElementById('enquiry-children');
2785 2901 if (adultsInput) adultsInput.value = 1;
@@ -2792,14 +2908,14 @@
2792 2908 messageBox.style.display = 'none';
2793 2909 }
2794 2910 }, 3000);
2795 2911 } else {
2796 - 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'));
2797 2913 }
2798 2914 })
2799 2915 .catch(error => {
2800 2916 console.error('Enquiry submission error:', error);
2801 - this.showMessage('error', 'An error occurred. Please try again later.');
2917 + this.showMessage('error', __('An error occurred. Please try again later.', 'yatra'));
2802 2918 })
2803 2919 .finally(() => {
2804 2920 if (submitBtn) {
2805 2921 submitBtn.textContent = originalText;
@@ -2864,21 +2980,33 @@
2864 2980 this.updateSidebarPosition(); // Initial check
2865 2981 }
2866 2982
2867 2983 getActualSections() {
2868 - // Base sections that are always present in order
2869 - const baseSections = ['overview', 'itinerary', 'included', 'location', 'important-info'];
2870 -
2871 - // Conditional sections that may or may not be present
2872 - const conditionalSections = ['downloads', 'faq', 'trip-story', 'what-makes-special', 'testimonials'];
2873 -
2874 - // Combine all sections and filter to only include those that actually exist on the page
2875 - const allSections = [...baseSections, ...conditionalSections];
2876 -
2877 - return allSections.filter(sectionId => {
2878 - const element = document.getElementById(sectionId);
2879 - 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 + }
2880 3007 });
3008 + return ids;
2881 3009 }
2882 3010
2883 3011 attachEventListeners() {
2884 3012 // Handle clicks on nav items
@@ -3050,8 +3178,15 @@
3050 3178 this.lastScrollTop = scrollTop;
3051 3179 }
3052 3180
3053 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.
3054 3189 const scrollPos = window.scrollY + 150;
3055 3190 let activeSectionId = null;
3056 3191
3057 3192 // Find which section is currently in view
@@ -3057,10 +3192,11 @@
3057 3192 // Find which section is currently in view
3058 3193 this.sections.forEach((sectionId) => {
3059 3194 const section = document.getElementById(sectionId);
3060 3195 if (section) {
3061 - const sectionTop = section.offsetTop;
3062 - const sectionBottom = sectionTop + section.offsetHeight;
3196 + const rect = section.getBoundingClientRect();
3197 + const sectionTop = rect.top + window.pageYOffset;
3198 + const sectionBottom = sectionTop + rect.height;
3063 3199
3064 3200 if (scrollPos >= sectionTop && scrollPos < sectionBottom) {
3065 3201 activeSectionId = sectionId;
3066 3202 }
@@ -3207,14 +3343,14 @@
3207 3343 }
3208 3344 }
3209 3345
3210 3346 if (travelers < 1) {
3211 - this.showBookingError(btn, 'Please select at least 1 traveler to continue.');
3347 + this.showBookingError(btn, __('Please select at least 1 traveler to continue.', 'yatra'));
3212 3348 return;
3213 3349 }
3214 3350
3215 3351 const originalText = btn.innerHTML;
3216 - 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');
3217 3353 btn.disabled = true;
3218 3354
3219 3355 if (!tripId || isNaN(parseInt(tripId)) || parseInt(tripId) <= 0) {
3220 3356 console.error('Invalid trip ID:', tripId);
@@ -3219,9 +3355,9 @@
3219 3355 if (!tripId || isNaN(parseInt(tripId)) || parseInt(tripId) <= 0) {
3220 3356 console.error('Invalid trip ID:', tripId);
3221 3357 btn.innerHTML = originalText;
3222 3358 btn.disabled = false;
3223 - this.showBookingError(btn, 'Unable to process booking. Invalid trip data.');
3359 + this.showBookingError(btn, __('Unable to process booking. Invalid trip data.', 'yatra'));
3224 3360 return;
3225 3361 }
3226 3362
3227 3363 const pricingType = btn.getAttribute('data-pricing-type') || 'regular';
@@ -3442,9 +3578,9 @@
3442 3578 currency: window.yatraBookingData?.currency || 'USD',
3443 3579 num_items: sessionPayload.travelers || 1
3444 3580 });
3445 3581
3446 - 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);
3447 3583 }
3448 3584
3449 3585 // Track Book Now click as begin_checkout (Google Analytics 4)
3450 3586 if (typeof gtag !== 'undefined' && window.yatraAdmin?.googleAnalytics?.measurementId) {
@@ -3462,9 +3598,9 @@
3462 3598 quantity: sessionPayload.travelers || 1
3463 3599 }]
3464 3600 });
3465 3601
3466 - 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);
3467 3603 }
3468 3604
3469 3605 // Set booking session via REST API
3470 3606 // credentials: 'same-origin' is required to send cookies for session
@@ -3495,9 +3631,9 @@
3495 3631 // Error - show message and reset button
3496 3632 console.error('Booking session error:', data);
3497 3633 btn.innerHTML = originalText;
3498 3634 btn.disabled = false;
3499 - 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'));
3500 3636 }
3501 3637 })
3502 3638 .catch(error => {
3503 3639 console.error('Error setting booking session:', error);
@@ -3502,9 +3638,9 @@
3502 3638 .catch(error => {
3503 3639 console.error('Error setting booking session:', error);
3504 3640 btn.innerHTML = originalText;
3505 3641 btn.disabled = false;
3506 - this.showBookingError(btn, 'Unable to process booking. Please try again.');
3642 + this.showBookingError(btn, __('Unable to process booking. Please try again.', 'yatra'));
3507 3643 });
3508 3644 }
3509 3645
3510 3646 initTravelerSelectors() {
@@ -3536,18 +3672,21 @@
3536 3672 categoryInputs.forEach((input) => {
3537 3673 const value = parseInt(input.value) || 0;
3538 3674 if (value > 0) {
3539 3675 const row = input.closest('.yatra-quantity-row');
3540 - const categoryLabel = row ? row.querySelector('.yatra-quantity-title')?.textContent : 'Traveler';
3676 + const categoryLabel = row ? row.querySelector('.yatra-quantity-title')?.textContent : __('Traveler', 'yatra');
3541 3677 parts.push(`${categoryLabel} x ${value}`);
3542 3678 }
3543 3679 });
3544 3680
3545 3681 if (parts.length === 0) {
3546 - // 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.
3547 3685 const firstRow = categoryInputs[0]?.closest('.yatra-quantity-row');
3548 - const firstLabel = firstRow ? firstRow.querySelector('.yatra-quantity-title')?.textContent : 'Traveler';
3549 - 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);
3550 3689 } else {
3551 3690 display.textContent = parts.join(', ');
3552 3691 }
3553 3692 };
@@ -3569,10 +3708,10 @@
3569 3708 const isMinus = btn.classList.contains('yatra-quantity-minus');
3570 3709 const isPlus = btn.classList.contains('yatra-quantity-plus');
3571 3710
3572 3711 let current = parseInt(input.value) || 0;
3573 - const min = parseInt(input.getAttribute('min')) || 0;
3574 - 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);
3575 3714 let newValue = current;
3576 3715
3577 3716 if (isPlus && current < max) {
3578 3717 newValue = current + 1;
@@ -3644,10 +3783,10 @@
3644 3783 const isMinus = btn.classList.contains('yatra-quantity-minus');
3645 3784 const isPlus = btn.classList.contains('yatra-quantity-plus');
3646 3785
3647 3786 let current = parseInt(input.value) || 1;
3648 - const min = parseInt(input.getAttribute('min')) || 1;
3649 - 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);
3650 3789 let newValue = current;
3651 3790
3652 3791 if (isPlus && current < max) {
3653 3792 newValue = current + 1;
@@ -3719,9 +3858,9 @@
3719 3858 categoryInputs.forEach((input) => {
3720 3859 const value = parseInt(input.value) || 0;
3721 3860 if (value > 0) {
3722 3861 const row = input.closest('.yatra-quantity-row');
3723 - const categoryLabel = row ? row.querySelector('.yatra-quantity-title')?.textContent : 'Traveler';
3862 + const categoryLabel = row ? row.querySelector('.yatra-quantity-title')?.textContent : __('Traveler', 'yatra');
3724 3863 parts.push(`${categoryLabel} x ${value}`);
3725 3864
3726 3865 // Also update sidebar inputs if they exist
3727 3866 const categoryId = input.getAttribute('data-category');
@@ -3757,8 +3896,9 @@
3757 3896 // Check for traveler-based pricing first
3758 3897 const categoryInputs = this.section.querySelectorAll(`.yatra-availability-category[data-item="${itemIndex}"]`);
3759 3898 let totalPrice = 0;
3760 3899 let totalTravelers = 0;
3900 + let totalGroups = 0; // per-group categories: number of group prices charged
3761 3901
3762 3902 if (categoryInputs.length > 0) {
3763 3903 // Traveler-based pricing: sum up each category's price * quantity (respecting pricing mode)
3764 3904 categoryInputs.forEach((input) => {
@@ -3765,12 +3905,16 @@
3765 3905 const quantity = parseInt(input.value) || 0;
3766 3906 const row = input.closest('.yatra-quantity-row');
3767 3907 const price = row ? parseFloat(row.getAttribute('data-price')) || 0 : 0;
3768 3908 const pricingMode = (row ? row.getAttribute('data-pricing-mode') : null) || input.getAttribute('data-pricing-mode') || 'per_person';
3769 -
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 +
3770 3912 if (pricingMode === 'per_group') {
3771 3913 if (quantity > 0) {
3772 - totalPrice += price;
3914 + const blocks = (groupOverflow === 'per_block' && maxPax > 0) ? Math.ceil(quantity / maxPax) : 1;
3915 + totalPrice += price * blocks;
3916 + totalGroups += blocks;
3773 3917 }
3774 3918 } else {
3775 3919 totalPrice += quantity * price;
3776 3920 }
@@ -3798,14 +3942,21 @@
3798 3942 } else {
3799 3943 totalAmountElement.textContent = String(finalPrice.toFixed(2));
3800 3944 }
3801 3945
3802 - // 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).
3803 3950 const cardRoot = this.section.querySelector('.yatra-availability-card[data-item="' + itemIndex + '"]');
3804 - 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;
3805 3953 if (headerSaleEl) {
3954 + const headerUnit = headerPriceWrap.getAttribute('data-price-unit') || 'per_person';
3806 3955 let perPerson = 0;
3807 - if (totalTravelers > 0) {
3956 + if (headerUnit === 'per_group') {
3957 + perPerson = totalGroups > 0 ? finalPrice / totalGroups : (totalPrice > 0 ? totalPrice : 0);
3958 + } else if (totalTravelers > 0) {
3808 3959 perPerson = finalPrice / totalTravelers;
3809 3960 } else if (totalPrice > 0) {
3810 3961 perPerson = totalPrice;
3811 3962 }
@@ -3819,11 +3970,13 @@
3819 3970 const noteElement = this.section.querySelector(
3820 3971 `.yatra-card-total-note[data-item="${itemIndex}"]`
3821 3972 );
3822 3973 if (noteElement) {
3823 - let travelerText = totalTravelers === 1
3824 - ? 'for 1 traveler'
3825 - : `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 + );
3826 3979
3827 3980 // Add group discount info if applied
3828 3981 if (groupDiscountApplied && groupDiscountAmount > 0) {
3829 3982 const discountText = (typeof yatra_format_price_js === 'function')
@@ -3828,9 +3981,13 @@
3828 3981 if (groupDiscountApplied && groupDiscountAmount > 0) {
3829 3982 const discountText = (typeof yatra_format_price_js === 'function')
3830 3983 ? yatra_format_price_js(groupDiscountAmount)
3831 3984 : String(groupDiscountAmount.toFixed(2));
3832 - travelerText += ` (Group discount: -${discountText})`;
3985 + travelerText += sprintf(
3986 + /* translators: %s: formatted discount amount. */
3987 + __(' (Group discount: -%s)', 'yatra'),
3988 + discountText
3989 + );
3833 3990 }
3834 3991 noteElement.textContent = travelerText;
3835 3992 }
3836 3993
@@ -3945,10 +4102,14 @@
3945 4102 }
3946 4103
3947 4104 // Update badge content
3948 4105 const discountText = discountResult.discountType === 'percentage'
3949 - ? `${discountResult.discountPercentage}% group discount applied!`
3950 - : `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');
3951 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}`;
3952 4113 } else if (badge) {
3953 4114 badge.remove();
3954 4115 }
@@ -4028,9 +4189,9 @@
4028 4189 }
4029 4190
4030 4191 // Show success feedback
4031 4192 const originalText = button.textContent;
4032 - button.textContent = 'Selected!';
4193 + button.textContent = __('Selected!', 'yatra');
4033 4194 button.style.background = '#10b981';
4034 4195 setTimeout(() => {
4035 4196 button.textContent = originalText;
4036 4197 button.style.background = '';
@@ -4055,9 +4216,11 @@
4055 4216 const travelDate = travelDateInput && travelDateInput.value ? travelDateInput.value : undefined;
4056 4217
4057 4218 const originalHTML = btn.innerHTML;
4058 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> ';
4059 - 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');
4060 4223 btn.disabled = true;
4061 4224
4062 4225 const url = buildAvailabilityTemplateUrl(tripId, {
4063 4226 date: travelDate,
@@ -4076,9 +4239,9 @@
4076 4239 credentials: 'omit',
4077 4240 })
4078 4241 .then((resp) => {
4079 4242 if (!resp.ok) {
4080 - throw new Error('Load more failed');
4243 + throw new Error(__('Load more failed', 'yatra'));
4081 4244 }
4082 4245 return resp.json();
4083 4246 })
4084 4247 .then((data) => {
@@ -4115,9 +4278,11 @@
4115 4278 return;
4116 4279 }
4117 4280
4118 4281 const remaining = Math.max(0, total - displayed);
4119 - 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}`;
4120 4285 btn.disabled = false;
4121 4286
4122 4287 loadMoreContainer.dataset.currentPage = String(nextPage);
4123 4288 loadMoreContainer.dataset.loaded = String(displayed);
@@ -4123,9 +4288,17 @@
4123 4288 loadMoreContainer.dataset.loaded = String(displayed);
4124 4289 loadMoreContainer.dataset.total = String(total);
4125 4290
4126 4291 if (countInfo) {
4127 - 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 + );
4128 4301 }
4129 4302 })
4130 4303 .catch((err) => {
4131 4304 console.error('[Yatra] Load more failed:', err);
@@ -4289,9 +4462,9 @@
4289 4462 if (!this.toggleAllBtn) return;
4290 4463 this.toggleAllBtn.classList.toggle('is-all-expanded', isExpanded);
4291 4464 const toggleText = this.toggleAllBtn.querySelector('.toggle-text');
4292 4465 if (toggleText) {
4293 - toggleText.textContent = isExpanded ? 'Collapse All' : 'Expand All';
4466 + toggleText.textContent = isExpanded ? __('Collapse All', 'yatra') : __('Expand All', 'yatra');
4294 4467 }
4295 4468 }
4296 4469 }
4297 4470
@@ -4404,9 +4577,10 @@
4404 4577 const originalText = submitBtn.innerHTML;
4405 4578
4406 4579 // Disable button and show loading
4407 4580 submitBtn.disabled = true;
4408 - submitBtn.innerHTML = '<span class="yatra-loading-spinner"></span> Submitting...';
4581 + submitBtn.innerHTML =
4582 + '<span class="yatra-loading-spinner"></span> ' + __('Submitting...', 'yatra');
4409 4583
4410 4584 try {
4411 4585 const formData = new FormData(this.form);
4412 4586 formData.append('action', 'yatra_submit_review');
@@ -4420,9 +4594,9 @@
4420 4594 const result = await response.json();
4421 4595
4422 4596 if (result.success) {
4423 4597 // Show success message
4424 - this.showMessage('success', result.data?.message || 'Thank you for your review!');
4598 + this.showMessage('success', result.data?.message || __('Thank you for your review!', 'yatra'));
4425 4599
4426 4600 // Check if this was an edit or new submission
4427 4601 const isEdit = this.form.querySelector('input[name="action_type"]')?.value === 'edit';
4428 4602
@@ -4439,13 +4613,13 @@
4439 4613 ratingLabels.forEach(label => label.classList.add('selected'));
4440 4614 }
4441 4615 }
4442 4616 } else {
4443 - 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'));
4444 4618 }
4445 4619 } catch (error) {
4446 4620 console.error('Review submission error:', error);
4447 - this.showMessage('error', 'An error occurred. Please try again.');
4621 + this.showMessage('error', __('An error occurred. Please try again.', 'yatra'));
4448 4622 } finally {
4449 4623 submitBtn.disabled = false;
4450 4624 submitBtn.innerHTML = originalText;
4451 4625 }
@@ -4619,16 +4793,13 @@
4619 4793 * Handles save/remove trip from wishlist functionality
4620 4794 */
4621 4795 class WishlistHandler {
4622 4796 constructor() {
4623 - if (!window.yatraTripData?.wishlistEnabled) {
4624 - return;
4625 - }
4626 - this.apiUrl = window.yatraTripData?.apiUrl || '/wp-json/yatra/v1';
4627 - this.nonce = window.yatraTripData?.nonce || '';
4628 - this.tripId = window.yatraTripData?.tripId || 0;
4629 - this.isLoggedIn = window.yatraTripData?.isLoggedIn || false;
4630 - 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;
4631 4802 }
4632 4803
4633 4804 init() {
4634 4805 // Check if user is logged in and update button states
@@ -4711,15 +4882,15 @@
4711 4882
4712 4883 const data = await response.json();
4713 4884 if (data.success) {
4714 4885 this.updateButtonState(true, tid);
4715 - this.showMessage(data.message || 'Trip saved to wishlist', 'success', btn);
4886 + this.showMessage(data.message || __('Trip saved to wishlist', 'yatra'), 'success', btn);
4716 4887 } else {
4717 - this.showMessage(data.message || 'Failed to save trip', 'error', btn);
4888 + this.showMessage(data.message || __('Failed to save trip', 'yatra'), 'error', btn);
4718 4889 }
4719 4890 } catch (error) {
4720 4891 console.error('Error saving trip:', error);
4721 - this.showMessage('An error occurred. Please try again.', 'error', btn);
4892 + this.showMessage(__('An error occurred. Please try again.', 'yatra'), 'error', btn);
4722 4893 } finally {
4723 4894 btn.disabled = false;
4724 4895 btn.classList.remove('loading');
4725 4896 }
@@ -4741,15 +4912,15 @@
4741 4912
4742 4913 const data = await response.json();
4743 4914 if (data.success) {
4744 4915 this.updateButtonState(false, tid);
4745 - this.showMessage(data.message || 'Trip removed from wishlist', 'success', btn);
4916 + this.showMessage(data.message || __('Trip removed from wishlist', 'yatra'), 'success', btn);
4746 4917 } else {
4747 - this.showMessage(data.message || 'Failed to remove trip', 'error', btn);
4918 + this.showMessage(data.message || __('Failed to remove trip', 'yatra'), 'error', btn);
4748 4919 }
4749 4920 } catch (error) {
4750 4921 console.error('Error removing trip:', error);
4751 - this.showMessage('An error occurred. Please try again.', 'error', btn);
4922 + this.showMessage(__('An error occurred. Please try again.', 'yatra'), 'error', btn);
4752 4923 } finally {
4753 4924 btn.disabled = false;
4754 4925 btn.classList.remove('loading');
4755 4926 }
@@ -4763,9 +4934,9 @@
4763 4934 const buttons = document.querySelectorAll(selector);
4764 4935 buttons.forEach(btn => {
4765 4936 if (isSaved) {
4766 4937 btn.classList.add('saved', 'is-saved');
4767 - btn.setAttribute('aria-label', 'Remove from favorites');
4938 + btn.setAttribute('aria-label', __('Remove from favorites', 'yatra'));
4768 4939 // Fill the heart icon and change color
4769 4940 const svg = btn.querySelector('svg');
4770 4941 if (svg) {
4771 4942 svg.style.fill = 'currentColor';
@@ -4773,9 +4944,9 @@
4773 4944 btn.style.color = '#ef4444';
4774 4945 }
4775 4946 } else {
4776 4947 btn.classList.remove('saved', 'is-saved');
4777 - btn.setAttribute('aria-label', 'Add to favorites');
4948 + btn.setAttribute('aria-label', __('Add to favorites', 'yatra'));
4778 4949 // Unfill the heart icon
4779 4950 const svg = btn.querySelector('svg');
4780 4951 if (svg) {
4781 4952 svg.style.fill = 'none';
@@ -4799,13 +4970,17 @@
4799 4970 if (modalContent) {
4800 4971 const loginMessage = document.createElement('div');
4801 4972 loginMessage.className = 'yatra-login-prompt';
4802 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');
4803 4978 loginMessage.innerHTML = `
4804 - <h3 style="margin-bottom: 20px;">Login Required</h3>
4805 - <p style="margin-bottom: 30px;">Please login to save trips to your wishlist.</p>
4806 - <a href="${window.yatraTripData?.loginUrl || '/wp-login.php'}" class="yatra-btn" style="display: inline-block; margin-right: 10px;">Login</a>
4807 - <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>
4808 4983 `;
4809 4984 modalContent.innerHTML = '';
4810 4985 modalContent.appendChild(loginMessage);
4811 4986 }
@@ -4810,9 +4985,9 @@
4810 4985 modalContent.appendChild(loginMessage);
4811 4986 }
4812 4987 } else {
4813 4988 // Fallback: show alert and redirect to login
4814 - 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'))) {
4815 4990 window.location.href = window.yatraTripData?.loginUrl || '/wp-login.php';
4816 4991 }
4817 4992 }
4818 4993 }
@@ -5062,15 +5237,15 @@
5062 5237 const videoUrl = link.getAttribute('href');
5063 5238 if (!videoUrl || videoUrl === '#') return;
5064 5239
5065 5240 const img = overlay.closest('.yatra-media-card').querySelector('img');
5066 - const videoTitle = img ? img.alt : 'Itinerary Video';
5241 + const videoTitle = img ? img.alt : __('Itinerary Video', 'yatra');
5067 5242
5068 5243 // Extract video ID from URL
5069 5244 let videoId = '';
5070 5245 if (videoUrl.includes('youtube.com') || videoUrl.includes('youtu.be')) {
5071 5246 // YouTube video ID extraction
5072 - 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?#\/]+)/);
5073 5248 videoId = matches ? matches[1] : '';
5074 5249 } else if (videoUrl.includes('vimeo.com')) {
5075 5250 // Vimeo video ID extraction
5076 5251 const matches = videoUrl.match(/vimeo\.com\/(?:.*#|.\/videos\/)?([0-9]+)/);
@@ -5183,9 +5358,9 @@
5183 5358 try {
5184 5359 // Get download URL with signature using centralized helper
5185 5360 const response = await window.YatraApiHelper.getDownloadInfo(downloadId);
5186 5361 if (!response.ok) {
5187 - throw new Error('Download failed');
5362 + throw new Error(__('Download failed', 'yatra'));
5188 5363 }
5189 5364
5190 5365 const data = await response.json();
5191 5366
@@ -5202,9 +5377,9 @@
5202 5377 }
5203 5378 } catch (error) {
5204 5379 console.error('Download error:', error);
5205 5380 // Show error message to user
5206 - this.showNotification('Download failed. Please try again.', 'error');
5381 + this.showNotification(__('Download failed. Please try again.', 'yatra'), 'error');
5207 5382 }
5208 5383 }
5209 5384
5210 5385 async previewFile(downloadId) {
@@ -5214,15 +5389,15 @@
5214 5389
5215 5390 // Get download info using centralized helper
5216 5391 const response = await window.YatraApiHelper.getDownloadInfo(downloadId);
5217 5392 if (!response.ok) {
5218 - throw new Error('Failed to get file info');
5393 + throw new Error(__('Failed to get file info', 'yatra'));
5219 5394 }
5220 5395
5221 5396 const data = await response.json();
5222 5397
5223 5398 if (data.download_url) {
5224 - this.loadPreview(data.download_url, data.title || 'Preview');
5399 + this.loadPreview(data.download_url, data.title || __('Preview', 'yatra'));
5225 5400 } else {
5226 5401 throw new Error('File URL not found');
5227 5402 }
5228 5403 } catch (error) {
@@ -5227,9 +5402,9 @@
5227 5402 }
5228 5403 } catch (error) {
5229 5404 console.error('Preview error:', error);
5230 5405 this.closePreviewModal();
5231 - this.showNotification('Failed to load preview. Please try again.', 'error');
5406 + this.showNotification(__('Failed to load preview. Please try again.', 'yatra'), 'error');
5232 5407 }
5233 5408 }
5234 5409
5235 5410 loadPreview(fileUrl, title) {
@@ -5257,13 +5432,15 @@
5257 5432 previewContent.innerHTML = `
5258 5433 <iframe src="${fileUrl}" style="width: 100%; height: 500px; border: none; border-radius: 8px;"></iframe>
5259 5434 `;
5260 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');
5261 5438 previewContent.innerHTML = `
5262 5439 <div style="text-align: center; padding: 40px;">
5263 5440 <div style="font-size: 48px; margin-bottom: 16px; opacity: 0.5;">📄</div>
5264 - <p style="color: #6b7280; margin-bottom: 16px;">Preview not available for this file type</p>
5265 - <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>
5266 5443 </div>
5267 5444 `;
5268 5445 }
5269 5446 }
@@ -5285,12 +5462,13 @@
5285 5462
5286 5463 // Reset loading state
5287 5464 const previewContent = document.getElementById('preview-content');
5288 5465 if (previewContent) {
5466 + const loadingPreviewLabel = __('Loading preview...', 'yatra');
5289 5467 previewContent.innerHTML = `
5290 5468 <div class="yatra-preview-loading">
5291 5469 <div class="yatra-preview-spinner"></div>
5292 - <p>Loading preview...</p>
5470 + <p>${loadingPreviewLabel}</p>
5293 5471 </div>
5294 5472 `;
5295 5473 }
5296 5474 }