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
yatra / resources / js / pages / EmailTemplateForm.tsx

EmailTemplateForm.tsx in Yatra – Travel Booking & Tour Operator Software 3.0.16, at resources/js/pages/EmailTemplateForm.tsx

1,895 lines 78.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 import React, { useState, useEffect, useMemo } from "react";
2 import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
3 import { __, 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";
19 import { useToast } from "../components/ui/toast";
20 import {
21 Card,
22 CardContent,
23 CardHeader,
24 CardTitle,
25 } from "../components/ui/card";
26 import { Button } from "../components/ui/button";
27 import { Input } from "../components/ui/input";
28 import { Badge } from "../components/ui/badge";
29 import {
30 Mail,
31 ArrowLeft,
32 Save,
33 Eye,
34 Send,
35 Loader2,
36 Code,
37 Copy,
38 Check,
39 Info,
40 Zap,
41 ChevronDown,
42 Sparkles,
43 } from "lucide-react";
44 import { AiEmailTemplateModal } from "../components/ai/AiEmailTemplateModal";
45 import { isAiEligible, isAiModuleEnabled } from "../lib/ai-availability";
46 import { Switch } from "../components/ui/switch";
47 import {
48 EMAIL_TEMPLATES_CATALOG,
49 getCatalogEntryByTemplateKey,
50 getCoreTemplateDefinition,
51 isCoreTemplateSlug,
52 } from "../lib/email-templates-catalog";
53 import {
54 isEmailAutomationModuleEnabled,
55 isModuleActive,
56 isProPluginActive,
57 } from "../lib/plugin-utils";
58 import {
59 fetchSettings,
60 previewCoreEmailTemplate,
61 saveSettings,
62 } from "../api/settings-api";
63 import { EmailPreviewModal } from "../components/email/EmailPreviewModal";
64 import {
65 createEmailTemplate,
66 fetchEmailTemplate,
67 fetchEmailTemplateEvents,
68 fetchEmailTemplateVariables,
69 previewEmailTemplate,
70 sendEmailTemplateTest,
71 updateEmailTemplate,
72 } from "../api/email-automation-api";
73
74 interface PreviewData {
75 subject: string;
76 body: string;
77 }
78
79 const EmailTemplateForm: React.FC = () => {
80 const { id, isCreateMode, coreTemplateSlug, coreReadOnlyParam } =
81 useMemo(() => {
82 const params = new URLSearchParams(window.location.search);
83 const templateId = params.get("id");
84 const action = params.get("action");
85 const core = params.get("core_template") || "";
86 return {
87 id: templateId,
88 isCreateMode: action === "create",
89 coreTemplateSlug: core,
90 coreReadOnlyParam: params.get("readonly") === "1",
91 };
92 }, []);
93
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
105 const coreDef = isCoreSettingsEdit
106 ? getCoreTemplateDefinition(coreTemplateSlug)
107 : undefined;
108
109 const catalogEntryForCore = useMemo(
110 () =>
111 coreTemplateSlug
112 ? getCatalogEntryByTemplateKey(coreTemplateSlug)
113 : undefined,
114 [coreTemplateSlug],
115 );
116
117 /** URL ?readonly=1 — no edits at all from this screen */
118 const isCoreStrictViewOnly = isCoreSettingsEdit && Boolean(coreReadOnlyParam);
119
120 /** Module required but inactive: subject/body/save locked; on/off can still be changed (site setting). */
121 const isCoreModuleBodyLocked = useMemo(() => {
122 if (!isCoreSettingsEdit || isCoreStrictViewOnly) {
123 return false;
124 }
125 const req = catalogEntryForCore?.requiresModule;
126 if (!req) {
127 return false;
128 }
129 return !isProPluginActive() || !isModuleActive(req);
130 }, [isCoreSettingsEdit, isCoreStrictViewOnly, catalogEntryForCore]);
131
132 /** Subject/body (and merge-tag insert) read-only; strict URL also locks status. */
133 const isCoreBodyReadOnly = isCoreStrictViewOnly || isCoreModuleBodyLocked;
134
135 const isCoreViewMode = isCoreStrictViewOnly || isCoreModuleBodyLocked;
136
137 const goBack = () => {
138 window.location.href = `admin.php?page=yatra&subpage=email-automation&tab=templates`;
139 };
140 const queryClient = useQueryClient();
141 const { showToast } = useToast();
142
143 const [formData, setFormData] = useState({
144 name: "",
145 description: "",
146 from_name: "",
147 from_email: "",
148 to_email: "",
149 reply_to: "",
150 bcc: "",
151 cc: "",
152 category: "booking",
153 subject: "",
154 body: "",
155 event_key: "",
156 is_active: true,
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);
163 const [showPreview, setShowPreview] = useState(false);
164 const [previewData, setPreviewData] = useState<PreviewData | null>(null);
165 const [testEmail, setTestEmail] = useState("");
166 const [copiedVar, setCopiedVar] = useState<string | null>(null);
167 const [showEventDropdown, setShowEventDropdown] = useState(false);
168 const [showAiModal, setShowAiModal] = useState(false);
169
170 useEffect(() => {
171 if (isCreateMode && !isEmailAutomationModuleEnabled()) {
172 window.location.href =
173 "admin.php?page=yatra&subpage=email-automation&tab=templates";
174 }
175 }, [isCreateMode]);
176
177 useEffect(() => {
178 const params = new URLSearchParams(window.location.search);
179 if (params.get("action") !== "edit") return;
180 const ct = params.get("core_template");
181 if (ct && !isCoreTemplateSlug(ct)) {
182 window.location.href =
183 "admin.php?page=yatra&subpage=email-automation&tab=templates";
184 }
185 }, []);
186
187 const { data: settings, isLoading: settingsLoading } = useQuery({
188 queryKey: ["settings"],
189 queryFn: () => fetchSettings(),
190 enabled: isCoreSettingsEdit,
191 });
192
193 const { data: templateData, isLoading } = useQuery({
194 queryKey: ["email-template", id],
195 queryFn: () => fetchEmailTemplate(id as string),
196 enabled: Boolean(id) && !isCoreSettingsEdit,
197 });
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).
264 const { data: variablesData } = useQuery({
265 queryKey: ["email-variables", formData.event_key || ""],
266 queryFn: () => fetchEmailTemplateVariables(formData.event_key || ""),
267 enabled: !isCoreSettingsEdit && isEmailAutomationModuleEnabled(),
268 });
269
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]);
308
309 useEffect(() => {
310 if (!templateData || typeof templateData !== "object") return;
311 const t = templateData as Record<string, unknown>;
312 setFormData({
313 name: String(t.name ?? ""),
314 description: String(t.description ?? ""),
315 from_name: String(t.from_name ?? ""),
316 from_email: String(t.from_email ?? ""),
317 to_email: String(t.to_email ?? ""),
318 reply_to: String(t.reply_to ?? ""),
319 bcc: String(t.bcc ?? ""),
320 cc: String(t.cc ?? ""),
321 category: String(t.category ?? "booking"),
322 subject: String(t.subject ?? ""),
323 body: String(t.body ?? ""),
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 || ""),
327 is_active: Boolean(t.is_active ?? true),
328 });
329 setTargets(
330 t.overrides && t.targets && typeof t.targets === "object"
331 ? (t.targets as TripTargets)
332 : {},
333 );
334 }, [templateData]);
335
336 useEffect(() => {
337 if (!isCoreSettingsEdit || !settings || !coreDef) return;
338 const flag = coreDef.settingsFlag;
339 const subj = coreDef.settingsSubject;
340 const bodyKey = coreDef.settingsBody;
341 if (!flag || !subj || !bodyKey) return;
342 const s = settings as Record<string, unknown>;
343 setFormData({
344 name: coreDef.name,
345 description: coreDef.description,
346 from_name: "",
347 from_email: "",
348 to_email: coreDef.to_email || "",
349 reply_to: "",
350 bcc: String(s[coreAddressKey(subj, "bcc") ?? ""] ?? ""),
351 cc: String(s[coreAddressKey(subj, "cc") ?? ""] ?? ""),
352 category: String(coreDef.category ?? "booking"),
353 subject: String(s[subj] ?? ""),
354 body: String(s[bodyKey] ?? ""),
355 event_key: coreDef.event_key,
356 is_active: Boolean(s[flag]),
357 });
358 }, [isCoreSettingsEdit, settings, coreDef]);
359
360 const saveMutation = useMutation({
361 mutationFn: async (data: typeof formData) => {
362 if (isCreateMode) {
363 const response = await createEmailTemplate(data);
364 const r = response as {
365 success?: boolean;
366 message?: string;
367 data?: { id?: string | number };
368 };
369 if (r && r.success === false) {
370 throw new Error(r.message || __("Failed to create template"));
371 }
372 return response;
373 }
374 return await updateEmailTemplate(id as string, data);
375 },
376 onSuccess: (response) => {
377 queryClient.invalidateQueries({ queryKey: ["email-templates"] });
378 if (isCreateMode) {
379 showToast(__("Template created successfully"), "success");
380 // Redirect to edit page after creation - response.data contains the template
381 const newId = (response as { data?: { id?: string | number } })?.data
382 ?.id;
383 if (newId) {
384 window.location.href = `admin.php?page=yatra&subpage=email-automation&tab=template&action=edit&id=${newId}`;
385 } else {
386 // Fallback to list page
387 window.location.href =
388 "admin.php?page=yatra&subpage=email-automation&tab=templates";
389 }
390 } else {
391 queryClient.invalidateQueries({ queryKey: ["email-template", id] });
392 showToast(__("Template saved successfully"), "success");
393 }
394 },
395 onError: (error: any) => {
396 const message =
397 error?.message ||
398 (isCreateMode
399 ? __("Failed to create template")
400 : __("Failed to save template"));
401 showToast(message, "error");
402 },
403 });
404
405 const saveCoreMutation = useMutation({
406 mutationFn: async () => {
407 if (isCoreStrictViewOnly) {
408 throw new Error(__("This template is view-only.", "yatra"));
409 }
410 if (
411 !coreDef?.settingsFlag ||
412 !coreDef.settingsSubject ||
413 !coreDef.settingsBody
414 ) {
415 throw new Error(__("Invalid template", "yatra"));
416 }
417 if (!settings) {
418 throw new Error(__("Settings not loaded", "yatra"));
419 }
420 const base = { ...(settings as Record<string, unknown>) };
421 if (isCoreModuleBodyLocked) {
422 base[coreDef.settingsFlag] = formData.is_active;
423 await saveSettings(base);
424 return base;
425 }
426 base[coreDef.settingsSubject] = formData.subject;
427 base[coreDef.settingsBody] = formData.body;
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;
434 await saveSettings(base);
435 return base;
436 },
437 onSuccess: (saved) => {
438 queryClient.setQueryData(["settings"], saved);
439 showToast(__("Template saved", "yatra"), "success");
440 },
441 onError: (error: unknown) => {
442 const message =
443 error instanceof Error ? error.message : __("Failed to save", "yatra");
444 showToast(message, "error");
445 },
446 });
447
448 const previewMutation = useMutation({
449 mutationFn: async () => {
450 return previewEmailTemplate(id as string, previewTrip);
451 },
452 onSuccess: (data) => {
453 setPreviewData(data);
454 setShowPreview(true);
455 },
456 onError: () => {
457 showToast(__("Failed to load preview"), "error");
458 },
459 });
460
461 const corePreviewMutation = useMutation({
462 mutationFn: async () => {
463 if (!coreTemplateSlug) {
464 throw new Error(__("Invalid template", "yatra"));
465 }
466 return previewCoreEmailTemplate({
467 template_key: coreTemplateSlug,
468 subject: formData.subject,
469 body: formData.body,
470 });
471 },
472 onSuccess: (data) => {
473 setPreviewData(data);
474 setShowPreview(true);
475 },
476 onError: (error: unknown) => {
477 const message =
478 error instanceof Error ? error.message : __("Preview failed", "yatra");
479 showToast(message, "error");
480 },
481 });
482
483 const testMutation = useMutation({
484 mutationFn: async (email: string) => {
485 return sendEmailTemplateTest(id as string, email, previewTrip);
486 },
487 onSuccess: () => {
488 showToast(__("Test email sent successfully"), "success");
489 setTestEmail("");
490 },
491 onError: () => {
492 showToast(__("Failed to send test email"), "error");
493 },
494 });
495
496 const handleSubmit = (e?: React.FormEvent | React.MouseEvent) => {
497 if (e) e.preventDefault();
498
499 if (isCoreSettingsEdit) {
500 if (isCoreStrictViewOnly) {
501 return;
502 }
503 saveCoreMutation.mutate();
504 return;
505 }
506
507 if (!formData.name.trim()) {
508 showToast(__("Template name is required"), "error");
509 return;
510 }
511 if (!formData.subject.trim()) {
512 showToast(__("Subject line is required"), "error");
513 return;
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 }
525
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 );
536 };
537
538 const copyVariable = (variable: string) => {
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 }
576 };
577
578 const insertVariable = (variable: string) => {
579 if (isCoreBodyReadOnly) {
580 return;
581 }
582 const textarea = document.getElementById(
583 "email-body",
584 ) as HTMLTextAreaElement;
585 if (textarea) {
586 const start = textarea.selectionStart;
587 const end = textarea.selectionEnd;
588 const text = formData.body;
589 const newText =
590 text.substring(0, start) + `{{${variable}}}` + text.substring(end);
591 setFormData({ ...formData, body: newText });
592 }
593 };
594
595 const showEditSkeleton =
596 !isCreateMode && (isCoreSettingsEdit ? settingsLoading : isLoading);
597
598 if (showEditSkeleton) {
599 return (
600 <div className="space-y-6">
601 {/* Header Skeleton */}
602 <div className="flex items-center justify-between animate-pulse">
603 <div className="flex items-center gap-4">
604 <div className="h-10 w-20 bg-gray-200 dark:bg-gray-700 rounded"></div>
605 <div>
606 <div className="h-8 w-56 bg-gray-200 dark:bg-gray-700 rounded mb-2"></div>
607 <div className="h-4 w-40 bg-gray-200 dark:bg-gray-700 rounded"></div>
608 </div>
609 </div>
610 <div className="flex items-center gap-3">
611 <div className="h-10 w-24 bg-gray-200 dark:bg-gray-700 rounded"></div>
612 <div className="h-10 w-32 bg-gray-200 dark:bg-gray-700 rounded"></div>
613 </div>
614 </div>
615
616 <div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
617 {/* Main Form Skeleton */}
618 <div className="lg:col-span-2 space-y-6">
619 {/* Template Details Card */}
620 <Card>
621 <CardHeader>
622 <div className="flex items-center justify-between">
623 <div className="h-6 w-36 bg-gray-200 dark:bg-gray-700 rounded"></div>
624 <div className="h-6 w-20 bg-gray-200 dark:bg-gray-700 rounded"></div>
625 </div>
626 </CardHeader>
627 <CardContent className="space-y-4">
628 <div className="space-y-2">
629 <div className="h-4 w-28 bg-gray-200 dark:bg-gray-700 rounded"></div>
630 <div className="h-10 w-full bg-gray-200 dark:bg-gray-700 rounded"></div>
631 </div>
632 <div className="space-y-2">
633 <div className="h-4 w-24 bg-gray-200 dark:bg-gray-700 rounded"></div>
634 <div className="h-10 w-full bg-gray-200 dark:bg-gray-700 rounded"></div>
635 </div>
636 <div className="space-y-2">
637 <div className="h-4 w-20 bg-gray-200 dark:bg-gray-700 rounded"></div>
638 <div className="h-10 w-full bg-gray-200 dark:bg-gray-700 rounded"></div>
639 </div>
640 <div className="space-y-2">
641 <div className="h-4 w-32 bg-gray-200 dark:bg-gray-700 rounded"></div>
642 <div className="h-64 w-full bg-gray-200 dark:bg-gray-700 rounded"></div>
643 </div>
644 </CardContent>
645 </Card>
646
647 {/* Test Email Card */}
648 <Card>
649 <CardHeader>
650 <div className="h-6 w-32 bg-gray-200 dark:bg-gray-700 rounded"></div>
651 </CardHeader>
652 <CardContent>
653 <div className="flex gap-3">
654 <div className="h-10 flex-1 bg-gray-200 dark:bg-gray-700 rounded"></div>
655 <div className="h-10 w-10 bg-gray-200 dark:bg-gray-700 rounded"></div>
656 </div>
657 </CardContent>
658 </Card>
659 </div>
660
661 {/* Sidebar Skeleton */}
662 <div className="space-y-6">
663 {/* Variables Card */}
664 <Card>
665 <CardHeader>
666 <div className="h-6 w-36 bg-gray-200 dark:bg-gray-700 rounded"></div>
667 </CardHeader>
668 <CardContent className="space-y-4">
669 <div className="h-4 w-full bg-gray-200 dark:bg-gray-700 rounded"></div>
670 {[1, 2, 3].map((group) => (
671 <div key={group} className="space-y-2">
672 <div className="h-3 w-20 bg-gray-200 dark:bg-gray-700 rounded"></div>
673 {[1, 2, 3].map((item) => (
674 <div
675 key={item}
676 className="h-12 w-full bg-gray-200 dark:bg-gray-700 rounded"
677 ></div>
678 ))}
679 </div>
680 ))}
681 </CardContent>
682 </Card>
683
684 {/* Template Info Card */}
685 <Card>
686 <CardHeader>
687 <div className="h-6 w-28 bg-gray-200 dark:bg-gray-700 rounded"></div>
688 </CardHeader>
689 <CardContent className="space-y-3">
690 {[1, 2, 3].map((i) => (
691 <div key={i} className="flex justify-between">
692 <div className="h-4 w-16 bg-gray-200 dark:bg-gray-700 rounded"></div>
693 <div className="h-4 w-24 bg-gray-200 dark:bg-gray-700 rounded"></div>
694 </div>
695 ))}
696 </CardContent>
697 </Card>
698 </div>
699 </div>
700 </div>
701 );
702 }
703
704 return (
705 <div className="space-y-6">
706 {/* Header */}
707 <div className="flex items-center justify-between">
708 <div className="flex items-center gap-4">
709 <Button variant="ghost" onClick={() => goBack()}>
710 <ArrowLeft className="w-4 h-4 mr-2" />
711 {__("Back")}
712 </Button>
713 <div>
714 <h1 className="text-2xl font-bold text-gray-900 dark:text-white flex items-center gap-2">
715 <Mail className="w-6 h-6 text-blue-500" />
716 {isCreateMode
717 ? __("Create Email Template")
718 : isCoreViewMode
719 ? __("View email template", "yatra")
720 : __("Edit Email Template")}
721 </h1>
722 <p className="text-gray-500 dark:text-gray-400 mt-1">
723 {isCreateMode
724 ? __("Create a new custom email template")
725 : isCoreSettingsEdit
726 ? isCoreViewMode
727 ? __(
728 "This template uses site settings. Enable Yatra Pro and the required module to edit subject and HTML body.",
729 "yatra",
730 )
731 : __(
732 "Core customer email (saved with site settings). From address is set under Email → Delivery.",
733 "yatra",
734 )
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 )}
761 </p>
762 </div>
763 </div>
764 <div className="flex items-center gap-3">
765 {!isCreateMode && (
766 <Button
767 variant="outline"
768 onClick={() =>
769 isCoreSettingsEdit
770 ? corePreviewMutation.mutate()
771 : previewMutation.mutate()
772 }
773 disabled={
774 previewMutation.isPending || corePreviewMutation.isPending
775 }
776 >
777 {previewMutation.isPending || corePreviewMutation.isPending ? (
778 <Loader2 className="w-4 h-4 mr-2 animate-spin" />
779 ) : (
780 <Eye className="w-4 h-4 mr-2" />
781 )}
782 {__("Preview")}
783 </Button>
784 )}
785 <Button
786 onClick={handleSubmit}
787 disabled={
788 saveMutation.isPending ||
789 saveCoreMutation.isPending ||
790 isCoreStrictViewOnly
791 }
792 className="bg-blue-600 hover:bg-blue-700 text-white"
793 >
794 {saveMutation.isPending || saveCoreMutation.isPending ? (
795 <Loader2 className="w-4 h-4 mr-2 animate-spin" />
796 ) : (
797 <Save className="w-4 h-4 mr-2" />
798 )}
799 {isCreateMode ? __("Create Template") : __("Save Template")}
800 </Button>
801 </div>
802 </div>
803
804 {isCoreBodyReadOnly && (
805 <div className="flex items-start gap-2 rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-900 dark:border-amber-800 dark:bg-amber-950/40 dark:text-amber-100">
806 <Info className="w-5 h-5 flex-shrink-0 mt-0.5" />
807 <p>
808 {isCoreStrictViewOnly
809 ? __(
810 "This link is view-only. Subject, body, and status cannot be changed from this screen.",
811 "yatra",
812 )
813 : __(
814 "Subject and HTML body are read-only until the required module is active. You can still turn this email on or off below; values reflect your site settings.",
815 "yatra",
816 )}
817 </p>
818 </div>
819 )}
820
821 <div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
822 {/* Main Form */}
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 )}
934 <Card>
935 <CardHeader>
936 <CardTitle className="flex items-center justify-between">
937 <span>{__("Template Details")}</span>
938 <div className="flex items-center gap-2">
939 <span
940 className={`text-sm ${formData.is_active ? "text-green-600 dark:text-green-400" : "text-gray-500"}`}
941 >
942 {formData.is_active ? __("Active") : __("Inactive")}
943 </span>
944 <Switch
945 checked={formData.is_active}
946 disabled={isCoreStrictViewOnly}
947 onCheckedChange={(checked) =>
948 setFormData({ ...formData, is_active: checked })
949 }
950 />
951 </div>
952 </CardTitle>
953 </CardHeader>
954 <CardContent className="space-y-4">
955 <div>
956 <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
957 {__("Template Name")}
958 </label>
959 <Input
960 value={formData.name}
961 onChange={(e) =>
962 setFormData({ ...formData, name: e.target.value })
963 }
964 placeholder={__("Enter template name")}
965 readOnly={isCoreSettingsEdit}
966 className={
967 isCoreSettingsEdit ? "bg-gray-50 dark:bg-gray-900/40" : ""
968 }
969 />
970 </div>
971
972 <div>
973 <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
974 {__("Description")}
975 </label>
976 <Input
977 value={formData.description}
978 onChange={(e) =>
979 setFormData({ ...formData, description: e.target.value })
980 }
981 placeholder={__(
982 "Brief description of when this email is sent",
983 )}
984 readOnly={isCoreSettingsEdit}
985 className={
986 isCoreSettingsEdit ? "bg-gray-50 dark:bg-gray-900/40" : ""
987 }
988 />
989 </div>
990
991 {/* Event Selector */}
992 <div>
993 <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
994 {__("Trigger Event")}
995 </label>
996 {Boolean(templateData?.is_system) ||
997 isCoreSettingsEdit ||
998 isOverride ? (
999 <div className="space-y-2">
1000 <div className="flex items-center gap-2">
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">
1002 <Zap className="w-4 h-4" />
1003 {formData.event_key || "-"}
1004 </span>
1005 <span className="text-xs text-gray-500 dark:text-gray-400">
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")}
1012 </span>
1013 </div>
1014 {formData.event_key && (
1015 <div className="p-3 bg-indigo-50 dark:bg-indigo-900/20 rounded-lg border border-indigo-200 dark:border-indigo-800">
1016 <p className="text-sm text-indigo-800 dark:text-indigo-300">
1017 {events.find((e: any) => e.key === formData.event_key)
1018 ?.description || ""}
1019 </p>
1020 </div>
1021 )}
1022 </div>
1023 ) : (
1024 <div className="relative">
1025 {/* Dropdown Trigger Button */}
1026 <button
1027 type="button"
1028 onClick={() => setShowEventDropdown(!showEventDropdown)}
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"
1030 >
1031 {formData.event_key ? (
1032 <div className="flex items-center gap-2">
1033 <Zap className="w-4 h-4 text-blue-500" />
1034 <span className="text-sm font-medium text-gray-900 dark:text-white">
1035 {events.find(
1036 (e: any) => e.key === formData.event_key,
1037 )?.name || formData.event_key}
1038 </span>
1039 </div>
1040 ) : (
1041 <div className="flex items-center gap-2">
1042 <Mail className="w-4 h-4 text-gray-400" />
1043 <span className="text-sm text-gray-500 dark:text-gray-400">
1044 {__("No Event (Sequence Only)")}
1045 </span>
1046 </div>
1047 )}
1048 <ChevronDown
1049 className={`w-4 h-4 text-gray-400 transition-transform ${showEventDropdown ? "rotate-180" : ""}`}
1050 />
1051 </button>
1052
1053 {/* Dropdown List */}
1054 {showEventDropdown && (
1055 <div className="absolute z-50 w-full mt-1 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg max-h-80 overflow-y-auto">
1056 <div className="p-2 border-b border-gray-200 dark:border-gray-700">
1057 <p className="text-xs text-gray-500 dark:text-gray-400 px-2">
1058 {__(
1059 "Select an event to trigger this email, or leave empty for sequence use",
1060 )}
1061 </p>
1062 </div>
1063 <div className="py-1">
1064 {/* None option - for templates used in sequences */}
1065 <button
1066 type="button"
1067 onClick={() => {
1068 setFormData({ ...formData, event_key: "" });
1069 setShowEventDropdown(false);
1070 }}
1071 className={`w-full px-3 py-3 text-left hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors border-b border-gray-100 dark:border-gray-700 ${
1072 !formData.event_key
1073 ? "bg-indigo-50 dark:bg-indigo-900/20"
1074 : ""
1075 }`}
1076 >
1077 <div className="flex items-start gap-2">
1078 <Mail
1079 className={`w-4 h-4 mt-0.5 flex-shrink-0 ${
1080 !formData.event_key
1081 ? "text-blue-500"
1082 : "text-gray-400"
1083 }`}
1084 />
1085 <div className="flex-1 min-w-0">
1086 <div className="flex items-center gap-2">
1087 <span
1088 className={`text-sm font-medium ${
1089 !formData.event_key
1090 ? "text-blue-800 dark:text-blue-400"
1091 : "text-gray-900 dark:text-white"
1092 }`}
1093 >
1094 {__("No Event (Sequence Only)")}
1095 </span>
1096 </div>
1097 <p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
1098 {__(
1099 "This template will only be used in email sequences",
1100 )}
1101 </p>
1102 </div>
1103 {!formData.event_key && (
1104 <Check className="w-4 h-4 text-blue-500 flex-shrink-0" />
1105 )}
1106 </div>
1107 </button>
1108 {events.map((event: any) => (
1109 <button
1110 key={event.key}
1111 type="button"
1112 onClick={() => {
1113 setFormData({
1114 ...formData,
1115 event_key: event.key,
1116 });
1117 setShowEventDropdown(false);
1118 }}
1119 className={`w-full px-3 py-3 text-left hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors border-b border-gray-100 dark:border-gray-700 last:border-b-0 ${
1120 formData.event_key === event.key
1121 ? "bg-indigo-50 dark:bg-indigo-900/20"
1122 : ""
1123 }`}
1124 >
1125 <div className="flex items-start gap-2">
1126 <Zap
1127 className={`w-4 h-4 mt-0.5 flex-shrink-0 ${
1128 formData.event_key === event.key
1129 ? "text-blue-500"
1130 : "text-gray-400"
1131 }`}
1132 />
1133 <div className="flex-1 min-w-0">
1134 <div className="flex items-center gap-2">
1135 <span
1136 className={`text-sm font-medium ${
1137 formData.event_key === event.key
1138 ? "text-blue-800 dark:text-blue-400"
1139 : "text-gray-900 dark:text-white"
1140 }`}
1141 >
1142 {event.name}
1143 </span>
1144 <code className="text-xs px-1.5 py-0.5 bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 rounded">
1145 {event.key}
1146 </code>
1147 </div>
1148 <p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5 line-clamp-2">
1149 {event.description}
1150 </p>
1151 </div>
1152 {formData.event_key === event.key && (
1153 <Check className="w-4 h-4 text-blue-500 flex-shrink-0" />
1154 )}
1155 </div>
1156 </button>
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 )}
1165 </div>
1166 </div>
1167 )}
1168
1169 {/* Click outside to close */}
1170 {showEventDropdown && (
1171 <div
1172 className="fixed inset-0 z-40"
1173 onClick={() => setShowEventDropdown(false)}
1174 />
1175 )}
1176 </div>
1177 )}
1178 <p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
1179 {__(
1180 "This email will be triggered when the selected event occurs",
1181 )}
1182 </p>
1183 </div>
1184
1185 {!isCoreSettingsEdit && (
1186 <>
1187 {/* From/Reply-To Settings */}
1188 <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
1189 <div>
1190 <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
1191 {__("From Name")}
1192 </label>
1193 <Input
1194 value={formData.from_name}
1195 onChange={(e) =>
1196 setFormData({
1197 ...formData,
1198 from_name: e.target.value,
1199 })
1200 }
1201 placeholder={__("e.g., Travel Agency Name")}
1202 />
1203 <p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
1204 {__("Leave empty to use site default")}
1205 </p>
1206 </div>
1207 <div>
1208 <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
1209 {__("From Email")}
1210 </label>
1211 <Input
1212 type="email"
1213 value={formData.from_email}
1214 onChange={(e) =>
1215 setFormData({
1216 ...formData,
1217 from_email: e.target.value,
1218 })
1219 }
1220 placeholder={__("e.g., [email protected]")}
1221 />
1222 <p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
1223 {__("Leave empty to use site default")}
1224 </p>
1225 </div>
1226 </div>
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
1262 <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
1263 <div>
1264 <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
1265 {__("To Email")}
1266 </label>
1267 <Input
1268 type="email"
1269 value={formData.to_email}
1270 onChange={(e) =>
1271 setFormData({ ...formData, to_email: e.target.value })
1272 }
1273 placeholder={__("e.g., {{customer_email}}")}
1274 />
1275 <p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
1276 {__(
1277 "Recipient email. Use variables or leave empty for default",
1278 )}
1279 </p>
1280 </div>
1281 <div>
1282 <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
1283 {__("Reply-To Email")}
1284 </label>
1285 <Input
1286 type="email"
1287 value={formData.reply_to}
1288 onChange={(e) =>
1289 setFormData({ ...formData, reply_to: e.target.value })
1290 }
1291 placeholder={__("e.g., [email protected]")}
1292 />
1293 <p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
1294 {__(
1295 "Where replies will be sent. Leave empty to use From Email",
1296 )}
1297 </p>
1298 </div>
1299 </div>
1300 </>
1301 )}
1302
1303 {isCoreSettingsEdit && (
1304 <p className="text-sm text-gray-600 dark:text-gray-400 rounded-lg border border-gray-200 dark:border-gray-600 p-3 bg-gray-50 dark:bg-gray-800/50">
1305 {__(
1306 "From / reply addresses are configured under Email → Delivery. Recipient uses the customer on the booking.",
1307 "yatra",
1308 )}
1309 </p>
1310 )}
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
1355 <div>
1356 <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
1357 {__("Subject Line")}
1358 </label>
1359 <Input
1360 value={formData.subject}
1361 onChange={(e) =>
1362 setFormData({ ...formData, subject: e.target.value })
1363 }
1364 placeholder={__("Email subject with {{variables}}")}
1365 readOnly={isCoreBodyReadOnly}
1366 className={`font-mono text-sm ${isCoreBodyReadOnly ? "bg-gray-50 dark:bg-gray-900/40" : ""}`}
1367 />
1368 <p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
1369 {__("Use {{variable}} syntax to insert dynamic content")}
1370 </p>
1371 </div>
1372
1373 <div>
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>
1397 <textarea
1398 id="email-body"
1399 value={formData.body}
1400 onChange={(e) =>
1401 setFormData({ ...formData, body: e.target.value })
1402 }
1403 rows={20}
1404 readOnly={isCoreBodyReadOnly}
1405 className={`w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg font-mono text-sm ${
1406 isCoreBodyReadOnly
1407 ? "bg-gray-50 dark:bg-gray-900/40 text-gray-900 dark:text-white"
1408 : "bg-white dark:bg-gray-800 text-gray-900 dark:text-white"
1409 }`}
1410 placeholder={__("HTML email content...")}
1411 />
1412 </div>
1413 </CardContent>
1414 </Card>
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 )}
1525 {/* Test Email - Only show in edit mode */}
1526 {!isCreateMode && !isCoreSettingsEdit && (
1527 <Card>
1528 <CardHeader>
1529 <CardTitle className="flex items-center gap-2">
1530 <Send className="w-5 h-5 text-blue-500" />
1531 {__("Send Test Email")}
1532 </CardTitle>
1533 </CardHeader>
1534 <CardContent>
1535 <div className="flex gap-3">
1536 <Input
1537 type="email"
1538 value={testEmail}
1539 onChange={(e) => setTestEmail(e.target.value)}
1540 placeholder={__("Enter email address")}
1541 className="flex-1"
1542 />
1543 <Button
1544 onClick={() => testMutation.mutate(testEmail)}
1545 disabled={!testEmail || testMutation.isPending}
1546 >
1547 {testMutation.isPending ? (
1548 <Loader2 className="w-4 h-4 animate-spin" />
1549 ) : (
1550 <Send className="w-4 h-4" />
1551 )}
1552 </Button>
1553 </div>
1554 <p className="text-xs text-gray-500 dark:text-gray-400 mt-2">
1555 {__(
1556 "Send a test email with sample data to preview how it looks",
1557 )}
1558 </p>
1559 </CardContent>
1560 </Card>
1561 )}
1562 </div>
1563
1564 {/* Sidebar - Variables */}
1565 <div className="space-y-6">
1566 <Card>
1567 <CardHeader>
1568 <CardTitle className="flex items-center gap-2">
1569 <Code className="w-5 h-5 text-blue-500" />
1570 {__("Available Variables")}
1571 </CardTitle>
1572 </CardHeader>
1573 <CardContent className="space-y-4">
1574 {!isCoreSettingsEdit && (
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 </>
1621 )}
1622
1623 {isCoreSettingsEdit && coreDef?.mergeTags ? (
1624 <div className="space-y-2">
1625 <p className="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase">
1626 {__("Merge tags", "yatra")}
1627 </p>
1628 <p className="text-xs text-gray-500 dark:text-gray-400">
1629 {__(
1630 "Click a tag to copy; paste into subject or HTML body.",
1631 "yatra",
1632 )}
1633 </p>
1634 <div className="flex flex-wrap gap-1">
1635 {coreDef.mergeTags.split(",").map((raw) => {
1636 const tag = raw.trim();
1637 const inner = tag.replace(/\{\{|\}\}/g, "").trim();
1638 if (!inner) return null;
1639 return (
1640 <button
1641 key={tag}
1642 type="button"
1643 onClick={() => copyVariable(inner)}
1644 className="rounded border border-gray-200 dark:border-gray-600 px-2 py-1 text-xs font-mono text-blue-600 dark:text-blue-400 hover:bg-gray-50 dark:hover:bg-gray-800"
1645 >
1646 {tag}
1647 </button>
1648 );
1649 })}
1650 </div>
1651 </div>
1652 ) : (
1653 variablesData &&
1654 Object.entries(variablesData).map(
1655 ([category, variables]: [string, any]) => (
1656 <div key={category}>
1657 <h4 className="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase mb-2">
1658 {category}
1659 </h4>
1660 <div className="space-y-1">
1661 {variables.map((v: any) => (
1662 <button
1663 key={v.key}
1664 type="button"
1665 onClick={() => copyVariable(v.key)}
1666 onDoubleClick={() => insertVariable(v.key)}
1667 className="w-full text-left px-2 py-1.5 rounded hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors group"
1668 >
1669 <div className="flex items-center justify-between">
1670 <code className="text-xs text-blue-600 dark:text-blue-400">
1671 {`{{${v.key}}}`}
1672 </code>
1673 {copiedVar === v.key ? (
1674 <Check className="w-3 h-3 text-green-500" />
1675 ) : (
1676 <Copy className="w-3 h-3 text-gray-400 opacity-0 group-hover:opacity-100" />
1677 )}
1678 </div>
1679 <p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
1680 {v.description}
1681 </p>
1682 </button>
1683 ))}
1684 </div>
1685 </div>
1686 ),
1687 )
1688 )}
1689 </CardContent>
1690 </Card>
1691
1692 {/* Template Info - Different content for create vs edit */}
1693 <Card>
1694 <CardHeader>
1695 <CardTitle className="flex items-center gap-2">
1696 <Info className="w-5 h-5 text-gray-500" />
1697 {isCreateMode ? __("New Template") : __("Template Info")}
1698 </CardTitle>
1699 </CardHeader>
1700 <CardContent className="space-y-3 text-sm">
1701 <div className="space-y-2">
1702 <div className="flex justify-between items-center">
1703 <span className="text-gray-500 dark:text-gray-400">
1704 {__("Event")}
1705 </span>
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">
1707 <Zap className="w-3 h-3" />
1708 {formData.event_key || "-"}
1709 </span>
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 )}
1736 {formData.event_key && (
1737 <p className="text-xs text-gray-500 dark:text-gray-400 italic">
1738 {events.find((e: any) => e.key === formData.event_key)
1739 ?.description || ""}
1740 </p>
1741 )}
1742 </div>
1743 {isCreateMode ? (
1744 <>
1745 <div className="flex justify-between">
1746 <span className="text-gray-500 dark:text-gray-400">
1747 {__("Type")}
1748 </span>
1749 <span className="text-gray-900 dark:text-white">
1750 {__("Custom")}
1751 </span>
1752 </div>
1753 <div className="p-3 bg-blue-50 dark:bg-blue-900/20 rounded-lg border border-blue-200 dark:border-blue-800">
1754 <p className="text-xs text-blue-700 dark:text-blue-300">
1755 {__(
1756 "Custom templates can be edited and deleted. Select an event to trigger this email automatically.",
1757 )}
1758 </p>
1759 </div>
1760 </>
1761 ) : (
1762 <>
1763 <div className="flex justify-between">
1764 <span className="text-gray-500 dark:text-gray-400">
1765 {__("Key")}
1766 </span>
1767 <code className="text-xs bg-gray-100 dark:bg-gray-800 px-2 py-0.5 rounded">
1768 {isCoreSettingsEdit
1769 ? coreDef?.template_key
1770 : String(templateData?.template_key ?? "")}
1771 </code>
1772 </div>
1773 <div className="flex justify-between">
1774 <span className="text-gray-500 dark:text-gray-400">
1775 {__("Category")}
1776 </span>
1777 <Badge className="bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400">
1778 {isCoreSettingsEdit
1779 ? coreDef?.category
1780 : String(templateData?.category ?? "")}
1781 </Badge>
1782 </div>
1783 <div className="flex justify-between">
1784 <span className="text-gray-500 dark:text-gray-400">
1785 {__("Recipient")}
1786 </span>
1787 <span
1788 className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${
1789 (isCoreSettingsEdit
1790 ? coreDef?.recipient_type
1791 : String(templateData?.recipient_type ?? "")) ===
1792 "admin"
1793 ? "bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400"
1794 : "bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400"
1795 }`}
1796 >
1797 {(isCoreSettingsEdit
1798 ? coreDef?.recipient_type
1799 : String(templateData?.recipient_type ?? "")) ===
1800 "admin"
1801 ? __("Admin")
1802 : __("Customer")}
1803 </span>
1804 </div>
1805 <div className="flex justify-between">
1806 <span className="text-gray-500 dark:text-gray-400">
1807 {__("Type")}
1808 </span>
1809 <span className="text-gray-900 dark:text-white">
1810 {isCoreSettingsEdit || Boolean(templateData?.is_system)
1811 ? __("System")
1812 : __("Custom")}
1813 </span>
1814 </div>
1815 </>
1816 )}
1817 </CardContent>
1818 </Card>
1819 </div>
1820 </div>
1821
1822 <EmailPreviewModal
1823 open={showPreview && Boolean(previewData)}
1824 onClose={() => {
1825 setShowPreview(false);
1826 setPreviewData(null);
1827 }}
1828 subject={previewData?.subject ?? ""}
1829 body={previewData?.body ?? ""}
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 )}
1890 </div>
1891 );
1892 };
1893
1894 export default EmailTemplateForm;
1895