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