PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.10
Yatra – Travel Booking & Tour Operator Software v3.0.10
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 2.0.10 All 83 releases
yatra / resources / js / pages / EmailTemplateForm.tsx

EmailTemplateForm.tsx in Yatra – Travel Booking & Tour Operator Software 3.0.10, at resources/js/pages/EmailTemplateForm.tsx

1,405 lines 56.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 import React, { useState, useEffect, useMemo } from "react";
2 import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
3 import { __ } from "../lib/i18n";
4 import { useToast } from "../components/ui/toast";
5 import {
6 Card,
7 CardContent,
8 CardHeader,
9 CardTitle,
10 } from "../components/ui/card";
11 import { Button } from "../components/ui/button";
12 import { Input } from "../components/ui/input";
13 import { Badge } from "../components/ui/badge";
14 import {
15 Mail,
16 ArrowLeft,
17 Save,
18 Eye,
19 Send,
20 Loader2,
21 Code,
22 Copy,
23 Check,
24 Info,
25 Zap,
26 ChevronDown,
27 Sparkles,
28 } from "lucide-react";
29 import { AiEmailTemplateModal } from "../components/ai/AiEmailTemplateModal";
30 import { isAiEligible, isAiModuleEnabled } from "../lib/ai-availability";
31 import { Switch } from "../components/ui/switch";
32 import {
33 EMAIL_TEMPLATES_CATALOG,
34 getCatalogEntryByTemplateKey,
35 getCoreTemplateDefinition,
36 isCoreTemplateSlug,
37 } from "../lib/email-templates-catalog";
38 import {
39 isEmailAutomationModuleEnabled,
40 isModuleActive,
41 isProPluginActive,
42 } from "../lib/plugin-utils";
43 import {
44 fetchSettings,
45 previewCoreEmailTemplate,
46 saveSettings,
47 } from "../api/settings-api";
48 import { EmailPreviewModal } from "../components/email/EmailPreviewModal";
49 import {
50 createEmailTemplate,
51 fetchEmailTemplate,
52 fetchEmailTemplateEvents,
53 fetchEmailTemplateVariables,
54 previewEmailTemplate,
55 sendEmailTemplateTest,
56 updateEmailTemplate,
57 } from "../api/email-automation-api";
58
59 interface PreviewData {
60 subject: string;
61 body: string;
62 }
63
64 const EmailTemplateForm: React.FC = () => {
65 const { id, isCreateMode, coreTemplateSlug, coreReadOnlyParam } =
66 useMemo(() => {
67 const params = new URLSearchParams(window.location.search);
68 const templateId = params.get("id");
69 const action = params.get("action");
70 const core = params.get("core_template") || "";
71 return {
72 id: templateId,
73 isCreateMode: action === "create",
74 coreTemplateSlug: core,
75 coreReadOnlyParam: params.get("readonly") === "1",
76 };
77 }, []);
78
79 const isCoreSettingsEdit = isCoreTemplateSlug(coreTemplateSlug);
80 const coreDef = isCoreSettingsEdit
81 ? getCoreTemplateDefinition(coreTemplateSlug)
82 : undefined;
83
84 const catalogEntryForCore = useMemo(
85 () =>
86 coreTemplateSlug
87 ? getCatalogEntryByTemplateKey(coreTemplateSlug)
88 : undefined,
89 [coreTemplateSlug],
90 );
91
92 /** URL ?readonly=1 — no edits at all from this screen */
93 const isCoreStrictViewOnly = isCoreSettingsEdit && Boolean(coreReadOnlyParam);
94
95 /** Module required but inactive: subject/body/save locked; on/off can still be changed (site setting). */
96 const isCoreModuleBodyLocked = useMemo(() => {
97 if (!isCoreSettingsEdit || isCoreStrictViewOnly) {
98 return false;
99 }
100 const req = catalogEntryForCore?.requiresModule;
101 if (!req) {
102 return false;
103 }
104 return !isProPluginActive() || !isModuleActive(req);
105 }, [isCoreSettingsEdit, isCoreStrictViewOnly, catalogEntryForCore]);
106
107 /** Subject/body (and merge-tag insert) read-only; strict URL also locks status. */
108 const isCoreBodyReadOnly = isCoreStrictViewOnly || isCoreModuleBodyLocked;
109
110 const isCoreViewMode = isCoreStrictViewOnly || isCoreModuleBodyLocked;
111
112 const goBack = () => {
113 window.location.href = `admin.php?page=yatra&subpage=email-automation&tab=templates`;
114 };
115 const queryClient = useQueryClient();
116 const { showToast } = useToast();
117
118 const [formData, setFormData] = useState({
119 name: "",
120 description: "",
121 from_name: "",
122 from_email: "",
123 to_email: "",
124 reply_to: "",
125 subject: "",
126 body: "",
127 event_key: "",
128 is_active: true,
129 });
130 const [showPreview, setShowPreview] = useState(false);
131 const [previewData, setPreviewData] = useState<PreviewData | null>(null);
132 const [testEmail, setTestEmail] = useState("");
133 const [copiedVar, setCopiedVar] = useState<string | null>(null);
134 const [showEventDropdown, setShowEventDropdown] = useState(false);
135 const [showAiModal, setShowAiModal] = useState(false);
136
137 useEffect(() => {
138 if (isCreateMode && !isEmailAutomationModuleEnabled()) {
139 window.location.href =
140 "admin.php?page=yatra&subpage=email-automation&tab=templates";
141 }
142 }, [isCreateMode]);
143
144 useEffect(() => {
145 const params = new URLSearchParams(window.location.search);
146 if (params.get("action") !== "edit") return;
147 const ct = params.get("core_template");
148 if (ct && !isCoreTemplateSlug(ct)) {
149 window.location.href =
150 "admin.php?page=yatra&subpage=email-automation&tab=templates";
151 }
152 }, []);
153
154 const { data: settings, isLoading: settingsLoading } = useQuery({
155 queryKey: ["settings"],
156 queryFn: () => fetchSettings(),
157 enabled: isCoreSettingsEdit,
158 });
159
160 const { data: templateData, isLoading } = useQuery({
161 queryKey: ["email-template", id],
162 queryFn: () => fetchEmailTemplate(id as string),
163 enabled: Boolean(id) && !isCoreSettingsEdit,
164 });
165
166 // Event-scoped variables: the sidebar re-fetches whenever the
167 // operator switches the trigger event so the "Available
168 // Variables" panel reflects only tags that will actually
169 // resolve at send-time for the chosen event. Empty event_key
170 // returns the full union (for the create-template starter
171 // state before they've picked an event).
172 const { data: variablesData } = useQuery({
173 queryKey: ["email-variables", formData.event_key || ""],
174 queryFn: () => fetchEmailTemplateVariables(formData.event_key || ""),
175 enabled: !isCoreSettingsEdit && isEmailAutomationModuleEnabled(),
176 });
177
178 // Trigger events. Fetch them live rather than trusting only the one-time
179 // window.yatraAdmin.emailEvents localize snapshot: that snapshot is written
180 // by the Pro module at PHP render time, but the module-enabled flag is
181 // refreshed client-side (useModules), so the two can diverge — the form
182 // opens (flag says enabled) while emailEvents is still empty, leaving the
183 // dropdown with "No Event to select". The live fetch corrects that; the
184 // localize snapshot is used as an instant fallback while it loads.
185 const { data: fetchedEvents } = useQuery({
186 queryKey: ["email-template-events"],
187 queryFn: fetchEmailTemplateEvents,
188 enabled: !isCoreSettingsEdit && isEmailAutomationModuleEnabled(),
189 });
190 // Resolve the trigger-event list from the first non-empty source. Both the
191 // live fetch AND the window.yatraAdmin.emailEvents localize are gated on the
192 // Pro Email Automation module actively delivering data (route + localize both
193 // only run when the module is enabled and the client flag agrees), so if the
194 // module gate isn't delivering — disabled, or a client/server flag drift —
195 // both are empty and the dropdown ended up unselectable. Fall back to the
196 // module-INDEPENDENT catalog (same source the templates list uses) so an event
197 // can always be chosen. `??` alone won't do — [] is not nullish.
198 const events = useMemo(() => {
199 if (Array.isArray(fetchedEvents) && fetchedEvents.length > 0) {
200 return fetchedEvents;
201 }
202 const localized = (window as any).yatraAdmin?.emailEvents;
203 if (Array.isArray(localized) && localized.length > 0) {
204 return localized;
205 }
206 // Derive a readable name from the event key (e.g. "booking.created" →
207 // "Booking Created") since the catalog keys events, not names.
208 const prettify = (key: string) =>
209 key.replace(/[._]/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
210 return [
211 ...new Set(
212 EMAIL_TEMPLATES_CATALOG.map((e) => e.event_key).filter(Boolean),
213 ),
214 ].map((key) => ({ key, name: prettify(key) }));
215 }, [fetchedEvents]);
216
217 useEffect(() => {
218 if (!templateData || typeof templateData !== "object") return;
219 const t = templateData as Record<string, unknown>;
220 setFormData({
221 name: String(t.name ?? ""),
222 description: String(t.description ?? ""),
223 from_name: String(t.from_name ?? ""),
224 from_email: String(t.from_email ?? ""),
225 to_email: String(t.to_email ?? ""),
226 reply_to: String(t.reply_to ?? ""),
227 subject: String(t.subject ?? ""),
228 body: String(t.body ?? ""),
229 event_key: String(t.event_key ?? ""),
230 is_active: Boolean(t.is_active ?? true),
231 });
232 }, [templateData]);
233
234 useEffect(() => {
235 if (!isCoreSettingsEdit || !settings || !coreDef) return;
236 const flag = coreDef.settingsFlag;
237 const subj = coreDef.settingsSubject;
238 const bodyKey = coreDef.settingsBody;
239 if (!flag || !subj || !bodyKey) return;
240 const s = settings as Record<string, unknown>;
241 setFormData({
242 name: coreDef.name,
243 description: coreDef.description,
244 from_name: "",
245 from_email: "",
246 to_email: coreDef.to_email || "",
247 reply_to: "",
248 subject: String(s[subj] ?? ""),
249 body: String(s[bodyKey] ?? ""),
250 event_key: coreDef.event_key,
251 is_active: Boolean(s[flag]),
252 });
253 }, [isCoreSettingsEdit, settings, coreDef]);
254
255 const saveMutation = useMutation({
256 mutationFn: async (data: typeof formData) => {
257 if (isCreateMode) {
258 const response = await createEmailTemplate(data);
259 const r = response as {
260 success?: boolean;
261 message?: string;
262 data?: { id?: string | number };
263 };
264 if (r && r.success === false) {
265 throw new Error(r.message || __("Failed to create template"));
266 }
267 return response;
268 }
269 return await updateEmailTemplate(id as string, data);
270 },
271 onSuccess: (response) => {
272 queryClient.invalidateQueries({ queryKey: ["email-templates"] });
273 if (isCreateMode) {
274 showToast(__("Template created successfully"), "success");
275 // Redirect to edit page after creation - response.data contains the template
276 const newId = (response as { data?: { id?: string | number } })?.data
277 ?.id;
278 if (newId) {
279 window.location.href = `admin.php?page=yatra&subpage=email-automation&tab=template&action=edit&id=${newId}`;
280 } else {
281 // Fallback to list page
282 window.location.href =
283 "admin.php?page=yatra&subpage=email-automation&tab=templates";
284 }
285 } else {
286 queryClient.invalidateQueries({ queryKey: ["email-template", id] });
287 showToast(__("Template saved successfully"), "success");
288 }
289 },
290 onError: (error: any) => {
291 const message =
292 error?.message ||
293 (isCreateMode
294 ? __("Failed to create template")
295 : __("Failed to save template"));
296 showToast(message, "error");
297 },
298 });
299
300 const saveCoreMutation = useMutation({
301 mutationFn: async () => {
302 if (isCoreStrictViewOnly) {
303 throw new Error(__("This template is view-only.", "yatra"));
304 }
305 if (
306 !coreDef?.settingsFlag ||
307 !coreDef.settingsSubject ||
308 !coreDef.settingsBody
309 ) {
310 throw new Error(__("Invalid template", "yatra"));
311 }
312 if (!settings) {
313 throw new Error(__("Settings not loaded", "yatra"));
314 }
315 const base = { ...(settings as Record<string, unknown>) };
316 if (isCoreModuleBodyLocked) {
317 base[coreDef.settingsFlag] = formData.is_active;
318 await saveSettings(base);
319 return base;
320 }
321 base[coreDef.settingsSubject] = formData.subject;
322 base[coreDef.settingsBody] = formData.body;
323 base[coreDef.settingsFlag] = formData.is_active;
324 await saveSettings(base);
325 return base;
326 },
327 onSuccess: (saved) => {
328 queryClient.setQueryData(["settings"], saved);
329 showToast(__("Template saved", "yatra"), "success");
330 },
331 onError: (error: unknown) => {
332 const message =
333 error instanceof Error ? error.message : __("Failed to save", "yatra");
334 showToast(message, "error");
335 },
336 });
337
338 const previewMutation = useMutation({
339 mutationFn: async () => {
340 return previewEmailTemplate(id as string);
341 },
342 onSuccess: (data) => {
343 setPreviewData(data);
344 setShowPreview(true);
345 },
346 onError: () => {
347 showToast(__("Failed to load preview"), "error");
348 },
349 });
350
351 const corePreviewMutation = useMutation({
352 mutationFn: async () => {
353 if (!coreTemplateSlug) {
354 throw new Error(__("Invalid template", "yatra"));
355 }
356 return previewCoreEmailTemplate({
357 template_key: coreTemplateSlug,
358 subject: formData.subject,
359 body: formData.body,
360 });
361 },
362 onSuccess: (data) => {
363 setPreviewData(data);
364 setShowPreview(true);
365 },
366 onError: (error: unknown) => {
367 const message =
368 error instanceof Error ? error.message : __("Preview failed", "yatra");
369 showToast(message, "error");
370 },
371 });
372
373 const testMutation = useMutation({
374 mutationFn: async (email: string) => {
375 return sendEmailTemplateTest(id as string, email);
376 },
377 onSuccess: () => {
378 showToast(__("Test email sent successfully"), "success");
379 setTestEmail("");
380 },
381 onError: () => {
382 showToast(__("Failed to send test email"), "error");
383 },
384 });
385
386 const handleSubmit = (e?: React.FormEvent | React.MouseEvent) => {
387 if (e) e.preventDefault();
388
389 if (isCoreSettingsEdit) {
390 if (isCoreStrictViewOnly) {
391 return;
392 }
393 saveCoreMutation.mutate();
394 return;
395 }
396
397 if (!formData.name.trim()) {
398 showToast(__("Template name is required"), "error");
399 return;
400 }
401 if (!formData.subject.trim()) {
402 showToast(__("Subject line is required"), "error");
403 return;
404 }
405
406 saveMutation.mutate(formData);
407 };
408
409 const copyVariable = (variable: string) => {
410 const text = `{{${variable}}}`;
411
412 const markCopied = () => {
413 setCopiedVar(variable);
414 setTimeout(() => setCopiedVar(null), 2000);
415 };
416
417 // Legacy fallback: navigator.clipboard is only defined in a secure context
418 // (HTTPS or localhost). On a plain-HTTP wp-admin it is undefined, so the
419 // previous call threw and nothing was copied. execCommand("copy") works
420 // there via a temporary off-screen textarea.
421 const legacyCopy = (): boolean => {
422 try {
423 const el = document.createElement("textarea");
424 el.value = text;
425 el.setAttribute("readonly", "");
426 el.style.position = "fixed";
427 el.style.top = "-9999px";
428 document.body.appendChild(el);
429 el.select();
430 const ok = document.execCommand("copy");
431 document.body.removeChild(el);
432 return ok;
433 } catch {
434 return false;
435 }
436 };
437
438 if (navigator?.clipboard?.writeText) {
439 navigator.clipboard.writeText(text).then(markCopied, () => {
440 if (legacyCopy()) {
441 markCopied();
442 }
443 });
444 } else if (legacyCopy()) {
445 markCopied();
446 }
447 };
448
449 const insertVariable = (variable: string) => {
450 if (isCoreBodyReadOnly) {
451 return;
452 }
453 const textarea = document.getElementById(
454 "email-body",
455 ) as HTMLTextAreaElement;
456 if (textarea) {
457 const start = textarea.selectionStart;
458 const end = textarea.selectionEnd;
459 const text = formData.body;
460 const newText =
461 text.substring(0, start) + `{{${variable}}}` + text.substring(end);
462 setFormData({ ...formData, body: newText });
463 }
464 };
465
466 const showEditSkeleton =
467 !isCreateMode && (isCoreSettingsEdit ? settingsLoading : isLoading);
468
469 if (showEditSkeleton) {
470 return (
471 <div className="space-y-6">
472 {/* Header Skeleton */}
473 <div className="flex items-center justify-between animate-pulse">
474 <div className="flex items-center gap-4">
475 <div className="h-10 w-20 bg-gray-200 dark:bg-gray-700 rounded"></div>
476 <div>
477 <div className="h-8 w-56 bg-gray-200 dark:bg-gray-700 rounded mb-2"></div>
478 <div className="h-4 w-40 bg-gray-200 dark:bg-gray-700 rounded"></div>
479 </div>
480 </div>
481 <div className="flex items-center gap-3">
482 <div className="h-10 w-24 bg-gray-200 dark:bg-gray-700 rounded"></div>
483 <div className="h-10 w-32 bg-gray-200 dark:bg-gray-700 rounded"></div>
484 </div>
485 </div>
486
487 <div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
488 {/* Main Form Skeleton */}
489 <div className="lg:col-span-2 space-y-6">
490 {/* Template Details Card */}
491 <Card>
492 <CardHeader>
493 <div className="flex items-center justify-between">
494 <div className="h-6 w-36 bg-gray-200 dark:bg-gray-700 rounded"></div>
495 <div className="h-6 w-20 bg-gray-200 dark:bg-gray-700 rounded"></div>
496 </div>
497 </CardHeader>
498 <CardContent className="space-y-4">
499 <div className="space-y-2">
500 <div className="h-4 w-28 bg-gray-200 dark:bg-gray-700 rounded"></div>
501 <div className="h-10 w-full bg-gray-200 dark:bg-gray-700 rounded"></div>
502 </div>
503 <div className="space-y-2">
504 <div className="h-4 w-24 bg-gray-200 dark:bg-gray-700 rounded"></div>
505 <div className="h-10 w-full bg-gray-200 dark:bg-gray-700 rounded"></div>
506 </div>
507 <div className="space-y-2">
508 <div className="h-4 w-20 bg-gray-200 dark:bg-gray-700 rounded"></div>
509 <div className="h-10 w-full bg-gray-200 dark:bg-gray-700 rounded"></div>
510 </div>
511 <div className="space-y-2">
512 <div className="h-4 w-32 bg-gray-200 dark:bg-gray-700 rounded"></div>
513 <div className="h-64 w-full bg-gray-200 dark:bg-gray-700 rounded"></div>
514 </div>
515 </CardContent>
516 </Card>
517
518 {/* Test Email Card */}
519 <Card>
520 <CardHeader>
521 <div className="h-6 w-32 bg-gray-200 dark:bg-gray-700 rounded"></div>
522 </CardHeader>
523 <CardContent>
524 <div className="flex gap-3">
525 <div className="h-10 flex-1 bg-gray-200 dark:bg-gray-700 rounded"></div>
526 <div className="h-10 w-10 bg-gray-200 dark:bg-gray-700 rounded"></div>
527 </div>
528 </CardContent>
529 </Card>
530 </div>
531
532 {/* Sidebar Skeleton */}
533 <div className="space-y-6">
534 {/* Variables Card */}
535 <Card>
536 <CardHeader>
537 <div className="h-6 w-36 bg-gray-200 dark:bg-gray-700 rounded"></div>
538 </CardHeader>
539 <CardContent className="space-y-4">
540 <div className="h-4 w-full bg-gray-200 dark:bg-gray-700 rounded"></div>
541 {[1, 2, 3].map((group) => (
542 <div key={group} className="space-y-2">
543 <div className="h-3 w-20 bg-gray-200 dark:bg-gray-700 rounded"></div>
544 {[1, 2, 3].map((item) => (
545 <div
546 key={item}
547 className="h-12 w-full bg-gray-200 dark:bg-gray-700 rounded"
548 ></div>
549 ))}
550 </div>
551 ))}
552 </CardContent>
553 </Card>
554
555 {/* Template Info Card */}
556 <Card>
557 <CardHeader>
558 <div className="h-6 w-28 bg-gray-200 dark:bg-gray-700 rounded"></div>
559 </CardHeader>
560 <CardContent className="space-y-3">
561 {[1, 2, 3].map((i) => (
562 <div key={i} className="flex justify-between">
563 <div className="h-4 w-16 bg-gray-200 dark:bg-gray-700 rounded"></div>
564 <div className="h-4 w-24 bg-gray-200 dark:bg-gray-700 rounded"></div>
565 </div>
566 ))}
567 </CardContent>
568 </Card>
569 </div>
570 </div>
571 </div>
572 );
573 }
574
575 return (
576 <div className="space-y-6">
577 {/* Header */}
578 <div className="flex items-center justify-between">
579 <div className="flex items-center gap-4">
580 <Button variant="ghost" onClick={() => goBack()}>
581 <ArrowLeft className="w-4 h-4 mr-2" />
582 {__("Back")}
583 </Button>
584 <div>
585 <h1 className="text-2xl font-bold text-gray-900 dark:text-white flex items-center gap-2">
586 <Mail className="w-6 h-6 text-blue-500" />
587 {isCreateMode
588 ? __("Create Email Template")
589 : isCoreViewMode
590 ? __("View email template", "yatra")
591 : __("Edit Email Template")}
592 </h1>
593 <p className="text-gray-500 dark:text-gray-400 mt-1">
594 {isCreateMode
595 ? __("Create a new custom email template")
596 : isCoreSettingsEdit
597 ? isCoreViewMode
598 ? __(
599 "This template uses site settings. Enable Yatra Pro and the required module to edit subject and HTML body.",
600 "yatra",
601 )
602 : __(
603 "Core customer email (saved with site settings). From address is set under Email → Delivery.",
604 "yatra",
605 )
606 : String(templateData?.name ?? "")}
607 </p>
608 </div>
609 </div>
610 <div className="flex items-center gap-3">
611 {!isCreateMode && (
612 <Button
613 variant="outline"
614 onClick={() =>
615 isCoreSettingsEdit
616 ? corePreviewMutation.mutate()
617 : previewMutation.mutate()
618 }
619 disabled={
620 previewMutation.isPending || corePreviewMutation.isPending
621 }
622 >
623 {previewMutation.isPending || corePreviewMutation.isPending ? (
624 <Loader2 className="w-4 h-4 mr-2 animate-spin" />
625 ) : (
626 <Eye className="w-4 h-4 mr-2" />
627 )}
628 {__("Preview")}
629 </Button>
630 )}
631 <Button
632 onClick={handleSubmit}
633 disabled={
634 saveMutation.isPending ||
635 saveCoreMutation.isPending ||
636 isCoreStrictViewOnly
637 }
638 className="bg-blue-600 hover:bg-blue-700 text-white"
639 >
640 {saveMutation.isPending || saveCoreMutation.isPending ? (
641 <Loader2 className="w-4 h-4 mr-2 animate-spin" />
642 ) : (
643 <Save className="w-4 h-4 mr-2" />
644 )}
645 {isCreateMode ? __("Create Template") : __("Save Template")}
646 </Button>
647 </div>
648 </div>
649
650 {isCoreBodyReadOnly && (
651 <div className="flex items-start gap-2 rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-900 dark:border-amber-800 dark:bg-amber-950/40 dark:text-amber-100">
652 <Info className="w-5 h-5 flex-shrink-0 mt-0.5" />
653 <p>
654 {isCoreStrictViewOnly
655 ? __(
656 "This link is view-only. Subject, body, and status cannot be changed from this screen.",
657 "yatra",
658 )
659 : __(
660 "Subject and HTML body are read-only until the required module is active. You can still turn this email on or off below; values reflect your site settings.",
661 "yatra",
662 )}
663 </p>
664 </div>
665 )}
666
667 <div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
668 {/* Main Form */}
669 <div className="lg:col-span-2 space-y-6">
670 <Card>
671 <CardHeader>
672 <CardTitle className="flex items-center justify-between">
673 <span>{__("Template Details")}</span>
674 <div className="flex items-center gap-2">
675 <span
676 className={`text-sm ${formData.is_active ? "text-green-600 dark:text-green-400" : "text-gray-500"}`}
677 >
678 {formData.is_active ? __("Active") : __("Inactive")}
679 </span>
680 <Switch
681 checked={formData.is_active}
682 disabled={isCoreStrictViewOnly}
683 onCheckedChange={(checked) =>
684 setFormData({ ...formData, is_active: checked })
685 }
686 />
687 </div>
688 </CardTitle>
689 </CardHeader>
690 <CardContent className="space-y-4">
691 <div>
692 <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
693 {__("Template Name")}
694 </label>
695 <Input
696 value={formData.name}
697 onChange={(e) =>
698 setFormData({ ...formData, name: e.target.value })
699 }
700 placeholder={__("Enter template name")}
701 readOnly={isCoreSettingsEdit}
702 className={
703 isCoreSettingsEdit ? "bg-gray-50 dark:bg-gray-900/40" : ""
704 }
705 />
706 </div>
707
708 <div>
709 <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
710 {__("Description")}
711 </label>
712 <Input
713 value={formData.description}
714 onChange={(e) =>
715 setFormData({ ...formData, description: e.target.value })
716 }
717 placeholder={__(
718 "Brief description of when this email is sent",
719 )}
720 readOnly={isCoreSettingsEdit}
721 className={
722 isCoreSettingsEdit ? "bg-gray-50 dark:bg-gray-900/40" : ""
723 }
724 />
725 </div>
726
727 {/* Event Selector */}
728 <div>
729 <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
730 {__("Trigger Event")}
731 </label>
732 {Boolean(templateData?.is_system) || isCoreSettingsEdit ? (
733 <div className="space-y-2">
734 <div className="flex items-center gap-2">
735 <span className="inline-flex items-center gap-1.5 px-3 py-2 rounded-lg text-sm font-medium bg-indigo-100 text-indigo-800 dark:bg-indigo-900/30 dark:text-indigo-400 border border-indigo-200 dark:border-indigo-800">
736 <Zap className="w-4 h-4" />
737 {formData.event_key || "-"}
738 </span>
739 <span className="text-xs text-gray-500 dark:text-gray-400">
740 {__("System templates cannot change events")}
741 </span>
742 </div>
743 {formData.event_key && (
744 <div className="p-3 bg-indigo-50 dark:bg-indigo-900/20 rounded-lg border border-indigo-200 dark:border-indigo-800">
745 <p className="text-sm text-indigo-800 dark:text-indigo-300">
746 {events.find((e: any) => e.key === formData.event_key)
747 ?.description || ""}
748 </p>
749 </div>
750 )}
751 </div>
752 ) : (
753 <div className="relative">
754 {/* Dropdown Trigger Button */}
755 <button
756 type="button"
757 onClick={() => setShowEventDropdown(!showEventDropdown)}
758 className="w-full px-3 py-2.5 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-left flex items-center justify-between hover:border-gray-400 dark:hover:border-gray-500 transition-colors"
759 >
760 {formData.event_key ? (
761 <div className="flex items-center gap-2">
762 <Zap className="w-4 h-4 text-blue-500" />
763 <span className="text-sm font-medium text-gray-900 dark:text-white">
764 {events.find(
765 (e: any) => e.key === formData.event_key,
766 )?.name || formData.event_key}
767 </span>
768 </div>
769 ) : (
770 <div className="flex items-center gap-2">
771 <Mail className="w-4 h-4 text-gray-400" />
772 <span className="text-sm text-gray-500 dark:text-gray-400">
773 {__("No Event (Sequence Only)")}
774 </span>
775 </div>
776 )}
777 <ChevronDown
778 className={`w-4 h-4 text-gray-400 transition-transform ${showEventDropdown ? "rotate-180" : ""}`}
779 />
780 </button>
781
782 {/* Dropdown List */}
783 {showEventDropdown && (
784 <div className="absolute z-50 w-full mt-1 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg max-h-80 overflow-y-auto">
785 <div className="p-2 border-b border-gray-200 dark:border-gray-700">
786 <p className="text-xs text-gray-500 dark:text-gray-400 px-2">
787 {__(
788 "Select an event to trigger this email, or leave empty for sequence use",
789 )}
790 </p>
791 </div>
792 <div className="py-1">
793 {/* None option - for templates used in sequences */}
794 <button
795 type="button"
796 onClick={() => {
797 setFormData({ ...formData, event_key: "" });
798 setShowEventDropdown(false);
799 }}
800 className={`w-full px-3 py-3 text-left hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors border-b border-gray-100 dark:border-gray-700 ${
801 !formData.event_key
802 ? "bg-indigo-50 dark:bg-indigo-900/20"
803 : ""
804 }`}
805 >
806 <div className="flex items-start gap-2">
807 <Mail
808 className={`w-4 h-4 mt-0.5 flex-shrink-0 ${
809 !formData.event_key
810 ? "text-blue-500"
811 : "text-gray-400"
812 }`}
813 />
814 <div className="flex-1 min-w-0">
815 <div className="flex items-center gap-2">
816 <span
817 className={`text-sm font-medium ${
818 !formData.event_key
819 ? "text-blue-800 dark:text-blue-400"
820 : "text-gray-900 dark:text-white"
821 }`}
822 >
823 {__("No Event (Sequence Only)")}
824 </span>
825 </div>
826 <p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
827 {__(
828 "This template will only be used in email sequences",
829 )}
830 </p>
831 </div>
832 {!formData.event_key && (
833 <Check className="w-4 h-4 text-blue-500 flex-shrink-0" />
834 )}
835 </div>
836 </button>
837 {events.map((event: any) => (
838 <button
839 key={event.key}
840 type="button"
841 onClick={() => {
842 setFormData({
843 ...formData,
844 event_key: event.key,
845 });
846 setShowEventDropdown(false);
847 }}
848 className={`w-full px-3 py-3 text-left hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors border-b border-gray-100 dark:border-gray-700 last:border-b-0 ${
849 formData.event_key === event.key
850 ? "bg-indigo-50 dark:bg-indigo-900/20"
851 : ""
852 }`}
853 >
854 <div className="flex items-start gap-2">
855 <Zap
856 className={`w-4 h-4 mt-0.5 flex-shrink-0 ${
857 formData.event_key === event.key
858 ? "text-blue-500"
859 : "text-gray-400"
860 }`}
861 />
862 <div className="flex-1 min-w-0">
863 <div className="flex items-center gap-2">
864 <span
865 className={`text-sm font-medium ${
866 formData.event_key === event.key
867 ? "text-blue-800 dark:text-blue-400"
868 : "text-gray-900 dark:text-white"
869 }`}
870 >
871 {event.name}
872 </span>
873 <code className="text-xs px-1.5 py-0.5 bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 rounded">
874 {event.key}
875 </code>
876 </div>
877 <p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5 line-clamp-2">
878 {event.description}
879 </p>
880 </div>
881 {formData.event_key === event.key && (
882 <Check className="w-4 h-4 text-blue-500 flex-shrink-0" />
883 )}
884 </div>
885 </button>
886 ))}
887 {events.length === 0 && (
888 <div className="px-3 py-3 text-xs text-amber-600 dark:text-amber-400">
889 {__(
890 "No trigger events are available. Enable the Email Automation module under Yatra → Modules, then reload this page.",
891 )}
892 </div>
893 )}
894 </div>
895 </div>
896 )}
897
898 {/* Click outside to close */}
899 {showEventDropdown && (
900 <div
901 className="fixed inset-0 z-40"
902 onClick={() => setShowEventDropdown(false)}
903 />
904 )}
905 </div>
906 )}
907 <p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
908 {__(
909 "This email will be triggered when the selected event occurs",
910 )}
911 </p>
912 </div>
913
914 {!isCoreSettingsEdit && (
915 <>
916 {/* From/Reply-To Settings */}
917 <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
918 <div>
919 <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
920 {__("From Name")}
921 </label>
922 <Input
923 value={formData.from_name}
924 onChange={(e) =>
925 setFormData({
926 ...formData,
927 from_name: e.target.value,
928 })
929 }
930 placeholder={__("e.g., Travel Agency Name")}
931 />
932 <p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
933 {__("Leave empty to use site default")}
934 </p>
935 </div>
936 <div>
937 <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
938 {__("From Email")}
939 </label>
940 <Input
941 type="email"
942 value={formData.from_email}
943 onChange={(e) =>
944 setFormData({
945 ...formData,
946 from_email: e.target.value,
947 })
948 }
949 placeholder={__("e.g., [email protected]")}
950 />
951 <p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
952 {__("Leave empty to use site default")}
953 </p>
954 </div>
955 </div>
956
957 <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
958 <div>
959 <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
960 {__("To Email")}
961 </label>
962 <Input
963 type="email"
964 value={formData.to_email}
965 onChange={(e) =>
966 setFormData({ ...formData, to_email: e.target.value })
967 }
968 placeholder={__("e.g., {{customer_email}}")}
969 />
970 <p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
971 {__(
972 "Recipient email. Use variables or leave empty for default",
973 )}
974 </p>
975 </div>
976 <div>
977 <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
978 {__("Reply-To Email")}
979 </label>
980 <Input
981 type="email"
982 value={formData.reply_to}
983 onChange={(e) =>
984 setFormData({ ...formData, reply_to: e.target.value })
985 }
986 placeholder={__("e.g., [email protected]")}
987 />
988 <p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
989 {__(
990 "Where replies will be sent. Leave empty to use From Email",
991 )}
992 </p>
993 </div>
994 </div>
995 </>
996 )}
997
998 {isCoreSettingsEdit && (
999 <p className="text-sm text-gray-600 dark:text-gray-400 rounded-lg border border-gray-200 dark:border-gray-600 p-3 bg-gray-50 dark:bg-gray-800/50">
1000 {__(
1001 "From / reply addresses are configured under Email → Delivery. Recipient uses the customer on the booking.",
1002 "yatra",
1003 )}
1004 </p>
1005 )}
1006
1007 <div>
1008 <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
1009 {__("Subject Line")}
1010 </label>
1011 <Input
1012 value={formData.subject}
1013 onChange={(e) =>
1014 setFormData({ ...formData, subject: e.target.value })
1015 }
1016 placeholder={__("Email subject with {{variables}}")}
1017 readOnly={isCoreBodyReadOnly}
1018 className={`font-mono text-sm ${isCoreBodyReadOnly ? "bg-gray-50 dark:bg-gray-900/40" : ""}`}
1019 />
1020 <p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
1021 {__("Use {{variable}} syntax to insert dynamic content")}
1022 </p>
1023 </div>
1024
1025 <div>
1026 {/* Label + AI sparkle on the same row — sparkle sits
1027 to the right of the "Email Body (HTML)" label so
1028 it's visually associated with the editor it
1029 affects, not floating above Subject Line. */}
1030 <div className="mb-1 flex items-center justify-between">
1031 <label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
1032 {__("Email Body (HTML)")}
1033 </label>
1034 {!isCoreBodyReadOnly &&
1035 isAiEligible() &&
1036 isAiModuleEnabled() && (
1037 <Button
1038 type="button"
1039 variant="outline"
1040 size="sm"
1041 onClick={() => setShowAiModal(true)}
1042 className="border-blue-300 text-blue-700 hover:bg-blue-50 dark:border-blue-500/40 dark:text-blue-200 dark:hover:bg-blue-900/30"
1043 >
1044 <Sparkles className="mr-1.5 h-3.5 w-3.5" />
1045 {__("Generate with AI", "yatra")}
1046 </Button>
1047 )}
1048 </div>
1049 <textarea
1050 id="email-body"
1051 value={formData.body}
1052 onChange={(e) =>
1053 setFormData({ ...formData, body: e.target.value })
1054 }
1055 rows={20}
1056 readOnly={isCoreBodyReadOnly}
1057 className={`w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg font-mono text-sm ${
1058 isCoreBodyReadOnly
1059 ? "bg-gray-50 dark:bg-gray-900/40 text-gray-900 dark:text-white"
1060 : "bg-white dark:bg-gray-800 text-gray-900 dark:text-white"
1061 }`}
1062 placeholder={__("HTML email content...")}
1063 />
1064 </div>
1065 </CardContent>
1066 </Card>
1067
1068 {/* Test Email - Only show in edit mode */}
1069 {!isCreateMode && !isCoreSettingsEdit && (
1070 <Card>
1071 <CardHeader>
1072 <CardTitle className="flex items-center gap-2">
1073 <Send className="w-5 h-5 text-blue-500" />
1074 {__("Send Test Email")}
1075 </CardTitle>
1076 </CardHeader>
1077 <CardContent>
1078 <div className="flex gap-3">
1079 <Input
1080 type="email"
1081 value={testEmail}
1082 onChange={(e) => setTestEmail(e.target.value)}
1083 placeholder={__("Enter email address")}
1084 className="flex-1"
1085 />
1086 <Button
1087 onClick={() => testMutation.mutate(testEmail)}
1088 disabled={!testEmail || testMutation.isPending}
1089 >
1090 {testMutation.isPending ? (
1091 <Loader2 className="w-4 h-4 animate-spin" />
1092 ) : (
1093 <Send className="w-4 h-4" />
1094 )}
1095 </Button>
1096 </div>
1097 <p className="text-xs text-gray-500 dark:text-gray-400 mt-2">
1098 {__(
1099 "Send a test email with sample data to preview how it looks",
1100 )}
1101 </p>
1102 </CardContent>
1103 </Card>
1104 )}
1105 </div>
1106
1107 {/* Sidebar - Variables */}
1108 <div className="space-y-6">
1109 <Card>
1110 <CardHeader>
1111 <CardTitle className="flex items-center gap-2">
1112 <Code className="w-5 h-5 text-blue-500" />
1113 {__("Available Variables")}
1114 </CardTitle>
1115 </CardHeader>
1116 <CardContent className="space-y-4">
1117 {!isCoreSettingsEdit && (
1118 <>
1119 <p className="text-sm text-gray-500 dark:text-gray-400">
1120 {__("Click to copy, double-click to insert at cursor")}
1121 </p>
1122 {/*
1123 Subtitle showing which event the variables list is
1124 scoped to. Without this hint, operators see a
1125 different set of tags every time they switch the
1126 event dropdown and have no clear explanation why.
1127 */}
1128 {formData.event_key ? (
1129 // Show the technical event KEY (dot-notation, monospace)
1130 // as the primary label so it can't be confused with the
1131 // template's display title — earlier versions showed
1132 // the event's friendly name, which often matches what
1133 // operators name their templates ("Customer email
1134 // verification" appears as both the event name and a
1135 // common template title) and made it unclear what the
1136 // chip was scoped to. Friendly name still appears as
1137 // the parenthesised clarifier.
1138 <p className="text-xs text-blue-700 dark:text-blue-300 bg-blue-50 dark:bg-blue-950/30 rounded px-2 py-1.5">
1139 {__("Variables available for trigger event:", "yatra")}{" "}
1140 <code className="font-mono text-blue-800 dark:text-blue-200 bg-blue-100 dark:bg-blue-900/40 px-1 rounded">
1141 {formData.event_key}
1142 </code>
1143 {(() => {
1144 const match = events.find(
1145 (e: any) => e.key === formData.event_key,
1146 );
1147 return match?.name ? (
1148 <span className="text-blue-600/80 dark:text-blue-400/80">
1149 {" "}
1150 ({match.name})
1151 </span>
1152 ) : null;
1153 })()}
1154 </p>
1155 ) : (
1156 <p className="text-xs text-amber-700 dark:text-amber-400 bg-amber-50 dark:bg-amber-950/30 rounded px-2 py-1.5">
1157 {__(
1158 "Pick a trigger event below to see the variables that apply to that event. Showing the full catalog until then.",
1159 "yatra",
1160 )}
1161 </p>
1162 )}
1163 </>
1164 )}
1165
1166 {isCoreSettingsEdit && coreDef?.mergeTags ? (
1167 <div className="space-y-2">
1168 <p className="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase">
1169 {__("Merge tags", "yatra")}
1170 </p>
1171 <p className="text-xs text-gray-500 dark:text-gray-400">
1172 {__(
1173 "Click a tag to copy; paste into subject or HTML body.",
1174 "yatra",
1175 )}
1176 </p>
1177 <div className="flex flex-wrap gap-1">
1178 {coreDef.mergeTags.split(",").map((raw) => {
1179 const tag = raw.trim();
1180 const inner = tag.replace(/\{\{|\}\}/g, "").trim();
1181 if (!inner) return null;
1182 return (
1183 <button
1184 key={tag}
1185 type="button"
1186 onClick={() => copyVariable(inner)}
1187 className="rounded border border-gray-200 dark:border-gray-600 px-2 py-1 text-xs font-mono text-blue-600 dark:text-blue-400 hover:bg-gray-50 dark:hover:bg-gray-800"
1188 >
1189 {tag}
1190 </button>
1191 );
1192 })}
1193 </div>
1194 </div>
1195 ) : (
1196 variablesData &&
1197 Object.entries(variablesData).map(
1198 ([category, variables]: [string, any]) => (
1199 <div key={category}>
1200 <h4 className="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase mb-2">
1201 {category}
1202 </h4>
1203 <div className="space-y-1">
1204 {variables.map((v: any) => (
1205 <button
1206 key={v.key}
1207 type="button"
1208 onClick={() => copyVariable(v.key)}
1209 onDoubleClick={() => insertVariable(v.key)}
1210 className="w-full text-left px-2 py-1.5 rounded hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors group"
1211 >
1212 <div className="flex items-center justify-between">
1213 <code className="text-xs text-blue-600 dark:text-blue-400">
1214 {`{{${v.key}}}`}
1215 </code>
1216 {copiedVar === v.key ? (
1217 <Check className="w-3 h-3 text-green-500" />
1218 ) : (
1219 <Copy className="w-3 h-3 text-gray-400 opacity-0 group-hover:opacity-100" />
1220 )}
1221 </div>
1222 <p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
1223 {v.description}
1224 </p>
1225 </button>
1226 ))}
1227 </div>
1228 </div>
1229 ),
1230 )
1231 )}
1232 </CardContent>
1233 </Card>
1234
1235 {/* Template Info - Different content for create vs edit */}
1236 <Card>
1237 <CardHeader>
1238 <CardTitle className="flex items-center gap-2">
1239 <Info className="w-5 h-5 text-gray-500" />
1240 {isCreateMode ? __("New Template") : __("Template Info")}
1241 </CardTitle>
1242 </CardHeader>
1243 <CardContent className="space-y-3 text-sm">
1244 <div className="space-y-2">
1245 <div className="flex justify-between items-center">
1246 <span className="text-gray-500 dark:text-gray-400">
1247 {__("Event")}
1248 </span>
1249 <span className="inline-flex items-center gap-1.5 px-2 py-1 rounded-full text-xs font-medium bg-indigo-100 text-indigo-800 dark:bg-indigo-900/30 dark:text-indigo-400">
1250 <Zap className="w-3 h-3" />
1251 {formData.event_key || "-"}
1252 </span>
1253 </div>
1254 {formData.event_key && (
1255 <p className="text-xs text-gray-500 dark:text-gray-400 italic">
1256 {events.find((e: any) => e.key === formData.event_key)
1257 ?.description || ""}
1258 </p>
1259 )}
1260 </div>
1261 {isCreateMode ? (
1262 <>
1263 <div className="flex justify-between">
1264 <span className="text-gray-500 dark:text-gray-400">
1265 {__("Type")}
1266 </span>
1267 <span className="text-gray-900 dark:text-white">
1268 {__("Custom")}
1269 </span>
1270 </div>
1271 <div className="p-3 bg-blue-50 dark:bg-blue-900/20 rounded-lg border border-blue-200 dark:border-blue-800">
1272 <p className="text-xs text-blue-700 dark:text-blue-300">
1273 {__(
1274 "Custom templates can be edited and deleted. Select an event to trigger this email automatically.",
1275 )}
1276 </p>
1277 </div>
1278 </>
1279 ) : (
1280 <>
1281 <div className="flex justify-between">
1282 <span className="text-gray-500 dark:text-gray-400">
1283 {__("Key")}
1284 </span>
1285 <code className="text-xs bg-gray-100 dark:bg-gray-800 px-2 py-0.5 rounded">
1286 {isCoreSettingsEdit
1287 ? coreDef?.template_key
1288 : String(templateData?.template_key ?? "")}
1289 </code>
1290 </div>
1291 <div className="flex justify-between">
1292 <span className="text-gray-500 dark:text-gray-400">
1293 {__("Category")}
1294 </span>
1295 <Badge className="bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400">
1296 {isCoreSettingsEdit
1297 ? coreDef?.category
1298 : String(templateData?.category ?? "")}
1299 </Badge>
1300 </div>
1301 <div className="flex justify-between">
1302 <span className="text-gray-500 dark:text-gray-400">
1303 {__("Recipient")}
1304 </span>
1305 <span
1306 className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${
1307 (isCoreSettingsEdit
1308 ? coreDef?.recipient_type
1309 : String(templateData?.recipient_type ?? "")) ===
1310 "admin"
1311 ? "bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400"
1312 : "bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400"
1313 }`}
1314 >
1315 {(isCoreSettingsEdit
1316 ? coreDef?.recipient_type
1317 : String(templateData?.recipient_type ?? "")) ===
1318 "admin"
1319 ? __("Admin")
1320 : __("Customer")}
1321 </span>
1322 </div>
1323 <div className="flex justify-between">
1324 <span className="text-gray-500 dark:text-gray-400">
1325 {__("Type")}
1326 </span>
1327 <span className="text-gray-900 dark:text-white">
1328 {isCoreSettingsEdit || Boolean(templateData?.is_system)
1329 ? __("System")
1330 : __("Custom")}
1331 </span>
1332 </div>
1333 </>
1334 )}
1335 </CardContent>
1336 </Card>
1337 </div>
1338 </div>
1339
1340 <EmailPreviewModal
1341 open={showPreview && Boolean(previewData)}
1342 onClose={() => {
1343 setShowPreview(false);
1344 setPreviewData(null);
1345 }}
1346 subject={previewData?.subject ?? ""}
1347 body={previewData?.body ?? ""}
1348 />
1349
1350 {/* AI: subject + body generator. The merge-tag list comes from
1351 the catalog entry when it's a core template, falling back
1352 to a sensible default set when editing a custom template. */}
1353 <AiEmailTemplateModal
1354 open={showAiModal}
1355 onClose={() => setShowAiModal(false)}
1356 templateKey={
1357 coreTemplateSlug || formData.event_key || "custom_template"
1358 }
1359 templateName={coreDef?.name || formData.name || formData.event_key}
1360 templateDescription={coreDef?.description || formData.description}
1361 recipientType={
1362 (catalogEntryForCore?.recipient_type as "customer" | "admin") ||
1363 "customer"
1364 }
1365 mergeTags={(() => {
1366 // Catalog stores mergeTags as a comma-string like
1367 // "{{customer_name}}, {{booking_reference}}, …". Coerce to
1368 // the bare tag names ('customer_name') that the agent's
1369 // server-side normalizer accepts.
1370 const raw = catalogEntryForCore?.mergeTags ?? "";
1371 if (!raw) {
1372 return [
1373 "site_name",
1374 "site_url",
1375 "customer_name",
1376 "customer_email",
1377 "booking_reference",
1378 "trip_name",
1379 "travel_date",
1380 "total_amount",
1381 "currency",
1382 ];
1383 }
1384 return raw
1385 .split(",")
1386 .map((s) =>
1387 s
1388 .trim()
1389 .replace(/^\{\{|\}\}$/g, "")
1390 .trim(),
1391 )
1392 .filter(Boolean);
1393 })()}
1394 currentSubject={formData.subject}
1395 currentBody={formData.body}
1396 onAccept={({ subject, body }) =>
1397 setFormData({ ...formData, subject, body })
1398 }
1399 />
1400 </div>
1401 );
1402 };
1403
1404 export default EmailTemplateForm;
1405