mirror of
https://github.com/mblanke/holiday-travel-app.git
synced 2026-03-01 13:30:20 -05:00
181 lines
8.2 KiB
TypeScript
181 lines
8.2 KiB
TypeScript
'use client';
|
|
|
|
import { useState } from "react";
|
|
import Section from "@/components/Section";
|
|
import DealCard from "@/components/DealCard";
|
|
|
|
type Result = {
|
|
results: any[]
|
|
};
|
|
|
|
export default function Page() {
|
|
const [origin, setOrigin] = useState("YOW");
|
|
const [dest, setDest] = useState("CUN,PUJ,MBJ");
|
|
const [startDate, setStartDate] = useState(new Date().toISOString().slice(0,10));
|
|
const [endDate, setEndDate] = useState(new Date(Date.now() + 1000*60*60*24*60).toISOString().slice(0,10));
|
|
const [minN, setMinN] = useState(5);
|
|
const [maxN, setMaxN] = useState(9);
|
|
const [budget, setBudget] = useState<number | ''>('');
|
|
const [nonStop, setNonStop] = useState(false);
|
|
const [loading, setLoading] = useState(false);
|
|
const [results, setResults] = useState<any[]>([]);
|
|
const [useDeals, setUseDeals] = useState(true);
|
|
const [useSky, setUseSky] = useState(true);
|
|
const [useG, setUseG] = useState(true);
|
|
const [useAC, setUseAC] = useState(true);
|
|
const [useAT, setUseAT] = useState(true);
|
|
|
|
async function onSubmit(e: React.FormEvent) {
|
|
e.preventDefault();
|
|
setLoading(true);
|
|
setResults([]);
|
|
|
|
const payload = {
|
|
origin: origin.trim().toUpperCase(),
|
|
destinations: dest.split(',').map(s => s.trim().toUpperCase()).filter(Boolean),
|
|
startDate, endDate,
|
|
tripLengthMin: Number(minN), tripLengthMax: Number(maxN),
|
|
budget: budget === '' ? null : Number(budget),
|
|
currency: "CAD",
|
|
nonStopOnly: nonStop,
|
|
sources: [
|
|
useDeals ? "Deals" : "",
|
|
useSky ? "Skyscanner" : "",
|
|
useG ? "GoogleFlights" : "",
|
|
useAC ? "AirCanada" : "",
|
|
useAT ? "AirTransat" : ""
|
|
].filter(Boolean)
|
|
};
|
|
|
|
const res = await fetch("/api/search", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(payload) });
|
|
const data: Result = await res.json();
|
|
|
|
// Sort by destination, then by source/provider
|
|
const sorted = (data.results || []).sort((a: any, b: any) => {
|
|
// First sort by destination
|
|
const destCompare = (a.destination || '').localeCompare(b.destination || '');
|
|
if (destCompare !== 0) return destCompare;
|
|
|
|
// Then by source/provider
|
|
return (a.source || '').localeCompare(b.source || '');
|
|
});
|
|
|
|
setResults(sorted);
|
|
setLoading(false);
|
|
}
|
|
|
|
return (
|
|
<main className="space-y-6">
|
|
<Section title="Your trip idea">
|
|
<form className="grid md:grid-cols-4 gap-4" onSubmit={onSubmit}>
|
|
<div>
|
|
<label className="label">From (IATA)</label>
|
|
<input className="input" value={origin} onChange={e=>setOrigin(e.target.value.toUpperCase())} maxLength={4} placeholder="YOW" />
|
|
</div>
|
|
<div className="md:col-span-3">
|
|
<label className="label">To (IATA, comma-separated)</label>
|
|
<input className="input" value={dest} onChange={e=>setDest(e.target.value)} placeholder="CUN,PUJ,MBJ" />
|
|
</div>
|
|
<div>
|
|
<label className="label">Start date</label>
|
|
<input type="date" className="input" value={startDate} onChange={e=>setStartDate(e.target.value)} />
|
|
</div>
|
|
<div>
|
|
<label className="label">End date</label>
|
|
<input type="date" className="input" value={endDate} onChange={e=>setEndDate(e.target.value)} />
|
|
</div>
|
|
<div>
|
|
<label className="label">Trip length (min)</label>
|
|
<input type="number" className="input" value={minN} onChange={e=>setMinN(Number(e.target.value))} min={1} />
|
|
</div>
|
|
<div>
|
|
<label className="label">Trip length (max)</label>
|
|
<input type="number" className="input" value={maxN} onChange={e=>setMaxN(Number(e.target.value))} min={minN} />
|
|
</div>
|
|
<div>
|
|
<label className="label">Budget (CAD)</label>
|
|
<input type="number" className="input" value={budget} onChange={e=>setBudget(e.target.value === '' ? '' : Number(e.target.value))} />
|
|
</div>
|
|
<div className="flex items-end gap-3">
|
|
<label className="inline-flex items-center gap-2">
|
|
<input type="checkbox" checked={nonStop} onChange={e=>setNonStop(e.target.checked)} />
|
|
Non-stop only
|
|
</label>
|
|
</div>
|
|
<div className="md:col-span-4">
|
|
<label className="label">Sources</label>
|
|
<div className="flex flex-wrap gap-3">
|
|
<label className="inline-flex items-center gap-2"><input type="checkbox" checked={useDeals} onChange={e=>setUseDeals(e.target.checked)} /> Deal sites (YOW/YYZ/YUL/etc.)</label>
|
|
<label className="inline-flex items-center gap-2"><input type="checkbox" checked={useSky} onChange={e=>setUseSky(e.target.checked)} /> Skyscanner links</label>
|
|
<label className="inline-flex items-center gap-2"><input type="checkbox" checked={useG} onChange={e=>setUseG(e.target.checked)} /> Google Flights links</label>
|
|
<label className="inline-flex items-center gap-2"><input type="checkbox" checked={useAC} onChange={e=>setUseAC(e.target.checked)} /> Air Canada links</label>
|
|
<label className="inline-flex items-center gap-2"><input type="checkbox" checked={useAT} onChange={e=>setUseAT(e.target.checked)} /> Air Transat links</label>
|
|
</div>
|
|
</div>
|
|
<div className="md:col-span-4 flex gap-3">
|
|
<button className="btn" type="submit" disabled={loading}>{loading ? "Searching..." : "Search deals & dates"}</button>
|
|
<button className="btn" type="button" onClick={()=>{
|
|
localStorage.setItem("lastSearch", JSON.stringify({origin,dest,startDate,endDate,minN,maxN,budget,nonStop,useDeals,useSky,useG,useAC,useAT}));
|
|
alert("Saved to this browser.");
|
|
}}>Save search</button>
|
|
<button className="btn" type="button" onClick={()=>{
|
|
const raw = localStorage.getItem("lastSearch");
|
|
if (raw) {
|
|
const s = JSON.parse(raw);
|
|
setOrigin(s.origin); setDest(s.dest);
|
|
setStartDate(s.startDate); setEndDate(s.endDate);
|
|
setMinN(s.minN); setMaxN(s.maxN);
|
|
setBudget(s.budget); setNonStop(s.nonStop);
|
|
setUseDeals(s.useDeals); setUseSky(s.useSky); setUseG(s.useG); setUseAC(s.useAC); setUseAT(s.useAT || true);
|
|
} else alert("No saved search found.");
|
|
}}>Load last</button>
|
|
</div>
|
|
</form>
|
|
</Section>
|
|
|
|
<Section title="Results">
|
|
{!loading && results.length === 0 && (
|
|
<div className="opacity-70">No results yet. Fill the form and hit search.</div>
|
|
)}
|
|
{results.length > 0 && (() => {
|
|
// Group by destination
|
|
const grouped: Record<string, any[]> = {};
|
|
results.forEach((r: any) => {
|
|
const dest = r.destination || 'Unknown';
|
|
if (!grouped[dest]) grouped[dest] = [];
|
|
grouped[dest].push(r);
|
|
});
|
|
|
|
return Object.keys(grouped).sort().map(destination => (
|
|
<div key={destination} className="mb-8">
|
|
<h3 className="text-xl font-bold mb-4 border-b border-slate-300 dark:border-slate-700 pb-2">
|
|
{destination}
|
|
</h3>
|
|
{(() => {
|
|
// Group by source/provider within this destination
|
|
const bySource: Record<string, any[]> = {};
|
|
grouped[destination].forEach((r: any) => {
|
|
const src = r.source || 'Other';
|
|
if (!bySource[src]) bySource[src] = [];
|
|
bySource[src].push(r);
|
|
});
|
|
|
|
return Object.keys(bySource).sort().map(source => (
|
|
<div key={source} className="mb-6">
|
|
<h4 className="text-sm font-semibold mb-3 text-slate-600 dark:text-slate-400 uppercase tracking-wide">
|
|
{source}
|
|
</h4>
|
|
<div className="grid md:grid-cols-2 gap-4">
|
|
{bySource[source].map((r: any) => <DealCard key={r.id} deal={r} />)}
|
|
</div>
|
|
</div>
|
|
));
|
|
})()}
|
|
</div>
|
|
));
|
|
})()}
|
|
</Section>
|
|
</main>
|
|
);
|
|
}
|