mirror of
https://github.com/mblanke/holiday-travel-app.git
synced 2026-03-01 13:30:20 -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" }
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user