diff --git a/app-9xzmfic2e4g1/supabase/functions/generate-itinerary/index.ts b/app-9xzmfic2e4g1/supabase/functions/generate-itinerary/index.ts index 3dd6c9d..a6ea305 100644 --- a/app-9xzmfic2e4g1/supabase/functions/generate-itinerary/index.ts +++ b/app-9xzmfic2e4g1/supabase/functions/generate-itinerary/index.ts @@ -11,148 +11,255 @@ const corsHeaders = { 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type', } -/** - * Normalize place names for consistent cache lookups. - * Handles Turkish characters, accents, and spelling variations. - */ function normalizePlaceName(name: string): string { return name - .toLowerCase() - .trim() - // Normalize Turkish characters to ASCII equivalents - .replace(/ğ/g, 'g') - .replace(/ü/g, 'u') - .replace(/ş/g, 's') - .replace(/ı/g, 'i') - .replace(/ö/g, 'o') - .replace(/ç/g, 'c') - // Also handle uppercase Turkish characters - .replace(/Ğ/g, 'g') - .replace(/Ü/g, 'u') - .replace(/Ş/g, 's') - .replace(/İ/g, 'i') - .replace(/Ö/g, 'o') - .replace(/Ç/g, 'c') - // Remove extra spaces + .toLowerCase().trim() + .replace(/ğ/g, 'g').replace(/ü/g, 'u').replace(/ş/g, 's') + .replace(/ı/g, 'i').replace(/ö/g, 'o').replace(/ç/g, 'c') + .replace(/Ğ/g, 'g').replace(/Ü/g, 'u').replace(/Ş/g, 's') + .replace(/İ/g, 'i').replace(/Ö/g, 'o').replace(/Ç/g, 'c') .replace(/\s+/g, ' ') - // Normalize common suffix variations (preserve them but ensure consistent spacing) - .replace(/\s*(open air museum|underground city|valley|village|castle|church)\s*$/i, (match) => ' ' + match.trim().toLowerCase()) } -/** - * Generate a mock itinerary when OpenAI is unavailable. - */ -function generateMockItinerary(startDate: string, endDate: string, interests: string[]) { - const start = new Date(startDate); - const end = new Date(endDate); - const diffTime = Math.abs(end.getTime() - start.getTime()); - const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)) + 1; - const numDays = Math.min(diffDays, 14); +// ─── Preference helpers ──────────────────────────────────────────────────────── - const pool = [ - { place_name: "Göreme Open Air Museum", category: "museum", duration: 120, desc: "UNESCO World Heritage site with rock-cut churches." }, - { place_name: "Uçhisar Castle", category: "landmark", duration: 90, desc: "The highest point in Cappadocia with panoramic views." }, - { place_name: "Pasabag (Monks Valley)", category: "nature", duration: 60, desc: "Famous fairy chimneys with multiple caps." }, - { place_name: "Devrent Valley (Imagination Valley)", category: "nature", duration: 45, desc: "Unique rock formations resembling animals." }, - { place_name: "Kaymakli Underground City", category: "history", duration: 90, desc: "Ancient multi-level underground city." }, - { place_name: "Derinkuyu Underground City", category: "history", duration: 120, desc: "The deepest underground city in the region." }, - { place_name: "Ihlara Valley", category: "nature", duration: 180, desc: "A stunning canyon with a river and rock-cut churches." }, - { place_name: "Selime Monastery", category: "history", duration: 60, desc: "The largest religious structure in Cappadocia." }, - { place_name: "Pigeon Valley", category: "nature", duration: 90, desc: "Valley named after the countless man-made dovecotes." }, - { place_name: "Love Valley", category: "nature", duration: 60, desc: "Famous for its phallic-shaped fairy chimneys." }, - { place_name: "Avanos Pottery Workshop", category: "culture", duration: 60, desc: "Traditional pottery making in the Red River town." }, - { place_name: "Zelve Open Air Museum", category: "museum", duration: 120, desc: "An abandoned cave town with ancient churches." }, - { place_name: "Ortahisar Castle", category: "landmark", duration: 60, desc: "A massive rock formation used as a fortress." }, - { place_name: "Rose Valley Sunset Hike", category: "nature", duration: 120, desc: "Beautiful valley that turns pink at sunset." }, - { place_name: "Red Valley", category: "nature", duration: 90, desc: "Stunning valley with sharp ridges and red hues." }, - { place_name: "Çavuşin Village", category: "culture", duration: 60, desc: "An old Greek village with a massive rock castle." }, - ]; +function getTravelTypeGuidance(travelType: string): string { + switch (travelType) { + case 'solo': + return 'Solo traveler: prioritize flexible, independent activities. Include hiking, photography spots, local cafes. Avoid group-only tours.' + case 'couple': + return 'Romantic couple trip: include sunset viewpoints, cave restaurants, hot air balloon, wine tasting, scenic valleys. Prioritize atmosphere and intimacy.' + case 'family': + return 'Family with children: only child-friendly activities. Avoid long strenuous hikes. Include interactive museums, easy nature walks, open-air museums. Max 4 stops per day.' + case 'friends': + return 'Friend group: include adventure activities (ATV, horse riding), nightlife, group tours, lively restaurants and social experiences.' + default: + return '' + } +} - const days = []; - for (let i = 1; i <= numDays; i++) { - const dailyItems = []; - - // Always add Hot Air Balloon on Day 1 or 2 if duration is 120 - if (i <= 2) { - dailyItems.push({ - place_name: "Hot Air Balloon Flight", - category: "activity", - estimated_duration_minutes: 180, - description: "Breathtaking sunrise flight over the fairy chimneys." - }); - } +function getBudgetGuidance(budget: string): string { + switch (budget) { + case 'budget': + return 'Budget traveler (₺500-1000/day): prioritize free or low-cost attractions (valleys, viewpoints, open-air sites). Avoid expensive private tours or luxury restaurants.' + case 'moderate': + return 'Moderate budget (₺1000-2500/day): mix of paid and free attractions. Include some paid guided tours and mid-range restaurants.' + case 'comfort': + return 'Comfort budget (₺2500-5000/day): include premium experiences like private guided tours, cave hotel visits, sunset dinners. Prioritize quality over quantity.' + case 'luxury': + return 'Luxury budget (₺5000+/day): include exclusive experiences: private balloon flight, VIP cave restaurant dinners, private guided tours, spa, premium viewpoints with private transfers.' + default: + return '' + } +} - // Pick 3-4 random items from the pool - const shuffled = [...pool].sort(() => 0.5 - Math.random()); - const selected = shuffled.slice(0, 3); - - selected.forEach(item => { - dailyItems.push({ - place_name: item.place_name, - category: item.category, - estimated_duration_minutes: item.duration, - description: item.desc - }); - }); +function getTransportGuidance(transport: string): string { + switch (transport) { + case 'rental': + return 'Has a rental car: can reach remote locations. Include distant valleys (Ihlara, Soganli), off-the-beaten-path spots. No need to cluster locations geographically.' + case 'transfer': + return 'Using private transfers: comfortable but planned routes. Group locations by area per day to minimize travel time. Can reach all sites.' + case 'shuttle': + return 'Using shuttle/minibus: stick to popular tourist routes. Cluster stops near Göreme, Ürgüp, Avanos. Avoid remote locations not on standard routes.' + case 'mixed': + return 'Mixed transport: balance between accessible and remote locations. Include a mix of central and off-the-beaten-path sites.' + default: + return '' + } +} - days.push({ - day: i, - items: dailyItems - }); +function getInterestGuidance(interests: string[]): string { + const map: Record = { + balloon: 'MUST include a hot air balloon flight on Day 1 or 2 at sunrise (05:00-08:00)', + nature: 'Prioritize valleys, hiking routes (Rose Valley, Red Valley, Pigeon Valley, Ihlara Valley)', + history: 'Prioritize underground cities (Derinkuyu, Kaymakli), rock churches, Selime Monastery', + photography: 'Include best photo spots: Uchisar Castle, Rose Valley sunset, Pasabag fairy chimneys, panoramic viewpoints', + adventure: 'Include ATV tours, horse riding, hiking, zip-lining where available', + gastronomy: 'Include local pottery workshop in Avanos, traditional restaurants, wine tasting in Ürgüp, local market visits', + } + return interests.map(i => map[i] || '').filter(Boolean).join('. ') +} + +function getDailyPlaceCount(budget: string, travelType: string): number { + if (travelType === 'family') return 3 + if (budget === 'luxury') return 3 // fewer but premium + if (budget === 'budget') return 5 // more self-guided stops + return 4 +} + +// ─── Mock itinerary (interest + budget aware) ───────────────────────────────── + +function generateMockItinerary( + startDate: string, + endDate: string, + interests: string[], + travelType: string, + budget: string, + transport: string +) { + const diffDays = Math.ceil( + (new Date(endDate).getTime() - new Date(startDate).getTime()) / 86400000 + ) + 1 + const numDays = Math.min(diffDays, 14) + + // Place pools by category + const pools: Record = { + nature: [ + { place_name: 'Rose Valley Sunset Hike', category: 'nature', duration: 120, desc: 'Beautiful valley that turns pink at sunset.' }, + { place_name: 'Pigeon Valley', category: 'nature', duration: 90, desc: 'Valley named after the countless man-made dovecotes.' }, + { place_name: 'Love Valley', category: 'nature', duration: 60, desc: 'Famous for its fairy chimneys.' }, + { place_name: 'Red Valley', category: 'nature', duration: 90, desc: 'Stunning valley with red hues.' }, + { place_name: 'Ihlara Valley', category: 'nature', duration: 180, desc: 'A stunning canyon with a river and rock-cut churches.' }, + { place_name: 'Devrent Valley', category: 'nature', duration: 45, desc: 'Unique rock formations resembling animals.' }, + ], + history: [ + { place_name: 'Kaymakli Underground City', category: 'history', duration: 90, desc: 'Ancient multi-level underground city.' }, + { place_name: 'Derinkuyu Underground City', category: 'history', duration: 120, desc: 'The deepest underground city in the region.' }, + { place_name: 'Selime Monastery', category: 'history', duration: 60, desc: 'The largest religious structure in Cappadocia.' }, + { place_name: 'Çavuşin Village', category: 'culture', duration: 60, desc: 'An old Greek village with a massive rock castle.' }, + ], + museum: [ + { place_name: 'Göreme Open Air Museum', category: 'museum', duration: 120, desc: 'UNESCO World Heritage site with rock-cut churches.' }, + { place_name: 'Zelve Open Air Museum', category: 'museum', duration: 120, desc: 'An abandoned cave town with ancient churches.' }, + ], + landmark: [ + { place_name: 'Uçhisar Castle', category: 'landmark', duration: 90, desc: 'The highest point in Cappadocia with panoramic views.' }, + { place_name: 'Ortahisar Castle', category: 'landmark', duration: 60, desc: 'A massive rock formation used as a fortress.' }, + { place_name: 'Pasabag (Monks Valley)', category: 'nature', duration: 60, desc: 'Famous fairy chimneys with multiple caps.' }, + ], + gastronomy: [ + { place_name: 'Avanos Pottery Workshop', category: 'culture', duration: 60, desc: 'Traditional pottery making in the Red River town.' }, + { place_name: 'Ürgüp Wine Tasting', category: 'gastronomy', duration: 90, desc: 'Local Cappadocian wine tasting experience.' }, + { place_name: 'Göreme Local Market', category: 'gastronomy', duration: 60, desc: 'Fresh local produce and Turkish delights.' }, + ], + luxury: [ + { place_name: 'Private Guided Valley Tour', category: 'activity', duration: 180, desc: 'Exclusive private tour of the most scenic valleys.' }, + { place_name: 'Cave Restaurant Dinner', category: 'gastronomy', duration: 120, desc: 'Fine dining in a traditional Cappadocian cave.' }, + { place_name: 'Turkish Bath (Hamam)', category: 'wellness', duration: 120, desc: 'Traditional Ottoman bath experience.' }, + ], } - return { days }; + // Build priority pool based on interests + const priorityPool: typeof pools.nature = [] + + if (interests.includes('nature')) priorityPool.push(...pools.nature) + if (interests.includes('history')) priorityPool.push(...pools.history) + if (interests.includes('photography')) priorityPool.push(...pools.landmark) + if (interests.includes('gastronomy')) priorityPool.push(...pools.gastronomy) + if (budget === 'luxury' || budget === 'comfort') priorityPool.push(...pools.luxury) + + // Always add museums as fallback + priorityPool.push(...pools.museum, ...pools.landmark) + + const usedPlaces = new Set() + const maxPerDay = getDailyPlaceCount(budget, travelType) + const days = [] + + for (let i = 1; i <= numDays; i++) { + const dailyItems: { place_name: string; category: string; estimated_duration_minutes: number; description: string }[] = [] + + // Balloon on day 1 if interest selected + if (i === 1 && interests.includes('balloon')) { + dailyItems.push({ + place_name: 'Hot Air Balloon Flight', + category: 'activity', + estimated_duration_minutes: budget === 'luxury' ? 90 : 60, + description: budget === 'luxury' + ? 'Private luxury sunrise balloon flight over the fairy chimneys.' + : 'Breathtaking sunrise flight over the fairy chimneys.', + }) + } + + // Fill remaining slots + const available = priorityPool.filter(p => !usedPlaces.has(p.place_name)) + const shuffled = [...available].sort(() => 0.5 - Math.random()) + const slots = maxPerDay - dailyItems.length + + for (let j = 0; j < Math.min(slots, shuffled.length); j++) { + usedPlaces.add(shuffled[j].place_name) + dailyItems.push({ + place_name: shuffled[j].place_name, + category: shuffled[j].category, + estimated_duration_minutes: shuffled[j].duration, + description: shuffled[j].desc, + }) + } + + days.push({ day: i, items: dailyItems }) + } + + return { days } } +// ─── Main handler ────────────────────────────────────────────────────────────── + serve(async (req) => { if (req.method === 'OPTIONS') { return new Response('ok', { headers: corsHeaders }) } try { - const { startDate, endDate, interests, dailySchedule, preferences } = await req.json() + const { + startDate, + endDate, + interests = [], + dailySchedule = 'moderate', + travelType = 'couple', + accommodation = 'center', + transport = 'mixed', + budget = 'moderate', + travelers = 2, + } = await req.json() - // Initialize Supabase client with service role - const supabase = createClient( - SUPABASE_URL!, - SUPABASE_SERVICE_ROLE_KEY! - ) + const supabase = createClient(SUPABASE_URL!, SUPABASE_SERVICE_ROLE_KEY!) - let itinerary; + let itinerary - // 1. Call OpenAI to generate itinerary (if API key exists) if (OPENAI_API_KEY && OPENAI_API_KEY !== 'PASTE_YOUR_OPENAI_API_KEY_HERE') { try { - const systemPrompt = `You are an expert travel planner for Cappadocia, Turkey. - Generate a personalized travel itinerary based on user preferences. - Allowed regions: Göreme, Uçhisar, Ürgüp, Avanos, Ortahisar, Çavuşin, Derinkuyu, Kaymaklı. - Rules: - - Balloon rides must be at sunrise (05:00 - 08:00) on Day 1 or 2. - - Sunset valley visits after 17:30. - - Max 5 places per day. - - No duplicate places. - - Only return JSON in the specified format. - - Interests: ${interests.join(', ')}. - - Daily Schedule: ${dailySchedule}. - - Response Format: - { - "days": [ - { - "day": 1, - "items": [ - { - "place_name": "Göreme Open Air Museum", - "category": "museum", - "estimated_duration_minutes": 120, - "description": "UNESCO World Heritage site with rock-cut churches." - } - ] - } - ] - }` + const systemPrompt = `You are an expert travel planner for Cappadocia, Turkey. +Generate a highly personalized travel itinerary strictly based on the user's preferences below. - const userPrompt = `Trip Dates: ${startDate} to ${endDate}. Preferences: ${preferences}` +=== USER PROFILE === +Travel Type: ${travelType} — ${getTravelTypeGuidance(travelType)} +Budget: ${budget} — ${getBudgetGuidance(budget)} +Transport: ${transport} — ${getTransportGuidance(transport)} +Group Size: ${travelers} people +Accommodation: ${accommodation} +Daily Schedule pace: ${dailySchedule} + +=== INTERESTS (strictly prioritize these) === +${getInterestGuidance(interests)} + +=== RULES === +- Max ${getDailyPlaceCount(budget, travelType)} places per day +- No duplicate places across days +- Balloon rides ONLY at sunrise (05:00–08:00) on Day 1 or 2, ONLY if balloon is in interests +- Sunset visits (Rose Valley, Red Valley) after 17:30 +- Allowed regions: Göreme, Uçhisar, Ürgüp, Avanos, Ortahisar, Çavuşin, Derinkuyu, Kaymaklı, Ihlara +- For luxury budget: include premium/private experiences +- For budget travelers: focus on free/low-cost sites (valleys, viewpoints) +- For families: ONLY child-safe, easy-access locations +- Return ONLY valid JSON, no markdown, no explanation + +=== RESPONSE FORMAT === +{ + "days": [ + { + "day": 1, + "items": [ + { + "place_name": "Göreme Open Air Museum", + "category": "museum", + "estimated_duration_minutes": 120, + "description": "UNESCO World Heritage site with rock-cut churches." + } + ] + } + ] +}` + + const userPrompt = `Trip: ${startDate} to ${endDate}. Travelers: ${travelers}. Interests: ${interests.join(', ')}. Budget: ${budget}. Transport: ${transport}. Travel type: ${travelType}.` const openaiRes = await fetch('https://api.openai.com/v1/chat/completions', { method: 'POST', @@ -164,47 +271,40 @@ serve(async (req) => { model: 'gpt-4o-mini', messages: [ { role: 'system', content: systemPrompt }, - { role: 'user', content: userPrompt } + { role: 'user', content: userPrompt }, ], - temperature: 0.3, - response_format: { type: 'json_object' } + temperature: 0.4, + response_format: { type: 'json_object' }, }), }) - if (!openaiRes.ok) { - throw new Error(`OpenAI API error: ${openaiRes.statusText}`); - } + if (!openaiRes.ok) throw new Error(`OpenAI error: ${openaiRes.statusText}`) const openaiData = await openaiRes.json() itinerary = JSON.parse(openaiData.choices[0].message.content) } catch (err) { console.error('OpenAI failed, falling back to mock:', err) - itinerary = generateMockItinerary(startDate, endDate, interests) + itinerary = generateMockItinerary(startDate, endDate, interests, travelType, budget, transport) } } else { - console.log('No OpenAI API key found, using mock itinerary') - itinerary = generateMockItinerary(startDate, endDate, interests) + itinerary = generateMockItinerary(startDate, endDate, interests, travelType, budget, transport) } - // 2. Verify places with Google Places API (with cache layer) + // ── Google Places verification with cache ────────────────────────────────── const verifiedDays = [] for (const day of itinerary.days) { const verifiedItems = [] for (const item of day.items) { - // Normalize place name for cache lookup using robust normalization const normalizedName = normalizePlaceName(item.place_name) - - // Check cache first - const { data: cachedPlace, error: cacheError } = await supabase + + const { data: cachedPlace } = await supabase .from('places_cache') .select('*') .eq('place_name_normalized', normalizedName) .limit(1) .maybeSingle() - - if (cachedPlace && !cacheError) { - // Cache HIT - skipping Google API call - console.log(`Cache HIT for "${item.place_name}" (normalized: "${normalizedName}") - skipping Google API call`) + + if (cachedPlace) { verifiedItems.push({ ...item, place_id: cachedPlace.place_id, @@ -213,23 +313,18 @@ serve(async (req) => { lat: cachedPlace.lat, lng: cachedPlace.lng, rating: cachedPlace.rating, - user_ratings_total: cachedPlace.user_ratings_total, - photo_reference: cachedPlace.photo_reference + photo_reference: cachedPlace.photo_reference, }) } else { - // Cache MISS - fetching from Google and caching - console.log(`Cache MISS for "${item.place_name}" (normalized: "${normalizedName}") - fetching from Google and caching`) - - let placeInfo = null; + let placeInfo = null if (GOOGLE_MAPS_API_KEY && GOOGLE_MAPS_API_KEY !== 'PASTE_YOUR_GOOGLE_MAPS_API_KEY_HERE') { try { - // Text Search to get place_id and basic info const searchUrl = `https://maps.googleapis.com/maps/api/place/textsearch/json?query=${encodeURIComponent(item.place_name + ' Cappadocia')}&key=${GOOGLE_MAPS_API_KEY}` const searchRes = await fetch(searchUrl) const searchData = await searchRes.json() - if (searchData.results && searchData.results.length > 0) { + if (searchData.results?.length > 0) { const place = searchData.results[0] placeInfo = { place_id: place.place_id, @@ -238,58 +333,45 @@ serve(async (req) => { lat: place.geometry.location.lat, lng: place.geometry.location.lng, rating: place.rating || null, - user_ratings_total: place.user_ratings_total || null, - photo_reference: place.photos?.[0]?.photo_reference || null + photo_reference: place.photos?.[0]?.photo_reference || null, } } } catch (err) { - console.error(`Google Places API error for ${item.place_name}:`, err) + console.error(`Google Places error for ${item.place_name}:`, err) } } if (placeInfo) { - // Store in cache for future use with normalized name - const cacheEntry = { + await supabase.from('places_cache').insert({ place_name_normalized: normalizedName, - ...placeInfo - } - - await supabase - .from('places_cache') - .insert(cacheEntry) - - verifiedItems.push({ - ...item, - ...placeInfo + ...placeInfo, }) + verifiedItems.push({ ...item, ...placeInfo }) } else { - // No Google info found or key missing, use item as is but mark as unverified - verifiedItems.push({ - ...item, - unverified: true - }) + verifiedItems.push({ ...item, unverified: true }) } } } verifiedDays.push({ ...day, items: verifiedItems }) } - // 3. Add time slots (Simple heuristic) + // ── Time slot assignment ─────────────────────────────────────────────────── const finalDays = verifiedDays.map(day => { - let currentTime = new Date(`2026-01-01T09:00:00`) + let currentTime = new Date('2026-01-01T09:00:00') const itemsWithTime = day.items.map(item => { - // Handle special cases: Balloon if (item.place_name.toLowerCase().includes('balloon')) { - const start = "05:00" - const end = "08:00" - return { ...item, start_time: start, end_time: end } + return { ...item, start_time: '05:00', end_time: '08:00' } } - + // Push sunset spots to evening + const isSunsetSpot = /rose valley|red valley|sunset/i.test(item.place_name) + if (isSunsetSpot) { + return { ...item, start_time: '17:30', end_time: '19:30' } + } + const startTime = currentTime.toTimeString().slice(0, 5) currentTime.setMinutes(currentTime.getMinutes() + (item.estimated_duration_minutes || 60)) const endTime = currentTime.toTimeString().slice(0, 5) - currentTime.setMinutes(currentTime.getMinutes() + 30) // 30 min travel/buffer - + currentTime.setMinutes(currentTime.getMinutes() + 30) // travel buffer return { ...item, start_time: startTime, end_time: endTime } }) return { ...day, items: itemsWithTime } @@ -299,7 +381,7 @@ serve(async (req) => { headers: { ...corsHeaders, 'Content-Type': 'application/json' }, }) } catch (error) { - console.error('Final catch error:', error) + console.error('Error:', error) return new Response(JSON.stringify({ error: error.message }), { status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' },