PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.9
Yatra – Travel Booking & Tour Operator Software v3.0.9
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 / BuildItineraryModal.tsx

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

803 lines 33.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 import React, { useEffect, useState } from "react";
2 import {
3 Sparkles,
4 X,
5 Loader2,
6 Check,
7 AlertCircle,
8 Replace,
9 Plus,
10 Calendar,
11 HelpCircle,
12 RefreshCw,
13 } from "lucide-react";
14 import { __ } from "../../lib/i18n";
15 import { aiApi } from "../../api/ai-api";
16 import { isAiReady } from "../../lib/ai-availability";
17 import { Button } from "../ui/button";
18 import { Modal } from "../ui/modal";
19 import { detectClarification } from "./clarificationDetector";
20
21 /**
22 * "Build Itinerary with AI" — the standalone version that lives on the
23 * Itinerary page (Yatra → Itinerary → trip selected → ✨ button).
24 *
25 * Flow:
26 * 1. Generate phase: kicks off /ai/itinerary/{tripId}/draft → returns
27 * a parsed days[] array (server-side parsing, so a bad LLM response
28 * that the React parser would choke on still surfaces meaningfully).
29 * 2. Preview phase: shows each day's title + description with the
30 * ability to scroll. Operator picks `Replace` (drop existing
31 * AI/manual days) or `Add` (append after existing days).
32 * 3. Apply phase: posts /ai/itinerary/{tripId}/apply with the chosen
33 * mode. Modal closes on success and the parent triggers a refetch.
34 */
35 interface ParsedActivity {
36 title: string;
37 description?: string;
38 item_type?: string;
39 item_name?: string;
40 start_time?: string;
41 end_time?: string;
42 duration?: string;
43 location?: string;
44 }
45
46 interface ParsedDay {
47 day: number;
48 day_title: string;
49 description: string;
50 /** Day entries (activity / meal / accommodation / transport blocks)
51 * the agent populates under each day. May be empty for days that
52 * the agent decided don't need a granular schedule. */
53 activities?: ParsedActivity[];
54 }
55
56 interface BuildItineraryModalProps {
57 open: boolean;
58 onClose: () => void;
59 /** Trip selected on the Itinerary page. */
60 tripId: number;
61 /** Display label for the trip — purely cosmetic, helps the operator
62 * confirm they're building for the right trip. */
63 tripName: string;
64 /** Duration from the trip record. We use this to drive UX before the
65 * request even fires: 0 means the operator needs to set duration on
66 * the Trip form first; otherwise show the actual day count so
67 * there's no doubt what AI will draft. */
68 tripDurationDays: number;
69 /** Called after a successful apply so the parent can refetch the
70 * itinerary list to surface the new rows. */
71 onApplied: (info: { count: number; message: string }) => void;
72 }
73
74 type Phase =
75 | "intro"
76 | "generating"
77 | "preview"
78 | "applying"
79 | "done"
80 | "error"
81 | "needs_context";
82
83 export const BuildItineraryModal: React.FC<BuildItineraryModalProps> = ({
84 open,
85 onClose,
86 tripId,
87 tripName,
88 tripDurationDays,
89 onApplied,
90 }) => {
91 const [phase, setPhase] = useState<Phase>("intro");
92 const [days, setDays] = useState<ParsedDay[]>([]);
93 const [error, setError] = useState<string | null>(null);
94 const [mode, setMode] = useState<"replace" | "append">("replace");
95 const [trip, setTrip] = useState<{ duration_days: number } | null>(null);
96 // Operator's free-text "what should AI know" notes — sent as
97 // `extra_context` so the LLM has ground-truth facts even when the
98 // form is sparsely filled.
99 const [extraContext, setExtraContext] = useState("");
100 // Structured wizard inputs — get serialized into extra_context so we
101 // don't need new prompt-template fields per option.
102 const [pace, setPace] = useState<"relaxed" | "balanced" | "packed">(
103 "balanced",
104 );
105 const [style, setStyle] = useState("cultural");
106 const [focus, setFocus] = useState("");
107 const [arrivalNotes, setArrivalNotes] = useState("");
108 const [accommodationTier, setAccommodationTier] = useState("mid-range");
109 // AI clarification details (when LLM refuses with a question).
110 const [clarification, setClarification] = useState<{
111 message: string;
112 questions: string[];
113 rawText: string;
114 } | null>(null);
115 // Operator's inline answer to a clarification request. Must be
116 // declared with the rest of the hooks (before any early-return) to
117 // preserve React's hook-order invariant (error #310).
118 const [retryContext, setRetryContext] = useState("");
119
120 useEffect(() => {
121 if (open) {
122 setPhase("intro");
123 setDays([]);
124 setError(null);
125 setMode("replace");
126 setTrip(null);
127 setExtraContext("");
128 setPace("balanced");
129 setStyle("cultural");
130 setFocus("");
131 setArrivalNotes("");
132 setAccommodationTier("mid-range");
133 setClarification(null);
134 setRetryContext("");
135 }
136 }, [open]);
137
138 useEffect(() => {
139 if (phase !== "needs_context") setRetryContext("");
140 }, [phase]);
141
142 const hasDuration = tripDurationDays > 0;
143
144 if (!open) return null;
145
146 if (!isAiReady()) {
147 return (
148 <Modal isOpen={open} onClose={onClose} size="md" hideHeader hideFooter>
149 <div className="p-6 max-w-md">
150 <div className="flex items-start gap-3">
151 <AlertCircle className="mt-0.5 h-5 w-5 text-amber-500" />
152 <div>
153 <h3 className="font-semibold text-gray-900 dark:text-white">
154 {__("AI Assistant not configured", "yatra")}
155 </h3>
156 <p className="mt-1 text-sm text-gray-600 dark:text-gray-300">
157 {__(
158 "Add an OpenAI or Anthropic key under Yatra → AI Assistant first.",
159 "yatra",
160 )}
161 </p>
162 <div className="mt-3 flex gap-2">
163 <Button asChild>
164 <a href="admin.php?page=yatra&subpage=ai-assistant">
165 {__("Open AI settings", "yatra")}
166 </a>
167 </Button>
168 <Button variant="outline" onClick={onClose}>
169 {__("Cancel", "yatra")}
170 </Button>
171 </div>
172 </div>
173 </div>
174 </div>
175 </Modal>
176 );
177 }
178
179 /**
180 * Merge the structured wizard fields into the free-text extra_context
181 * the prompt template already expects. Keeps the prompt-template
182 * surface narrow — operators can override the master template later
183 * via the Prompts tab without us breaking when we add wizard fields.
184 */
185 const buildExtraContext = (extraSuffix = ""): string => {
186 const structured: string[] = [];
187 if (style) structured.push(`Travel style: ${style}`);
188 if (pace) structured.push(`Pace: ${pace} (${paceHint(pace)})`);
189 if (accommodationTier)
190 structured.push(`Accommodation tier: ${accommodationTier}`);
191 if (focus.trim()) structured.push(`Daily focus emphasis: ${focus.trim()}`);
192 if (arrivalNotes.trim())
193 structured.push(`Arrival/departure logistics: ${arrivalNotes.trim()}`);
194
195 return [structured.join("\n"), extraContext.trim(), extraSuffix.trim()]
196 .filter((s) => s !== "")
197 .join("\n\n");
198 };
199
200 const generate = async (extraSuffix = "") => {
201 setPhase("generating");
202 setError(null);
203 setClarification(null);
204 try {
205 const combined = buildExtraContext(extraSuffix);
206 const res = await aiApi.draftItinerary(tripId, combined);
207
208 // Detect when the LLM responded with a refusal/clarification
209 // instead of a valid Day-N itinerary. The standalone server-side
210 // parser would return 0 days, which historically just produced
211 // an unhelpful "couldn't parse" message — now we surface the AI's
212 // question and let the operator answer inline.
213 const c = detectClarification(res.text, { expectDayHeads: true });
214 if ((!res.days || res.days.length === 0) && c.isClarification) {
215 setClarification({
216 message: c.message,
217 questions: c.questions,
218 rawText: res.text,
219 });
220 setTrip({ duration_days: res.trip.duration_days });
221 setPhase("needs_context");
222 return;
223 }
224
225 setDays(res.days);
226 setTrip({ duration_days: res.trip.duration_days });
227 setPhase("preview");
228 } catch (e: any) {
229 setError(extractError(e));
230 setPhase("error");
231 }
232 };
233
234 const retryWithAnswers = () => {
235 const suffix = retryContext.trim();
236 if (suffix === "") return;
237 // Merge the answers into the persistent extraContext so subsequent
238 // retries don't lose them. Then re-run.
239 setExtraContext((prev) =>
240 [prev.trim(), suffix].filter((s) => s !== "").join("\n\n"),
241 );
242 void generate(suffix);
243 };
244
245 const apply = async () => {
246 setPhase("applying");
247 setError(null);
248 try {
249 const res = await aiApi.applyItinerary(tripId, days, mode === "replace");
250 setPhase("done");
251 onApplied({ count: res.count, message: res.message });
252 // Auto-close after a beat so the operator sees the success state.
253 setTimeout(() => onClose(), 900);
254 } catch (e: any) {
255 setError(extractError(e));
256 setPhase("error");
257 }
258 };
259
260 return (
261 <Modal
262 isOpen={open}
263 onClose={onClose}
264 size="xl"
265 hideHeader
266 hideFooter
267 // Suppress the Modal's default padding + 70vh body scroll so this
268 // wizard's own sticky-header / scrolling-body / sticky-footer
269 // layout takes over. Without these overrides the outer modal
270 // capped at 70vh and the wizard's body never engaged its own
271 // scroll, forcing the operator to scroll the whole modal.
272 bodyClassName=""
273 bodyScrollClassName=""
274 >
275 <div className="flex h-[85vh] max-h-[85vh] min-h-[480px] w-full flex-col">
276 {/* Header */}
277 <div className="flex items-center justify-between border-b border-gray-200 px-6 py-4 dark:border-gray-700">
278 <div className="flex items-center gap-2">
279 <div className="rounded-md bg-gradient-to-br from-blue-600 to-blue-700 p-1.5 text-white">
280 <Sparkles className="h-4 w-4" />
281 </div>
282 <div>
283 <h2 className="text-base font-semibold text-gray-900 dark:text-white">
284 {__("Build Itinerary with AI", "yatra")}
285 </h2>
286 <div className="text-xs text-gray-500 dark:text-gray-400">
287 {tripName}
288 </div>
289 </div>
290 </div>
291 <button
292 type="button"
293 onClick={onClose}
294 className="rounded text-gray-400 hover:text-gray-600"
295 aria-label={__("Close", "yatra")}
296 >
297 <X className="h-5 w-5" />
298 </button>
299 </div>
300
301 {/* Body */}
302 <div className="flex-1 overflow-y-auto px-6 py-4">
303 {phase === "intro" && (
304 <div className="space-y-3">
305 <p className="text-sm text-gray-700 dark:text-gray-300">
306 {__(
307 "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.",
308 "yatra",
309 )}
310 </p>
311
312 {/* Show the actual duration from the trip record. If it's
313 missing, only then surface the warning — and tell the
314 operator where to set it. */}
315 {hasDuration ? (
316 <div className="flex items-center gap-2 rounded-md border border-blue-200 bg-blue-50/60 px-3 py-2 text-sm text-blue-900 dark:border-blue-500/40 dark:bg-blue-900/20 dark:text-blue-200">
317 <Calendar className="h-4 w-4" />
318 <span>
319 {__("AI will generate", "yatra")}{" "}
320 <strong>
321 {tripDurationDays} {__("days", "yatra")}
322 </strong>{" "}
323 {__(
324 "of itinerary, matching this trip's duration.",
325 "yatra",
326 )}
327 </span>
328 </div>
329 ) : (
330 <div className="rounded-md border border-amber-200 bg-amber-50/40 p-3 text-xs text-amber-800 dark:border-amber-500/40 dark:bg-amber-900/20 dark:text-amber-200">
331 <AlertCircle className="mr-1 inline h-3.5 w-3.5" />
332 {__(
333 "This trip has no duration set. Open the trip and fill in 'Duration (days)' under Duration & Schedule, then come back here.",
334 "yatra",
335 )}
336 </div>
337 )}
338
339 {/* Structured wizard inputs — these get serialized into
340 extra_context so the prompt template doesn't need new
341 fields per option. */}
342 <div className="grid gap-3 md:grid-cols-3">
343 <div>
344 <label className="mb-1 block text-xs font-medium text-gray-700 dark:text-gray-200">
345 {__("Pace", "yatra")}
346 </label>
347 <select
348 className="w-full rounded-md border border-gray-300 bg-white p-2 text-sm dark:border-gray-600 dark:bg-gray-800"
349 value={pace}
350 onChange={(e) => setPace(e.target.value as typeof pace)}
351 >
352 <option value="relaxed">{__("Relaxed", "yatra")}</option>
353 <option value="balanced">{__("Balanced", "yatra")}</option>
354 <option value="packed">{__("Packed", "yatra")}</option>
355 </select>
356 </div>
357 <div>
358 <label className="mb-1 block text-xs font-medium text-gray-700 dark:text-gray-200">
359 {__("Travel style", "yatra")}
360 </label>
361 <select
362 className="w-full rounded-md border border-gray-300 bg-white p-2 text-sm dark:border-gray-600 dark:bg-gray-800"
363 value={style}
364 onChange={(e) => setStyle(e.target.value)}
365 >
366 <option value="cultural">{__("Cultural", "yatra")}</option>
367 <option value="adventure">
368 {__("Adventure", "yatra")}
369 </option>
370 <option value="luxury">{__("Luxury", "yatra")}</option>
371 <option value="budget">{__("Budget", "yatra")}</option>
372 <option value="family-friendly">
373 {__("Family-friendly", "yatra")}
374 </option>
375 <option value="food-focused">
376 {__("Food-focused", "yatra")}
377 </option>
378 <option value="photography">
379 {__("Photography", "yatra")}
380 </option>
381 <option value="wildlife">{__("Wildlife", "yatra")}</option>
382 </select>
383 </div>
384 <div>
385 <label className="mb-1 block text-xs font-medium text-gray-700 dark:text-gray-200">
386 {__("Accommodation", "yatra")}
387 </label>
388 <select
389 className="w-full rounded-md border border-gray-300 bg-white p-2 text-sm dark:border-gray-600 dark:bg-gray-800"
390 value={accommodationTier}
391 onChange={(e) => setAccommodationTier(e.target.value)}
392 >
393 <option value="budget">{__("Budget", "yatra")}</option>
394 <option value="mid-range">
395 {__("Mid-range", "yatra")}
396 </option>
397 <option value="boutique">{__("Boutique", "yatra")}</option>
398 <option value="luxury">{__("Luxury", "yatra")}</option>
399 <option value="homestay">
400 {__("Homestay / local", "yatra")}
401 </option>
402 </select>
403 </div>
404 </div>
405
406 <div className="grid gap-3 md:grid-cols-2">
407 <div>
408 <label className="mb-1 block text-xs font-medium text-gray-700 dark:text-gray-200">
409 {__("Daily focus emphasis", "yatra")}{" "}
410 <span className="text-[10px] font-normal text-gray-400">
411 {__("(optional)", "yatra")}
412 </span>
413 </label>
414 <input
415 type="text"
416 className="w-full rounded-md border border-gray-300 bg-white p-2 text-sm dark:border-gray-600 dark:bg-gray-800"
417 placeholder={__(
418 "morning hikes, afternoon culture, evening street food",
419 "yatra",
420 )}
421 value={focus}
422 onChange={(e) => setFocus(e.target.value)}
423 />
424 </div>
425 <div>
426 <label className="mb-1 block text-xs font-medium text-gray-700 dark:text-gray-200">
427 {__("Arrival / departure logistics", "yatra")}{" "}
428 <span className="text-[10px] font-normal text-gray-400">
429 {__("(optional)", "yatra")}
430 </span>
431 </label>
432 <input
433 type="text"
434 className="w-full rounded-md border border-gray-300 bg-white p-2 text-sm dark:border-gray-600 dark:bg-gray-800"
435 placeholder={__(
436 "Day 1 evening arrival, last day morning departure",
437 "yatra",
438 )}
439 value={arrivalNotes}
440 onChange={(e) => setArrivalNotes(e.target.value)}
441 />
442 </div>
443 </div>
444
445 {/* Free-text context — the operator's notes flow into the
446 prompt as `{{extra_context}}` so AI doesn't refuse on
447 sparse trips. */}
448 <div className="rounded-md border border-blue-200 bg-blue-50/40 p-3 dark:border-blue-500/40 dark:bg-blue-900/20">
449 <label
450 htmlFor="build-itin-context"
451 className="mb-1 flex items-center gap-1.5 text-xs font-medium text-blue-900 dark:text-blue-200"
452 >
453 <Sparkles className="h-3.5 w-3.5" />
454 {__("Anything else AI should know", "yatra")}
455 <span className="ml-1 rounded bg-blue-200 px-1.5 py-0.5 text-[10px] font-semibold uppercase text-blue-800 dark:bg-blue-900/60 dark:text-blue-200">
456 {__("Recommended", "yatra")}
457 </span>
458 </label>
459 <textarea
460 id="build-itin-context"
461 rows={2}
462 className="w-full rounded-md border border-blue-200 bg-white p-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-400 dark:border-blue-500/40 dark:bg-gray-800"
463 placeholder={__(
464 "Halal-friendly meals, max group size 8, photo-friendly viewpoints…",
465 "yatra",
466 )}
467 value={extraContext}
468 onChange={(e) => setExtraContext(e.target.value)}
469 />
470 </div>
471 </div>
472 )}
473
474 {phase === "needs_context" && (
475 <div className="space-y-3">
476 <div className="rounded-md border border-amber-200 bg-amber-50/60 p-3 dark:border-amber-500/40 dark:bg-amber-900/20">
477 <div className="flex items-start gap-2 text-amber-900 dark:text-amber-200">
478 <HelpCircle className="mt-0.5 h-4 w-4 shrink-0" />
479 <div className="flex-1 text-sm">
480 <div className="font-medium">
481 {__(
482 "AI needs a few more details to draft a useful itinerary.",
483 "yatra",
484 )}
485 </div>
486 {clarification?.message && (
487 <p className="mt-1 text-xs text-amber-800/90 dark:text-amber-200/90">
488 {clarification.message}
489 </p>
490 )}
491 {clarification?.questions &&
492 clarification.questions.length > 0 && (
493 <ul className="mt-2 list-disc space-y-0.5 pl-4 text-xs text-amber-900 dark:text-amber-200">
494 {clarification.questions.map((q, i) => (
495 <li key={i}>{q}</li>
496 ))}
497 </ul>
498 )}
499 </div>
500 </div>
501 </div>
502 <label
503 htmlFor="build-itin-retry"
504 className="block text-xs font-medium text-gray-700 dark:text-gray-200"
505 >
506 {__("Answer the questions in your own words:", "yatra")}
507 </label>
508 <textarea
509 id="build-itin-retry"
510 rows={5}
511 className="w-full rounded-md border border-amber-200 bg-white p-2 text-sm focus:outline-none focus:ring-2 focus:ring-amber-400 dark:border-amber-500/40 dark:bg-gray-800"
512 placeholder={__(
513 "e.g. 7 days, focus on food tours and rickshaw rides, moderate difficulty, best in November-February.",
514 "yatra",
515 )}
516 value={retryContext}
517 onChange={(e) => setRetryContext(e.target.value)}
518 />
519 </div>
520 )}
521
522 {phase === "generating" && (
523 <div className="flex h-full flex-col items-center justify-center gap-3 text-center">
524 <Loader2 className="h-8 w-8 animate-spin text-blue-500" />
525 <div className="text-sm text-gray-700 dark:text-gray-300">
526 {__("AI is drafting your itinerary…", "yatra")}
527 </div>
528 <div className="text-xs text-gray-500 dark:text-gray-400">
529 {__("This usually takes 10-20 seconds.", "yatra")}
530 </div>
531 </div>
532 )}
533
534 {phase === "preview" && (
535 <div className="space-y-3">
536 <div className="flex items-center justify-between rounded-md border border-blue-200 bg-blue-50/60 px-3 py-2 dark:border-blue-500/40 dark:bg-blue-900/20">
537 <div className="text-sm text-blue-900 dark:text-blue-200">
538 {__("Drafted", "yatra")} <strong>{days.length}</strong>{" "}
539 {__("days. Review before applying.", "yatra")}
540 {trip && days.length !== trip.duration_days && (
541 <div className="mt-1 text-xs text-amber-700 dark:text-amber-300">
542 <AlertCircle className="mr-1 inline h-3 w-3" />
543 {__("Trip duration is", "yatra")} {trip.duration_days}{" "}
544 {__("days — itinerary may need adjustment.", "yatra")}
545 </div>
546 )}
547 </div>
548 </div>
549
550 {days.length === 0 && (
551 <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">
552 <AlertCircle className="mr-1 inline h-4 w-4" />
553 {__(
554 "AI couldn't parse a clean itinerary. Try generating again.",
555 "yatra",
556 )}
557 </div>
558 )}
559
560 {days.map((d) => (
561 <div
562 key={d.day}
563 className="rounded-md border border-gray-200 bg-white p-3 dark:border-gray-700 dark:bg-gray-800"
564 >
565 <div className="mb-1 flex items-center gap-2">
566 <span className="inline-flex h-5 min-w-[40px] items-center justify-center rounded bg-blue-100 px-1.5 text-xs font-semibold text-blue-700 dark:bg-blue-900/40 dark:text-blue-200">
567 {__("Day", "yatra")} {d.day}
568 </span>
569 <h4 className="text-sm font-medium text-gray-900 dark:text-white">
570 {d.day_title || `Day ${d.day}`}
571 </h4>
572 </div>
573 <p className="whitespace-pre-wrap text-xs leading-relaxed text-gray-700 dark:text-gray-300">
574 {d.description}
575 </p>
576 {/* Activities list — each block becomes a day_entry
577 row on apply. Shown as a compact stack inside
578 each day card so the operator sees the same
579 two-layer structure the public trip page will
580 render (day overview + per-activity blocks). */}
581 {d.activities && d.activities.length > 0 && (
582 <ul className="mt-3 space-y-1.5 border-t border-gray-100 pt-2 dark:border-gray-700">
583 {d.activities.map((a, idx) => (
584 <li
585 key={idx}
586 className="flex items-start gap-2 text-xs"
587 >
588 <span
589 className="mt-0.5 inline-flex shrink-0 items-center rounded bg-gray-100 px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-gray-600 dark:bg-gray-700 dark:text-gray-300"
590 title={a.item_type}
591 >
592 {a.item_name ||
593 (a.item_type
594 ? a.item_type.replace(/_/g, " ")
595 : __("Activity", "yatra"))}
596 </span>
597 <div className="flex-1 leading-relaxed">
598 <span className="font-medium text-gray-900 dark:text-white">
599 {a.title}
600 </span>
601 {(a.start_time || a.end_time || a.duration) && (
602 <span className="ml-2 text-gray-500 dark:text-gray-400">
603 ·{" "}
604 {a.start_time && a.end_time
605 ? `${a.start_time}–${a.end_time}`
606 : a.start_time || a.duration}
607 </span>
608 )}
609 {a.location && (
610 <span className="ml-2 text-gray-500 dark:text-gray-400">
611 · {a.location}
612 </span>
613 )}
614 {a.description && (
615 <div className="mt-0.5 text-gray-600 dark:text-gray-300">
616 {a.description}
617 </div>
618 )}
619 </div>
620 </li>
621 ))}
622 </ul>
623 )}
624 </div>
625 ))}
626
627 {days.length > 0 && (
628 <div className="rounded-md border border-gray-200 p-3 dark:border-gray-700">
629 <div className="text-xs font-medium text-gray-700 dark:text-gray-200">
630 {__("How should these days land?", "yatra")}
631 </div>
632 <div className="mt-2 flex flex-col gap-2 sm:flex-row">
633 <label className="flex flex-1 cursor-pointer items-start gap-2 rounded-md border border-gray-200 p-2 text-xs hover:bg-gray-50 dark:border-gray-700 dark:hover:bg-gray-800">
634 <input
635 type="radio"
636 className="mt-0.5"
637 checked={mode === "replace"}
638 onChange={() => setMode("replace")}
639 />
640 <div>
641 <div className="flex items-center gap-1 font-medium text-gray-900 dark:text-white">
642 <Replace className="h-3.5 w-3.5" />
643 {__("Replace existing", "yatra")}
644 </div>
645 <div className="text-gray-500 dark:text-gray-400">
646 {__(
647 "Deletes the trip's current day rows first. Use when starting fresh.",
648 "yatra",
649 )}
650 </div>
651 </div>
652 </label>
653 <label className="flex flex-1 cursor-pointer items-start gap-2 rounded-md border border-gray-200 p-2 text-xs hover:bg-gray-50 dark:border-gray-700 dark:hover:bg-gray-800">
654 <input
655 type="radio"
656 className="mt-0.5"
657 checked={mode === "append"}
658 onChange={() => setMode("append")}
659 />
660 <div>
661 <div className="flex items-center gap-1 font-medium text-gray-900 dark:text-white">
662 <Plus className="h-3.5 w-3.5" />
663 {__("Append after existing", "yatra")}
664 </div>
665 <div className="text-gray-500 dark:text-gray-400">
666 {__(
667 "Keeps current days and adds these on top.",
668 "yatra",
669 )}
670 </div>
671 </div>
672 </label>
673 </div>
674 </div>
675 )}
676 </div>
677 )}
678
679 {phase === "applying" && (
680 <div className="flex h-full flex-col items-center justify-center gap-3 text-center">
681 <Loader2 className="h-8 w-8 animate-spin text-blue-500" />
682 <div className="text-sm text-gray-700 dark:text-gray-300">
683 {__("Saving days to the itinerary…", "yatra")}
684 </div>
685 </div>
686 )}
687
688 {phase === "done" && (
689 <div className="flex h-full flex-col items-center justify-center gap-3 text-center">
690 <div className="rounded-full bg-green-100 p-3 dark:bg-green-900/30">
691 <Check className="h-6 w-6 text-green-600" />
692 </div>
693 <div className="text-sm font-medium text-gray-900 dark:text-white">
694 {__("Itinerary built.", "yatra")}
695 </div>
696 </div>
697 )}
698
699 {phase === "error" && (
700 <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">
701 <AlertCircle className="mr-1 inline h-4 w-4" />
702 {error || __("Something went wrong.", "yatra")}
703 </div>
704 )}
705 </div>
706
707 {/* Footer */}
708 <div className="flex items-center justify-end gap-2 border-t border-gray-200 px-6 py-3 dark:border-gray-700">
709 {phase === "intro" && (
710 <>
711 <Button variant="outline" onClick={onClose}>
712 {__("Cancel", "yatra")}
713 </Button>
714 <Button onClick={() => generate()} disabled={!hasDuration}>
715 <Sparkles className="mr-2 h-4 w-4" />
716 {__("Generate", "yatra")}
717 </Button>
718 </>
719 )}
720 {phase === "needs_context" && (
721 <>
722 <Button variant="outline" onClick={onClose}>
723 {__("Cancel", "yatra")}
724 </Button>
725 <Button
726 onClick={retryWithAnswers}
727 disabled={retryContext.trim() === ""}
728 >
729 <RefreshCw className="mr-2 h-4 w-4" />
730 {__("Try again with this", "yatra")}
731 </Button>
732 </>
733 )}
734 {phase === "generating" && (
735 <Button variant="outline" disabled>
736 <Loader2 className="mr-2 h-4 w-4 animate-spin" />
737 {__("Generating…", "yatra")}
738 </Button>
739 )}
740 {phase === "preview" && (
741 <>
742 <Button variant="outline" onClick={onClose}>
743 {__("Discard", "yatra")}
744 </Button>
745 <Button variant="outline" onClick={() => generate()}>
746 {__("Try again", "yatra")}
747 </Button>
748 <Button onClick={apply} disabled={days.length === 0}>
749 <Check className="mr-2 h-4 w-4" />
750 {mode === "replace"
751 ? __("Replace & save", "yatra")
752 : __("Append & save", "yatra")}
753 </Button>
754 </>
755 )}
756 {phase === "applying" && (
757 <Button variant="outline" disabled>
758 <Loader2 className="mr-2 h-4 w-4 animate-spin" />
759 {__("Saving…", "yatra")}
760 </Button>
761 )}
762 {phase === "error" && (
763 <>
764 <Button variant="outline" onClick={onClose}>
765 {__("Close", "yatra")}
766 </Button>
767 <Button onClick={() => generate()}>
768 {__("Try again", "yatra")}
769 </Button>
770 </>
771 )}
772 {phase === "done" && (
773 <Button variant="outline" onClick={onClose}>
774 {__("Close", "yatra")}
775 </Button>
776 )}
777 </div>
778 </div>
779 </Modal>
780 );
781 };
782
783 function paceHint(p: "relaxed" | "balanced" | "packed"): string {
784 if (p === "relaxed") return "1-2 main activities per day, time for rest";
785 if (p === "packed") return "4-5 activities per day, full schedule";
786 return "2-3 main activities per day with breathing room";
787 }
788
789 function extractError(e: any): string {
790 if (!e) return "Request failed.";
791 const data = e?.response?.data ?? e?.data ?? null;
792 if (
793 data &&
794 typeof data === "object" &&
795 typeof (data as any).message === "string"
796 ) {
797 return (data as any).message;
798 }
799 return e?.message || "Request failed.";
800 }
801
802 export default BuildItineraryModal;
803