| @@ -1,21 +1,59 @@ | ||
| 1 | -// Wishlist functionality for trip listing page (Yatra Pro + setting) | |
| 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 | + | |
| 2 | 19 | class TripListingWishlist { |
| 3 | 20 | constructor() { |
| 4 | - var cfg = window.yatraWishlistConfig || {}; | |
| 5 | - var base = (cfg.restUrl || '').replace(/\/$/, ''); | |
| 6 | - this.apiUrl = base || (window.location && window.location.origin ? window.location.origin + '/wp-json/yatra/v1' : '/wp-json/yatra/v1'); | |
| 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'); | |
| 7 | 27 | this.nonce = cfg.nonce || ''; |
| 8 | 28 | this.isLoggedIn = !!cfg.isLoggedIn; |
| 9 | - | |
| 10 | - // Debug logging | |
| 11 | - | |
| 12 | - | |
| 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 | + | |
| 13 | 52 | this.init(); |
| 14 | 53 | } |
| 15 | 54 | |
| 16 | 55 | init() { |
| 17 | - // Handle wishlist button clicks | |
| 18 | 56 | document.addEventListener('click', (e) => { |
| 19 | 57 | const btn = e.target.closest('.yatra-favorite-btn, .yatra-similar-favorite-btn'); |
| 20 | 58 | if (btn) { |
| 21 | 59 | e.preventDefault(); |
| @@ -23,40 +61,54 @@ | ||
| 23 | 61 | this.handleWishlistClick(btn); |
| 24 | 62 | } |
| 25 | 63 | }); |
| 26 | 64 | |
| 27 | - // Check saved status for all trips if logged in | |
| 28 | 65 | if (this.isLoggedIn) { |
| 29 | 66 | this.checkAllSavedStatus(); |
| 30 | 67 | } |
| 31 | 68 | } |
| 32 | 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. | |
| 33 | 73 | async checkAllSavedStatus() { |
| 34 | - const buttons = document.querySelectorAll('.yatra-favorite-btn[data-trip-id], .yatra-similar-favorite-btn[data-trip-id]'); | |
| 35 | - buttons.forEach(async (btn) => { | |
| 36 | - const tripId = btn.getAttribute('data-trip-id'); | |
| 37 | - if (tripId) { | |
| 38 | - try { | |
| 39 | - const response = await fetch( | |
| 40 | - `${this.apiUrl}/saved-trips/check/${tripId}`, | |
| 41 | - { | |
| 42 | - method: 'GET', | |
| 43 | - headers: { | |
| 44 | - 'X-WP-Nonce': this.nonce | |
| 45 | - }, | |
| 46 | - credentials: 'same-origin' | |
| 47 | - } | |
| 48 | - ); | |
| 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; | |
| 49 | 78 | |
| 50 | - const data = await response.json(); | |
| 51 | - if (data.success && data.data.is_saved) { | |
| 52 | - this.updateButtonState(btn, true); | |
| 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', | |
| 53 | 97 | } |
| 54 | - } catch (error) { | |
| 55 | - console.error('Error checking wishlist status:', error); | |
| 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)); | |
| 56 | 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 | + } | |
| 57 | 109 | } |
| 58 | - }); | |
| 110 | + })); | |
| 59 | 111 | } |
| 60 | 112 | |
| 61 | 113 | handleWishlistClick(btn) { |
| 62 | 114 | if (!this.isLoggedIn) { |
| @@ -63,14 +115,17 @@ | ||
| 63 | 115 | this.showLoginPopup(); |
| 64 | 116 | return; |
| 65 | 117 | } |
| 66 | 118 | |
| 67 | - const tripId = btn.getAttribute('data-trip-id'); | |
| 68 | - if (!tripId) { | |
| 69 | - console.error('Trip ID not found'); | |
| 119 | + const tripId = parseInt(btn.getAttribute('data-trip-id'), 10); | |
| 120 | + if (!tripId || tripId <= 0) { | |
| 70 | 121 | return; |
| 71 | 122 | } |
| 72 | 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 | + | |
| 73 | 128 | const isSaved = btn.classList.contains('saved'); |
| 74 | 129 | if (isSaved) { |
| 75 | 130 | this.removeFromWishlist(btn, tripId); |
| 76 | 131 | } else { |
| @@ -77,42 +132,55 @@ | ||
| 77 | 132 | this.addToWishlist(btn, tripId); |
| 78 | 133 | } |
| 79 | 134 | } |
| 80 | 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 | + | |
| 81 | 144 | async addToWishlist(btn, tripId) { |
| 145 | + this.inFlight.add(tripId); | |
| 82 | 146 | btn.disabled = true; |
| 83 | 147 | btn.classList.add('loading'); |
| 84 | 148 | |
| 85 | 149 | try { |
| 86 | 150 | const params = new URLSearchParams(); |
| 87 | - params.set('trip_id', String(parseInt(tripId, 10))); | |
| 151 | + params.set('trip_id', String(tripId)); | |
| 88 | 152 | const response = await fetch(`${this.apiUrl}/saved-trips`, { |
| 89 | 153 | method: 'POST', |
| 90 | 154 | headers: { |
| 91 | 155 | 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8', |
| 92 | - 'X-WP-Nonce': this.nonce | |
| 156 | + 'X-WP-Nonce': this.nonce, | |
| 93 | 157 | }, |
| 94 | 158 | credentials: 'same-origin', |
| 95 | - body: params.toString() | |
| 159 | + body: params.toString(), | |
| 96 | 160 | }); |
| 97 | 161 | |
| 98 | - const data = await response.json(); | |
| 99 | - if (data.success) { | |
| 100 | - this.updateButtonState(btn, true); | |
| 101 | - this.showMessage(data.message || 'Trip saved to wishlist', 'success', btn); | |
| 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); | |
| 102 | 166 | } else { |
| 103 | - this.showMessage(data.message || 'Failed to save trip', 'error', btn); | |
| 167 | + this.showMessage((data && data.message) || this.strings.saveFailed, 'error', btn); | |
| 104 | 168 | } |
| 105 | 169 | } catch (error) { |
| 106 | - console.error('Error saving trip:', error); | |
| 107 | - this.showMessage('An error occurred. Please try again.', 'error', btn); | |
| 170 | + if (window.console && console.error) { | |
| 171 | + console.error('Yatra wishlist: save failed', error); | |
| 172 | + } | |
| 173 | + this.showMessage(this.strings.genericError, 'error', btn); | |
| 108 | 174 | } finally { |
| 109 | 175 | btn.disabled = false; |
| 110 | 176 | btn.classList.remove('loading'); |
| 177 | + this.inFlight.delete(tripId); | |
| 111 | 178 | } |
| 112 | 179 | } |
| 113 | 180 | |
| 114 | 181 | async removeFromWishlist(btn, tripId) { |
| 182 | + this.inFlight.add(tripId); | |
| 115 | 183 | btn.disabled = true; |
| 116 | 184 | btn.classList.add('loading'); |
| 117 | 185 | |
| 118 | 186 | try { |
| @@ -117,139 +185,304 @@ | ||
| 117 | 185 | |
| 118 | 186 | try { |
| 119 | 187 | const response = await fetch(`${this.apiUrl}/saved-trips/${tripId}`, { |
| 120 | 188 | method: 'DELETE', |
| 121 | - headers: { | |
| 122 | - 'X-WP-Nonce': this.nonce | |
| 123 | - }, | |
| 124 | - credentials: 'same-origin' | |
| 189 | + headers: { 'X-WP-Nonce': this.nonce }, | |
| 190 | + credentials: 'same-origin', | |
| 125 | 191 | }); |
| 126 | 192 | |
| 127 | - const data = await response.json(); | |
| 128 | - if (data.success) { | |
| 129 | - this.updateButtonState(btn, false); | |
| 130 | - this.showMessage(data.message || 'Trip removed from wishlist', 'success', btn); | |
| 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); | |
| 131 | 197 | } else { |
| 132 | - this.showMessage(data.message || 'Failed to remove trip', 'error', btn); | |
| 198 | + this.showMessage((data && data.message) || this.strings.removeFailed, 'error', btn); | |
| 133 | 199 | } |
| 134 | 200 | } catch (error) { |
| 135 | - console.error('Error removing trip:', error); | |
| 136 | - this.showMessage('An error occurred. Please try again.', 'error', btn); | |
| 201 | + if (window.console && console.error) { | |
| 202 | + console.error('Yatra wishlist: remove failed', error); | |
| 203 | + } | |
| 204 | + this.showMessage(this.strings.genericError, 'error', btn); | |
| 137 | 205 | } finally { |
| 138 | 206 | btn.disabled = false; |
| 139 | 207 | btn.classList.remove('loading'); |
| 208 | + this.inFlight.delete(tripId); | |
| 140 | 209 | } |
| 141 | 210 | } |
| 142 | 211 | |
| 143 | 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. | |
| 144 | 217 | if (isSaved) { |
| 145 | 218 | btn.classList.add('saved', 'is-saved'); |
| 146 | - btn.setAttribute('aria-label', 'Remove from favorites'); | |
| 147 | - btn.setAttribute('title', 'Remove from favorites'); | |
| 148 | - // Fill the heart icon and change color | |
| 149 | - const svg = btn.querySelector('svg'); | |
| 150 | - if (svg) { | |
| 151 | - svg.style.fill = 'currentColor'; | |
| 152 | - svg.style.stroke = 'currentColor'; | |
| 153 | - btn.style.color = '#ef4444'; | |
| 154 | - } | |
| 219 | + btn.setAttribute('aria-label', this.strings.removeAria); | |
| 220 | + btn.setAttribute('title', this.strings.removeAria); | |
| 155 | 221 | } else { |
| 156 | 222 | btn.classList.remove('saved', 'is-saved'); |
| 157 | - btn.setAttribute('aria-label', 'Add to favorites'); | |
| 158 | - btn.setAttribute('title', 'Add to favorites'); | |
| 159 | - // Unfill the heart icon | |
| 160 | - const svg = btn.querySelector('svg'); | |
| 161 | - if (svg) { | |
| 162 | - svg.style.fill = 'none'; | |
| 163 | - svg.style.stroke = 'currentColor'; | |
| 164 | - btn.style.color = ''; | |
| 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; | |
| 165 | 264 | } |
| 166 | - } | |
| 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); | |
| 167 | 369 | } |
| 168 | 370 | |
| 169 | 371 | showLoginPopup() { |
| 170 | - // Create a simple modal for login prompt | |
| 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 | + | |
| 171 | 380 | const modal = document.createElement('div'); |
| 381 | + modal.id = 'yatra-login-modal'; | |
| 172 | 382 | modal.className = 'yatra-login-modal'; |
| 173 | - modal.innerHTML = ` | |
| 174 | - <div class="yatra-login-modal-overlay"></div> | |
| 175 | - <div class="yatra-login-modal-content"> | |
| 176 | - <div class="yatra-login-prompt"> | |
| 177 | - <h3>Login Required</h3> | |
| 178 | - <p>Please login to save trips to your wishlist.</p> | |
| 179 | - <div class="yatra-login-buttons"> | |
| 180 | - <a href="${window.yatraAdmin?.login_url || '/wp-login.php'}" class="yatra-btn yatra-btn-primary">Login</a> | |
| 181 | - <button type="button" class="yatra-btn yatra-btn-secondary yatra-close-modal">Cancel</button> | |
| 182 | - </div> | |
| 183 | - </div> | |
| 184 | - </div> | |
| 185 | - `; | |
| 186 | - | |
| 187 | - // Add styles | |
| 188 | - modal.style.cssText = ` | |
| 189 | - position: fixed; | |
| 190 | - top: 0; | |
| 191 | - left: 0; | |
| 192 | - width: 100%; | |
| 193 | - height: 100%; | |
| 194 | - z-index: 10000; | |
| 195 | - display: flex; | |
| 196 | - align-items: center; | |
| 197 | - justify-content: center; | |
| 198 | - `; | |
| 199 | - | |
| 200 | - const overlay = modal.querySelector('.yatra-login-modal-overlay'); | |
| 201 | - overlay.style.cssText = ` | |
| 202 | - position: absolute; | |
| 203 | - top: 0; | |
| 204 | - left: 0; | |
| 205 | - width: 100%; | |
| 206 | - height: 100%; | |
| 207 | - background: rgba(0, 0, 0, 0.5); | |
| 208 | - `; | |
| 209 | - | |
| 210 | - const content = modal.querySelector('.yatra-login-modal-content'); | |
| 211 | - content.style.cssText = ` | |
| 212 | - position: relative; | |
| 213 | - background: white; | |
| 214 | - border-radius: 8px; | |
| 215 | - padding: 40px; | |
| 216 | - max-width: 400px; | |
| 217 | - width: 90%; | |
| 218 | - text-align: center; | |
| 219 | - box-shadow: 0 10px 25px rgba(0, 0, 0, 0.2); | |
| 220 | - `; | |
| 221 | - | |
| 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; | |
| 222 | 441 | document.body.appendChild(modal); |
| 223 | 442 | document.body.style.overflow = 'hidden'; |
| 224 | - | |
| 225 | - // Handle close | |
| 443 | + | |
| 226 | 444 | const closeModal = () => { |
| 227 | 445 | modal.remove(); |
| 228 | - document.body.style.overflow = ''; | |
| 446 | + document.body.style.overflow = prevOverflow; | |
| 447 | + document.removeEventListener('keydown', onKey); | |
| 229 | 448 | }; |
| 230 | - | |
| 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 | + | |
| 231 | 463 | overlay.addEventListener('click', closeModal); |
| 232 | - modal.querySelector('.yatra-close-modal').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); | |
| 233 | 470 | } |
| 234 | 471 | |
| 235 | 472 | showMessage(message, type, button) { |
| 236 | - // Remove existing toast if any | |
| 237 | 473 | const existingToast = document.getElementById('yatra-wishlist-toast'); |
| 238 | - if (existingToast) { | |
| 239 | - existingToast.remove(); | |
| 240 | - } | |
| 474 | + if (existingToast) existingToast.remove(); | |
| 241 | 475 | |
| 242 | - // Create toast near the button | |
| 243 | 476 | const toast = document.createElement('div'); |
| 244 | 477 | toast.id = 'yatra-wishlist-toast'; |
| 478 | + toast.setAttribute('role', type === 'error' ? 'alert' : 'status'); | |
| 245 | 479 | toast.textContent = message; |
| 246 | - | |
| 247 | - // Position relative to button | |
| 480 | + | |
| 248 | 481 | const rect = button.getBoundingClientRect(); |
| 249 | 482 | const scrollTop = window.pageYOffset || document.documentElement.scrollTop; |
| 250 | 483 | const scrollLeft = window.pageXOffset || document.documentElement.scrollLeft; |
| 251 | - | |
| 484 | + | |
| 252 | 485 | toast.style.cssText = ` |
| 253 | 486 | position: absolute; |
| 254 | 487 | top: ${rect.bottom + scrollTop + 10}px; |
| 255 | 488 | left: ${rect.left + scrollLeft}px; |
| @@ -265,33 +498,25 @@ | ||
| 265 | 498 | color: #ffffff; |
| 266 | 499 | opacity: 0; |
| 267 | 500 | transform: translateY(-10px); |
| 268 | 501 | `; |
| 269 | - | |
| 502 | + | |
| 270 | 503 | document.body.appendChild(toast); |
| 271 | - | |
| 272 | - // Animate in | |
| 504 | + | |
| 273 | 505 | requestAnimationFrame(() => { |
| 274 | 506 | toast.style.opacity = '1'; |
| 275 | 507 | toast.style.transform = 'translateY(0)'; |
| 276 | 508 | }); |
| 277 | 509 | |
| 278 | - // Remove after delay | |
| 279 | 510 | setTimeout(() => { |
| 280 | 511 | toast.style.opacity = '0'; |
| 281 | 512 | toast.style.transform = 'translateY(-10px)'; |
| 282 | - setTimeout(() => { | |
| 283 | - if (toast.parentNode) { | |
| 284 | - toast.remove(); | |
| 285 | - } | |
| 286 | - }, 300); | |
| 513 | + setTimeout(() => { if (toast.parentNode) toast.remove(); }, 300); | |
| 287 | 514 | }, 3000); |
| 288 | 515 | } |
| 289 | 516 | } |
| 290 | 517 | |
| 291 | -document.addEventListener('DOMContentLoaded', function() { | |
| 292 | - var cfg = window.yatraWishlistConfig || {}; | |
| 293 | - if (!cfg.enabled) { | |
| 294 | - return; | |
| 295 | - } | |
| 518 | +document.addEventListener('DOMContentLoaded', function () { | |
| 519 | + const cfg = window.yatraWishlistConfig || {}; | |
| 520 | + if (!cfg.enabled) return; | |
| 296 | 521 | window.tripListingWishlist = new TripListingWishlist(); |
| 297 | 522 | }); |