mirror of
https://github.com/mblanke/holiday-travel-app.git
synced 2026-03-01 05:20:22 -05:00
Add resort comparison tab to web UI with preference sliders and budget filtering
This commit is contained in:
36
.next/trace
36
.next/trace
File diff suppressed because one or more lines are too long
226
app/page.tsx
226
app/page.tsx
@@ -9,6 +9,8 @@ type Result = {
|
||||
};
|
||||
|
||||
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));
|
||||
@@ -25,6 +27,19 @@ export default function Page() {
|
||||
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);
|
||||
@@ -64,9 +79,80 @@ export default function Page() {
|
||||
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">
|
||||
<Section title="Your trip idea">
|
||||
{/* 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>
|
||||
@@ -175,6 +261,144 @@ export default function Page() {
|
||||
));
|
||||
})()}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user