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
yatra / assets / js / listing-wishlist.js

listing-wishlist.js in Yatra – Travel Booking & Tour Operator Software 3.0.16, at assets/js/listing-wishlist.js

523 lines 22.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 // Wishlist functionality for trip listing / single / similar trips.
2
3 // Translation helpers — idempotent shim matching the other frontend bundles.
4 (function () {
5 if (typeof window.__ === 'function') return;
6 if (window.wp && window.wp.i18n && typeof window.wp.i18n.__ === 'function') {
7 window.__ = function (text, domain) { return window.wp.i18n.__(text, domain || 'yatra'); };
8 window._n = function (s, p, n, domain) { return window.wp.i18n._n(s, p, n, domain || 'yatra'); };
9 window._x = function (text, ctx, domain) { return window.wp.i18n._x(text, ctx, domain || 'yatra'); };
10 window.sprintf = window.sprintf || (window.wp.i18n.sprintf || function (fmt) { return fmt; });
11 } else {
12 window.__ = function (text) { return text; };
13 window._n = function (s, p, n) { return n === 1 ? s : p; };
14 window._x = function (text) { return text; };
15 window.sprintf = window.sprintf || function (fmt) { return fmt; };
16 }
17 })();
18
19 class TripListingWishlist {
20 constructor() {
21 const cfg = window.yatraWishlistConfig || {};
22 const base = (cfg.restUrl || '').replace(/\/$/, '');
23 this.apiUrl = base
24 || (window.location && window.location.origin
25 ? window.location.origin + '/wp-json/yatra/v1'
26 : '/wp-json/yatra/v1');
27 this.nonce = cfg.nonce || '';
28 this.isLoggedIn = !!cfg.isLoggedIn;
29 this.loginUrl = cfg.loginUrl || '/wp-login.php';
30
31 // Server-localized strings — fall back to JS-side __() so the bundle
32 // still works if the PHP localizer didn't ship the i18n bag.
33 const i = cfg.i18n || {};
34 this.strings = {
35 loginRequired: i.loginRequired || __('Login Required', 'yatra'),
36 loginPrompt: i.loginPrompt || __('Please login to save trips to your wishlist.', 'yatra'),
37 login: i.login || __('Login', 'yatra'),
38 cancel: i.cancel || __('Cancel', 'yatra'),
39 genericError: i.genericError || __('An error occurred. Please try again.', 'yatra'),
40 saved: i.saved || __('Trip saved to wishlist', 'yatra'),
41 removed: i.removed || __('Trip removed from wishlist', 'yatra'),
42 saveFailed: i.saveFailed || __('Failed to save trip', 'yatra'),
43 removeFailed: i.removeFailed || __('Failed to remove trip', 'yatra'),
44 addAria: i.addAria || __('Add to favorites', 'yatra'),
45 removeAria: i.removeAria || __('Remove from favorites', 'yatra'),
46 };
47
48 // Per-button in-flight guard so rapid double-clicks don't fire two
49 // requests. Keyed by trip ID.
50 this.inFlight = new Set();
51
52 this.init();
53 }
54
55 init() {
56 document.addEventListener('click', (e) => {
57 const btn = e.target.closest('.yatra-favorite-btn, .yatra-similar-favorite-btn');
58 if (btn) {
59 e.preventDefault();
60 e.stopPropagation();
61 this.handleWishlistClick(btn);
62 }
63 });
64
65 if (this.isLoggedIn) {
66 this.checkAllSavedStatus();
67 }
68 }
69
70 // Hydrate the saved/unsaved state for every wishlist button on the page.
71 // We Promise.all the lookups so errors aren't swallowed by `forEach(async)`
72 // and the caller can await full settling if needed.
73 async checkAllSavedStatus() {
74 const buttons = Array.from(document.querySelectorAll(
75 '.yatra-favorite-btn[data-trip-id], .yatra-similar-favorite-btn[data-trip-id]'
76 ));
77 if (buttons.length === 0) return;
78
79 // Dedupe — multiple buttons can point at the same trip (hero +
80 // similar-trips card). One lookup, many DOM updates.
81 const byId = new Map();
82 for (const btn of buttons) {
83 const id = parseInt(btn.getAttribute('data-trip-id'), 10);
84 if (!id || id <= 0) continue;
85 if (!byId.has(id)) byId.set(id, []);
86 byId.get(id).push(btn);
87 }
88
89 await Promise.all(Array.from(byId.entries()).map(async ([tripId, btns]) => {
90 try {
91 const response = await fetch(
92 `${this.apiUrl}/saved-trips/check/${tripId}`,
93 {
94 method: 'GET',
95 headers: { 'X-WP-Nonce': this.nonce },
96 credentials: 'same-origin',
97 }
98 );
99 if (!response.ok) return;
100 const data = await response.json();
101 if (data && data.success && data.data && data.data.is_saved) {
102 btns.forEach((b) => this.updateButtonState(b, true));
103 }
104 } catch (error) {
105 // Silent — page still functions, button just shows unsaved.
106 if (window.console && console.warn) {
107 console.warn('Yatra wishlist: status check failed for trip', tripId, error);
108 }
109 }
110 }));
111 }
112
113 handleWishlistClick(btn) {
114 if (!this.isLoggedIn) {
115 this.showLoginPopup();
116 return;
117 }
118
119 const tripId = parseInt(btn.getAttribute('data-trip-id'), 10);
120 if (!tripId || tripId <= 0) {
121 return;
122 }
123
124 // Idempotent guard: ignore clicks while a request for this trip is in
125 // flight. Prevents double-POSTs from rapid taps / accidental dbl-click.
126 if (this.inFlight.has(tripId)) return;
127
128 const isSaved = btn.classList.contains('saved');
129 if (isSaved) {
130 this.removeFromWishlist(btn, tripId);
131 } else {
132 this.addToWishlist(btn, tripId);
133 }
134 }
135
136 // Mirror the new state across every button on the page pointing at this
137 // trip ID so the hero + card + similar-trips all stay in sync.
138 syncAllButtonsForTrip(tripId, isSaved) {
139 document.querySelectorAll(
140 `.yatra-favorite-btn[data-trip-id="${tripId}"], .yatra-similar-favorite-btn[data-trip-id="${tripId}"]`
141 ).forEach((b) => this.updateButtonState(b, isSaved));
142 }
143
144 async addToWishlist(btn, tripId) {
145 this.inFlight.add(tripId);
146 btn.disabled = true;
147 btn.classList.add('loading');
148
149 try {
150 const params = new URLSearchParams();
151 params.set('trip_id', String(tripId));
152 const response = await fetch(`${this.apiUrl}/saved-trips`, {
153 method: 'POST',
154 headers: {
155 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
156 'X-WP-Nonce': this.nonce,
157 },
158 credentials: 'same-origin',
159 body: params.toString(),
160 });
161
162 const data = await response.json().catch(() => ({}));
163 if (response.ok && data && data.success) {
164 this.syncAllButtonsForTrip(tripId, true);
165 this.showMessage(data.message || this.strings.saved, 'success', btn);
166 } else {
167 this.showMessage((data && data.message) || this.strings.saveFailed, 'error', btn);
168 }
169 } catch (error) {
170 if (window.console && console.error) {
171 console.error('Yatra wishlist: save failed', error);
172 }
173 this.showMessage(this.strings.genericError, 'error', btn);
174 } finally {
175 btn.disabled = false;
176 btn.classList.remove('loading');
177 this.inFlight.delete(tripId);
178 }
179 }
180
181 async removeFromWishlist(btn, tripId) {
182 this.inFlight.add(tripId);
183 btn.disabled = true;
184 btn.classList.add('loading');
185
186 try {
187 const response = await fetch(`${this.apiUrl}/saved-trips/${tripId}`, {
188 method: 'DELETE',
189 headers: { 'X-WP-Nonce': this.nonce },
190 credentials: 'same-origin',
191 });
192
193 const data = await response.json().catch(() => ({}));
194 if (response.ok && data && data.success) {
195 this.syncAllButtonsForTrip(tripId, false);
196 this.showMessage(data.message || this.strings.removed, 'success', btn);
197 } else {
198 this.showMessage((data && data.message) || this.strings.removeFailed, 'error', btn);
199 }
200 } catch (error) {
201 if (window.console && console.error) {
202 console.error('Yatra wishlist: remove failed', error);
203 }
204 this.showMessage(this.strings.genericError, 'error', btn);
205 } finally {
206 btn.disabled = false;
207 btn.classList.remove('loading');
208 this.inFlight.delete(tripId);
209 }
210 }
211
212 updateButtonState(btn, isSaved) {
213 // Visual state is driven entirely by CSS — see
214 // `.yatra-favorite-btn.saved` / `.yatra-favorite-btn.is-saved` rules.
215 // We only toggle classes + a11y attributes here so the markup
216 // stays clean and the CSS can be edited in one place.
217 if (isSaved) {
218 btn.classList.add('saved', 'is-saved');
219 btn.setAttribute('aria-label', this.strings.removeAria);
220 btn.setAttribute('title', this.strings.removeAria);
221 } else {
222 btn.classList.remove('saved', 'is-saved');
223 btn.setAttribute('aria-label', this.strings.addAria);
224 btn.setAttribute('title', this.strings.addAria);
225 }
226
227 // Clear any inline styles a previous version of this script may have
228 // left behind so the CSS rules can take effect cleanly.
229 if (btn.style.color) btn.style.color = '';
230 const svg = btn.querySelector('svg');
231 if (svg) {
232 if (svg.style.fill) svg.style.fill = '';
233 if (svg.style.stroke) svg.style.stroke = '';
234 }
235 }
236
237 // Build a login URL that returns the user back to the page they were on
238 // after authenticating, rather than dropping them on the dashboard.
239 buildLoginUrl() {
240 const here = (window.location && window.location.href) || '/';
241 try {
242 const u = new URL(this.loginUrl, window.location.origin);
243 u.searchParams.set('redirect_to', here);
244 return u.toString();
245 } catch (_e) {
246 const sep = this.loginUrl.indexOf('?') >= 0 ? '&' : '?';
247 return this.loginUrl + sep + 'redirect_to=' + encodeURIComponent(here);
248 }
249 }
250
251 // Inject the modal stylesheet once per page. Uses Yatra design tokens
252 // (--yatra-primary, --yatra-text-*, --yatra-radius-*, --yatra-shadow-*)
253 // from common.css with safe fallbacks if the stylesheet hasn't loaded.
254 ensureModalStyles() {
255 if (document.getElementById('yatra-login-modal-styles')) return;
256 const style = document.createElement('style');
257 style.id = 'yatra-login-modal-styles';
258 style.textContent = `
259 .yatra-login-modal {
260 position: fixed; inset: 0; z-index: 100000;
261 display: flex; align-items: center; justify-content: center;
262 font-family: inherit;
263 animation: yatraLoginFade .18s ease-out;
264 }
265 .yatra-login-modal__overlay {
266 position: absolute; inset: 0;
267 background: rgba(17, 24, 39, 0.55);
268 backdrop-filter: blur(3px);
269 -webkit-backdrop-filter: blur(3px);
270 }
271 .yatra-login-modal__panel {
272 position: relative;
273 background: var(--yatra-bg-white, #ffffff);
274 border-radius: var(--yatra-radius-lg, 16px);
275 box-shadow: var(--yatra-shadow-lg, 0 10px 15px -3px rgba(0,0,0,.1), 0 4px 6px -4px rgba(0,0,0,.1));
276 padding: var(--yatra-space-xl, 32px) var(--yatra-space-lg, 24px) var(--yatra-space-lg, 24px);
277 max-width: 420px; width: calc(100% - 32px);
278 text-align: center; box-sizing: border-box;
279 animation: yatraLoginPop .22s cubic-bezier(.2,.9,.4,1.2);
280 }
281 .yatra-login-modal__close {
282 position: absolute; top: var(--yatra-space-sm, 8px); right: var(--yatra-space-sm, 8px);
283 width: 32px; height: 32px; border-radius: 50%; border: 0;
284 background: transparent; cursor: pointer;
285 color: var(--yatra-text-muted, #6b7280);
286 display: inline-flex; align-items: center; justify-content: center;
287 transition: background .15s ease, color .15s ease;
288 padding: 0;
289 }
290 .yatra-login-modal__close:hover,
291 .yatra-login-modal__close:focus-visible {
292 background: var(--yatra-bg-gray-100, #f3f4f6);
293 color: var(--yatra-text-primary, #1f2937);
294 outline: none;
295 }
296 .yatra-login-modal__icon {
297 width: 56px; height: 56px; border-radius: 50%;
298 background: var(--yatra-primary-light-soft, #eff6ff);
299 color: var(--yatra-primary, #3b82f6);
300 display: inline-flex; align-items: center; justify-content: center;
301 margin: var(--yatra-space-xs, 4px) auto var(--yatra-space-md, 16px);
302 }
303 .yatra-login-modal__icon svg { width: 28px; height: 28px; fill: currentColor; }
304 .yatra-login-modal__title {
305 margin: 0 0 var(--yatra-space-sm, 8px);
306 font-size: 20px; font-weight: 700;
307 color: var(--yatra-text-primary, #1f2937);
308 line-height: 1.3; letter-spacing: -.01em;
309 }
310 .yatra-login-modal__body {
311 margin: 0 0 var(--yatra-space-lg, 24px);
312 font-size: 14.5px;
313 color: var(--yatra-text-secondary, #4b5563);
314 line-height: 1.55;
315 }
316 .yatra-login-modal__actions {
317 display: flex; gap: var(--yatra-space-sm, 10px);
318 justify-content: center; flex-wrap: wrap;
319 }
320 .yatra-login-modal__btn {
321 display: inline-flex; align-items: center; justify-content: center;
322 min-width: 120px;
323 padding: 11px var(--yatra-space-lg, 22px);
324 font-size: 14.5px; font-weight: 600;
325 border-radius: var(--yatra-radius, 8px);
326 cursor: pointer; border: 1px solid transparent;
327 transition: transform .08s ease, box-shadow .15s ease, background .15s ease, color .15s ease, border-color .15s ease;
328 text-decoration: none; line-height: 1;
329 font-family: inherit; box-sizing: border-box;
330 }
331 .yatra-login-modal__btn:active { transform: translateY(1px); }
332 .yatra-login-modal__btn--primary {
333 background: var(--yatra-primary, #3b82f6);
334 color: var(--yatra-bg-white, #ffffff);
335 box-shadow: var(--yatra-shadow-md, 0 4px 6px -1px rgba(0,0,0,.1), 0 2px 4px -2px rgba(0,0,0,.1));
336 }
337 .yatra-login-modal__btn--primary:hover,
338 .yatra-login-modal__btn--primary:focus-visible {
339 background: var(--yatra-primary-dark, #2563eb);
340 color: var(--yatra-bg-white, #ffffff);
341 outline: none;
342 }
343 .yatra-login-modal__btn--ghost {
344 background: var(--yatra-bg-white, #ffffff);
345 color: var(--yatra-text-secondary, #4b5563);
346 border-color: var(--yatra-border-color, #e5e7eb);
347 }
348 .yatra-login-modal__btn--ghost:hover,
349 .yatra-login-modal__btn--ghost:focus-visible {
350 background: var(--yatra-bg-gray-50, #f9fafb);
351 color: var(--yatra-text-primary, #1f2937);
352 border-color: var(--yatra-text-light, #9ca3af);
353 outline: none;
354 }
355 @keyframes yatraLoginFade { from { opacity: 0 } to { opacity: 1 } }
356 @keyframes yatraLoginPop {
357 from { opacity: 0; transform: translateY(8px) scale(.96) }
358 to { opacity: 1; transform: translateY(0) scale(1) }
359 }
360 @media (max-width: 480px) {
361 .yatra-login-modal__panel {
362 padding: var(--yatra-space-lg, 24px) var(--yatra-space-md, 18px) var(--yatra-space-md, 18px);
363 border-radius: var(--yatra-radius-md, 12px);
364 }
365 .yatra-login-modal__btn { min-width: 0; flex: 1; }
366 }
367 `;
368 document.head.appendChild(style);
369 }
370
371 showLoginPopup() {
372 this.ensureModalStyles();
373
374 // Remove an existing instance first to avoid stacking modals.
375 const existing = document.getElementById('yatra-login-modal');
376 if (existing) existing.remove();
377
378 const loginHref = this.buildLoginUrl();
379
380 const modal = document.createElement('div');
381 modal.id = 'yatra-login-modal';
382 modal.className = 'yatra-login-modal';
383 modal.setAttribute('role', 'dialog');
384 modal.setAttribute('aria-modal', 'true');
385 modal.setAttribute('aria-labelledby', 'yatra-login-modal-title');
386 modal.setAttribute('aria-describedby', 'yatra-login-modal-body');
387
388 const overlay = document.createElement('div');
389 overlay.className = 'yatra-login-modal__overlay';
390
391 const panel = document.createElement('div');
392 panel.className = 'yatra-login-modal__panel';
393
394 const closeX = document.createElement('button');
395 closeX.type = 'button';
396 closeX.className = 'yatra-login-modal__close';
397 closeX.setAttribute('aria-label', this.strings.cancel);
398 closeX.innerHTML = '<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>';
399
400 const iconWrap = document.createElement('div');
401 iconWrap.className = 'yatra-login-modal__icon';
402 iconWrap.setAttribute('aria-hidden', 'true');
403 iconWrap.innerHTML = '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 21s-7.5-4.5-9.5-9.2C1.1 8.6 2.7 5 6.2 5c2 0 3.3 1 3.8 2 .5-1 1.8-2 3.8-2 3.5 0 5.1 3.6 3.7 6.8C19.5 16.5 12 21 12 21z"/></svg>';
404
405 const title = document.createElement('h3');
406 title.id = 'yatra-login-modal-title';
407 title.className = 'yatra-login-modal__title';
408 title.textContent = this.strings.loginRequired;
409
410 const body = document.createElement('p');
411 body.id = 'yatra-login-modal-body';
412 body.className = 'yatra-login-modal__body';
413 body.textContent = this.strings.loginPrompt;
414
415 const actions = document.createElement('div');
416 actions.className = 'yatra-login-modal__actions';
417
418 const cancelBtn = document.createElement('button');
419 cancelBtn.type = 'button';
420 cancelBtn.className = 'yatra-login-modal__btn yatra-login-modal__btn--ghost';
421 cancelBtn.textContent = this.strings.cancel;
422
423 const loginLink = document.createElement('a');
424 loginLink.href = loginHref;
425 loginLink.className = 'yatra-login-modal__btn yatra-login-modal__btn--primary';
426 loginLink.textContent = this.strings.login;
427
428 // Cancel on the left, Login (primary action) on the right — matches
429 // standard dialog conventions on web/macOS.
430 actions.appendChild(cancelBtn);
431 actions.appendChild(loginLink);
432 panel.appendChild(closeX);
433 panel.appendChild(iconWrap);
434 panel.appendChild(title);
435 panel.appendChild(body);
436 panel.appendChild(actions);
437 modal.appendChild(overlay);
438 modal.appendChild(panel);
439
440 const prevOverflow = document.body.style.overflow;
441 document.body.appendChild(modal);
442 document.body.style.overflow = 'hidden';
443
444 const closeModal = () => {
445 modal.remove();
446 document.body.style.overflow = prevOverflow;
447 document.removeEventListener('keydown', onKey);
448 };
449 const onKey = (e) => {
450 if (e.key === 'Escape') closeModal();
451 // Trap Tab focus within the dialog.
452 if (e.key === 'Tab') {
453 const focusables = [closeX, cancelBtn, loginLink];
454 const idx = focusables.indexOf(document.activeElement);
455 if (e.shiftKey) {
456 if (idx <= 0) { e.preventDefault(); focusables[focusables.length - 1].focus(); }
457 } else {
458 if (idx === focusables.length - 1) { e.preventDefault(); focusables[0].focus(); }
459 }
460 }
461 };
462
463 overlay.addEventListener('click', closeModal);
464 closeX.addEventListener('click', closeModal);
465 cancelBtn.addEventListener('click', closeModal);
466 document.addEventListener('keydown', onKey);
467
468 // Focus the primary action so keyboard users can press Enter to log in.
469 setTimeout(() => { try { loginLink.focus(); } catch (_e) {} }, 0);
470 }
471
472 showMessage(message, type, button) {
473 const existingToast = document.getElementById('yatra-wishlist-toast');
474 if (existingToast) existingToast.remove();
475
476 const toast = document.createElement('div');
477 toast.id = 'yatra-wishlist-toast';
478 toast.setAttribute('role', type === 'error' ? 'alert' : 'status');
479 toast.textContent = message;
480
481 const rect = button.getBoundingClientRect();
482 const scrollTop = window.pageYOffset || document.documentElement.scrollTop;
483 const scrollLeft = window.pageXOffset || document.documentElement.scrollLeft;
484
485 toast.style.cssText = `
486 position: absolute;
487 top: ${rect.bottom + scrollTop + 10}px;
488 left: ${rect.left + scrollLeft}px;
489 padding: 10px 16px;
490 border-radius: 8px;
491 z-index: 10001;
492 box-shadow: 0 4px 12px rgba(0,0,0,0.2);
493 transition: all 0.3s ease;
494 white-space: nowrap;
495 font-size: 14px;
496 pointer-events: none;
497 background-color: ${type === 'success' ? '#10b981' : '#ef4444'};
498 color: #ffffff;
499 opacity: 0;
500 transform: translateY(-10px);
501 `;
502
503 document.body.appendChild(toast);
504
505 requestAnimationFrame(() => {
506 toast.style.opacity = '1';
507 toast.style.transform = 'translateY(0)';
508 });
509
510 setTimeout(() => {
511 toast.style.opacity = '0';
512 toast.style.transform = 'translateY(-10px)';
513 setTimeout(() => { if (toast.parentNode) toast.remove(); }, 300);
514 }, 3000);
515 }
516 }
517
518 document.addEventListener('DOMContentLoaded', function () {
519 const cfg = window.yatraWishlistConfig || {};
520 if (!cfg.enabled) return;
521 window.tripListingWishlist = new TripListingWishlist();
522 });
523