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:
26
lib/date.ts
Normal file
26
lib/date.ts
Normal 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
2
lib/providers/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./yowDeals";
|
||||
export * from "./linkBuilders";
|
||||
125
lib/providers/linkBuilders.ts
Normal file
125
lib/providers/linkBuilders.ts
Normal 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
42
lib/providers/yowDeals.ts
Normal 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
362
lib/resorts.ts
Normal 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
70
lib/score.ts
Normal 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
106
lib/trips.ts
Normal 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
43
lib/types.ts
Normal 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;
|
||||
};
|
||||
Reference in New Issue
Block a user