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
{__(
"Add an OpenAI or Anthropic key under Yatra → AI Assistant first.",
"yatra",
)}
` 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 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
{__(
"Your generated content is still here — you can retry, or go back to review and edit it.",
"yatra",
)}
{__(
"Tell AI about the trip — the more specific, the better the draft. You'll edit everything afterwards.",
"yatra",
)}
{__(
"Review each section below. You can re-run any individual section, or fill in details for sections AI needs more info on.",
"yatra",
)}
${escape(p).replace(/\n/g, "
{__("AI Assistant not configured", "yatra")}
{__("Create trip with AI", "yatra")}
{order.map((p, i) => {
const active = i === activeIdx;
const past = i < activeIdx || phase === "done";
return (
);
};
const SetupStep: React.FC<{
setup: SetupData;
onChange: (patch: Partial
{state.preview}
)}
{state.status === "failed" && (
{state.clarificationQuestions.map((q, i) => (
)}
")}