mirror of
https://github.com/mblanke/holiday-travel-app.git
synced 2026-03-01 13:30:20 -05:00
Initial commit: Holiday Travel App with resort comparison, trip management, and multi-provider search
This commit is contained in:
180
app/page.tsx
Normal file
180
app/page.tsx
Normal file
@@ -0,0 +1,180 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user