Initial commit: Holiday Travel App with resort comparison, trip management, and multi-provider search

This commit is contained in:
2025-10-29 16:22:35 -04:00
commit 74f8e268c3
167 changed files with 18721 additions and 0 deletions

View 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
View 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
View 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" }
});
}
}