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