Flyair_wonderplan / new_script.js
RKugel's picture
Pricing reliability + deal intelligence upgrade (provider telemetry, negative caching, city-code queries, stale-fare guard, fare tracker, true trip cost, TripRank; 78 unit tests)
13e0153 verified
Raw
History Blame Contribute Delete
58.1 kB
// ===================== 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 &amp; 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.'));
}