PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.5
Yatra – Travel Booking & Tour Operator Software v3.0.5
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 / AiEmailTemplateModal.tsx

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

434 lines 15.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 import React, { useState } from "react";
2 import { Sparkles, Loader2, X, Check } from "lucide-react";
3 import { __ } from "../../lib/i18n";
4 import { aiApi } from "../../api/ai-api";
5 import { isAiReady } from "../../lib/ai-availability";
6 import { Modal } from "../ui/modal";
7 import { Button } from "../ui/button";
8
9 /**
10 * AI generator for one email-template's subject + body.
11 *
12 * The caller (EmailTemplateForm) opens this with the template's
13 * catalog metadata pre-filled — template_key, name, description,
14 * recipient_type, merge_tags. The operator picks a tone, optionally
15 * adds extra context, hits Generate. The backend agent emits one
16 * `set_email(subject, body)` tool call so the response shape is
17 * guaranteed structured (no regex parsing).
18 *
19 * On accept, the modal calls `onAccept({subject, body})` and the
20 * form replaces its current values with the AI output. On cancel,
21 * the existing subject/body stay untouched — the operator can try
22 * again without losing what they had.
23 */
24
25 interface MergeTag {
26 key: string;
27 label?: string;
28 }
29
30 interface Props {
31 open: boolean;
32 onClose: () => void;
33 templateKey: string;
34 templateName?: string;
35 templateDescription?: string;
36 recipientType?: "customer" | "admin";
37 mergeTags: string[];
38 currentSubject: string;
39 currentBody: string;
40 onAccept: (result: { subject: string; body: string }) => void;
41 }
42
43 const TONE_PRESETS = [
44 { id: "warm-professional", label: "Warm + professional" },
45 { id: "friendly-casual", label: "Friendly + casual" },
46 { id: "concise-formal", label: "Concise + formal" },
47 { id: "excited-promotional", label: "Excited + promotional" },
48 ];
49
50 export const AiEmailTemplateModal: React.FC<Props> = ({
51 open,
52 onClose,
53 templateKey,
54 templateName,
55 templateDescription,
56 recipientType = "customer",
57 mergeTags,
58 currentSubject,
59 currentBody,
60 onAccept,
61 }) => {
62 const [tone, setTone] = useState<string>(TONE_PRESETS[0].id);
63 const [extraContext, setExtraContext] = useState<string>("");
64 const [isGenerating, setIsGenerating] = useState<boolean>(false);
65 const [error, setError] = useState<string | null>(null);
66 const [draft, setDraft] = useState<{ subject: string; body: string } | null>(
67 null,
68 );
69
70 // Reset when the modal opens/closes — operators should see a fresh
71 // form each time they reopen the sparkle, not stale state.
72 React.useEffect(() => {
73 if (open) {
74 setError(null);
75 setDraft(null);
76 setIsGenerating(false);
77 }
78 }, [open]);
79
80 if (!open) return null;
81
82 if (!isAiReady()) {
83 return (
84 <Modal isOpen={open} onClose={onClose} size="md" hideHeader hideFooter>
85 <div className="p-6">
86 <h3 className="font-semibold text-gray-900 dark:text-white">
87 {__("AI Assistant not configured", "yatra")}
88 </h3>
89 <p className="mt-1 text-sm text-gray-600 dark:text-gray-300">
90 {__(
91 "Add an OpenAI or Anthropic key under Yatra → AI Assistant first.",
92 "yatra",
93 )}
94 </p>
95 <div className="mt-3 flex gap-2">
96 <Button asChild>
97 <a href="admin.php?page=yatra&subpage=ai-assistant">
98 {__("Open AI settings", "yatra")}
99 </a>
100 </Button>
101 <Button variant="outline" onClick={onClose}>
102 {__("Cancel", "yatra")}
103 </Button>
104 </div>
105 </div>
106 </Modal>
107 );
108 }
109
110 const generate = async () => {
111 setIsGenerating(true);
112 setError(null);
113 try {
114 const result = await aiApi.generateEmailTemplate({
115 template_key: templateKey,
116 template_name: templateName,
117 template_description: templateDescription,
118 recipient_type: recipientType,
119 merge_tags: mergeTags,
120 current_subject: currentSubject,
121 current_body: currentBody,
122 tone: TONE_PRESETS.find((t) => t.id === tone)?.label ?? tone,
123 extra_context: extraContext,
124 });
125 setDraft({ subject: result.subject, body: result.body });
126 } catch (e: any) {
127 const data = e?.response?.data ?? e?.data ?? null;
128 const msg =
129 data && typeof data === "object" && typeof data.message === "string"
130 ? data.message
131 : e?.message || __("AI generation failed.", "yatra");
132 setError(msg);
133 } finally {
134 setIsGenerating(false);
135 }
136 };
137
138 const accept = () => {
139 if (!draft) return;
140 onAccept({ subject: draft.subject, body: draft.body });
141 onClose();
142 };
143
144 return (
145 <Modal isOpen={open} onClose={onClose} size="xl" hideHeader hideFooter>
146 <div className="flex max-h-[90vh] min-h-[420px] min-w-[640px] max-w-[860px] flex-col">
147 {/* Header */}
148 <div className="flex items-center justify-between border-b border-gray-200 px-6 py-4 dark:border-gray-700">
149 <div className="flex items-center gap-2">
150 <div className="rounded-md bg-gradient-to-br from-blue-600 to-blue-700 p-1.5 text-white">
151 <Sparkles className="h-4 w-4" />
152 </div>
153 <div>
154 <h2 className="text-base font-semibold text-gray-900 dark:text-white">
155 {__("Generate email with AI", "yatra")}
156 </h2>
157 <div className="text-xs text-gray-500 dark:text-gray-400">
158 {templateName || templateKey}
159 </div>
160 </div>
161 </div>
162 <button
163 type="button"
164 onClick={onClose}
165 className="rounded text-gray-400 hover:text-gray-600"
166 aria-label={__("Close", "yatra")}
167 >
168 <X className="h-5 w-5" />
169 </button>
170 </div>
171
172 {/* Body */}
173 <div className="flex-1 overflow-y-auto px-6 py-4 space-y-4">
174 {!draft && (
175 <>
176 <p className="text-sm text-gray-600 dark:text-gray-300">
177 {templateDescription ||
178 __(
179 "AI will write a fresh subject and body using only the merge tags this template supports.",
180 "yatra",
181 )}
182 </p>
183
184 <div>
185 <label className="mb-1 block text-xs font-medium text-gray-700 dark:text-gray-200">
186 {__("Tone", "yatra")}
187 </label>
188 <div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
189 {TONE_PRESETS.map((t) => (
190 <button
191 key={t.id}
192 type="button"
193 onClick={() => setTone(t.id)}
194 className={`rounded-md border px-2.5 py-1.5 text-xs ${
195 tone === t.id
196 ? "border-blue-500 bg-blue-50 text-blue-700 dark:border-blue-400 dark:bg-blue-900/30 dark:text-blue-200"
197 : "border-gray-200 bg-white text-gray-700 hover:bg-gray-50 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-200"
198 }`}
199 >
200 {t.label}
201 </button>
202 ))}
203 </div>
204 </div>
205
206 <div>
207 <label className="mb-1 block text-xs font-medium text-gray-700 dark:text-gray-200">
208 {__("Anything else AI should know", "yatra")}{" "}
209 <span className="text-[10px] font-normal text-gray-400">
210 {__("(optional)", "yatra")}
211 </span>
212 </label>
213 <textarea
214 rows={3}
215 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"
216 placeholder={__(
217 "e.g. Mention our 24/7 support hotline. Keep total length under 120 words. Emphasize peace of mind.",
218 "yatra",
219 )}
220 value={extraContext}
221 onChange={(e) => setExtraContext(e.target.value)}
222 />
223 </div>
224
225 {mergeTags.length > 0 && (
226 <div className="rounded-md border border-gray-200 bg-gray-50 p-3 dark:border-gray-700 dark:bg-gray-800/40">
227 <div className="mb-1 text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
228 {__("Available merge tags", "yatra")}
229 </div>
230 <div className="flex flex-wrap gap-1.5">
231 {mergeTags.slice(0, 24).map((t) => (
232 <code
233 key={t}
234 className="rounded bg-white px-1.5 py-0.5 text-[10px] text-gray-700 dark:bg-gray-900 dark:text-gray-200"
235 >
236 {`{{${t}}}`}
237 </code>
238 ))}
239 {mergeTags.length > 24 && (
240 <span className="text-[10px] text-gray-500">
241 +{mergeTags.length - 24} {__("more", "yatra")}
242 </span>
243 )}
244 </div>
245 <p className="mt-1.5 text-[11px] text-gray-500 dark:text-gray-400">
246 {__(
247 "AI will only use tags from this list; it won't invent new ones.",
248 "yatra",
249 )}
250 </p>
251 </div>
252 )}
253 </>
254 )}
255
256 {error && (
257 <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">
258 {error}
259 </div>
260 )}
261
262 {draft && <DraftEditor draft={draft} onChange={(d) => setDraft(d)} />}
263 </div>
264
265 {/* Footer */}
266 <div className="flex items-center justify-end gap-2 border-t border-gray-200 px-6 py-3 dark:border-gray-700">
267 {!draft && (
268 <>
269 <Button
270 variant="outline"
271 onClick={onClose}
272 disabled={isGenerating}
273 >
274 {__("Cancel", "yatra")}
275 </Button>
276 <Button onClick={generate} disabled={isGenerating}>
277 {isGenerating ? (
278 <>
279 <Loader2 className="mr-2 h-4 w-4 animate-spin" />
280 {__("Generating…", "yatra")}
281 </>
282 ) : (
283 <>
284 <Sparkles className="mr-2 h-4 w-4" />
285 {__("Generate", "yatra")}
286 </>
287 )}
288 </Button>
289 </>
290 )}
291 {draft && (
292 <>
293 <Button variant="outline" onClick={() => setDraft(null)}>
294 {__("Discard, try again", "yatra")}
295 </Button>
296 <Button onClick={accept}>
297 <Check className="mr-2 h-4 w-4" />
298 {__("Use this", "yatra")}
299 </Button>
300 </>
301 )}
302 </div>
303 </div>
304 </Modal>
305 );
306 };
307
308 /**
309 * Subject + body editor with tabbed Preview / HTML views. The
310 * Preview tab renders the agent's HTML in a sandboxed iframe so the
311 * inline styles look like they will in a real email client; the
312 * HTML tab is a plain textarea for power users who want to tweak
313 * before accepting.
314 *
315 * iframe sandbox is important — operators paste the result into a
316 * production email template, so we must NOT execute any script the
317 * model might emit (scripts shouldn't be in the agent's output but
318 * we treat that as defense-in-depth, not a guarantee).
319 */
320 const DraftEditor: React.FC<{
321 draft: { subject: string; body: string };
322 onChange: (d: { subject: string; body: string }) => void;
323 }> = ({ draft, onChange }) => {
324 const [tab, setTab] = useState<"preview" | "html">("preview");
325 return (
326 <div className="space-y-3">
327 <div>
328 <label className="mb-1 block text-xs font-medium text-gray-700 dark:text-gray-200">
329 {__("Subject", "yatra")}
330 </label>
331 <input
332 type="text"
333 className="w-full rounded-md border border-gray-300 bg-white p-2 font-mono text-sm dark:border-gray-600 dark:bg-gray-800"
334 value={draft.subject}
335 onChange={(e) => onChange({ ...draft, subject: e.target.value })}
336 />
337 </div>
338 <div>
339 <div className="mb-1 flex items-center justify-between">
340 <label className="block text-xs font-medium text-gray-700 dark:text-gray-200">
341 {__("Body", "yatra")}
342 </label>
343 <div
344 role="tablist"
345 className="inline-flex overflow-hidden rounded-md border border-gray-200 dark:border-gray-700"
346 >
347 <button
348 type="button"
349 role="tab"
350 aria-selected={tab === "preview"}
351 onClick={() => setTab("preview")}
352 className={`px-2.5 py-1 text-[11px] font-medium ${
353 tab === "preview"
354 ? "bg-blue-600 text-white"
355 : "bg-white text-gray-700 hover:bg-gray-50 dark:bg-gray-800 dark:text-gray-200"
356 }`}
357 >
358 {__("Preview", "yatra")}
359 </button>
360 <button
361 type="button"
362 role="tab"
363 aria-selected={tab === "html"}
364 onClick={() => setTab("html")}
365 className={`px-2.5 py-1 text-[11px] font-medium ${
366 tab === "html"
367 ? "bg-blue-600 text-white"
368 : "bg-white text-gray-700 hover:bg-gray-50 dark:bg-gray-800 dark:text-gray-200"
369 }`}
370 >
371 {__("HTML", "yatra")}
372 </button>
373 </div>
374 </div>
375
376 {tab === "preview" ? (
377 <EmailPreview html={draft.body} />
378 ) : (
379 <textarea
380 rows={14}
381 className="w-full rounded-md border border-gray-300 bg-white p-2 font-mono text-xs dark:border-gray-600 dark:bg-gray-800"
382 value={draft.body}
383 onChange={(e) => onChange({ ...draft, body: e.target.value })}
384 />
385 )}
386 <p className="mt-1 text-[11px] text-gray-500 dark:text-gray-400">
387 {__(
388 "Preview shows the rendered email; HTML tab lets you edit. Merge tags like {{customer_name}} stay as-is — they're replaced when the email actually sends.",
389 "yatra",
390 )}
391 </p>
392 </div>
393 </div>
394 );
395 };
396
397 /**
398 * Sandboxed iframe that renders the agent's HTML body inside a
399 * minimal shell — same neutral typography + light background that
400 * Yatra's email shell will eventually wrap around. Sandboxed with
401 * `allow-same-origin` only, so any <script> the model emits is
402 * inert (scripts shouldn't exist; this is defense-in-depth).
403 */
404 const EmailPreview: React.FC<{ html: string }> = ({ html }) => {
405 // Build a self-contained HTML doc. The agent's body is dropped in
406 // raw — its inline styles are what we want to see. The wrapper
407 // provides only the neutral background + the centered content
408 // width that a real email client would render with.
409 const doc = `<!DOCTYPE html>
410 <html>
411 <head>
412 <meta charset="utf-8">
413 <meta name="viewport" content="width=device-width,initial-scale=1">
414 </head>
415 <body style="margin:0;padding:24px 16px;background:#f3f4f6;font-family:Arial,Helvetica,sans-serif;">
416 <div style="max-width:600px;margin:0 auto;background:#ffffff;border:1px solid #e5e7eb;border-radius:10px;padding:28px 24px;">
417 ${html}
418 </div>
419 </body>
420 </html>`;
421
422 return (
423 <iframe
424 title="Email preview"
425 sandbox="allow-same-origin"
426 srcDoc={doc}
427 className="w-full rounded-md border border-gray-200 bg-white dark:border-gray-700"
428 style={{ height: "420px" }}
429 />
430 );
431 };
432
433 export default AiEmailTemplateModal;
434