import React, { useEffect, useState } from "react"; import { Sparkles, X, Loader2, Check, AlertCircle, Replace, Plus, Calendar, HelpCircle, RefreshCw, } from "lucide-react"; import { __ } from "../../lib/i18n"; import { aiApi } from "../../api/ai-api"; import { isAiReady } from "../../lib/ai-availability"; import { Button } from "../ui/button"; import { Modal } from "../ui/modal"; import { detectClarification } from "./clarificationDetector"; /** * "Build Itinerary with AI" — the standalone version that lives on the * Itinerary page (Yatra → Itinerary → trip selected → ✨ button). * * Flow: * 1. Generate phase: kicks off /ai/itinerary/{tripId}/draft → returns * a parsed days[] array (server-side parsing, so a bad LLM response * that the React parser would choke on still surfaces meaningfully). * 2. Preview phase: shows each day's title + description with the * ability to scroll. Operator picks `Replace` (drop existing * AI/manual days) or `Add` (append after existing days). * 3. Apply phase: posts /ai/itinerary/{tripId}/apply with the chosen * mode. Modal closes on success and the parent triggers a refetch. */ interface ParsedActivity { title: string; description?: string; item_type?: string; item_name?: string; start_time?: string; end_time?: string; duration?: string; location?: string; } interface ParsedDay { day: number; day_title: string; description: string; /** Day entries (activity / meal / accommodation / transport blocks) * the agent populates under each day. May be empty for days that * the agent decided don't need a granular schedule. */ activities?: ParsedActivity[]; } interface BuildItineraryModalProps { open: boolean; onClose: () => void; /** Trip selected on the Itinerary page. */ tripId: number; /** Display label for the trip — purely cosmetic, helps the operator * confirm they're building for the right trip. */ tripName: string; /** Duration from the trip record. We use this to drive UX before the * request even fires: 0 means the operator needs to set duration on * the Trip form first; otherwise show the actual day count so * there's no doubt what AI will draft. */ tripDurationDays: number; /** Called after a successful apply so the parent can refetch the * itinerary list to surface the new rows. */ onApplied: (info: { count: number; message: string }) => void; } type Phase = | "intro" | "generating" | "preview" | "applying" | "done" | "error" | "needs_context"; export const BuildItineraryModal: React.FC = ({ open, onClose, tripId, tripName, tripDurationDays, onApplied, }) => { const [phase, setPhase] = useState("intro"); const [days, setDays] = useState([]); const [error, setError] = useState(null); const [mode, setMode] = useState<"replace" | "append">("replace"); const [trip, setTrip] = useState<{ duration_days: number } | null>(null); // Operator's free-text "what should AI know" notes — sent as // `extra_context` so the LLM has ground-truth facts even when the // form is sparsely filled. const [extraContext, setExtraContext] = useState(""); // Structured wizard inputs — get serialized into extra_context so we // don't need new prompt-template fields per option. const [pace, setPace] = useState<"relaxed" | "balanced" | "packed">( "balanced", ); const [style, setStyle] = useState("cultural"); const [focus, setFocus] = useState(""); const [arrivalNotes, setArrivalNotes] = useState(""); const [accommodationTier, setAccommodationTier] = useState("mid-range"); // AI clarification details (when LLM refuses with a question). const [clarification, setClarification] = useState<{ message: string; questions: string[]; rawText: string; } | null>(null); // Operator's inline answer to a clarification request. Must be // declared with the rest of the hooks (before any early-return) to // preserve React's hook-order invariant (error #310). const [retryContext, setRetryContext] = useState(""); useEffect(() => { if (open) { setPhase("intro"); setDays([]); setError(null); setMode("replace"); setTrip(null); setExtraContext(""); setPace("balanced"); setStyle("cultural"); setFocus(""); setArrivalNotes(""); setAccommodationTier("mid-range"); setClarification(null); setRetryContext(""); } }, [open]); useEffect(() => { if (phase !== "needs_context") setRetryContext(""); }, [phase]); const hasDuration = tripDurationDays > 0; if (!open) return null; if (!isAiReady()) { return (

{__("AI Assistant not configured", "yatra")}

{__( "Add an OpenAI or Anthropic key under Yatra → AI Assistant first.", "yatra", )}

); } /** * Merge the structured wizard fields into the free-text extra_context * the prompt template already expects. Keeps the prompt-template * surface narrow — operators can override the master template later * via the Prompts tab without us breaking when we add wizard fields. */ const buildExtraContext = (extraSuffix = ""): string => { const structured: string[] = []; if (style) structured.push(`Travel style: ${style}`); if (pace) structured.push(`Pace: ${pace} (${paceHint(pace)})`); if (accommodationTier) structured.push(`Accommodation tier: ${accommodationTier}`); if (focus.trim()) structured.push(`Daily focus emphasis: ${focus.trim()}`); if (arrivalNotes.trim()) structured.push(`Arrival/departure logistics: ${arrivalNotes.trim()}`); return [structured.join("\n"), extraContext.trim(), extraSuffix.trim()] .filter((s) => s !== "") .join("\n\n"); }; const generate = async (extraSuffix = "") => { setPhase("generating"); setError(null); setClarification(null); try { const combined = buildExtraContext(extraSuffix); const res = await aiApi.draftItinerary(tripId, combined); // Detect when the LLM responded with a refusal/clarification // instead of a valid Day-N itinerary. The standalone server-side // parser would return 0 days, which historically just produced // an unhelpful "couldn't parse" message — now we surface the AI's // question and let the operator answer inline. const c = detectClarification(res.text, { expectDayHeads: true }); if ((!res.days || res.days.length === 0) && c.isClarification) { setClarification({ message: c.message, questions: c.questions, rawText: res.text, }); setTrip({ duration_days: res.trip.duration_days }); setPhase("needs_context"); return; } setDays(res.days); setTrip({ duration_days: res.trip.duration_days }); setPhase("preview"); } catch (e: any) { setError(extractError(e)); setPhase("error"); } }; const retryWithAnswers = () => { const suffix = retryContext.trim(); if (suffix === "") return; // Merge the answers into the persistent extraContext so subsequent // retries don't lose them. Then re-run. setExtraContext((prev) => [prev.trim(), suffix].filter((s) => s !== "").join("\n\n"), ); void generate(suffix); }; const apply = async () => { setPhase("applying"); setError(null); try { const res = await aiApi.applyItinerary(tripId, days, mode === "replace"); setPhase("done"); onApplied({ count: res.count, message: res.message }); // Auto-close after a beat so the operator sees the success state. setTimeout(() => onClose(), 900); } catch (e: any) { setError(extractError(e)); setPhase("error"); } }; return (
{/* Header */}

{__("Build Itinerary with AI", "yatra")}

{tripName}
{/* Body */}
{phase === "intro" && (

{__( "AI will draft a day-by-day itinerary based on this trip's details — destination, difficulty, activities. The result will be parsed into individual day rows that you can edit afterwards.", "yatra", )}

{/* Show the actual duration from the trip record. If it's missing, only then surface the warning — and tell the operator where to set it. */} {hasDuration ? (
{__("AI will generate", "yatra")}{" "} {tripDurationDays} {__("days", "yatra")} {" "} {__( "of itinerary, matching this trip's duration.", "yatra", )}
) : (
{__( "This trip has no duration set. Open the trip and fill in 'Duration (days)' under Duration & Schedule, then come back here.", "yatra", )}
)} {/* Structured wizard inputs — these get serialized into extra_context so the prompt template doesn't need new fields per option. */}
setFocus(e.target.value)} />
setArrivalNotes(e.target.value)} />
{/* Free-text context — the operator's notes flow into the prompt as `{{extra_context}}` so AI doesn't refuse on sparse trips. */}