From 74e2b00ac7eb8a0a0faf45cebc43bb60f4b0407a Mon Sep 17 00:00:00 2001 From: Jennifer Ye Date: Sat, 17 Jan 2026 18:12:39 -0500 Subject: [PATCH] itinerary generator using google places api --- travel-ai/README.md | 30 +- travel-ai/next.config.ts | 10 +- travel-ai/src/app/api/activities/route.ts | 398 ++++++++++++++++++++++ travel-ai/src/app/page.tsx | 211 ++++++++++++ 4 files changed, 647 insertions(+), 2 deletions(-) create mode 100644 travel-ai/src/app/api/activities/route.ts diff --git a/travel-ai/README.md b/travel-ai/README.md index e215bc4..3eed709 100644 --- a/travel-ai/README.md +++ b/travel-ai/README.md @@ -1,8 +1,36 @@ This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). +## Features + +- ✈️ **Flight Search**: Find the best round-trip flights using natural language +- 🗺️ **Activity Itinerary**: Generate personalized daily itineraries with local activities, attractions, and recommendations using Google Places API +- 🤖 **AI-Powered**: Uses OpenAI to parse user requests and organize activities + ## Getting Started -First, run the development server: +### Prerequisites + +You'll need API keys for: +- **OpenAI API** (for parsing flight requests and organizing itineraries) +- **Google Places API** (for finding local activities and attractions) + +### Environment Variables + +Create a `.env.local` file in the root directory with the following: + +```env +OPENAI_API_KEY=your_openai_api_key_here +GOOGLE_PLACES_API_KEY=your_google_places_api_key_here +``` + +To get a Google Places API key: +1. Go to [Google Cloud Console](https://console.cloud.google.com/) +2. Create a new project or select an existing one +3. Enable the "Places API" (New) or "Places API" +4. Create credentials (API Key) +5. Restrict the API key to "Places API" for security + +### Running the Development Server ```bash npm run dev diff --git a/travel-ai/next.config.ts b/travel-ai/next.config.ts index e9ffa30..ff3f3e4 100644 --- a/travel-ai/next.config.ts +++ b/travel-ai/next.config.ts @@ -1,7 +1,15 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { - /* config options here */ + images: { + remotePatterns: [ + { + protocol: 'https', + hostname: 'maps.googleapis.com', + pathname: '/maps/api/place/photo/**', + }, + ], + }, }; export default nextConfig; diff --git a/travel-ai/src/app/api/activities/route.ts b/travel-ai/src/app/api/activities/route.ts new file mode 100644 index 0000000..da3c74e --- /dev/null +++ b/travel-ai/src/app/api/activities/route.ts @@ -0,0 +1,398 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; + +const ActivitiesRequest = z.object({ + destination: z.string().min(1), + arrival_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), + departure_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), + preferences: z.string().optional(), +}); + +interface PlaceResult { + place_id: string; + name: string; + rating?: number; + user_ratings_total?: number; + types?: string[]; + formatted_address?: string; + geometry?: { + location: { + lat: number; + lng: number; + }; + }; + photos?: Array<{ + photo_reference: string; + }>; +} + +interface Activity { + id: string; + name: string; + address: string; + rating?: number; + ratingCount?: number; + types: string[]; + photoUrl?: string; + category: string; +} + +interface DayItinerary { + day: number; + date: string; + activities: Activity[]; +} + +// Simple mapping of common airport codes to city names +const AIRPORT_TO_CITY: Record = { + "JFK": "New York", + "LGA": "New York", + "EWR": "New York", + "LAX": "Los Angeles", + "SFO": "San Francisco", + "ORD": "Chicago", + "DFW": "Dallas", + "MIA": "Miami", + "ATL": "Atlanta", + "SEA": "Seattle", + "BOS": "Boston", + "IAD": "Washington DC", + "DCA": "Washington DC", + "LHR": "London", + "LGW": "London", + "CDG": "Paris", + "FRA": "Frankfurt", + "AMS": "Amsterdam", + "FCO": "Rome", + "MAD": "Madrid", + "BCN": "Barcelona", + "NRT": "Tokyo", + "HND": "Tokyo", + "ICN": "Seoul", + "PEK": "Beijing", + "PVG": "Shanghai", + "SYD": "Sydney", + "MEL": "Melbourne", + "DXB": "Dubai", + "SIN": "Singapore", + "BKK": "Bangkok", + "HKG": "Hong Kong", +}; + +function getCityName(destination: string): string { + // If it's a 3-letter code, try to map it + if (destination.length === 3 && destination === destination.toUpperCase()) { + return AIRPORT_TO_CITY[destination] || destination; + } + return destination; +} + +export async function POST(req: Request) { + try { + const body = ActivitiesRequest.parse(await req.json()); + const apiKey = process.env.GOOGLE_PLACES_API_KEY; + + if (!apiKey) { + return NextResponse.json( + { error: "Missing GOOGLE_PLACES_API_KEY in .env.local" }, + { status: 500 } + ); + } + + // Calculate number of days + const arrival = new Date(body.arrival_date); + const departure = new Date(body.departure_date); + + // Validate dates + if (isNaN(arrival.getTime()) || isNaN(departure.getTime())) { + return NextResponse.json( + { error: `Invalid date format. Received: arrival_date=${body.arrival_date}, departure_date=${body.departure_date}` }, + { status: 400 } + ); + } + + const days = Math.ceil((departure.getTime() - arrival.getTime()) / (1000 * 60 * 60 * 24)); + + if (days <= 0) { + return NextResponse.json( + { + error: `Invalid date range: arrival_date (${body.arrival_date}) must be before departure_date (${body.departure_date}). Calculated days: ${days}` + }, + { status: 400 } + ); + } + + // Use OpenAI to help organize activities by day and category + const openaiKey = process.env.OPENAI_API_KEY; + if (!openaiKey) { + return NextResponse.json( + { error: "Missing OPENAI_API_KEY in .env.local" }, + { status: 500 } + ); + } + + // Convert airport code to city name if needed + const cityName = getCityName(body.destination); + + // First, search for places using Google Places API + // Build a more flexible search query + let searchQuery = cityName; + if (body.preferences) { + searchQuery = `${cityName} ${body.preferences}`; + } else { + searchQuery = `${cityName} tourist attractions`; + } + + // Use Text Search API to find places (without type filter initially for better results) + const placesUrl = `https://maps.googleapis.com/maps/api/place/textsearch/json?query=${encodeURIComponent(searchQuery)}&key=${apiKey}`; + + console.log("Google Places API query:", searchQuery); + console.log("Google Places API URL:", placesUrl.replace(apiKey, "***")); + + const placesResponse = await fetch(placesUrl); + if (!placesResponse.ok) { + const errorText = await placesResponse.text(); + console.error("Google Places API error:", placesResponse.status, errorText); + return NextResponse.json( + { error: `Failed to fetch places from Google Places API: ${placesResponse.status} - ${errorText}` }, + { status: 500 } + ); + } + + const placesData = await placesResponse.json(); + + // Check for API errors in response + if (placesData.status && placesData.status !== "OK") { + console.error("Google Places API error status:", placesData.status, placesData.error_message); + return NextResponse.json( + { + error: `Google Places API error: ${placesData.status}${placesData.error_message ? ` - ${placesData.error_message}` : ""}` + }, + { status: 400 } + ); + } + + let places: PlaceResult[] = placesData.results || []; + + // If no results, try a broader search + if (places.length === 0) { + console.log("No results with initial query, trying broader search..."); + const broaderUrl = `https://maps.googleapis.com/maps/api/place/textsearch/json?query=${encodeURIComponent(cityName + " attractions")}&key=${apiKey}`; + const broaderResponse = await fetch(broaderUrl); + + if (broaderResponse.ok) { + const broaderData = await broaderResponse.json(); + if (broaderData.status === "OK" && broaderData.results && broaderData.results.length > 0) { + places = broaderData.results; + console.log(`Found ${places.length} places with broader search`); + } + } + } + + if (places.length === 0) { + return NextResponse.json( + { + error: `No activities found for "${cityName}". Please check that the destination name is correct and that your Google Places API key has the necessary permissions.`, + debug: { + originalDestination: body.destination, + convertedCityName: cityName, + searchQuery, + placesApiStatus: placesData.status, + } + }, + { status: 404 } + ); + } + + // Process places into activities + const activities: Activity[] = places.slice(0, Math.min(places.length, 30)).map((place, idx) => { + // Get photo URL if available + let photoUrl: string | undefined; + if (place.photos && place.photos.length > 0) { + const photo = place.photos[0]; + // Check if photo_reference exists (it might be in different formats) + const photoRef = photo.photo_reference || (photo as any).reference; + if (photoRef) { + // Google Places Photo API requires maxwidth or maxheight, and the photo_reference + photoUrl = `https://maps.googleapis.com/maps/api/place/photo?maxwidth=400&maxheight=400&photo_reference=${photoRef}&key=${apiKey}`; + } else { + console.log(`No photo_reference for place ${place.name}, photos:`, JSON.stringify(photo)); + } + } + + // Categorize activity + const types = place.types || []; + let category = "Attraction"; + if (types.some(t => t.includes("museum"))) category = "Museum"; + else if (types.some(t => t.includes("park"))) category = "Park"; + else if (types.some(t => t.includes("restaurant"))) category = "Restaurant"; + else if (types.some(t => t.includes("shopping"))) category = "Shopping"; + else if (types.some(t => t.includes("amusement"))) category = "Entertainment"; + else if (types.some(t => t.includes("zoo") || t.includes("aquarium"))) category = "Nature"; + + return { + id: place.place_id || `place_${idx}`, + name: place.name, + address: place.formatted_address || "", + rating: place.rating, + ratingCount: place.user_ratings_total, + types: types, + photoUrl, + category, + }; + }); + + // Use OpenAI to organize activities into a daily itinerary + const systemPrompt = `You are a travel itinerary planner. Organize activities into a ${days}-day itinerary. + +Output ONLY valid JSON with this structure: +{ + "itinerary": [ + { + "day": number, + "date": string (YYYY-MM-DD), + "activities": [ + { + "id": string, + "time": string (e.g., "09:00", "14:00", "19:00"), + "duration": string (e.g., "2 hours", "3 hours"), + "notes": string (optional suggestions) + } + ] + } + ] +} + +Rules: +- Distribute activities evenly across days +- Group nearby activities on the same day +- Include 2-4 activities per day +- Consider opening hours (museums/attractions in morning/afternoon, restaurants in evening) +- Start days around 9-10 AM +- End days around 6-8 PM +- Mix different types of activities (museums, parks, restaurants, etc.) +- Consider user preferences: ${body.preferences || "general tourism"} + +Available activities: +${JSON.stringify(activities.map(a => ({ id: a.id, name: a.name, category: a.category })), null, 2)} + +Calculate dates starting from ${body.arrival_date} (day 1) to ${body.departure_date} (day ${days}). + +No markdown. Just JSON.`; + + const openaiResponse = await fetch("https://api.openai.com/v1/chat/completions", { + method: "POST", + headers: { + "Authorization": `Bearer ${openaiKey}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + model: "gpt-4o-mini", + temperature: 0.7, + messages: [ + { role: "system", content: systemPrompt }, + { role: "user", content: `Create a ${days}-day itinerary for ${body.destination}` }, + ], + }), + }); + + if (!openaiResponse.ok) { + const errorText = await openaiResponse.text(); + console.error("OpenAI error:", errorText); + // Fallback: simple distribution without AI + return generateFallbackItinerary(activities, days, body.arrival_date, body.departure_date); + } + + const openaiData = await openaiResponse.json(); + const content = openaiData?.choices?.[0]?.message?.content; + + let itineraryData: any; + try { + const cleaned = content.replace(/```json\n?|\n?```/g, '').trim(); + itineraryData = JSON.parse(cleaned); + } catch { + // Fallback if JSON parsing fails + return generateFallbackItinerary(activities, days, body.arrival_date, body.departure_date); + } + + // Enrich itinerary with full activity details + const activityMap = new Map(activities.map(a => [a.id, a])); + const enrichedItinerary: DayItinerary[] = itineraryData.itinerary.map((day: any) => { + const date = new Date(body.arrival_date); + date.setDate(date.getDate() + (day.day - 1)); + + return { + day: day.day, + date: date.toISOString().split('T')[0], + activities: day.activities.map((act: any) => { + const fullActivity = activityMap.get(act.id); + return { + ...fullActivity, + time: act.time, + duration: act.duration, + notes: act.notes, + }; + }).filter((act: any) => act !== undefined), + }; + }); + + return NextResponse.json({ + success: true, + destination: cityName, + days, + itinerary: enrichedItinerary, + allActivities: activities, + }); + } catch (e: any) { + console.error("Activities error:", e); + if (e instanceof z.ZodError) { + return NextResponse.json( + { error: "Invalid request parameters", details: e.errors }, + { status: 400 } + ); + } + return NextResponse.json( + { error: e.message || "Unknown error" }, + { status: 500 } + ); + } +} + +function generateFallbackItinerary( + activities: Activity[], + days: number, + arrivalDate: string, + departureDate: string +): NextResponse { + const activitiesPerDay = Math.ceil(activities.length / days); + const itinerary: DayItinerary[] = []; + const times = ["09:00", "12:00", "15:00", "18:00"]; + + for (let day = 1; day <= days; day++) { + const date = new Date(arrivalDate); + date.setDate(date.getDate() + (day - 1)); + + const startIdx = (day - 1) * activitiesPerDay; + const endIdx = Math.min(startIdx + activitiesPerDay, activities.length); + const dayActivities = activities.slice(startIdx, endIdx); + + itinerary.push({ + day, + date: date.toISOString().split('T')[0], + activities: dayActivities.map((act, idx) => ({ + ...act, + time: times[idx % times.length], + duration: idx % 2 === 0 ? "2 hours" : "3 hours", + notes: `Visit ${act.name}`, + })), + }); + } + + return NextResponse.json({ + success: true, + destination: getCityName(""), + days, + itinerary, + allActivities: activities, + }); +} diff --git a/travel-ai/src/app/page.tsx b/travel-ai/src/app/page.tsx index 48372ae..72f1725 100644 --- a/travel-ai/src/app/page.tsx +++ b/travel-ai/src/app/page.tsx @@ -47,6 +47,26 @@ interface ProcessedFlight { bookingUrl?: string; } +interface Activity { + id: string; + name: string; + address: string; + rating?: number; + ratingCount?: number; + types: string[]; + photoUrl?: string; + category: string; + time?: string; + duration?: string; + notes?: string; +} + +interface DayItinerary { + day: number; + date: string; + activities: Activity[]; +} + export default function Home() { const [messages, setMessages] = useState([ { @@ -60,6 +80,11 @@ export default function Home() { const [flights, setFlights] = useState([]); const [searchComplete, setSearchComplete] = useState(false); const [error, setError] = useState(null); + const [itinerary, setItinerary] = useState(null); + const [loadingItinerary, setLoadingItinerary] = useState(false); + const [itineraryError, setItineraryError] = useState(null); + const [activityPreferences, setActivityPreferences] = useState(""); + const [destinationName, setDestinationName] = useState(null); async function parsePrompt(userMessage: string, previousData?: FlightParams) { const res = await fetch("/api/parse-flight", { @@ -159,6 +184,60 @@ export default function Home() { doSubmit(); } + async function generateItinerary() { + if (!flightParams?.arrival_airport_code || !flightParams?.arrival_date || !flightParams?.departure_date) { + setItineraryError("Missing flight information to generate itinerary"); + return; + } + + setLoadingItinerary(true); + setItineraryError(null); + + try { + // Convert airport code to city name (simplified - in production, use a mapping) + const destination = flightParams.arrival_airport_code; + + // For activities, we need: + // - arrival_date: when you arrive at destination (use departure_date from flight search) + // - departure_date: when you leave destination (use arrival_date from flight search) + const res = await fetch("/api/activities", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + destination, + arrival_date: flightParams.departure_date, // When you leave origin = when you arrive at destination + departure_date: flightParams.arrival_date, // When you return to origin = when you leave destination + preferences: activityPreferences || undefined, + }), + }); + + if (!res.ok) { + const data = await res.json(); + const errorMsg = data?.error || "Failed to generate itinerary"; + // Include debug info if available + if (data?.debug) { + console.error("Activities API debug info:", data.debug); + } + throw new Error(errorMsg); + } + + const data = await res.json(); + setItinerary(data.itinerary || []); + setDestinationName(data.destination || null); + setMessages((prev) => [ + ...prev, + { + role: "assistant", + content: `Great! I've generated a ${data.days}-day itinerary for ${data.destination} with ${data.itinerary?.reduce((sum: number, day: DayItinerary) => sum + day.activities.length, 0) || 0} activities.`, + }, + ]); + } catch (err: any) { + setItineraryError(err.message); + } finally { + setLoadingItinerary(false); + } + } + function resetSearch() { setMessages([ { @@ -170,6 +249,10 @@ export default function Home() { setFlights([]); setSearchComplete(false); setError(null); + setItinerary(null); + setItineraryError(null); + setActivityPreferences(""); + setDestinationName(null); } function formatDuration(minutes: number): string { @@ -187,6 +270,12 @@ export default function Home() { }; } + function formatDate(dateStr: string): string { + if (!dateStr) return ""; + const d = new Date(dateStr); + return d.toLocaleDateString("en-US", { weekday: "long", month: "long", day: "numeric", year: "numeric" }); + } + return (
@@ -337,6 +426,128 @@ export default function Home() {
)} + {/* Activities/Itinerary Section */} + {searchComplete && flightParams && ( + + + 🗺️ Local Activities & Itinerary + + + {!itinerary ? ( + <> +

+ Generate a personalized itinerary with local activities, attractions, and recommendations for your destination. +

+
+ setActivityPreferences(e.target.value)} + placeholder="Preferences (e.g., 'museums and art galleries', 'outdoor activities', 'family-friendly')" + className="w-full" + /> + +
+ {itineraryError && ( +

{itineraryError}

+ )} + + ) : ( +
+
+

+ {itinerary.length}-Day Itinerary for {destinationName || flightParams.arrival_airport_code} +

+ +
+ {itinerary.map((day) => ( + + + + Day {day.day} - {formatDate(day.date)} + + + + {day.activities.map((activity, idx) => ( +
+ {activity.photoUrl ? ( + {activity.name} { + // Log error and hide image if it fails to load + console.error(`Failed to load image for ${activity.name}:`, activity.photoUrl); + (e.target as HTMLImageElement).style.display = 'none'; + }} + onLoad={() => { + console.log(`Successfully loaded image for ${activity.name}`); + }} + /> + ) : ( +
+ No Image +
+ )} +
+
+
+

{activity.name}

+

{activity.address}

+
+
+ {activity.time && ( + + {activity.time} + + )} + {activity.duration && ( + + {activity.duration} + + )} +
+
+
+ + {activity.category} + + {activity.rating && ( + + ⭐ {activity.rating.toFixed(1)} + {activity.ratingCount && ` (${activity.ratingCount.toLocaleString()})`} + + )} +
+ {activity.notes && ( +

{activity.notes}

+ )} +
+
+ ))} +
+
+ ))} +
+ )} +
+
+ )} + {/* Input Form */} {!searchComplete && (