PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.13
Yatra – Travel Booking & Tour Operator Software v3.0.13
3.0.16 3.0.15 3.0.14 3.0.14.1 3.0.14.2 3.0.12 3.0.13 3.0.11 3.0.10 3.0.9 3.0.8 3.0.7 3.0.6 3.0.5 3.0.5.1 3.0.4 3.0.3 3.0.2.9 3.0.2.7 3.0.2.8 3.0.2.6 trunk 1.0.0 2.0.0 2.0.1 All 84 releases
yatra / resources / js / components / ai / CreateTripWithAiWizard.tsx

CreateTripWithAiWizard.tsx in Yatra – Travel Booking & Tour Operator Software 3.0.13, at resources/js/components/ai/CreateTripWithAiWizard.tsx

1,617 lines 53.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 import React, { useEffect, useMemo, useState } from "react";
2 import { useQuery } from "@tanstack/react-query";
3 import {
4 Sparkles,
5 X,
6 Loader2,
7 Check,
8 AlertCircle,
9 ChevronLeft,
10 ChevronRight,
11 Wand2,
12 CheckCircle2,
13 Edit3,
14 HelpCircle,
15 RefreshCw,
16 } from "lucide-react";
17 import { __ } from "../../lib/i18n";
18 import { aiApi, type AiGenerateResponse } from "../../api/ai-api";
19 import { apiClient } from "../../lib/api-client";
20 import { isAiReady } from "../../lib/ai-availability";
21 import { Button } from "../ui/button";
22 import { Input } from "../ui/input";
23 import { Modal } from "../ui/modal";
24 import { Select } from "../ui/select";
25 import { parseItineraryText } from "./itineraryParser";
26 import { detectClarification } from "./clarificationDetector";
27
28 /**
29 * "Add Trip with AI" wizard — invoked from the Trips list page when the
30 * operator wants AI to draft an entire trip end-to-end instead of
31 * starting from a blank form.
32 *
33 * Flow:
34 * 1. setup — operator answers 6-8 seed questions (destination,
35 * trip type, duration, audience, etc.)
36 * 2. generating — runs all content-generation tasks in parallel
37 * (description, short, highlights, included, excluded,
38 * cancellation, FAQ, itinerary, SEO meta x2)
39 * 3. review — operator inspects each section, can request a
40 * per-section regen with extra context
41 * 4. creating — POST /trips → PUT /trips/{id} with the rich content →
42 * POST /ai/itinerary/{id}/apply with the day rows
43 * 5. done — redirects to the trip edit form
44 *
45 * Why client-side orchestration? Each generation task is already an
46 * authorized REST call; doing it client-side keeps the operator's
47 * progress visible (one row per task, can see what's slow / failing)
48 * and avoids a long-running server request that would time out behind
49 * common WAFs and PHP-FPM timeouts.
50 */
51
52 interface CreateTripWithAiWizardProps {
53 open: boolean;
54 onClose: () => void;
55 }
56
57 type Phase = "setup" | "generating" | "review" | "creating" | "done" | "error";
58
59 interface SetupData {
60 name: string;
61 slug: string;
62 destinations: string;
63 trip_type: "multi_day" | "single_day";
64 duration_days: string;
65 duration_nights: string;
66 /** Difficulty taxonomy ID as a string (so it round-trips through Select). Empty = skip. */
67 difficulty_level_id: string;
68 /** Human label for the chosen difficulty — passed to the agent as hint context only. */
69 difficulty_level_label: string;
70 best_season: string;
71 audience: string;
72 activities: string;
73 /** Per-person price for the trip. Empty allowed; the operator can fill it later. */
74 price: string;
75 /** Optional discounted price; must be < price when set. */
76 sale_price: string;
77 extra_context: string;
78 }
79
80 const DEFAULT_SETUP: SetupData = {
81 name: "",
82 slug: "",
83 destinations: "",
84 trip_type: "multi_day",
85 duration_days: "5",
86 duration_nights: "4",
87 difficulty_level_id: "",
88 difficulty_level_label: "",
89 best_season: "",
90 audience: "",
91 activities: "",
92 price: "",
93 sale_price: "",
94 extra_context: "",
95 };
96
97 type SectionId =
98 | "description"
99 | "short_description"
100 | "trip_details"
101 | "what_makes_special"
102 | "trip_story"
103 | "highlights"
104 | "included_items"
105 | "excluded_items"
106 | "cancellation_policy"
107 | "faqs"
108 | "itinerary"
109 | "starting_location"
110 | "ending_location"
111 | "accommodation_type"
112 | "meta_title"
113 | "meta_description";
114
115 interface SectionSpec {
116 id: SectionId;
117 label: string;
118 task: string;
119 /** Decode the LLM text into the shape the trip-write payload expects. */
120 decode: (text: string) => unknown;
121 /** Render a short preview for the review screen. */
122 preview: (value: unknown) => string;
123 /** Key in the trip-update payload (PUT /trips/{id}). null = handled separately (e.g. itinerary). */
124 payloadKey: string | null;
125 }
126
127 const SECTIONS: SectionSpec[] = [
128 {
129 id: "description",
130 label: "Trip description",
131 task: "trip-description",
132 decode: (t) => plainTextToHtml(t),
133 preview: (v) =>
134 typeof v === "string"
135 ? v
136 .replace(/<\/p>\s*<p>/gi, "\n\n")
137 .replace(/<[^>]+>/g, "")
138 .trim()
139 : "",
140 payloadKey: "description",
141 },
142 {
143 id: "short_description",
144 label: "Short description",
145 task: "trip-short-description",
146 decode: (t) => t.trim(),
147 preview: (v) => String(v ?? ""),
148 payloadKey: "short_description",
149 },
150 {
151 id: "trip_details",
152 label: "Trip details (logistics)",
153 task: "trip-details",
154 decode: (t) => plainTextToHtml(t),
155 preview: (v) =>
156 typeof v === "string"
157 ? v
158 .replace(/<\/p>\s*<p>/gi, "\n\n")
159 .replace(/<[^>]+>/g, "")
160 .trim()
161 : "",
162 payloadKey: "trip_details",
163 },
164 {
165 id: "what_makes_special",
166 label: "What makes this trip special",
167 task: "what-makes-special",
168 decode: (t) => plainTextToHtml(t),
169 preview: (v) =>
170 typeof v === "string"
171 ? v
172 .replace(/<\/p>\s*<p>/gi, "\n\n")
173 .replace(/<[^>]+>/g, "")
174 .trim()
175 : "",
176 payloadKey: "what_makes_special",
177 },
178 {
179 id: "trip_story",
180 label: "Trip story",
181 task: "trip-story",
182 decode: (t) => plainTextToHtml(t),
183 preview: (v) =>
184 typeof v === "string"
185 ? v
186 .replace(/<\/p>\s*<p>/gi, "\n\n")
187 .replace(/<[^>]+>/g, "")
188 .trim()
189 : "",
190 payloadKey: "trip_story",
191 },
192 {
193 id: "starting_location",
194 label: "Starting location",
195 task: "starting-location",
196 decode: (t) => t.trim(),
197 preview: (v) => String(v ?? ""),
198 payloadKey: "starting_location",
199 },
200 {
201 id: "ending_location",
202 label: "Ending location",
203 task: "ending-location",
204 decode: (t) => t.trim(),
205 preview: (v) => String(v ?? ""),
206 payloadKey: "ending_location",
207 },
208 {
209 id: "accommodation_type",
210 label: "Accommodation tier",
211 task: "accommodation-type",
212 decode: (t) => t.trim(),
213 preview: (v) => String(v ?? ""),
214 payloadKey: "accommodation_type",
215 },
216 {
217 id: "highlights",
218 label: "Highlights",
219 task: "trip-highlights",
220 decode: (t) =>
221 t
222 .split(/\r?\n/)
223 .map((l) => l.replace(/^[\s\-\*•·●]+/, "").trim())
224 .filter((l) => l !== ""),
225 preview: (v) =>
226 Array.isArray(v) ? (v as string[]).map((s) => `• ${s}`).join("\n") : "",
227 payloadKey: "highlights",
228 },
229 {
230 id: "included_items",
231 label: "What's included",
232 task: "trip-included-items",
233 decode: (t) => decodeAmenityList(t),
234 preview: (v) =>
235 Array.isArray(v)
236 ? (v as Array<{ title: string }>).map((i) => `• ${i.title}`).join("\n")
237 : "",
238 payloadKey: "included_items",
239 },
240 {
241 id: "excluded_items",
242 label: "What's excluded",
243 task: "trip-excluded-items",
244 decode: (t) => decodeAmenityList(t),
245 preview: (v) =>
246 Array.isArray(v)
247 ? (v as Array<{ title: string }>).map((i) => `• ${i.title}`).join("\n")
248 : "",
249 payloadKey: "excluded_items",
250 },
251 {
252 id: "cancellation_policy",
253 label: "Cancellation policy",
254 task: "trip-cancellation-policy",
255 decode: (t) => t.trim(),
256 preview: (v) => String(v ?? ""),
257 payloadKey: "cancellation_policy",
258 },
259 {
260 id: "faqs",
261 label: "FAQ",
262 task: "trip-faq",
263 decode: (t) => decodeFaq(t),
264 preview: (v) =>
265 Array.isArray(v)
266 ? (v as Array<{ question: string; answer: string }>)
267 .map((f) => `Q: ${f.question}\nA: ${f.answer}`)
268 .join("\n\n")
269 : "",
270 // Stored as a relationship row on the trip — same key as the
271 // backend agent uses, so no mapping is needed.
272 payloadKey: "faqs",
273 },
274 {
275 id: "itinerary",
276 label: "Day-by-day itinerary",
277 task: "trip-itinerary",
278 decode: (t) => parseItineraryText(t),
279 preview: (v) =>
280 Array.isArray(v)
281 ? (v as Array<{ day: number; day_title: string; description: string }>)
282 .map((d) => `Day ${d.day}: ${d.day_title || ""}\n${d.description}`)
283 .join("\n\n")
284 : "",
285 payloadKey: null,
286 },
287 {
288 id: "meta_title",
289 label: "SEO meta title",
290 task: "seo-meta-title",
291 decode: (t) => t.trim().slice(0, 60),
292 preview: (v) => String(v ?? ""),
293 payloadKey: "meta_title",
294 },
295 {
296 id: "meta_description",
297 label: "SEO meta description",
298 task: "seo-meta-description",
299 decode: (t) => t.trim().slice(0, 160),
300 preview: (v) => String(v ?? ""),
301 payloadKey: "meta_description",
302 },
303 ];
304
305 type SectionStatus = "idle" | "running" | "done" | "failed" | "needs_context";
306
307 interface SectionState {
308 status: SectionStatus;
309 decoded?: unknown;
310 preview?: string;
311 error?: string;
312 clarificationMessage?: string;
313 clarificationQuestions?: string[];
314 retryContext: string;
315 }
316
317 interface DifficultyOption {
318 id: number;
319 name: string;
320 }
321
322 const SEASONS = [
323 "spring",
324 "summer",
325 "monsoon",
326 "autumn",
327 "winter",
328 "year-round",
329 ];
330
331 export const CreateTripWithAiWizard: React.FC<CreateTripWithAiWizardProps> = ({
332 open,
333 onClose,
334 }) => {
335 const [phase, setPhase] = useState<Phase>("setup");
336 const [setup, setSetup] = useState<SetupData>(DEFAULT_SETUP);
337 const [sections, setSections] = useState<Record<SectionId, SectionState>>(
338 () => initialSectionState(),
339 );
340 const [createError, setCreateError] = useState<string | null>(null);
341
342 useEffect(() => {
343 if (open) {
344 setPhase("setup");
345 setSetup(DEFAULT_SETUP);
346 setSections(initialSectionState());
347 setCreateError(null);
348 }
349 }, [open]);
350
351 // Auto-derive slug from the trip name. The operator can rename the
352 // slug from the trip editor after the wizard finishes — exposing it
353 // here just adds noise for the AI flow.
354 useEffect(() => {
355 setSetup((prev) => ({ ...prev, slug: slugify(prev.name) }));
356 }, [setup.name]);
357
358 // ALL hooks must run on every render before any conditional early
359 // return — otherwise React's hook-order invariant blows up with the
360 // "rendered more hooks" error #310. baseContext was previously after
361 // the `if (!open)` short-circuit, which violated this.
362 const baseContext = useMemo(
363 () => ({
364 name: setup.name.trim(),
365 destinations: [setup.destinations.trim()],
366 categories: [] as string[],
367 activities: setup.activities
368 ? setup.activities
369 .split(",")
370 .map((s) => s.trim())
371 .filter(Boolean)
372 : [],
373 difficulty_level: setup.difficulty_level_label,
374 duration_days:
375 setup.trip_type === "single_day" ? "1" : setup.duration_days,
376 duration_nights:
377 setup.trip_type === "single_day" ? "0" : setup.duration_nights,
378 best_season: setup.best_season,
379 accommodation_type: "",
380 transportation_included: "",
381 starting_location: "",
382 ending_location: "",
383 price: setup.price,
384 description: "",
385 short_description: "",
386 included_items: [] as string[],
387 excluded_items: [] as string[],
388 age_min: "",
389 age_max: "",
390 }),
391 [setup],
392 );
393
394 if (!open) return null;
395
396 if (!isAiReady()) {
397 return (
398 <Modal isOpen={open} onClose={onClose} size="md" hideHeader hideFooter>
399 <div className="p-6">
400 <div className="flex items-start gap-3">
401 <AlertCircle className="mt-0.5 h-5 w-5 text-amber-500" />
402 <div>
403 <h3 className="font-semibold text-gray-900 dark:text-white">
404 {__("AI Assistant not configured", "yatra")}
405 </h3>
406 <p className="mt-1 text-sm text-gray-600 dark:text-gray-300">
407 {__(
408 "Add an OpenAI or Anthropic key under Yatra → AI Assistant first.",
409 "yatra",
410 )}
411 </p>
412 <div className="mt-3 flex gap-2">
413 <Button asChild>
414 <a href="admin.php?page=yatra&subpage=ai-assistant">
415 {__("Open AI settings", "yatra")}
416 </a>
417 </Button>
418 <Button variant="outline" onClick={onClose}>
419 {__("Cancel", "yatra")}
420 </Button>
421 </div>
422 </div>
423 </div>
424 </div>
425 </Modal>
426 );
427 }
428
429 const setupValid =
430 setup.name.trim() !== "" && setup.destinations.trim() !== "";
431
432 /**
433 * Convert a section value as the backend agent returned it into
434 * the trip-form payload shape we eventually POST. Mostly identity
435 * (the agent already returns decoded shapes); the one exception is
436 * `description`, which arrives as plain-text prose but TripForm's
437 * RichTextEditor stores HTML, so we wrap it in `<p>` blocks here.
438 */
439 const coerceAgentSection = (id: SectionId, content: unknown): unknown => {
440 if (id === "description" && typeof content === "string") {
441 return plainTextToHtml(content);
442 }
443 return content;
444 };
445
446 /**
447 * Mirror coerceAgentSection for the on-screen preview. Same lookup
448 * the existing SectionSpec.preview functions use, so the review
449 * cards render identically regardless of which generation path
450 * produced the content.
451 */
452 const buildPreview = (id: SectionId, decoded: unknown): string => {
453 const spec = SECTIONS.find((s) => s.id === id);
454 return spec ? spec.preview(decoded) : "";
455 };
456
457 /**
458 * Generate every section in ONE agent call. The backend's
459 * TripCreationAgent runs a tool-driven loop that writes sections
460 * with cross-references (description-aware short_description,
461 * itinerary-aware FAQ, etc.) — much more coherent than firing N
462 * independent single-shot tasks like the old flow did.
463 */
464 const startGeneration = async () => {
465 setPhase("generating");
466 setSections(() => {
467 const next = initialSectionState();
468 for (const s of SECTIONS) {
469 next[s.id] = { ...next[s.id], status: "running" };
470 }
471 return next;
472 });
473
474 try {
475 const res = await aiApi.wizardCreateTrip({
476 name: setup.name,
477 destinations: setup.destinations,
478 trip_type: setup.trip_type,
479 duration_days: setup.duration_days,
480 duration_nights: setup.duration_nights,
481 difficulty_level: setup.difficulty_level_label,
482 best_season: setup.best_season,
483 audience: setup.audience,
484 activities: setup.activities,
485 extra_context: setup.extra_context,
486 });
487
488 // Walk SECTIONS so we apply the same coercion + preview to
489 // every key the agent could have written. Anything the agent
490 // skipped lands in the `missing` array — we mark those rows
491 // as failed so the operator sees what needs a manual re-run.
492 const agentSections = res.sections || {};
493 setSections((prev) => {
494 const next = { ...prev };
495 for (const s of SECTIONS) {
496 const raw = agentSections[s.id];
497 if (raw === undefined || raw === null) {
498 next[s.id] = {
499 ...next[s.id],
500 status: "failed",
501 error: "Agent didn't write this section",
502 retryContext: "",
503 };
504 continue;
505 }
506 const decoded = coerceAgentSection(s.id, raw);
507 next[s.id] = {
508 status: "done",
509 decoded,
510 preview: buildPreview(s.id, decoded),
511 retryContext: "",
512 };
513 }
514 return next;
515 });
516 setPhase("review");
517 } catch (e: any) {
518 // Whole-agent failure (API down, missing key, etc.). Mark every
519 // section failed with the error message so the operator can see
520 // why nothing landed.
521 const msg = extractError(e);
522 setSections((prev) => {
523 const next = { ...prev };
524 for (const s of SECTIONS) {
525 next[s.id] = {
526 ...next[s.id],
527 status: "failed",
528 error: msg,
529 retryContext: "",
530 };
531 }
532 return next;
533 });
534 setPhase("review");
535 }
536 };
537
538 /**
539 * Re-run ONE section via the agent's regenerate-section endpoint.
540 * The agent reads the other already-written sections (passed in
541 * the request) so the new content stays consistent with the rest
542 * of the wizard's output.
543 */
544 const runSection = async (id: SectionId, guidance = "") => {
545 setSections((prev) => ({
546 ...prev,
547 [id]: {
548 ...prev[id],
549 status: "running",
550 error: undefined,
551 clarificationMessage: undefined,
552 clarificationQuestions: undefined,
553 },
554 }));
555
556 // Snapshot every other completed section so the agent has the
557 // current state to read against. JSON-serializable shapes only.
558 const existing: Record<string, unknown> = {};
559 for (const s of SECTIONS) {
560 if (s.id === id) continue;
561 const st = sections[s.id];
562 if (st.status === "done" && st.decoded !== undefined) {
563 // For description, strip the <p> wrappers we added so the
564 // agent reads plain text — it'll re-wrap on write if needed.
565 let value = st.decoded;
566 if (s.id === "description" && typeof value === "string") {
567 value = value
568 .replace(/<\/p>\s*<p>/gi, "\n\n")
569 .replace(/<[^>]+>/g, "")
570 .trim();
571 }
572 existing[s.id] = value;
573 }
574 }
575
576 try {
577 const res = await aiApi.wizardRegenSection(
578 id,
579 {
580 name: setup.name,
581 destinations: setup.destinations,
582 trip_type: setup.trip_type,
583 duration_days: setup.duration_days,
584 duration_nights: setup.duration_nights,
585 difficulty_level: setup.difficulty_level_label,
586 best_season: setup.best_season,
587 audience: setup.audience,
588 activities: setup.activities,
589 extra_context: setup.extra_context,
590 },
591 existing,
592 guidance,
593 );
594 if (res.content === null || res.content === undefined) {
595 setSections((prev) => ({
596 ...prev,
597 [id]: {
598 ...prev[id],
599 status: "failed",
600 error: "Agent returned no content for this section.",
601 },
602 }));
603 return;
604 }
605 const decoded = coerceAgentSection(id, res.content);
606 setSections((prev) => ({
607 ...prev,
608 [id]: {
609 status: "done",
610 decoded,
611 preview: buildPreview(id, decoded),
612 retryContext: "",
613 },
614 }));
615 } catch (e: any) {
616 setSections((prev) => ({
617 ...prev,
618 [id]: {
619 ...prev[id],
620 status: "failed",
621 error: extractError(e),
622 },
623 }));
624 }
625 };
626
627 const retrySection = (id: SectionId) => {
628 const ctx = sections[id].retryContext.trim();
629 if (ctx === "") return;
630 // Persist the operator's retry guidance into extra_context so a
631 // second retry inherits it (and any subsequent full regen also
632 // sees it).
633 setSetup((prev) => ({
634 ...prev,
635 extra_context: [prev.extra_context.trim(), ctx]
636 .filter((s) => s !== "")
637 .join("\n\n"),
638 }));
639 void runSection(id, ctx);
640 };
641
642 const sectionsDone = Object.values(sections).filter(
643 (s) => s.status === "done",
644 ).length;
645 const sectionsTotal = SECTIONS.length;
646 const sectionsFailed = Object.values(sections).filter(
647 (s) => s.status === "failed",
648 ).length;
649 const sectionsNeedContext = Object.values(sections).filter(
650 (s) => s.status === "needs_context",
651 ).length;
652
653 const createTrip = async () => {
654 setPhase("creating");
655 setCreateError(null);
656
657 // Build the trip-update payload from the accepted sections.
658 const tripUpdate: Record<string, unknown> = {};
659 for (const spec of SECTIONS) {
660 if (!spec.payloadKey) continue;
661 const st = sections[spec.id];
662 if (st.status === "done" && st.decoded !== undefined) {
663 tripUpdate[spec.payloadKey] = st.decoded;
664 }
665 }
666
667 try {
668 // 1. Create the basic trip record. We use the same shape as the
669 // existing "Add New Trip" flow so the row passes validation.
670 const createPayload = {
671 title: setup.name.trim(),
672 slug: setup.slug.trim() || slugify(setup.name),
673 status: "draft",
674 trip_type: setup.trip_type,
675 };
676 const createResp: any = await apiClient.post("/trips", createPayload);
677 const tripId = createResp?.data?.id ?? createResp?.id;
678 if (!tripId) {
679 throw new Error("Trip created but no ID returned.");
680 }
681
682 // 2. Patch the rest of the fields (season, duration, pricing,
683 // difficulty, the generated AI content). PUT /trips/{id}
684 // accepts a wide payload so we send the union of
685 // accepted-string fields.
686 //
687 // difficulty_level requires a positive integer matching the
688 // taxonomy row ID (TripValidator). The wizard now loads the
689 // real /difficulty-levels list at setup and stores the chosen
690 // row's ID — we send that here. Operator can leave it blank
691 // (empty string) to skip.
692 const patchPayload: Record<string, unknown> = {
693 ...tripUpdate,
694 duration_days:
695 setup.trip_type === "single_day" ? 1 : Number(setup.duration_days),
696 duration_nights:
697 setup.trip_type === "single_day" ? 0 : Number(setup.duration_nights),
698 };
699 if (setup.best_season) {
700 patchPayload.best_season = setup.best_season;
701 }
702 const difficultyId = parseInt(setup.difficulty_level_id, 10);
703 if (Number.isFinite(difficultyId) && difficultyId > 0) {
704 patchPayload.difficulty_level = difficultyId;
705 }
706 const priceNum = parseFloat(setup.price);
707 if (Number.isFinite(priceNum) && priceNum >= 0) {
708 patchPayload.original_price = priceNum;
709 }
710 const salePriceNum = parseFloat(setup.sale_price);
711 if (
712 Number.isFinite(salePriceNum) &&
713 salePriceNum > 0 &&
714 (!Number.isFinite(priceNum) || salePriceNum < priceNum)
715 ) {
716 patchPayload.sale_price = salePriceNum;
717 }
718 await apiClient.put(`/trips/${tripId}`, patchPayload);
719
720 // 3. Apply the itinerary days separately — they live in their
721 // own table and use the existing AI-apply endpoint that knows
722 // how to insert rows + clean up on conflict. The agent's
723 // `set_itinerary` returns activities nested inside each day;
724 // we pass them through to the apply endpoint, which inserts
725 // both day rows AND day_entry rows in one round trip.
726 const itinerarySt = sections.itinerary;
727 let itineraryWarning = "";
728 if (itinerarySt.status === "done" && Array.isArray(itinerarySt.decoded)) {
729 const days = (
730 itinerarySt.decoded as Array<{
731 day: number;
732 day_title: string;
733 description: string;
734 activities?: Array<{
735 title: string;
736 description?: string;
737 item_type?: string;
738 item_name?: string;
739 start_time?: string;
740 end_time?: string;
741 duration?: string;
742 location?: string;
743 }>;
744 }>
745 ).map((d) => ({
746 day: d.day,
747 day_title: d.day_title,
748 description: d.description,
749 // Preserve activities the trip-creation agent generated so
750 // each day's schedule entries persist to the day_entry
751 // table alongside the day row. Without this the trip page
752 // would render day overviews but no per-block schedule.
753 activities: Array.isArray(d.activities) ? d.activities : [],
754 }));
755 if (days.length > 0) {
756 try {
757 await aiApi.applyItinerary(tripId, days, true);
758 } catch (e) {
759 // Don't fail the whole wizard — the operator still gets a
760 // valid trip with content; we surface the warning instead
761 // of silently dropping it so they know to re-run from the
762 // Itinerary page if they want days.
763 itineraryWarning = extractError(e);
764 console.warn("Itinerary apply failed:", e);
765 }
766 }
767 } else if (
768 itinerarySt.status === "needs_context" ||
769 itinerarySt.status === "failed"
770 ) {
771 itineraryWarning = __(
772 "Itinerary section didn't complete — you can build it from the Itinerary page after the wizard finishes.",
773 "yatra",
774 );
775 }
776 if (itineraryWarning) {
777 console.warn("Wizard itinerary warning:", itineraryWarning);
778 }
779
780 setPhase("done");
781 // Hop to the trip's edit page so the operator lands inside the
782 // form with their AI-generated content already populated.
783 setTimeout(() => {
784 const base = (window as any).yatraAdmin?.siteUrl || "";
785 window.location.href = `${base}/wp-admin/admin.php?page=yatra&subpage=trips&action=edit&id=${tripId}`;
786 }, 900);
787 } catch (e: any) {
788 setCreateError(extractError(e));
789 setPhase("error");
790 }
791 };
792
793 return (
794 <Modal
795 isOpen={open}
796 onClose={() => {
797 if (phase !== "creating") onClose();
798 }}
799 size="xl"
800 hideHeader
801 hideFooter
802 // The wizard manages its own sticky header / scrolling body /
803 // sticky footer layout (max-h-[90vh] + flex-1 overflow-y-auto).
804 // Suppress the Modal's default padding + max-h-[70vh] body scroll
805 // so the wizard's layout takes over instead of fighting it —
806 // without these overrides the modal capped at 70vh and the
807 // wizard's own scrolling never engaged, forcing the operator to
808 // scroll the OUTER modal instead.
809 bodyClassName=""
810 bodyScrollClassName=""
811 >
812 <div className="flex h-[85vh] max-h-[85vh] min-h-[480px] w-full flex-col">
813 {/* Header */}
814 <div className="flex items-center justify-between border-b border-gray-200 px-6 py-4 dark:border-gray-700">
815 <div className="flex items-center gap-2">
816 <div className="rounded-md bg-gradient-to-br from-blue-600 to-blue-700 p-1.5 text-white">
817 <Sparkles className="h-4 w-4" />
818 </div>
819 <div>
820 <h2 className="text-base font-semibold text-gray-900 dark:text-white">
821 {__("Create trip with AI", "yatra")}
822 </h2>
823 <div className="text-xs text-gray-500 dark:text-gray-400">
824 {phaseLabel(phase)}
825 </div>
826 </div>
827 </div>
828 {phase !== "creating" && (
829 <button
830 type="button"
831 onClick={onClose}
832 className="rounded text-gray-400 hover:text-gray-600"
833 aria-label={__("Close", "yatra")}
834 >
835 <X className="h-5 w-5" />
836 </button>
837 )}
838 </div>
839
840 {/* Steps indicator */}
841 <div className="border-b border-gray-100 px-6 py-2 dark:border-gray-700">
842 <Steps phase={phase} />
843 </div>
844
845 {/* Body */}
846 <div className="flex-1 overflow-y-auto px-6 py-4">
847 {phase === "setup" && (
848 <SetupStep
849 setup={setup}
850 onChange={(patch) => setSetup((prev) => ({ ...prev, ...patch }))}
851 />
852 )}
853
854 {phase === "generating" && (
855 <GeneratingStep
856 sections={sections}
857 done={sectionsDone}
858 total={sectionsTotal}
859 />
860 )}
861
862 {phase === "review" && (
863 <ReviewStep
864 sections={sections}
865 onRetryContextChange={(id, v) =>
866 setSections((prev) => ({
867 ...prev,
868 [id]: { ...prev[id], retryContext: v },
869 }))
870 }
871 onRetry={(id) => retrySection(id)}
872 onRerun={(id) => void runSection(id)}
873 />
874 )}
875
876 {phase === "creating" && (
877 <div className="flex h-full flex-col items-center justify-center gap-3 text-center">
878 <Loader2 className="h-8 w-8 animate-spin text-blue-500" />
879 <div className="text-sm font-medium text-gray-900 dark:text-white">
880 {__("Saving your AI-generated trip…", "yatra")}
881 </div>
882 <div className="text-xs text-gray-500 dark:text-gray-400">
883 {__(
884 "Creating the draft, applying content, building itinerary days.",
885 "yatra",
886 )}
887 </div>
888 </div>
889 )}
890
891 {phase === "done" && (
892 <div className="flex h-full flex-col items-center justify-center gap-3 text-center">
893 <div className="rounded-full bg-green-100 p-3 dark:bg-green-900/30">
894 <Check className="h-6 w-6 text-green-600" />
895 </div>
896 <div className="text-base font-semibold text-gray-900 dark:text-white">
897 {__("Trip created.", "yatra")}
898 </div>
899 <div className="text-xs text-gray-500 dark:text-gray-400">
900 {__("Redirecting to the trip editor…", "yatra")}
901 </div>
902 </div>
903 )}
904
905 {phase === "error" && (
906 <div className="space-y-3">
907 <div className="rounded-md border border-red-200 bg-red-50 p-3 text-sm text-red-700 dark:border-red-500/40 dark:bg-red-900/20 dark:text-red-300">
908 <AlertCircle className="mr-1 inline h-4 w-4" />
909 {createError || __("Something went wrong.", "yatra")}
910 </div>
911 <p className="text-xs text-gray-500 dark:text-gray-400">
912 {__(
913 "Your generated content is still here — you can retry, or go back to review and edit it.",
914 "yatra",
915 )}
916 </p>
917 </div>
918 )}
919 </div>
920
921 {/* Footer */}
922 <div className="flex items-center justify-between border-t border-gray-200 px-6 py-3 dark:border-gray-700">
923 <div className="text-xs text-gray-500 dark:text-gray-400">
924 {phase === "generating" && (
925 <>
926 {sectionsDone}/{sectionsTotal} {__("ready", "yatra")}
927 {sectionsNeedContext > 0 && (
928 <span className="ml-2 text-amber-600 dark:text-amber-300">
929 {sectionsNeedContext} {__("need more info", "yatra")}
930 </span>
931 )}
932 {sectionsFailed > 0 && (
933 <span className="ml-2 text-red-500">
934 {sectionsFailed} {__("failed", "yatra")}
935 </span>
936 )}
937 </>
938 )}
939 {phase === "review" && (
940 <>
941 {sectionsDone}/{sectionsTotal}{" "}
942 {__("sections accepted", "yatra")}
943 {sectionsNeedContext > 0 && (
944 <span className="ml-2 text-amber-600 dark:text-amber-300">
945 {sectionsNeedContext} {__("waiting for input", "yatra")}
946 </span>
947 )}
948 </>
949 )}
950 </div>
951 <div className="flex gap-2">
952 {phase === "setup" && (
953 <>
954 <Button variant="outline" onClick={onClose}>
955 {__("Cancel", "yatra")}
956 </Button>
957 <Button onClick={startGeneration} disabled={!setupValid}>
958 <Wand2 className="mr-2 h-4 w-4" />
959 {__("Generate with AI", "yatra")}
960 </Button>
961 </>
962 )}
963 {phase === "generating" && (
964 <Button variant="outline" disabled>
965 <Loader2 className="mr-2 h-4 w-4 animate-spin" />
966 {__("Working…", "yatra")}
967 </Button>
968 )}
969 {phase === "review" && (
970 <>
971 <Button variant="outline" onClick={() => setPhase("setup")}>
972 <ChevronLeft className="mr-2 h-4 w-4" />
973 {__("Back", "yatra")}
974 </Button>
975 <Button
976 onClick={createTrip}
977 disabled={sectionsDone === 0}
978 title={
979 sectionsDone === 0
980 ? __(
981 "At least one section needs to succeed before you can create the trip.",
982 "yatra",
983 )
984 : ""
985 }
986 >
987 <Check className="mr-2 h-4 w-4" />
988 {__("Create trip", "yatra")}
989 </Button>
990 </>
991 )}
992 {phase === "error" && (
993 <>
994 <Button variant="outline" onClick={() => setPhase("review")}>
995 <ChevronLeft className="mr-2 h-4 w-4" />
996 {__("Back to review", "yatra")}
997 </Button>
998 <Button onClick={createTrip}>
999 <RefreshCw className="mr-2 h-4 w-4" />
1000 {__("Retry create", "yatra")}
1001 </Button>
1002 </>
1003 )}
1004 </div>
1005 </div>
1006 </div>
1007 </Modal>
1008 );
1009 };
1010
1011 /* -------------------------------------------------------------------------- */
1012 /* Sub-components */
1013 /* -------------------------------------------------------------------------- */
1014
1015 const Steps: React.FC<{ phase: Phase }> = ({ phase }) => {
1016 const order: Phase[] = ["setup", "generating", "review", "creating"];
1017 const activeIdx =
1018 phase === "done"
1019 ? order.length
1020 : phase === "error"
1021 ? order.indexOf("creating")
1022 : order.indexOf(phase);
1023 return (
1024 <ol className="flex items-center gap-1 text-[11px]">
1025 {order.map((p, i) => {
1026 const active = i === activeIdx;
1027 const past = i < activeIdx || phase === "done";
1028 return (
1029 <li key={p} className="flex items-center gap-1">
1030 <div
1031 className={`flex h-5 w-5 items-center justify-center rounded-full text-[10px] font-semibold ${
1032 past
1033 ? "bg-green-500 text-white"
1034 : active
1035 ? "bg-blue-600 text-white"
1036 : "bg-gray-200 text-gray-500 dark:bg-gray-700 dark:text-gray-400"
1037 }`}
1038 >
1039 {past ? <Check className="h-3 w-3" /> : i + 1}
1040 </div>
1041 <span
1042 className={`${
1043 active
1044 ? "font-medium text-blue-700 dark:text-blue-300"
1045 : "text-gray-500 dark:text-gray-400"
1046 }`}
1047 >
1048 {p === "setup"
1049 ? __("Setup", "yatra")
1050 : p === "generating"
1051 ? __("Generate", "yatra")
1052 : p === "review"
1053 ? __("Review", "yatra")
1054 : __("Save", "yatra")}
1055 </span>
1056 {i < order.length - 1 && (
1057 <ChevronRight className="h-3 w-3 text-gray-300 dark:text-gray-600" />
1058 )}
1059 </li>
1060 );
1061 })}
1062 </ol>
1063 );
1064 };
1065
1066 const SetupStep: React.FC<{
1067 setup: SetupData;
1068 onChange: (patch: Partial<SetupData>) => void;
1069 }> = ({ setup, onChange }) => {
1070 // Load real difficulty taxonomy rows so the wizard sends the ID
1071 // the trip validator expects, rather than a freeform label that
1072 // gets dropped on PUT.
1073 const { data: difficultyData } = useQuery<DifficultyOption[]>({
1074 queryKey: ["wizard-difficulty-levels"],
1075 queryFn: async () => {
1076 try {
1077 const resp: any = await apiClient.get("/difficulty-levels", {
1078 params: { per_page: 100 },
1079 });
1080 const list = Array.isArray(resp?.data) ? resp.data : [];
1081 return list
1082 .filter((d: any) => d && d.id && d.name)
1083 .map((d: any) => ({ id: Number(d.id), name: String(d.name) }));
1084 } catch {
1085 return [];
1086 }
1087 },
1088 staleTime: 5 * 60 * 1000,
1089 });
1090 const difficulties = difficultyData || [];
1091
1092 const currency = (window as any)?.yatraAdmin?.currency || "USD";
1093
1094 return (
1095 <div className="space-y-3">
1096 <p className="text-xs text-gray-600 dark:text-gray-300">
1097 {__(
1098 "Tell AI about the trip — the more specific, the better the draft. You'll edit everything afterwards.",
1099 "yatra",
1100 )}
1101 </p>
1102
1103 {/* Row 1: name (full width) — slug auto-derives silently */}
1104 <Field label={__("Trip name", "yatra")} required>
1105 <Input
1106 value={setup.name}
1107 placeholder={__("e.g. 7-Day Dhaka Cultural Tour", "yatra")}
1108 onChange={(e) => onChange({ name: e.target.value })}
1109 />
1110 </Field>
1111
1112 {/* Row 2: destinations | trip type | days | nights */}
1113 <div className="grid gap-3 md:grid-cols-4">
1114 <div className="md:col-span-2">
1115 <Field label={__("Destination(s)", "yatra")} required>
1116 <Input
1117 value={setup.destinations}
1118 placeholder={__("e.g. Dhaka, Bangladesh", "yatra")}
1119 onChange={(e) => onChange({ destinations: e.target.value })}
1120 />
1121 </Field>
1122 </div>
1123 <Field label={__("Type", "yatra")}>
1124 <Select
1125 value={setup.trip_type}
1126 onChange={(e) =>
1127 onChange({
1128 trip_type: e.target.value as SetupData["trip_type"],
1129 })
1130 }
1131 >
1132 <option value="multi_day">{__("Multi-day", "yatra")}</option>
1133 <option value="single_day">{__("Single day", "yatra")}</option>
1134 </Select>
1135 </Field>
1136 {setup.trip_type === "multi_day" ? (
1137 <div className="grid grid-cols-2 gap-2">
1138 <Field label={__("Days", "yatra")}>
1139 <Input
1140 type="number"
1141 min={1}
1142 max={60}
1143 value={setup.duration_days}
1144 onChange={(e) => onChange({ duration_days: e.target.value })}
1145 />
1146 </Field>
1147 <Field label={__("Nights", "yatra")}>
1148 <Input
1149 type="number"
1150 min={0}
1151 max={60}
1152 value={setup.duration_nights}
1153 onChange={(e) => onChange({ duration_nights: e.target.value })}
1154 />
1155 </Field>
1156 </div>
1157 ) : (
1158 <div />
1159 )}
1160 </div>
1161
1162 {/* Row 3: difficulty | season | audience */}
1163 <div className="grid gap-3 md:grid-cols-3">
1164 <Field label={__("Difficulty", "yatra")}>
1165 <Select
1166 value={setup.difficulty_level_id}
1167 onChange={(e) => {
1168 const id = e.target.value;
1169 const match = difficulties.find((d) => String(d.id) === id);
1170 onChange({
1171 difficulty_level_id: id,
1172 difficulty_level_label: match ? match.name : "",
1173 });
1174 }}
1175 disabled={difficulties.length === 0}
1176 >
1177 <option value="">
1178 {difficulties.length === 0
1179 ? __("(no difficulty levels configured)", "yatra")
1180 : __("(skip — operator picks later)", "yatra")}
1181 </option>
1182 {difficulties.map((d) => (
1183 <option key={d.id} value={String(d.id)}>
1184 {d.name}
1185 </option>
1186 ))}
1187 </Select>
1188 </Field>
1189 <Field label={__("Best season", "yatra")}>
1190 <Select
1191 value={setup.best_season}
1192 onChange={(e) => onChange({ best_season: e.target.value })}
1193 >
1194 <option value="">{__("(any)", "yatra")}</option>
1195 {SEASONS.map((s) => (
1196 <option key={s} value={s}>
1197 {s.charAt(0).toUpperCase() + s.slice(1)}
1198 </option>
1199 ))}
1200 </Select>
1201 </Field>
1202 <Field label={__("Audience", "yatra")}>
1203 <Input
1204 value={setup.audience}
1205 placeholder={__("couples, families, solo…", "yatra")}
1206 onChange={(e) => onChange({ audience: e.target.value })}
1207 />
1208 </Field>
1209 </div>
1210
1211 {/* Row 3b: price | sale price — the agent doesn't invent prices,
1212 so the operator provides them up front. Trip saves with $0
1213 if both are left blank. */}
1214 <div className="grid gap-3 md:grid-cols-3">
1215 <Field label={__("Price per person", "yatra")} hint={`(${currency})`}>
1216 <Input
1217 type="number"
1218 min={0}
1219 step="0.01"
1220 value={setup.price}
1221 placeholder={__("e.g. 1499", "yatra")}
1222 onChange={(e) => onChange({ price: e.target.value })}
1223 />
1224 </Field>
1225 <Field
1226 label={__("Sale price (optional)", "yatra")}
1227 hint={`(${currency})`}
1228 >
1229 <Input
1230 type="number"
1231 min={0}
1232 step="0.01"
1233 value={setup.sale_price}
1234 placeholder={__("e.g. 1299", "yatra")}
1235 onChange={(e) => onChange({ sale_price: e.target.value })}
1236 />
1237 </Field>
1238 <div />
1239 </div>
1240
1241 {/* Row 4: key activities (full width but compact) */}
1242 <Field
1243 label={__("Key activities", "yatra")}
1244 hint={__("(comma-separated)", "yatra")}
1245 >
1246 <Input
1247 value={setup.activities}
1248 placeholder={__(
1249 "food tours, rickshaw rides, mosque visits, river cruise",
1250 "yatra",
1251 )}
1252 onChange={(e) => onChange({ activities: e.target.value })}
1253 />
1254 </Field>
1255
1256 {/* Row 5: extra context (textarea, 2 rows max) */}
1257 <Field
1258 label={__("Anything else AI should know", "yatra")}
1259 hint={__("(optional but recommended)", "yatra")}
1260 >
1261 <textarea
1262 rows={2}
1263 className="w-full rounded-md border border-gray-300 bg-white p-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-400 dark:border-gray-600 dark:bg-gray-800"
1264 value={setup.extra_context}
1265 placeholder={__(
1266 "Mid-range hotels, halal-friendly meals, max group size 10…",
1267 "yatra",
1268 )}
1269 onChange={(e) => onChange({ extra_context: e.target.value })}
1270 />
1271 </Field>
1272 </div>
1273 );
1274 };
1275
1276 const GeneratingStep: React.FC<{
1277 sections: Record<SectionId, SectionState>;
1278 done: number;
1279 total: number;
1280 }> = ({ sections, done, total }) => {
1281 const pct = total === 0 ? 0 : Math.round((done / total) * 100);
1282 return (
1283 <div className="space-y-3">
1284 <div className="rounded-md bg-blue-50 p-3 dark:bg-blue-900/20">
1285 <div className="mb-2 flex items-center justify-between text-sm">
1286 <span className="font-medium text-blue-900 dark:text-blue-200">
1287 {__("AI is drafting your trip…", "yatra")}
1288 </span>
1289 <span className="text-blue-700 dark:text-blue-300">{pct}%</span>
1290 </div>
1291 <div className="h-1.5 w-full overflow-hidden rounded-full bg-blue-200 dark:bg-blue-900/40">
1292 <div
1293 className="h-full bg-blue-600 transition-all dark:bg-blue-400"
1294 style={{ width: `${pct}%` }}
1295 />
1296 </div>
1297 </div>
1298 <div className="space-y-1">
1299 {SECTIONS.map((s) => (
1300 <SectionStatusRow key={s.id} spec={s} state={sections[s.id]} />
1301 ))}
1302 </div>
1303 </div>
1304 );
1305 };
1306
1307 const ReviewStep: React.FC<{
1308 sections: Record<SectionId, SectionState>;
1309 onRetryContextChange: (id: SectionId, v: string) => void;
1310 onRetry: (id: SectionId) => void;
1311 onRerun: (id: SectionId) => void;
1312 }> = ({ sections, onRetryContextChange, onRetry, onRerun }) => {
1313 return (
1314 <div className="space-y-3">
1315 <p className="text-sm text-gray-600 dark:text-gray-300">
1316 {__(
1317 "Review each section below. You can re-run any individual section, or fill in details for sections AI needs more info on.",
1318 "yatra",
1319 )}
1320 </p>
1321 {SECTIONS.map((spec) => (
1322 <ReviewRow
1323 key={spec.id}
1324 spec={spec}
1325 state={sections[spec.id]}
1326 onRetryContextChange={(v) => onRetryContextChange(spec.id, v)}
1327 onRetry={() => onRetry(spec.id)}
1328 onRerun={() => onRerun(spec.id)}
1329 />
1330 ))}
1331 </div>
1332 );
1333 };
1334
1335 const SectionStatusRow: React.FC<{
1336 spec: SectionSpec;
1337 state: SectionState;
1338 }> = ({ spec, state }) => {
1339 return (
1340 <div className="flex items-center gap-2 rounded-md border border-gray-100 px-3 py-1.5 text-xs dark:border-gray-800">
1341 {state.status === "idle" && <span className="text-gray-400">○</span>}
1342 {state.status === "running" && (
1343 <Loader2 className="h-3.5 w-3.5 animate-spin text-blue-500" />
1344 )}
1345 {state.status === "done" && (
1346 <CheckCircle2 className="h-3.5 w-3.5 text-green-500" />
1347 )}
1348 {state.status === "failed" && (
1349 <AlertCircle className="h-3.5 w-3.5 text-red-500" />
1350 )}
1351 {state.status === "needs_context" && (
1352 <HelpCircle className="h-3.5 w-3.5 text-amber-500" />
1353 )}
1354 <span className="text-gray-700 dark:text-gray-200">{spec.label}</span>
1355 </div>
1356 );
1357 };
1358
1359 const ReviewRow: React.FC<{
1360 spec: SectionSpec;
1361 state: SectionState;
1362 onRetryContextChange: (v: string) => void;
1363 onRetry: () => void;
1364 onRerun: () => void;
1365 }> = ({ spec, state, onRetryContextChange, onRetry, onRerun }) => {
1366 // Default OPEN so the operator immediately sees the AI-generated
1367 // content — having to click into every section was hiding the
1368 // itinerary in particular.
1369 const [open, setOpen] = useState(true);
1370 const sectionSummary = summarize(spec.id, state);
1371 return (
1372 <div className="rounded-md border border-gray-200 dark:border-gray-700">
1373 <button
1374 type="button"
1375 onClick={() => setOpen((v) => !v)}
1376 className="flex w-full items-center justify-between gap-2 px-3 py-2 text-left text-sm hover:bg-gray-50 dark:hover:bg-gray-800/60"
1377 >
1378 <div className="flex items-center gap-2">
1379 {state.status === "done" && (
1380 <CheckCircle2 className="h-4 w-4 text-green-500" />
1381 )}
1382 {state.status === "failed" && (
1383 <AlertCircle className="h-4 w-4 text-red-500" />
1384 )}
1385 {state.status === "needs_context" && (
1386 <HelpCircle className="h-4 w-4 text-amber-500" />
1387 )}
1388 {state.status === "running" && (
1389 <Loader2 className="h-4 w-4 animate-spin text-blue-500" />
1390 )}
1391 <span className="font-medium text-gray-900 dark:text-white">
1392 {spec.label}
1393 </span>
1394 {sectionSummary && (
1395 <span className="text-xs text-gray-500 dark:text-gray-400">
1396 · {sectionSummary}
1397 </span>
1398 )}
1399 </div>
1400 <div className="flex items-center gap-2">
1401 <button
1402 type="button"
1403 onClick={(e) => {
1404 e.stopPropagation();
1405 onRerun();
1406 }}
1407 className="rounded border border-gray-200 px-2 py-0.5 text-[11px] text-gray-600 hover:bg-gray-50 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-700"
1408 title={__("Re-run this section", "yatra")}
1409 >
1410 <RefreshCw className="inline h-3 w-3" />
1411 </button>
1412 <ChevronRight
1413 className={`h-4 w-4 text-gray-400 transition-transform ${open ? "rotate-90" : ""}`}
1414 />
1415 </div>
1416 </button>
1417
1418 {open && (
1419 <div className="space-y-2 border-t border-gray-200 p-3 dark:border-gray-700">
1420 {state.status === "done" && state.preview && (
1421 <pre className="max-h-64 overflow-auto whitespace-pre-wrap rounded bg-gray-50 p-2 text-xs text-gray-800 dark:bg-gray-900 dark:text-gray-200">
1422 {state.preview}
1423 </pre>
1424 )}
1425 {state.status === "failed" && (
1426 <div className="rounded bg-red-50 p-2 text-xs text-red-700 dark:bg-red-900/30 dark:text-red-300">
1427 <AlertCircle className="mr-1 inline h-3 w-3" />
1428 {state.error}
1429 </div>
1430 )}
1431 {state.status === "needs_context" && (
1432 <div className="space-y-2">
1433 <div className="rounded-md border border-amber-200 bg-amber-50/60 p-2 text-xs text-amber-900 dark:border-amber-500/40 dark:bg-amber-900/20 dark:text-amber-200">
1434 {state.clarificationMessage && (
1435 <div className="mb-1">{state.clarificationMessage}</div>
1436 )}
1437 {state.clarificationQuestions &&
1438 state.clarificationQuestions.length > 0 && (
1439 <ul className="list-disc space-y-0.5 pl-4">
1440 {state.clarificationQuestions.map((q, i) => (
1441 <li key={i}>{q}</li>
1442 ))}
1443 </ul>
1444 )}
1445 </div>
1446 <textarea
1447 rows={3}
1448 className="w-full rounded-md border border-amber-200 bg-white p-2 text-xs focus:outline-none focus:ring-2 focus:ring-amber-400 dark:border-amber-500/40 dark:bg-gray-800"
1449 placeholder={__(
1450 "Type the answers — these get added to the prompt and only this section regenerates.",
1451 "yatra",
1452 )}
1453 value={state.retryContext}
1454 onChange={(e) => onRetryContextChange(e.target.value)}
1455 />
1456 <div className="flex justify-end">
1457 <Button
1458 size="sm"
1459 onClick={onRetry}
1460 disabled={state.retryContext.trim() === ""}
1461 >
1462 <Wand2 className="mr-2 h-3 w-3" />
1463 {__("Try again with this", "yatra")}
1464 </Button>
1465 </div>
1466 </div>
1467 )}
1468 </div>
1469 )}
1470 </div>
1471 );
1472 };
1473
1474 const Field: React.FC<{
1475 label: string;
1476 hint?: string;
1477 required?: boolean;
1478 children: React.ReactNode;
1479 }> = ({ label, hint, required, children }) => (
1480 <div>
1481 <label className="mb-1 flex items-center gap-1 text-xs font-medium text-gray-700 dark:text-gray-200">
1482 {label}
1483 {required && <span className="text-red-500">*</span>}
1484 {hint && (
1485 <span className="ml-1 text-[10px] font-normal text-gray-400">
1486 {hint}
1487 </span>
1488 )}
1489 </label>
1490 {children}
1491 </div>
1492 );
1493
1494 /* -------------------------------------------------------------------------- */
1495 /* Helpers */
1496 /* -------------------------------------------------------------------------- */
1497
1498 /**
1499 * Short human-readable summary shown next to each section title in the
1500 * review step. Helps the operator spot at-a-glance whether a section
1501 * landed — "5 days drafted", "153-char description", etc. — without
1502 * having to expand every card.
1503 */
1504 function summarize(id: SectionId, state: SectionState): string {
1505 if (state.status !== "done") return "";
1506 const d = state.decoded;
1507 if (id === "itinerary" && Array.isArray(d)) {
1508 return `${d.length} ${d.length === 1 ? "day" : "days"} drafted`;
1509 }
1510 if (id === "highlights" && Array.isArray(d)) {
1511 return `${d.length} ${d.length === 1 ? "highlight" : "highlights"}`;
1512 }
1513 if (
1514 (id === "included_items" || id === "excluded_items") &&
1515 Array.isArray(d)
1516 ) {
1517 return `${d.length} ${d.length === 1 ? "item" : "items"}`;
1518 }
1519 if (id === "faqs" && Array.isArray(d)) {
1520 return `${d.length} Q&A`;
1521 }
1522 if (typeof d === "string") {
1523 const len = d.replace(/<[^>]+>/g, "").length;
1524 return `${len} ${len === 1 ? "char" : "chars"}`;
1525 }
1526 return "";
1527 }
1528
1529 function initialSectionState(): Record<SectionId, SectionState> {
1530 const out = {} as Record<SectionId, SectionState>;
1531 for (const s of SECTIONS) {
1532 out[s.id] = { status: "idle", retryContext: "" };
1533 }
1534 return out;
1535 }
1536
1537 function phaseLabel(phase: Phase): string {
1538 switch (phase) {
1539 case "setup":
1540 return __("Step 1 of 4 — tell AI about the trip", "yatra");
1541 case "generating":
1542 return __("Step 2 of 4 — drafting", "yatra");
1543 case "review":
1544 return __("Step 3 of 4 — review", "yatra");
1545 case "creating":
1546 return __("Step 4 of 4 — saving", "yatra");
1547 case "done":
1548 return __("Done", "yatra");
1549 case "error":
1550 return __("Error", "yatra");
1551 }
1552 }
1553
1554 function slugify(s: string): string {
1555 return s
1556 .toLowerCase()
1557 .trim()
1558 .replace(/[^\p{L}\p{N}-]+/gu, "-")
1559 .replace(/^-+|-+$/g, "")
1560 .slice(0, 80);
1561 }
1562
1563 function plainTextToHtml(text: string): string {
1564 if (!text) return "";
1565 const escape = (s: string) =>
1566 s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
1567 const paragraphs = text
1568 .replace(/\r\n?/g, "\n")
1569 .split(/\n{2,}/)
1570 .map((p) => p.trim())
1571 .filter((p) => p !== "");
1572 return paragraphs
1573 .map((p) => `<p>${escape(p).replace(/\n/g, "<br>")}</p>`)
1574 .join("");
1575 }
1576
1577 function decodeAmenityList(
1578 text: string,
1579 ): Array<{ title: string; description: string }> {
1580 return text
1581 .split(/\r?\n/)
1582 .map((line) => line.replace(/^[\s\-\*••]+/, "").trim())
1583 .filter((line) => line !== "" && !/^Day\s+\d+/i.test(line))
1584 .map((title) => ({ title, description: "" }));
1585 }
1586
1587 function decodeFaq(text: string): Array<{ question: string; answer: string }> {
1588 const out: Array<{ question: string; answer: string }> = [];
1589 const blocks = text.replace(/\r\n?/g, "\n").split(/\n{2,}/);
1590 for (const block of blocks) {
1591 const qMatch = block.match(/^\s*Q[:\.\-]\s*(.+)/im);
1592 const aMatch = block.match(/A[:\.\-]\s*([\s\S]+)/im);
1593 if (qMatch) {
1594 out.push({
1595 question: qMatch[1].trim(),
1596 answer: aMatch ? aMatch[1].trim() : "",
1597 });
1598 }
1599 }
1600 return out;
1601 }
1602
1603 function extractError(e: any): string {
1604 if (!e) return "Request failed.";
1605 const data = e?.response?.data ?? e?.data ?? null;
1606 if (
1607 data &&
1608 typeof data === "object" &&
1609 typeof (data as any).message === "string"
1610 ) {
1611 return (data as any).message;
1612 }
1613 return e?.message || "Request failed.";
1614 }
1615
1616 export default CreateTripWithAiWizard;
1617