| 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, "&").replace(/</g, "<").replace(/>/g, ">"); |
| 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 |
|