mirror of
https://github.com/mblanke/holiday-travel-app.git
synced 2026-03-01 05:20:22 -05:00
405 lines
18 KiB
TypeScript
405 lines
18 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 [activeTab, setActiveTab] = useState<'flights' | 'resorts'>('flights');
|
||
|
||
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);
|
||
|
||
// Resort comparison state
|
||
const [resortLoading, setResortLoading] = useState(false);
|
||
const [resortResults, setResortResults] = useState<any[]>([]);
|
||
const [selectedResorts, setSelectedResorts] = useState<string[]>([]);
|
||
const [resortOrigin, setResortOrigin] = useState("YOW");
|
||
const [resortDate, setResortDate] = useState(new Date().toISOString().slice(0,10));
|
||
const [resortNights, setResortNights] = useState(7);
|
||
const [resortBudget, setResortBudget] = useState<number | ''>(5500);
|
||
const [prefs, setPrefs] = useState({
|
||
beach: 8, pool: 8, golf: 5, spa: 7, food: 9,
|
||
nightlife: 6, shopping: 4, culture: 5, outdoor: 6, family: 5
|
||
});
|
||
|
||
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);
|
||
}
|
||
|
||
const availableResorts = [
|
||
"Dreams Onyx Resort & Spa", "Excellence Riviera Cancun", "Secrets Maroma Beach",
|
||
"Hyatt Ziva Cancun", "Moon Palace Cancun", "Barcelo Maya Riviera",
|
||
"Grand Sirenis Riviera Maya", "Iberostar Selection Paraiso Maya", "Hotel Xcaret Mexico",
|
||
"Dreams Punta Cana", "Excellence Punta Cana", "Secrets Cap Cana",
|
||
"Hyatt Ziva Cap Cana", "Iberostar Grand Bavaro"
|
||
];
|
||
|
||
async function onResortCompare(e: React.FormEvent) {
|
||
e.preventDefault();
|
||
if (selectedResorts.length === 0) {
|
||
alert("Please select at least one resort");
|
||
return;
|
||
}
|
||
|
||
setResortLoading(true);
|
||
setResortResults([]);
|
||
|
||
const payload = {
|
||
resorts: selectedResorts,
|
||
departureDate: resortDate,
|
||
origin: resortOrigin,
|
||
tripLength: resortNights,
|
||
budget: resortBudget === '' ? null : Number(resortBudget),
|
||
contingencyPercent: 15,
|
||
preferences: prefs
|
||
};
|
||
|
||
const res = await fetch("/api/resort-compare", {
|
||
method: "POST",
|
||
headers: { "content-type": "application/json" },
|
||
body: JSON.stringify(payload)
|
||
});
|
||
const data = await res.json();
|
||
setResortResults(data.comparisons || data.allComparisons || []);
|
||
setResortLoading(false);
|
||
}
|
||
|
||
function toggleResort(resort: string) {
|
||
setSelectedResorts(prev =>
|
||
prev.includes(resort) ? prev.filter(r => r !== resort) : [...prev, resort]
|
||
);
|
||
}
|
||
|
||
return (
|
||
<main className="space-y-6">
|
||
{/* Tab Switcher */}
|
||
<div className="flex gap-2 border-b border-slate-300 dark:border-slate-700">
|
||
<button
|
||
onClick={() => setActiveTab('flights')}
|
||
className={`px-6 py-3 font-semibold transition-colors ${
|
||
activeTab === 'flights'
|
||
? 'border-b-2 border-blue-600 text-blue-600 dark:text-blue-400'
|
||
: 'text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-slate-200'
|
||
}`}
|
||
>
|
||
✈️ Flight Search
|
||
</button>
|
||
<button
|
||
onClick={() => setActiveTab('resorts')}
|
||
className={`px-6 py-3 font-semibold transition-colors ${
|
||
activeTab === 'resorts'
|
||
? 'border-b-2 border-blue-600 text-blue-600 dark:text-blue-400'
|
||
: 'text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-slate-200'
|
||
}`}
|
||
>
|
||
🏖️ Resort Comparison
|
||
</button>
|
||
</div>
|
||
|
||
{/* Flight Search Tab */}
|
||
{activeTab === 'flights' && (
|
||
<>
|
||
<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>
|
||
</>
|
||
)}
|
||
|
||
{/* Resort Comparison Tab */}
|
||
{activeTab === 'resorts' && (
|
||
<>
|
||
<Section title="Compare Resorts">
|
||
<form onSubmit={onResortCompare} className="space-y-6">
|
||
{/* Basic Settings */}
|
||
<div className="grid md:grid-cols-4 gap-4">
|
||
<div>
|
||
<label className="label">From (IATA)</label>
|
||
<input className="input" value={resortOrigin} onChange={e=>setResortOrigin(e.target.value.toUpperCase())} maxLength={4} placeholder="YOW" />
|
||
</div>
|
||
<div>
|
||
<label className="label">Departure Date</label>
|
||
<input type="date" className="input" value={resortDate} onChange={e=>setResortDate(e.target.value)} />
|
||
</div>
|
||
<div>
|
||
<label className="label">Trip Length (nights)</label>
|
||
<input type="number" className="input" value={resortNights} onChange={e=>setResortNights(Number(e.target.value))} min={1} />
|
||
</div>
|
||
<div>
|
||
<label className="label">Budget (CAD)</label>
|
||
<input type="number" className="input" value={resortBudget} onChange={e=>setResortBudget(e.target.value === '' ? '' : Number(e.target.value))} placeholder="5500" />
|
||
</div>
|
||
</div>
|
||
|
||
{/* Preference Sliders */}
|
||
<div>
|
||
<label className="label mb-3">Your Preferences (0-10)</label>
|
||
<div className="grid md:grid-cols-2 lg:grid-cols-5 gap-4">
|
||
{Object.keys(prefs).map(key => (
|
||
<div key={key}>
|
||
<label className="text-sm capitalize mb-1 block">{key}</label>
|
||
<input
|
||
type="range"
|
||
min="0"
|
||
max="10"
|
||
value={prefs[key as keyof typeof prefs]}
|
||
onChange={e=>setPrefs({...prefs, [key]: Number(e.target.value)})}
|
||
className="w-full"
|
||
/>
|
||
<div className="text-xs text-center mt-1">{prefs[key as keyof typeof prefs]}</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Resort Selection */}
|
||
<div>
|
||
<label className="label mb-3">Select Resorts ({selectedResorts.length} selected)</label>
|
||
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-2">
|
||
{availableResorts.map(resort => (
|
||
<label key={resort} className="inline-flex items-center gap-2 p-2 border rounded hover:bg-slate-50 dark:hover:bg-slate-800 cursor-pointer">
|
||
<input
|
||
type="checkbox"
|
||
checked={selectedResorts.includes(resort)}
|
||
onChange={() => toggleResort(resort)}
|
||
/>
|
||
<span className="text-sm">{resort}</span>
|
||
</label>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
<button className="btn" type="submit" disabled={resortLoading}>
|
||
{resortLoading ? "Comparing..." : "Compare Resorts"}
|
||
</button>
|
||
</form>
|
||
</Section>
|
||
|
||
<Section title="Results">
|
||
{!resortLoading && resortResults.length === 0 && (
|
||
<div className="opacity-70">No results yet. Select resorts and hit compare.</div>
|
||
)}
|
||
{resortResults.length > 0 && (
|
||
<div className="space-y-4">
|
||
{resortResults.map((comp: any) => (
|
||
<div key={comp.resort.name} className="card p-4">
|
||
<div className="flex justify-between items-start mb-3">
|
||
<div>
|
||
<h3 className="font-bold text-lg">{comp.resort.name}</h3>
|
||
<p className="text-sm text-slate-600 dark:text-slate-400">
|
||
{comp.resort.destination}, {comp.resort.country} ({comp.resort.airportCode})
|
||
</p>
|
||
</div>
|
||
<div className="text-right">
|
||
<div className="text-2xl font-bold text-blue-600 dark:text-blue-400">
|
||
{comp.matchScore}
|
||
</div>
|
||
<div className="text-xs text-slate-600 dark:text-slate-400">Match Score</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="grid md:grid-cols-3 gap-4 mb-3">
|
||
<div>
|
||
<div className="text-xs text-slate-600 dark:text-slate-400">TripAdvisor</div>
|
||
<div className="font-semibold">{comp.resort.tripAdvisorRating}/5.0 ⭐ ({comp.resort.tripAdvisorReviews?.toLocaleString()} reviews)</div>
|
||
</div>
|
||
<div>
|
||
<div className="text-xs text-slate-600 dark:text-slate-400">Price Tier</div>
|
||
<div className="font-semibold">{comp.resort.priceRange}</div>
|
||
</div>
|
||
<div>
|
||
<div className="text-xs text-slate-600 dark:text-slate-400">Estimated Cost (2 people)</div>
|
||
<div className="font-semibold">${comp.estimatedTotalMin?.toLocaleString()}-${comp.estimatedTotalMax?.toLocaleString()}</div>
|
||
</div>
|
||
</div>
|
||
|
||
{comp.budgetStatus && (
|
||
<div className={`text-sm mb-3 ${comp.withinBudget ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'}`}>
|
||
{comp.budgetStatus}
|
||
</div>
|
||
)}
|
||
|
||
<div className="text-xs text-slate-600 dark:text-slate-400 mb-2">Resort Features:</div>
|
||
<div className="flex flex-wrap gap-2 text-xs">
|
||
<span className="badge">Beach: {comp.resort.features.beach}</span>
|
||
<span className="badge">Pool: {comp.resort.features.pool}</span>
|
||
<span className="badge">Food: {comp.resort.features.food}</span>
|
||
<span className="badge">Spa: {comp.resort.features.spa}</span>
|
||
<span className="badge">Nightlife: {comp.resort.features.nightlife}</span>
|
||
<span className="badge">Golf: {comp.resort.features.golf}</span>
|
||
</div>
|
||
|
||
<div className="mt-3 flex gap-2">
|
||
<a href={comp.flightLinks.skyscanner} target="_blank" rel="noopener" className="link text-xs">Skyscanner</a>
|
||
<a href={comp.flightLinks.googleFlights} target="_blank" rel="noopener" className="link text-xs">Google Flights</a>
|
||
<a href={comp.flightLinks.airCanada} target="_blank" rel="noopener" className="link text-xs">Air Canada</a>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</Section>
|
||
</>
|
||
)}
|
||
</main>
|
||
);
|
||
}
|