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 +581 -12 3.0.7 → 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,
@@ -29,8 +44,9 @@
29 44 import { AiEmailTemplateModal } from "../components/ai/AiEmailTemplateModal";
30 45 import { isAiEligible, isAiModuleEnabled } from "../lib/ai-availability";
31 46 import { Switch } from "../components/ui/switch";
32 47 import {
48 + EMAIL_TEMPLATES_CATALOG,
33 49 getCatalogEntryByTemplateKey,
34 50 getCoreTemplateDefinition,
35 51 isCoreTemplateSlug,
36 52 } from "../lib/email-templates-catalog";
@@ -47,8 +63,9 @@
47 63 import { EmailPreviewModal } from "../components/email/EmailPreviewModal";
48 64 import {
49 65 createEmailTemplate,
50 66 fetchEmailTemplate,
67 + fetchEmailTemplateEvents,
51 68 fetchEmailTemplateVariables,
52 69 previewEmailTemplate,
53 70 sendEmailTemplateTest,
54 71 updateEmailTemplate,
@@ -74,8 +91,18 @@
74 91 };
75 92 }, []);
76 93
77 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 +
78 105 const coreDef = isCoreSettingsEdit
79 106 ? getCoreTemplateDefinition(coreTemplateSlug)
80 107 : undefined;
81 108
@@ -119,13 +146,21 @@
119 146 from_name: "",
120 147 from_email: "",
121 148 to_email: "",
122 149 reply_to: "",
150 + bcc: "",
151 + cc: "",
152 + category: "booking",
123 153 subject: "",
124 154 body: "",
125 155 event_key: "",
126 156 is_active: true,
127 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);
128 163 const [showPreview, setShowPreview] = useState(false);
129 164 const [previewData, setPreviewData] = useState<PreviewData | null>(null);
130 165 const [testEmail, setTestEmail] = useState("");
131 166 const [copiedVar, setCopiedVar] = useState<string | null>(null);
@@ -160,8 +195,67 @@
160 195 queryFn: () => fetchEmailTemplate(id as string),
161 196 enabled: Boolean(id) && !isCoreSettingsEdit,
162 197 });
163 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 +
164 258 // Event-scoped variables: the sidebar re-fetches whenever the
165 259 // operator switches the trigger event so the "Available
166 260 // Variables" panel reflects only tags that will actually
167 261 // resolve at send-time for the chosen event. Empty event_key
@@ -172,10 +266,46 @@
172 266 queryFn: () => fetchEmailTemplateVariables(formData.event_key || ""),
173 267 enabled: !isCoreSettingsEdit && isEmailAutomationModuleEnabled(),
174 268 });
175 269
176 - // Get events from window.yatraAdmin (passed from PHP)
177 - 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]);
178 308
179 309 useEffect(() => {
180 310 if (!templateData || typeof templateData !== "object") return;
181 311 const t = templateData as Record<string, unknown>;
@@ -185,13 +315,23 @@
185 315 from_name: String(t.from_name ?? ""),
186 316 from_email: String(t.from_email ?? ""),
187 317 to_email: String(t.to_email ?? ""),
188 318 reply_to: String(t.reply_to ?? ""),
319 + bcc: String(t.bcc ?? ""),
320 + cc: String(t.cc ?? ""),
321 + category: String(t.category ?? "booking"),
189 322 subject: String(t.subject ?? ""),
190 323 body: String(t.body ?? ""),
191 - 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 || ""),
192 327 is_active: Boolean(t.is_active ?? true),
193 328 });
329 + setTargets(
330 + t.overrides && t.targets && typeof t.targets === "object"
331 + ? (t.targets as TripTargets)
332 + : {},
333 + );
194 334 }, [templateData]);
195 335
196 336 useEffect(() => {
197 337 if (!isCoreSettingsEdit || !settings || !coreDef) return;
@@ -206,8 +346,11 @@
206 346 from_name: "",
207 347 from_email: "",
208 348 to_email: coreDef.to_email || "",
209 349 reply_to: "",
350 + bcc: String(s[coreAddressKey(subj, "bcc") ?? ""] ?? ""),
351 + cc: String(s[coreAddressKey(subj, "cc") ?? ""] ?? ""),
352 + category: String(coreDef.category ?? "booking"),
210 353 subject: String(s[subj] ?? ""),
211 354 body: String(s[bodyKey] ?? ""),
212 355 event_key: coreDef.event_key,
213 356 is_active: Boolean(s[flag]),
@@ -282,8 +425,13 @@
282 425 }
283 426 base[coreDef.settingsSubject] = formData.subject;
284 427 base[coreDef.settingsBody] = formData.body;
285 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;
286 434 await saveSettings(base);
287 435 return base;
288 436 },
289 437 onSuccess: (saved) => {
@@ -298,9 +446,9 @@
298 446 });
299 447
300 448 const previewMutation = useMutation({
301 449 mutationFn: async () => {
302 - return previewEmailTemplate(id as string);
450 + return previewEmailTemplate(id as string, previewTrip);
303 451 },
304 452 onSuccess: (data) => {
305 453 setPreviewData(data);
306 454 setShowPreview(true);
@@ -333,9 +481,9 @@
333 481 });
334 482
335 483 const testMutation = useMutation({
336 484 mutationFn: async (email: string) => {
337 - return sendEmailTemplateTest(id as string, email);
485 + return sendEmailTemplateTest(id as string, email, previewTrip);
338 486 },
339 487 onSuccess: () => {
340 488 showToast(__("Test email sent successfully"), "success");
341 489 setTestEmail("");
@@ -363,16 +511,69 @@
363 511 if (!formData.subject.trim()) {
364 512 showToast(__("Subject line is required"), "error");
365 513 return;
366 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 + }
367 525
368 - 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 + );
369 536 };
370 537
371 538 const copyVariable = (variable: string) => {
372 - navigator.clipboard.writeText(`{{${variable}}}`);
373 - setCopiedVar(variable);
374 - 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 + }
375 576 };
376 577
377 578 const insertVariable = (variable: string) => {
378 579 if (isCoreBodyReadOnly) {
@@ -531,8 +732,33 @@
531 732 "Core customer email (saved with site settings). From address is set under Email → Delivery.",
532 733 "yatra",
533 734 )
534 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 + )}
535 761 </p>
536 762 </div>
537 763 </div>
538 764 <div className="flex items-center gap-3">
@@ -594,8 +820,118 @@
594 820
595 821 <div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
596 822 {/* Main Form */}
597 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 + )}
598 934 <Card>
599 935 <CardHeader>
600 936 <CardTitle className="flex items-center justify-between">
601 937 <span>{__("Template Details")}</span>
@@ -656,9 +992,11 @@
656 992 <div>
657 993 <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
658 994 {__("Trigger Event")}
659 995 </label>
660 - {Boolean(templateData?.is_system) || isCoreSettingsEdit ? (
996 + {Boolean(templateData?.is_system) ||
997 + isCoreSettingsEdit ||
998 + isOverride ? (
661 999 <div className="space-y-2">
662 1000 <div className="flex items-center gap-2">
663 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">
664 1002 <Zap className="w-4 h-4" />
@@ -664,9 +1002,14 @@
664 1002 <Zap className="w-4 h-4" />
665 1003 {formData.event_key || "-"}
666 1004 </span>
667 1005 <span className="text-xs text-gray-500 dark:text-gray-400">
668 - {__("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")}
669 1012 </span>
670 1013 </div>
671 1014 {formData.event_key && (
672 1015 <div className="p-3 bg-indigo-50 dark:bg-indigo-900/20 rounded-lg border border-indigo-200 dark:border-indigo-800">
@@ -811,8 +1154,15 @@
811 1154 )}
812 1155 </div>
813 1156 </button>
814 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 + )}
815 1165 </div>
816 1166 </div>
817 1167 )}
818 1168
@@ -874,8 +1224,42 @@
874 1224 </p>
875 1225 </div>
876 1226 </div>
877 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 +
878 1262 <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
879 1263 <div>
880 1264 <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
881 1265 {__("To Email")}
@@ -924,8 +1308,51 @@
924 1308 )}
925 1309 </p>
926 1310 )}
927 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 +
928 1355 <div>
929 1356 <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
930 1357 {__("Subject Line")}
931 1358 </label>
@@ -985,8 +1412,117 @@
985 1412 </div>
986 1413 </CardContent>
987 1414 </Card>
988 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 + )}
989 1525 {/* Test Email - Only show in edit mode */}
990 1526 {!isCreateMode && !isCoreSettingsEdit && (
991 1527 <Card>
992 1528 <CardHeader>
@@ -1171,8 +1707,33 @@
1171 1707 <Zap className="w-3 h-3" />
1172 1708 {formData.event_key || "-"}
1173 1709 </span>
1174 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 + )}
1175 1736 {formData.event_key && (
1176 1737 <p className="text-xs text-gray-500 dark:text-gray-400 italic">
1177 1738 {events.find((e: any) => e.key === formData.event_key)
1178 1739 ?.description || ""}
@@ -1317,8 +1878,16 @@
1317 1878 onAccept={({ subject, body }) =>
1318 1879 setFormData({ ...formData, subject, body })
1319 1880 }
1320 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 + )}
1321 1890 </div>
1322 1891 );
1323 1892 };
1324 1893