Files
holiday-travel-app/app/page.tsx

405 lines
18 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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>
);
}