// Wishlist functionality for trip listing / single / similar trips. // Translation helpers — idempotent shim matching the other frontend bundles. (function () { if (typeof window.__ === 'function') return; if (window.wp && window.wp.i18n && typeof window.wp.i18n.__ === 'function') { window.__ = function (text, domain) { return window.wp.i18n.__(text, domain || 'yatra'); }; window._n = function (s, p, n, domain) { return window.wp.i18n._n(s, p, n, domain || 'yatra'); }; window._x = function (text, ctx, domain) { return window.wp.i18n._x(text, ctx, domain || 'yatra'); }; window.sprintf = window.sprintf || (window.wp.i18n.sprintf || function (fmt) { return fmt; }); } else { window.__ = function (text) { return text; }; window._n = function (s, p, n) { return n === 1 ? s : p; }; window._x = function (text) { return text; }; window.sprintf = window.sprintf || function (fmt) { return fmt; }; } })(); class TripListingWishlist { constructor() { const cfg = window.yatraWishlistConfig || {}; const base = (cfg.restUrl || '').replace(/\/$/, ''); this.apiUrl = base || (window.location && window.location.origin ? window.location.origin + '/wp-json/yatra/v1' : '/wp-json/yatra/v1'); this.nonce = cfg.nonce || ''; this.isLoggedIn = !!cfg.isLoggedIn; this.loginUrl = cfg.loginUrl || '/wp-login.php'; // Server-localized strings — fall back to JS-side __() so the bundle // still works if the PHP localizer didn't ship the i18n bag. const i = cfg.i18n || {}; this.strings = { loginRequired: i.loginRequired || __('Login Required', 'yatra'), loginPrompt: i.loginPrompt || __('Please login to save trips to your wishlist.', 'yatra'), login: i.login || __('Login', 'yatra'), cancel: i.cancel || __('Cancel', 'yatra'), genericError: i.genericError || __('An error occurred. Please try again.', 'yatra'), saved: i.saved || __('Trip saved to wishlist', 'yatra'), removed: i.removed || __('Trip removed from wishlist', 'yatra'), saveFailed: i.saveFailed || __('Failed to save trip', 'yatra'), removeFailed: i.removeFailed || __('Failed to remove trip', 'yatra'), addAria: i.addAria || __('Add to favorites', 'yatra'), removeAria: i.removeAria || __('Remove from favorites', 'yatra'), }; // Per-button in-flight guard so rapid double-clicks don't fire two // requests. Keyed by trip ID. this.inFlight = new Set(); this.init(); } init() { document.addEventListener('click', (e) => { const btn = e.target.closest('.yatra-favorite-btn, .yatra-similar-favorite-btn'); if (btn) { e.preventDefault(); e.stopPropagation(); this.handleWishlistClick(btn); } }); if (this.isLoggedIn) { this.checkAllSavedStatus(); } } // Hydrate the saved/unsaved state for every wishlist button on the page. // We Promise.all the lookups so errors aren't swallowed by `forEach(async)` // and the caller can await full settling if needed. async checkAllSavedStatus() { const buttons = Array.from(document.querySelectorAll( '.yatra-favorite-btn[data-trip-id], .yatra-similar-favorite-btn[data-trip-id]' )); if (buttons.length === 0) return; // Dedupe — multiple buttons can point at the same trip (hero + // similar-trips card). One lookup, many DOM updates. const byId = new Map(); for (const btn of buttons) { const id = parseInt(btn.getAttribute('data-trip-id'), 10); if (!id || id <= 0) continue; if (!byId.has(id)) byId.set(id, []); byId.get(id).push(btn); } await Promise.all(Array.from(byId.entries()).map(async ([tripId, btns]) => { try { const response = await fetch( `${this.apiUrl}/saved-trips/check/${tripId}`, { method: 'GET', headers: { 'X-WP-Nonce': this.nonce }, credentials: 'same-origin', } ); if (!response.ok) return; const data = await response.json(); if (data && data.success && data.data && data.data.is_saved) { btns.forEach((b) => this.updateButtonState(b, true)); } } catch (error) { // Silent — page still functions, button just shows unsaved. if (window.console && console.warn) { console.warn('Yatra wishlist: status check failed for trip', tripId, error); } } })); } handleWishlistClick(btn) { if (!this.isLoggedIn) { this.showLoginPopup(); return; } const tripId = parseInt(btn.getAttribute('data-trip-id'), 10); if (!tripId || tripId <= 0) { return; } // Idempotent guard: ignore clicks while a request for this trip is in // flight. Prevents double-POSTs from rapid taps / accidental dbl-click. if (this.inFlight.has(tripId)) return; const isSaved = btn.classList.contains('saved'); if (isSaved) { this.removeFromWishlist(btn, tripId); } else { this.addToWishlist(btn, tripId); } } // Mirror the new state across every button on the page pointing at this // trip ID so the hero + card + similar-trips all stay in sync. syncAllButtonsForTrip(tripId, isSaved) { document.querySelectorAll( `.yatra-favorite-btn[data-trip-id="${tripId}"], .yatra-similar-favorite-btn[data-trip-id="${tripId}"]` ).forEach((b) => this.updateButtonState(b, isSaved)); } async addToWishlist(btn, tripId) { this.inFlight.add(tripId); btn.disabled = true; btn.classList.add('loading'); try { const params = new URLSearchParams(); params.set('trip_id', String(tripId)); const response = await fetch(`${this.apiUrl}/saved-trips`, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8', 'X-WP-Nonce': this.nonce, }, credentials: 'same-origin', body: params.toString(), }); const data = await response.json().catch(() => ({})); if (response.ok && data && data.success) { this.syncAllButtonsForTrip(tripId, true); this.showMessage(data.message || this.strings.saved, 'success', btn); } else { this.showMessage((data && data.message) || this.strings.saveFailed, 'error', btn); } } catch (error) { if (window.console && console.error) { console.error('Yatra wishlist: save failed', error); } this.showMessage(this.strings.genericError, 'error', btn); } finally { btn.disabled = false; btn.classList.remove('loading'); this.inFlight.delete(tripId); } } async removeFromWishlist(btn, tripId) { this.inFlight.add(tripId); btn.disabled = true; btn.classList.add('loading'); try { const response = await fetch(`${this.apiUrl}/saved-trips/${tripId}`, { method: 'DELETE', headers: { 'X-WP-Nonce': this.nonce }, credentials: 'same-origin', }); const data = await response.json().catch(() => ({})); if (response.ok && data && data.success) { this.syncAllButtonsForTrip(tripId, false); this.showMessage(data.message || this.strings.removed, 'success', btn); } else { this.showMessage((data && data.message) || this.strings.removeFailed, 'error', btn); } } catch (error) { if (window.console && console.error) { console.error('Yatra wishlist: remove failed', error); } this.showMessage(this.strings.genericError, 'error', btn); } finally { btn.disabled = false; btn.classList.remove('loading'); this.inFlight.delete(tripId); } } updateButtonState(btn, isSaved) { // Visual state is driven entirely by CSS — see // `.yatra-favorite-btn.saved` / `.yatra-favorite-btn.is-saved` rules. // We only toggle classes + a11y attributes here so the markup // stays clean and the CSS can be edited in one place. if (isSaved) { btn.classList.add('saved', 'is-saved'); btn.setAttribute('aria-label', this.strings.removeAria); btn.setAttribute('title', this.strings.removeAria); } else { btn.classList.remove('saved', 'is-saved'); btn.setAttribute('aria-label', this.strings.addAria); btn.setAttribute('title', this.strings.addAria); } // Clear any inline styles a previous version of this script may have // left behind so the CSS rules can take effect cleanly. if (btn.style.color) btn.style.color = ''; const svg = btn.querySelector('svg'); if (svg) { if (svg.style.fill) svg.style.fill = ''; if (svg.style.stroke) svg.style.stroke = ''; } } // Build a login URL that returns the user back to the page they were on // after authenticating, rather than dropping them on the dashboard. buildLoginUrl() { const here = (window.location && window.location.href) || '/'; try { const u = new URL(this.loginUrl, window.location.origin); u.searchParams.set('redirect_to', here); return u.toString(); } catch (_e) { const sep = this.loginUrl.indexOf('?') >= 0 ? '&' : '?'; return this.loginUrl + sep + 'redirect_to=' + encodeURIComponent(here); } } // Inject the modal stylesheet once per page. Uses Yatra design tokens // (--yatra-primary, --yatra-text-*, --yatra-radius-*, --yatra-shadow-*) // from common.css with safe fallbacks if the stylesheet hasn't loaded. ensureModalStyles() { if (document.getElementById('yatra-login-modal-styles')) return; const style = document.createElement('style'); style.id = 'yatra-login-modal-styles'; style.textContent = ` .yatra-login-modal { position: fixed; inset: 0; z-index: 100000; display: flex; align-items: center; justify-content: center; font-family: inherit; animation: yatraLoginFade .18s ease-out; } .yatra-login-modal__overlay { position: absolute; inset: 0; background: rgba(17, 24, 39, 0.55); backdrop-filter: blur(3px); -webkit-backdrop-filter: blur(3px); } .yatra-login-modal__panel { position: relative; background: var(--yatra-bg-white, #ffffff); border-radius: var(--yatra-radius-lg, 16px); box-shadow: var(--yatra-shadow-lg, 0 10px 15px -3px rgba(0,0,0,.1), 0 4px 6px -4px rgba(0,0,0,.1)); padding: var(--yatra-space-xl, 32px) var(--yatra-space-lg, 24px) var(--yatra-space-lg, 24px); max-width: 420px; width: calc(100% - 32px); text-align: center; box-sizing: border-box; animation: yatraLoginPop .22s cubic-bezier(.2,.9,.4,1.2); } .yatra-login-modal__close { position: absolute; top: var(--yatra-space-sm, 8px); right: var(--yatra-space-sm, 8px); width: 32px; height: 32px; border-radius: 50%; border: 0; background: transparent; cursor: pointer; color: var(--yatra-text-muted, #6b7280); display: inline-flex; align-items: center; justify-content: center; transition: background .15s ease, color .15s ease; padding: 0; } .yatra-login-modal__close:hover, .yatra-login-modal__close:focus-visible { background: var(--yatra-bg-gray-100, #f3f4f6); color: var(--yatra-text-primary, #1f2937); outline: none; } .yatra-login-modal__icon { width: 56px; height: 56px; border-radius: 50%; background: var(--yatra-primary-light-soft, #eff6ff); color: var(--yatra-primary, #3b82f6); display: inline-flex; align-items: center; justify-content: center; margin: var(--yatra-space-xs, 4px) auto var(--yatra-space-md, 16px); } .yatra-login-modal__icon svg { width: 28px; height: 28px; fill: currentColor; } .yatra-login-modal__title { margin: 0 0 var(--yatra-space-sm, 8px); font-size: 20px; font-weight: 700; color: var(--yatra-text-primary, #1f2937); line-height: 1.3; letter-spacing: -.01em; } .yatra-login-modal__body { margin: 0 0 var(--yatra-space-lg, 24px); font-size: 14.5px; color: var(--yatra-text-secondary, #4b5563); line-height: 1.55; } .yatra-login-modal__actions { display: flex; gap: var(--yatra-space-sm, 10px); justify-content: center; flex-wrap: wrap; } .yatra-login-modal__btn { display: inline-flex; align-items: center; justify-content: center; min-width: 120px; padding: 11px var(--yatra-space-lg, 22px); font-size: 14.5px; font-weight: 600; border-radius: var(--yatra-radius, 8px); cursor: pointer; border: 1px solid transparent; transition: transform .08s ease, box-shadow .15s ease, background .15s ease, color .15s ease, border-color .15s ease; text-decoration: none; line-height: 1; font-family: inherit; box-sizing: border-box; } .yatra-login-modal__btn:active { transform: translateY(1px); } .yatra-login-modal__btn--primary { background: var(--yatra-primary, #3b82f6); color: var(--yatra-bg-white, #ffffff); box-shadow: var(--yatra-shadow-md, 0 4px 6px -1px rgba(0,0,0,.1), 0 2px 4px -2px rgba(0,0,0,.1)); } .yatra-login-modal__btn--primary:hover, .yatra-login-modal__btn--primary:focus-visible { background: var(--yatra-primary-dark, #2563eb); color: var(--yatra-bg-white, #ffffff); outline: none; } .yatra-login-modal__btn--ghost { background: var(--yatra-bg-white, #ffffff); color: var(--yatra-text-secondary, #4b5563); border-color: var(--yatra-border-color, #e5e7eb); } .yatra-login-modal__btn--ghost:hover, .yatra-login-modal__btn--ghost:focus-visible { background: var(--yatra-bg-gray-50, #f9fafb); color: var(--yatra-text-primary, #1f2937); border-color: var(--yatra-text-light, #9ca3af); outline: none; } @keyframes yatraLoginFade { from { opacity: 0 } to { opacity: 1 } } @keyframes yatraLoginPop { from { opacity: 0; transform: translateY(8px) scale(.96) } to { opacity: 1; transform: translateY(0) scale(1) } } @media (max-width: 480px) { .yatra-login-modal__panel { padding: var(--yatra-space-lg, 24px) var(--yatra-space-md, 18px) var(--yatra-space-md, 18px); border-radius: var(--yatra-radius-md, 12px); } .yatra-login-modal__btn { min-width: 0; flex: 1; } } `; document.head.appendChild(style); } showLoginPopup() { this.ensureModalStyles(); // Remove an existing instance first to avoid stacking modals. const existing = document.getElementById('yatra-login-modal'); if (existing) existing.remove(); const loginHref = this.buildLoginUrl(); const modal = document.createElement('div'); modal.id = 'yatra-login-modal'; modal.className = 'yatra-login-modal'; modal.setAttribute('role', 'dialog'); modal.setAttribute('aria-modal', 'true'); modal.setAttribute('aria-labelledby', 'yatra-login-modal-title'); modal.setAttribute('aria-describedby', 'yatra-login-modal-body'); const overlay = document.createElement('div'); overlay.className = 'yatra-login-modal__overlay'; const panel = document.createElement('div'); panel.className = 'yatra-login-modal__panel'; const closeX = document.createElement('button'); closeX.type = 'button'; closeX.className = 'yatra-login-modal__close'; closeX.setAttribute('aria-label', this.strings.cancel); closeX.innerHTML = ''; const iconWrap = document.createElement('div'); iconWrap.className = 'yatra-login-modal__icon'; iconWrap.setAttribute('aria-hidden', 'true'); iconWrap.innerHTML = ''; const title = document.createElement('h3'); title.id = 'yatra-login-modal-title'; title.className = 'yatra-login-modal__title'; title.textContent = this.strings.loginRequired; const body = document.createElement('p'); body.id = 'yatra-login-modal-body'; body.className = 'yatra-login-modal__body'; body.textContent = this.strings.loginPrompt; const actions = document.createElement('div'); actions.className = 'yatra-login-modal__actions'; const cancelBtn = document.createElement('button'); cancelBtn.type = 'button'; cancelBtn.className = 'yatra-login-modal__btn yatra-login-modal__btn--ghost'; cancelBtn.textContent = this.strings.cancel; const loginLink = document.createElement('a'); loginLink.href = loginHref; loginLink.className = 'yatra-login-modal__btn yatra-login-modal__btn--primary'; loginLink.textContent = this.strings.login; // Cancel on the left, Login (primary action) on the right — matches // standard dialog conventions on web/macOS. actions.appendChild(cancelBtn); actions.appendChild(loginLink); panel.appendChild(closeX); panel.appendChild(iconWrap); panel.appendChild(title); panel.appendChild(body); panel.appendChild(actions); modal.appendChild(overlay); modal.appendChild(panel); const prevOverflow = document.body.style.overflow; document.body.appendChild(modal); document.body.style.overflow = 'hidden'; const closeModal = () => { modal.remove(); document.body.style.overflow = prevOverflow; document.removeEventListener('keydown', onKey); }; const onKey = (e) => { if (e.key === 'Escape') closeModal(); // Trap Tab focus within the dialog. if (e.key === 'Tab') { const focusables = [closeX, cancelBtn, loginLink]; const idx = focusables.indexOf(document.activeElement); if (e.shiftKey) { if (idx <= 0) { e.preventDefault(); focusables[focusables.length - 1].focus(); } } else { if (idx === focusables.length - 1) { e.preventDefault(); focusables[0].focus(); } } } }; overlay.addEventListener('click', closeModal); closeX.addEventListener('click', closeModal); cancelBtn.addEventListener('click', closeModal); document.addEventListener('keydown', onKey); // Focus the primary action so keyboard users can press Enter to log in. setTimeout(() => { try { loginLink.focus(); } catch (_e) {} }, 0); } showMessage(message, type, button) { const existingToast = document.getElementById('yatra-wishlist-toast'); if (existingToast) existingToast.remove(); const toast = document.createElement('div'); toast.id = 'yatra-wishlist-toast'; toast.setAttribute('role', type === 'error' ? 'alert' : 'status'); toast.textContent = message; const rect = button.getBoundingClientRect(); const scrollTop = window.pageYOffset || document.documentElement.scrollTop; const scrollLeft = window.pageXOffset || document.documentElement.scrollLeft; toast.style.cssText = ` position: absolute; top: ${rect.bottom + scrollTop + 10}px; left: ${rect.left + scrollLeft}px; padding: 10px 16px; border-radius: 8px; z-index: 10001; box-shadow: 0 4px 12px rgba(0,0,0,0.2); transition: all 0.3s ease; white-space: nowrap; font-size: 14px; pointer-events: none; background-color: ${type === 'success' ? '#10b981' : '#ef4444'}; color: #ffffff; opacity: 0; transform: translateY(-10px); `; document.body.appendChild(toast); requestAnimationFrame(() => { toast.style.opacity = '1'; toast.style.transform = 'translateY(0)'; }); setTimeout(() => { toast.style.opacity = '0'; toast.style.transform = 'translateY(-10px)'; setTimeout(() => { if (toast.parentNode) toast.remove(); }, 300); }, 3000); } } document.addEventListener('DOMContentLoaded', function () { const cfg = window.yatraWishlistConfig || {}; if (!cfg.enabled) return; window.tripListingWishlist = new TripListingWishlist(); });