PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.17
Yatra – Travel Booking & Tour Operator Software v3.0.17
3.0.17 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 All 85 releases
← All changes | resources/js/pages/EmailTemplateForm.tsx +828 -51 3.0.2.9 → 3.0.17 View file →
@@ -1,7 +1,22 @@
1 1 import React, { useState, useEffect, useMemo } from "react";
2 2 import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
3 -import { __ } from "../lib/i18n";
3 +import { __, sprintf } from "../lib/i18n";
4 +import { MultiSelect } from "../components/ui/multi-select";
5 +import { SearchableSelect } from "../components/ui/searchable-select";
6 +import {
7 + decodeTargets,
8 + describeTargets,
9 + encodeTargets,
10 + hasTargets,
11 + useTripTargets,
12 + type TripTargets,
13 +} from "../hooks/useTripTargets";
14 +import { EmailOverrideCreateModal } from "../components/email/EmailOverrideCreateModal";
15 +import {
16 + fetchEmailTemplates,
17 + resolveEmailTemplatesForTrip,
18 +} from "../api/email-automation-api";
4 19 import { useToast } from "../components/ui/toast";
5 20 import {
6 21 Card,
7 22 CardContent,
@@ -23,16 +38,25 @@
23 38 Check,
24 39 Info,
25 40 Zap,
26 41 ChevronDown,
42 + Sparkles,
27 43 } from "lucide-react";
44 +import { AiEmailTemplateModal } from "../components/ai/AiEmailTemplateModal";
45 +import { isAiEligible, isAiModuleEnabled } from "../lib/ai-availability";
28 46 import { Switch } from "../components/ui/switch";
29 47 import {
48 + EMAIL_TEMPLATES_CATALOG,
49 + getCatalogEntryByTemplateKey,
30 50 getCoreTemplateDefinition,
31 51 isCoreTemplateSlug,
32 52 } from "../lib/email-templates-catalog";
33 -import { isEmailAutomationModuleEnabled } from "../lib/plugin-utils";
34 53 import {
54 + isEmailAutomationModuleEnabled,
55 + isModuleActive,
56 + isProPluginActive,
57 +} from "../lib/plugin-utils";
58 +import {
35 59 fetchSettings,
36 60 previewCoreEmailTemplate,
37 61 saveSettings,
38 62 } from "../api/settings-api";
@@ -39,8 +63,9 @@
39 63 import { EmailPreviewModal } from "../components/email/EmailPreviewModal";
40 64 import {
41 65 createEmailTemplate,
42 66 fetchEmailTemplate,
67 + fetchEmailTemplateEvents,
43 68 fetchEmailTemplateVariables,
44 69 previewEmailTemplate,
45 70 sendEmailTemplateTest,
46 71 updateEmailTemplate,
@@ -51,25 +76,65 @@
51 76 body: string;
52 77 }
53 78
54 79 const EmailTemplateForm: React.FC = () => {
55 - const { id, isCreateMode, coreTemplateSlug } = useMemo(() => {
56 - const params = new URLSearchParams(window.location.search);
57 - const templateId = params.get("id");
58 - const action = params.get("action");
59 - const core = params.get("core_template") || "";
60 - return {
61 - id: templateId,
62 - isCreateMode: action === "create",
63 - coreTemplateSlug: core,
64 - };
65 - }, []);
80 + const { id, isCreateMode, coreTemplateSlug, coreReadOnlyParam } =
81 + useMemo(() => {
82 + const params = new URLSearchParams(window.location.search);
83 + const templateId = params.get("id");
84 + const action = params.get("action");
85 + const core = params.get("core_template") || "";
86 + return {
87 + id: templateId,
88 + isCreateMode: action === "create",
89 + coreTemplateSlug: core,
90 + coreReadOnlyParam: params.get("readonly") === "1",
91 + };
92 + }, []);
66 93
67 94 const isCoreSettingsEdit = isCoreTemplateSlug(coreTemplateSlug);
95 + // Per-template BCC/CC settings keys are derived from the template's subject
96 + // key (email_tpl_booking_subject -> _bcc / _cc), exactly as the PHP side does,
97 + // so the catalog needs no extra entries and a new template type is covered
98 + // automatically.
99 + const coreAddressKey = (
100 + subjectKey: string | undefined,
101 + suffix: "bcc" | "cc",
102 + ): string | null =>
103 + subjectKey ? subjectKey.replace(/_subject$/, "") + "_" + suffix : null;
104 +
68 105 const coreDef = isCoreSettingsEdit
69 106 ? getCoreTemplateDefinition(coreTemplateSlug)
70 107 : undefined;
71 108
109 + const catalogEntryForCore = useMemo(
110 + () =>
111 + coreTemplateSlug
112 + ? getCatalogEntryByTemplateKey(coreTemplateSlug)
113 + : undefined,
114 + [coreTemplateSlug],
115 + );
116 +
117 + /** URL ?readonly=1 — no edits at all from this screen */
118 + const isCoreStrictViewOnly = isCoreSettingsEdit && Boolean(coreReadOnlyParam);
119 +
120 + /** Module required but inactive: subject/body/save locked; on/off can still be changed (site setting). */
121 + const isCoreModuleBodyLocked = useMemo(() => {
122 + if (!isCoreSettingsEdit || isCoreStrictViewOnly) {
123 + return false;
124 + }
125 + const req = catalogEntryForCore?.requiresModule;
126 + if (!req) {
127 + return false;
128 + }
129 + return !isProPluginActive() || !isModuleActive(req);
130 + }, [isCoreSettingsEdit, isCoreStrictViewOnly, catalogEntryForCore]);
131 +
132 + /** Subject/body (and merge-tag insert) read-only; strict URL also locks status. */
133 + const isCoreBodyReadOnly = isCoreStrictViewOnly || isCoreModuleBodyLocked;
134 +
135 + const isCoreViewMode = isCoreStrictViewOnly || isCoreModuleBodyLocked;
136 +
72 137 const goBack = () => {
73 138 window.location.href = `admin.php?page=yatra&subpage=email-automation&tab=templates`;
74 139 };
75 140 const queryClient = useQueryClient();
@@ -81,18 +146,27 @@
81 146 from_name: "",
82 147 from_email: "",
83 148 to_email: "",
84 149 reply_to: "",
150 + bcc: "",
151 + cc: "",
152 + category: "booking",
85 153 subject: "",
86 154 body: "",
87 155 event_key: "",
88 156 is_active: true,
89 157 });
158 + // Trip-specific overrides (Pro Email Automation). `targets` is only sent
159 + // for override rows; `previewTrip` feeds preview / test with a real trip.
160 + const [targets, setTargets] = useState<TripTargets>({});
161 + const [previewTrip, setPreviewTrip] = useState<number | null>(null);
162 + const [overrideModalOpen, setOverrideModalOpen] = useState(false);
90 163 const [showPreview, setShowPreview] = useState(false);
91 164 const [previewData, setPreviewData] = useState<PreviewData | null>(null);
92 165 const [testEmail, setTestEmail] = useState("");
93 166 const [copiedVar, setCopiedVar] = useState<string | null>(null);
94 167 const [showEventDropdown, setShowEventDropdown] = useState(false);
168 + const [showAiModal, setShowAiModal] = useState(false);
95 169
96 170 useEffect(() => {
97 171 if (isCreateMode && !isEmailAutomationModuleEnabled()) {
98 172 window.location.href =
@@ -121,16 +195,117 @@
121 195 queryFn: () => fetchEmailTemplate(id as string),
122 196 enabled: Boolean(id) && !isCoreSettingsEdit,
123 197 });
124 198
199 + // System templates own their trigger event; everything else may choose one.
200 + const isSystemTemplate = Boolean((templateData as any)?.is_system);
201 + const overridesSupported =
202 + !isCoreSettingsEdit &&
203 + !!(window as any).yatraAdmin?.emailTemplateOverridesEnabled;
204 + const overrideOf = String((templateData as any)?.overrides ?? "");
205 + const isOverride = overridesSupported && overrideOf !== "";
206 + const isOverridableGlobal =
207 + overridesSupported &&
208 + !isOverride &&
209 + Boolean((templateData as any)?.overridable);
210 + // Global list (for the override's parent name, the global's override strip
211 + // and the picker labels). Cached with the Templates tab.
212 + const { data: allTemplatesData } = useQuery({
213 + queryKey: ["email-templates"],
214 + queryFn: () => fetchEmailTemplates(),
215 + enabled: overridesSupported && !isCreateMode,
216 + });
217 + const allTemplates = useMemo(
218 + () => (Array.isArray(allTemplatesData) ? (allTemplatesData as any[]) : []),
219 + [allTemplatesData],
220 + );
221 + const parentTemplate = useMemo(
222 + () => allTemplates.find((t) => t.template_key === overrideOf) || null,
223 + [allTemplates, overrideOf],
224 + );
225 + const myOverrides = useMemo(
226 + () =>
227 + allTemplates
228 + .filter(
229 + (t) =>
230 + t.overrides && t.overrides === (templateData as any)?.template_key,
231 + )
232 + .sort((a, b) => (a.priority || 0) - (b.priority || 0)),
233 + [allTemplates, templateData],
234 + );
235 + const { options: tripTargetOptions } = useTripTargets(
236 + overridesSupported && (isOverride || isOverridableGlobal),
237 + );
238 + const tripOptions = useMemo(
239 + () =>
240 + tripTargetOptions
241 + .filter((o) => String(o.value).startsWith("trip:"))
242 + .map((o) => ({
243 + id: Number(String(o.value).slice(5)),
244 + label: o.label.replace(/^[^:]+: /, ""),
245 + })),
246 + [tripTargetOptions],
247 + );
248 + // "Check a trip": which template the chosen trip would actually get.
249 + const { data: tripResolution } = useQuery({
250 + queryKey: ["email-templates-resolve", previewTrip],
251 + queryFn: () => resolveEmailTemplatesForTrip(previewTrip as number),
252 + enabled:
253 + overridesSupported &&
254 + !!previewTrip &&
255 + (isOverride || isOverridableGlobal),
256 + });
257 +
258 + // Event-scoped variables: the sidebar re-fetches whenever the
259 + // operator switches the trigger event so the "Available
260 + // Variables" panel reflects only tags that will actually
261 + // resolve at send-time for the chosen event. Empty event_key
262 + // returns the full union (for the create-template starter
263 + // state before they've picked an event).
125 264 const { data: variablesData } = useQuery({
126 - queryKey: ["email-variables"],
127 - queryFn: () => fetchEmailTemplateVariables(),
265 + queryKey: ["email-variables", formData.event_key || ""],
266 + queryFn: () => fetchEmailTemplateVariables(formData.event_key || ""),
128 267 enabled: !isCoreSettingsEdit && isEmailAutomationModuleEnabled(),
129 268 });
130 269
131 - // Get events from window.yatraAdmin (passed from PHP)
132 - const events = (window as any).yatraAdmin?.emailEvents || [];
270 + // Trigger events. Fetch them live rather than trusting only the one-time
271 + // window.yatraAdmin.emailEvents localize snapshot: that snapshot is written
272 + // by the Pro module at PHP render time, but the module-enabled flag is
273 + // refreshed client-side (useModules), so the two can diverge — the form
274 + // opens (flag says enabled) while emailEvents is still empty, leaving the
275 + // dropdown with "No Event to select". The live fetch corrects that; the
276 + // localize snapshot is used as an instant fallback while it loads.
277 + const { data: fetchedEvents } = useQuery({
278 + queryKey: ["email-template-events"],
279 + queryFn: fetchEmailTemplateEvents,
280 + enabled: !isCoreSettingsEdit && isEmailAutomationModuleEnabled(),
281 + });
282 + // Resolve the trigger-event list from the first non-empty source. Both the
283 + // live fetch AND the window.yatraAdmin.emailEvents localize are gated on the
284 + // Pro Email Automation module actively delivering data (route + localize both
285 + // only run when the module is enabled and the client flag agrees), so if the
286 + // module gate isn't delivering — disabled, or a client/server flag drift —
287 + // both are empty and the dropdown ended up unselectable. Fall back to the
288 + // module-INDEPENDENT catalog (same source the templates list uses) so an event
289 + // can always be chosen. `??` alone won't do — [] is not nullish.
290 + const events = useMemo(() => {
291 + if (Array.isArray(fetchedEvents) && fetchedEvents.length > 0) {
292 + return fetchedEvents;
293 + }
294 + const localized = (window as any).yatraAdmin?.emailEvents;
295 + if (Array.isArray(localized) && localized.length > 0) {
296 + return localized;
297 + }
298 + // Derive a readable name from the event key (e.g. "booking.created" →
299 + // "Booking Created") since the catalog keys events, not names.
300 + const prettify = (key: string) =>
301 + key.replace(/[._]/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
302 + return [
303 + ...new Set(
304 + EMAIL_TEMPLATES_CATALOG.map((e) => e.event_key).filter(Boolean),
305 + ),
306 + ].map((key) => ({ key, name: prettify(key) }));
307 + }, [fetchedEvents]);
133 308
134 309 useEffect(() => {
135 310 if (!templateData || typeof templateData !== "object") return;
136 311 const t = templateData as Record<string, unknown>;
@@ -140,13 +315,23 @@
140 315 from_name: String(t.from_name ?? ""),
141 316 from_email: String(t.from_email ?? ""),
142 317 to_email: String(t.to_email ?? ""),
143 318 reply_to: String(t.reply_to ?? ""),
319 + bcc: String(t.bcc ?? ""),
320 + cc: String(t.cc ?? ""),
321 + category: String(t.category ?? "booking"),
144 322 subject: String(t.subject ?? ""),
145 323 body: String(t.body ?? ""),
146 - event_key: String(t.event_key ?? ""),
324 + // An override's own event_key is stored empty; show (and fetch
325 + // variables for) the global template's event instead.
326 + event_key: String(t.effective_event_key || t.event_key || ""),
147 327 is_active: Boolean(t.is_active ?? true),
148 328 });
329 + setTargets(
330 + t.overrides && t.targets && typeof t.targets === "object"
331 + ? (t.targets as TripTargets)
332 + : {},
333 + );
149 334 }, [templateData]);
150 335
151 336 useEffect(() => {
152 337 if (!isCoreSettingsEdit || !settings || !coreDef) return;
@@ -161,8 +346,11 @@
161 346 from_name: "",
162 347 from_email: "",
163 348 to_email: coreDef.to_email || "",
164 349 reply_to: "",
350 + bcc: String(s[coreAddressKey(subj, "bcc") ?? ""] ?? ""),
351 + cc: String(s[coreAddressKey(subj, "cc") ?? ""] ?? ""),
352 + category: String(coreDef.category ?? "booking"),
165 353 subject: String(s[subj] ?? ""),
166 354 body: String(s[bodyKey] ?? ""),
167 355 event_key: coreDef.event_key,
168 356 is_active: Boolean(s[flag]),
@@ -215,8 +403,11 @@
215 403 });
216 404
217 405 const saveCoreMutation = useMutation({
218 406 mutationFn: async () => {
407 + if (isCoreStrictViewOnly) {
408 + throw new Error(__("This template is view-only.", "yatra"));
409 + }
219 410 if (
220 411 !coreDef?.settingsFlag ||
221 412 !coreDef.settingsSubject ||
222 413 !coreDef.settingsBody
@@ -226,11 +417,21 @@
226 417 if (!settings) {
227 418 throw new Error(__("Settings not loaded", "yatra"));
228 419 }
229 420 const base = { ...(settings as Record<string, unknown>) };
421 + if (isCoreModuleBodyLocked) {
422 + base[coreDef.settingsFlag] = formData.is_active;
423 + await saveSettings(base);
424 + return base;
425 + }
230 426 base[coreDef.settingsSubject] = formData.subject;
231 427 base[coreDef.settingsBody] = formData.body;
232 428 base[coreDef.settingsFlag] = formData.is_active;
429 +
430 + const bccKey = coreAddressKey(coreDef.settingsSubject, "bcc");
431 + const ccKey = coreAddressKey(coreDef.settingsSubject, "cc");
432 + if (bccKey) base[bccKey] = formData.bcc;
433 + if (ccKey) base[ccKey] = formData.cc;
233 434 await saveSettings(base);
234 435 return base;
235 436 },
236 437 onSuccess: (saved) => {
@@ -245,9 +446,9 @@
245 446 });
246 447
247 448 const previewMutation = useMutation({
248 449 mutationFn: async () => {
249 - return previewEmailTemplate(id as string);
450 + return previewEmailTemplate(id as string, previewTrip);
250 451 },
251 452 onSuccess: (data) => {
252 453 setPreviewData(data);
253 454 setShowPreview(true);
@@ -280,9 +481,9 @@
280 481 });
281 482
282 483 const testMutation = useMutation({
283 484 mutationFn: async (email: string) => {
284 - return sendEmailTemplateTest(id as string, email);
485 + return sendEmailTemplateTest(id as string, email, previewTrip);
285 486 },
286 487 onSuccess: () => {
287 488 showToast(__("Test email sent successfully"), "success");
288 489 setTestEmail("");
@@ -295,8 +496,11 @@
295 496 const handleSubmit = (e?: React.FormEvent | React.MouseEvent) => {
296 497 if (e) e.preventDefault();
297 498
298 499 if (isCoreSettingsEdit) {
500 + if (isCoreStrictViewOnly) {
501 + return;
502 + }
299 503 saveCoreMutation.mutate();
300 504 return;
301 505 }
302 506
@@ -307,19 +511,75 @@
307 511 if (!formData.subject.trim()) {
308 512 showToast(__("Subject line is required"), "error");
309 513 return;
310 514 }
515 + if (isOverride && !hasTargets(targets)) {
516 + showToast(
517 + __(
518 + "An override needs at least one trip, category or trip type — or delete it to use the global template.",
519 + "yatra",
520 + ),
521 + "error",
522 + );
523 + return;
524 + }
311 525
312 - saveMutation.mutate(formData);
526 + saveMutation.mutate(
527 + isOverride
528 + ? // event_key is not sent: an override always fires on its global's event.
529 + ({
530 + ...formData,
531 + event_key: undefined,
532 + targets,
533 + } as unknown as typeof formData)
534 + : formData,
535 + );
313 536 };
314 537
315 538 const copyVariable = (variable: string) => {
316 - navigator.clipboard.writeText(`{{${variable}}}`);
317 - setCopiedVar(variable);
318 - setTimeout(() => setCopiedVar(null), 2000);
539 + const text = `{{${variable}}}`;
540 +
541 + const markCopied = () => {
542 + setCopiedVar(variable);
543 + setTimeout(() => setCopiedVar(null), 2000);
544 + };
545 +
546 + // Legacy fallback: navigator.clipboard is only defined in a secure context
547 + // (HTTPS or localhost). On a plain-HTTP wp-admin it is undefined, so the
548 + // previous call threw and nothing was copied. execCommand("copy") works
549 + // there via a temporary off-screen textarea.
550 + const legacyCopy = (): boolean => {
551 + try {
552 + const el = document.createElement("textarea");
553 + el.value = text;
554 + el.setAttribute("readonly", "");
555 + el.style.position = "fixed";
556 + el.style.top = "-9999px";
557 + document.body.appendChild(el);
558 + el.select();
559 + const ok = document.execCommand("copy");
560 + document.body.removeChild(el);
561 + return ok;
562 + } catch {
563 + return false;
564 + }
565 + };
566 +
567 + if (navigator?.clipboard?.writeText) {
568 + navigator.clipboard.writeText(text).then(markCopied, () => {
569 + if (legacyCopy()) {
570 + markCopied();
571 + }
572 + });
573 + } else if (legacyCopy()) {
574 + markCopied();
575 + }
319 576 };
320 577
321 578 const insertVariable = (variable: string) => {
579 + if (isCoreBodyReadOnly) {
580 + return;
581 + }
322 582 const textarea = document.getElementById(
323 583 "email-body",
324 584 ) as HTMLTextAreaElement;
325 585 if (textarea) {
@@ -454,19 +714,51 @@
454 714 <h1 className="text-2xl font-bold text-gray-900 dark:text-white flex items-center gap-2">
455 715 <Mail className="w-6 h-6 text-blue-500" />
456 716 {isCreateMode
457 717 ? __("Create Email Template")
458 - : __("Edit Email Template")}
718 + : isCoreViewMode
719 + ? __("View email template", "yatra")
720 + : __("Edit Email Template")}
459 721 </h1>
460 722 <p className="text-gray-500 dark:text-gray-400 mt-1">
461 723 {isCreateMode
462 724 ? __("Create a new custom email template")
463 725 : isCoreSettingsEdit
464 - ? __(
465 - "Core customer email (saved with site settings). From address is set under Email → Delivery.",
466 - "yatra",
467 - )
726 + ? isCoreViewMode
727 + ? __(
728 + "This template uses site settings. Enable Yatra Pro and the required module to edit subject and HTML body.",
729 + "yatra",
730 + )
731 + : __(
732 + "Core customer email (saved with site settings). From address is set under Email → Delivery.",
733 + "yatra",
734 + )
468 735 : String(templateData?.name ?? "")}
736 + {isOverride && (
737 + <span className="ml-2 inline-flex items-center gap-1 text-xs align-middle">
738 + <span className="px-1.5 py-0.5 rounded font-medium bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-300">
739 + {__("Override", "yatra")}
740 + </span>
741 + <span className="text-gray-400">
742 + {__("of global template", "yatra")}
743 + </span>
744 + {parentTemplate ? (
745 + <a
746 + href={`admin.php?page=yatra&subpage=email-automation&tab=templates&action=edit&id=${parentTemplate.id}`}
747 + className="text-blue-600 dark:text-blue-400 hover:underline font-medium"
748 + >
749 + {parentTemplate.name}
750 + </a>
751 + ) : (
752 + <span>{overrideOf}</span>
753 + )}
754 + </span>
755 + )}
756 + {isOverridableGlobal && (
757 + <span className="ml-2 px-1.5 py-0.5 rounded text-xs font-medium align-middle bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-300">
758 + {__("Global", "yatra")}
759 + </span>
760 + )}
469 761 </p>
470 762 </div>
471 763 </div>
472 764 <div className="flex items-center gap-3">
@@ -491,9 +783,13 @@
491 783 </Button>
492 784 )}
493 785 <Button
494 786 onClick={handleSubmit}
495 - disabled={saveMutation.isPending || saveCoreMutation.isPending}
787 + disabled={
788 + saveMutation.isPending ||
789 + saveCoreMutation.isPending ||
790 + isCoreStrictViewOnly
791 + }
496 792 className="bg-blue-600 hover:bg-blue-700 text-white"
497 793 >
498 794 {saveMutation.isPending || saveCoreMutation.isPending ? (
499 795 <Loader2 className="w-4 h-4 mr-2 animate-spin" />
@@ -504,11 +800,138 @@
504 800 </Button>
505 801 </div>
506 802 </div>
507 803
804 + {isCoreBodyReadOnly && (
805 + <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">
806 + <Info className="w-5 h-5 flex-shrink-0 mt-0.5" />
807 + <p>
808 + {isCoreStrictViewOnly
809 + ? __(
810 + "This link is view-only. Subject, body, and status cannot be changed from this screen.",
811 + "yatra",
812 + )
813 + : __(
814 + "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.",
815 + "yatra",
816 + )}
817 + </p>
818 + </div>
819 + )}
820 +
508 821 <div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
509 822 {/* Main Form */}
510 823 <div className="lg:col-span-2 space-y-6">
824 + {isOverride && (
825 + <div
826 + className="rounded-lg border border-purple-300 dark:border-purple-800 overflow-hidden"
827 + data-testid="override-applies-to"
828 + >
829 + <div className="px-4 py-2.5 bg-purple-50 dark:bg-purple-900/20 text-sm font-semibold text-purple-800 dark:text-purple-200">
830 + {__("Applies to", "yatra")}{" "}
831 + <span className="font-normal text-purple-600 dark:text-purple-300">
832 + {__(
833 + "— bookings on these trips get this override instead of the global template",
834 + "yatra",
835 + )}
836 + </span>
837 + </div>
838 + <div className="p-4 space-y-3">
839 + <MultiSelect
840 + value={encodeTargets(targets)}
841 + onChange={(vals) => setTargets(decodeTargets(vals))}
842 + options={tripTargetOptions}
843 + placeholder={__(
844 + "Search trips, categories, trip types…",
845 + "yatra",
846 + )}
847 + />
848 + {!hasTargets(targets) && (
849 + <p className="text-xs text-red-600">
850 + {__(
851 + "Pick at least one trip, category or trip type.",
852 + "yatra",
853 + )}
854 + </p>
855 + )}
856 + <ul className="text-xs text-gray-500 dark:text-gray-400 list-disc pl-5 space-y-0.5">
857 + <li>
858 + {__(
859 + "Any match applies. A trip in a sub-category also matches its parent category.",
860 + "yatra",
861 + )}
862 + </li>
863 + <li>
864 + {__(
865 + "When several overrides match one trip, the most specific wins — trip › category › trip type — then the priority order in the Override templates list.",
866 + "yatra",
867 + )}
868 + </li>
869 + <li>
870 + {__(
871 + "Trips that match no override keep the global template. Switching this override off does the same for its trips.",
872 + "yatra",
873 + )}
874 + </li>
875 + </ul>
876 + </div>
877 + </div>
878 + )}
879 + {isOverridableGlobal && (
880 + <div
881 + className="flex flex-wrap items-start gap-3 rounded-lg border border-purple-300 dark:border-purple-800 bg-purple-50 dark:bg-purple-900/20 px-4 py-3 text-sm text-purple-900 dark:text-purple-200"
882 + data-testid="global-overrides-strip"
883 + >
884 + <div className="flex-1">
885 + {myOverrides.length === 0
886 + ? __(
887 + "No overrides yet. Every trip gets this template. Add an override to give some trips different wording.",
888 + "yatra",
889 + )
890 + : sprintf(
891 + __(
892 + "This global template has %d override(s) with their own wording. Changes you make here don't change them; every other trip gets this template.",
893 + "yatra",
894 + ),
895 + myOverrides.length,
896 + )}
897 + {myOverrides.length > 0 && (
898 + <ul className="mt-1 space-y-0.5">
899 + {myOverrides.map((o) => (
900 + <li
901 + key={o.id}
902 + className="flex flex-wrap items-center gap-2"
903 + >
904 + <a
905 + href={`admin.php?page=yatra&subpage=email-automation&tab=templates&action=edit&id=${o.id}`}
906 + className="font-medium text-blue-700 dark:text-blue-300 hover:underline"
907 + >
908 + {o.name}
909 + </a>
910 + <span className="text-xs text-purple-700 dark:text-purple-300">
911 + {describeTargets(o.targets, tripTargetOptions)}
912 + </span>
913 + {!o.is_active && (
914 + <span className="text-xs text-amber-700 dark:text-amber-300">
915 + {__("(off)", "yatra")}
916 + </span>
917 + )}
918 + </li>
919 + ))}
920 + </ul>
921 + )}
922 + </div>
923 + <Button
924 + type="button"
925 + variant="outline"
926 + size="sm"
927 + onClick={() => setOverrideModalOpen(true)}
928 + data-testid="add-override-from-global"
929 + >
930 + {__("+ Add override", "yatra")}
931 + </Button>
932 + </div>
933 + )}
511 934 <Card>
512 935 <CardHeader>
513 936 <CardTitle className="flex items-center justify-between">
514 937 <span>{__("Template Details")}</span>
@@ -519,8 +942,9 @@
519 942 {formData.is_active ? __("Active") : __("Inactive")}
520 943 </span>
521 944 <Switch
522 945 checked={formData.is_active}
946 + disabled={isCoreStrictViewOnly}
523 947 onCheckedChange={(checked) =>
524 948 setFormData({ ...formData, is_active: checked })
525 949 }
526 950 />
@@ -568,22 +992,29 @@
568 992 <div>
569 993 <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
570 994 {__("Trigger Event")}
571 995 </label>
572 - {Boolean(templateData?.is_system) || isCoreSettingsEdit ? (
996 + {Boolean(templateData?.is_system) ||
997 + isCoreSettingsEdit ||
998 + isOverride ? (
573 999 <div className="space-y-2">
574 1000 <div className="flex items-center gap-2">
575 - <span className="inline-flex items-center gap-1.5 px-3 py-2 rounded-lg text-sm font-medium bg-indigo-100 text-indigo-700 dark:bg-indigo-900/30 dark:text-indigo-400 border border-indigo-200 dark:border-indigo-800">
1001 + <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">
576 1002 <Zap className="w-4 h-4" />
577 1003 {formData.event_key || "-"}
578 1004 </span>
579 1005 <span className="text-xs text-gray-500 dark:text-gray-400">
580 - {__("System templates cannot change events")}
1006 + {isOverride
1007 + ? __(
1008 + "Same as the global template — an override can't change when it is sent, only what it says",
1009 + "yatra",
1010 + )
1011 + : __("System templates cannot change events")}
581 1012 </span>
582 1013 </div>
583 1014 {formData.event_key && (
584 1015 <div className="p-3 bg-indigo-50 dark:bg-indigo-900/20 rounded-lg border border-indigo-200 dark:border-indigo-800">
585 - <p className="text-sm text-indigo-700 dark:text-indigo-300">
1016 + <p className="text-sm text-indigo-800 dark:text-indigo-300">
586 1017 {events.find((e: any) => e.key === formData.event_key)
587 1018 ?.description || ""}
588 1019 </p>
589 1020 </div>
@@ -598,9 +1029,9 @@
598 1029 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"
599 1030 >
600 1031 {formData.event_key ? (
601 1032 <div className="flex items-center gap-2">
602 - <Zap className="w-4 h-4 text-indigo-500" />
1033 + <Zap className="w-4 h-4 text-blue-500" />
603 1034 <span className="text-sm font-medium text-gray-900 dark:text-white">
604 1035 {events.find(
605 1036 (e: any) => e.key === formData.event_key,
606 1037 )?.name || formData.event_key}
@@ -646,9 +1077,9 @@
646 1077 <div className="flex items-start gap-2">
647 1078 <Mail
648 1079 className={`w-4 h-4 mt-0.5 flex-shrink-0 ${
649 1080 !formData.event_key
650 - ? "text-indigo-500"
1081 + ? "text-blue-500"
651 1082 : "text-gray-400"
652 1083 }`}
653 1084 />
654 1085 <div className="flex-1 min-w-0">
@@ -655,9 +1086,9 @@
655 1086 <div className="flex items-center gap-2">
656 1087 <span
657 1088 className={`text-sm font-medium ${
658 1089 !formData.event_key
659 - ? "text-indigo-700 dark:text-indigo-400"
1090 + ? "text-blue-800 dark:text-blue-400"
660 1091 : "text-gray-900 dark:text-white"
661 1092 }`}
662 1093 >
663 1094 {__("No Event (Sequence Only)")}
@@ -669,9 +1100,9 @@
669 1100 )}
670 1101 </p>
671 1102 </div>
672 1103 {!formData.event_key && (
673 - <Check className="w-4 h-4 text-indigo-500 flex-shrink-0" />
1104 + <Check className="w-4 h-4 text-blue-500 flex-shrink-0" />
674 1105 )}
675 1106 </div>
676 1107 </button>
677 1108 {events.map((event: any) => (
@@ -694,9 +1125,9 @@
694 1125 <div className="flex items-start gap-2">
695 1126 <Zap
696 1127 className={`w-4 h-4 mt-0.5 flex-shrink-0 ${
697 1128 formData.event_key === event.key
698 - ? "text-indigo-500"
1129 + ? "text-blue-500"
699 1130 : "text-gray-400"
700 1131 }`}
701 1132 />
702 1133 <div className="flex-1 min-w-0">
@@ -703,9 +1134,9 @@
703 1134 <div className="flex items-center gap-2">
704 1135 <span
705 1136 className={`text-sm font-medium ${
706 1137 formData.event_key === event.key
707 - ? "text-indigo-700 dark:text-indigo-400"
1138 + ? "text-blue-800 dark:text-blue-400"
708 1139 : "text-gray-900 dark:text-white"
709 1140 }`}
710 1141 >
711 1142 {event.name}
@@ -718,13 +1149,20 @@
718 1149 {event.description}
719 1150 </p>
720 1151 </div>
721 1152 {formData.event_key === event.key && (
722 - <Check className="w-4 h-4 text-indigo-500 flex-shrink-0" />
1153 + <Check className="w-4 h-4 text-blue-500 flex-shrink-0" />
723 1154 )}
724 1155 </div>
725 1156 </button>
726 1157 ))}
1158 + {events.length === 0 && (
1159 + <div className="px-3 py-3 text-xs text-amber-600 dark:text-amber-400">
1160 + {__(
1161 + "No trigger events are available. Enable the Email Automation module under Yatra → Modules, then reload this page.",
1162 + )}
1163 + </div>
1164 + )}
727 1165 </div>
728 1166 </div>
729 1167 )}
730 1168
@@ -786,8 +1224,42 @@
786 1224 </p>
787 1225 </div>
788 1226 </div>
789 1227
1228 + {/* Category — lets the operator classify the template (e.g.
1229 + Payment) instead of it always defaulting to Booking. The
1230 + recipient (Customer/Admin) is derived server-side from the
1231 + To address ({{admin_email}} => admin). Locked for core/
1232 + system templates whose category is fixed. */}
1233 + {!isCoreSettingsEdit && (
1234 + <div className="mb-4">
1235 + <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
1236 + {__("Category")}
1237 + </label>
1238 + <select
1239 + aria-label={__("Category")}
1240 + value={formData.category || "booking"}
1241 + onChange={(e) =>
1242 + setFormData({ ...formData, category: e.target.value })
1243 + }
1244 + disabled={isCoreViewMode || isSystemTemplate}
1245 + 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"
1246 + >
1247 + <option value="booking">{__("Booking")}</option>
1248 + <option value="payment">{__("Payment")}</option>
1249 + <option value="reminder">{__("Reminder")}</option>
1250 + <option value="enquiry">{__("Enquiry")}</option>
1251 + <option value="account">{__("Account")}</option>
1252 + <option value="marketing">{__("Marketing")}</option>
1253 + </select>
1254 + <p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
1255 + {__(
1256 + "How this template is grouped in the list. Recipient (Customer/Admin) follows the To address.",
1257 + )}
1258 + </p>
1259 + </div>
1260 + )}
1261 +
790 1262 <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
791 1263 <div>
792 1264 <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
793 1265 {__("To Email")}
@@ -836,8 +1308,51 @@
836 1308 )}
837 1309 </p>
838 1310 )}
839 1311
1312 + {/* Copy addresses — available for every template, core or Pro.
1313 + Both are opt-in: left empty, no copy is sent. */}
1314 + <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
1315 + <div>
1316 + <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
1317 + {__("BCC", "yatra")}
1318 + </label>
1319 + <Input
1320 + value={formData.bcc}
1321 + onChange={(e) =>
1322 + setFormData({ ...formData, bcc: e.target.value })
1323 + }
1324 + placeholder={__("e.g., [email protected]", "yatra")}
1325 + disabled={isCoreViewMode}
1326 + />
1327 + <p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
1328 + {__(
1329 + "Blind copy, hidden from the recipient. Separate several addresses with commas. Leave empty to send no copy.",
1330 + "yatra",
1331 + )}
1332 + </p>
1333 + </div>
1334 + <div>
1335 + <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
1336 + {__("CC", "yatra")}
1337 + </label>
1338 + <Input
1339 + value={formData.cc}
1340 + onChange={(e) =>
1341 + setFormData({ ...formData, cc: e.target.value })
1342 + }
1343 + placeholder={__("e.g., [email protected]", "yatra")}
1344 + disabled={isCoreViewMode}
1345 + />
1346 + <p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
1347 + {__(
1348 + "Visible copy — the recipient can see this address. Separate several addresses with commas.",
1349 + "yatra",
1350 + )}
1351 + </p>
1352 + </div>
1353 + </div>
1354 +
840 1355 <div>
841 1356 <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
842 1357 {__("Subject Line")}
843 1358 </label>
@@ -846,9 +1361,10 @@
846 1361 onChange={(e) =>
847 1362 setFormData({ ...formData, subject: e.target.value })
848 1363 }
849 1364 placeholder={__("Email subject with {{variables}}")}
850 - className="font-mono text-sm"
1365 + readOnly={isCoreBodyReadOnly}
1366 + className={`font-mono text-sm ${isCoreBodyReadOnly ? "bg-gray-50 dark:bg-gray-900/40" : ""}`}
851 1367 />
852 1368 <p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
853 1369 {__("Use {{variable}} syntax to insert dynamic content")}
854 1370 </p>
@@ -854,11 +1370,31 @@
854 1370 </p>
855 1371 </div>
856 1372
857 1373 <div>
858 - <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
859 - {__("Email Body (HTML)")}
860 - </label>
1374 + {/* Label + AI sparkle on the same row — sparkle sits
1375 + to the right of the "Email Body (HTML)" label so
1376 + it's visually associated with the editor it
1377 + affects, not floating above Subject Line. */}
1378 + <div className="mb-1 flex items-center justify-between">
1379 + <label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
1380 + {__("Email Body (HTML)")}
1381 + </label>
1382 + {!isCoreBodyReadOnly &&
1383 + isAiEligible() &&
1384 + isAiModuleEnabled() && (
1385 + <Button
1386 + type="button"
1387 + variant="outline"
1388 + size="sm"
1389 + onClick={() => setShowAiModal(true)}
1390 + 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"
1391 + >
1392 + <Sparkles className="mr-1.5 h-3.5 w-3.5" />
1393 + {__("Generate with AI", "yatra")}
1394 + </Button>
1395 + )}
1396 + </div>
861 1397 <textarea
862 1398 id="email-body"
863 1399 value={formData.body}
864 1400 onChange={(e) =>
@@ -864,9 +1400,14 @@
864 1400 onChange={(e) =>
865 1401 setFormData({ ...formData, body: e.target.value })
866 1402 }
867 1403 rows={20}
868 - className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white font-mono text-sm"
1404 + readOnly={isCoreBodyReadOnly}
1405 + className={`w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg font-mono text-sm ${
1406 + isCoreBodyReadOnly
1407 + ? "bg-gray-50 dark:bg-gray-900/40 text-gray-900 dark:text-white"
1408 + : "bg-white dark:bg-gray-800 text-gray-900 dark:text-white"
1409 + }`}
869 1410 placeholder={__("HTML email content...")}
870 1411 />
871 1412 </div>
872 1413 </CardContent>
@@ -871,8 +1412,117 @@
871 1412 </div>
872 1413 </CardContent>
873 1414 </Card>
874 1415
1416 + {/* One trip dropdown for the editor: preview, test AND "which
1417 + template does this trip get". It is NEVER saved — the saved
1418 + targeting is the Applies-to block. */}
1419 + {!isCreateMode &&
1420 + !isCoreSettingsEdit &&
1421 + overridesSupported &&
1422 + (isOverride || isOverridableGlobal) && (
1423 + <Card>
1424 + <CardHeader>
1425 + <CardTitle className="text-base">
1426 + {__("Try it with a real trip", "yatra")}
1427 + </CardTitle>
1428 + </CardHeader>
1429 + <CardContent className="space-y-2">
1430 + <div data-testid="preview-trip">
1431 + <SearchableSelect
1432 + value={previewTrip ? String(previewTrip) : ""}
1433 + onChange={(value) =>
1434 + setPreviewTrip(value ? Number(value) : null)
1435 + }
1436 + options={tripOptions.map((t) => ({
1437 + value: String(t.id),
1438 + label: t.label,
1439 + }))}
1440 + placeholder={__("Sample data (no trip)", "yatra")}
1441 + searchPlaceholder={__("Search trips…", "yatra")}
1442 + />
1443 + </div>
1444 + <p className="text-xs text-gray-500">
1445 + {__(
1446 + "Not saved. Preview and Send Test Email use this trip's real name, link and sample booking data. Which trips actually get this template is set in “Applies to”.",
1447 + "yatra",
1448 + )}
1449 + </p>
1450 + {previewTrip && tripResolution && (
1451 + <div
1452 + className="text-sm rounded-md border border-dashed border-gray-300 dark:border-gray-600 px-3 py-2"
1453 + data-testid="check-trip-result"
1454 + >
1455 + <span className="text-gray-500">
1456 + {__("This trip's customers get:", "yatra")}
1457 + </span>{" "}
1458 + {(() => {
1459 + const parentKey = isOverride
1460 + ? overrideOf
1461 + : String((templateData as any)?.template_key || "");
1462 + const hit = tripResolution.resolved[parentKey];
1463 + if (isOverride) {
1464 + if (hit?.id === Number(id)) {
1465 + return (
1466 + <span className="px-1.5 py-0.5 rounded text-xs font-medium bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300">
1467 + {__("this override", "yatra")}
1468 + {hit?.reason ? ` · ${hit.reason}` : ""}
1469 + </span>
1470 + );
1471 + }
1472 + const noBodyYet = !String(
1473 + (templateData as any)?.body || "",
1474 + ).trim()
1475 + ? " — " +
1476 + __(
1477 + "this override has no body yet, so it is skipped",
1478 + "yatra",
1479 + )
1480 + : "";
1481 + if (hit) {
1482 + return (
1483 + <span className="text-amber-700 dark:text-amber-300">
1484 + {sprintf(
1485 + __("“%1$s” (%2$s)", "yatra"),
1486 + hit.name,
1487 + hit.reason,
1488 + )}
1489 + {noBodyYet}
1490 + </span>
1491 + );
1492 + }
1493 + return (
1494 + <span className="text-gray-700 dark:text-gray-300">
1495 + {sprintf(
1496 + __("the global template (%s)", "yatra"),
1497 + parentTemplate?.name || overrideOf,
1498 + )}
1499 + {noBodyYet}
1500 + </span>
1501 + );
1502 + }
1503 + return hit ? (
1504 + <span className="text-amber-700 dark:text-amber-300">
1505 + {sprintf(
1506 + __(
1507 + "the override “%1$s” (%2$s), not this template",
1508 + "yatra",
1509 + ),
1510 + hit.name,
1511 + hit.reason,
1512 + )}
1513 + </span>
1514 + ) : (
1515 + <span className="px-1.5 py-0.5 rounded text-xs font-medium bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300">
1516 + {__("this template", "yatra")}
1517 + </span>
1518 + );
1519 + })()}
1520 + </div>
1521 + )}
1522 + </CardContent>
1523 + </Card>
1524 + )}
875 1525 {/* Test Email - Only show in edit mode */}
876 1526 {!isCreateMode && !isCoreSettingsEdit && (
877 1527 <Card>
878 1528 <CardHeader>
@@ -915,17 +1565,60 @@
915 1565 <div className="space-y-6">
916 1566 <Card>
917 1567 <CardHeader>
918 1568 <CardTitle className="flex items-center gap-2">
919 - <Code className="w-5 h-5 text-purple-500" />
1569 + <Code className="w-5 h-5 text-blue-500" />
920 1570 {__("Available Variables")}
921 1571 </CardTitle>
922 1572 </CardHeader>
923 1573 <CardContent className="space-y-4">
924 1574 {!isCoreSettingsEdit && (
925 - <p className="text-sm text-gray-500 dark:text-gray-400">
926 - {__("Click to copy, double-click to insert at cursor")}
927 - </p>
1575 + <>
1576 + <p className="text-sm text-gray-500 dark:text-gray-400">
1577 + {__("Click to copy, double-click to insert at cursor")}
1578 + </p>
1579 + {/*
1580 + Subtitle showing which event the variables list is
1581 + scoped to. Without this hint, operators see a
1582 + different set of tags every time they switch the
1583 + event dropdown and have no clear explanation why.
1584 + */}
1585 + {formData.event_key ? (
1586 + // Show the technical event KEY (dot-notation, monospace)
1587 + // as the primary label so it can't be confused with the
1588 + // template's display title — earlier versions showed
1589 + // the event's friendly name, which often matches what
1590 + // operators name their templates ("Customer email
1591 + // verification" appears as both the event name and a
1592 + // common template title) and made it unclear what the
1593 + // chip was scoped to. Friendly name still appears as
1594 + // the parenthesised clarifier.
1595 + <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">
1596 + {__("Variables available for trigger event:", "yatra")}{" "}
1597 + <code className="font-mono text-blue-800 dark:text-blue-200 bg-blue-100 dark:bg-blue-900/40 px-1 rounded">
1598 + {formData.event_key}
1599 + </code>
1600 + {(() => {
1601 + const match = events.find(
1602 + (e: any) => e.key === formData.event_key,
1603 + );
1604 + return match?.name ? (
1605 + <span className="text-blue-600/80 dark:text-blue-400/80">
1606 + {" "}
1607 + ({match.name})
1608 + </span>
1609 + ) : null;
1610 + })()}
1611 + </p>
1612 + ) : (
1613 + <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">
1614 + {__(
1615 + "Pick a trigger event below to see the variables that apply to that event. Showing the full catalog until then.",
1616 + "yatra",
1617 + )}
1618 + </p>
1619 + )}
1620 + </>
928 1621 )}
929 1622
930 1623 {isCoreSettingsEdit && coreDef?.mergeTags ? (
931 1624 <div className="space-y-2">
@@ -1009,13 +1702,38 @@
1009 1702 <div className="flex justify-between items-center">
1010 1703 <span className="text-gray-500 dark:text-gray-400">
1011 1704 {__("Event")}
1012 1705 </span>
1013 - <span className="inline-flex items-center gap-1.5 px-2 py-1 rounded-full text-xs font-medium bg-indigo-100 text-indigo-700 dark:bg-indigo-900/30 dark:text-indigo-400">
1706 + <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">
1014 1707 <Zap className="w-3 h-3" />
1015 1708 {formData.event_key || "-"}
1016 1709 </span>
1017 1710 </div>
1711 + {/* The badge above only reports the choice. This panel sits right
1712 + under "Select an event to trigger this email automatically",
1713 + so give it a control that actually selects one — a plain
1714 + <select>, which cannot be clipped by an ancestor and needs no
1715 + open/close state, unlike the styled dropdown in the main
1716 + column. System and core templates keep their fixed event. */}
1717 + {!isSystemTemplate && !isCoreSettingsEdit && (
1718 + <select
1719 + aria-label={__("Trigger Event")}
1720 + value={formData.event_key || ""}
1721 + onChange={(e) =>
1722 + setFormData({ ...formData, event_key: e.target.value })
1723 + }
1724 + 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"
1725 + >
1726 + <option value="">
1727 + {__("No event (use in a sequence)")}
1728 + </option>
1729 + {events.map((ev: any) => (
1730 + <option key={ev.key} value={ev.key}>
1731 + {ev.name || ev.key}
1732 + </option>
1733 + ))}
1734 + </select>
1735 + )}
1018 1736 {formData.event_key && (
1019 1737 <p className="text-xs text-gray-500 dark:text-gray-400 italic">
1020 1738 {events.find((e: any) => e.key === formData.event_key)
1021 1739 ?.description || ""}
@@ -1109,8 +1827,67 @@
1109 1827 }}
1110 1828 subject={previewData?.subject ?? ""}
1111 1829 body={previewData?.body ?? ""}
1112 1830 />
1831 +
1832 + {/* AI: subject + body generator. The merge-tag list comes from
1833 + the catalog entry when it's a core template, falling back
1834 + to a sensible default set when editing a custom template. */}
1835 + <AiEmailTemplateModal
1836 + open={showAiModal}
1837 + onClose={() => setShowAiModal(false)}
1838 + templateKey={
1839 + coreTemplateSlug || formData.event_key || "custom_template"
1840 + }
1841 + templateName={coreDef?.name || formData.name || formData.event_key}
1842 + templateDescription={coreDef?.description || formData.description}
1843 + recipientType={
1844 + (catalogEntryForCore?.recipient_type as "customer" | "admin") ||
1845 + "customer"
1846 + }
1847 + mergeTags={(() => {
1848 + // Catalog stores mergeTags as a comma-string like
1849 + // "{{customer_name}}, {{booking_reference}}, …". Coerce to
1850 + // the bare tag names ('customer_name') that the agent's
1851 + // server-side normalizer accepts.
1852 + const raw = catalogEntryForCore?.mergeTags ?? "";
1853 + if (!raw) {
1854 + return [
1855 + "site_name",
1856 + "site_url",
1857 + "customer_name",
1858 + "customer_email",
1859 + "booking_reference",
1860 + "trip_name",
1861 + "travel_date",
1862 + "total_amount",
1863 + "currency",
1864 + ];
1865 + }
1866 + return raw
1867 + .split(",")
1868 + .map((s) =>
1869 + s
1870 + .trim()
1871 + .replace(/^\{\{|\}\}$/g, "")
1872 + .trim(),
1873 + )
1874 + .filter(Boolean);
1875 + })()}
1876 + currentSubject={formData.subject}
1877 + currentBody={formData.body}
1878 + onAccept={({ subject, body }) =>
1879 + setFormData({ ...formData, subject, body })
1880 + }
1881 + />
1882 + {isOverridableGlobal && templateData && (
1883 + <EmailOverrideCreateModal
1884 + isOpen={overrideModalOpen}
1885 + onClose={() => setOverrideModalOpen(false)}
1886 + globals={[templateData as any]}
1887 + parent={templateData as any}
1888 + />
1889 + )}
1113 1890 </div>
1114 1891 );
1115 1892 };
1116 1893