PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.16
Yatra – Travel Booking & Tour Operator Software v3.0.16
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
← All changes | resources/js/pages/EmailTemplateForm.tsx +725 -33 3.0.3 → 3.0.16 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,11 +38,15 @@
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,
30 49 getCatalogEntryByTemplateKey,
31 50 getCoreTemplateDefinition,
32 51 isCoreTemplateSlug,
33 52 } from "../lib/email-templates-catalog";
@@ -44,8 +63,9 @@
44 63 import { EmailPreviewModal } from "../components/email/EmailPreviewModal";
45 64 import {
46 65 createEmailTemplate,
47 66 fetchEmailTemplate,
67 + fetchEmailTemplateEvents,
48 68 fetchEmailTemplateVariables,
49 69 previewEmailTemplate,
50 70 sendEmailTemplateTest,
51 71 updateEmailTemplate,
@@ -71,8 +91,18 @@
71 91 };
72 92 }, []);
73 93
74 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 +
75 105 const coreDef = isCoreSettingsEdit
76 106 ? getCoreTemplateDefinition(coreTemplateSlug)
77 107 : undefined;
78 108
@@ -84,10 +114,9 @@
84 114 [coreTemplateSlug],
85 115 );
86 116
87 117 /** URL ?readonly=1 — no edits at all from this screen */
88 - const isCoreStrictViewOnly =
89 - isCoreSettingsEdit && Boolean(coreReadOnlyParam);
118 + const isCoreStrictViewOnly = isCoreSettingsEdit && Boolean(coreReadOnlyParam);
90 119
91 120 /** Module required but inactive: subject/body/save locked; on/off can still be changed (site setting). */
92 121 const isCoreModuleBodyLocked = useMemo(() => {
93 122 if (!isCoreSettingsEdit || isCoreStrictViewOnly) {
@@ -117,18 +146,27 @@
117 146 from_name: "",
118 147 from_email: "",
119 148 to_email: "",
120 149 reply_to: "",
150 + bcc: "",
151 + cc: "",
152 + category: "booking",
121 153 subject: "",
122 154 body: "",
123 155 event_key: "",
124 156 is_active: true,
125 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);
126 163 const [showPreview, setShowPreview] = useState(false);
127 164 const [previewData, setPreviewData] = useState<PreviewData | null>(null);
128 165 const [testEmail, setTestEmail] = useState("");
129 166 const [copiedVar, setCopiedVar] = useState<string | null>(null);
130 167 const [showEventDropdown, setShowEventDropdown] = useState(false);
168 + const [showAiModal, setShowAiModal] = useState(false);
131 169
132 170 useEffect(() => {
133 171 if (isCreateMode && !isEmailAutomationModuleEnabled()) {
134 172 window.location.href =
@@ -157,16 +195,117 @@
157 195 queryFn: () => fetchEmailTemplate(id as string),
158 196 enabled: Boolean(id) && !isCoreSettingsEdit,
159 197 });
160 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).
161 264 const { data: variablesData } = useQuery({
162 - queryKey: ["email-variables"],
163 - queryFn: () => fetchEmailTemplateVariables(),
265 + queryKey: ["email-variables", formData.event_key || ""],
266 + queryFn: () => fetchEmailTemplateVariables(formData.event_key || ""),
164 267 enabled: !isCoreSettingsEdit && isEmailAutomationModuleEnabled(),
165 268 });
166 269
167 - // Get events from window.yatraAdmin (passed from PHP)
168 - 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]);
169 308
170 309 useEffect(() => {
171 310 if (!templateData || typeof templateData !== "object") return;
172 311 const t = templateData as Record<string, unknown>;
@@ -176,13 +315,23 @@
176 315 from_name: String(t.from_name ?? ""),
177 316 from_email: String(t.from_email ?? ""),
178 317 to_email: String(t.to_email ?? ""),
179 318 reply_to: String(t.reply_to ?? ""),
319 + bcc: String(t.bcc ?? ""),
320 + cc: String(t.cc ?? ""),
321 + category: String(t.category ?? "booking"),
180 322 subject: String(t.subject ?? ""),
181 323 body: String(t.body ?? ""),
182 - 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 || ""),
183 327 is_active: Boolean(t.is_active ?? true),
184 328 });
329 + setTargets(
330 + t.overrides && t.targets && typeof t.targets === "object"
331 + ? (t.targets as TripTargets)
332 + : {},
333 + );
185 334 }, [templateData]);
186 335
187 336 useEffect(() => {
188 337 if (!isCoreSettingsEdit || !settings || !coreDef) return;
@@ -197,8 +346,11 @@
197 346 from_name: "",
198 347 from_email: "",
199 348 to_email: coreDef.to_email || "",
200 349 reply_to: "",
350 + bcc: String(s[coreAddressKey(subj, "bcc") ?? ""] ?? ""),
351 + cc: String(s[coreAddressKey(subj, "cc") ?? ""] ?? ""),
352 + category: String(coreDef.category ?? "booking"),
201 353 subject: String(s[subj] ?? ""),
202 354 body: String(s[bodyKey] ?? ""),
203 355 event_key: coreDef.event_key,
204 356 is_active: Boolean(s[flag]),
@@ -273,8 +425,13 @@
273 425 }
274 426 base[coreDef.settingsSubject] = formData.subject;
275 427 base[coreDef.settingsBody] = formData.body;
276 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;
277 434 await saveSettings(base);
278 435 return base;
279 436 },
280 437 onSuccess: (saved) => {
@@ -289,9 +446,9 @@
289 446 });
290 447
291 448 const previewMutation = useMutation({
292 449 mutationFn: async () => {
293 - return previewEmailTemplate(id as string);
450 + return previewEmailTemplate(id as string, previewTrip);
294 451 },
295 452 onSuccess: (data) => {
296 453 setPreviewData(data);
297 454 setShowPreview(true);
@@ -324,9 +481,9 @@
324 481 });
325 482
326 483 const testMutation = useMutation({
327 484 mutationFn: async (email: string) => {
328 - return sendEmailTemplateTest(id as string, email);
485 + return sendEmailTemplateTest(id as string, email, previewTrip);
329 486 },
330 487 onSuccess: () => {
331 488 showToast(__("Test email sent successfully"), "success");
332 489 setTestEmail("");
@@ -354,16 +511,69 @@
354 511 if (!formData.subject.trim()) {
355 512 showToast(__("Subject line is required"), "error");
356 513 return;
357 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 + }
358 525
359 - 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 + );
360 536 };
361 537
362 538 const copyVariable = (variable: string) => {
363 - navigator.clipboard.writeText(`{{${variable}}}`);
364 - setCopiedVar(variable);
365 - 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 + }
366 576 };
367 577
368 578 const insertVariable = (variable: string) => {
369 579 if (isCoreBodyReadOnly) {
@@ -522,8 +732,33 @@
522 732 "Core customer email (saved with site settings). From address is set under Email → Delivery.",
523 733 "yatra",
524 734 )
525 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 + )}
526 761 </p>
527 762 </div>
528 763 </div>
529 764 <div className="flex items-center gap-3">
@@ -585,8 +820,118 @@
585 820
586 821 <div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
587 822 {/* Main Form */}
588 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 + )}
589 934 <Card>
590 935 <CardHeader>
591 936 <CardTitle className="flex items-center justify-between">
592 937 <span>{__("Template Details")}</span>
@@ -647,22 +992,29 @@
647 992 <div>
648 993 <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
649 994 {__("Trigger Event")}
650 995 </label>
651 - {Boolean(templateData?.is_system) || isCoreSettingsEdit ? (
996 + {Boolean(templateData?.is_system) ||
997 + isCoreSettingsEdit ||
998 + isOverride ? (
652 999 <div className="space-y-2">
653 1000 <div className="flex items-center gap-2">
654 - <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">
655 1002 <Zap className="w-4 h-4" />
656 1003 {formData.event_key || "-"}
657 1004 </span>
658 1005 <span className="text-xs text-gray-500 dark:text-gray-400">
659 - {__("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")}
660 1012 </span>
661 1013 </div>
662 1014 {formData.event_key && (
663 1015 <div className="p-3 bg-indigo-50 dark:bg-indigo-900/20 rounded-lg border border-indigo-200 dark:border-indigo-800">
664 - <p className="text-sm text-indigo-700 dark:text-indigo-300">
1016 + <p className="text-sm text-indigo-800 dark:text-indigo-300">
665 1017 {events.find((e: any) => e.key === formData.event_key)
666 1018 ?.description || ""}
667 1019 </p>
668 1020 </div>
@@ -677,9 +1029,9 @@
677 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"
678 1030 >
679 1031 {formData.event_key ? (
680 1032 <div className="flex items-center gap-2">
681 - <Zap className="w-4 h-4 text-indigo-500" />
1033 + <Zap className="w-4 h-4 text-blue-500" />
682 1034 <span className="text-sm font-medium text-gray-900 dark:text-white">
683 1035 {events.find(
684 1036 (e: any) => e.key === formData.event_key,
685 1037 )?.name || formData.event_key}
@@ -725,9 +1077,9 @@
725 1077 <div className="flex items-start gap-2">
726 1078 <Mail
727 1079 className={`w-4 h-4 mt-0.5 flex-shrink-0 ${
728 1080 !formData.event_key
729 - ? "text-indigo-500"
1081 + ? "text-blue-500"
730 1082 : "text-gray-400"
731 1083 }`}
732 1084 />
733 1085 <div className="flex-1 min-w-0">
@@ -734,9 +1086,9 @@
734 1086 <div className="flex items-center gap-2">
735 1087 <span
736 1088 className={`text-sm font-medium ${
737 1089 !formData.event_key
738 - ? "text-indigo-700 dark:text-indigo-400"
1090 + ? "text-blue-800 dark:text-blue-400"
739 1091 : "text-gray-900 dark:text-white"
740 1092 }`}
741 1093 >
742 1094 {__("No Event (Sequence Only)")}
@@ -748,9 +1100,9 @@
748 1100 )}
749 1101 </p>
750 1102 </div>
751 1103 {!formData.event_key && (
752 - <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" />
753 1105 )}
754 1106 </div>
755 1107 </button>
756 1108 {events.map((event: any) => (
@@ -773,9 +1125,9 @@
773 1125 <div className="flex items-start gap-2">
774 1126 <Zap
775 1127 className={`w-4 h-4 mt-0.5 flex-shrink-0 ${
776 1128 formData.event_key === event.key
777 - ? "text-indigo-500"
1129 + ? "text-blue-500"
778 1130 : "text-gray-400"
779 1131 }`}
780 1132 />
781 1133 <div className="flex-1 min-w-0">
@@ -782,9 +1134,9 @@
782 1134 <div className="flex items-center gap-2">
783 1135 <span
784 1136 className={`text-sm font-medium ${
785 1137 formData.event_key === event.key
786 - ? "text-indigo-700 dark:text-indigo-400"
1138 + ? "text-blue-800 dark:text-blue-400"
787 1139 : "text-gray-900 dark:text-white"
788 1140 }`}
789 1141 >
790 1142 {event.name}
@@ -797,13 +1149,20 @@
797 1149 {event.description}
798 1150 </p>
799 1151 </div>
800 1152 {formData.event_key === event.key && (
801 - <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" />
802 1154 )}
803 1155 </div>
804 1156 </button>
805 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 + )}
806 1165 </div>
807 1166 </div>
808 1167 )}
809 1168
@@ -865,8 +1224,42 @@
865 1224 </p>
866 1225 </div>
867 1226 </div>
868 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 +
869 1262 <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
870 1263 <div>
871 1264 <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
872 1265 {__("To Email")}
@@ -915,8 +1308,51 @@
915 1308 )}
916 1309 </p>
917 1310 )}
918 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 +
919 1355 <div>
920 1356 <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
921 1357 {__("Subject Line")}
922 1358 </label>
@@ -934,11 +1370,31 @@
934 1370 </p>
935 1371 </div>
936 1372
937 1373 <div>
938 - <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
939 - {__("Email Body (HTML)")}
940 - </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>
941 1397 <textarea
942 1398 id="email-body"
943 1399 value={formData.body}
944 1400 onChange={(e) =>
@@ -956,8 +1412,117 @@
956 1412 </div>
957 1413 </CardContent>
958 1414 </Card>
959 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 + )}
960 1525 {/* Test Email - Only show in edit mode */}
961 1526 {!isCreateMode && !isCoreSettingsEdit && (
962 1527 <Card>
963 1528 <CardHeader>
@@ -1000,17 +1565,60 @@
1000 1565 <div className="space-y-6">
1001 1566 <Card>
1002 1567 <CardHeader>
1003 1568 <CardTitle className="flex items-center gap-2">
1004 - <Code className="w-5 h-5 text-purple-500" />
1569 + <Code className="w-5 h-5 text-blue-500" />
1005 1570 {__("Available Variables")}
1006 1571 </CardTitle>
1007 1572 </CardHeader>
1008 1573 <CardContent className="space-y-4">
1009 1574 {!isCoreSettingsEdit && (
1010 - <p className="text-sm text-gray-500 dark:text-gray-400">
1011 - {__("Click to copy, double-click to insert at cursor")}
1012 - </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 + </>
1013 1621 )}
1014 1622
1015 1623 {isCoreSettingsEdit && coreDef?.mergeTags ? (
1016 1624 <div className="space-y-2">
@@ -1094,13 +1702,38 @@
1094 1702 <div className="flex justify-between items-center">
1095 1703 <span className="text-gray-500 dark:text-gray-400">
1096 1704 {__("Event")}
1097 1705 </span>
1098 - <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">
1099 1707 <Zap className="w-3 h-3" />
1100 1708 {formData.event_key || "-"}
1101 1709 </span>
1102 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 + )}
1103 1736 {formData.event_key && (
1104 1737 <p className="text-xs text-gray-500 dark:text-gray-400 italic">
1105 1738 {events.find((e: any) => e.key === formData.event_key)
1106 1739 ?.description || ""}
@@ -1194,8 +1827,67 @@
1194 1827 }}
1195 1828 subject={previewData?.subject ?? ""}
1196 1829 body={previewData?.body ?? ""}
1197 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 + )}
1198 1890 </div>
1199 1891 );
1200 1892 };
1201 1893