PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.9
Yatra – Travel Booking & Tour Operator Software v3.0.9
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.9, at resources/js/pages/EmailTemplateForm.tsx

1,352 lines 54.9 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 { __ } from "../lib/i18n";
4 import { useToast } from "../components/ui/toast";
5 import {
6 Card,
7 CardContent,
8 CardHeader,
9 CardTitle,
10 } from "../components/ui/card";
11 import { Button } from "../components/ui/button";
12 import { Input } from "../components/ui/input";
13 import { Badge } from "../components/ui/badge";
14 import {
15 Mail,
16 ArrowLeft,
17 Save,
18 Eye,
19 Send,
20 Loader2,
21 Code,
22 Copy,
23 Check,
24 Info,
25 Zap,
26 ChevronDown,
27 Sparkles,
28 } from "lucide-react";
29 import { AiEmailTemplateModal } from "../components/ai/AiEmailTemplateModal";
30 import { isAiEligible, isAiModuleEnabled } from "../lib/ai-availability";
31 import { Switch } from "../components/ui/switch";
32 import {
33 getCatalogEntryByTemplateKey,
34 getCoreTemplateDefinition,
35 isCoreTemplateSlug,
36 } from "../lib/email-templates-catalog";
37 import {
38 isEmailAutomationModuleEnabled,
39 isModuleActive,
40 isProPluginActive,
41 } from "../lib/plugin-utils";
42 import {
43 fetchSettings,
44 previewCoreEmailTemplate,
45 saveSettings,
46 } from "../api/settings-api";
47 import { EmailPreviewModal } from "../components/email/EmailPreviewModal";
48 import {
49 createEmailTemplate,
50 fetchEmailTemplate,
51 fetchEmailTemplateEvents,
52 fetchEmailTemplateVariables,
53 previewEmailTemplate,
54 sendEmailTemplateTest,
55 updateEmailTemplate,
56 } from "../api/email-automation-api";
57
58 interface PreviewData {
59 subject: string;
60 body: string;
61 }
62
63 const EmailTemplateForm: React.FC = () => {
64 const { id, isCreateMode, coreTemplateSlug, coreReadOnlyParam } =
65 useMemo(() => {
66 const params = new URLSearchParams(window.location.search);
67 const templateId = params.get("id");
68 const action = params.get("action");
69 const core = params.get("core_template") || "";
70 return {
71 id: templateId,
72 isCreateMode: action === "create",
73 coreTemplateSlug: core,
74 coreReadOnlyParam: params.get("readonly") === "1",
75 };
76 }, []);
77
78 const isCoreSettingsEdit = isCoreTemplateSlug(coreTemplateSlug);
79 const coreDef = isCoreSettingsEdit
80 ? getCoreTemplateDefinition(coreTemplateSlug)
81 : undefined;
82
83 const catalogEntryForCore = useMemo(
84 () =>
85 coreTemplateSlug
86 ? getCatalogEntryByTemplateKey(coreTemplateSlug)
87 : undefined,
88 [coreTemplateSlug],
89 );
90
91 /** URL ?readonly=1 — no edits at all from this screen */
92 const isCoreStrictViewOnly = isCoreSettingsEdit && Boolean(coreReadOnlyParam);
93
94 /** Module required but inactive: subject/body/save locked; on/off can still be changed (site setting). */
95 const isCoreModuleBodyLocked = useMemo(() => {
96 if (!isCoreSettingsEdit || isCoreStrictViewOnly) {
97 return false;
98 }
99 const req = catalogEntryForCore?.requiresModule;
100 if (!req) {
101 return false;
102 }
103 return !isProPluginActive() || !isModuleActive(req);
104 }, [isCoreSettingsEdit, isCoreStrictViewOnly, catalogEntryForCore]);
105
106 /** Subject/body (and merge-tag insert) read-only; strict URL also locks status. */
107 const isCoreBodyReadOnly = isCoreStrictViewOnly || isCoreModuleBodyLocked;
108
109 const isCoreViewMode = isCoreStrictViewOnly || isCoreModuleBodyLocked;
110
111 const goBack = () => {
112 window.location.href = `admin.php?page=yatra&subpage=email-automation&tab=templates`;
113 };
114 const queryClient = useQueryClient();
115 const { showToast } = useToast();
116
117 const [formData, setFormData] = useState({
118 name: "",
119 description: "",
120 from_name: "",
121 from_email: "",
122 to_email: "",
123 reply_to: "",
124 subject: "",
125 body: "",
126 event_key: "",
127 is_active: true,
128 });
129 const [showPreview, setShowPreview] = useState(false);
130 const [previewData, setPreviewData] = useState<PreviewData | null>(null);
131 const [testEmail, setTestEmail] = useState("");
132 const [copiedVar, setCopiedVar] = useState<string | null>(null);
133 const [showEventDropdown, setShowEventDropdown] = useState(false);
134 const [showAiModal, setShowAiModal] = useState(false);
135
136 useEffect(() => {
137 if (isCreateMode && !isEmailAutomationModuleEnabled()) {
138 window.location.href =
139 "admin.php?page=yatra&subpage=email-automation&tab=templates";
140 }
141 }, [isCreateMode]);
142
143 useEffect(() => {
144 const params = new URLSearchParams(window.location.search);
145 if (params.get("action") !== "edit") return;
146 const ct = params.get("core_template");
147 if (ct && !isCoreTemplateSlug(ct)) {
148 window.location.href =
149 "admin.php?page=yatra&subpage=email-automation&tab=templates";
150 }
151 }, []);
152
153 const { data: settings, isLoading: settingsLoading } = useQuery({
154 queryKey: ["settings"],
155 queryFn: () => fetchSettings(),
156 enabled: isCoreSettingsEdit,
157 });
158
159 const { data: templateData, isLoading } = useQuery({
160 queryKey: ["email-template", id],
161 queryFn: () => fetchEmailTemplate(id as string),
162 enabled: Boolean(id) && !isCoreSettingsEdit,
163 });
164
165 // Event-scoped variables: the sidebar re-fetches whenever the
166 // operator switches the trigger event so the "Available
167 // Variables" panel reflects only tags that will actually
168 // resolve at send-time for the chosen event. Empty event_key
169 // returns the full union (for the create-template starter
170 // state before they've picked an event).
171 const { data: variablesData } = useQuery({
172 queryKey: ["email-variables", formData.event_key || ""],
173 queryFn: () => fetchEmailTemplateVariables(formData.event_key || ""),
174 enabled: !isCoreSettingsEdit && isEmailAutomationModuleEnabled(),
175 });
176
177 // Trigger events. Fetch them live rather than trusting only the one-time
178 // window.yatraAdmin.emailEvents localize snapshot: that snapshot is written
179 // by the Pro module at PHP render time, but the module-enabled flag is
180 // refreshed client-side (useModules), so the two can diverge — the form
181 // opens (flag says enabled) while emailEvents is still empty, leaving the
182 // dropdown with "No Event to select". The live fetch corrects that; the
183 // localize snapshot is used as an instant fallback while it loads.
184 const { data: fetchedEvents } = useQuery({
185 queryKey: ["email-template-events"],
186 queryFn: fetchEmailTemplateEvents,
187 enabled: !isCoreSettingsEdit && isEmailAutomationModuleEnabled(),
188 });
189 // Prefer the live fetch, but only when it actually returned events — an empty
190 // array (transient error / not-yet-loaded edge) must NOT wipe out the working
191 // localize snapshot, or the "Trigger Event" dropdown ends up empty. `??` alone
192 // would keep an empty [] because [] is not nullish.
193 const events =
194 Array.isArray(fetchedEvents) && fetchedEvents.length > 0
195 ? fetchedEvents
196 : (window as any).yatraAdmin?.emailEvents || [];
197
198 useEffect(() => {
199 if (!templateData || typeof templateData !== "object") return;
200 const t = templateData as Record<string, unknown>;
201 setFormData({
202 name: String(t.name ?? ""),
203 description: String(t.description ?? ""),
204 from_name: String(t.from_name ?? ""),
205 from_email: String(t.from_email ?? ""),
206 to_email: String(t.to_email ?? ""),
207 reply_to: String(t.reply_to ?? ""),
208 subject: String(t.subject ?? ""),
209 body: String(t.body ?? ""),
210 event_key: String(t.event_key ?? ""),
211 is_active: Boolean(t.is_active ?? true),
212 });
213 }, [templateData]);
214
215 useEffect(() => {
216 if (!isCoreSettingsEdit || !settings || !coreDef) return;
217 const flag = coreDef.settingsFlag;
218 const subj = coreDef.settingsSubject;
219 const bodyKey = coreDef.settingsBody;
220 if (!flag || !subj || !bodyKey) return;
221 const s = settings as Record<string, unknown>;
222 setFormData({
223 name: coreDef.name,
224 description: coreDef.description,
225 from_name: "",
226 from_email: "",
227 to_email: coreDef.to_email || "",
228 reply_to: "",
229 subject: String(s[subj] ?? ""),
230 body: String(s[bodyKey] ?? ""),
231 event_key: coreDef.event_key,
232 is_active: Boolean(s[flag]),
233 });
234 }, [isCoreSettingsEdit, settings, coreDef]);
235
236 const saveMutation = useMutation({
237 mutationFn: async (data: typeof formData) => {
238 if (isCreateMode) {
239 const response = await createEmailTemplate(data);
240 const r = response as {
241 success?: boolean;
242 message?: string;
243 data?: { id?: string | number };
244 };
245 if (r && r.success === false) {
246 throw new Error(r.message || __("Failed to create template"));
247 }
248 return response;
249 }
250 return await updateEmailTemplate(id as string, data);
251 },
252 onSuccess: (response) => {
253 queryClient.invalidateQueries({ queryKey: ["email-templates"] });
254 if (isCreateMode) {
255 showToast(__("Template created successfully"), "success");
256 // Redirect to edit page after creation - response.data contains the template
257 const newId = (response as { data?: { id?: string | number } })?.data
258 ?.id;
259 if (newId) {
260 window.location.href = `admin.php?page=yatra&subpage=email-automation&tab=template&action=edit&id=${newId}`;
261 } else {
262 // Fallback to list page
263 window.location.href =
264 "admin.php?page=yatra&subpage=email-automation&tab=templates";
265 }
266 } else {
267 queryClient.invalidateQueries({ queryKey: ["email-template", id] });
268 showToast(__("Template saved successfully"), "success");
269 }
270 },
271 onError: (error: any) => {
272 const message =
273 error?.message ||
274 (isCreateMode
275 ? __("Failed to create template")
276 : __("Failed to save template"));
277 showToast(message, "error");
278 },
279 });
280
281 const saveCoreMutation = useMutation({
282 mutationFn: async () => {
283 if (isCoreStrictViewOnly) {
284 throw new Error(__("This template is view-only.", "yatra"));
285 }
286 if (
287 !coreDef?.settingsFlag ||
288 !coreDef.settingsSubject ||
289 !coreDef.settingsBody
290 ) {
291 throw new Error(__("Invalid template", "yatra"));
292 }
293 if (!settings) {
294 throw new Error(__("Settings not loaded", "yatra"));
295 }
296 const base = { ...(settings as Record<string, unknown>) };
297 if (isCoreModuleBodyLocked) {
298 base[coreDef.settingsFlag] = formData.is_active;
299 await saveSettings(base);
300 return base;
301 }
302 base[coreDef.settingsSubject] = formData.subject;
303 base[coreDef.settingsBody] = formData.body;
304 base[coreDef.settingsFlag] = formData.is_active;
305 await saveSettings(base);
306 return base;
307 },
308 onSuccess: (saved) => {
309 queryClient.setQueryData(["settings"], saved);
310 showToast(__("Template saved", "yatra"), "success");
311 },
312 onError: (error: unknown) => {
313 const message =
314 error instanceof Error ? error.message : __("Failed to save", "yatra");
315 showToast(message, "error");
316 },
317 });
318
319 const previewMutation = useMutation({
320 mutationFn: async () => {
321 return previewEmailTemplate(id as string);
322 },
323 onSuccess: (data) => {
324 setPreviewData(data);
325 setShowPreview(true);
326 },
327 onError: () => {
328 showToast(__("Failed to load preview"), "error");
329 },
330 });
331
332 const corePreviewMutation = useMutation({
333 mutationFn: async () => {
334 if (!coreTemplateSlug) {
335 throw new Error(__("Invalid template", "yatra"));
336 }
337 return previewCoreEmailTemplate({
338 template_key: coreTemplateSlug,
339 subject: formData.subject,
340 body: formData.body,
341 });
342 },
343 onSuccess: (data) => {
344 setPreviewData(data);
345 setShowPreview(true);
346 },
347 onError: (error: unknown) => {
348 const message =
349 error instanceof Error ? error.message : __("Preview failed", "yatra");
350 showToast(message, "error");
351 },
352 });
353
354 const testMutation = useMutation({
355 mutationFn: async (email: string) => {
356 return sendEmailTemplateTest(id as string, email);
357 },
358 onSuccess: () => {
359 showToast(__("Test email sent successfully"), "success");
360 setTestEmail("");
361 },
362 onError: () => {
363 showToast(__("Failed to send test email"), "error");
364 },
365 });
366
367 const handleSubmit = (e?: React.FormEvent | React.MouseEvent) => {
368 if (e) e.preventDefault();
369
370 if (isCoreSettingsEdit) {
371 if (isCoreStrictViewOnly) {
372 return;
373 }
374 saveCoreMutation.mutate();
375 return;
376 }
377
378 if (!formData.name.trim()) {
379 showToast(__("Template name is required"), "error");
380 return;
381 }
382 if (!formData.subject.trim()) {
383 showToast(__("Subject line is required"), "error");
384 return;
385 }
386
387 saveMutation.mutate(formData);
388 };
389
390 const copyVariable = (variable: string) => {
391 navigator.clipboard.writeText(`{{${variable}}}`);
392 setCopiedVar(variable);
393 setTimeout(() => setCopiedVar(null), 2000);
394 };
395
396 const insertVariable = (variable: string) => {
397 if (isCoreBodyReadOnly) {
398 return;
399 }
400 const textarea = document.getElementById(
401 "email-body",
402 ) as HTMLTextAreaElement;
403 if (textarea) {
404 const start = textarea.selectionStart;
405 const end = textarea.selectionEnd;
406 const text = formData.body;
407 const newText =
408 text.substring(0, start) + `{{${variable}}}` + text.substring(end);
409 setFormData({ ...formData, body: newText });
410 }
411 };
412
413 const showEditSkeleton =
414 !isCreateMode && (isCoreSettingsEdit ? settingsLoading : isLoading);
415
416 if (showEditSkeleton) {
417 return (
418 <div className="space-y-6">
419 {/* Header Skeleton */}
420 <div className="flex items-center justify-between animate-pulse">
421 <div className="flex items-center gap-4">
422 <div className="h-10 w-20 bg-gray-200 dark:bg-gray-700 rounded"></div>
423 <div>
424 <div className="h-8 w-56 bg-gray-200 dark:bg-gray-700 rounded mb-2"></div>
425 <div className="h-4 w-40 bg-gray-200 dark:bg-gray-700 rounded"></div>
426 </div>
427 </div>
428 <div className="flex items-center gap-3">
429 <div className="h-10 w-24 bg-gray-200 dark:bg-gray-700 rounded"></div>
430 <div className="h-10 w-32 bg-gray-200 dark:bg-gray-700 rounded"></div>
431 </div>
432 </div>
433
434 <div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
435 {/* Main Form Skeleton */}
436 <div className="lg:col-span-2 space-y-6">
437 {/* Template Details Card */}
438 <Card>
439 <CardHeader>
440 <div className="flex items-center justify-between">
441 <div className="h-6 w-36 bg-gray-200 dark:bg-gray-700 rounded"></div>
442 <div className="h-6 w-20 bg-gray-200 dark:bg-gray-700 rounded"></div>
443 </div>
444 </CardHeader>
445 <CardContent className="space-y-4">
446 <div className="space-y-2">
447 <div className="h-4 w-28 bg-gray-200 dark:bg-gray-700 rounded"></div>
448 <div className="h-10 w-full bg-gray-200 dark:bg-gray-700 rounded"></div>
449 </div>
450 <div className="space-y-2">
451 <div className="h-4 w-24 bg-gray-200 dark:bg-gray-700 rounded"></div>
452 <div className="h-10 w-full bg-gray-200 dark:bg-gray-700 rounded"></div>
453 </div>
454 <div className="space-y-2">
455 <div className="h-4 w-20 bg-gray-200 dark:bg-gray-700 rounded"></div>
456 <div className="h-10 w-full bg-gray-200 dark:bg-gray-700 rounded"></div>
457 </div>
458 <div className="space-y-2">
459 <div className="h-4 w-32 bg-gray-200 dark:bg-gray-700 rounded"></div>
460 <div className="h-64 w-full bg-gray-200 dark:bg-gray-700 rounded"></div>
461 </div>
462 </CardContent>
463 </Card>
464
465 {/* Test Email Card */}
466 <Card>
467 <CardHeader>
468 <div className="h-6 w-32 bg-gray-200 dark:bg-gray-700 rounded"></div>
469 </CardHeader>
470 <CardContent>
471 <div className="flex gap-3">
472 <div className="h-10 flex-1 bg-gray-200 dark:bg-gray-700 rounded"></div>
473 <div className="h-10 w-10 bg-gray-200 dark:bg-gray-700 rounded"></div>
474 </div>
475 </CardContent>
476 </Card>
477 </div>
478
479 {/* Sidebar Skeleton */}
480 <div className="space-y-6">
481 {/* Variables Card */}
482 <Card>
483 <CardHeader>
484 <div className="h-6 w-36 bg-gray-200 dark:bg-gray-700 rounded"></div>
485 </CardHeader>
486 <CardContent className="space-y-4">
487 <div className="h-4 w-full bg-gray-200 dark:bg-gray-700 rounded"></div>
488 {[1, 2, 3].map((group) => (
489 <div key={group} className="space-y-2">
490 <div className="h-3 w-20 bg-gray-200 dark:bg-gray-700 rounded"></div>
491 {[1, 2, 3].map((item) => (
492 <div
493 key={item}
494 className="h-12 w-full bg-gray-200 dark:bg-gray-700 rounded"
495 ></div>
496 ))}
497 </div>
498 ))}
499 </CardContent>
500 </Card>
501
502 {/* Template Info Card */}
503 <Card>
504 <CardHeader>
505 <div className="h-6 w-28 bg-gray-200 dark:bg-gray-700 rounded"></div>
506 </CardHeader>
507 <CardContent className="space-y-3">
508 {[1, 2, 3].map((i) => (
509 <div key={i} className="flex justify-between">
510 <div className="h-4 w-16 bg-gray-200 dark:bg-gray-700 rounded"></div>
511 <div className="h-4 w-24 bg-gray-200 dark:bg-gray-700 rounded"></div>
512 </div>
513 ))}
514 </CardContent>
515 </Card>
516 </div>
517 </div>
518 </div>
519 );
520 }
521
522 return (
523 <div className="space-y-6">
524 {/* Header */}
525 <div className="flex items-center justify-between">
526 <div className="flex items-center gap-4">
527 <Button variant="ghost" onClick={() => goBack()}>
528 <ArrowLeft className="w-4 h-4 mr-2" />
529 {__("Back")}
530 </Button>
531 <div>
532 <h1 className="text-2xl font-bold text-gray-900 dark:text-white flex items-center gap-2">
533 <Mail className="w-6 h-6 text-blue-500" />
534 {isCreateMode
535 ? __("Create Email Template")
536 : isCoreViewMode
537 ? __("View email template", "yatra")
538 : __("Edit Email Template")}
539 </h1>
540 <p className="text-gray-500 dark:text-gray-400 mt-1">
541 {isCreateMode
542 ? __("Create a new custom email template")
543 : isCoreSettingsEdit
544 ? isCoreViewMode
545 ? __(
546 "This template uses site settings. Enable Yatra Pro and the required module to edit subject and HTML body.",
547 "yatra",
548 )
549 : __(
550 "Core customer email (saved with site settings). From address is set under Email → Delivery.",
551 "yatra",
552 )
553 : String(templateData?.name ?? "")}
554 </p>
555 </div>
556 </div>
557 <div className="flex items-center gap-3">
558 {!isCreateMode && (
559 <Button
560 variant="outline"
561 onClick={() =>
562 isCoreSettingsEdit
563 ? corePreviewMutation.mutate()
564 : previewMutation.mutate()
565 }
566 disabled={
567 previewMutation.isPending || corePreviewMutation.isPending
568 }
569 >
570 {previewMutation.isPending || corePreviewMutation.isPending ? (
571 <Loader2 className="w-4 h-4 mr-2 animate-spin" />
572 ) : (
573 <Eye className="w-4 h-4 mr-2" />
574 )}
575 {__("Preview")}
576 </Button>
577 )}
578 <Button
579 onClick={handleSubmit}
580 disabled={
581 saveMutation.isPending ||
582 saveCoreMutation.isPending ||
583 isCoreStrictViewOnly
584 }
585 className="bg-blue-600 hover:bg-blue-700 text-white"
586 >
587 {saveMutation.isPending || saveCoreMutation.isPending ? (
588 <Loader2 className="w-4 h-4 mr-2 animate-spin" />
589 ) : (
590 <Save className="w-4 h-4 mr-2" />
591 )}
592 {isCreateMode ? __("Create Template") : __("Save Template")}
593 </Button>
594 </div>
595 </div>
596
597 {isCoreBodyReadOnly && (
598 <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">
599 <Info className="w-5 h-5 flex-shrink-0 mt-0.5" />
600 <p>
601 {isCoreStrictViewOnly
602 ? __(
603 "This link is view-only. Subject, body, and status cannot be changed from this screen.",
604 "yatra",
605 )
606 : __(
607 "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.",
608 "yatra",
609 )}
610 </p>
611 </div>
612 )}
613
614 <div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
615 {/* Main Form */}
616 <div className="lg:col-span-2 space-y-6">
617 <Card>
618 <CardHeader>
619 <CardTitle className="flex items-center justify-between">
620 <span>{__("Template Details")}</span>
621 <div className="flex items-center gap-2">
622 <span
623 className={`text-sm ${formData.is_active ? "text-green-600 dark:text-green-400" : "text-gray-500"}`}
624 >
625 {formData.is_active ? __("Active") : __("Inactive")}
626 </span>
627 <Switch
628 checked={formData.is_active}
629 disabled={isCoreStrictViewOnly}
630 onCheckedChange={(checked) =>
631 setFormData({ ...formData, is_active: checked })
632 }
633 />
634 </div>
635 </CardTitle>
636 </CardHeader>
637 <CardContent className="space-y-4">
638 <div>
639 <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
640 {__("Template Name")}
641 </label>
642 <Input
643 value={formData.name}
644 onChange={(e) =>
645 setFormData({ ...formData, name: e.target.value })
646 }
647 placeholder={__("Enter template name")}
648 readOnly={isCoreSettingsEdit}
649 className={
650 isCoreSettingsEdit ? "bg-gray-50 dark:bg-gray-900/40" : ""
651 }
652 />
653 </div>
654
655 <div>
656 <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
657 {__("Description")}
658 </label>
659 <Input
660 value={formData.description}
661 onChange={(e) =>
662 setFormData({ ...formData, description: e.target.value })
663 }
664 placeholder={__(
665 "Brief description of when this email is sent",
666 )}
667 readOnly={isCoreSettingsEdit}
668 className={
669 isCoreSettingsEdit ? "bg-gray-50 dark:bg-gray-900/40" : ""
670 }
671 />
672 </div>
673
674 {/* Event Selector */}
675 <div>
676 <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
677 {__("Trigger Event")}
678 </label>
679 {Boolean(templateData?.is_system) || isCoreSettingsEdit ? (
680 <div className="space-y-2">
681 <div className="flex items-center gap-2">
682 <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">
683 <Zap className="w-4 h-4" />
684 {formData.event_key || "-"}
685 </span>
686 <span className="text-xs text-gray-500 dark:text-gray-400">
687 {__("System templates cannot change events")}
688 </span>
689 </div>
690 {formData.event_key && (
691 <div className="p-3 bg-indigo-50 dark:bg-indigo-900/20 rounded-lg border border-indigo-200 dark:border-indigo-800">
692 <p className="text-sm text-indigo-800 dark:text-indigo-300">
693 {events.find((e: any) => e.key === formData.event_key)
694 ?.description || ""}
695 </p>
696 </div>
697 )}
698 </div>
699 ) : (
700 <div className="relative">
701 {/* Dropdown Trigger Button */}
702 <button
703 type="button"
704 onClick={() => setShowEventDropdown(!showEventDropdown)}
705 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"
706 >
707 {formData.event_key ? (
708 <div className="flex items-center gap-2">
709 <Zap className="w-4 h-4 text-blue-500" />
710 <span className="text-sm font-medium text-gray-900 dark:text-white">
711 {events.find(
712 (e: any) => e.key === formData.event_key,
713 )?.name || formData.event_key}
714 </span>
715 </div>
716 ) : (
717 <div className="flex items-center gap-2">
718 <Mail className="w-4 h-4 text-gray-400" />
719 <span className="text-sm text-gray-500 dark:text-gray-400">
720 {__("No Event (Sequence Only)")}
721 </span>
722 </div>
723 )}
724 <ChevronDown
725 className={`w-4 h-4 text-gray-400 transition-transform ${showEventDropdown ? "rotate-180" : ""}`}
726 />
727 </button>
728
729 {/* Dropdown List */}
730 {showEventDropdown && (
731 <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">
732 <div className="p-2 border-b border-gray-200 dark:border-gray-700">
733 <p className="text-xs text-gray-500 dark:text-gray-400 px-2">
734 {__(
735 "Select an event to trigger this email, or leave empty for sequence use",
736 )}
737 </p>
738 </div>
739 <div className="py-1">
740 {/* None option - for templates used in sequences */}
741 <button
742 type="button"
743 onClick={() => {
744 setFormData({ ...formData, event_key: "" });
745 setShowEventDropdown(false);
746 }}
747 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 ${
748 !formData.event_key
749 ? "bg-indigo-50 dark:bg-indigo-900/20"
750 : ""
751 }`}
752 >
753 <div className="flex items-start gap-2">
754 <Mail
755 className={`w-4 h-4 mt-0.5 flex-shrink-0 ${
756 !formData.event_key
757 ? "text-blue-500"
758 : "text-gray-400"
759 }`}
760 />
761 <div className="flex-1 min-w-0">
762 <div className="flex items-center gap-2">
763 <span
764 className={`text-sm font-medium ${
765 !formData.event_key
766 ? "text-blue-800 dark:text-blue-400"
767 : "text-gray-900 dark:text-white"
768 }`}
769 >
770 {__("No Event (Sequence Only)")}
771 </span>
772 </div>
773 <p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
774 {__(
775 "This template will only be used in email sequences",
776 )}
777 </p>
778 </div>
779 {!formData.event_key && (
780 <Check className="w-4 h-4 text-blue-500 flex-shrink-0" />
781 )}
782 </div>
783 </button>
784 {events.map((event: any) => (
785 <button
786 key={event.key}
787 type="button"
788 onClick={() => {
789 setFormData({
790 ...formData,
791 event_key: event.key,
792 });
793 setShowEventDropdown(false);
794 }}
795 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 ${
796 formData.event_key === event.key
797 ? "bg-indigo-50 dark:bg-indigo-900/20"
798 : ""
799 }`}
800 >
801 <div className="flex items-start gap-2">
802 <Zap
803 className={`w-4 h-4 mt-0.5 flex-shrink-0 ${
804 formData.event_key === event.key
805 ? "text-blue-500"
806 : "text-gray-400"
807 }`}
808 />
809 <div className="flex-1 min-w-0">
810 <div className="flex items-center gap-2">
811 <span
812 className={`text-sm font-medium ${
813 formData.event_key === event.key
814 ? "text-blue-800 dark:text-blue-400"
815 : "text-gray-900 dark:text-white"
816 }`}
817 >
818 {event.name}
819 </span>
820 <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">
821 {event.key}
822 </code>
823 </div>
824 <p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5 line-clamp-2">
825 {event.description}
826 </p>
827 </div>
828 {formData.event_key === event.key && (
829 <Check className="w-4 h-4 text-blue-500 flex-shrink-0" />
830 )}
831 </div>
832 </button>
833 ))}
834 {events.length === 0 && (
835 <div className="px-3 py-3 text-xs text-amber-600 dark:text-amber-400">
836 {__(
837 "No trigger events are available. Enable the Email Automation module under Yatra → Modules, then reload this page.",
838 )}
839 </div>
840 )}
841 </div>
842 </div>
843 )}
844
845 {/* Click outside to close */}
846 {showEventDropdown && (
847 <div
848 className="fixed inset-0 z-40"
849 onClick={() => setShowEventDropdown(false)}
850 />
851 )}
852 </div>
853 )}
854 <p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
855 {__(
856 "This email will be triggered when the selected event occurs",
857 )}
858 </p>
859 </div>
860
861 {!isCoreSettingsEdit && (
862 <>
863 {/* From/Reply-To Settings */}
864 <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
865 <div>
866 <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
867 {__("From Name")}
868 </label>
869 <Input
870 value={formData.from_name}
871 onChange={(e) =>
872 setFormData({
873 ...formData,
874 from_name: e.target.value,
875 })
876 }
877 placeholder={__("e.g., Travel Agency Name")}
878 />
879 <p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
880 {__("Leave empty to use site default")}
881 </p>
882 </div>
883 <div>
884 <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
885 {__("From Email")}
886 </label>
887 <Input
888 type="email"
889 value={formData.from_email}
890 onChange={(e) =>
891 setFormData({
892 ...formData,
893 from_email: e.target.value,
894 })
895 }
896 placeholder={__("e.g., [email protected]")}
897 />
898 <p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
899 {__("Leave empty to use site default")}
900 </p>
901 </div>
902 </div>
903
904 <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
905 <div>
906 <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
907 {__("To Email")}
908 </label>
909 <Input
910 type="email"
911 value={formData.to_email}
912 onChange={(e) =>
913 setFormData({ ...formData, to_email: e.target.value })
914 }
915 placeholder={__("e.g., {{customer_email}}")}
916 />
917 <p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
918 {__(
919 "Recipient email. Use variables or leave empty for default",
920 )}
921 </p>
922 </div>
923 <div>
924 <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
925 {__("Reply-To Email")}
926 </label>
927 <Input
928 type="email"
929 value={formData.reply_to}
930 onChange={(e) =>
931 setFormData({ ...formData, reply_to: e.target.value })
932 }
933 placeholder={__("e.g., [email protected]")}
934 />
935 <p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
936 {__(
937 "Where replies will be sent. Leave empty to use From Email",
938 )}
939 </p>
940 </div>
941 </div>
942 </>
943 )}
944
945 {isCoreSettingsEdit && (
946 <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">
947 {__(
948 "From / reply addresses are configured under Email → Delivery. Recipient uses the customer on the booking.",
949 "yatra",
950 )}
951 </p>
952 )}
953
954 <div>
955 <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
956 {__("Subject Line")}
957 </label>
958 <Input
959 value={formData.subject}
960 onChange={(e) =>
961 setFormData({ ...formData, subject: e.target.value })
962 }
963 placeholder={__("Email subject with {{variables}}")}
964 readOnly={isCoreBodyReadOnly}
965 className={`font-mono text-sm ${isCoreBodyReadOnly ? "bg-gray-50 dark:bg-gray-900/40" : ""}`}
966 />
967 <p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
968 {__("Use {{variable}} syntax to insert dynamic content")}
969 </p>
970 </div>
971
972 <div>
973 {/* Label + AI sparkle on the same row — sparkle sits
974 to the right of the "Email Body (HTML)" label so
975 it's visually associated with the editor it
976 affects, not floating above Subject Line. */}
977 <div className="mb-1 flex items-center justify-between">
978 <label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
979 {__("Email Body (HTML)")}
980 </label>
981 {!isCoreBodyReadOnly &&
982 isAiEligible() &&
983 isAiModuleEnabled() && (
984 <Button
985 type="button"
986 variant="outline"
987 size="sm"
988 onClick={() => setShowAiModal(true)}
989 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"
990 >
991 <Sparkles className="mr-1.5 h-3.5 w-3.5" />
992 {__("Generate with AI", "yatra")}
993 </Button>
994 )}
995 </div>
996 <textarea
997 id="email-body"
998 value={formData.body}
999 onChange={(e) =>
1000 setFormData({ ...formData, body: e.target.value })
1001 }
1002 rows={20}
1003 readOnly={isCoreBodyReadOnly}
1004 className={`w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg font-mono text-sm ${
1005 isCoreBodyReadOnly
1006 ? "bg-gray-50 dark:bg-gray-900/40 text-gray-900 dark:text-white"
1007 : "bg-white dark:bg-gray-800 text-gray-900 dark:text-white"
1008 }`}
1009 placeholder={__("HTML email content...")}
1010 />
1011 </div>
1012 </CardContent>
1013 </Card>
1014
1015 {/* Test Email - Only show in edit mode */}
1016 {!isCreateMode && !isCoreSettingsEdit && (
1017 <Card>
1018 <CardHeader>
1019 <CardTitle className="flex items-center gap-2">
1020 <Send className="w-5 h-5 text-blue-500" />
1021 {__("Send Test Email")}
1022 </CardTitle>
1023 </CardHeader>
1024 <CardContent>
1025 <div className="flex gap-3">
1026 <Input
1027 type="email"
1028 value={testEmail}
1029 onChange={(e) => setTestEmail(e.target.value)}
1030 placeholder={__("Enter email address")}
1031 className="flex-1"
1032 />
1033 <Button
1034 onClick={() => testMutation.mutate(testEmail)}
1035 disabled={!testEmail || testMutation.isPending}
1036 >
1037 {testMutation.isPending ? (
1038 <Loader2 className="w-4 h-4 animate-spin" />
1039 ) : (
1040 <Send className="w-4 h-4" />
1041 )}
1042 </Button>
1043 </div>
1044 <p className="text-xs text-gray-500 dark:text-gray-400 mt-2">
1045 {__(
1046 "Send a test email with sample data to preview how it looks",
1047 )}
1048 </p>
1049 </CardContent>
1050 </Card>
1051 )}
1052 </div>
1053
1054 {/* Sidebar - Variables */}
1055 <div className="space-y-6">
1056 <Card>
1057 <CardHeader>
1058 <CardTitle className="flex items-center gap-2">
1059 <Code className="w-5 h-5 text-blue-500" />
1060 {__("Available Variables")}
1061 </CardTitle>
1062 </CardHeader>
1063 <CardContent className="space-y-4">
1064 {!isCoreSettingsEdit && (
1065 <>
1066 <p className="text-sm text-gray-500 dark:text-gray-400">
1067 {__("Click to copy, double-click to insert at cursor")}
1068 </p>
1069 {/*
1070 Subtitle showing which event the variables list is
1071 scoped to. Without this hint, operators see a
1072 different set of tags every time they switch the
1073 event dropdown and have no clear explanation why.
1074 */}
1075 {formData.event_key ? (
1076 // Show the technical event KEY (dot-notation, monospace)
1077 // as the primary label so it can't be confused with the
1078 // template's display title — earlier versions showed
1079 // the event's friendly name, which often matches what
1080 // operators name their templates ("Customer email
1081 // verification" appears as both the event name and a
1082 // common template title) and made it unclear what the
1083 // chip was scoped to. Friendly name still appears as
1084 // the parenthesised clarifier.
1085 <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">
1086 {__("Variables available for trigger event:", "yatra")}{" "}
1087 <code className="font-mono text-blue-800 dark:text-blue-200 bg-blue-100 dark:bg-blue-900/40 px-1 rounded">
1088 {formData.event_key}
1089 </code>
1090 {(() => {
1091 const match = events.find(
1092 (e: any) => e.key === formData.event_key,
1093 );
1094 return match?.name ? (
1095 <span className="text-blue-600/80 dark:text-blue-400/80">
1096 {" "}
1097 ({match.name})
1098 </span>
1099 ) : null;
1100 })()}
1101 </p>
1102 ) : (
1103 <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">
1104 {__(
1105 "Pick a trigger event below to see the variables that apply to that event. Showing the full catalog until then.",
1106 "yatra",
1107 )}
1108 </p>
1109 )}
1110 </>
1111 )}
1112
1113 {isCoreSettingsEdit && coreDef?.mergeTags ? (
1114 <div className="space-y-2">
1115 <p className="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase">
1116 {__("Merge tags", "yatra")}
1117 </p>
1118 <p className="text-xs text-gray-500 dark:text-gray-400">
1119 {__(
1120 "Click a tag to copy; paste into subject or HTML body.",
1121 "yatra",
1122 )}
1123 </p>
1124 <div className="flex flex-wrap gap-1">
1125 {coreDef.mergeTags.split(",").map((raw) => {
1126 const tag = raw.trim();
1127 const inner = tag.replace(/\{\{|\}\}/g, "").trim();
1128 if (!inner) return null;
1129 return (
1130 <button
1131 key={tag}
1132 type="button"
1133 onClick={() => copyVariable(inner)}
1134 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"
1135 >
1136 {tag}
1137 </button>
1138 );
1139 })}
1140 </div>
1141 </div>
1142 ) : (
1143 variablesData &&
1144 Object.entries(variablesData).map(
1145 ([category, variables]: [string, any]) => (
1146 <div key={category}>
1147 <h4 className="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase mb-2">
1148 {category}
1149 </h4>
1150 <div className="space-y-1">
1151 {variables.map((v: any) => (
1152 <button
1153 key={v.key}
1154 type="button"
1155 onClick={() => copyVariable(v.key)}
1156 onDoubleClick={() => insertVariable(v.key)}
1157 className="w-full text-left px-2 py-1.5 rounded hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors group"
1158 >
1159 <div className="flex items-center justify-between">
1160 <code className="text-xs text-blue-600 dark:text-blue-400">
1161 {`{{${v.key}}}`}
1162 </code>
1163 {copiedVar === v.key ? (
1164 <Check className="w-3 h-3 text-green-500" />
1165 ) : (
1166 <Copy className="w-3 h-3 text-gray-400 opacity-0 group-hover:opacity-100" />
1167 )}
1168 </div>
1169 <p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
1170 {v.description}
1171 </p>
1172 </button>
1173 ))}
1174 </div>
1175 </div>
1176 ),
1177 )
1178 )}
1179 </CardContent>
1180 </Card>
1181
1182 {/* Template Info - Different content for create vs edit */}
1183 <Card>
1184 <CardHeader>
1185 <CardTitle className="flex items-center gap-2">
1186 <Info className="w-5 h-5 text-gray-500" />
1187 {isCreateMode ? __("New Template") : __("Template Info")}
1188 </CardTitle>
1189 </CardHeader>
1190 <CardContent className="space-y-3 text-sm">
1191 <div className="space-y-2">
1192 <div className="flex justify-between items-center">
1193 <span className="text-gray-500 dark:text-gray-400">
1194 {__("Event")}
1195 </span>
1196 <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">
1197 <Zap className="w-3 h-3" />
1198 {formData.event_key || "-"}
1199 </span>
1200 </div>
1201 {formData.event_key && (
1202 <p className="text-xs text-gray-500 dark:text-gray-400 italic">
1203 {events.find((e: any) => e.key === formData.event_key)
1204 ?.description || ""}
1205 </p>
1206 )}
1207 </div>
1208 {isCreateMode ? (
1209 <>
1210 <div className="flex justify-between">
1211 <span className="text-gray-500 dark:text-gray-400">
1212 {__("Type")}
1213 </span>
1214 <span className="text-gray-900 dark:text-white">
1215 {__("Custom")}
1216 </span>
1217 </div>
1218 <div className="p-3 bg-blue-50 dark:bg-blue-900/20 rounded-lg border border-blue-200 dark:border-blue-800">
1219 <p className="text-xs text-blue-700 dark:text-blue-300">
1220 {__(
1221 "Custom templates can be edited and deleted. Select an event to trigger this email automatically.",
1222 )}
1223 </p>
1224 </div>
1225 </>
1226 ) : (
1227 <>
1228 <div className="flex justify-between">
1229 <span className="text-gray-500 dark:text-gray-400">
1230 {__("Key")}
1231 </span>
1232 <code className="text-xs bg-gray-100 dark:bg-gray-800 px-2 py-0.5 rounded">
1233 {isCoreSettingsEdit
1234 ? coreDef?.template_key
1235 : String(templateData?.template_key ?? "")}
1236 </code>
1237 </div>
1238 <div className="flex justify-between">
1239 <span className="text-gray-500 dark:text-gray-400">
1240 {__("Category")}
1241 </span>
1242 <Badge className="bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400">
1243 {isCoreSettingsEdit
1244 ? coreDef?.category
1245 : String(templateData?.category ?? "")}
1246 </Badge>
1247 </div>
1248 <div className="flex justify-between">
1249 <span className="text-gray-500 dark:text-gray-400">
1250 {__("Recipient")}
1251 </span>
1252 <span
1253 className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${
1254 (isCoreSettingsEdit
1255 ? coreDef?.recipient_type
1256 : String(templateData?.recipient_type ?? "")) ===
1257 "admin"
1258 ? "bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400"
1259 : "bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400"
1260 }`}
1261 >
1262 {(isCoreSettingsEdit
1263 ? coreDef?.recipient_type
1264 : String(templateData?.recipient_type ?? "")) ===
1265 "admin"
1266 ? __("Admin")
1267 : __("Customer")}
1268 </span>
1269 </div>
1270 <div className="flex justify-between">
1271 <span className="text-gray-500 dark:text-gray-400">
1272 {__("Type")}
1273 </span>
1274 <span className="text-gray-900 dark:text-white">
1275 {isCoreSettingsEdit || Boolean(templateData?.is_system)
1276 ? __("System")
1277 : __("Custom")}
1278 </span>
1279 </div>
1280 </>
1281 )}
1282 </CardContent>
1283 </Card>
1284 </div>
1285 </div>
1286
1287 <EmailPreviewModal
1288 open={showPreview && Boolean(previewData)}
1289 onClose={() => {
1290 setShowPreview(false);
1291 setPreviewData(null);
1292 }}
1293 subject={previewData?.subject ?? ""}
1294 body={previewData?.body ?? ""}
1295 />
1296
1297 {/* AI: subject + body generator. The merge-tag list comes from
1298 the catalog entry when it's a core template, falling back
1299 to a sensible default set when editing a custom template. */}
1300 <AiEmailTemplateModal
1301 open={showAiModal}
1302 onClose={() => setShowAiModal(false)}
1303 templateKey={
1304 coreTemplateSlug || formData.event_key || "custom_template"
1305 }
1306 templateName={coreDef?.name || formData.name || formData.event_key}
1307 templateDescription={coreDef?.description || formData.description}
1308 recipientType={
1309 (catalogEntryForCore?.recipient_type as "customer" | "admin") ||
1310 "customer"
1311 }
1312 mergeTags={(() => {
1313 // Catalog stores mergeTags as a comma-string like
1314 // "{{customer_name}}, {{booking_reference}}, …". Coerce to
1315 // the bare tag names ('customer_name') that the agent's
1316 // server-side normalizer accepts.
1317 const raw = catalogEntryForCore?.mergeTags ?? "";
1318 if (!raw) {
1319 return [
1320 "site_name",
1321 "site_url",
1322 "customer_name",
1323 "customer_email",
1324 "booking_reference",
1325 "trip_name",
1326 "travel_date",
1327 "total_amount",
1328 "currency",
1329 ];
1330 }
1331 return raw
1332 .split(",")
1333 .map((s) =>
1334 s
1335 .trim()
1336 .replace(/^\{\{|\}\}$/g, "")
1337 .trim(),
1338 )
1339 .filter(Boolean);
1340 })()}
1341 currentSubject={formData.subject}
1342 currentBody={formData.body}
1343 onAccept={({ subject, body }) =>
1344 setFormData({ ...formData, subject, body })
1345 }
1346 />
1347 </div>
1348 );
1349 };
1350
1351 export default EmailTemplateForm;
1352