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

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