Spaces:
Running
Running
| // ===================== CONFIG ===================== | |
| // Plain JSON endpoints served by the FastAPI backend. | |
| async function callApi(name, data) { | |
| const body = name === 'cities' ? {} : { city: data[0], strictness: data[1] }; | |
| const res = await fetch(`/api/${name}`, { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify(body) | |
| }); | |
| if (!res.ok) throw new Error(`API ${name} -> ${res.status}`); | |
| return await res.json(); | |
| } | |
| // ===================== STATE ===================== | |
| let CATALOG = []; | |
| const BY_NAME = {}; | |
| let selectedBase = null; | |
| let ORIGIN = ''; // global "From" for inline pricing | |
| let currentCategory = 'all'; | |
| let currentContinent = 'all'; | |
| let visibleCount = 0; | |
| const PAGE_SIZE = 24; | |
| const imageCache = {}; | |
| // Which price provider the backend is ACTUALLY running ('live' | 'mock'). | |
| // Never impersonate live data: when mock, every price gets a "demo" badge. | |
| let PRICE_SOURCE = 'live'; | |
| const CATEGORY_ICONS = { | |
| Beach: '🏖️', Mountain: '🏔️', Urban: '🏙️', Historical: '🏛️', Nature: '🌲', | |
| Adventure: '🧭', Island: '🏝️', Lake: '🛶', Wine: '🍷', Wellness: '🧘', Wildlife: '🦁' | |
| }; | |
| // ===================== INIT ===================== | |
| document.addEventListener('DOMContentLoaded', init); | |
| async function init() { | |
| showSection('explore'); | |
| initFlightDateDefault(); | |
| injectFlightControls(); // "Any date" scan chip + "Nonstop only" toggle | |
| try { ORIGIN = localStorage.getItem('wm_origin') || ''; } catch (e) {} | |
| if (ORIGIN) { | |
| const ho = document.getElementById('heroOrigin'); if (ho) ho.value = ORIGIN; | |
| const fo = document.getElementById('flightOrigin'); if (fo) fo.value = ORIGIN; | |
| } | |
| // Learn the real price source (non-blocking; defaults to 'live' until known). | |
| fetch('/health').then(r => r.json()) | |
| .then(j => { if (j && j.provider) PRICE_SOURCE = j.provider; }) | |
| .catch(() => {}); | |
| const grid = document.getElementById('destinationsGrid'); | |
| grid.innerHTML = loadingGrid(); | |
| try { | |
| CATALOG = await callApi('cities', []); | |
| CATALOG.forEach(c => { BY_NAME[c.name.toLowerCase()] = c; }); | |
| buildCategoryFilters(); | |
| buildContinentFilter(); | |
| renderExplore(); | |
| } catch (e) { | |
| console.error(e); | |
| grid.innerHTML = `<div class="col-span-full text-center py-20 text-slate-500"> | |
| <i class="fa-solid fa-triangle-exclamation text-3xl text-amber-400 mb-3 block"></i> | |
| <p class="font-semibold">Couldn't load destinations.</p> | |
| <p class="text-sm mt-1">The backend may still be starting — try reloading in a moment.</p></div>`; | |
| } | |
| } | |
| // ===================== PRICE HONESTY HELPERS ===================== | |
| // Small banner shown above any price list when the backend is on demo pricing. | |
| function demoNote() { | |
| if (PRICE_SOURCE === 'live') return ''; | |
| return `<div class="mb-4 px-4 py-2.5 bg-amber-50 border border-amber-200 rounded-xl text-sm text-amber-800"> | |
| <i class="fa-solid fa-flask mr-1"></i><span class="font-semibold">Demo prices.</span> | |
| Live pricing isn't configured on the server — these fares are simulated for preview only.</div>`; | |
| } | |
| // "round trip" / "one-way" — a round-trip total shown bare reads as a 2x-too-expensive one-way. | |
| function tripLabel(f) { | |
| if (!f || f.price == null) return ''; | |
| return `<span class="text-xs font-normal text-slate-400 ml-1">${f.round_trip ? 'round trip' : 'one-way'}${f.source === 'mock' ? ' · demo' : ''}</span>`; | |
| } | |
| // Deal-intelligence badge: 0-100 score vs the hedonic fair-price baseline. | |
| // Green = below typical, slate = typical, amber = above typical. | |
| function dealBadge(f) { | |
| const d = f && f.deal; | |
| if (!d || d.score == null) return ''; | |
| const cls = d.score >= 65 ? 'bg-green-50 text-green-700 border-green-200' | |
| : d.score >= 45 ? 'bg-slate-50 text-slate-600 border-slate-200' | |
| : 'bg-amber-50 text-amber-700 border-amber-200'; | |
| const pct = d.vs_typical_pct; | |
| const vs = pct == null ? '' : (pct < 0 ? ` · ${-pct}% below typical` : pct > 0 ? ` · ${pct}% above typical` : ''); | |
| return `<span class="inline-block text-xs font-semibold px-2 py-0.5 rounded-lg border ${cls}" title="Deal score ${d.score}/100 — typical price for this route & date: $${d.typical_price}">${d.label}${vs}</span>`; | |
| } | |
| // Utility-per-dollar chip for similar-city results (100 = best value in this set). | |
| function wanderBadge(a) { | |
| if (a.wander_score == null) return ''; | |
| const star = a.wander_score === 100 ? '<i class="fa-solid fa-crown mr-1"></i>' : ''; | |
| return `<span class="inline-block text-xs font-bold px-2 py-0.5 rounded-lg bg-accent-50 text-accent-700" title="Similarity per airfare dollar, best in this set = 100">${star}value ${a.wander_score}</span>`; | |
| } | |
| // "Fly Tuesday, save $X" strip built from the backend's date analytics. | |
| function insightsBar(ins) { | |
| if (!ins) return ''; | |
| const bits = []; | |
| if (ins.cheapest_date) bits.push(`<i class="fa-regular fa-calendar-check mr-1"></i>Cheapest day: <b>${ins.cheapest_date}</b> ($${ins.cheapest_date_price})`); | |
| if (ins.max_flex_savings > 0) bits.push(`<i class="fa-solid fa-arrows-left-right mr-1"></i>Flexible dates save up to <b>$${ins.max_flex_savings}</b>`); | |
| if (ins.best_weekday) bits.push(`<i class="fa-solid fa-plane-departure mr-1"></i><b>${ins.best_weekday}s</b> average <b>$${ins.weekday_avg_savings}</b> less`); | |
| if (!bits.length) return ''; | |
| return `<div class="mb-4 px-4 py-3 bg-primary-50/60 border border-primary-100 rounded-xl text-sm text-slate-700 flex flex-wrap gap-x-6 gap-y-1">${bits.map(b => `<span>${b}</span>`).join('')}</div>`; | |
| } | |
| // Book-now-or-wait verdict from the fare tracker (real observed prices, this route). | |
| function priceWatchLine(pw) { | |
| if (!pw || pw.status === 'tracking' || pw.recent_median == null) return ''; | |
| if (pw.status === 'below_recent') | |
| return `<p class="text-sm font-semibold text-green-700 mb-3"><i class="fa-solid fa-arrow-trend-down mr-1"></i>Good time to book — ${-pw.vs_median_pct}% below the recent median ($${pw.recent_median}) we've seen on this route.</p>`; | |
| if (pw.status === 'above_recent') | |
| return `<p class="text-sm font-semibold text-amber-700 mb-3"><i class="fa-solid fa-arrow-trend-up mr-1"></i>Above the recent range for this route (median $${pw.recent_median}) — flex your dates if you can.</p>`; | |
| return `<p class="text-sm text-slate-500 mb-3"><i class="fa-solid fa-scale-balanced mr-1"></i>In line with recent prices on this route (median $${pw.recent_median}).</p>`; | |
| } | |
| // True trip cost line: flight + nights x daily budget (from the city dataset). | |
| function tripLine(t) { | |
| if (!t || t.total == null) return ''; | |
| return `<p class="text-xs text-slate-600 mt-1"><i class="fa-solid fa-wallet mr-1 text-primary-400"></i><b>≈ $${t.total}</b> for ${t.nights} nights all-in <span class="text-slate-400">(flight $${t.flight} + ~$${t.daily}/day)</span></p>`; | |
| } | |
| function seasonChip(t) { | |
| const n = t && t.season_note; | |
| if (!n) return ''; | |
| const cls = n === 'in season' ? 'bg-green-50 text-green-700' | |
| : n === 'off season' ? 'bg-amber-50 text-amber-700' : 'bg-slate-100 text-slate-600'; | |
| return `<span class="inline-block text-xs font-semibold px-2 py-0.5 rounded-lg ${cls}">${n}</span>`; | |
| } | |
| function bestOverallBadge(a) { | |
| if (!a.best_overall) return ''; | |
| const why = (a.trip_rank && a.trip_rank.reasons || []).join(' · '); | |
| return `<span class="inline-block text-xs font-bold px-2 py-0.5 rounded-lg bg-primary-600 text-white" title="${why}"><i class="fa-solid fa-crown mr-1"></i>Best overall</span>`; | |
| } | |
| // Shown when the backend fell back to nearest-date fares (exact_window === false). | |
| function windowNote(d) { | |
| if (d && d.exact_window === false) { | |
| return `<p class="text-xs text-amber-700 mb-3"><i class="fa-regular fa-calendar mr-1"></i>No fares cached inside your exact date window — showing the closest available dates.</p>`; | |
| } | |
| return ''; | |
| } | |
| // ===================== NAVIGATION ===================== | |
| function showSection(section) { | |
| document.querySelectorAll('.section-content').forEach(el => el.classList.add('hidden')); | |
| document.getElementById(section + '-section').classList.remove('hidden'); | |
| document.querySelectorAll('.nav-btn').forEach(btn => { | |
| btn.classList.remove('text-white'); | |
| btn.classList.add('text-slate-300'); | |
| }); | |
| document.querySelectorAll(`[data-section="${section}"]`).forEach(btn => { | |
| btn.classList.add('text-white'); | |
| btn.classList.remove('text-slate-300'); | |
| }); | |
| document.getElementById('mobileMenu').classList.add('hidden'); | |
| window.scrollTo({ top: 0, behavior: 'smooth' }); | |
| } | |
| function toggleMobileMenu() { | |
| document.getElementById('mobileMenu').classList.toggle('hidden'); | |
| } | |
| // ===================== IMAGES (Wikipedia, lazy + cached) ===================== | |
| async function fetchCityImage(name) { | |
| if (name in imageCache) return imageCache[name]; | |
| try { | |
| const r = await fetch(`https://en.wikipedia.org/api/rest_v1/page/summary/${encodeURIComponent(name)}`); | |
| if (r.ok) { | |
| const j = await r.json(); | |
| const url = (j.thumbnail && j.thumbnail.source) || (j.originalimage && j.originalimage.source) || null; | |
| imageCache[name] = url; | |
| return url; | |
| } | |
| } catch (e) { /* ignore */ } | |
| imageCache[name] = null; | |
| return null; | |
| } | |
| function hydrateImage(imgId, name) { | |
| const el = document.getElementById(imgId); | |
| if (!el) return; | |
| fetchCityImage(name).then(url => { | |
| el.src = url || `https://picsum.photos/seed/${encodeURIComponent(name)}/600/400`; | |
| }); | |
| } | |
| // Safely embed an arbitrary string inside an inline onclick='...(' ... ')' | |
| function js(s) { return String(s).replace(/\\/g, '\\\\').replace(/'/g, "\\'"); } | |
| // ----- Weather / season helpers (B3) ----- | |
| function seasonShort(s) { return s ? String(s).split('(')[0].trim() : ''; } | |
| function seasonBadge(dest) { | |
| const s = seasonShort(dest && dest.best_travel_season); | |
| if (!s) return ''; | |
| return `<p class="text-xs text-slate-500 mb-2"><i class="fa-regular fa-calendar mr-1 text-primary-400"></i>Best in ${s}</p>`; | |
| } | |
| // ----- Global origin (for inline pricing) ----- | |
| function setOrigin(v) { | |
| ORIGIN = (v || '').trim(); | |
| try { localStorage.setItem('wm_origin', ORIGIN); } catch (e) {} | |
| const fo = document.getElementById('flightOrigin'); | |
| if (fo && ORIGIN) fo.value = ORIGIN; | |
| } | |
| function defaultFlightDate() { | |
| const d = new Date(); d.setDate(d.getDate() + 45); | |
| return d.toISOString().slice(0, 10); | |
| } | |
| // ===================== EXPLORE ===================== | |
| function buildCategoryFilters() { | |
| const cats = ['all', ...Array.from(new Set(CATALOG.map(c => c.category))).sort()]; | |
| document.getElementById('categoryFilters').innerHTML = cats.map(cat => { | |
| const label = cat === 'all' ? 'All' : `${CATEGORY_ICONS[cat] || ''} ${cat}`.trim(); | |
| const active = cat === currentCategory; | |
| return `<button onclick="filterByCategory('${js(cat)}')" data-category="${cat}" | |
| class="filter-btn px-4 py-2 rounded-lg text-sm font-medium transition-all ${active ? 'bg-primary-600 text-white' : 'bg-white text-slate-600 border border-slate-200 hover:border-primary-300'}">${label}</button>`; | |
| }).join(''); | |
| } | |
| function buildContinentFilter() { | |
| const cont = Array.from(new Set(CATALOG.map(c => c.continent).filter(Boolean))).sort(); | |
| document.getElementById('continentFilter').innerHTML = | |
| `<option value="all">All continents</option>` + cont.map(c => `<option value="${c}">${c}</option>`).join(''); | |
| } | |
| function onContinentChange(v) { currentContinent = v; renderExplore(); } | |
| function applyFilters() { | |
| const q = (document.getElementById('searchInput').value || '').toLowerCase().trim(); | |
| return CATALOG.filter(c => { | |
| const okCat = currentCategory === 'all' || c.category === currentCategory; | |
| const okCont = currentContinent === 'all' || c.continent === currentContinent; | |
| const okQ = !q || c.name.toLowerCase().includes(q) || (c.country || '').toLowerCase().includes(q) | |
| || (c.category || '').toLowerCase().includes(q) || (c.tags || []).some(t => t.toLowerCase().includes(q)); | |
| return okCat && okCont && okQ; | |
| }); | |
| } | |
| function filterDestinations() { renderExplore(); } | |
| function filterByCategory(cat) { | |
| currentCategory = cat; | |
| document.querySelectorAll('#categoryFilters .filter-btn').forEach(btn => { | |
| const on = btn.dataset.category === cat; | |
| btn.classList.toggle('bg-primary-600', on); | |
| btn.classList.toggle('text-white', on); | |
| btn.classList.toggle('bg-white', !on); | |
| btn.classList.toggle('text-slate-600', !on); | |
| btn.classList.toggle('border', !on); | |
| btn.classList.toggle('border-slate-200', !on); | |
| }); | |
| renderExplore(); | |
| } | |
| function renderExplore(reset = true) { | |
| if (reset) visibleCount = PAGE_SIZE; | |
| const filtered = applyFilters(); | |
| document.getElementById('noResults').classList.toggle('hidden', filtered.length > 0); | |
| const slice = filtered.slice(0, visibleCount); | |
| document.getElementById('destinationsGrid').innerHTML = slice.map(exploreCard).join(''); | |
| slice.forEach(c => hydrateImage('img-' + c.id, c.name)); | |
| const wrap = document.getElementById('loadMoreWrap'); | |
| if (filtered.length > visibleCount) { | |
| wrap.innerHTML = `<button onclick="loadMore()" class="px-6 py-3 bg-white border border-slate-200 rounded-xl font-semibold text-slate-700 hover:border-primary-300 shadow-sm transition-all"> | |
| Load more <span class="text-slate-400">(${filtered.length - visibleCount} more)</span></button>`; | |
| } else { | |
| wrap.innerHTML = filtered.length ? `<p class="text-sm text-slate-400">Showing all ${filtered.length} destinations</p>` : ''; | |
| } | |
| } | |
| function loadMore() { visibleCount += PAGE_SIZE; renderExplore(false); } | |
| function exploreCard(dest) { | |
| const tags = (dest.tags || []).slice(0, 2).map(t => | |
| `<span class="px-2 py-1 bg-black/40 backdrop-blur-sm text-white text-xs rounded-md">${t}</span>`).join(''); | |
| return ` | |
| <div class="card-hover bg-white rounded-2xl shadow-sm border border-slate-100 overflow-hidden cursor-pointer fade-in" onclick="openModalByName('${js(dest.name)}')"> | |
| <div class="relative h-48 overflow-hidden bg-gradient-to-br from-primary-100 to-accent-100"> | |
| <img id="img-${dest.id}" alt="${dest.name}" class="w-full h-full object-cover transition-transform duration-500 hover:scale-110" | |
| onerror="this.src='https://picsum.photos/seed/${encodeURIComponent(dest.name)}/600/400'"> | |
| <div class="absolute top-3 right-3 bg-white/90 backdrop-blur-sm px-2 py-1 rounded-lg text-xs font-bold text-slate-700 shadow-sm">${dest.price}</div> | |
| <div class="absolute bottom-3 left-3 flex gap-1">${tags}</div> | |
| </div> | |
| <div class="p-4"> | |
| <div class="flex justify-between items-start mb-2 gap-2"> | |
| <div> | |
| <h3 class="font-bold text-lg text-slate-800">${dest.name}</h3> | |
| <p class="text-sm text-slate-500"><i class="fa-solid fa-location-dot mr-1 text-primary-500"></i>${dest.country}</p> | |
| </div> | |
| <span class="text-xs font-semibold text-primary-700 bg-primary-50 px-2 py-1 rounded-lg whitespace-nowrap">${CATEGORY_ICONS[dest.category] || ''} ${dest.category}</span> | |
| </div> | |
| ${seasonBadge(dest)} | |
| <p class="text-sm text-slate-600 line-clamp-2 mb-3">${dest.description}</p> | |
| <div class="flex items-center justify-between pt-3 border-t border-slate-100"> | |
| <span class="text-xs text-slate-500"><i class="fa-solid fa-plane-departure mr-1 text-primary-400"></i>from $${dest.avg_flight_usd}</span> | |
| <button onclick="event.stopPropagation(); selectBaseByName('${js(dest.name)}'); showSection('similar');" | |
| class="text-xs font-semibold text-accent-600 hover:text-accent-700 flex items-center gap-1"> | |
| <i class="fa-solid fa-wand-magic-sparkles"></i> Find similar</button> | |
| </div> | |
| </div> | |
| </div>`; | |
| } | |
| function loadingGrid() { | |
| return Array.from({ length: 8 }).map(() => ` | |
| <div class="bg-white rounded-2xl shadow-sm border border-slate-100 overflow-hidden"> | |
| <div class="h-48 bg-slate-100 shimmer"></div> | |
| <div class="p-4 space-y-3"><div class="h-4 bg-slate-100 rounded w-2/3"></div><div class="h-3 bg-slate-100 rounded w-1/3"></div></div> | |
| </div>`).join(''); | |
| } | |
| // ===================== MODAL ===================== | |
| function openModalByName(name) { | |
| const dest = BY_NAME[name.toLowerCase()]; | |
| if (!dest) return; | |
| const modal = document.getElementById('detailModal'); | |
| const content = document.getElementById('modalContent'); | |
| content.innerHTML = ` | |
| <div class="relative"> | |
| <div class="h-64 overflow-hidden rounded-t-2xl bg-gradient-to-br from-primary-100 to-accent-100"> | |
| <img id="modalImg" class="w-full h-full object-cover" onerror="this.src='https://picsum.photos/seed/${encodeURIComponent(dest.name)}/800/500'"> | |
| <div class="absolute inset-0 bg-gradient-to-t from-black/60 to-transparent"></div> | |
| <button onclick="closeModal()" class="absolute top-4 right-4 w-10 h-10 bg-white/20 backdrop-blur-sm rounded-full flex items-center justify-center text-white hover:bg-white/30 transition-colors"><i class="fa-solid fa-xmark"></i></button> | |
| <div class="absolute bottom-4 left-6 text-white"> | |
| <h2 class="text-3xl font-bold mb-1">${dest.name}</h2> | |
| <p class="text-white/90"><i class="fa-solid fa-location-dot mr-2"></i>${dest.country}</p> | |
| </div> | |
| </div> | |
| <div class="p-6"> | |
| <div class="flex items-center gap-3 mb-6 flex-wrap"> | |
| <div class="px-3 py-2 bg-primary-50 rounded-lg font-semibold text-primary-700">${CATEGORY_ICONS[dest.category] || ''} ${dest.category}</div> | |
| <div class="px-3 py-2 bg-slate-50 rounded-lg font-semibold text-slate-700">${dest.price}</div> | |
| <div class="flex gap-2 flex-wrap">${(dest.tags || []).map(t => `<span class="px-3 py-2 bg-slate-100 rounded-lg text-sm text-slate-600">${t}</span>`).join('')}</div> | |
| </div> | |
| <p class="text-slate-600 leading-relaxed mb-4">${dest.description}</p> | |
| ${dest.best_travel_season ? `<p class="text-sm text-slate-500 mb-6"><i class="fa-regular fa-calendar mr-2 text-primary-500"></i>Best time to visit: <span class="font-medium text-slate-700">${dest.best_travel_season}</span></p>` : ''} | |
| <div class="grid grid-cols-2 sm:grid-cols-4 gap-3 mb-6"> | |
| <div class="p-4 bg-slate-50 rounded-xl"><i class="fa-solid fa-earth-americas text-primary-500 mb-2 text-xl"></i><p class="text-sm font-semibold text-slate-700">Continent</p><p class="text-xs text-slate-500">${dest.continent || '—'}</p></div> | |
| <div class="p-4 bg-slate-50 rounded-xl"><i class="fa-solid fa-layer-group text-primary-500 mb-2 text-xl"></i><p class="text-sm font-semibold text-slate-700">Category</p><p class="text-xs text-slate-500">${dest.category}</p></div> | |
| <div class="p-4 bg-slate-50 rounded-xl"><i class="fa-solid fa-plane text-primary-500 mb-2 text-xl"></i><p class="text-sm font-semibold text-slate-700">Avg flight</p><p class="text-xs text-slate-500">$${dest.avg_flight_usd}</p></div> | |
| <div class="p-4 bg-slate-50 rounded-xl"><i class="fa-solid fa-wallet text-primary-500 mb-2 text-xl"></i><p class="text-sm font-semibold text-slate-700">Avg / day</p><p class="text-xs text-slate-500">$${dest.avg_daily_usd}</p></div> | |
| </div> | |
| <div class="flex gap-3"> | |
| <button onclick="setFlightDestination('${js(dest.name)}'); closeModal(); showSection('flights');" class="flex-1 py-3 bg-gradient-to-r from-primary-600 to-primary-700 text-white rounded-xl font-semibold shadow-lg shadow-primary-500/20 hover:shadow-xl transition-all"><i class="fa-solid fa-plane mr-2"></i>Search Flights</button> | |
| <button onclick="selectBaseByName('${js(dest.name)}'); closeModal(); showSection('similar');" class="flex-1 py-3 bg-white text-slate-700 border border-slate-200 rounded-xl font-semibold hover:border-primary-300 transition-all"><i class="fa-solid fa-wand-magic-sparkles mr-2"></i>Find Similar</button> | |
| </div> | |
| </div> | |
| </div>`; | |
| hydrateImage('modalImg', dest.name); | |
| modal.classList.remove('hidden'); | |
| setTimeout(() => { | |
| content.classList.remove('scale-95', 'opacity-0'); | |
| content.classList.add('scale-100', 'opacity-100'); | |
| }, 10); | |
| } | |
| function closeModal() { | |
| const modal = document.getElementById('detailModal'); | |
| const content = document.getElementById('modalContent'); | |
| content.classList.remove('scale-100', 'opacity-100'); | |
| content.classList.add('scale-95', 'opacity-0'); | |
| setTimeout(() => modal.classList.add('hidden'), 200); | |
| } | |
| // ===================== SIMILAR ===================== | |
| function showBaseSuggestions(query) { | |
| const box = document.getElementById('baseSuggestions'); | |
| const q = (query || '').trim().toLowerCase(); | |
| if (!q) { box.classList.add('hidden'); return; } | |
| const matches = CATALOG.filter(c => | |
| c.name.toLowerCase().includes(q) || (c.country || '').toLowerCase().includes(q)).slice(0, 8); | |
| if (!matches.length) { box.classList.add('hidden'); return; } | |
| box.innerHTML = matches.map(c => ` | |
| <div class="flex items-center gap-3 p-3 hover:bg-slate-50 cursor-pointer border-b border-slate-50 last:border-0" onclick="selectBaseByName('${js(c.name)}')"> | |
| <div class="w-10 h-10 rounded-lg bg-gradient-to-br from-primary-100 to-accent-100 flex items-center justify-center text-primary-600"><i class="fa-solid fa-location-dot"></i></div> | |
| <div><p class="font-semibold text-sm">${c.name}</p><p class="text-xs text-slate-500">${c.country} · ${c.category}</p></div> | |
| </div>`).join(''); | |
| box.classList.remove('hidden'); | |
| } | |
| function selectBaseByName(name) { | |
| const dest = BY_NAME[name.toLowerCase()]; | |
| if (!dest) return; | |
| selectedBase = dest; | |
| document.getElementById('baseSuggestions').classList.add('hidden'); | |
| document.getElementById('baseDestinationInput').value = dest.name; | |
| const baseImg = document.getElementById('selectedBaseImg'); | |
| baseImg.removeAttribute('src'); | |
| hydrateImage('selectedBaseImg', dest.name); | |
| document.getElementById('selectedBaseName').textContent = dest.name; | |
| document.getElementById('selectedBaseDesc').textContent = `${dest.country} • ${(dest.tags || []).join(', ')}`; | |
| document.getElementById('selectedBase').classList.remove('hidden'); | |
| document.getElementById('step2').classList.remove('opacity-50', 'pointer-events-none'); | |
| document.getElementById('step3').classList.remove('opacity-50', 'pointer-events-none'); | |
| } | |
| // Hero search -> jump into the Find Similar flow with that place selected. | |
| function heroFindSimilar() { | |
| const q = document.getElementById('heroSearch').value.trim(); | |
| if (!q) return; | |
| document.getElementById('heroSuggestions').classList.add('hidden'); | |
| let city = BY_NAME[q.toLowerCase()] || CATALOG.find(c => c.name.toLowerCase().includes(q.toLowerCase())); | |
| showSection('similar'); | |
| if (city) selectBaseByName(city.name); | |
| } | |
| // ----- Hero modes: free-text "vibe" search vs city seed ----- | |
| let heroMode = 'vibe'; | |
| function setHeroMode(mode) { | |
| heroMode = mode; | |
| const vibe = mode === 'vibe'; | |
| const vb = document.getElementById('hmode-vibe'), pb = document.getElementById('hmode-place'); | |
| vb.classList.toggle('bg-primary-600', vibe); vb.classList.toggle('text-white', vibe); | |
| vb.classList.toggle('bg-slate-100', !vibe); vb.classList.toggle('text-slate-600', !vibe); | |
| pb.classList.toggle('bg-primary-600', !vibe); pb.classList.toggle('text-white', !vibe); | |
| pb.classList.toggle('bg-slate-100', vibe); pb.classList.toggle('text-slate-600', vibe); | |
| const inp = document.getElementById('heroSearch'); | |
| inp.value = ''; | |
| document.getElementById('heroSuggestions').classList.add('hidden'); | |
| inp.placeholder = vibe ? 'warm beach, great food, cheap nightlife…' : 'e.g. Santorini, Kyoto, Barcelona'; | |
| document.getElementById('heroBtnLabel').textContent = vibe ? 'Search' : 'Find Similar'; | |
| } | |
| function onHeroInput(v) { | |
| if (heroMode === 'place') citySuggest(v, 'heroSuggestions', 'heroSearch'); | |
| else document.getElementById('heroSuggestions').classList.add('hidden'); | |
| } | |
| function runHeroSearch() { return heroMode === 'vibe' ? searchVibe() : heroFindSimilar(); } | |
| function _heroFilters(body) { | |
| const cat = document.getElementById('heroCategory').value; | |
| const md = parseFloat(document.getElementById('heroMaxDaily').value); | |
| if (cat) body.category = cat; | |
| if (md) body.max_daily = md; | |
| return body; | |
| } | |
| async function searchVibe() { | |
| const q = document.getElementById('heroSearch').value.trim(); | |
| if (!q) return; | |
| document.getElementById('heroSuggestions').classList.add('hidden'); | |
| const wrap = document.getElementById('heroResultsWrap'); | |
| const title = document.getElementById('heroResultsTitle'); | |
| document.getElementById('heroResultsGrid').innerHTML = ''; | |
| wrap.classList.remove('hidden'); | |
| title.textContent = `Searching "${q}"…`; | |
| try { | |
| const res = await fetch('/api/search_text', { | |
| method: 'POST', headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify(_heroFilters({ query: q, k: 6 })) | |
| }); | |
| const data = await res.json(); | |
| if (!Array.isArray(data)) throw new Error(data.message || 'Search unavailable'); | |
| if (!data.length) { title.textContent = `No matches for "${q}" — try fewer filters.`; return; } | |
| title.textContent = ORIGIN ? `Vibes like "${q}" — fares from ${ORIGIN.toUpperCase()}` : `Vibes like "${q}"`; | |
| // Inline live fares when an origin is set (the one-screen book experience). | |
| if (ORIGIN) { | |
| try { | |
| const qr = await fetch('/api/flights/quote_batch', { | |
| method: 'POST', headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ origin: ORIGIN, cities: data.map(d => d.name), departure_date: defaultFlightDate() }) | |
| }); | |
| const quotes = await qr.json(); | |
| if (quotes && !quotes.status) data.forEach(d => { d.flight = quotes[d.name]; }); | |
| } catch (e) { /* pricing is optional */ } | |
| } | |
| renderResultCards(data, 'heroResultsGrid'); | |
| wrap.scrollIntoView({ behavior: 'smooth', block: 'start' }); | |
| } catch (e) { | |
| console.error(e); | |
| title.textContent = `Couldn't search: ${e.message}`; | |
| } | |
| } | |
| function clearHeroResults() { | |
| document.getElementById('heroResultsWrap').classList.add('hidden'); | |
| document.getElementById('heroResultsGrid').innerHTML = ''; | |
| } | |
| // Generic result-card renderer (free-text results). | |
| function renderResultCards(items, gridId) { | |
| const grid = document.getElementById(gridId); | |
| const C = 2 * Math.PI * 24; | |
| grid.innerHTML = items.map((dest, i) => ` | |
| <div class="card-hover bg-white rounded-2xl shadow-sm border border-slate-100 overflow-hidden slide-up" style="animation-delay: ${i * 0.06}s"> | |
| <div class="relative h-44 overflow-hidden bg-gradient-to-br from-primary-100 to-accent-100"> | |
| <img id="${gridId}-img-${i}" class="w-full h-full object-cover" onerror="this.src='https://picsum.photos/seed/${encodeURIComponent(dest.name)}/600/400'"> | |
| <div class="absolute top-3 right-3"><div class="relative w-14 h-14"> | |
| <svg class="w-14 h-14 transform -rotate-90"> | |
| <circle cx="28" cy="28" r="24" stroke="#e5e7eb" stroke-width="4" fill="none"/> | |
| <circle cx="28" cy="28" r="22" fill="white" opacity="0.9"/> | |
| <circle cx="28" cy="28" r="24" stroke="${dest.similarity > 90 ? '#0066FF' : '#3385ff'}" stroke-width="4" fill="none" stroke-dasharray="${C}" stroke-dashoffset="${C * (1 - dest.similarity / 100)}" stroke-linecap="round"/> | |
| </svg> | |
| <div class="absolute inset-0 flex items-center justify-center"><span class="text-xs font-bold text-slate-700">${dest.similarity}%</span></div> | |
| </div></div> | |
| </div> | |
| <div class="p-4"> | |
| <h3 class="font-bold text-lg mb-1">${dest.name}</h3> | |
| <p class="text-sm text-slate-500 mb-2"><i class="fa-solid fa-location-dot mr-1 text-primary-500"></i>${dest.country}</p> | |
| ${seasonBadge(dest)} | |
| <div class="flex flex-wrap gap-1 mb-3">${(dest.tags || []).slice(0, 3).map(t => `<span class="px-2 py-1 bg-slate-100 text-slate-600 text-xs rounded-md">${t}</span>`).join('')}</div> | |
| <div class="flex gap-2"> | |
| ${dest.flight | |
| ? `<a href="${dest.flight.link}" target="_blank" rel="noopener" class="flex-1 py-2 bg-primary-600 hover:bg-primary-700 text-white rounded-lg text-sm font-semibold text-center transition-colors"><i class="fa-solid fa-plane mr-1"></i>${dest.flight.price != null ? 'from $' + dest.flight.price + (dest.flight.source === 'mock' ? ' (demo)' : '') + ' · Book' : 'Search & book'}</a>` | |
| : `<button onclick="setFlightDestination('${js(dest.name)}'); showSection('flights');" class="flex-1 py-2 bg-primary-50 text-primary-700 rounded-lg text-sm font-semibold hover:bg-primary-100 transition-colors"><i class="fa-solid fa-plane mr-1"></i> Flights</button>`} | |
| <button onclick="openModalByName('${js(dest.name)}')" class="flex-1 py-2 bg-slate-50 text-slate-700 rounded-lg text-sm font-semibold hover:bg-slate-100 transition-colors">Details</button> | |
| </div> | |
| </div> | |
| </div>`).join(''); | |
| items.forEach((d, i) => hydrateImage(gridId + '-img-' + i, d.name)); | |
| } | |
| function clearBaseSelection() { | |
| selectedBase = null; | |
| document.getElementById('baseDestinationInput').value = ''; | |
| document.getElementById('selectedBase').classList.add('hidden'); | |
| document.getElementById('step2').classList.add('opacity-50', 'pointer-events-none'); | |
| document.getElementById('step3').classList.add('opacity-50', 'pointer-events-none'); | |
| document.getElementById('similarResults').classList.add('hidden'); | |
| } | |
| function updateStrictnessLabel(val) { | |
| const labels = { 1: 'More variety (MMR)', 2: 'Balanced (MMR)', 3: 'Pure cosine match' }; | |
| document.getElementById('strictnessValue').textContent = labels[val]; | |
| } | |
| async function generateSimilar() { | |
| if (!selectedBase) return; | |
| const btn = document.getElementById('generateBtn'); | |
| const original = btn.innerHTML; | |
| btn.innerHTML = '<i class="fa-solid fa-circle-notch fa-spin mr-2"></i>Analyzing...'; | |
| btn.disabled = true; | |
| const strictness = parseInt(document.getElementById('strictnessSlider').value, 10) || 2; | |
| try { | |
| const similar = await callApi('recommend', [selectedBase.name, strictness]); | |
| if (!similar || !similar.length) throw new Error('No results returned'); | |
| if (ORIGIN) { | |
| try { | |
| const qr = await fetch('/api/flights/quote_batch', { | |
| method: 'POST', headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ origin: ORIGIN, cities: similar.map(d => d.name), departure_date: defaultFlightDate() }) | |
| }); | |
| const quotes = await qr.json(); | |
| if (quotes && !quotes.status) similar.forEach(d => { d.flight = quotes[d.name]; }); | |
| } catch (e) { /* pricing optional */ } | |
| } | |
| renderSimilarResults(similar); | |
| document.getElementById('similarResults').classList.remove('hidden'); | |
| document.getElementById('similarResults').scrollIntoView({ behavior: 'smooth', block: 'start' }); | |
| } catch (err) { | |
| console.error('Recommendation error:', err); | |
| document.getElementById('similarResults').classList.remove('hidden'); | |
| document.getElementById('similarGrid').innerHTML = ` | |
| <div class="col-span-3 text-center py-12 text-slate-500"> | |
| <i class="fa-solid fa-triangle-exclamation text-3xl text-amber-400 mb-3 block"></i> | |
| <p class="font-semibold">Couldn't reach the recommendation engine.</p> | |
| <p class="text-sm mt-1">Please try again in a moment.</p> | |
| </div>`; | |
| } finally { | |
| btn.innerHTML = original; | |
| btn.disabled = false; | |
| } | |
| } | |
| function renderSimilarResults(items) { | |
| const grid = document.getElementById('similarGrid'); | |
| const C = 2 * Math.PI * 24; | |
| grid.innerHTML = items.map((dest, i) => ` | |
| <div class="card-hover bg-white rounded-2xl shadow-sm border border-slate-100 overflow-hidden slide-up" style="animation-delay: ${i * 0.1}s"> | |
| <div class="relative h-44 overflow-hidden bg-gradient-to-br from-primary-100 to-accent-100"> | |
| <img id="simimg-${i}" class="w-full h-full object-cover" onerror="this.src='https://picsum.photos/seed/${encodeURIComponent(dest.name)}/600/400'"> | |
| <div class="absolute top-3 right-3"> | |
| <div class="relative w-14 h-14"> | |
| <svg class="w-14 h-14 transform -rotate-90"> | |
| <circle cx="28" cy="28" r="24" stroke="#e5e7eb" stroke-width="4" fill="none"/> | |
| <circle cx="28" cy="28" r="22" fill="white" opacity="0.9"/> | |
| <circle cx="28" cy="28" r="24" stroke="${dest.similarity > 90 ? '#3b82f6' : '#c026d3'}" stroke-width="4" fill="none" | |
| stroke-dasharray="${C}" stroke-dashoffset="${C * (1 - dest.similarity / 100)}" stroke-linecap="round"/> | |
| </svg> | |
| <div class="absolute inset-0 flex items-center justify-center"><span class="text-xs font-bold text-slate-700">${dest.similarity}%</span></div> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="p-4"> | |
| <h3 class="font-bold text-lg mb-1">${dest.name}</h3> | |
| <p class="text-sm text-slate-500 mb-2"><i class="fa-solid fa-location-dot mr-1 text-primary-500"></i>${dest.country}</p> | |
| ${seasonBadge(dest)} | |
| <div class="flex flex-wrap gap-1 mb-3">${(dest.tags || []).map(t => `<span class="px-2 py-1 bg-slate-100 text-slate-600 text-xs rounded-md">${t}</span>`).join('')}</div> | |
| <div class="flex gap-2"> | |
| <button onclick="setFlightDestination('${js(dest.name)}'); showSection('flights');" | |
| class="flex-1 py-2 bg-primary-50 text-primary-700 rounded-lg text-sm font-semibold hover:bg-primary-100 transition-colors"> | |
| <i class="fa-solid fa-plane mr-1"></i> Flights</button> | |
| <button onclick="openModalByName('${js(dest.name)}')" | |
| class="flex-1 py-2 bg-slate-50 text-slate-700 rounded-lg text-sm font-semibold hover:bg-slate-100 transition-colors">Details</button> | |
| </div> | |
| </div> | |
| </div>`).join(''); | |
| items.forEach((d, i) => hydrateImage('simimg-' + i, d.name)); | |
| } | |
| function resetSimilar() { | |
| clearBaseSelection(); | |
| document.getElementById('similarResults').classList.add('hidden'); | |
| } | |
| // ===================== FLIGHT DEALS ===================== | |
| // ----- When mode: exact date (±3 days) vs whole month vs 3-month scan ----- | |
| let WHEN = 'month'; | |
| let NONSTOP = false; | |
| // The "Any date" chip and "Nonstop only" toggle are injected next to the | |
| // existing chips so index.html stays untouched. | |
| function injectFlightControls() { | |
| const wm = document.getElementById('when-month'); | |
| if (wm && !document.getElementById('when-any')) { | |
| wm.insertAdjacentHTML('afterend', | |
| `<button id="when-any" onclick="setWhen('any')" title="Scan the next 3 months for the cheapest date" | |
| class="px-3 py-1.5 rounded-lg text-sm font-medium bg-slate-100 text-slate-600 transition-all">Any date</button>`); | |
| } | |
| const tr = document.getElementById('trip-round'); | |
| if (tr && !document.getElementById('nonstopToggle')) { | |
| tr.insertAdjacentHTML('afterend', | |
| `<button id="nonstopToggle" onclick="toggleNonstop()" title="Only show direct flights" | |
| class="px-3 py-1.5 rounded-lg text-sm font-medium bg-slate-100 text-slate-600 transition-all ml-2"><i class="fa-solid fa-route mr-1"></i>Nonstop only</button>`); | |
| } | |
| } | |
| function toggleNonstop() { | |
| NONSTOP = !NONSTOP; | |
| const b = document.getElementById('nonstopToggle'); | |
| if (!b) return; | |
| b.classList.toggle('bg-primary-600', NONSTOP); b.classList.toggle('text-white', NONSTOP); | |
| b.classList.toggle('bg-slate-100', !NONSTOP); b.classList.toggle('text-slate-600', !NONSTOP); | |
| } | |
| function setWhenDefaults() { | |
| const isDate = WHEN === 'date'; | |
| const d = new Date(); d.setMonth(d.getMonth() + 1); | |
| ['flightDate', 'flightReturn'].forEach(id => { | |
| const el = document.getElementById(id); | |
| if (!el) return; | |
| el.type = isDate ? 'date' : 'month'; | |
| el.value = isDate ? d.toISOString().slice(0, 10) : d.toISOString().slice(0, 7); | |
| el.min = isDate ? new Date().toISOString().slice(0, 10) : new Date().toISOString().slice(0, 7); | |
| }); | |
| } | |
| function initFlightDateDefault() { setWhenDefaults(); } | |
| function setWhen(m) { | |
| WHEN = m; | |
| const chips = { date: 'when-date', month: 'when-month', any: 'when-any' }; | |
| Object.entries(chips).forEach(([k, id]) => { | |
| const el = document.getElementById(id); | |
| if (!el) return; | |
| const on = k === m; | |
| el.classList.toggle('bg-primary-600', on); el.classList.toggle('text-white', on); | |
| el.classList.toggle('bg-slate-100', !on); el.classList.toggle('text-slate-600', !on); | |
| }); | |
| document.getElementById('departLabel').textContent = | |
| m === 'date' ? 'Depart date (±3 days)' | |
| : m === 'month' ? 'Depart month' | |
| : 'From month (scans 3 months)'; | |
| setWhenDefaults(); | |
| } | |
| // ----- Trip type (one-way / round-trip) ----- | |
| let TRIP = 'oneway'; | |
| function setTrip(m) { | |
| TRIP = m; | |
| const ow = document.getElementById('trip-oneway'), rd = document.getElementById('trip-round'); | |
| const isOW = m === 'oneway'; | |
| ow.classList.toggle('bg-primary-600', isOW); ow.classList.toggle('text-white', isOW); | |
| ow.classList.toggle('bg-slate-100', !isOW); ow.classList.toggle('text-slate-600', !isOW); | |
| rd.classList.toggle('bg-primary-600', !isOW); rd.classList.toggle('text-white', !isOW); | |
| rd.classList.toggle('bg-slate-100', isOW); rd.classList.toggle('text-slate-600', isOW); | |
| document.getElementById('returnCell').classList.toggle('hidden', isOW); | |
| } | |
| function monthToDate(v) { return v ? v + '-01' : ''; } // month picker -> YYYY-MM-01 | |
| // Departure params for the request, based on the When mode. | |
| function flightWhen() { | |
| const v = document.getElementById('flightDate').value; | |
| const base = { nonstop_only: NONSTOP }; | |
| if (!v) return base; | |
| if (WHEN === 'date') return Object.assign(base, { departure_date: v, month_mode: false, flex_days: 3 }); | |
| if (WHEN === 'month') return Object.assign(base, { departure_date: monthToDate(v), month_mode: true }); | |
| return Object.assign(base, { departure_date: monthToDate(v), month_mode: true, scan_months: 3 }); | |
| } | |
| function tripReturn() { | |
| if (TRIP !== 'round') return {}; | |
| const r = document.getElementById('flightReturn').value; | |
| if (!r) return {}; | |
| return { return_date: WHEN === 'date' ? r : monthToDate(r) }; | |
| } | |
| let currentFlightMode = 'similar'; | |
| // Clicking "Find flights" anywhere jumps to the Similar mode with that place filled in. | |
| function setFlightDestination(destName) { | |
| setFlightMode('similar'); | |
| const el = document.getElementById('refDestInput'); | |
| if (el) el.value = destName; | |
| } | |
| function setFlightMode(mode) { | |
| currentFlightMode = mode; | |
| ['similar', 'route', 'budget'].forEach(m => { | |
| const on = m === mode; | |
| const btn = document.getElementById('mode-' + m); | |
| btn.classList.toggle('bg-primary-600', on); | |
| btn.classList.toggle('text-white', on); | |
| btn.classList.toggle('bg-white', !on); | |
| btn.classList.toggle('text-slate-600', !on); | |
| btn.classList.toggle('border', !on); | |
| btn.classList.toggle('border-slate-200', !on); | |
| document.getElementById('fld-' + m).classList.toggle('hidden', !on); | |
| }); | |
| document.getElementById('vibeRow').classList.toggle('hidden', mode !== 'budget'); | |
| document.getElementById('dealsBtnLabel').textContent = | |
| mode === 'budget' ? 'Explore' : (mode === 'route' ? 'Search' : 'Find Deals'); | |
| document.getElementById('dealsResults').innerHTML = ''; | |
| } | |
| function runFlightMode() { | |
| if (currentFlightMode === 'route') return searchRoute(); | |
| if (currentFlightMode === 'budget') return exploreBudget(); | |
| return findFlightDeals(); | |
| } | |
| // Generic city autocomplete (used by both the "place you love" and "to" fields). | |
| function citySuggest(query, boxId, inputId) { | |
| const box = document.getElementById(boxId); | |
| const q = (query || '').trim().toLowerCase(); | |
| if (!q) { box.classList.add('hidden'); return; } | |
| const matches = CATALOG.filter(c => | |
| c.name.toLowerCase().includes(q) || (c.country || '').toLowerCase().includes(q)).slice(0, 8); | |
| if (!matches.length) { box.classList.add('hidden'); return; } | |
| box.innerHTML = matches.map(c => ` | |
| <div class="flex items-center gap-3 p-3 hover:bg-slate-50 cursor-pointer border-b border-slate-50 last:border-0" onclick="pickCity('${boxId}','${inputId}','${js(c.name)}')"> | |
| <div class="w-9 h-9 rounded-lg bg-gradient-to-br from-primary-100 to-accent-100 flex items-center justify-center text-primary-600"><i class="fa-solid fa-location-dot"></i></div> | |
| <div><p class="font-semibold text-sm">${c.name}</p><p class="text-xs text-slate-500">${c.country}</p></div> | |
| </div>`).join(''); | |
| box.classList.remove('hidden'); | |
| } | |
| function pickCity(boxId, inputId, name) { | |
| document.getElementById(inputId).value = name; | |
| document.getElementById(boxId).classList.add('hidden'); | |
| } | |
| async function _withDealsBtn(fn, out) { | |
| const btn = document.getElementById('dealsBtn'); | |
| const original = btn.innerHTML; | |
| btn.innerHTML = '<i class="fa-solid fa-circle-notch fa-spin mr-2"></i>Searching...'; | |
| btn.disabled = true; | |
| out.innerHTML = ''; | |
| try { await fn(); } | |
| catch (e) { console.error(e); out.innerHTML = flightsError(e.message); } | |
| finally { btn.innerHTML = original; btn.disabled = false; } | |
| } | |
| // Skyscanner-style price-by-date chart: one bar per date, green = cheapest. | |
| function priceCalendar(cal) { | |
| if (!cal || cal.length < 3) return ''; | |
| const prices = cal.map(c => c.price); | |
| const min = Math.min(...prices), max = Math.max(...prices); | |
| let lastMonth = ''; | |
| const bars = cal.map(c => { | |
| const h = Math.round(22 + 58 * (c.price - min) / ((max - min) || 1)); | |
| const best = c.price === min; | |
| const mo = c.date.slice(5, 7); | |
| const newMonth = mo !== lastMonth; lastMonth = mo; | |
| return `<div class="flex flex-col items-center shrink-0 ${newMonth ? 'ml-2' : ''}" title="${c.date} — $${c.price}${c.stops === 0 ? ' nonstop' : ''}"> | |
| <span class="text-[9px] font-semibold ${best ? 'text-green-600' : 'text-transparent'}">$${c.price}</span> | |
| <div class="w-4 rounded-t ${best ? 'bg-green-500' : 'bg-primary-200 hover:bg-primary-500'} transition-colors" style="height:${h}px"></div> | |
| <span class="text-[9px] ${newMonth ? 'font-bold text-slate-600' : 'text-slate-400'}">${newMonth ? c.date.slice(5) : c.date.slice(8)}</span> | |
| </div>`; | |
| }).join(''); | |
| return `<div class="mb-6 bg-white rounded-2xl border border-slate-100 shadow-sm p-4"> | |
| <p class="text-sm font-semibold text-slate-700 mb-2"><i class="fa-regular fa-calendar mr-1 text-primary-500"></i>Price by date | |
| <span class="text-xs font-normal text-slate-400">· ${cal.length} days priced · green = cheapest ($${min})</span></p> | |
| <div class="flex items-end gap-1 overflow-x-auto pb-1">${bars}</div> | |
| </div>`; | |
| } | |
| // "Nonstop from $X · with stops from $Y" — the direct-vs-layover tradeoff. | |
| function directLine(d) { | |
| const c = d.cheapest_overall, dir = d.cheapest_direct; | |
| if (!c || c.price == null) return ''; | |
| if (d.nonstop_only) return `<p class="text-xs text-slate-500 mb-3"><i class="fa-solid fa-route mr-1 text-primary-400"></i>Showing nonstop flights only.</p>`; | |
| if (!dir) return `<p class="text-xs text-slate-500 mb-3"><i class="fa-solid fa-route mr-1 text-primary-400"></i>No nonstop found in the cached fares — cheapest has ${c.stops || 1}+ stop.</p>`; | |
| if (dir.price === c.price) return ''; | |
| return `<p class="text-xs text-slate-600 mb-3"><i class="fa-solid fa-route mr-1 text-primary-400"></i>Nonstop from <b>$${dir.price}</b> (${dir.date}) · with stops from <b>$${c.price}</b> — layover saves $${(dir.price - c.price).toFixed(0)}.</p>`; | |
| } | |
| // ----- Mode 2: search a specific route ----- | |
| async function searchRoute() { | |
| const origin = document.getElementById('flightOrigin').value.trim(); | |
| const dest = document.getElementById('routeDest').value.trim(); | |
| const date = document.getElementById('flightDate').value; | |
| const out = document.getElementById('dealsResults'); | |
| if (!origin || !dest || !date) { out.innerHTML = flightsError('Enter origin, destination, and a date.'); return; } | |
| await _withDealsBtn(async () => { | |
| const res = await fetch('/api/flights/search', { | |
| method: 'POST', headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify(Object.assign({ origin, destination: dest }, flightWhen(), tripReturn())) | |
| }); | |
| const d = await res.json(); | |
| if (d.status !== 'success') throw new Error(d.message || 'No flights found for that route.'); | |
| renderRoute(d, origin, dest); | |
| }, out); | |
| } | |
| function renderRoute(d, origin, dest) { | |
| const c = d.cheapest_overall; | |
| const save = d.savings_vs_exact_request; | |
| const opts = d.top_options || []; | |
| const priceLabel = p => (p == null ? 'See live fares' : '$' + p); | |
| const stopsLabel = s => (s == null ? '' : ' · ' + (s === 0 ? 'nonstop' : s + ' stop')); | |
| document.getElementById('dealsResults').innerHTML = ` | |
| ${demoNote()} | |
| ${windowNote(d)} | |
| ${priceWatchLine(d.price_watch)} | |
| ${insightsBar(d.insights)} | |
| ${priceCalendar(d.price_by_date)} | |
| ${directLine(d)} | |
| ${d.live_search ? `<p class="text-sm text-slate-500 mb-3"><i class="fa-solid fa-bolt mr-1 text-primary-500"></i>No cached fare for this exact route — opening a live search with real-time prices.</p>` : ''} | |
| <div class="bg-white rounded-2xl shadow-sm border border-primary-100 p-5 mb-6 flex items-center justify-between flex-wrap gap-3"> | |
| <div> | |
| <p class="text-xs uppercase tracking-wide text-primary-500 font-semibold mb-1">Cheapest ${origin.toUpperCase()} → ${dest}${d.scanned_months > 1 ? ` <span class="normal-case text-slate-400">· scanned ${d.scanned_months} months</span>` : ''}</p> | |
| <h3 class="text-2xl font-bold text-primary-600">${priceLabel(c.price)}${tripLabel(c)} <span class="text-sm font-normal text-slate-500">${c.airline}${c.date ? ' · ' + c.date : ''}${stopsLabel(c.stops)}</span></h3> | |
| <div class="mt-1 flex flex-wrap gap-2">${dealBadge(c)} | |
| ${save ? `<span class="text-sm text-green-700 font-semibold">save $${save} vs your exact date</span>` : ''}</div> | |
| </div> | |
| ${c.link ? `<a href="${c.link}" target="_blank" class="px-4 py-2 bg-primary-600 text-white rounded-lg text-sm font-semibold">Book →</a>` : ''} | |
| </div> | |
| <div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4"> | |
| ${opts.filter(o => o.price != null).map(o => `<div class="bg-white rounded-xl border border-slate-100 p-4"> | |
| <p class="text-lg font-bold text-primary-600">$${o.price}${tripLabel(o)}</p> | |
| <p class="text-xs text-slate-500 mb-1">${o.airline} · ${o.date}${o.return_date ? ' → ' + o.return_date : ''}${stopsLabel(o.stops)}</p> | |
| ${dealBadge(o)}</div>`).join('')} | |
| </div>`; | |
| } | |
| // ----- Mode 3: explore on a budget ----- | |
| async function exploreBudget() { | |
| const origin = document.getElementById('flightOrigin').value.trim(); | |
| const date = document.getElementById('flightDate').value; | |
| const max = parseFloat(document.getElementById('budgetMax').value); | |
| const vibe = document.getElementById('vibeTags').value.trim(); | |
| const out = document.getElementById('dealsResults'); | |
| if (!origin || !date || !max) { out.innerHTML = flightsError('Enter origin, a date, and a budget.'); return; } | |
| const body = Object.assign({ origin, max_price: max }, flightWhen(), tripReturn()); | |
| if (vibe) body.vibe_tags = vibe.split(',').map(s => s.trim()).filter(Boolean); | |
| await _withDealsBtn(async () => { | |
| const res = await fetch('/api/flights/explore', { | |
| method: 'POST', headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify(body) | |
| }); | |
| const d = await res.json(); | |
| if (d.status !== 'success') throw new Error(d.message || 'Search failed.'); | |
| renderExploreDeals(d, origin); | |
| }, out); | |
| } | |
| function renderExploreDeals(d, origin) { | |
| const out = document.getElementById('dealsResults'); | |
| const m = d.matches || []; | |
| if (!m.length) { out.innerHTML = demoNote() + flightsError(`Nothing under $${d.budget} found — try a higher budget or another date.`); return; } | |
| out.innerHTML = `${demoNote()}<h3 class="text-lg font-bold mb-4">${m.length} places under $${d.budget} from ${origin.toUpperCase()}</h3> | |
| <div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-5"> | |
| ${m.map((x, i) => { | |
| const f = x.cheapest_flight; | |
| return `<div class="card-hover bg-white rounded-2xl shadow-sm border border-slate-100 p-5 slide-up" style="animation-delay:${i * 0.05}s"> | |
| <div class="mb-2"><h3 class="font-bold text-lg">${x.city}</h3><p class="text-sm text-slate-500"><i class="fa-solid fa-location-dot mr-1 text-primary-500"></i>${x.country}</p></div> | |
| <div class="flex flex-wrap gap-1 mb-3">${(x.tags || []).slice(0, 3).map(t => `<span class="px-2 py-0.5 bg-slate-100 text-slate-600 text-xs rounded-md">${t}</span>`).join('')}</div> | |
| <div class="pt-3 border-t border-slate-100"><p class="text-2xl font-bold text-primary-600">$${f.price}${tripLabel(f)}</p><p class="text-xs text-slate-500 mb-1">${f.airline} · ${f.date}</p><span class="flex flex-wrap gap-1">${dealBadge(f)}${seasonChip(x.trip)}</span>${tripLine(x.trip)}</div> | |
| ${f.link ? `<a href="${f.link}" target="_blank" class="mt-3 block text-center py-2 bg-primary-50 text-primary-700 rounded-lg text-sm font-semibold">Book →</a>` : ''} | |
| </div>`; | |
| }).join('')}</div>`; | |
| } | |
| function flightsError(msg) { | |
| return `<div class="text-center py-12 text-slate-500"> | |
| <i class="fa-solid fa-triangle-exclamation text-3xl text-amber-400 mb-3 block"></i> | |
| <p class="font-semibold">${msg}</p></div>`; | |
| } | |
| async function findFlightDeals() { | |
| const origin = document.getElementById('flightOrigin').value.trim(); | |
| const ref = document.getElementById('refDestInput').value.trim(); | |
| const date = document.getElementById('flightDate').value; | |
| const out = document.getElementById('dealsResults'); | |
| if (!origin || !ref || !date) { | |
| out.innerHTML = flightsError('Enter your origin, a place you love, and a departure date.'); | |
| return; | |
| } | |
| const btn = document.getElementById('dealsBtn'); | |
| const original = btn.innerHTML; | |
| btn.innerHTML = '<i class="fa-solid fa-circle-notch fa-spin mr-2"></i>Finding deals...'; | |
| btn.disabled = true; | |
| out.innerHTML = ''; | |
| try { | |
| const res = await fetch('/api/flights/similar', { | |
| method: 'POST', headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify(Object.assign({ origin, reference_destination: ref, k: 6 }, flightWhen(), tripReturn())) | |
| }); | |
| const data = await res.json(); | |
| if (data.status !== 'success') throw new Error(data.message || 'Search failed'); | |
| renderFlightDeals(data, origin); | |
| } catch (err) { | |
| console.error(err); | |
| out.innerHTML = flightsError(err.message); | |
| } finally { | |
| btn.innerHTML = original; btn.disabled = false; | |
| } | |
| } | |
| function renderFlightDeals(data, origin) { | |
| const out = document.getElementById('dealsResults'); | |
| const ref = data.reference; | |
| const refPrice = ref.cheapest_flight ? ref.cheapest_flight.price : null; | |
| const priceOf = a => (a.cheapest_flight && a.cheapest_flight.price != null) ? a.cheapest_flight.price : Infinity; | |
| const alts = [...data.alternatives].sort((a, b) => priceOf(a) - priceOf(b)); // priced first, search-only last | |
| const pricedVals = alts.map(priceOf).filter(p => p !== Infinity); | |
| const cheapest = pricedVals.length ? Math.min(...pricedVals) : null; | |
| const sourceTag = (data.price_source || PRICE_SOURCE) === 'live' | |
| ? '<span class="text-sm font-normal text-slate-400">(live fares via Travelpayouts)</span>' | |
| : '<span class="text-sm font-normal text-amber-600">(demo prices — live pricing not configured)</span>'; | |
| const refCard = ` | |
| <div class="bg-white rounded-2xl shadow-sm border border-primary-100 p-5 mb-6 flex items-center justify-between"> | |
| <div> | |
| <p class="text-xs uppercase tracking-wide text-primary-500 font-semibold mb-1">Your pick</p> | |
| <h3 class="text-xl font-bold">${ref.city}</h3> | |
| <p class="text-sm text-slate-500">from ${origin.toUpperCase()}</p> | |
| </div> | |
| <div class="text-right"> | |
| <p class="text-2xl font-bold text-white">${refPrice != null ? '$' + refPrice : '—'}${tripLabel(ref.cheapest_flight)}</p> | |
| <p class="text-xs text-slate-500 mb-1">cheapest fare</p> | |
| ${ref.cheapest_flight && ref.cheapest_flight.link ? `<a href="${ref.cheapest_flight.link}" target="_blank" rel="noopener" class="text-sm font-semibold text-primary-400 hover:text-primary-300">Book →</a>` : ''} | |
| </div> | |
| </div>`; | |
| const cards = alts.map((a, i) => { | |
| const f = a.cheapest_flight; | |
| const isCheapest = f.price != null && f.price === cheapest; | |
| const save = a.savings_vs_reference; | |
| return ` | |
| <div class="card-hover bg-white rounded-2xl shadow-sm border ${isCheapest ? 'border-primary-300' : 'border-slate-100'} overflow-hidden slide-up" style="animation-delay:${i * 0.06}s"> | |
| <div class="p-5"> | |
| <div class="flex items-start justify-between mb-2"> | |
| <div> | |
| <h3 class="font-bold text-lg">${a.city}</h3> | |
| <p class="text-sm text-slate-500"><i class="fa-solid fa-location-dot mr-1 text-primary-500"></i>${a.country}</p> | |
| </div> | |
| <span class="text-xs font-semibold text-accent-700 bg-accent-50 px-2 py-1 rounded-lg">${Math.round(a.similarity * 100)}% match</span> | |
| </div> | |
| <div class="flex flex-wrap gap-1 mb-4">${(a.tags || []).slice(0, 3).map(t => `<span class="px-2 py-0.5 bg-slate-100 text-slate-600 text-xs rounded-md">${t}</span>`).join('')}</div> | |
| <div class="flex items-end justify-between pt-3 border-t border-slate-100"> | |
| <div> | |
| ${f.price != null | |
| ? `<p class="text-2xl font-bold text-primary-600">$${f.price}${tripLabel(f)}</p> | |
| <p class="text-xs text-slate-500 mb-1">${f.airline} · ${f.date}${f.exact_window === false ? ' · nearest date' : ''}${f.stops === 0 ? ' · nonstop' : (f.stops ? ' · ' + f.stops + ' stop' : '')}</p> | |
| ${tripLine(a.trip)} | |
| <span class="flex flex-wrap gap-1 mt-1">${bestOverallBadge(a)}${dealBadge(f)}${seasonChip(a.trip)}</span>` | |
| : `<p class="text-base font-semibold text-slate-300">See live fares</p> | |
| <p class="text-xs text-slate-500">no cached price yet</p>`} | |
| </div> | |
| ${(f.price != null && save != null && save > 0) | |
| ? `<span class="text-xs font-bold text-green-700 bg-green-50 px-2 py-1 rounded-lg">save $${save} vs ${ref.city}</span>` | |
| : (isCheapest ? '<span class="text-xs font-bold text-primary-700 bg-primary-50 px-2 py-1 rounded-lg">cheapest</span>' : '')} | |
| </div> | |
| ${f.link | |
| ? `<a href="${f.link}" target="_blank" rel="noopener" class="mt-4 block text-center py-2.5 bg-primary-600 hover:bg-primary-700 text-white rounded-lg text-sm font-semibold transition-colors"><i class="fa-solid fa-ticket mr-1"></i>${f.price != null ? 'Book this flight →' : 'Search & book →'}</a>` | |
| : ''} | |
| </div> | |
| </div>`; | |
| }).join(''); | |
| out.innerHTML = demoNote() + refCard + | |
| (alts.length | |
| ? `<h3 class="text-lg font-bold mb-4">Cheaper, similar places ${sourceTag}</h3> | |
| <div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-5">${cards}</div>` | |
| : flightsError('No priced alternatives found — try a different date or origin.')); | |
| } | |