PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.13
Yatra – Travel Booking & Tour Operator Software v3.0.13
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.13, at resources/js/pages/EmailTemplateForm.tsx

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