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

26
lib/date.ts Normal file
View File

@@ -0,0 +1,26 @@
import { addDays, differenceInDays, parseISO, format, isBefore } from "date-fns";
export function enumerateDatePairs(startISO: string, endISO: string, minNights: number, maxNights: number, maxPairs = 8) {
const start = parseISO(startISO);
const end = parseISO(endISO);
const pairs: {out: string, back: string, nights: number}[] = [];
let cursor = start;
while (!isBefore(end, cursor) && pairs.length < maxPairs) {
for (let n = minNights; n <= maxNights && pairs.length < maxPairs; n++) {
const back = addDays(cursor, n);
if (isBefore(end, back)) continue;
pairs.push({ out: format(cursor, "yyyyMMdd"), back: format(back, "yyyyMMdd"), nights: n });
}
cursor = addDays(cursor, Math.max(1, Math.floor((differenceInDays(end, start) || 1) / (maxPairs))));
}
if (pairs.length === 0) {
// fallback to exact minNights from start
const back = addDays(start, minNights);
pairs.push({ out: format(start, "yyyyMMdd"), back: format(back, "yyyyMMdd"), nights: minNights });
}
return pairs;
}
export function isoToSkyscanner(iso: string) {
return iso.replace(/-/g, "");
}

2
lib/providers/index.ts Normal file
View File

@@ -0,0 +1,2 @@
export * from "./yowDeals";
export * from "./linkBuilders";

View File

@@ -0,0 +1,125 @@
import type { Deal, SearchCriteria } from "../types";
import { enumerateDatePairs } from "../date";
function toLower(s: string) { return (s||"").toLowerCase(); }
export async function buildSkyscannerLinks(c: SearchCriteria): Promise<Deal[]> {
const origin = toLower(c.origin);
const out: Deal[] = [];
const pairs = enumerateDatePairs(c.startDate, c.endDate, c.tripLengthMin, c.tripLengthMax, 6);
for (const dest of c.destinations) {
for (const p of pairs) {
const path = `https://www.skyscanner.ca/transport/flights/${origin}/${toLower(dest)}/${p.out}/${p.back}/`;
out.push({
id: `sky-${origin}-${dest}-${p.out}-${p.back}`,
title: `${c.origin}${dest} (${p.nights} nights)`,
source: "Skyscanner Link",
link: path,
price: null,
currency: c.currency || "CAD",
startDate: `${p.out.slice(0,4)}-${p.out.slice(4,6)}-${p.out.slice(6,8)}`,
endDate: `${p.back.slice(0,4)}-${p.back.slice(4,6)}-${p.back.slice(6,8)}`,
nights: p.nights,
origin: c.origin,
destination: dest,
});
}
}
return out;
}
export async function buildGoogleFlightsLinks(c: SearchCriteria): Promise<Deal[]> {
const out: Deal[] = [];
const pairs = enumerateDatePairs(c.startDate, c.endDate, c.tripLengthMin, c.tripLengthMax, 4);
for (const dest of c.destinations) {
for (const p of pairs) {
const q = encodeURIComponent(`flights from ${c.origin} to ${dest} ${p.out} to ${p.back}`);
const link = `https://www.google.com/travel/flights?q=${q}`;
out.push({
id: `gfl-${c.origin}-${dest}-${p.out}-${p.back}`,
title: `Google Flights: ${c.origin}${dest} (${p.nights} nights)`,
source: "Google Flights Link",
link,
price: null,
currency: c.currency || "CAD",
startDate: `${p.out.slice(0,4)}-${p.out.slice(4,6)}-${p.out.slice(6,8)}`,
endDate: `${p.back.slice(0,4)}-${p.back.slice(4,6)}-${p.back.slice(6,8)}`,
nights: p.nights,
origin: c.origin,
destination: dest,
});
}
}
return out;
}
export async function buildAirCanadaLinks(c: SearchCriteria): Promise<Deal[]> {
const out: Deal[] = [];
const pairs = enumerateDatePairs(c.startDate, c.endDate, c.tripLengthMin, c.tripLengthMax, 3);
for (const dest of c.destinations) {
for (const p of pairs) {
const sd = `${p.out.slice(0,4)}-${p.out.slice(4,6)}-${p.out.slice(6,8)}`;
const ed = `${p.back.slice(0,4)}-${p.back.slice(4,6)}-${p.back.slice(6,8)}`;
const params = new URLSearchParams({
org1: c.origin.toUpperCase(),
dest1: dest.toUpperCase(),
departureDate1: sd,
returnDate1: ed,
tripType: "2",
lang: "en-CA"
});
const link = `https://www.aircanada.com/ca/en/aco/home/book/travel.html?${params.toString()}`;
out.push({
id: `ac-${c.origin}-${dest}-${p.out}-${p.back}`,
title: `Air Canada: ${c.origin}${dest} (${p.nights} nights)`,
source: "Air Canada Link",
link,
price: null,
currency: c.currency || "CAD",
startDate: sd,
endDate: ed,
nights: p.nights,
origin: c.origin,
destination: dest,
});
}
}
return out;
}
export async function buildAirTransatLinks(c: SearchCriteria): Promise<Deal[]> {
const out: Deal[] = [];
const pairs = enumerateDatePairs(c.startDate, c.endDate, c.tripLengthMin, c.tripLengthMax, 3);
for (const dest of c.destinations) {
for (const p of pairs) {
const sd = `${p.out.slice(0,4)}-${p.out.slice(4,6)}-${p.out.slice(6,8)}`;
const ed = `${p.back.slice(0,4)}-${p.back.slice(4,6)}-${p.back.slice(6,8)}`;
// Air Transat vacation packages search
const params = new URLSearchParams({
depCity: c.origin.toUpperCase(),
destCode: dest.toUpperCase(),
depDate: sd,
retDate: ed,
adults: "2",
children: "0",
infants: "0"
});
const link = `https://www.airtransat.com/en-CA/flights-hotels?${params.toString()}`;
out.push({
id: `at-${c.origin}-${dest}-${p.out}-${p.back}`,
title: `Air Transat: ${c.origin}${dest} (${p.nights} nights)`,
source: "Air Transat Link",
link,
price: null,
currency: c.currency || "CAD",
startDate: sd,
endDate: ed,
nights: p.nights,
origin: c.origin,
destination: dest,
});
}
}
return out;
}

42
lib/providers/yowDeals.ts Normal file
View File

@@ -0,0 +1,42 @@
import * as cheerio from "cheerio";
import type { Deal, SearchCriteria } from "../types";
const SITE_BY_ORIGIN: Record<string, string> = {
"YOW": "https://www.yowdeals.com",
"YYZ": "https://www.yyzdeals.com",
"YUL": "https://www.yuldeals.com",
"YVR": "https://www.yvrdeals.com",
"YYC": "https://www.yycdeals.com",
"YEG": "https://www.yegdeals.com",
};
export async function fetchCityDeals(criteria: SearchCriteria): Promise<Deal[]> {
const site = SITE_BY_ORIGIN[(criteria.origin || "").toUpperCase()] || SITE_BY_ORIGIN["YOW"];
try {
const res = await fetch(site, { cache: "no-store" });
const html = await res.text();
const $ = cheerio.load(html);
const deals: Deal[] = [];
$("h2.post-title a, .post h2 a, .post-title a").each((_, el) => {
const title = $(el).text().trim();
const link = $(el).attr("href") || site;
const priceMatch = title.match(/\$\s?(\d+[\,\d+]*)/);
const price = priceMatch ? parseInt(priceMatch[1].replace(/,/g, "")) : null;
const id = `dealsite-${Buffer.from(link).toString("base64").slice(0,16)}`;
deals.push({
id,
title,
source: new URL(site).host.replace("www.",""),
link,
price,
currency: "CAD",
origin: criteria.origin.toUpperCase(),
});
});
return deals.slice(0, 20); // don't flood
} catch (e) {
return [];
}
}

362
lib/resorts.ts Normal file
View File

@@ -0,0 +1,362 @@
// Resort database with features and ratings
export type Resort = {
name: string;
destination: string;
country: string;
airportCode: string;
features: {
beach: number;
pool: number;
golf: number;
spa: number;
food: number;
nightlife: number;
shopping: number;
culture: number;
outdoor: number;
family: number;
};
tripAdvisorRating?: number;
tripAdvisorReviews?: number;
priceRange?: string; // $$$, $$$$, etc.
allInclusive: boolean;
};
// Resort database
export const RESORT_DATABASE: Record<string, Resort> = {
"bahia principe luxury sian ka'an": {
name: "Bahia Principe Luxury Sian Ka'an",
destination: "Riviera Maya",
country: "Mexico",
airportCode: "CUN",
features: {
beach: 9,
pool: 8,
golf: 5,
spa: 8,
food: 8,
nightlife: 6,
shopping: 5,
culture: 6,
outdoor: 7,
family: 9
},
tripAdvisorRating: 4.5,
tripAdvisorReviews: 8234,
priceRange: "$$$$",
allInclusive: true
},
"catalonia royal tulum": {
name: "Catalonia Royal Tulum",
destination: "Tulum",
country: "Mexico",
airportCode: "CUN",
features: {
beach: 10,
pool: 9,
golf: 4,
spa: 7,
food: 8,
nightlife: 6,
shopping: 4,
culture: 7,
outdoor: 8,
family: 7
},
tripAdvisorRating: 4.5,
tripAdvisorReviews: 6421,
priceRange: "$$$$",
allInclusive: true
},
"secrets akumal riviera maya": {
name: "Secrets Akumal Riviera Maya",
destination: "Akumal",
country: "Mexico",
airportCode: "CUN",
features: {
beach: 10,
pool: 9,
golf: 5,
spa: 9,
food: 9,
nightlife: 7,
shopping: 5,
culture: 6,
outdoor: 9,
family: 6
},
tripAdvisorRating: 4.5,
tripAdvisorReviews: 9127,
priceRange: "$$$$$",
allInclusive: true
},
"unico 20°87° hotel riviera maya": {
name: "UNICO 20°87° Hotel Riviera Maya",
destination: "Riviera Maya",
country: "Mexico",
airportCode: "CUN",
features: {
beach: 9,
pool: 10,
golf: 6,
spa: 10,
food: 10,
nightlife: 8,
shopping: 6,
culture: 7,
outdoor: 8,
family: 7
},
tripAdvisorRating: 5.0,
tripAdvisorReviews: 3894,
priceRange: "$$$$$",
allInclusive: true
},
"trs yucatán hotel": {
name: "TRS Yucatán Hotel",
destination: "Riviera Maya",
country: "Mexico",
airportCode: "CUN",
features: {
beach: 9,
pool: 9,
golf: 7,
spa: 8,
food: 8,
nightlife: 7,
shopping: 5,
culture: 5,
outdoor: 7,
family: 8
},
tripAdvisorRating: 4.5,
tripAdvisorReviews: 5621,
priceRange: "$$$$",
allInclusive: true
},
"barcelo maya riviera": {
name: "Barcelo Maya Riviera",
destination: "Riviera Maya",
country: "Mexico",
airportCode: "CUN",
features: {
beach: 9,
pool: 9,
golf: 8,
spa: 7,
food: 8,
nightlife: 7,
shopping: 6,
culture: 5,
outdoor: 7,
family: 9
},
tripAdvisorRating: 4.5,
tripAdvisorReviews: 12453,
priceRange: "$$$",
allInclusive: true
},
"valentin imperial riviera maya": {
name: "Valentin Imperial Riviera Maya",
destination: "Playa del Carmen",
country: "Mexico",
airportCode: "CUN",
features: {
beach: 10,
pool: 8,
golf: 5,
spa: 8,
food: 9,
nightlife: 6,
shopping: 5,
culture: 5,
outdoor: 7,
family: 7
},
tripAdvisorRating: 4.5,
tripAdvisorReviews: 8934,
priceRange: "$$$$",
allInclusive: true
},
"grand sirenis riviera maya": {
name: "Grand Sirenis Riviera Maya",
destination: "Riviera Maya",
country: "Mexico",
airportCode: "CUN",
features: {
beach: 8,
pool: 8,
golf: 6,
spa: 7,
food: 7,
nightlife: 6,
shopping: 5,
culture: 5,
outdoor: 7,
family: 9
},
tripAdvisorRating: 4.0,
tripAdvisorReviews: 7821,
priceRange: "$$$",
allInclusive: true
},
"dreams tulum resort & spa": {
name: "Dreams Tulum Resort & Spa",
destination: "Tulum",
country: "Mexico",
airportCode: "CUN",
features: {
beach: 10,
pool: 8,
golf: 4,
spa: 8,
food: 8,
nightlife: 6,
shopping: 4,
culture: 7,
outdoor: 8,
family: 8
},
tripAdvisorRating: 4.5,
tripAdvisorReviews: 6754,
priceRange: "$$$$",
allInclusive: true
},
"hyatt zilara cap cana": {
name: "Hyatt Zilara Cap Cana",
destination: "Cap Cana",
country: "Dominican Republic",
airportCode: "PUJ",
features: {
beach: 10,
pool: 9,
golf: 8,
spa: 9,
food: 9,
nightlife: 8,
shopping: 6,
culture: 5,
outdoor: 8,
family: 5
},
tripAdvisorRating: 5.0,
tripAdvisorReviews: 9821,
priceRange: "$$$$$",
allInclusive: true
},
"secrets cap cana": {
name: "Secrets Cap Cana",
destination: "Cap Cana",
country: "Dominican Republic",
airportCode: "PUJ",
features: {
beach: 10,
pool: 9,
golf: 9,
spa: 9,
food: 9,
nightlife: 8,
shopping: 6,
culture: 5,
outdoor: 8,
family: 6
},
tripAdvisorRating: 4.5,
tripAdvisorReviews: 8432,
priceRange: "$$$$$",
allInclusive: true
},
"dreams onyx resort & spa": {
name: "Dreams Onyx Resort & Spa",
destination: "Punta Cana",
country: "Dominican Republic",
airportCode: "PUJ",
features: {
beach: 9,
pool: 9,
golf: 6,
spa: 8,
food: 8,
nightlife: 7,
shopping: 6,
culture: 4,
outdoor: 7,
family: 8
},
tripAdvisorRating: 4.5,
tripAdvisorReviews: 5643,
priceRange: "$$$$",
allInclusive: true
},
"royalton chic punta cana": {
name: "Royalton CHIC Punta Cana",
destination: "Punta Cana",
country: "Dominican Republic",
airportCode: "PUJ",
features: {
beach: 9,
pool: 9,
golf: 6,
spa: 8,
food: 8,
nightlife: 9,
shopping: 7,
culture: 4,
outdoor: 7,
family: 5
},
tripAdvisorRating: 4.5,
tripAdvisorReviews: 7234,
priceRange: "$$$$",
allInclusive: true
},
"iberostar grand bávaro": {
name: "Iberostar Grand Bávaro",
destination: "Bávaro",
country: "Dominican Republic",
airportCode: "PUJ",
features: {
beach: 10,
pool: 8,
golf: 7,
spa: 8,
food: 8,
nightlife: 7,
shopping: 6,
culture: 4,
outdoor: 7,
family: 7
},
tripAdvisorRating: 4.5,
tripAdvisorReviews: 6821,
priceRange: "$$$$",
allInclusive: true
}
};
export function findResort(name: string): Resort | null {
const normalized = name.toLowerCase().trim();
return RESORT_DATABASE[normalized] || null;
}
export function calculateResortScore(resort: Resort, preferences?: Record<string, number>): number {
if (!preferences) return 0;
let score = 0;
let totalWeight = 0;
for (const [feature, userRating] of Object.entries(preferences)) {
if (userRating && userRating > 0) {
const resortRating = resort.features[feature as keyof typeof resort.features] || 5;
score += (userRating * resortRating);
totalWeight += (userRating * 10);
}
}
// Add TripAdvisor bonus
if (resort.tripAdvisorRating) {
score += resort.tripAdvisorRating * 100;
}
return totalWeight > 0 ? Math.round((score / totalWeight) * 1000) : 0;
}

70
lib/score.ts Normal file
View File

@@ -0,0 +1,70 @@
import type { Deal, TravelPreferences } from "./types";
// Destination feature profiles (0-10 ratings for common destinations)
const DESTINATION_PROFILES: Record<string, TravelPreferences> = {
// Caribbean
"CUN": { beach: 10, pool: 9, golf: 6, spa: 8, food: 8, nightlife: 9, shopping: 7, culture: 5, outdoor: 8, family: 9 },
"PUJ": { beach: 10, pool: 9, golf: 7, spa: 8, food: 7, nightlife: 8, shopping: 6, culture: 4, outdoor: 7, family: 9 },
"MBJ": { beach: 10, pool: 8, golf: 5, spa: 7, food: 8, nightlife: 7, shopping: 5, culture: 6, outdoor: 9, family: 8 },
"NAS": { beach: 9, pool: 8, golf: 6, spa: 6, food: 7, nightlife: 8, shopping: 7, culture: 5, outdoor: 8, family: 8 },
// Europe
"LHR": { beach: 2, pool: 5, golf: 6, spa: 7, food: 9, nightlife: 8, shopping: 10, culture: 10, outdoor: 6, family: 7 },
"CDG": { beach: 1, pool: 6, golf: 5, spa: 8, food: 10, nightlife: 9, shopping: 10, culture: 10, outdoor: 5, family: 7 },
"FCO": { beach: 3, pool: 6, golf: 4, spa: 7, food: 10, nightlife: 8, shopping: 9, culture: 10, outdoor: 7, family: 8 },
"BCN": { beach: 8, pool: 7, golf: 5, spa: 7, food: 9, nightlife: 10, shopping: 8, culture: 9, outdoor: 7, family: 7 },
"AMS": { beach: 1, pool: 5, golf: 4, spa: 6, food: 8, nightlife: 9, shopping: 8, culture: 9, outdoor: 6, family: 7 },
// USA
"LAX": { beach: 8, pool: 7, golf: 7, spa: 8, food: 9, nightlife: 9, shopping: 9, culture: 8, outdoor: 8, family: 7 },
"LAS": { beach: 0, pool: 9, golf: 8, spa: 10, food: 9, nightlife: 10, shopping: 10, culture: 5, outdoor: 5, family: 6 },
"MIA": { beach: 9, pool: 8, golf: 7, spa: 8, food: 8, nightlife: 10, shopping: 9, culture: 7, outdoor: 8, family: 7 },
"MCO": { beach: 5, pool: 10, golf: 8, spa: 7, food: 7, nightlife: 7, shopping: 8, culture: 6, outdoor: 6, family: 10 },
"HNL": { beach: 10, pool: 8, golf: 9, spa: 8, food: 8, nightlife: 7, shopping: 7, culture: 8, outdoor: 10, family: 9 },
// Default fallback
"DEFAULT": { beach: 5, pool: 5, golf: 5, spa: 5, food: 5, nightlife: 5, shopping: 5, culture: 5, outdoor: 5, family: 5 }
};
function calculatePreferenceMatch(deal: Deal, preferences?: TravelPreferences): number {
if (!preferences || !deal.destination) return 0;
const destProfile = DESTINATION_PROFILES[deal.destination.toUpperCase()] || DESTINATION_PROFILES["DEFAULT"];
let matchScore = 0;
let totalWeight = 0;
// Calculate how well the destination matches user preferences
for (const [feature, userRating] of Object.entries(preferences)) {
if (userRating && userRating > 0) {
const destRating = destProfile[feature as keyof TravelPreferences] || 5;
// Higher user rating + higher destination rating = better match
matchScore += (userRating * destRating);
totalWeight += (userRating * 10); // max possible for this feature
}
}
// Normalize to 0-1000 scale
if (totalWeight > 0) {
return (matchScore / totalWeight) * 1000;
}
return 0;
}
export function scoreDeal(d: Deal, preferences?: TravelPreferences): number {
// Very simple scoring: prefer lower price, direct, reasonable nights
let score = 0;
if (typeof d.price === "number") score += Math.max(0, 10000 - d.price);
if (d.stops === 0) score += 500;
if (typeof d.nights === "number") {
const target = 7;
score += Math.max(0, 300 - Math.abs(d.nights - target) * 40);
}
if (d.source.includes("Deals")) score += 150; // curated deal sites
// Add preference matching bonus
const preferenceBonus = calculatePreferenceMatch(d, preferences);
score += preferenceBonus;
return score;
}

106
lib/trips.ts Normal file
View File

@@ -0,0 +1,106 @@
// Trip planning database
export type TripPlan = {
id: number;
name: string;
destination: string;
region: string;
departureDate?: string;
returnDate?: string;
tripLength?: number;
budget?: number;
currency: string;
status: "planning" | "booked" | "completed";
preferences?: {
beach?: number;
pool?: number;
golf?: number;
spa?: number;
food?: number;
nightlife?: number;
shopping?: number;
culture?: number;
outdoor?: number;
family?: number;
};
resorts?: string[];
notes?: string;
createdAt: string;
updatedAt: string;
};
// Sample trips
export const TRIP_PLANS: Record<number, TripPlan> = {
1: {
id: 1,
name: "Jan 2026 Caribbean Holiday",
destination: "Mexico & Dominican Republic",
region: "Caribbean",
departureDate: "2026-01-15",
tripLength: 7,
budget: 5500,
currency: "CAD",
status: "planning",
preferences: {
food: 10,
pool: 9,
beach: 8,
nightlife: 7,
golf: 6,
spa: 5,
outdoor: 4
},
resorts: [
"Dreams Onyx Resort & Spa",
"Excellence Riviera Cancun",
"Secrets Maroma Beach",
"Hyatt Ziva Cancun",
"Moon Palace Cancun",
"Barcelo Maya Riviera",
"Grand Sirenis Riviera Maya",
"Hotel Xcaret Mexico"
],
notes: "All-inclusive beach vacation. Prefer adults-only or adult-focused resorts.",
createdAt: "2025-10-29T00:00:00Z",
updatedAt: "2025-10-29T00:00:00Z"
},
2: {
id: 2,
name: "Fall 2026 Japan Adventure",
destination: "Tokyo, Kyoto, Osaka",
region: "Asia",
departureDate: "2026-10-15",
tripLength: 14,
budget: 8000,
currency: "CAD",
status: "planning",
preferences: {
culture: 10,
food: 10,
shopping: 8,
outdoor: 7,
nightlife: 6
},
notes: "Cherry blossom season alternative. Focus on temples, food tours, and cultural experiences.",
createdAt: "2025-10-29T00:00:00Z",
updatedAt: "2025-10-29T00:00:00Z"
}
};
export function getAllTrips(): TripPlan[] {
return Object.values(TRIP_PLANS);
}
export function getTripById(id: number | string): TripPlan | undefined {
const numId = typeof id === 'string' ? parseInt(id, 10) : id;
return TRIP_PLANS[numId];
}
export function getTripsByStatus(status: "planning" | "booked" | "completed"): TripPlan[] {
return Object.values(TRIP_PLANS).filter(trip => trip.status === status);
}
export function getTripsByRegion(region: string): TripPlan[] {
return Object.values(TRIP_PLANS).filter(trip =>
trip.region.toLowerCase().includes(region.toLowerCase())
);
}

43
lib/types.ts Normal file
View File

@@ -0,0 +1,43 @@
export type TravelPreferences = {
beach?: number; // 0-10 rating
pool?: number;
golf?: number;
spa?: number;
food?: number;
nightlife?: number;
shopping?: number;
culture?: number;
outdoor?: number;
family?: number;
};
export type SearchCriteria = {
origin: string;
destinations: string[];
startDate: string; // YYYY-MM-DD
endDate: string; // YYYY-MM-DD
tripLengthMin: number;
tripLengthMax: number;
budget?: number | null;
currency?: string;
nonStopOnly?: boolean;
sources?: string[]; // which providers to include
preferences?: TravelPreferences; // feature importance ratings
};
export type Deal = {
id: string;
title: string;
source: string;
link: string;
price?: number | null;
currency?: string | null;
startDate?: string | null;
endDate?: string | null;
nights?: number | null;
destination?: string | null;
origin?: string | null;
stops?: number | null;
meta?: Record<string, any>;
score?: number;
};