// ===================== 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 = `

Couldn't load destinations.

The backend may still be starting β€” try reloading in a moment.

`; } } // ===================== 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 `
Demo prices. Live pricing isn't configured on the server β€” these fares are simulated for preview only.
`; } // "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 `${f.round_trip ? 'round trip' : 'one-way'}${f.source === 'mock' ? ' Β· demo' : ''}`; } // 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 `${d.label}${vs}`; } // 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 ? '' : ''; return `${star}value ${a.wander_score}`; } // "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(`Cheapest day: ${ins.cheapest_date} ($${ins.cheapest_date_price})`); if (ins.max_flex_savings > 0) bits.push(`Flexible dates save up to $${ins.max_flex_savings}`); if (ins.best_weekday) bits.push(`${ins.best_weekday}s average $${ins.weekday_avg_savings} less`); if (!bits.length) return ''; return `
${bits.map(b => `${b}`).join('')}
`; } // 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 `

Good time to book β€” ${-pw.vs_median_pct}% below the recent median ($${pw.recent_median}) we've seen on this route.

`; if (pw.status === 'above_recent') return `

Above the recent range for this route (median $${pw.recent_median}) β€” flex your dates if you can.

`; return `

In line with recent prices on this route (median $${pw.recent_median}).

`; } // True trip cost line: flight + nights x daily budget (from the city dataset). function tripLine(t) { if (!t || t.total == null) return ''; return `

β‰ˆ $${t.total} for ${t.nights} nights all-in (flight $${t.flight} + ~$${t.daily}/day)

`; } 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 `${n}`; } function bestOverallBadge(a) { if (!a.best_overall) return ''; const why = (a.trip_rank && a.trip_rank.reasons || []).join(' Β· '); return `Best overall`; } // Shown when the backend fell back to nearest-date fares (exact_window === false). function windowNote(d) { if (d && d.exact_window === false) { return `

No fares cached inside your exact date window β€” showing the closest available dates.

`; } 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 `

Best in ${s}

`; } // ----- 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 ``; }).join(''); } function buildContinentFilter() { const cont = Array.from(new Set(CATALOG.map(c => c.continent).filter(Boolean))).sort(); document.getElementById('continentFilter').innerHTML = `` + cont.map(c => ``).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 = ``; } else { wrap.innerHTML = filtered.length ? `

Showing all ${filtered.length} destinations

` : ''; } } function loadMore() { visibleCount += PAGE_SIZE; renderExplore(false); } function exploreCard(dest) { const tags = (dest.tags || []).slice(0, 2).map(t => `${t}`).join(''); return `
${dest.name}
${dest.price}
${tags}

${dest.name}

${dest.country}

${CATEGORY_ICONS[dest.category] || ''} ${dest.category}
${seasonBadge(dest)}

${dest.description}

from $${dest.avg_flight_usd}
`; } function loadingGrid() { return Array.from({ length: 8 }).map(() => `
`).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 = `

${dest.name}

${dest.country}

${CATEGORY_ICONS[dest.category] || ''} ${dest.category}
${dest.price}
${(dest.tags || []).map(t => `${t}`).join('')}

${dest.description}

${dest.best_travel_season ? `

Best time to visit: ${dest.best_travel_season}

` : ''}

Continent

${dest.continent || 'β€”'}

Category

${dest.category}

Avg flight

$${dest.avg_flight_usd}

Avg / day

$${dest.avg_daily_usd}

`; 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 => `

${c.name}

${c.country} Β· ${c.category}

`).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) => `
${dest.similarity}%

${dest.name}

${dest.country}

${seasonBadge(dest)}
${(dest.tags || []).slice(0, 3).map(t => `${t}`).join('')}
`).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 = '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 = `

Couldn't reach the recommendation engine.

Please try again in a moment.

`; } 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) => `
${dest.similarity}%

${dest.name}

${dest.country}

${seasonBadge(dest)}
${(dest.tags || []).map(t => `${t}`).join('')}
`).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', ``); } const tr = document.getElementById('trip-round'); if (tr && !document.getElementById('nonstopToggle')) { tr.insertAdjacentHTML('afterend', ``); } } 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 => `

${c.name}

${c.country}

`).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 = '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 `
$${c.price}
${newMonth ? c.date.slice(5) : c.date.slice(8)}
`; }).join(''); return `

Price by date Β· ${cal.length} days priced Β· green = cheapest ($${min})

${bars}
`; } // "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 `

Showing nonstop flights only.

`; if (!dir) return `

No nonstop found in the cached fares β€” cheapest has ${c.stops || 1}+ stop.

`; if (dir.price === c.price) return ''; return `

Nonstop from $${dir.price} (${dir.date}) Β· with stops from $${c.price} β€” layover saves $${(dir.price - c.price).toFixed(0)}.

`; } // ----- 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 ? `

No cached fare for this exact route β€” opening a live search with real-time prices.

` : ''}

Cheapest ${origin.toUpperCase()} β†’ ${dest}${d.scanned_months > 1 ? ` Β· scanned ${d.scanned_months} months` : ''}

${priceLabel(c.price)}${tripLabel(c)} ${c.airline}${c.date ? ' Β· ' + c.date : ''}${stopsLabel(c.stops)}

${dealBadge(c)} ${save ? `save $${save} vs your exact date` : ''}
${c.link ? `Book β†’` : ''}
${opts.filter(o => o.price != null).map(o => `

$${o.price}${tripLabel(o)}

${o.airline} Β· ${o.date}${o.return_date ? ' β†’ ' + o.return_date : ''}${stopsLabel(o.stops)}

${dealBadge(o)}
`).join('')}
`; } // ----- 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()}

${m.length} places under $${d.budget} from ${origin.toUpperCase()}

${m.map((x, i) => { const f = x.cheapest_flight; return `

${x.city}

${x.country}

${(x.tags || []).slice(0, 3).map(t => `${t}`).join('')}

$${f.price}${tripLabel(f)}

${f.airline} Β· ${f.date}

${dealBadge(f)}${seasonChip(x.trip)}${tripLine(x.trip)}
${f.link ? `Book β†’` : ''}
`; }).join('')}
`; } function flightsError(msg) { return `

${msg}

`; } 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 = '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' ? '(live fares via Travelpayouts)' : '(demo prices β€” live pricing not configured)'; const refCard = `

Your pick

${ref.city}

from ${origin.toUpperCase()}

${refPrice != null ? '$' + refPrice : 'β€”'}${tripLabel(ref.cheapest_flight)}

cheapest fare

${ref.cheapest_flight && ref.cheapest_flight.link ? `Book β†’` : ''}
`; 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 `

${a.city}

${a.country}

${Math.round(a.similarity * 100)}% match
${(a.tags || []).slice(0, 3).map(t => `${t}`).join('')}
${f.price != null ? `

$${f.price}${tripLabel(f)}

${f.airline} Β· ${f.date}${f.exact_window === false ? ' Β· nearest date' : ''}${f.stops === 0 ? ' Β· nonstop' : (f.stops ? ' Β· ' + f.stops + ' stop' : '')}

${tripLine(a.trip)} ${bestOverallBadge(a)}${dealBadge(f)}${seasonChip(a.trip)}` : `

See live fares

no cached price yet

`}
${(f.price != null && save != null && save > 0) ? `save $${save} vs ${ref.city}` : (isCheapest ? 'cheapest' : '')}
${f.link ? `${f.price != null ? 'Book this flight β†’' : 'Search & book β†’'}` : ''}
`; }).join(''); out.innerHTML = demoNote() + refCard + (alts.length ? `

Cheaper, similar places ${sourceTag}

${cards}
` : flightsError('No priced alternatives found β€” try a different date or origin.')); }