mirror of
https://github.com/mblanke/holiday-travel-app.git
synced 2026-03-01 05:20:22 -05:00
Initial commit: Holiday Travel App with resort comparison, trip management, and multi-provider search
This commit is contained in:
210
app/api/resort-compare/route.ts
Normal file
210
app/api/resort-compare/route.ts
Normal file
@@ -0,0 +1,210 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { findResort, calculateResortScore, type Resort } from "@/lib/resorts";
|
||||
import { format, addDays } from "date-fns";
|
||||
|
||||
const schema = z.object({
|
||||
resorts: z.array(z.string()).nonempty(),
|
||||
departureDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
|
||||
origin: z.string().min(3).max(4).default("YOW"),
|
||||
tripLength: z.number().int().positive().default(7),
|
||||
budget: z.number().positive().optional(),
|
||||
contingencyPercent: z.number().min(0).max(50).optional().default(15), // Allow up to 15% over budget for high scores
|
||||
preferences: z.object({
|
||||
beach: z.number().min(0).max(10).optional(),
|
||||
pool: z.number().min(0).max(10).optional(),
|
||||
golf: z.number().min(0).max(10).optional(),
|
||||
spa: z.number().min(0).max(10).optional(),
|
||||
food: z.number().min(0).max(10).optional(),
|
||||
nightlife: z.number().min(0).max(10).optional(),
|
||||
shopping: z.number().min(0).max(10).optional(),
|
||||
culture: z.number().min(0).max(10).optional(),
|
||||
outdoor: z.number().min(0).max(10).optional(),
|
||||
family: z.number().min(0).max(10).optional(),
|
||||
}).optional()
|
||||
});
|
||||
|
||||
type ResortComparison = {
|
||||
resort: Resort;
|
||||
matchScore: number;
|
||||
flightLinks: {
|
||||
skyscanner: string;
|
||||
googleFlights: string;
|
||||
airCanada: string;
|
||||
};
|
||||
estimatedFlightPrice?: string;
|
||||
estimatedFlightPriceMin?: number;
|
||||
estimatedFlightPriceMax?: number;
|
||||
estimatedTotalMin?: number;
|
||||
estimatedTotalMax?: number;
|
||||
withinBudget?: boolean;
|
||||
budgetStatus?: string;
|
||||
overBudgetAmount?: number;
|
||||
};
|
||||
|
||||
function buildFlightLinks(origin: string, destination: string, departureDate: string, returnDate: string) {
|
||||
const depFormatted = departureDate.replace(/-/g, "");
|
||||
const retFormatted = returnDate.replace(/-/g, "");
|
||||
|
||||
return {
|
||||
skyscanner: `https://www.skyscanner.ca/transport/flights/${origin.toLowerCase()}/${destination.toLowerCase()}/${depFormatted}/${retFormatted}/`,
|
||||
googleFlights: `https://www.google.com/travel/flights?q=${encodeURIComponent(`flights from ${origin} to ${destination} ${departureDate} to ${returnDate}`)}`,
|
||||
airCanada: `https://www.aircanada.com/ca/en/aco/home/book/travel.html?${new URLSearchParams({
|
||||
org1: origin.toUpperCase(),
|
||||
dest1: destination.toUpperCase(),
|
||||
departureDate1: departureDate,
|
||||
returnDate1: returnDate,
|
||||
tripType: "2",
|
||||
lang: "en-CA"
|
||||
}).toString()}`
|
||||
};
|
||||
}
|
||||
|
||||
function estimateResortCost(priceRange: string, nights: number): { min: number; max: number } {
|
||||
// CANADIAN ALL-INCLUSIVE PACKAGE PRICING (per person, CAD)
|
||||
// Based on real travel agent quotes for 7-night packages from Ottawa
|
||||
// Includes: flights, accommodations, transfers, taxes
|
||||
const packageRanges: Record<string, { min: number; max: number }> = {
|
||||
"$": { min: 1200, max: 1600 }, // Budget packages
|
||||
"$$": { min: 1600, max: 2000 }, // Mid-range packages
|
||||
"$$$": { min: 2000, max: 2400 }, // Upper mid-range
|
||||
"$$$$": { min: 2400, max: 2800 }, // Premium (Dreams Onyx: $2509)
|
||||
"$$$$$": { min: 2800, max: 3500 } // Luxury packages
|
||||
};
|
||||
|
||||
// Adjust for trip length (base is 7 nights)
|
||||
const baseDays = 7;
|
||||
const lengthMultiplier = nights / baseDays;
|
||||
|
||||
const range = packageRanges[priceRange] || packageRanges["$$$"];
|
||||
return {
|
||||
min: Math.round(range.min * lengthMultiplier),
|
||||
max: Math.round(range.max * lengthMultiplier)
|
||||
};
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const body = await req.json();
|
||||
const parsed = schema.parse(body);
|
||||
|
||||
const comparisons: ResortComparison[] = [];
|
||||
const notFound: string[] = [];
|
||||
|
||||
// Calculate return date
|
||||
const returnDate = format(addDays(new Date(parsed.departureDate), parsed.tripLength), "yyyy-MM-dd");
|
||||
|
||||
for (const resortName of parsed.resorts) {
|
||||
const resort = findResort(resortName);
|
||||
|
||||
if (!resort) {
|
||||
notFound.push(resortName);
|
||||
continue;
|
||||
}
|
||||
|
||||
const matchScore = calculateResortScore(resort, parsed.preferences);
|
||||
const flightLinks = buildFlightLinks(parsed.origin, resort.airportCode, parsed.departureDate, returnDate);
|
||||
|
||||
// Package pricing (per person, includes flights)
|
||||
const packageCost = estimateResortCost(resort.priceRange || "$$$", parsed.tripLength);
|
||||
|
||||
// Total cost for 2 people (standard package assumption)
|
||||
const estimatedTotalMin = packageCost.min * 2;
|
||||
const estimatedTotalMax = packageCost.max * 2;
|
||||
|
||||
// Per person cost for display
|
||||
const estimatedPerPerson = `$${packageCost.min}-$${packageCost.max} per person`;
|
||||
|
||||
|
||||
// Budget analysis
|
||||
let withinBudget = true;
|
||||
let budgetStatus = "Within Budget";
|
||||
let overBudgetAmount = 0;
|
||||
|
||||
if (parsed.budget) {
|
||||
const contingencyAmount = parsed.budget * ((parsed.contingencyPercent || 15) / 100);
|
||||
const budgetWithContingency = parsed.budget + contingencyAmount;
|
||||
|
||||
if (estimatedTotalMin > parsed.budget) {
|
||||
overBudgetAmount = estimatedTotalMin - parsed.budget;
|
||||
|
||||
// Check if high score justifies going over budget
|
||||
if (matchScore >= 1800 && estimatedTotalMin <= budgetWithContingency) {
|
||||
withinBudget = true;
|
||||
budgetStatus = `Over budget by $${overBudgetAmount.toFixed(0)} but HIGH MATCH (within ${parsed.contingencyPercent}% contingency)`;
|
||||
} else if (estimatedTotalMin <= budgetWithContingency) {
|
||||
withinBudget = true;
|
||||
budgetStatus = `Within ${parsed.contingencyPercent}% contingency (+$${overBudgetAmount.toFixed(0)})`;
|
||||
} else {
|
||||
withinBudget = false;
|
||||
budgetStatus = `Over budget by $${overBudgetAmount.toFixed(0)} (exceeds contingency)`;
|
||||
}
|
||||
} else {
|
||||
budgetStatus = `Under budget by $${(parsed.budget - estimatedTotalMin).toFixed(0)}`;
|
||||
}
|
||||
}
|
||||
|
||||
comparisons.push({
|
||||
resort,
|
||||
matchScore,
|
||||
flightLinks,
|
||||
estimatedFlightPrice: estimatedPerPerson,
|
||||
estimatedFlightPriceMin: packageCost.min,
|
||||
estimatedFlightPriceMax: packageCost.max,
|
||||
estimatedTotalMin,
|
||||
estimatedTotalMax,
|
||||
withinBudget,
|
||||
budgetStatus,
|
||||
overBudgetAmount: overBudgetAmount > 0 ? overBudgetAmount : undefined
|
||||
});
|
||||
}
|
||||
|
||||
// Sort by match score (highest first)
|
||||
comparisons.sort((a, b) => b.matchScore - a.matchScore);
|
||||
|
||||
// Filter by budget if specified (but include high-scoring resorts within contingency)
|
||||
let budgetFiltered = comparisons;
|
||||
if (parsed.budget) {
|
||||
budgetFiltered = comparisons.filter(c => c.withinBudget);
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify({
|
||||
comparisons: budgetFiltered,
|
||||
allComparisons: comparisons, // Include all for reference
|
||||
notFound,
|
||||
budgetInfo: parsed.budget ? {
|
||||
budget: parsed.budget,
|
||||
contingencyPercent: parsed.contingencyPercent || 15,
|
||||
contingencyAmount: parsed.budget * ((parsed.contingencyPercent || 15) / 100),
|
||||
maxBudget: parsed.budget + (parsed.budget * ((parsed.contingencyPercent || 15) / 100)),
|
||||
withinBudgetCount: budgetFiltered.length,
|
||||
totalCount: comparisons.length,
|
||||
overBudgetCount: comparisons.length - budgetFiltered.length
|
||||
} : null,
|
||||
flightInfo: {
|
||||
origin: parsed.origin,
|
||||
departureDate: parsed.departureDate,
|
||||
returnDate,
|
||||
tripLength: parsed.tripLength
|
||||
},
|
||||
packageSites: [
|
||||
"Sunwing Vacations",
|
||||
"Air Canada Vacations",
|
||||
"WestJet Vacations",
|
||||
"Air Transat Holidays",
|
||||
"RedTag.ca"
|
||||
],
|
||||
pricingNote: "Estimates are for individual bookings (flight + resort). Package deals are typically 30-40% cheaper."
|
||||
}), {
|
||||
headers: { "content-type": "application/json" }
|
||||
});
|
||||
|
||||
} catch (e: any) {
|
||||
return new Response(JSON.stringify({
|
||||
error: e?.message || "Invalid payload"
|
||||
}), {
|
||||
status: 400,
|
||||
headers: { "content-type": "application/json" }
|
||||
});
|
||||
}
|
||||
}
|
||||
82
app/api/search/route.ts
Normal file
82
app/api/search/route.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { z } from "zod";
|
||||
import type { SearchCriteria, Deal } from "@/lib/types";
|
||||
import { fetchCityDeals } from "@/lib/providers/yowDeals";
|
||||
import { buildSkyscannerLinks, buildGoogleFlightsLinks, buildAirCanadaLinks, buildAirTransatLinks } from "@/lib/providers/linkBuilders";
|
||||
import { scoreDeal } from "@/lib/score";
|
||||
|
||||
const schema = z.object({
|
||||
origin: z.string().min(3).max(4),
|
||||
destinations: z.array(z.string().min(3)).nonempty(),
|
||||
startDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
|
||||
endDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
|
||||
tripLengthMin: z.number().int().positive(),
|
||||
tripLengthMax: z.number().int().positive(),
|
||||
budget: z.number().int().positive().optional().nullable(),
|
||||
currency: z.string().default("CAD").optional(),
|
||||
nonStopOnly: z.boolean().optional(),
|
||||
sources: z.array(z.string()).optional(),
|
||||
preferences: z.object({
|
||||
beach: z.number().min(0).max(10).optional(),
|
||||
pool: z.number().min(0).max(10).optional(),
|
||||
golf: z.number().min(0).max(10).optional(),
|
||||
spa: z.number().min(0).max(10).optional(),
|
||||
food: z.number().min(0).max(10).optional(),
|
||||
nightlife: z.number().min(0).max(10).optional(),
|
||||
shopping: z.number().min(0).max(10).optional(),
|
||||
culture: z.number().min(0).max(10).optional(),
|
||||
outdoor: z.number().min(0).max(10).optional(),
|
||||
family: z.number().min(0).max(10).optional(),
|
||||
}).optional()
|
||||
});
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const body = await req.json();
|
||||
const parsed = schema.parse(body) as SearchCriteria;
|
||||
const sources = (parsed.sources && parsed.sources.length ? parsed.sources : ["Deals", "Skyscanner", "GoogleFlights", "AirCanada", "AirTransat"]).map(s => s.toLowerCase());
|
||||
|
||||
const tasks: Promise<Deal[]>[] = [];
|
||||
|
||||
if (sources.includes("deals")) tasks.push(fetchCityDeals(parsed));
|
||||
if (sources.includes("skyscanner")) tasks.push(buildSkyscannerLinks(parsed));
|
||||
if (sources.includes("googleflights")) tasks.push(buildGoogleFlightsLinks(parsed));
|
||||
if (sources.includes("aircanada")) tasks.push(buildAirCanadaLinks(parsed));
|
||||
if (sources.includes("airtransat")) tasks.push(buildAirTransatLinks(parsed));
|
||||
|
||||
// Demo mode fallback
|
||||
const demo = process.env.DEMO === "true";
|
||||
|
||||
let results: Deal[] = [];
|
||||
if (demo) {
|
||||
results = [
|
||||
{ id: "demo1", title: "🔥 Ottawa → Cancun (7 nights)", source: "Demo", link: "#", price: 685, currency: "CAD", startDate: parsed.startDate, endDate: parsed.startDate, nights: 7, origin: parsed.origin, destination: parsed.destinations[0], stops: 0 },
|
||||
{ id: "demo2", title: "Ottawa → Punta Cana (7 nights)", source: "Demo", link: "#", price: 712, currency: "CAD", startDate: parsed.startDate, endDate: parsed.startDate, nights: 7, origin: parsed.origin, destination: parsed.destinations[0], stops: 1 }
|
||||
];
|
||||
} else {
|
||||
const settled = await Promise.allSettled(tasks);
|
||||
for (const s of settled) {
|
||||
if (s.status === "fulfilled") results.push(...s.value);
|
||||
}
|
||||
}
|
||||
|
||||
// Filter and score
|
||||
if (parsed.budget) results = results.filter(r => !r.price || r.price <= (parsed.budget || 0));
|
||||
if (parsed.nonStopOnly) results = results.filter(r => r.stops == null || r.stops === 0);
|
||||
|
||||
results.forEach(r => r.score = scoreDeal(r, parsed.preferences));
|
||||
results.sort((a,b) => (b.score || 0) - (a.score || 0));
|
||||
|
||||
// Optional n8n webhook
|
||||
const webhook = process.env.N8N_WEBHOOK_URL;
|
||||
if (webhook) {
|
||||
try {
|
||||
await fetch(webhook, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ criteria: parsed, top10: results.slice(0,10) }) });
|
||||
} catch(e) { /* ignore */ }
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify({ results }), { headers: { "content-type": "application/json" } });
|
||||
} catch (e: any) {
|
||||
return new Response(JSON.stringify({ error: e?.message || "Invalid payload" }), { status: 400, headers: { "content-type": "application/json" } });
|
||||
}
|
||||
}
|
||||
63
app/api/trips/route.ts
Normal file
63
app/api/trips/route.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { getAllTrips, getTripById, getTripsByStatus, getTripsByRegion } from "@/lib/trips";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(req.url);
|
||||
const id = searchParams.get("id");
|
||||
const status = searchParams.get("status");
|
||||
const region = searchParams.get("region");
|
||||
|
||||
// Get specific trip by ID
|
||||
if (id) {
|
||||
const trip = getTripById(id);
|
||||
if (!trip) {
|
||||
return new Response(JSON.stringify({ error: "Trip not found" }), {
|
||||
status: 404,
|
||||
headers: { "content-type": "application/json" }
|
||||
});
|
||||
}
|
||||
return new Response(JSON.stringify({ trip }), {
|
||||
headers: { "content-type": "application/json" }
|
||||
});
|
||||
}
|
||||
|
||||
// Filter by status
|
||||
if (status) {
|
||||
const trips = getTripsByStatus(status as any);
|
||||
return new Response(JSON.stringify({ trips, count: trips.length }), {
|
||||
headers: { "content-type": "application/json" }
|
||||
});
|
||||
}
|
||||
|
||||
// Filter by region
|
||||
if (region) {
|
||||
const trips = getTripsByRegion(region);
|
||||
return new Response(JSON.stringify({ trips, count: trips.length }), {
|
||||
headers: { "content-type": "application/json" }
|
||||
});
|
||||
}
|
||||
|
||||
// Get all trips
|
||||
const trips = getAllTrips();
|
||||
return new Response(JSON.stringify({
|
||||
trips,
|
||||
count: trips.length,
|
||||
byStatus: {
|
||||
planning: getTripsByStatus("planning").length,
|
||||
booked: getTripsByStatus("booked").length,
|
||||
completed: getTripsByStatus("completed").length
|
||||
}
|
||||
}), {
|
||||
headers: { "content-type": "application/json" }
|
||||
});
|
||||
|
||||
} catch (e: any) {
|
||||
return new Response(JSON.stringify({
|
||||
error: e?.message || "Failed to retrieve trips"
|
||||
}), {
|
||||
status: 500,
|
||||
headers: { "content-type": "application/json" }
|
||||
});
|
||||
}
|
||||
}
|
||||
26
app/layout.tsx
Normal file
26
app/layout.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import "../styles/globals.css";
|
||||
import type { Metadata } from "next";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Holiday Deal Finder",
|
||||
description: "Search multiple sites at once for the best dates and fares",
|
||||
};
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body>
|
||||
<div className="container max-w-6xl py-6 md:py-10">
|
||||
<header className="mb-6 md:mb-10">
|
||||
<h1 className="text-2xl md:text-3xl font-extrabold tracking-tight">Holiday Deal Finder</h1>
|
||||
<p className="opacity-80">Point it at a few destinations and a date range; it builds smart links and pulls curated deal posts.</p>
|
||||
</header>
|
||||
{children}
|
||||
<footer className="mt-10 text-sm opacity-70">
|
||||
Built for your Holiday Travel App workflow. No scraping of paywalled content; links open official search UIs.
|
||||
</footer>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
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