diff --git a/templates/agentic/travel-planner/.env.example b/templates/agentic/travel-planner/.env.example new file mode 100644 index 0000000..d7e1dd8 --- /dev/null +++ b/templates/agentic/travel-planner/.env.example @@ -0,0 +1 @@ +LAMATIC_API_KEY=your_lamatic_api_key diff --git a/templates/agentic/travel-planner/.eslintrc.json b/templates/agentic/travel-planner/.eslintrc.json new file mode 100644 index 0000000..6b10a5b --- /dev/null +++ b/templates/agentic/travel-planner/.eslintrc.json @@ -0,0 +1,6 @@ +{ + "extends": [ + "next/core-web-vitals", + "next/typescript" + ] +} diff --git a/templates/agentic/travel-planner/.gitignore b/templates/agentic/travel-planner/.gitignore new file mode 100644 index 0000000..399e3e7 --- /dev/null +++ b/templates/agentic/travel-planner/.gitignore @@ -0,0 +1,37 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules + +# next.js +/.next/ +/out/ + +# production +/build + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# env files +.env +.env.local + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts + +# package manager +package-lock.json +yarn.lock +pnpm-lock.yaml +bun.lockb + +#lamatic +config.json \ No newline at end of file diff --git a/templates/agentic/travel-planner/.npmrc b/templates/agentic/travel-planner/.npmrc new file mode 100644 index 0000000..165d6cf --- /dev/null +++ b/templates/agentic/travel-planner/.npmrc @@ -0,0 +1 @@ +force=true \ No newline at end of file diff --git a/templates/agentic/travel-planner/LICENSE b/templates/agentic/travel-planner/LICENSE new file mode 100644 index 0000000..1713f88 --- /dev/null +++ b/templates/agentic/travel-planner/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Lamatic.ai + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/templates/agentic/travel-planner/README.md b/templates/agentic/travel-planner/README.md new file mode 100644 index 0000000..0e96cde --- /dev/null +++ b/templates/agentic/travel-planner/README.md @@ -0,0 +1,107 @@ +# Lamatic Multi-Agent Travel Planner + +Design flight-ready itineraries with Lamatic’s multi-agent coordination stack. This template wires the Lamatic workflow you deployed (project `bee05145-3d20-4d4b-a965-75ec69cc4a65`) into a polished Next.js front-end so travellers can collect flights, stays, activities, budgets, visualisations, and booking steps in one place. + +![Travel planner hero](https://images.unsplash.com/photo-1500530855697-b586d89ba3ee?auto=format&fit=crop&w=1200&q=60) + +--- + +## ✨ What’s inside + +- **Preference capture UI** – guided form with origin/destination, dates, budget, travellers, cabin class, and up to three interests. +- **Lamatic orchestration** – calls the `Multi-Agent Travel Planner` flow (`3cc791a2-ca33-4e27-8791-ff386cef14b2`). The trigger node fans out to flight, hotel, and activity sub-flows and returns a structured brief. +- **Itinerary visualiser** – renders overview, day-by-day plan, flight & stay cards, activity gallery, budget breakdown, booking checklist, and raw payload inspector. +- **Travel-first styling** – airy gradients, travel-inspired hero, and badge-based highlights for an easy hand-off to marketing or ops teams. +- **Friendly UX touches** – date pickers prevent past departures and the response is reformatted into polished cards for trip summary, flights, lodgings, tips, and booking steps. + +--- + +## 🚀 Quick start + +> Make sure you have exported your Lamatic deployment (`lamatic-config.json`) and Lamatic API key (`LAMATIC_API_KEY`). + +1. **Install dependencies** + ```bash + cd templates/agentic/travel-planner + npm install + ``` + +2. **Configure environment** + ```bash + cp .env.example .env.local + # add your Lamatic key + ``` + +3. **Run locally** + ```bash + npm run dev + # open http://localhost:3000 + ``` + +4. **Deploy (optional)** + - Create a new Vercel project with root set to `templates/agentic/travel-planner`. + - Add `LAMATIC_API_KEY` to the Vercel environment variables. + - Upload the same `lamatic-config.json` that you exported from Lamatic Studio. + +--- + +## 🧠 Workflow architecture + +| Stage | Lamatic node | Description | +| ----- | ------------ | ----------- | +| Trigger | `triggerNode_1` | Receives the form payload, validates schema (`origin`, `destination`, `start_date`, `end_date`, `budget`, `interests[]`, `travelers`, `flight_class`). | +| Coordinator | `LLMNode_113` + `codeNode_405` | Builds aggregated search parameters: budget splits, IATA codes, trip summary. | +| Specialists | `flowNode_122`, `flowNode_175`, `flowNode_506` | Dedicated sub-flows for flights, hotels, and activities using the structured queries. | +| Synthesis | `LLMNode_310` + `codeNode_827` | Produces the final travel brief, day-by-day itinerary, budget ledger, tips, and booking next steps. | +| Response | `responseNode_triggerNode_1` | Returns the JSON payload consumed by this Next.js template. | + +All flow metadata (workflow ID, Lamatic endpoint, project ID) live in [`lamatic-config.json`](./lamatic-config.json). + +--- + +## 🖥️ Front-end tour + +| Module | Path | Notes | +| ------ | ---- | ----- | +| Lamatic client | [`lib/lamatic-client.ts`](./lib/lamatic-client.ts) | Thin wrapper around the Lamatic SDK using the config + API key. | +| Orchestrator | [`actions/orchestrate.ts`](./actions/orchestrate.ts) | Cleans preferences, executes the travel flow, and normalises the JSON (flights, stays, activities, budget, tasks, visualisations). | +| UI | [`app/page.tsx`](./app/page.tsx) | Preference form, preset starter trips, loading states, itinerary renderer, and raw payload viewer. | + +Custom helpers in `actions/orchestrate.ts` normalise the Lamatic response so the UI can be data-driven regardless of provider formatting. + +--- + +## 🧩 Required inputs + +- **Origin & destination** – plain text; Lamatic turn will map to IATA codes. +- **Dates** – ISO `YYYY-MM-DD`. +- **Budget** – per-trip amount in USD (update front-end copy if you use another currency). +- **Travellers** – integer; fed to flight and hotel agents. +- **Flight class** – `economy`, `premium_economy`, `business`, `first`. +- **Interests** – up to three tags (`art`, `food`, `history`, `adventure`, `nature`, `shopping`, `nightlife`, `culture`, `family`, `music`, `architecture`). These route requests to the activity analyst. +- **Optional notes** – appended as `"notes: ..."`, giving agents extra context (dietary needs, remote work, accessibility, etc.). + +--- + +## 🛠️ Customisation tips + +- Swap the preset journeys (`PRESET_TRIPS` in `app/page.tsx`) for your audience. +- Add more interest tags or rename them in `ACTIVITY_OPTIONS`, but keep the cap at 3 to respect the downstream agents. +- Extend the normalisers in `actions/orchestrate.ts` if your flow returns additional data (e.g., car rentals, insurance). +- Update the colour palette or background photography by adjusting the header block in `app/page.tsx`. + +--- + +## ✅ Validation checklist + +- [ ] `LAMATIC_API_KEY` present in `.env.local`. +- [ ] `lamatic-config.json` copied from Lamatic Studio (contains your workflow ID + endpoint). +- [ ] `npm run dev` boots without runtime errors. +- [ ] Form submission returns a full itinerary, references, and booking list in the UI. +- [ ] Raw payload viewer shows the JSON delivered by Lamatic (useful for debugging or exporting). + +--- + +## 📄 License + +MIT — see [`LICENSE`](./LICENSE). Have fun building! 🧳✈️ diff --git a/templates/agentic/travel-planner/actions/orchestrate.ts b/templates/agentic/travel-planner/actions/orchestrate.ts new file mode 100644 index 0000000..d6625b2 --- /dev/null +++ b/templates/agentic/travel-planner/actions/orchestrate.ts @@ -0,0 +1,852 @@ +"use server"; + +import { lamaticClient } from "@/lib/lamatic-client"; +import config from "@/lamatic-config.json"; + +const travelPlannerFlow = (config.flows as Record | undefined) + ?.travelPlanner; + +if (!travelPlannerFlow) { + throw new Error("travelPlanner flow not found in lamatic-config.json"); +} + +export interface TravelPreferences { + origin: string; + destination: string; + startDate: string; + endDate: string; + budget: number; + travelers: number; + flightClass?: string; + activities: string[]; +} + +export interface TravelSegment { + time?: string; + activity: string; + location?: string; + details?: string; + cost?: number; + link?: string; +} + +export interface TravelDayPlan { + title: string; + location?: string; + description?: string; + segments: TravelSegment[]; +} + +export interface FlightOption { + airline?: string; + from?: string; + to?: string; + departure?: string; + arrival?: string; + duration?: string; + price?: number; + currency?: string; + bookingLink?: string; + notes?: string; +} + +export interface StayOption { + name: string; + location?: string; + checkIn?: string; + checkOut?: string; + nights?: number; + price?: number; + currency?: string; + link?: string; + description?: string; + rating?: number; +} + +export interface ActivityOption { + name: string; + category?: string; + location?: string; + time?: string; + description?: string; + price?: number; + currency?: string; + link?: string; +} + +export interface BudgetBreakdownItem { + label: string; + amount: number; + currency?: string; +} + +export interface BudgetSummary { + total?: number; + currency?: string; + breakdown: BudgetBreakdownItem[]; + notes?: string; +} + +export interface VisualizationResource { + label: string; + url?: string; + type?: string; + description?: string; + embedHtml?: string; +} + +export interface BookingTask { + title: string; + description?: string; + link?: string; + status?: string; +} + +export interface TripSummary { + destination?: string; + dates?: string; + duration?: string; + totalEstimatedCost?: number; + travelers?: number; +} + +export interface FlightLeg extends FlightOption { + flightNumber?: string; + departureAirport?: string; + arrivalAirport?: string; +} + +export interface FlightItinerary { + outbound?: FlightLeg; + return?: FlightLeg; +} + +export interface NormalizedTravelPlan { + overview?: string; + keyHighlights: string[]; + itinerary: TravelDayPlan[]; + flights: FlightOption[]; + stays: StayOption[]; + activities: ActivityOption[]; + budget?: BudgetSummary; + visualizations: VisualizationResource[]; + bookingTasks: BookingTask[]; + references: string[]; + travelTips: string[]; + nextSteps: string[]; + tripSummary?: TripSummary; + flightItinerary?: FlightItinerary; + accommodation?: StayOption; + raw: unknown; +} + +export interface PlanTripSuccess { + success: true; + status?: string; + plan: NormalizedTravelPlan; +} + +export interface PlanTripError { + success: false; + error: string; +} + +export type PlanTripResponse = PlanTripSuccess | PlanTripError; + +const toArray = (value: unknown): T[] => { + if (Array.isArray(value)) return value as T[]; + if (value === undefined || value === null) return []; + return [value as T]; +}; + +const toNumber = (value: unknown): number | undefined => { + if (typeof value === "number" && !Number.isNaN(value)) return value; + if (typeof value === "string") { + const parsed = Number(value.replace(/[^0-9.-]+/g, "")); + return Number.isFinite(parsed) ? parsed : undefined; + } + return undefined; +}; + +const cleanObject = (obj: Record) => + Object.fromEntries( + Object.entries(obj).filter( + ([, v]) => v !== undefined && v !== null && v !== "" + ) + ); + +const parseJson = (value: unknown) => { + if (typeof value === "string") { + try { + return JSON.parse(value); + } catch { + return value; + } + } + return value; +}; + +const parseSegments = (value: unknown): TravelSegment[] => { + return toArray(value) + .map((segment) => { + const segmentObj = + typeof segment === "string" ? { activity: segment } : segment; + const activity = + segmentObj?.activity ?? segmentObj?.title ?? segmentObj?.name ?? ""; + if (!activity) return null; + return cleanObject({ + time: segmentObj?.time ?? segmentObj?.slot ?? segmentObj?.startTime, + activity, + location: segmentObj?.location ?? segmentObj?.city ?? segmentObj?.place, + details: segmentObj?.details ?? segmentObj?.description, + cost: toNumber(segmentObj?.price ?? segmentObj?.cost), + link: segmentObj?.link ?? segmentObj?.bookingLink, + }) as TravelSegment; + }) + .filter(Boolean) as TravelSegment[]; +}; + +const parseItinerary = (value: unknown): TravelDayPlan[] => { + const days = toArray(value); + if (days.length === 0) return []; + + return days + .map((day, index) => { + if (typeof day === "string") { + return { + title: `Day ${index + 1}`, + description: day, + segments: [], + } satisfies TravelDayPlan; + } + + const title = + day?.title ?? + day?.day ?? + day?.label ?? + (day?.date ? `Day ${index + 1} · ${day.date}` : `Day ${index + 1}`); + + const segments = parseSegments( + day?.segments ?? day?.activities ?? day?.schedule + ); + + return cleanObject({ + title, + location: day?.location ?? day?.city ?? day?.region, + description: day?.description ?? day?.summary ?? day?.highlights, + segments, + }) as TravelDayPlan; + }) + .filter(Boolean); +}; + +const parseFlights = (value: unknown): FlightOption[] => { + return toArray(value) + .map((flight) => { + const airline = flight?.airline ?? flight?.carrier ?? flight?.provider; + const from = flight?.from ?? flight?.origin; + const to = flight?.to ?? flight?.destination; + if (!airline && !from && !to) { + return null; + } + + return cleanObject({ + airline, + from, + to, + departure: flight?.departure ?? flight?.depart ?? flight?.startTime, + arrival: flight?.arrival ?? flight?.arrive ?? flight?.endTime, + duration: flight?.duration, + price: toNumber(flight?.price ?? flight?.cost), + currency: flight?.currency, + bookingLink: flight?.link ?? flight?.bookingLink, + notes: flight?.notes ?? flight?.description, + }) as FlightOption; + }) + .filter(Boolean) as FlightOption[]; +}; + +const parseStays = (value: unknown): StayOption[] => { + return toArray(value) + .map((stay) => { + const name = stay?.name ?? stay?.hotel ?? stay?.property; + if (!name) return null; + + return cleanObject({ + name, + location: stay?.location ?? stay?.address ?? stay?.city, + checkIn: stay?.checkIn ?? stay?.check_in, + checkOut: stay?.checkOut ?? stay?.check_out, + nights: stay?.nights ?? stay?.length, + price: toNumber(stay?.price ?? stay?.cost), + currency: stay?.currency, + link: stay?.link ?? stay?.bookingLink, + description: stay?.description ?? stay?.notes, + rating: typeof stay?.rating === "number" ? stay.rating : undefined, + }) as StayOption; + }) + .filter(Boolean) as StayOption[]; +}; + +const parseActivities = (value: unknown): ActivityOption[] => { + return toArray(value) + .map((activity) => { + if (typeof activity === "string") { + return { name: activity } satisfies ActivityOption; + } + + const name = activity?.name ?? activity?.title; + if (!name) return null; + + return cleanObject({ + name, + category: activity?.category ?? activity?.type, + location: activity?.location ?? activity?.city, + time: activity?.time ?? activity?.slot, + description: activity?.description ?? activity?.details, + price: toNumber(activity?.price ?? activity?.cost), + currency: activity?.currency, + link: activity?.link ?? activity?.bookingLink, + }) as ActivityOption; + }) + .filter(Boolean) as ActivityOption[]; +}; + +const parseBudget = (value: unknown): BudgetSummary | undefined => { + if (!value) return undefined; + + const data = Array.isArray(value) ? value : [value]; + const breakdown: BudgetBreakdownItem[] = []; + let total: number | undefined; + let currency: string | undefined; + let notes: string | undefined; + + for (const item of data) { + if (!item) continue; + if (item.total !== undefined || item.overall !== undefined) { + total = toNumber(item.total ?? item.overall) ?? total; + currency = item.currency ?? currency; + continue; + } + if (item.notes || item.summary) { + notes = item.notes ?? item.summary; + } + if (item.label || item.category || item.type) { + const label = item.label ?? item.category ?? item.type; + const amount = toNumber(item.amount ?? item.cost ?? item.price); + if (label && amount !== undefined) { + breakdown.push( + cleanObject({ + label, + amount, + currency: item.currency, + }) as BudgetBreakdownItem + ); + } + } + } + + if ( + breakdown.length === 0 && + value && + typeof value === "object" && + !Array.isArray(value) + ) { + const record = value as Record; + const ignoredKeys = new Set(["total", "overall", "currency", "notes", "summary"]); + + for (const [label, amountRaw] of Object.entries(record)) { + if (ignoredKeys.has(label)) continue; + const amount = toNumber(amountRaw); + if (amount === undefined) continue; + const formattedLabel = label + .replace(/_/g, " ") + .replace(/\b\w/g, (char) => char.toUpperCase()); + breakdown.push( + cleanObject({ + label: formattedLabel, + amount, + currency: record.currency ?? currency, + }) as BudgetBreakdownItem + ); + } + + const inferredTotal = + toNumber(record.total ?? record.overall) ?? + (breakdown.length + ? breakdown.reduce((sum, entry) => sum + entry.amount, 0) + : undefined); + if (inferredTotal !== undefined) { + total = inferredTotal; + } + currency = record.currency ?? currency; + if (record.notes || record.summary) { + notes = record.notes ?? record.summary; + } + } + + if (!total && breakdown.length) { + total = breakdown.reduce((sum, entry) => sum + entry.amount, 0); + } + + if (total || breakdown.length || notes) { + return cleanObject({ + total, + currency, + breakdown, + notes, + }) as BudgetSummary; + } + + return undefined; +}; + +const parseVisualizations = (value: unknown): VisualizationResource[] => { + return toArray(value) + .map((viz) => { + const label = viz?.label ?? viz?.title ?? viz?.name; + const url = viz?.url ?? viz?.link ?? viz?.mapUrl; + const embed = viz?.embed ?? viz?.iframe; + if (!label && !url && !embed) return null; + + return cleanObject({ + label: label ?? "Visualization", + url, + type: viz?.type ?? viz?.variant, + description: viz?.description ?? viz?.notes, + embedHtml: embed, + }) as VisualizationResource; + }) + .filter(Boolean) as VisualizationResource[]; +}; + +const parseBookingTasks = (value: unknown): BookingTask[] => { + return toArray(value) + .map((task) => { + if (typeof task === "string") { + return { title: task } satisfies BookingTask; + } + + const title = task?.title ?? task?.name; + if (!title) return null; + + return cleanObject({ + title, + description: task?.description ?? task?.details, + link: task?.link ?? task?.bookingLink, + status: task?.status, + }) as BookingTask; + }) + .filter(Boolean) as BookingTask[]; +}; + +const parseStringList = (value: unknown): string[] => { + return toArray(value) + .map((item) => { + if (typeof item === "string") { + return item.trim(); + } + if (item && typeof item === "object") { + const record = item as Record; + const text = + record.title ?? + record.summary ?? + record.detail ?? + record.description ?? + record.note ?? + record.value ?? + ""; + return typeof text === "string" ? text.trim() : ""; + } + return ""; + }) + .filter((entry) => entry.length > 0); +}; + +const parseTripSummary = (value: unknown): TripSummary | undefined => { + if (!value || typeof value !== "object") return undefined; + + const summary = value as Record; + const result = cleanObject({ + destination: summary.destination ?? summary.location, + dates: summary.dates ?? summary.date_range, + duration: summary.duration ?? summary.length, + totalEstimatedCost: toNumber(summary.total_estimated_cost ?? summary.totalCost ?? summary.total_budget), + travelers: toNumber(summary.travelers ?? summary.guests), + }) as TripSummary; + + return Object.keys(result).length > 0 ? result : undefined; +}; + +const parseFlightLeg = (value: unknown): FlightLeg | undefined => { + if (!value || typeof value !== "object") return undefined; + + const leg = value as Record; + const legData = cleanObject({ + airline: leg.airline ?? leg.carrier, + flightNumber: leg.flight_number ?? leg.flightNumber, + from: leg.from ?? leg.origin ?? leg.departure_airport, + to: leg.to ?? leg.destination ?? leg.arrival_airport, + departure: leg.departure_time ?? leg.departure ?? leg.depart, + arrival: leg.arrival_time ?? leg.arrival ?? leg.arrive, + duration: leg.duration, + price: toNumber(leg.price ?? leg.cost ?? leg.max_price), + currency: leg.currency ?? leg.price_currency, + bookingLink: leg.booking_link ?? leg.link, + notes: leg.notes, + departureAirport: leg.departure_airport, + arrivalAirport: leg.arrival_airport, + }) as FlightLeg; + + if (!legData.airline && !legData.from && !legData.to) { + return undefined; + } + + return legData; +}; + +const parseFlightItinerary = (value: unknown): FlightItinerary | undefined => { + if (!value || typeof value !== "object") return undefined; + + const flights = value as Record; + const outboundLeg = parseFlightLeg(flights.outbound ?? flights.departure ?? flights.departing); + const returnLeg = parseFlightLeg(flights.return ?? flights.inbound ?? flights.arrival); + + if (!outboundLeg && !returnLeg) { + return undefined; + } + + return { + outbound: outboundLeg, + return: returnLeg, + }; +}; + +const flightLegToOption = (leg: FlightLeg): FlightOption => { + return cleanObject({ + airline: leg.airline, + from: leg.from ?? leg.departureAirport, + to: leg.to ?? leg.arrivalAirport, + departure: leg.departure, + arrival: leg.arrival, + duration: leg.duration, + price: leg.price, + currency: leg.currency, + bookingLink: leg.bookingLink, + notes: leg.flightNumber ? `Flight ${leg.flightNumber}` : leg.notes, + }) as FlightOption; +}; + +const parseAccommodationDetail = (value: unknown): StayOption | undefined => { + if (!value || typeof value !== "object") return undefined; + + const stay = value as Record; + const stayRecord: Record = { + name: stay.hotel_name ?? stay.name ?? stay.property, + location: stay.address ?? stay.location ?? stay.city, + checkIn: stay.check_in ?? stay.checkIn, + checkOut: stay.check_out ?? stay.checkOut, + nights: toNumber(stay.nights ?? stay.length), + price: toNumber(stay.total_price ?? stay.price_per_night ?? stay.price), + currency: stay.currency ?? stay.price_currency, + link: stay.booking_link ?? stay.link, + description: stay.description ?? stay.notes, + rating: typeof stay.rating === "number" ? stay.rating : toNumber(stay.rating), + }; + + const normalizedStay = cleanObject(stayRecord) as StayOption; + if (!normalizedStay.name) { + return undefined; + } + return normalizedStay; +}; + +const parseDailyActivitiesPlan = (value: unknown): TravelDayPlan[] => { + const days = toArray(value); + if (!days.length) return []; + + return days + .map((day, index) => { + const dayNumber = + typeof day?.day === "number" && Number.isFinite(day.day) ? day.day : index + 1; + const title = day?.title ?? `Day ${dayNumber}`; + const description = typeof day?.summary === "string" ? day.summary : undefined; + const location = day?.location ?? day?.city; + const activities = toArray(day?.activities); + + const segments = activities + .map((activity) => { + if (typeof activity === "string") { + const trimmed = activity.trim(); + if (!trimmed) return null; + return cleanObject({ + activity: trimmed, + }) as TravelSegment; + } + + if (activity && typeof activity === "object") { + const record = activity as Record; + const text = + record.activity ?? record.title ?? record.description ?? record.detail ?? ""; + if (!text || typeof text !== "string") return null; + return cleanObject({ + time: record.time ?? record.timeslot ?? record.period, + activity: text, + location: record.location ?? record.city, + details: record.details ?? record.notes, + cost: toNumber(record.price ?? record.cost), + link: record.link ?? record.booking_link, + }) as TravelSegment; + } + + return null; + }) + .filter(Boolean) as TravelSegment[]; + + if (!segments.length && description) { + segments.push({ + activity: description, + }); + } + + return cleanObject({ + title, + location, + description, + segments, + }) as TravelDayPlan; + }) + .filter((plan) => plan.segments.length > 0); +}; + +const parseHighlights = (value: unknown): string[] => { + return parseStringList(value); +}; + +const parseReferences = (value: unknown): string[] => { + return toArray(value) + .map((item) => { + if (typeof item === "string") return item; + return item?.url ?? item?.link ?? null; + }) + .filter((link): link is string => Boolean(link)); +}; + +const normalizeTravelPlan = (raw: unknown): NormalizedTravelPlan => { + const parsed = parseJson(raw) as Record | string | undefined; + + if (!parsed) { + return { + overview: undefined, + keyHighlights: [], + itinerary: [], + flights: [], + stays: [], + activities: [], + budget: undefined, + visualizations: [], + bookingTasks: [], + references: [], + travelTips: [], + nextSteps: [], + tripSummary: undefined, + flightItinerary: undefined, + accommodation: undefined, + raw, + }; + } + + if (typeof parsed === "string") { + return { + overview: parsed, + keyHighlights: [], + itinerary: [], + flights: [], + stays: [], + activities: [], + budget: undefined, + visualizations: [], + bookingTasks: [], + references: [], + travelTips: [], + nextSteps: [], + tripSummary: undefined, + flightItinerary: undefined, + accommodation: undefined, + raw, + }; + } + + let overview = + parsed.overview ?? + parsed.summary ?? + parsed.description ?? + parsed.planSummary ?? + parsed.resultSummary; + + let itinerary = parseItinerary( + parsed.itinerary ?? parsed.timeline ?? parsed.dailyPlan ?? parsed.days + ); + + let flights = parseFlights( + parsed.flights ?? parsed.flightOptions ?? parsed.transport + ); + let stays = parseStays( + parsed.accommodations ?? parsed.hotels ?? parsed.stays + ); + const activities = parseActivities( + parsed.activities ?? parsed.experiences ?? parsed.recommendations + ); + let budget = parseBudget(parsed.budget ?? parsed.costs ?? parsed.pricing); + const visualizations = parseVisualizations( + parsed.visualizations ?? parsed.maps ?? parsed.routes + ); + let bookingTasks = parseBookingTasks( + parsed.booking ?? parsed.nextSteps ?? parsed.actions ?? parsed.booking_next_steps + ); + const references = parseReferences( + parsed.references ?? parsed.links ?? parsed.sources + ); + let keyHighlights = parseHighlights( + parsed.highlights ?? parsed.keyPoints ?? parsed.takeaways ?? [] + ); + + const tripSummary = parseTripSummary(parsed.trip_summary); + const recommendedItinerary = parsed.recommended_itinerary; + const flightItinerary = parseFlightItinerary(recommendedItinerary?.flights); + const accommodation = parseAccommodationDetail(recommendedItinerary?.accommodation); + const recommendedDaily = parseDailyActivitiesPlan(recommendedItinerary?.daily_activities); + const travelTips = parseStringList(parsed.travel_tips); + const nextSteps = parseStringList(parsed.booking_next_steps); + + if (!overview && tripSummary) { + const destinationText = tripSummary.destination ? ` to ${tripSummary.destination}` : ""; + const durationText = tripSummary.duration ? ` for ${tripSummary.duration}` : ""; + overview = `Journey${destinationText}${durationText}`.trim(); + } + + if (recommendedDaily.length) { + itinerary = recommendedDaily; + } + + if (flightItinerary) { + const itineraryFlights: FlightOption[] = []; + if (flightItinerary.outbound) { + itineraryFlights.push(flightLegToOption(flightItinerary.outbound)); + } + if (flightItinerary.return) { + itineraryFlights.push(flightLegToOption(flightItinerary.return)); + } + if (itineraryFlights.length) { + flights = [...itineraryFlights, ...flights]; + } + } + + if (accommodation) { + const existingNames = new Set(stays.map((stay) => stay.name)); + if (!existingNames.has(accommodation.name)) { + stays = [accommodation, ...stays]; + } + } + + if (parsed.budget_breakdown) { + const breakdownBudget = parseBudget(parsed.budget_breakdown); + if (breakdownBudget) { + budget = breakdownBudget; + } + } + + if (!keyHighlights.length && travelTips.length) { + keyHighlights = travelTips.slice(0, 3); + } + + if (nextSteps.length) { + const taskTitles = new Set(bookingTasks.map((task) => task.title)); + const additionalTasks = nextSteps + .filter((step) => !taskTitles.has(step)) + .map((step) => ({ title: step } satisfies BookingTask)); + if (additionalTasks.length) { + bookingTasks = [...bookingTasks, ...additionalTasks]; + } + } + + return { + overview, + keyHighlights, + itinerary, + flights, + stays, + activities, + budget, + visualizations, + bookingTasks, + references, + travelTips, + nextSteps, + tripSummary, + flightItinerary, + accommodation, + raw, + }; +}; + +export async function planTrip( + preferences: TravelPreferences +): Promise { + try { + const interestsList: string[] = Array.isArray(preferences.activities) + ? preferences.activities + .map((item) => item.trim()) + .filter((item) => item.length > 0) + : []; + + const payload = cleanObject({ + origin: preferences.origin.trim(), + destination: preferences.destination.trim(), + start_date: preferences.startDate, + end_date: preferences.endDate, + budget: Number.isFinite(preferences.budget) + ? preferences.budget + : undefined, + travelers: Number.isFinite(preferences.travelers) + ? preferences.travelers + : undefined, + flight_class: preferences.flightClass?.trim(), + interests: interestsList.length > 0 ? interestsList : undefined, + }); + + const response = await lamaticClient.executeFlow( + travelPlannerFlow.workflowId, + payload + ); + + const status = response?.status ?? response?.result?.status ?? "unknown"; + const rawResult = + response?.result?.result ?? + response?.result?.data ?? + response?.result ?? + response; + + const plan = normalizeTravelPlan(rawResult); + + return { + success: true, + status, + plan, + }; + } catch (error) { + console.error( + "[travel-planner] Failed to execute travel planner flow", + error + ); + const message = + error instanceof Error + ? error.message + : "Unexpected error while executing travel planner workflow."; + + return { + success: false, + error: message, + }; + } +} diff --git a/templates/agentic/travel-planner/app/globals.css b/templates/agentic/travel-planner/app/globals.css new file mode 100644 index 0000000..23cc7e3 --- /dev/null +++ b/templates/agentic/travel-planner/app/globals.css @@ -0,0 +1,125 @@ +@import 'tailwindcss'; +@import 'tw-animate-css'; + +@custom-variant dark (&:is(.dark *)); + +:root { + --background: oklch(1 0 0); + --foreground: oklch(0.145 0 0); + --card: oklch(1 0 0); + --card-foreground: oklch(0.145 0 0); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.145 0 0); + --primary: oklch(0.205 0 0); + --primary-foreground: oklch(0.985 0 0); + --secondary: oklch(0.97 0 0); + --secondary-foreground: oklch(0.205 0 0); + --muted: oklch(0.97 0 0); + --muted-foreground: oklch(0.556 0 0); + --accent: oklch(0.97 0 0); + --accent-foreground: oklch(0.205 0 0); + --destructive: oklch(0.577 0.245 27.325); + --destructive-foreground: oklch(0.577 0.245 27.325); + --border: oklch(0.922 0 0); + --input: oklch(0.922 0 0); + --ring: oklch(0.708 0 0); + --chart-1: oklch(0.646 0.222 41.116); + --chart-2: oklch(0.6 0.118 184.704); + --chart-3: oklch(0.398 0.07 227.392); + --chart-4: oklch(0.828 0.189 84.429); + --chart-5: oklch(0.769 0.188 70.08); + --radius: 0.625rem; + --sidebar: oklch(0.985 0 0); + --sidebar-foreground: oklch(0.145 0 0); + --sidebar-primary: oklch(0.205 0 0); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.97 0 0); + --sidebar-accent-foreground: oklch(0.205 0 0); + --sidebar-border: oklch(0.922 0 0); + --sidebar-ring: oklch(0.708 0 0); +} + +.dark { + --background: oklch(0.145 0 0); + --foreground: oklch(0.985 0 0); + --card: oklch(0.145 0 0); + --card-foreground: oklch(0.985 0 0); + --popover: oklch(0.145 0 0); + --popover-foreground: oklch(0.985 0 0); + --primary: oklch(0.985 0 0); + --primary-foreground: oklch(0.205 0 0); + --secondary: oklch(0.269 0 0); + --secondary-foreground: oklch(0.985 0 0); + --muted: oklch(0.269 0 0); + --muted-foreground: oklch(0.708 0 0); + --accent: oklch(0.269 0 0); + --accent-foreground: oklch(0.985 0 0); + --destructive: oklch(0.396 0.141 25.723); + --destructive-foreground: oklch(0.637 0.237 25.331); + --border: oklch(0.269 0 0); + --input: oklch(0.269 0 0); + --ring: oklch(0.439 0 0); + --chart-1: oklch(0.488 0.243 264.376); + --chart-2: oklch(0.696 0.17 162.48); + --chart-3: oklch(0.769 0.188 70.08); + --chart-4: oklch(0.627 0.265 303.9); + --chart-5: oklch(0.645 0.246 16.439); + --sidebar: oklch(0.205 0 0); + --sidebar-foreground: oklch(0.985 0 0); + --sidebar-primary: oklch(0.488 0.243 264.376); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.269 0 0); + --sidebar-accent-foreground: oklch(0.985 0 0); + --sidebar-border: oklch(0.269 0 0); + --sidebar-ring: oklch(0.439 0 0); +} + +@theme inline { + --font-sans: var(--font-geist-sans); + --font-mono: var(--font-geist-mono); + --color-background: var(--background); + --color-foreground: var(--foreground); + --color-card: var(--card); + --color-card-foreground: var(--card-foreground); + --color-popover: var(--popover); + --color-popover-foreground: var(--popover-foreground); + --color-primary: var(--primary); + --color-primary-foreground: var(--primary-foreground); + --color-secondary: var(--secondary); + --color-secondary-foreground: var(--secondary-foreground); + --color-muted: var(--muted); + --color-muted-foreground: var(--muted-foreground); + --color-accent: var(--accent); + --color-accent-foreground: var(--accent-foreground); + --color-destructive: var(--destructive); + --color-destructive-foreground: var(--destructive-foreground); + --color-border: var(--border); + --color-input: var(--input); + --color-ring: var(--ring); + --color-chart-1: var(--chart-1); + --color-chart-2: var(--chart-2); + --color-chart-3: var(--chart-3); + --color-chart-4: var(--chart-4); + --color-chart-5: var(--chart-5); + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) + 4px); + --color-sidebar: var(--sidebar); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-ring: var(--sidebar-ring); +} + +@layer base { + * { + @apply border-border outline-ring/50; + } + body { + @apply bg-background text-foreground; + } +} diff --git a/templates/agentic/travel-planner/app/icon.png b/templates/agentic/travel-planner/app/icon.png new file mode 100644 index 0000000..8df2938 Binary files /dev/null and b/templates/agentic/travel-planner/app/icon.png differ diff --git a/templates/agentic/travel-planner/app/layout.tsx b/templates/agentic/travel-planner/app/layout.tsx new file mode 100644 index 0000000..a73eb6b --- /dev/null +++ b/templates/agentic/travel-planner/app/layout.tsx @@ -0,0 +1,28 @@ +import type React from "react" +import type { Metadata } from "next" +import { GeistSans } from "geist/font/sans" +import { GeistMono } from "geist/font/mono" +import { Analytics } from "@vercel/analytics/next" +import { Suspense } from "react" +import "./globals.css" + +export const metadata: Metadata = { + title: "Agent Kit Reasoning", + description: "AI-powered search and chat interface by Lamatic.ai", + generator: "v0.app", +} + +export default function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode +}>) { + return ( + + + {children} + + + + ) +} diff --git a/templates/agentic/travel-planner/app/page.tsx b/templates/agentic/travel-planner/app/page.tsx new file mode 100644 index 0000000..cd3092b --- /dev/null +++ b/templates/agentic/travel-planner/app/page.tsx @@ -0,0 +1,1541 @@ +"use client"; + +import { useMemo, useState, useTransition } from "react"; +import Link from "next/link"; +import ReactMarkdown from "react-markdown"; +import { + planTrip, + type FlightLeg, + type NormalizedTravelPlan, +} from "@/actions/orchestrate"; +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { Separator } from "@/components/ui/separator"; +import { cn } from "@/lib/utils"; +import { + ArrowRight, + BedDouble, + CalendarDays, + CheckCircle2, + Compass, + DollarSign, + Earth, + ExternalLink, + Globe2, + Lightbulb, + ListChecks, + Loader2, + MapPinned, + Plane, + PlaneLanding, + PlaneTakeoff, + Sparkles, +} from "lucide-react"; + +const MAX_ACTIVITIES = 3; + +interface PlannerFormState { + origin: string; + destination: string; + startDate: string; + endDate: string; + budget: string; + travelers: string; + flightClass: string; + activities: string[]; +} + +const ACTIVITY_OPTIONS: { value: string; label: string }[] = [ + { value: "art", label: "Art & design" }, + { value: "food", label: "Food experiences" }, + { value: "history", label: "History & heritage" }, + { value: "adventure", label: "Adventure" }, + { value: "nature", label: "Nature & outdoors" }, + { value: "shopping", label: "Shopping" }, + { value: "nightlife", label: "Nightlife" }, + { value: "culture", label: "Culture" }, + { value: "family", label: "Family-friendly" }, + { value: "music", label: "Music & events" }, + { value: "architecture", label: "Architecture" }, +]; + +const FLIGHT_CLASS_OPTIONS = [ + { value: "economy", label: "Economy" }, + { value: "premium_economy", label: "Premium economy" }, + { value: "business", label: "Business" }, + { value: "first", label: "First" }, +]; + +const PRESET_TRIPS: { label: string; data: Partial }[] = [ + { + label: "Lisbon remote work week", + data: { + origin: "Madrid, Spain", + destination: "Lisbon, Portugal", + startDate: "2026-03-03", + endDate: "2026-03-10", + budget: "1800", + travelers: "2", + flightClass: "economy", + activities: ["food", "culture", "nightlife"], + }, + }, + { + label: "Tokyo family adventure", + data: { + origin: "Los Angeles, USA", + destination: "Tokyo, Japan", + startDate: "2026-06-15", + endDate: "2026-06-25", + budget: "6500", + travelers: "4", + flightClass: "premium_economy", + activities: ["family", "food", "art"], + }, + }, + { + label: "Patagonia trekking escape", + data: { + origin: "Buenos Aires, Argentina", + destination: "El Calafate, Argentina", + startDate: "2026-11-04", + endDate: "2026-11-12", + budget: "3200", + travelers: "2", + flightClass: "economy", + activities: ["adventure", "nature", "history"], + }, + }, +]; + +const defaultFormState: PlannerFormState = { + origin: "", + destination: "", + startDate: "", + endDate: "", + budget: "2500", + travelers: "2", + flightClass: "economy", + activities: [] as string[], +}; + +const parseNumber = (value: string, fallback: number) => { + const parsed = Number(value); + if (Number.isFinite(parsed)) { + return Math.max(parsed, 0); + } + return fallback; +}; + +const formatCurrency = (value?: number, currency = "USD") => { + if (value === undefined) return undefined; + try { + return new Intl.NumberFormat("en-US", { + style: "currency", + currency, + maximumFractionDigits: 0, + }).format(value); + } catch { + return `${currency} ${value.toFixed(0)}`; + } +}; + +const TravelPlannerPage = () => { + const [form, setForm] = useState(defaultFormState); + const [submittedForm, setSubmittedForm] = useState( + null + ); + const [plan, setPlan] = useState(null); + const [status, setStatus] = useState(undefined); + const [error, setError] = useState(null); + const [activityError, setActivityError] = useState(null); + const [isPending, startTransition] = useTransition(); + + const today = useMemo(() => new Date().toISOString().split("T")[0], []); + + const endDateMin = useMemo(() => { + if (form.startDate) { + return form.startDate > today ? form.startDate : today; + } + return today; + }, [form.startDate, today]); + + const isFormIncomplete = + !form.origin.trim() || + !form.destination.trim() || + !form.startDate || + !form.endDate || + form.activities.length === 0; + + const rawOutput = useMemo(() => { + if (!plan) return ""; + if (typeof plan.raw === "string") return plan.raw; + try { + return JSON.stringify(plan.raw, null, 2); + } catch { + return String(plan.raw); + } + }, [plan]); + + const tripSummaryData = useMemo(() => { + if (plan?.tripSummary) { + return plan.tripSummary; + } + if (!submittedForm) return null; + const travelersValue = + submittedForm.travelers && submittedForm.travelers.length > 0 + ? parseNumber(submittedForm.travelers, 0) + : undefined; + return { + destination: submittedForm.destination || undefined, + dates: + submittedForm.startDate && submittedForm.endDate + ? `${submittedForm.startDate} → ${submittedForm.endDate}` + : undefined, + duration: undefined, + totalEstimatedCost: plan?.budget?.total, + travelers: + travelersValue && travelersValue > 0 ? travelersValue : undefined, + }; + }, [plan, submittedForm]); + + const outboundLeg = plan?.flightItinerary?.outbound; + const returnLeg = plan?.flightItinerary?.return; + const fallbackFlights = !outboundLeg && !returnLeg ? plan?.flights ?? [] : []; + const accommodation = + plan?.accommodation ?? + (plan?.stays && plan.stays.length > 0 ? plan.stays[0] : undefined); + const travelTips = plan?.travelTips ?? []; + const bookingTasks = plan?.bookingTasks ?? []; + const totalCostDisplay = + tripSummaryData?.totalEstimatedCost !== undefined + ? formatCurrency( + tripSummaryData.totalEstimatedCost, + plan?.budget?.currency ?? "USD" + ) + : plan?.budget?.total !== undefined + ? formatCurrency(plan.budget.total, plan?.budget?.currency ?? "USD") + : undefined; + const destinationDisplay = + tripSummaryData?.destination ?? submittedForm?.destination; + const datesDisplay = + tripSummaryData?.dates ?? + (submittedForm?.startDate && submittedForm?.endDate + ? `${submittedForm.startDate} → ${submittedForm.endDate}` + : undefined); + const durationDisplay = tripSummaryData?.duration; + const travelersDisplay = + tripSummaryData?.travelers !== undefined + ? `${tripSummaryData.travelers}` + : submittedForm?.travelers || undefined; + const flightClassLabel = submittedForm + ? FLIGHT_CLASS_OPTIONS.find( + (option) => option.value === submittedForm.flightClass + )?.label + : undefined; + + const handleActivityToggle = (value: string) => { + setForm((prev) => { + const alreadySelected = prev.activities.includes(value); + if (alreadySelected) { + const nextActivities = prev.activities.filter( + (activity) => activity !== value + ); + setActivityError(null); + return { ...prev, activities: nextActivities }; + } + if (prev.activities.length >= MAX_ACTIVITIES) { + setActivityError(`Choose up to ${MAX_ACTIVITIES} interests.`); + return prev; + } + setActivityError(null); + return { ...prev, activities: [...prev.activities, value] }; + }); + }; + + const handlePreset = (data: Partial) => { + setForm((prev) => ({ + ...prev, + ...data, + activities: data.activities ? [...data.activities] : [...prev.activities], + })); + setError(null); + setActivityError(null); + }; + + const handleSubmit = (event: React.FormEvent) => { + event.preventDefault(); + + if (form.activities.length === 0) { + setActivityError( + "Pick at least one interest so the researcher agents can specialize." + ); + return; + } + + setError(null); + setStatus(undefined); + setActivityError(null); + + const snapshot: PlannerFormState = { + ...form, + activities: [...form.activities], + }; + + setSubmittedForm(snapshot); + setPlan(null); + + startTransition(async () => { + const result = await planTrip({ + origin: snapshot.origin, + destination: snapshot.destination, + startDate: snapshot.startDate, + endDate: snapshot.endDate, + budget: parseNumber(snapshot.budget, 0), + travelers: parseNumber(snapshot.travelers, 1), + flightClass: snapshot.flightClass || undefined, + activities: snapshot.activities, + }); + + if (!result.success) { + setPlan(null); + setStatus(undefined); + setError(result.error); + return; + } + + setPlan(result.plan); + setStatus(result.status); + }); + }; + + const renderFlightLeg = (leg: FlightLeg | undefined, label: string) => { + if (!leg) return null; + const priceLabel = + leg.price !== undefined + ? formatCurrency(leg.price, leg.currency ?? "USD") + : undefined; + + return ( +
+
+
+ {label === "Outbound" ? ( + + ) : ( + + )} + + {leg.airline ?? "Flight"} + +
+ {priceLabel && ( + + {priceLabel} + + )} +
+
+
+ + {leg.from ?? leg.departureAirport} + + + + {leg.to ?? leg.arrivalAirport} + +
+
+ {leg.departure && Depart: {leg.departure}} + {leg.arrival && Arrive: {leg.arrival}} + {leg.duration && Duration: {leg.duration}} + {leg.flightNumber && Flight: {leg.flightNumber}} + {leg.notes && {leg.notes}} +
+
+
+ ); + }; + + return ( +
+
+
+
+
+
+
+
+ + + Lamatic Multi-Agent Kit + +
+

+ Multi-Agent Travel Researcher & Planner +

+

+ Design dream itineraries backed by real-time flights, stays, and + activities. Delegate research to specialized Lamatic agents, + visualize routes, and ship a booking-ready travel brief. +

+
+
+ + +
+
+ +
+ + + Travel snapshot + + + Agents coordinate flights, stays, and experiences—then render a + booking-ready journey. + + + +
+

+ Flight scout +

+

+ Live routes & fares +

+
+
+

+ Stay curator +

+

+ Hotels & rentals +

+
+
+

+ Adventure graph +

+

+ Visual itineraries with budget breakdowns, booking tasks, and + shareable briefs. +

+
+
+ +
+
+ +
+
+
+ + + + + Plan a new journey + + + Share the essentials and we will orchestrate research, + visualization, and booking-ready output. + + + +
+
+
+ + + setForm((prev) => ({ + ...prev, + origin: event.target.value, + })) + } + required + /> +
+
+ + + setForm((prev) => ({ + ...prev, + destination: event.target.value, + })) + } + required + /> +
+
+ +
+
+ + { + const rawValue = event.target.value; + setForm((prev) => { + if (!rawValue) { + return { ...prev, startDate: "", endDate: "" }; + } + const normalizedValue = + rawValue < today ? today : rawValue; + const nextState: PlannerFormState = { + ...prev, + startDate: normalizedValue, + }; + if ( + prev.endDate && + prev.endDate < normalizedValue + ) { + nextState.endDate = normalizedValue; + } + return nextState; + }); + }} + required + /> +
+
+ + { + const rawValue = event.target.value; + setForm((prev) => { + if (!rawValue) { + return { ...prev, endDate: "" }; + } + const minValue = + prev.startDate && prev.startDate > today + ? prev.startDate + : today; + const normalizedValue = + rawValue < minValue ? minValue : rawValue; + return { ...prev, endDate: normalizedValue }; + }); + }} + required + /> +
+
+ +
+
+ + + setForm((prev) => ({ + ...prev, + budget: event.target.value, + })) + } + /> +
+
+ + + setForm((prev) => ({ + ...prev, + travelers: event.target.value, + })) + } + /> +
+
+ + +
+
+ +
+
+
+ +

+ Interests help route tasks to the right research + agents and activity providers. +

+
+ + {form.activities.length}/{MAX_ACTIVITIES} selected + +
+
+ {ACTIVITY_OPTIONS.map((option) => { + const selected = form.activities.includes(option.value); + return ( + + ); + })} +
+ {activityError && ( + + Activity selection + {activityError} + + )} +
+ +
+ +
+ {PRESET_TRIPS.map((preset) => ( + + ))} +
+
+ +
+

+ The coordinator agent fans out to flight, lodging, + activity, budget, and booking sub-agents. +

+ +
+
+
+
+ + {error && ( + + + We hit an issue while orchestrating the workflow + + {error} + + )} +
+ + {(isPending || plan) && ( +
+
+
+

+ Itinerary & research output +

+

+ Results blend research, visualization, and booking + instructions for the preferences above. +

+
+ {status && ( + + + Status: {status.toUpperCase()} + + )} +
+ + {isPending && ( + + + +
+

+ Coordinating planner, flight, hotel, and experience + agents… +

+

+ Live web search and provider APIs can take a few + seconds. +

+
+
+
+ )} + + {!isPending && plan && ( +
+
+ + + + + Overview + + + A cross-agent summary that fuses research, + availability checks, and suggested flow. + + + + {plan.overview ? ( +
+ {plan.overview} +
+ ) : ( +

+ No high-level overview returned by the workflow – + check the detailed sections below. +

+ )} + {plan.keyHighlights.length > 0 && ( +
+ {plan.keyHighlights.map((highlight, index) => ( + + + {highlight} + + ))} +
+ )} +
+
+ + + + + + Trip summary + + + Derived from Lamatic's trip summary and the + preferences you provided. + + + +
+
+ + Origin + + + {submittedForm?.origin || "—"} + +
+
+ + Destination + + + {destinationDisplay || "—"} + +
+
+ Dates + + {datesDisplay || "—"} + +
+ {durationDisplay && ( +
+ + Duration + + + {durationDisplay} + +
+ )} +
+ + Travelers + + + {travelersDisplay || "—"} + +
+
+ + Flight class + + + {flightClassLabel || "—"} + +
+ {totalCostDisplay && ( +
+ + Est. spend + + + {totalCostDisplay} + +
+ )} +
+ +
+

Interests

+
+ {submittedForm?.activities.length ? ( + submittedForm.activities.map((activity) => { + const label = + ACTIVITY_OPTIONS.find( + (option) => option.value === activity + )?.label ?? activity; + return ( + + {label} + + ); + }) + ) : ( + + No interests selected + + )} +
+
+
+
+
+ + {plan.itinerary.length > 0 && ( + + + + + Daily itinerary + + + Timeline generated by the itinerary planner agent. + + + + {plan.itinerary.map((day, index) => ( +
+
+ + Day {index + 1} + +

+ {day.title} +

+ {day.location && ( +
+ + {day.location} +
+ )} +
+ {day.description && ( +

+ {day.description} +

+ )} + {day.segments.length > 0 && ( +
+ {day.segments.map((segment, segmentIndex) => ( +
+
+
+ {segment.time && ( + + {segment.time} + + )} +

+ {segment.activity} +

+
+ {(segment.location || + segment.details) && ( +
+ {segment.location && ( + + + {segment.location} + + )} + {segment.details && ( + {segment.details} + )} +
+ )} +
+
+ {segment.cost !== undefined && ( + + Approx. {formatCurrency(segment.cost)} + + )} + {segment.link && ( + + )} +
+
+ ))} +
+ )} +
+ ))} +
+
+ )} + + {(outboundLeg || + returnLeg || + (fallbackFlights && fallbackFlights.length > 0)) && ( + + + + + Flight options + + + Curated by the flight research sub-agent. + + + + {outboundLeg && + renderFlightLeg(outboundLeg, "Outbound")} + {returnLeg && renderFlightLeg(returnLeg, "Return")} + {!outboundLeg && + !returnLeg && + fallbackFlights.map((flight, index) => ( +
+
+
+ {flight.airline ?? "Flight option"} +
+ {flight.price !== undefined && ( + + {formatCurrency( + flight.price, + flight.currency + )} + + )} +
+
+
+ + {flight.from} + + + + {flight.to} + +
+
+ {flight.departure && ( + Depart: {flight.departure} + )} + {flight.arrival && ( + Arrive: {flight.arrival} + )} + {flight.duration && ( + Duration: {flight.duration} + )} + {flight.notes && {flight.notes}} +
+
+ {flight.bookingLink && ( + + )} +
+ ))} +
+
+ )} + + {plan.stays.length > 0 && ( + + + + + Stay recommendations + + + Produced by the lodging planner agent with live + availability checks. + + + + {plan.stays.map((stay, index) => { + const isPrimary = + accommodation && stay.name === accommodation.name; + return ( +
+
+
+

+ {stay.name} +

+ {stay.location && ( +

+ {stay.location} +

+ )} +
+ {isPrimary && ( + + Primary stay + + )} +
+
+ {stay.checkIn && stay.checkOut && ( + + {stay.checkIn} → {stay.checkOut} + + )} + {stay.nights && ( + {stay.nights} nights + )} + {stay.description && ( + {stay.description} + )} +
+
+ {stay.price !== undefined && ( + + {formatCurrency(stay.price, stay.currency)} + + )} + {stay.rating && ( + + {stay.rating.toFixed(1)} rating + + )} +
+ {stay.link && ( + + )} +
+ ); + })} +
+
+ )} + + {plan.activities.length > 0 && ( + + + + + Experiences & add-ons + + + Fielded by the attraction scout and local culture + agents. + + + + {plan.activities.map((activity, index) => ( +
+
+

{activity.name}

+ {activity.price !== undefined && ( + + {formatCurrency( + activity.price, + activity.currency + )} + + )} +
+
+ {activity.category && ( + {activity.category} + )} + {activity.location && ( + Where: {activity.location} + )} + {activity.time && ( + When: {activity.time} + )} + {activity.description && ( + {activity.description} + )} +
+ {activity.link && ( + + )} +
+ ))} +
+
+ )} + + {plan.budget && ( + + + + + Budget summary + + + Compiled by the budget allocation agent. + + + + {plan.budget.total !== undefined && ( +
+ Estimated total:{" "} + + {formatCurrency( + plan.budget.total, + plan.budget.currency + )} + +
+ )} + {plan.budget.breakdown.length > 0 && ( +
+ {plan.budget.breakdown.map((item, index) => ( +
+ {item.label} + + {formatCurrency( + item.amount, + item.currency ?? + plan.budget?.currency ?? + "USD" + )} + +
+ ))} +
+ )} + {plan.budget.notes && ( +

+ {plan.budget.notes} +

+ )} +
+
+ )} + + {travelTips.length > 0 && ( + + + + + Travel tips + + + Quick wins sourced from the coordinator and local + insight agents. + + + +
    + {travelTips.map((tip, index) => ( +
  • + + {tip} +
  • + ))} +
+
+
+ )} + + {plan.visualizations.length > 0 && ( + + + + + Visualizations & routes + + + Rendered by the mapping and timeline visualization + agent. + + + + {plan.visualizations.map((viz, index) => ( +
+

{viz.label}

+ {viz.description && ( +

+ {viz.description} +

+ )} +
+ {viz.type && ( + {viz.type} + )} + {viz.url && ( + + )} +
+ {viz.embedHtml && ( +
+ )} +
+ ))} + + + )} + + {bookingTasks.length > 0 && ( + + + + + Booking checklist + + + Suggested next steps to finalize the itinerary. + + + + {bookingTasks.map((task, index) => ( +
+
+

{task.title}

+ {task.status && ( + + {task.status} + + )} +
+ {task.description && ( +

+ {task.description} +

+ )} + {task.link && ( + + )} +
+ ))} +
+
+ )} + + {plan.references.length > 0 && ( + + + Research references + + Primary sources gathered by the research sub-agents. + + + +
+ {plan.references.map((link, index) => ( +
+ + Source {index + 1} + + +
+ ))} +
+
+
+ )} +
+ )} +
+ )} +
+
+
+ ); +}; + +export default TravelPlannerPage; diff --git a/templates/agentic/travel-planner/components.json b/templates/agentic/travel-planner/components.json new file mode 100644 index 0000000..335484f --- /dev/null +++ b/templates/agentic/travel-planner/components.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "", + "css": "app/globals.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "iconLibrary": "lucide" +} \ No newline at end of file diff --git a/templates/agentic/travel-planner/components/theme-provider.tsx b/templates/agentic/travel-planner/components/theme-provider.tsx new file mode 100644 index 0000000..55c2f6e --- /dev/null +++ b/templates/agentic/travel-planner/components/theme-provider.tsx @@ -0,0 +1,11 @@ +'use client' + +import * as React from 'react' +import { + ThemeProvider as NextThemesProvider, + type ThemeProviderProps, +} from 'next-themes' + +export function ThemeProvider({ children, ...props }: ThemeProviderProps) { + return {children} +} diff --git a/templates/agentic/travel-planner/components/ui/accordion.tsx b/templates/agentic/travel-planner/components/ui/accordion.tsx new file mode 100644 index 0000000..4a8cca4 --- /dev/null +++ b/templates/agentic/travel-planner/components/ui/accordion.tsx @@ -0,0 +1,66 @@ +"use client" + +import * as React from "react" +import * as AccordionPrimitive from "@radix-ui/react-accordion" +import { ChevronDownIcon } from "lucide-react" + +import { cn } from "@/lib/utils" + +function Accordion({ + ...props +}: React.ComponentProps) { + return +} + +function AccordionItem({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AccordionTrigger({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + svg]:rotate-180", + className + )} + {...props} + > + {children} + + + + ) +} + +function AccordionContent({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + +
{children}
+
+ ) +} + +export { Accordion, AccordionItem, AccordionTrigger, AccordionContent } diff --git a/templates/agentic/travel-planner/components/ui/alert-dialog.tsx b/templates/agentic/travel-planner/components/ui/alert-dialog.tsx new file mode 100644 index 0000000..0863e40 --- /dev/null +++ b/templates/agentic/travel-planner/components/ui/alert-dialog.tsx @@ -0,0 +1,157 @@ +"use client" + +import * as React from "react" +import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog" + +import { cn } from "@/lib/utils" +import { buttonVariants } from "@/components/ui/button" + +function AlertDialog({ + ...props +}: React.ComponentProps) { + return +} + +function AlertDialogTrigger({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogPortal({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogOverlay({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogContent({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + + ) +} + +function AlertDialogHeader({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function AlertDialogFooter({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function AlertDialogTitle({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogAction({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogCancel({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { + AlertDialog, + AlertDialogPortal, + AlertDialogOverlay, + AlertDialogTrigger, + AlertDialogContent, + AlertDialogHeader, + AlertDialogFooter, + AlertDialogTitle, + AlertDialogDescription, + AlertDialogAction, + AlertDialogCancel, +} diff --git a/templates/agentic/travel-planner/components/ui/alert.tsx b/templates/agentic/travel-planner/components/ui/alert.tsx new file mode 100644 index 0000000..1421354 --- /dev/null +++ b/templates/agentic/travel-planner/components/ui/alert.tsx @@ -0,0 +1,66 @@ +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const alertVariants = cva( + "relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current", + { + variants: { + variant: { + default: "bg-card text-card-foreground", + destructive: + "text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +function Alert({ + className, + variant, + ...props +}: React.ComponentProps<"div"> & VariantProps) { + return ( +
+ ) +} + +function AlertTitle({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function AlertDescription({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ ) +} + +export { Alert, AlertTitle, AlertDescription } diff --git a/templates/agentic/travel-planner/components/ui/aspect-ratio.tsx b/templates/agentic/travel-planner/components/ui/aspect-ratio.tsx new file mode 100644 index 0000000..3df3fd0 --- /dev/null +++ b/templates/agentic/travel-planner/components/ui/aspect-ratio.tsx @@ -0,0 +1,11 @@ +"use client" + +import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio" + +function AspectRatio({ + ...props +}: React.ComponentProps) { + return +} + +export { AspectRatio } diff --git a/templates/agentic/travel-planner/components/ui/avatar.tsx b/templates/agentic/travel-planner/components/ui/avatar.tsx new file mode 100644 index 0000000..71e428b --- /dev/null +++ b/templates/agentic/travel-planner/components/ui/avatar.tsx @@ -0,0 +1,53 @@ +"use client" + +import * as React from "react" +import * as AvatarPrimitive from "@radix-ui/react-avatar" + +import { cn } from "@/lib/utils" + +function Avatar({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AvatarImage({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AvatarFallback({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { Avatar, AvatarImage, AvatarFallback } diff --git a/templates/agentic/travel-planner/components/ui/badge.tsx b/templates/agentic/travel-planner/components/ui/badge.tsx new file mode 100644 index 0000000..0205413 --- /dev/null +++ b/templates/agentic/travel-planner/components/ui/badge.tsx @@ -0,0 +1,46 @@ +import * as React from "react" +import { Slot } from "@radix-ui/react-slot" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const badgeVariants = cva( + "inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden", + { + variants: { + variant: { + default: + "border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90", + secondary: + "border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90", + destructive: + "border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", + outline: + "text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +function Badge({ + className, + variant, + asChild = false, + ...props +}: React.ComponentProps<"span"> & + VariantProps & { asChild?: boolean }) { + const Comp = asChild ? Slot : "span" + + return ( + + ) +} + +export { Badge, badgeVariants } diff --git a/templates/agentic/travel-planner/components/ui/breadcrumb.tsx b/templates/agentic/travel-planner/components/ui/breadcrumb.tsx new file mode 100644 index 0000000..eb88f32 --- /dev/null +++ b/templates/agentic/travel-planner/components/ui/breadcrumb.tsx @@ -0,0 +1,109 @@ +import * as React from "react" +import { Slot } from "@radix-ui/react-slot" +import { ChevronRight, MoreHorizontal } from "lucide-react" + +import { cn } from "@/lib/utils" + +function Breadcrumb({ ...props }: React.ComponentProps<"nav">) { + return