import React, { useEffect, useMemo, useState } from "react"; import { useQuery } from "@tanstack/react-query"; import { Sparkles, X, Loader2, Check, AlertCircle, ChevronLeft, ChevronRight, Wand2, CheckCircle2, Edit3, HelpCircle, RefreshCw, } from "lucide-react"; import { __ } from "../../lib/i18n"; import { aiApi, type AiGenerateResponse } from "../../api/ai-api"; import { apiClient } from "../../lib/api-client"; import { isAiReady } from "../../lib/ai-availability"; import { Button } from "../ui/button"; import { Input } from "../ui/input"; import { Modal } from "../ui/modal"; import { Select } from "../ui/select"; import { parseItineraryText } from "./itineraryParser"; import { detectClarification } from "./clarificationDetector"; /** * "Add Trip with AI" wizard — invoked from the Trips list page when the * operator wants AI to draft an entire trip end-to-end instead of * starting from a blank form. * * Flow: * 1. setup — operator answers 6-8 seed questions (destination, * trip type, duration, audience, etc.) * 2. generating — runs all content-generation tasks in parallel * (description, short, highlights, included, excluded, * cancellation, FAQ, itinerary, SEO meta x2) * 3. review — operator inspects each section, can request a * per-section regen with extra context * 4. creating — POST /trips → PUT /trips/{id} with the rich content → * POST /ai/itinerary/{id}/apply with the day rows * 5. done — redirects to the trip edit form * * Why client-side orchestration? Each generation task is already an * authorized REST call; doing it client-side keeps the operator's * progress visible (one row per task, can see what's slow / failing) * and avoids a long-running server request that would time out behind * common WAFs and PHP-FPM timeouts. */ interface CreateTripWithAiWizardProps { open: boolean; onClose: () => void; } type Phase = "setup" | "generating" | "review" | "creating" | "done" | "error"; interface SetupData { name: string; slug: string; destinations: string; trip_type: "multi_day" | "single_day"; duration_days: string; duration_nights: string; /** Difficulty taxonomy ID as a string (so it round-trips through Select). Empty = skip. */ difficulty_level_id: string; /** Human label for the chosen difficulty — passed to the agent as hint context only. */ difficulty_level_label: string; best_season: string; audience: string; activities: string; /** Per-person price for the trip. Empty allowed; the operator can fill it later. */ price: string; /** Optional discounted price; must be < price when set. */ sale_price: string; extra_context: string; } const DEFAULT_SETUP: SetupData = { name: "", slug: "", destinations: "", trip_type: "multi_day", duration_days: "5", duration_nights: "4", difficulty_level_id: "", difficulty_level_label: "", best_season: "", audience: "", activities: "", price: "", sale_price: "", extra_context: "", }; type SectionId = | "description" | "short_description" | "trip_details" | "what_makes_special" | "trip_story" | "highlights" | "included_items" | "excluded_items" | "cancellation_policy" | "faqs" | "itinerary" | "starting_location" | "ending_location" | "accommodation_type" | "meta_title" | "meta_description"; interface SectionSpec { id: SectionId; label: string; task: string; /** Decode the LLM text into the shape the trip-write payload expects. */ decode: (text: string) => unknown; /** Render a short preview for the review screen. */ preview: (value: unknown) => string; /** Key in the trip-update payload (PUT /trips/{id}). null = handled separately (e.g. itinerary). */ payloadKey: string | null; } const SECTIONS: SectionSpec[] = [ { id: "description", label: "Trip description", task: "trip-description", decode: (t) => plainTextToHtml(t), preview: (v) => typeof v === "string" ? v .replace(/<\/p>\s*

/gi, "\n\n") .replace(/<[^>]+>/g, "") .trim() : "", payloadKey: "description", }, { id: "short_description", label: "Short description", task: "trip-short-description", decode: (t) => t.trim(), preview: (v) => String(v ?? ""), payloadKey: "short_description", }, { id: "trip_details", label: "Trip details (logistics)", task: "trip-details", decode: (t) => plainTextToHtml(t), preview: (v) => typeof v === "string" ? v .replace(/<\/p>\s*

/gi, "\n\n") .replace(/<[^>]+>/g, "") .trim() : "", payloadKey: "trip_details", }, { id: "what_makes_special", label: "What makes this trip special", task: "what-makes-special", decode: (t) => plainTextToHtml(t), preview: (v) => typeof v === "string" ? v .replace(/<\/p>\s*

/gi, "\n\n") .replace(/<[^>]+>/g, "") .trim() : "", payloadKey: "what_makes_special", }, { id: "trip_story", label: "Trip story", task: "trip-story", decode: (t) => plainTextToHtml(t), preview: (v) => typeof v === "string" ? v .replace(/<\/p>\s*

/gi, "\n\n") .replace(/<[^>]+>/g, "") .trim() : "", payloadKey: "trip_story", }, { id: "starting_location", label: "Starting location", task: "starting-location", decode: (t) => t.trim(), preview: (v) => String(v ?? ""), payloadKey: "starting_location", }, { id: "ending_location", label: "Ending location", task: "ending-location", decode: (t) => t.trim(), preview: (v) => String(v ?? ""), payloadKey: "ending_location", }, { id: "accommodation_type", label: "Accommodation tier", task: "accommodation-type", decode: (t) => t.trim(), preview: (v) => String(v ?? ""), payloadKey: "accommodation_type", }, { id: "highlights", label: "Highlights", task: "trip-highlights", decode: (t) => t .split(/\r?\n/) .map((l) => l.replace(/^[\s\-\*•·●]+/, "").trim()) .filter((l) => l !== ""), preview: (v) => Array.isArray(v) ? (v as string[]).map((s) => `• ${s}`).join("\n") : "", payloadKey: "highlights", }, { id: "included_items", label: "What's included", task: "trip-included-items", decode: (t) => decodeAmenityList(t), preview: (v) => Array.isArray(v) ? (v as Array<{ title: string }>).map((i) => `• ${i.title}`).join("\n") : "", payloadKey: "included_items", }, { id: "excluded_items", label: "What's excluded", task: "trip-excluded-items", decode: (t) => decodeAmenityList(t), preview: (v) => Array.isArray(v) ? (v as Array<{ title: string }>).map((i) => `• ${i.title}`).join("\n") : "", payloadKey: "excluded_items", }, { id: "cancellation_policy", label: "Cancellation policy", task: "trip-cancellation-policy", decode: (t) => t.trim(), preview: (v) => String(v ?? ""), payloadKey: "cancellation_policy", }, { id: "faqs", label: "FAQ", task: "trip-faq", decode: (t) => decodeFaq(t), preview: (v) => Array.isArray(v) ? (v as Array<{ question: string; answer: string }>) .map((f) => `Q: ${f.question}\nA: ${f.answer}`) .join("\n\n") : "", // Stored as a relationship row on the trip — same key as the // backend agent uses, so no mapping is needed. payloadKey: "faqs", }, { id: "itinerary", label: "Day-by-day itinerary", task: "trip-itinerary", decode: (t) => parseItineraryText(t), preview: (v) => Array.isArray(v) ? (v as Array<{ day: number; day_title: string; description: string }>) .map((d) => `Day ${d.day}: ${d.day_title || ""}\n${d.description}`) .join("\n\n") : "", payloadKey: null, }, { id: "meta_title", label: "SEO meta title", task: "seo-meta-title", decode: (t) => t.trim().slice(0, 60), preview: (v) => String(v ?? ""), payloadKey: "meta_title", }, { id: "meta_description", label: "SEO meta description", task: "seo-meta-description", decode: (t) => t.trim().slice(0, 160), preview: (v) => String(v ?? ""), payloadKey: "meta_description", }, ]; type SectionStatus = "idle" | "running" | "done" | "failed" | "needs_context"; interface SectionState { status: SectionStatus; decoded?: unknown; preview?: string; error?: string; clarificationMessage?: string; clarificationQuestions?: string[]; retryContext: string; } interface DifficultyOption { id: number; name: string; } const SEASONS = [ "spring", "summer", "monsoon", "autumn", "winter", "year-round", ]; export const CreateTripWithAiWizard: React.FC = ({ open, onClose, }) => { const [phase, setPhase] = useState("setup"); const [setup, setSetup] = useState(DEFAULT_SETUP); const [sections, setSections] = useState>( () => initialSectionState(), ); const [createError, setCreateError] = useState(null); useEffect(() => { if (open) { setPhase("setup"); setSetup(DEFAULT_SETUP); setSections(initialSectionState()); setCreateError(null); } }, [open]); // Auto-derive slug from the trip name. The operator can rename the // slug from the trip editor after the wizard finishes — exposing it // here just adds noise for the AI flow. useEffect(() => { setSetup((prev) => ({ ...prev, slug: slugify(prev.name) })); }, [setup.name]); // ALL hooks must run on every render before any conditional early // return — otherwise React's hook-order invariant blows up with the // "rendered more hooks" error #310. baseContext was previously after // the `if (!open)` short-circuit, which violated this. const baseContext = useMemo( () => ({ name: setup.name.trim(), destinations: [setup.destinations.trim()], categories: [] as string[], activities: setup.activities ? setup.activities .split(",") .map((s) => s.trim()) .filter(Boolean) : [], difficulty_level: setup.difficulty_level_label, duration_days: setup.trip_type === "single_day" ? "1" : setup.duration_days, duration_nights: setup.trip_type === "single_day" ? "0" : setup.duration_nights, best_season: setup.best_season, accommodation_type: "", transportation_included: "", starting_location: "", ending_location: "", price: setup.price, description: "", short_description: "", included_items: [] as string[], excluded_items: [] as string[], age_min: "", age_max: "", }), [setup], ); if (!open) return null; if (!isAiReady()) { return (

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

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

); } const setupValid = setup.name.trim() !== "" && setup.destinations.trim() !== ""; /** * Convert a section value as the backend agent returned it into * the trip-form payload shape we eventually POST. Mostly identity * (the agent already returns decoded shapes); the one exception is * `description`, which arrives as plain-text prose but TripForm's * RichTextEditor stores HTML, so we wrap it in `

` blocks here. */ const coerceAgentSection = (id: SectionId, content: unknown): unknown => { if (id === "description" && typeof content === "string") { return plainTextToHtml(content); } return content; }; /** * Mirror coerceAgentSection for the on-screen preview. Same lookup * the existing SectionSpec.preview functions use, so the review * cards render identically regardless of which generation path * produced the content. */ const buildPreview = (id: SectionId, decoded: unknown): string => { const spec = SECTIONS.find((s) => s.id === id); return spec ? spec.preview(decoded) : ""; }; /** * Generate every section in ONE agent call. The backend's * TripCreationAgent runs a tool-driven loop that writes sections * with cross-references (description-aware short_description, * itinerary-aware FAQ, etc.) — much more coherent than firing N * independent single-shot tasks like the old flow did. */ const startGeneration = async () => { setPhase("generating"); setSections(() => { const next = initialSectionState(); for (const s of SECTIONS) { next[s.id] = { ...next[s.id], status: "running" }; } return next; }); try { const res = await aiApi.wizardCreateTrip({ name: setup.name, destinations: setup.destinations, trip_type: setup.trip_type, duration_days: setup.duration_days, duration_nights: setup.duration_nights, difficulty_level: setup.difficulty_level_label, best_season: setup.best_season, audience: setup.audience, activities: setup.activities, extra_context: setup.extra_context, }); // Walk SECTIONS so we apply the same coercion + preview to // every key the agent could have written. Anything the agent // skipped lands in the `missing` array — we mark those rows // as failed so the operator sees what needs a manual re-run. const agentSections = res.sections || {}; setSections((prev) => { const next = { ...prev }; for (const s of SECTIONS) { const raw = agentSections[s.id]; if (raw === undefined || raw === null) { next[s.id] = { ...next[s.id], status: "failed", error: "Agent didn't write this section", retryContext: "", }; continue; } const decoded = coerceAgentSection(s.id, raw); next[s.id] = { status: "done", decoded, preview: buildPreview(s.id, decoded), retryContext: "", }; } return next; }); setPhase("review"); } catch (e: any) { // Whole-agent failure (API down, missing key, etc.). Mark every // section failed with the error message so the operator can see // why nothing landed. const msg = extractError(e); setSections((prev) => { const next = { ...prev }; for (const s of SECTIONS) { next[s.id] = { ...next[s.id], status: "failed", error: msg, retryContext: "", }; } return next; }); setPhase("review"); } }; /** * Re-run ONE section via the agent's regenerate-section endpoint. * The agent reads the other already-written sections (passed in * the request) so the new content stays consistent with the rest * of the wizard's output. */ const runSection = async (id: SectionId, guidance = "") => { setSections((prev) => ({ ...prev, [id]: { ...prev[id], status: "running", error: undefined, clarificationMessage: undefined, clarificationQuestions: undefined, }, })); // Snapshot every other completed section so the agent has the // current state to read against. JSON-serializable shapes only. const existing: Record = {}; for (const s of SECTIONS) { if (s.id === id) continue; const st = sections[s.id]; if (st.status === "done" && st.decoded !== undefined) { // For description, strip the

wrappers we added so the // agent reads plain text — it'll re-wrap on write if needed. let value = st.decoded; if (s.id === "description" && typeof value === "string") { value = value .replace(/<\/p>\s*

/gi, "\n\n") .replace(/<[^>]+>/g, "") .trim(); } existing[s.id] = value; } } try { const res = await aiApi.wizardRegenSection( id, { name: setup.name, destinations: setup.destinations, trip_type: setup.trip_type, duration_days: setup.duration_days, duration_nights: setup.duration_nights, difficulty_level: setup.difficulty_level_label, best_season: setup.best_season, audience: setup.audience, activities: setup.activities, extra_context: setup.extra_context, }, existing, guidance, ); if (res.content === null || res.content === undefined) { setSections((prev) => ({ ...prev, [id]: { ...prev[id], status: "failed", error: "Agent returned no content for this section.", }, })); return; } const decoded = coerceAgentSection(id, res.content); setSections((prev) => ({ ...prev, [id]: { status: "done", decoded, preview: buildPreview(id, decoded), retryContext: "", }, })); } catch (e: any) { setSections((prev) => ({ ...prev, [id]: { ...prev[id], status: "failed", error: extractError(e), }, })); } }; const retrySection = (id: SectionId) => { const ctx = sections[id].retryContext.trim(); if (ctx === "") return; // Persist the operator's retry guidance into extra_context so a // second retry inherits it (and any subsequent full regen also // sees it). setSetup((prev) => ({ ...prev, extra_context: [prev.extra_context.trim(), ctx] .filter((s) => s !== "") .join("\n\n"), })); void runSection(id, ctx); }; const sectionsDone = Object.values(sections).filter( (s) => s.status === "done", ).length; const sectionsTotal = SECTIONS.length; const sectionsFailed = Object.values(sections).filter( (s) => s.status === "failed", ).length; const sectionsNeedContext = Object.values(sections).filter( (s) => s.status === "needs_context", ).length; const createTrip = async () => { setPhase("creating"); setCreateError(null); // Build the trip-update payload from the accepted sections. const tripUpdate: Record = {}; for (const spec of SECTIONS) { if (!spec.payloadKey) continue; const st = sections[spec.id]; if (st.status === "done" && st.decoded !== undefined) { tripUpdate[spec.payloadKey] = st.decoded; } } try { // 1. Create the basic trip record. We use the same shape as the // existing "Add New Trip" flow so the row passes validation. const createPayload = { title: setup.name.trim(), slug: setup.slug.trim() || slugify(setup.name), status: "draft", trip_type: setup.trip_type, }; const createResp: any = await apiClient.post("/trips", createPayload); const tripId = createResp?.data?.id ?? createResp?.id; if (!tripId) { throw new Error("Trip created but no ID returned."); } // 2. Patch the rest of the fields (season, duration, pricing, // difficulty, the generated AI content). PUT /trips/{id} // accepts a wide payload so we send the union of // accepted-string fields. // // difficulty_level requires a positive integer matching the // taxonomy row ID (TripValidator). The wizard now loads the // real /difficulty-levels list at setup and stores the chosen // row's ID — we send that here. Operator can leave it blank // (empty string) to skip. const patchPayload: Record = { ...tripUpdate, duration_days: setup.trip_type === "single_day" ? 1 : Number(setup.duration_days), duration_nights: setup.trip_type === "single_day" ? 0 : Number(setup.duration_nights), }; if (setup.best_season) { patchPayload.best_season = setup.best_season; } const difficultyId = parseInt(setup.difficulty_level_id, 10); if (Number.isFinite(difficultyId) && difficultyId > 0) { patchPayload.difficulty_level = difficultyId; } const priceNum = parseFloat(setup.price); if (Number.isFinite(priceNum) && priceNum >= 0) { patchPayload.original_price = priceNum; } const salePriceNum = parseFloat(setup.sale_price); if ( Number.isFinite(salePriceNum) && salePriceNum > 0 && (!Number.isFinite(priceNum) || salePriceNum < priceNum) ) { patchPayload.sale_price = salePriceNum; } await apiClient.put(`/trips/${tripId}`, patchPayload); // 3. Apply the itinerary days separately — they live in their // own table and use the existing AI-apply endpoint that knows // how to insert rows + clean up on conflict. The agent's // `set_itinerary` returns activities nested inside each day; // we pass them through to the apply endpoint, which inserts // both day rows AND day_entry rows in one round trip. const itinerarySt = sections.itinerary; let itineraryWarning = ""; if (itinerarySt.status === "done" && Array.isArray(itinerarySt.decoded)) { const days = ( itinerarySt.decoded as Array<{ day: number; day_title: string; description: string; activities?: Array<{ title: string; description?: string; item_type?: string; item_name?: string; start_time?: string; end_time?: string; duration?: string; location?: string; }>; }> ).map((d) => ({ day: d.day, day_title: d.day_title, description: d.description, // Preserve activities the trip-creation agent generated so // each day's schedule entries persist to the day_entry // table alongside the day row. Without this the trip page // would render day overviews but no per-block schedule. activities: Array.isArray(d.activities) ? d.activities : [], })); if (days.length > 0) { try { await aiApi.applyItinerary(tripId, days, true); } catch (e) { // Don't fail the whole wizard — the operator still gets a // valid trip with content; we surface the warning instead // of silently dropping it so they know to re-run from the // Itinerary page if they want days. itineraryWarning = extractError(e); console.warn("Itinerary apply failed:", e); } } } else if ( itinerarySt.status === "needs_context" || itinerarySt.status === "failed" ) { itineraryWarning = __( "Itinerary section didn't complete — you can build it from the Itinerary page after the wizard finishes.", "yatra", ); } if (itineraryWarning) { console.warn("Wizard itinerary warning:", itineraryWarning); } setPhase("done"); // Hop to the trip's edit page so the operator lands inside the // form with their AI-generated content already populated. setTimeout(() => { const base = (window as any).yatraAdmin?.siteUrl || ""; window.location.href = `${base}/wp-admin/admin.php?page=yatra&subpage=trips&action=edit&id=${tripId}`; }, 900); } catch (e: any) { setCreateError(extractError(e)); setPhase("error"); } }; return ( { if (phase !== "creating") onClose(); }} size="xl" hideHeader hideFooter // The wizard manages its own sticky header / scrolling body / // sticky footer layout (max-h-[90vh] + flex-1 overflow-y-auto). // Suppress the Modal's default padding + max-h-[70vh] body scroll // so the wizard's layout takes over instead of fighting it — // without these overrides the modal capped at 70vh and the // wizard's own scrolling never engaged, forcing the operator to // scroll the OUTER modal instead. bodyClassName="" bodyScrollClassName="" >

{/* Header */}

{__("Create trip with AI", "yatra")}

{phaseLabel(phase)}
{phase !== "creating" && ( )}
{/* Steps indicator */}
{/* Body */}
{phase === "setup" && ( setSetup((prev) => ({ ...prev, ...patch }))} /> )} {phase === "generating" && ( )} {phase === "review" && ( setSections((prev) => ({ ...prev, [id]: { ...prev[id], retryContext: v }, })) } onRetry={(id) => retrySection(id)} onRerun={(id) => void runSection(id)} /> )} {phase === "creating" && (
{__("Saving your AI-generated trip…", "yatra")}
{__( "Creating the draft, applying content, building itinerary days.", "yatra", )}
)} {phase === "done" && (
{__("Trip created.", "yatra")}
{__("Redirecting to the trip editor…", "yatra")}
)} {phase === "error" && (
{createError || __("Something went wrong.", "yatra")}

{__( "Your generated content is still here — you can retry, or go back to review and edit it.", "yatra", )}

)}
{/* Footer */}
{phase === "generating" && ( <> {sectionsDone}/{sectionsTotal} {__("ready", "yatra")} {sectionsNeedContext > 0 && ( {sectionsNeedContext} {__("need more info", "yatra")} )} {sectionsFailed > 0 && ( {sectionsFailed} {__("failed", "yatra")} )} )} {phase === "review" && ( <> {sectionsDone}/{sectionsTotal}{" "} {__("sections accepted", "yatra")} {sectionsNeedContext > 0 && ( {sectionsNeedContext} {__("waiting for input", "yatra")} )} )}
{phase === "setup" && ( <> )} {phase === "generating" && ( )} {phase === "review" && ( <> )} {phase === "error" && ( <> )}
); }; /* -------------------------------------------------------------------------- */ /* Sub-components */ /* -------------------------------------------------------------------------- */ const Steps: React.FC<{ phase: Phase }> = ({ phase }) => { const order: Phase[] = ["setup", "generating", "review", "creating"]; const activeIdx = phase === "done" ? order.length : phase === "error" ? order.indexOf("creating") : order.indexOf(phase); return (
    {order.map((p, i) => { const active = i === activeIdx; const past = i < activeIdx || phase === "done"; return (
  1. {past ? : i + 1}
    {p === "setup" ? __("Setup", "yatra") : p === "generating" ? __("Generate", "yatra") : p === "review" ? __("Review", "yatra") : __("Save", "yatra")} {i < order.length - 1 && ( )}
  2. ); })}
); }; const SetupStep: React.FC<{ setup: SetupData; onChange: (patch: Partial) => void; }> = ({ setup, onChange }) => { // Load real difficulty taxonomy rows so the wizard sends the ID // the trip validator expects, rather than a freeform label that // gets dropped on PUT. const { data: difficultyData } = useQuery({ queryKey: ["wizard-difficulty-levels"], queryFn: async () => { try { const resp: any = await apiClient.get("/difficulty-levels", { params: { per_page: 100 }, }); const list = Array.isArray(resp?.data) ? resp.data : []; return list .filter((d: any) => d && d.id && d.name) .map((d: any) => ({ id: Number(d.id), name: String(d.name) })); } catch { return []; } }, staleTime: 5 * 60 * 1000, }); const difficulties = difficultyData || []; const currency = (window as any)?.yatraAdmin?.currency || "USD"; return (

{__( "Tell AI about the trip — the more specific, the better the draft. You'll edit everything afterwards.", "yatra", )}

{/* Row 1: name (full width) — slug auto-derives silently */} onChange({ name: e.target.value })} /> {/* Row 2: destinations | trip type | days | nights */}
onChange({ destinations: e.target.value })} />
{setup.trip_type === "multi_day" ? (
onChange({ duration_days: e.target.value })} /> onChange({ duration_nights: e.target.value })} />
) : (
)}
{/* Row 3: difficulty | season | audience */}
onChange({ audience: e.target.value })} />
{/* Row 3b: price | sale price — the agent doesn't invent prices, so the operator provides them up front. Trip saves with $0 if both are left blank. */}
onChange({ price: e.target.value })} /> onChange({ sale_price: e.target.value })} />
{/* Row 4: key activities (full width but compact) */} onChange({ activities: e.target.value })} /> {/* Row 5: extra context (textarea, 2 rows max) */}