PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.14.2
Yatra – Travel Booking & Tour Operator Software v3.0.14.2
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 / pages / EmailTemplateForm.tsx

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

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