const { useState, useEffect, useCallback, useRef } = React; const API = ""; // Same origin when served from FastAPI window.API = API; // Expose to other JSX files // ─── Constants ────────────────────────────────────────────────────────────── // Z-index layers (ordered lowest → highest) const Z_STICKY_HEADER = 2; const Z_TABLE_CELL = 1; const Z_SEARCH_FORM = 100; const Z_DROPDOWN = 100; const Z_MODAL_OVERLAY = 1000; const Z_MODAL = 1000; const Z_TOOLTIP = 9999; const Z_SUGGESTIONS = 9999; const Z_DATE_PICKER = 9998; const Z_MODAL_TOP = 10000; const Z_TOAST = 99999; // Timing (ms) const TOAST_DISMISS_MS = 5000; const MAP_INVALIDATE_DELAY_MS = 300; const SEARCH_DEBOUNCE_MS = 250; const INPUT_FOCUS_DELAY_MS = 50; const CONFIRMATION_MSG_MS = 1500; // Layout / sizing (px) const TOAST_MAX_WIDTH = 380; const TOAST_OFFSET = 20; const SUGGESTIONS_MAX_HEIGHT = 300; const DATE_PICKER_MAX_HEIGHT = 220; const ALLIANCE_INFO_MAX_WIDTH = 520; const MAX_TOASTS = 5; // ─── Error Boundary ───────────────────────────────────────────────────────── class ErrorBoundary extends React.Component { constructor(props) { super(props); this.state = { hasError: false, error: null }; } static getDerivedStateFromError(error) { return { hasError: true, error }; } componentDidCatch(error, info) { console.error("ErrorBoundary caught:", error, info); } render() { if (this.state.hasError) { return React.createElement("div", { style: { background: "rgba(239,68,68,0.1)", border: "1px solid rgba(239,68,68,0.3)", borderRadius: 12, padding: 20, margin: "12px 0", textAlign: "center" } }, React.createElement("div", { style: { fontSize: 14, color: "#ef4444", marginBottom: 8 } }, "This section failed to load"), React.createElement("div", { style: { fontSize: 12, color: theme.textMuted } }, String(this.state.error?.message || "Unknown error")), React.createElement("button", { onClick: () => this.setState({ hasError: false, error: null }), style: { marginTop: 10, padding: "6px 16px", background: "rgba(59,130,246,0.15)", color: "#3b82f6", border: "1px solid rgba(59,130,246,0.3)", borderRadius: 8, cursor: "pointer", fontSize: 12, fontFamily: "inherit" } }, "Retry") ); } return this.props.children; } } // ─── Toast Notifications ───────────────────────────────────────────────────── // Lightweight global toast system for surfacing errors (replaces silent .catch(() => {})) const _toastListeners = new Set(); let _toastId = 0; window.showToast = (msg, type = "error") => { const t = { id: ++_toastId, msg, type, ts: Date.now() }; _toastListeners.forEach(fn => fn(t)); }; // Screen-reader announcements via live region window.announce = (msg) => { const el = document.getElementById("a11y-announcer"); if (el) { el.textContent = ""; setTimeout(() => { el.textContent = msg; }, 100); } }; // Shorthand for catch blocks — logs to console AND shows toast window.silentErr = (context) => (err) => { console.warn(`[${context}]`, err); window.showToast(`${context}: ${err.message || "Network error"}`); }; function ToastContainer() { const [toasts, setToasts] = useState([]); const theme = window.activeTheme || darkTheme; useEffect(() => { const add = (t) => setToasts(prev => [...prev.slice(-(MAX_TOASTS - 1)), t]); _toastListeners.add(add); return () => _toastListeners.delete(add); }, []); useEffect(() => { if (toasts.length === 0) return; const timer = setTimeout(() => setToasts(prev => prev.slice(1)), TOAST_DISMISS_MS); return () => clearTimeout(timer); }, [toasts]); if (toasts.length === 0) return null; return ReactDOM.createPortal( React.createElement("div", { role: "status", "aria-live": "polite", "aria-atomic": "false", style: { position: "fixed", bottom: TOAST_OFFSET, right: TOAST_OFFSET, zIndex: Z_TOAST, display: "flex", flexDirection: "column", gap: 8, maxWidth: TOAST_MAX_WIDTH } }, toasts.map(t => React.createElement("div", { key: t.id, role: t.type === "error" ? "alert" : "status", style: { background: t.type === "error" ? "rgba(239,68,68,0.95)" : t.type === "success" ? "rgba(16,185,129,0.95)" : "rgba(59,130,246,0.95)", color: "#fff", padding: "10px 16px", borderRadius: 10, fontSize: 13, fontFamily: "inherit", boxShadow: "0 4px 20px rgba(0,0,0,0.3)", display: "flex", alignItems: "center", gap: 8, animation: "fadeIn 0.2s ease" } }, React.createElement("span", { style: { flex: 1 } }, t.msg), React.createElement("button", { onClick: () => setToasts(prev => prev.filter(x => x.id !== t.id)), "aria-label": "Dismiss notification", style: { background: "none", border: "none", color: "#fff", cursor: "pointer", fontSize: 16, padding: "0 4px", opacity: 0.7 } }, "×") )) ), document.body ); } // ─── Help Guide ───────────────────────────────────────────────────────────── // In-app guide for new users — Quick Start + detailed feature walkthroughs function HelpGuide({ onClose }) { const theme = window.activeTheme || darkTheme; const [activeSection, setActiveSection] = useState("quickstart"); const sections = [ { id: "quickstart", icon: "🚀", label: "Quick Start" }, { id: "search", icon: "🔍", label: "Searching Flights" }, { id: "results", icon: "📋", label: "Reading Results" }, { id: "programs", icon: "🏛", label: "Programs & Banks" }, { id: "dashboard", icon: "📊", label: "Dashboard" }, { id: "alerts", icon: "📧", label: "Alerts" }, { id: "profile", icon: "👤", label: "Your Profile" }, { id: "tips", icon: "💡", label: "Pro Tips" }, ]; const Section = ({ title, children }) => (

{title}

{children}
); const Step = ({ num, title, children }) => (
{num}
{title}
{children}
); const Kbd = ({ children }) => ( {children} ); const Tip = ({ children }) => (
💡 {children}
); const content = { quickstart: ( <>

Award Flight DB tracks over 6 million award flight seats across 25 airline loyalty programs and 7 credit card transfer partners. Here's how to find your next flight:

Type a city name, country, or airport code in the FROM and TO fields. Select from the dropdown suggestions. You can type Tokyo to search all Tokyo airports at once, or NRT for a specific one. You can also search with just an origin (to see everywhere you can fly from that airport) or just a destination (to find all routes into a city). Pick a Date From and Date To range. The database tracks availability up to 11 months ahead, so cast a wide net. Select First, Business, Premium Economy, or Economy. Business is selected by default — that's where the best award value usually is. Click Search Flights and results will appear ranked by mileage cost. The best deals show up first. Create a free account to save your home airports, set up price alerts, and track your loyalty program balances — all in one place.
), search: ( <>

You don't need both an origin and destination to search. Use any combination:

Origin + Destination — Find flights on a specific route (e.g., MEM → NRT).
Origin only — Leave the TO field empty to discover everywhere you can fly from your airport. Great for "where can I go?" inspiration.
Destination only — Leave the FROM field empty to see all routes into a city. Perfect for finding the cheapest way to reach a destination.
Combine a partial route with cabin and bank filters to answer questions like "Where can I fly Business class from MEM using Chase points?"
Trip Type One Way finds single-direction flights. Round Trip finds paired outbound + return itineraries with smart layover scoring. Multi City lets you search flexible city pairings.
Cabin Class Select one or more cabin classes: First, Business, Premium Economy, or Economy. Each is color-coded throughout the app so you can spot them at a glance.
Bank & Airline Filters Filter by a specific credit card program (like Chase, Amex, or Capital One) to see only flights bookable with those points. Or filter by airline to see a specific carrier's availability. These filters work with partial route searches too.
Max Miles Set a ceiling on the number of miles/points you're willing to spend. Default is 120,000 — lower it to find the real sweet spots.
Max Stops Choose Nonstop for direct flights only, 1 Stop (default) for connections through hubs, or 2 Stops to see all options. Connections are automatically built by matching flights through hub airports.
Passengers Search for up to 9 passengers. Results will only show flights with enough available seats for your group.
Use the ⇌ swap button between FROM and TO to quickly reverse your route.
), results: ( <>

Results are shown in a table sorted by mileage cost (lowest first). Here's what each column means:

Route — Origin → Destination airport codes. For connections, you'll see the connecting airport in between.
Date — The travel date. Hover over dates to see a sparkline chart showing how availability changes over time.
Airline — The operating carrier with their logo. The airline name links to their program details.
Cabin — Color-coded by class: First, Business, Premium Economy, Economy.
Miles — The number of miles/points required to book. Actual currency depends on which program you book through.
Program — Which loyalty program this availability was found through. This is who you'd book with.
Seats — How many award seats are available on that flight. Book quickly if you see only 1-2 seats!
Click the sort headers to re-sort by any column. Click a program name to see which credit card points can transfer to it.

Direct flights appear first in a clean table. Below that, connection itineraries are shown with a connecting city indicator. Connections are automatically scored for quality — shorter layovers and better cabin matches rank higher.

), programs: ( <>

The Programs tab is your reference for the 25 airline programs and 7 bank transfer partners tracked in the database.

🔥 Active Transfer Bonuses At the top, you'll see current transfer bonus promotions from credit card programs. For example, if Chase is offering a +20% bonus to British Airways, transferring 100,000 points gives you 120,000 Avios. These bonuses change frequently — check before you transfer!
Alliance Groups Programs are organized by airline alliance — Star Alliance, Oneworld, SkyTeam, and Independent. Partners within the same alliance can often book each other's flights.
Bank Transfer Partners Below the airlines, you'll see each bank's transfer partners with their transfer ratios. A "1:1" ratio means 1,000 bank points becomes 1,000 airline miles. Some have better ratios — look for these to maximize value.
Sign in to track your point balances for each program and bank. They'll show right on the cards so you can quickly see what you can afford.
), dashboard: ( <>

The Dashboard gives you a bird's-eye view of the entire database with five powerful tools for exploring award flight data.

Stats at a Glance Four stat cards show Total Flights tracked, Programs monitored, unique Routes, and Airports covered. The database is updated daily with fresh award availability.
System Status Shows database connection health, the last ingestion timestamp, the date range of tracked availability, and API version. If the last ingestion is more than a day old, data might be stale.

An interactive world map plotting every airport with tracked award flights. Airports are color-coded by activity level:

● Gold — Top 50 busiest airports (major hubs like JFK, LHR, NRT)
● Blue — Top 200 airports
● Green — All other active airports

Zoom and pan the map to explore different regions. The counter shows total airports and countries covered.

A visual explorer that draws animated arcs across a world map showing every route in the database. Thicker, brighter arcs mean more award availability on that route.

Route Limit Control how many routes are loaded: 800 for a quick overview, 2K for broader coverage, 5K (default) for most routes, or All to load the entire network.
Cabin Filter Switch between All cabins, Business only, or Economy only. This instantly hides routes without availability in your chosen cabin.
Region Toggles Color-coded region tags like NA↔EU, NA↔Asia, NA↔SA, and 14 more. Click any tag to toggle that region's routes on or off. Use Hide All to start blank, then enable only the corridors you care about. Show All brings everything back.
Origin & Destination Search Filter routes by airport, city, country, or region. Enter just an origin to see everywhere you can fly from, or just a destination to find all routes into a city. Use the type selector (Any, IATA, City, Country, Region) to control match scope.
Click Any Arc Click a route arc to open a detail panel showing origin, destination, available cabins, mileage costs, and which programs have availability.
Power combo: set cabin to Business, click Hide All, turn on just NA↔Asia, then type your home airport in the origin field. You'll instantly see every business-class route to Asia from your city.

A color-coded heatmap showing the lowest mileage price for every program across every month. This is the fastest way to find which programs offer the cheapest redemptions and when.

Cabin Toggle Switch between Business and Economy to see the best prices in each cabin class. Each shows completely different programs and pricing.
Cheapest vs Average Cheapest shows the absolute lowest mileage cost found that month — the best possible deal. Average shows the typical cost, which is more realistic for planning. Compare both to gauge how rare the sweet spots are.
Reading the Heatmap Each cell shows the mileage cost (in thousands, e.g., "10k" = 10,000 miles) for a program in a given month. Cells are color-coded from green (cheap) through yellow to red (expensive). Look for the greenest cells — those are your sweet spots.
Route & Program Filters Use the origin and destination search to narrow results to specific routes. The program filter lets you search and hide specific programs. Click any program name to temporarily hide it and focus on the ones you care about.
Cell Details Click any cell to pin a detail panel showing that program's total flights and routes for that month, plus the best business and economy deals with exact route, date, and mileage cost. Hover over other cells to preview without unpinning. Use Compact mode to fit more programs on screen at once.
Search for your home airport as origin, then look across the row for the greenest cells. That tells you which programs offer the cheapest flights from your city and which months have the best availability.

A monthly calendar view that shows daily award availability by cabin class for any route. Perfect for finding exact dates to book.

How to Use It Enter an origin, destination, or both in the search fields, then click Search. The calendar fills in with color-coded bars for each day showing which cabin classes have award space available.
Reading the Colors Each day shows up to four bars: ■ Economy, ■ Premium Economy, ■ Business, and ■ First. If a bar shows a mileage number (like "10k"), award space is available in that cabin for that date. Empty or dim bars mean no availability.
Month Navigation Use the and arrows to move between months. The database tracks availability up to 11 months ahead, so explore future months for the best selection.
Day Details Click any day in the calendar to see a breakdown of available flights, programs, mileage costs, and seat counts for that specific date. This is the final step before you go book your flight.
Found a sweet spot in the heatmap above? Plug that same route into the Cabin Class Calendar to see the exact dates with availability. Together, these two tools take you from "what's cheap?" all the way to "when can I fly?"
), alerts: ( <>

Requires a free account — sign in to access.

Alerts let you monitor specific routes and get notified when award seats become available at the mileage price you want.

Go to the Alerts tab and set your origin, destination, cabin class, and the maximum miles you'd pay. Give it a friendly name so you remember what it's tracking. Choose how long to monitor — 7 days, 30 days, 90 days, 6 months, or a full year. Longer windows catch seasonal availability that opens up. When matching award seats are found, you'll receive an email with the flight details, mileage cost, and a direct link to book. Set alerts for popular routes like the US to Japan in business class. Availability opens up unpredictably — alerts catch the moment it appears.
), profile: ( <>

Requires a free account — sign in to access.

Home Airports Set your home airports (e.g., MEM, JFK) and they'll automatically fill in the "From" field when you search. Great if you always fly out of the same cities.
Preferred Programs & Banks Select the airline programs and credit card programs you have. This helps personalize which results are most relevant to you.
Cabin & Booking Preferences Set your default cabin class, trip type, and whether you prefer award bookings or cash fares. These become your search defaults.
Theme Switch between dark mode and light mode based on your preference.
Your settings are synced to the cloud, so they persist even if you clear your browser. Sign in on any device to pick up right where you left off.
), tips: ( <>
🗺️ Explore with Partial Routes Leave the destination empty and search from your home airport to discover all the places you can fly with points. Or leave the origin empty to find every route into your dream destination. It's the best way to find deals you didn't know existed.
🎯 Search Wide, Then Narrow Start with a broad date range (2-3 months) and all cabins to see what's available. Once you spot patterns, narrow down to specific dates and classes.
💰 Check Transfer Bonuses First Before transferring points, check the Programs tab for active transfer bonuses. A 20% bonus can save you thousands of points on a single booking.
🔗 Connections Can Be Gold Don't skip connection results! Sometimes routing through a hub unlocks availability that doesn't exist on direct flights. A business class connection through Doha or Istanbul can be a fantastic experience.
📅 Timing Matters Airlines release award seats on specific schedules — some 330 days out, others 90 days. New availability also opens up close-in (2-3 weeks before departure) when airlines release unsold seats.
🏦 Know Your Transfer Partners The same flight might be bookable through multiple programs at different prices. Check the program column in results — the same SFO→NRT flight could cost 60k through one program and 85k through another.
⚡ Act Fast on Low Availability If you see 1-2 seats available, book quickly. Award seats can disappear within hours, especially on premium cabins to popular destinations.
), }; // Focus trap and ESC key for modal accessibility const modalRef = React.useRef(null); React.useEffect(() => { const handleKeyDown = (e) => { if (e.key === "Escape") { onClose(); return; } if (e.key === "Tab" && modalRef.current) { const focusable = modalRef.current.querySelectorAll('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'); if (focusable.length === 0) return; const first = focusable[0], last = focusable[focusable.length - 1]; if (e.shiftKey) { if (document.activeElement === first) { e.preventDefault(); last.focus(); } } else { if (document.activeElement === last) { e.preventDefault(); first.focus(); } } } }; document.addEventListener("keydown", handleKeyDown); // Focus first element on mount setTimeout(() => { if (modalRef.current) { const first = modalRef.current.querySelector('button'); if (first) first.focus(); } }, 50); return () => document.removeEventListener("keydown", handleKeyDown); }, [onClose]); return ReactDOM.createPortal(
e.stopPropagation()}> {/* Sidebar nav */}
📖 User Guide
Learn how to use Award Flight DB
{sections.map(s => ( ))}
{/* Content area */}

{sections.find(s => s.id === activeSection)?.icon} {sections.find(s => s.id === activeSection)?.label}

{content[activeSection]}
, document.body ); } // ─── Theme ────────────────────────────────────────────────────────────────── const darkTheme = { bg: "#0a0e1a", surface: "#111827", surfaceHover: "#1a2235", card: "#151d2e", cardBg: "#151d2e", border: "#1e293b", borderLight: "#2a3a52", text: "#e2e8f0", textMuted: "#94a3b8", textDim: "#8b9ab5", accent: "#3b82f6", accentGlow: "rgba(59, 130, 246, 0.15)", accentSoft: "#1e3a5f", success: "#10b981", warning: "#f59e0b", danger: "#ef4444", gold: "#fbbf24", first: "#c084fc", business: "#3b82f6", premium: "#10b981", economy: "#94a3b8", hover: "#1a2235", }; const lightTheme = { bg: "#f8fafc", surface: "#f1f5f9", surfaceHover: "#e2e8f0", card: "#ffffff", cardBg: "#ffffff", border: "#cbd5e1", borderLight: "#e2e8f0", text: "#1e293b", textMuted: "#64748b", textDim: "#94a3b8", accent: "#2563eb", accentGlow: "rgba(37, 99, 235, 0.12)", accentSoft: "#dbeafe", success: "#059669", warning: "#d97706", danger: "#dc2626", gold: "#d97706", first: "#9333ea", business: "#2563eb", premium: "#059669", economy: "#64748b", hover: "#e2e8f0", }; // Mutable theme object — all code references theme.X, we swap properties in-place on toggle const theme = { ...darkTheme }; function applyTheme(mode) { const src = mode === "light" ? lightTheme : darkTheme; Object.keys(src).forEach(k => { theme[k] = src[k]; }); window.theme = theme; window.activeTheme = theme; document.body.style.backgroundColor = theme.bg; document.body.style.color = theme.text; if (mode === "light") { document.body.classList.add("light-maps"); } else { document.body.classList.remove("light-maps"); } } // Initialize from localStorage immediately to prevent flash applyTheme(localStorage.getItem("theme_mode") || "dark"); const cabinInfo = { F: { label: "First", color: theme.first, icon: "👑" }, J: { label: "Business", color: theme.business, icon: "💎" }, W: { label: "Premium Econ", color: theme.premium, icon: "✦" }, Y: { label: "Economy", color: theme.economy, icon: "✈" }, }; const allianceColors = { "Star Alliance": "#c9a227", Oneworld: "#c53030", SkyTeam: "#2563eb", Independent: "#6b7280", }; // Logo helpers — square icon/tail logos // favicone.com: high-quality 256×256 square icons (best quality where available) const fav = (domain) => `https://favicone.com/${domain}?s=256`; // Google favicon at 128px (reliable fallback for domains favicone doesn't cover) const gfav = (domain) => `https://www.google.com/s2/favicons?domain=${domain}&sz=128`; // Alliance logos (icon + full member team image for hover tooltip) const ALLIANCE_LOGOS = { "Star Alliance": { src: gfav("staralliance.com"), dark: false, teamImg: "/static/star_alliance.png" }, "Oneworld": { src: gfav("oneworld.com"), dark: false, teamImg: "/static/oneworld.png" }, "SkyTeam": { src: "/static/logo_skyteam.png", dark: false, teamImg: "/static/skyteam.png" }, "Independent": null, }; const PROGRAMS = { aeroplan: { name: "Air Canada Aeroplan", alliance: "Star Alliance", currency: "Aeroplan points", url: "https://www.aircanada.com/ca/en/aco/home/aeroplan.html", logo: fav("aircanada.com"), darkLogo: false }, flyingblue: { name: "Air France/KLM Flying Blue", alliance: "SkyTeam", currency: "Flying Blue miles", url: "https://www.flyingblue.com/en", logo: gfav("airfrance.com"), darkLogo: false }, alaska: { name: "Alaska Atmos Rewards", alliance: "Oneworld", currency: "Atmos Rewards", url: "https://www.alaskaair.com/en/mileage-plan", logo: "/static/logo_alaska.png", darkLogo: false }, american: { name: "American AAdvantage", alliance: "Oneworld", currency: "AAdvantage miles", url: "https://www.aa.com/i18n/aadvantage-program/aadvantage-program.jsp", logo: fav("aa.com"), darkLogo: false }, azul: { name: "Azul Fidelidade", alliance: "Independent", currency: "Fidelidade points", url: "https://www.voeazul.com.br/en/loyalty", logo: "/static/logo_azul.png", darkLogo: false }, connectmiles: { name: "Copa ConnectMiles", alliance: "Star Alliance", currency: "ConnectMiles", url: "https://www.copaair.com/en-us/connectmiles/", logo: gfav("copaair.com"), darkLogo: false }, delta: { name: "Delta SkyMiles", alliance: "SkyTeam", currency: "SkyMiles", url: "https://www.delta.com/us/en/skymiles/overview", logo: fav("delta.com"), darkLogo: true }, emirates: { name: "Emirates Skywards", alliance: "Independent", currency: "Skywards miles", url: "https://www.emirates.com/us/english/skywards/", logo: "/static/logo_emirates.png", darkLogo: false }, ethiopian: { name: "Ethiopian ShebaMiles", alliance: "Star Alliance", currency: "ShebaMiles", url: "https://www.ethiopianairlines.com/en/shebamiles", logo: gfav("ethiopianairlines.com"), darkLogo: false }, etihad: { name: "Etihad Guest", alliance: "Independent", currency: "Etihad Guest miles", url: "https://www.etihad.com/en-us/etihad-guest", logo: "/static/logo_etihad.png", darkLogo: false }, finnair: { name: "Finnair Plus", alliance: "Oneworld", currency: "Avios", url: "https://www.finnair.com/en/finnair-plus", logo: fav("finnair.com"), darkLogo: true }, smiles: { name: "GOL Smiles", alliance: "Independent", currency: "Smiles miles", url: "https://www.smiles.com.br/home", logo: gfav("voegol.com.br"), darkLogo: false }, jetblue: { name: "JetBlue TrueBlue", alliance: "Independent", currency: "TrueBlue points", url: "https://trueblue.jetblue.com", logo: fav("jetblue.com"), darkLogo: true }, lufthansa: { name: "Lufthansa Miles & More", alliance: "Star Alliance", currency: "Miles & More miles", url: "https://www.miles-and-more.com/row/en.html", logo: "/static/logo_lufthansa.png", darkLogo: false }, qantas: { name: "Qantas Frequent Flyer", alliance: "Oneworld", currency: "Qantas Points", url: "https://www.qantas.com/au/en/frequent-flyer.html", logo: fav("qantas.com"), darkLogo: false }, qatar: { name: "Qatar Privilege Club", alliance: "Oneworld", currency: "Avios", url: "https://www.qatarairways.com/en-us/Privilege-Club.html", logo: "/static/logo_qatar.png", darkLogo: false }, eurobonus: { name: "SAS EuroBonus", alliance: "Star Alliance", currency: "EuroBonus points", url: "https://www.flysas.com/en/eurobonus/", logo: fav("flysas.com"), darkLogo: false }, saudia: { name: "Saudia AlFursan", alliance: "SkyTeam", currency: "AlFursan miles", url: "https://www.saudia.com/en/alfursan", logo: gfav("saudia.com"), darkLogo: false }, singapore: { name: "Singapore KrisFlyer", alliance: "Star Alliance", currency: "KrisFlyer miles", url: "https://www.singaporeair.com/en_UK/us/ppsclub-krisflyer/krisflyer/", logo: gfav("singaporeair.com"), darkLogo: false }, spirit: { name: "Spirit Free Spirit", alliance: "Independent", currency: "Free Spirit points", url: "https://www.spirit.com/free-spirit", logo: fav("spirit.com"), darkLogo: false }, turkish: { name: "Turkish Miles&Smiles", alliance: "Star Alliance", currency: "Miles&Smiles", url: "https://www.turkishairlines.com/en-us/miles-and-smiles/", logo: gfav("turkishairlines.com"), darkLogo: false }, united: { name: "United MileagePlus", alliance: "Star Alliance", currency: "MileagePlus miles", url: "https://www.united.com/en/us/fly/mileageplus.html", logo: fav("united.com"), darkLogo: true }, virginatlantic: { name: "Virgin Atlantic Flying Club", alliance: "SkyTeam", currency: "Flying Club miles", url: "https://www.virginatlantic.com/en-us/flying-club", logo: gfav("virginatlantic.com"), darkLogo: false }, velocity: { name: "Virgin Australia Velocity", alliance: "Independent", currency: "Velocity Points", url: "https://www.velocityfrequentflyer.com", logo: gfav("virginaustralia.com"), darkLogo: false }, aeromexico: { name: "Aeromexico Club Premier", alliance: "SkyTeam", currency: "Club Premier points", url: "https://www.aeromexico.com/en-us/club-premier", logo: fav("aeromexico.com"), darkLogo: false, whiteBg: true }, }; // ─── Award Availability Release Schedule ──────────────────────────────────── // How far in advance each airline releases award seats (in days). // Sources: roame.travel/guides/award-availability-release, upgradedpoints.com // "bookable_via" = programs that can book partner awards on this airline's metal const RELEASE_SCHEDULE = { // ── Seats.aero tracked programs (we have direct award data) ── aeromexico: { days: 330, notes: "" }, aeroplan: { days: 355, notes: "" }, flyingblue: { days: 359, notes: "" }, alaska: { days: 331, notes: "" }, american: { days: 331, notes: "" }, azul: { days: 330, notes: "Estimate" }, connectmiles: { days: 330, notes: "Estimate" }, delta: { days: 331, notes: "" }, emirates: { days: 328, notes: "" }, ethiopian: { days: 330, notes: "Estimate" }, etihad: { days: 330, notes: "" }, finnair: { days: 360, notes: "" }, smiles: { days: 330, notes: "Estimate" }, jetblue: { days: 331, notes: "" }, lufthansa: { days: 360, notes: "" }, qantas: { days: 353, notes: "" }, qatar: { days: 361, notes: "" }, eurobonus: { days: 330, notes: "Estimate" }, saudia: { days: 330, notes: "Estimate" }, singapore: { days: 355, notes: "" }, spirit: { days: 140, notes: "Limited window" }, turkish: { days: 355, notes: "" }, united: { days: 337, notes: "" }, virginatlantic: { days: 331, notes: "" }, velocity: { days: 330, notes: "Estimate" }, // ── Transfer-only programs (bookable via partner programs) ── aerlingus: { days: 330, notes: "Avios shared", iata: "EI", name: "Aer Lingus AerClub (Avios)", alliance: "Independent", transferOnly: true, logo: gfav("aerlingus.com") }, ana: { days: 355, notes: "", iata: "NH", name: "ANA Mileage Club", alliance: "Star Alliance", transferOnly: true, logo: gfav("ana.co.jp") }, avianca: { days: 355, notes: "", iata: "AV", name: "Avianca LifeMiles", alliance: "Star Alliance", transferOnly: true, logo: gfav("avianca.com") }, britishairways: { days: 354, notes: "Avios shared", iata: "BA", name: "British Airways Executive Club (Avios)", alliance: "Oneworld", transferOnly: true, logo: gfav("britishairways.com") }, cathay: { days: 360, notes: "", iata: "CX", name: "Cathay Pacific Asia Miles", alliance: "Oneworld", transferOnly: true, logo: gfav("cathaypacific.com") }, evaair: { days: 360, notes: "", iata: "BR", name: "EVA Air Infinity MileageLands", alliance: "Star Alliance", transferOnly: true, logo: gfav("evaair.com") }, hawaiian: { days: 330, notes: "", iata: "HA", name: "Hawaiian Airlines HawaiianMiles", alliance: "Independent", transferOnly: true, logo: gfav("hawaiianairlines.com") }, iberia: { days: 360, notes: "Avios shared", iata: "IB", name: "Iberia Plus (Avios)", alliance: "Oneworld", transferOnly: true, logo: gfav("iberia.com") }, jal: { days: 360, notes: "", iata: "JL", name: "Japan Airlines Mileage Bank", alliance: "Oneworld", transferOnly: true, logo: gfav("jal.co.jp") }, southwest: { days: 0, notes: "Batch releases every 4-10 weeks", iata: "WN", name: "Southwest Rapid Rewards", alliance: "Independent", transferOnly: true, logo: gfav("southwest.com") }, tap: { days: 361, notes: "", iata: "TP", name: "TAP Air Portugal Miles&Go", alliance: "Star Alliance", transferOnly: true, logo: gfav("flytap.com") }, thai: { days: 339, notes: "", iata: "TG", name: "Thai Airways Royal Orchid Plus", alliance: "Star Alliance", transferOnly: true, logo: gfav("thaiairways.com") }, airindia: { days: 330, notes: "Estimate", iata: "AI", name: "Air India Maharaja Club", alliance: "Star Alliance", transferOnly: true, logo: gfav("airindia.com") }, korean: { days: 361, notes: "", iata: "KE", name: "Korean Air SkyPass", alliance: "SkyTeam", transferOnly: true, logo: gfav("koreanair.com") }, asiana: { days: 361, notes: "", iata: "OZ", name: "Asiana Club", alliance: "Star Alliance", transferOnly: true, logo: gfav("flyasiana.com") }, }; const BANKS = { chase_ur: { name: "Chase Ultimate Rewards", shortName: "Chase UR", color: "#1a73e8", currency: "UR", url: "https://ultimaterewardspoints.chase.com", logo: gfav("chase.com"), darkLogo: false }, amex_mr: { name: "Amex Membership Rewards", shortName: "Amex MR", color: "#006fcf", currency: "MR", url: "https://global.americanexpress.com/rewards", logo: gfav("americanexpress.com"), darkLogo: false }, capital_one: { name: "Capital One Miles", shortName: "Capital One", color: "#d03027", currency: "miles", url: "https://www.capitalone.com/credit-cards/rewards", logo: "https://www.capitalone.com/apple-touch-icon.png", darkLogo: false }, bilt: { name: "Bilt Rewards", shortName: "Bilt", color: "#e8927c", currency: "points", url: "https://www.biltrewards.com", logo: gfav("biltrewards.com"), darkLogo: true }, citi_typ: { name: "Citi ThankYou Points", shortName: "Citi TYP", color: "#0066b2", currency: "TYP", url: "https://www.thankyou.com", logo: gfav("citi.com"), darkLogo: false }, wells_fargo: { name: "Wells Fargo Rewards", shortName: "Wells Fargo", color: "#cd1309", currency: "points", url: "https://www.wellsfargo.com/rewards", logo: gfav("wellsfargo.com"), darkLogo: false }, rove: { name: "Rove Miles", shortName: "Rove", color: "#7c3aed", currency: "miles", url: "https://www.rovemiles.com", logo: gfav("rovemiles.com"), darkLogo: true }, }; // ─── Formatters ───────────────────────────────────────────────────────────── const fmt = { miles: (n) => (n ? `${(n / 1000).toFixed(1)}k` : "—"), milesFull: (n) => (n ? n.toLocaleString() : "—"), taxes: (n) => (n != null ? `$${Math.round(n)}` : "—"), date: (d) => { if (!d) return "—"; const dt = new Date(d + "T00:00:00"); return dt.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric", weekday: "short" }); }, dateFull: (d) => { if (!d) return "—"; const dt = new Date(d + "T00:00:00"); return dt.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric", weekday: "short" }); }, ago: (ts) => { if (!ts) return "never"; const diff = Date.now() - new Date(ts).getTime(); const mins = Math.floor(diff / 60000); if (mins < 60) return `${mins}m ago`; const hrs = Math.floor(mins / 60); if (hrs < 24) return `${hrs}h ago`; return `${Math.floor(hrs / 24)}d ago`; }, }; // ─── GlowButton ───────────────────────────────────────────────────────────── function GlowButton({ children, onClick, active, small, disabled, style, color }) { const bg = color || theme.accent; return ( ); } // ─── Input ─────────────────────────────────────────────────────────────────── function Input({ value, onChange, placeholder, style: extraStyle, type = "text", min, max, "aria-label": ariaLabel, ...rest }) { return ( onChange(e.target.value)} placeholder={placeholder} min={min} max={max} aria-label={ariaLabel || placeholder} {...rest} style={{ background: theme.surface, color: theme.text, border: `1px solid ${theme.border}`, borderRadius: 8, padding: "10px 14px", fontSize: 14, fontFamily: "inherit", width: "100%", boxSizing: "border-box", transition: "border-color 0.2s", colorScheme: "dark", ...extraStyle, }} onFocus={(e) => (e.target.style.borderColor = theme.accent)} onBlur={(e) => (e.target.style.borderColor = theme.border)} /> ); } // ─── Section Card ─────────────────────────────────────────────────────────── function Section({ title, icon, children, actions, collapsed, onToggle }) { return (
{ if (e.key === "Enter" || e.key === " ") { e.preventDefault(); onToggle(); } } : undefined} style={{ padding: "14px 20px", display: "flex", justifyContent: "space-between", alignItems: "center", cursor: onToggle ? "pointer" : "default", borderBottom: collapsed ? "none" : `1px solid ${theme.border}`, }}>
{icon} {title}
{actions} {onToggle && {collapsed ? "▸" : "▾"}}
{!collapsed &&
{children}
}
); } // ─── StatusDot ────────────────────────────────────────────────────────────── function StatusDot({ status }) { const colors = { healthy: theme.success, warning: theme.warning, error: theme.danger, stale: theme.warning, offline: theme.textDim }; return ( ); } // ─── Toggle ───────────────────────────────────────────────────────────────── function Toggle({ checked, onChange, label }) { return (