|
@@ -1,7 +1,22 @@ |
|
1
|
1
|
import React, { useState, useEffect, useMemo } from "react"; |
|
2
|
2
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; |
|
3
|
|
-import { __ } from "../lib/i18n"; |
|
3
|
+import { __, sprintf } from "../lib/i18n"; |
|
4
|
+import { MultiSelect } from "../components/ui/multi-select"; |
|
5
|
+import { SearchableSelect } from "../components/ui/searchable-select"; |
|
6
|
+import { |
|
7
|
+ decodeTargets, |
|
8
|
+ describeTargets, |
|
9
|
+ encodeTargets, |
|
10
|
+ hasTargets, |
|
11
|
+ useTripTargets, |
|
12
|
+ type TripTargets, |
|
13
|
+} from "../hooks/useTripTargets"; |
|
14
|
+import { EmailOverrideCreateModal } from "../components/email/EmailOverrideCreateModal"; |
|
15
|
+import { |
|
16
|
+ fetchEmailTemplates, |
|
17
|
+ resolveEmailTemplatesForTrip, |
|
18
|
+} from "../api/email-automation-api"; |
|
4
|
19
|
import { useToast } from "../components/ui/toast"; |
|
5
|
20
|
import { |
|
6
|
21
|
Card, |
|
7
|
22
|
CardContent, |
|
@@ -23,11 +38,15 @@ |
|
23
|
38
|
Check, |
|
24
|
39
|
Info, |
|
25
|
40
|
Zap, |
|
26
|
41
|
ChevronDown, |
|
42
|
+ Sparkles, |
|
27
|
43
|
} from "lucide-react"; |
|
44
|
+import { AiEmailTemplateModal } from "../components/ai/AiEmailTemplateModal"; |
|
45
|
+import { isAiEligible, isAiModuleEnabled } from "../lib/ai-availability"; |
|
28
|
46
|
import { Switch } from "../components/ui/switch"; |
|
29
|
47
|
import { |
|
48
|
+ EMAIL_TEMPLATES_CATALOG, |
|
30
|
49
|
getCatalogEntryByTemplateKey, |
|
31
|
50
|
getCoreTemplateDefinition, |
|
32
|
51
|
isCoreTemplateSlug, |
|
33
|
52
|
} from "../lib/email-templates-catalog"; |
|
@@ -44,8 +63,9 @@ |
|
44
|
63
|
import { EmailPreviewModal } from "../components/email/EmailPreviewModal"; |
|
45
|
64
|
import { |
|
46
|
65
|
createEmailTemplate, |
|
47
|
66
|
fetchEmailTemplate, |
|
67
|
+ fetchEmailTemplateEvents, |
|
48
|
68
|
fetchEmailTemplateVariables, |
|
49
|
69
|
previewEmailTemplate, |
|
50
|
70
|
sendEmailTemplateTest, |
|
51
|
71
|
updateEmailTemplate, |
|
@@ -71,8 +91,18 @@ |
|
71
|
91
|
}; |
|
72
|
92
|
}, []); |
|
73
|
93
|
|
|
74
|
94
|
const isCoreSettingsEdit = isCoreTemplateSlug(coreTemplateSlug); |
|
95
|
+ // Per-template BCC/CC settings keys are derived from the template's subject |
|
96
|
+ // key (email_tpl_booking_subject -> _bcc / _cc), exactly as the PHP side does, |
|
97
|
+ // so the catalog needs no extra entries and a new template type is covered |
|
98
|
+ // automatically. |
|
99
|
+ const coreAddressKey = ( |
|
100
|
+ subjectKey: string | undefined, |
|
101
|
+ suffix: "bcc" | "cc", |
|
102
|
+ ): string | null => |
|
103
|
+ subjectKey ? subjectKey.replace(/_subject$/, "") + "_" + suffix : null; |
|
104
|
+ |
|
75
|
105
|
const coreDef = isCoreSettingsEdit |
|
76
|
106
|
? getCoreTemplateDefinition(coreTemplateSlug) |
|
77
|
107
|
: undefined; |
|
78
|
108
|
|
|
@@ -84,10 +114,9 @@ |
|
84
|
114
|
[coreTemplateSlug], |
|
85
|
115
|
); |
|
86
|
116
|
|
|
87
|
117
|
/** URL ?readonly=1 — no edits at all from this screen */ |
|
88
|
|
- const isCoreStrictViewOnly = |
|
89
|
|
- isCoreSettingsEdit && Boolean(coreReadOnlyParam); |
|
118
|
+ const isCoreStrictViewOnly = isCoreSettingsEdit && Boolean(coreReadOnlyParam); |
|
90
|
119
|
|
|
91
|
120
|
/** Module required but inactive: subject/body/save locked; on/off can still be changed (site setting). */ |
|
92
|
121
|
const isCoreModuleBodyLocked = useMemo(() => { |
|
93
|
122
|
if (!isCoreSettingsEdit || isCoreStrictViewOnly) { |
|
@@ -117,18 +146,27 @@ |
|
117
|
146
|
from_name: "", |
|
118
|
147
|
from_email: "", |
|
119
|
148
|
to_email: "", |
|
120
|
149
|
reply_to: "", |
|
150
|
+ bcc: "", |
|
151
|
+ cc: "", |
|
152
|
+ category: "booking", |
|
121
|
153
|
subject: "", |
|
122
|
154
|
body: "", |
|
123
|
155
|
event_key: "", |
|
124
|
156
|
is_active: true, |
|
125
|
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); |
|
126
|
163
|
const [showPreview, setShowPreview] = useState(false); |
|
127
|
164
|
const [previewData, setPreviewData] = useState<PreviewData | null>(null); |
|
128
|
165
|
const [testEmail, setTestEmail] = useState(""); |
|
129
|
166
|
const [copiedVar, setCopiedVar] = useState<string | null>(null); |
|
130
|
167
|
const [showEventDropdown, setShowEventDropdown] = useState(false); |
|
168
|
+ const [showAiModal, setShowAiModal] = useState(false); |
|
131
|
169
|
|
|
132
|
170
|
useEffect(() => { |
|
133
|
171
|
if (isCreateMode && !isEmailAutomationModuleEnabled()) { |
|
134
|
172
|
window.location.href = |
|
@@ -157,16 +195,117 @@ |
|
157
|
195
|
queryFn: () => fetchEmailTemplate(id as string), |
|
158
|
196
|
enabled: Boolean(id) && !isCoreSettingsEdit, |
|
159
|
197
|
}); |
|
160
|
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). |
|
161
|
264
|
const { data: variablesData } = useQuery({ |
|
162
|
|
- queryKey: ["email-variables"], |
|
163
|
|
- queryFn: () => fetchEmailTemplateVariables(), |
|
265
|
+ queryKey: ["email-variables", formData.event_key || ""], |
|
266
|
+ queryFn: () => fetchEmailTemplateVariables(formData.event_key || ""), |
|
164
|
267
|
enabled: !isCoreSettingsEdit && isEmailAutomationModuleEnabled(), |
|
165
|
268
|
}); |
|
166
|
269
|
|
|
167
|
|
- // Get events from window.yatraAdmin (passed from PHP) |
|
168
|
|
- const events = (window as any).yatraAdmin?.emailEvents || []; |
|
270
|
+ // Trigger events. Fetch them live rather than trusting only the one-time |
|
271
|
+ // window.yatraAdmin.emailEvents localize snapshot: that snapshot is written |
|
272
|
+ // by the Pro module at PHP render time, but the module-enabled flag is |
|
273
|
+ // refreshed client-side (useModules), so the two can diverge — the form |
|
274
|
+ // opens (flag says enabled) while emailEvents is still empty, leaving the |
|
275
|
+ // dropdown with "No Event to select". The live fetch corrects that; the |
|
276
|
+ // localize snapshot is used as an instant fallback while it loads. |
|
277
|
+ const { data: fetchedEvents } = useQuery({ |
|
278
|
+ queryKey: ["email-template-events"], |
|
279
|
+ queryFn: fetchEmailTemplateEvents, |
|
280
|
+ enabled: !isCoreSettingsEdit && isEmailAutomationModuleEnabled(), |
|
281
|
+ }); |
|
282
|
+ // Resolve the trigger-event list from the first non-empty source. Both the |
|
283
|
+ // live fetch AND the window.yatraAdmin.emailEvents localize are gated on the |
|
284
|
+ // Pro Email Automation module actively delivering data (route + localize both |
|
285
|
+ // only run when the module is enabled and the client flag agrees), so if the |
|
286
|
+ // module gate isn't delivering — disabled, or a client/server flag drift — |
|
287
|
+ // both are empty and the dropdown ended up unselectable. Fall back to the |
|
288
|
+ // module-INDEPENDENT catalog (same source the templates list uses) so an event |
|
289
|
+ // can always be chosen. `??` alone won't do — [] is not nullish. |
|
290
|
+ const events = useMemo(() => { |
|
291
|
+ if (Array.isArray(fetchedEvents) && fetchedEvents.length > 0) { |
|
292
|
+ return fetchedEvents; |
|
293
|
+ } |
|
294
|
+ const localized = (window as any).yatraAdmin?.emailEvents; |
|
295
|
+ if (Array.isArray(localized) && localized.length > 0) { |
|
296
|
+ return localized; |
|
297
|
+ } |
|
298
|
+ // Derive a readable name from the event key (e.g. "booking.created" → |
|
299
|
+ // "Booking Created") since the catalog keys events, not names. |
|
300
|
+ const prettify = (key: string) => |
|
301
|
+ key.replace(/[._]/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()); |
|
302
|
+ return [ |
|
303
|
+ ...new Set( |
|
304
|
+ EMAIL_TEMPLATES_CATALOG.map((e) => e.event_key).filter(Boolean), |
|
305
|
+ ), |
|
306
|
+ ].map((key) => ({ key, name: prettify(key) })); |
|
307
|
+ }, [fetchedEvents]); |
|
169
|
308
|
|
|
170
|
309
|
useEffect(() => { |
|
171
|
310
|
if (!templateData || typeof templateData !== "object") return; |
|
172
|
311
|
const t = templateData as Record<string, unknown>; |
|
@@ -176,13 +315,23 @@ |
|
176
|
315
|
from_name: String(t.from_name ?? ""), |
|
177
|
316
|
from_email: String(t.from_email ?? ""), |
|
178
|
317
|
to_email: String(t.to_email ?? ""), |
|
179
|
318
|
reply_to: String(t.reply_to ?? ""), |
|
319
|
+ bcc: String(t.bcc ?? ""), |
|
320
|
+ cc: String(t.cc ?? ""), |
|
321
|
+ category: String(t.category ?? "booking"), |
|
180
|
322
|
subject: String(t.subject ?? ""), |
|
181
|
323
|
body: String(t.body ?? ""), |
|
182
|
|
- event_key: String(t.event_key ?? ""), |
|
324
|
+ // An override's own event_key is stored empty; show (and fetch |
|
325
|
+ // variables for) the global template's event instead. |
|
326
|
+ event_key: String(t.effective_event_key || t.event_key || ""), |
|
183
|
327
|
is_active: Boolean(t.is_active ?? true), |
|
184
|
328
|
}); |
|
329
|
+ setTargets( |
|
330
|
+ t.overrides && t.targets && typeof t.targets === "object" |
|
331
|
+ ? (t.targets as TripTargets) |
|
332
|
+ : {}, |
|
333
|
+ ); |
|
185
|
334
|
}, [templateData]); |
|
186
|
335
|
|
|
187
|
336
|
useEffect(() => { |
|
188
|
337
|
if (!isCoreSettingsEdit || !settings || !coreDef) return; |
|
@@ -197,8 +346,11 @@ |
|
197
|
346
|
from_name: "", |
|
198
|
347
|
from_email: "", |
|
199
|
348
|
to_email: coreDef.to_email || "", |
|
200
|
349
|
reply_to: "", |
|
350
|
+ bcc: String(s[coreAddressKey(subj, "bcc") ?? ""] ?? ""), |
|
351
|
+ cc: String(s[coreAddressKey(subj, "cc") ?? ""] ?? ""), |
|
352
|
+ category: String(coreDef.category ?? "booking"), |
|
201
|
353
|
subject: String(s[subj] ?? ""), |
|
202
|
354
|
body: String(s[bodyKey] ?? ""), |
|
203
|
355
|
event_key: coreDef.event_key, |
|
204
|
356
|
is_active: Boolean(s[flag]), |
|
@@ -273,8 +425,13 @@ |
|
273
|
425
|
} |
|
274
|
426
|
base[coreDef.settingsSubject] = formData.subject; |
|
275
|
427
|
base[coreDef.settingsBody] = formData.body; |
|
276
|
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; |
|
277
|
434
|
await saveSettings(base); |
|
278
|
435
|
return base; |
|
279
|
436
|
}, |
|
280
|
437
|
onSuccess: (saved) => { |
|
@@ -289,9 +446,9 @@ |
|
289
|
446
|
}); |
|
290
|
447
|
|
|
291
|
448
|
const previewMutation = useMutation({ |
|
292
|
449
|
mutationFn: async () => { |
|
293
|
|
- return previewEmailTemplate(id as string); |
|
450
|
+ return previewEmailTemplate(id as string, previewTrip); |
|
294
|
451
|
}, |
|
295
|
452
|
onSuccess: (data) => { |
|
296
|
453
|
setPreviewData(data); |
|
297
|
454
|
setShowPreview(true); |
|
@@ -324,9 +481,9 @@ |
|
324
|
481
|
}); |
|
325
|
482
|
|
|
326
|
483
|
const testMutation = useMutation({ |
|
327
|
484
|
mutationFn: async (email: string) => { |
|
328
|
|
- return sendEmailTemplateTest(id as string, email); |
|
485
|
+ return sendEmailTemplateTest(id as string, email, previewTrip); |
|
329
|
486
|
}, |
|
330
|
487
|
onSuccess: () => { |
|
331
|
488
|
showToast(__("Test email sent successfully"), "success"); |
|
332
|
489
|
setTestEmail(""); |
|
@@ -354,16 +511,69 @@ |
|
354
|
511
|
if (!formData.subject.trim()) { |
|
355
|
512
|
showToast(__("Subject line is required"), "error"); |
|
356
|
513
|
return; |
|
357
|
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
|
+ } |
|
358
|
525
|
|
|
359
|
|
- saveMutation.mutate(formData); |
|
526
|
+ saveMutation.mutate( |
|
527
|
+ isOverride |
|
528
|
+ ? // event_key is not sent: an override always fires on its global's event. |
|
529
|
+ ({ |
|
530
|
+ ...formData, |
|
531
|
+ event_key: undefined, |
|
532
|
+ targets, |
|
533
|
+ } as unknown as typeof formData) |
|
534
|
+ : formData, |
|
535
|
+ ); |
|
360
|
536
|
}; |
|
361
|
537
|
|
|
362
|
538
|
const copyVariable = (variable: string) => { |
|
363
|
|
- navigator.clipboard.writeText(`{{${variable}}}`); |
|
364
|
|
- setCopiedVar(variable); |
|
365
|
|
- setTimeout(() => setCopiedVar(null), 2000); |
|
539
|
+ const text = `{{${variable}}}`; |
|
540
|
+ |
|
541
|
+ const markCopied = () => { |
|
542
|
+ setCopiedVar(variable); |
|
543
|
+ setTimeout(() => setCopiedVar(null), 2000); |
|
544
|
+ }; |
|
545
|
+ |
|
546
|
+ // Legacy fallback: navigator.clipboard is only defined in a secure context |
|
547
|
+ // (HTTPS or localhost). On a plain-HTTP wp-admin it is undefined, so the |
|
548
|
+ // previous call threw and nothing was copied. execCommand("copy") works |
|
549
|
+ // there via a temporary off-screen textarea. |
|
550
|
+ const legacyCopy = (): boolean => { |
|
551
|
+ try { |
|
552
|
+ const el = document.createElement("textarea"); |
|
553
|
+ el.value = text; |
|
554
|
+ el.setAttribute("readonly", ""); |
|
555
|
+ el.style.position = "fixed"; |
|
556
|
+ el.style.top = "-9999px"; |
|
557
|
+ document.body.appendChild(el); |
|
558
|
+ el.select(); |
|
559
|
+ const ok = document.execCommand("copy"); |
|
560
|
+ document.body.removeChild(el); |
|
561
|
+ return ok; |
|
562
|
+ } catch { |
|
563
|
+ return false; |
|
564
|
+ } |
|
565
|
+ }; |
|
566
|
+ |
|
567
|
+ if (navigator?.clipboard?.writeText) { |
|
568
|
+ navigator.clipboard.writeText(text).then(markCopied, () => { |
|
569
|
+ if (legacyCopy()) { |
|
570
|
+ markCopied(); |
|
571
|
+ } |
|
572
|
+ }); |
|
573
|
+ } else if (legacyCopy()) { |
|
574
|
+ markCopied(); |
|
575
|
+ } |
|
366
|
576
|
}; |
|
367
|
577
|
|
|
368
|
578
|
const insertVariable = (variable: string) => { |
|
369
|
579
|
if (isCoreBodyReadOnly) { |
|
@@ -522,8 +732,33 @@ |
|
522
|
732
|
"Core customer email (saved with site settings). From address is set under Email → Delivery.", |
|
523
|
733
|
"yatra", |
|
524
|
734
|
) |
|
525
|
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
|
+ )} |
|
526
|
761
|
</p> |
|
527
|
762
|
</div> |
|
528
|
763
|
</div> |
|
529
|
764
|
<div className="flex items-center gap-3"> |
|
@@ -585,8 +820,118 @@ |
|
585
|
820
|
|
|
586
|
821
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6"> |
|
587
|
822
|
{/* Main Form */} |
|
588
|
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
|
+ )} |
|
589
|
934
|
<Card> |
|
590
|
935
|
<CardHeader> |
|
591
|
936
|
<CardTitle className="flex items-center justify-between"> |
|
592
|
937
|
<span>{__("Template Details")}</span> |
|
@@ -647,22 +992,29 @@ |
|
647
|
992
|
<div> |
|
648
|
993
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"> |
|
649
|
994
|
{__("Trigger Event")} |
|
650
|
995
|
</label> |
|
651
|
|
- {Boolean(templateData?.is_system) || isCoreSettingsEdit ? ( |
|
996
|
+ {Boolean(templateData?.is_system) || |
|
997
|
+ isCoreSettingsEdit || |
|
998
|
+ isOverride ? ( |
|
652
|
999
|
<div className="space-y-2"> |
|
653
|
1000
|
<div className="flex items-center gap-2"> |
|
654
|
|
- <span className="inline-flex items-center gap-1.5 px-3 py-2 rounded-lg text-sm font-medium bg-indigo-100 text-indigo-700 dark:bg-indigo-900/30 dark:text-indigo-400 border border-indigo-200 dark:border-indigo-800"> |
|
1001
|
+ <span className="inline-flex items-center gap-1.5 px-3 py-2 rounded-lg text-sm font-medium bg-indigo-100 text-indigo-800 dark:bg-indigo-900/30 dark:text-indigo-400 border border-indigo-200 dark:border-indigo-800"> |
|
655
|
1002
|
<Zap className="w-4 h-4" /> |
|
656
|
1003
|
{formData.event_key || "-"} |
|
657
|
1004
|
</span> |
|
658
|
1005
|
<span className="text-xs text-gray-500 dark:text-gray-400"> |
|
659
|
|
- {__("System templates cannot change events")} |
|
1006
|
+ {isOverride |
|
1007
|
+ ? __( |
|
1008
|
+ "Same as the global template — an override can't change when it is sent, only what it says", |
|
1009
|
+ "yatra", |
|
1010
|
+ ) |
|
1011
|
+ : __("System templates cannot change events")} |
|
660
|
1012
|
</span> |
|
661
|
1013
|
</div> |
|
662
|
1014
|
{formData.event_key && ( |
|
663
|
1015
|
<div className="p-3 bg-indigo-50 dark:bg-indigo-900/20 rounded-lg border border-indigo-200 dark:border-indigo-800"> |
|
664
|
|
- <p className="text-sm text-indigo-700 dark:text-indigo-300"> |
|
1016
|
+ <p className="text-sm text-indigo-800 dark:text-indigo-300"> |
|
665
|
1017
|
{events.find((e: any) => e.key === formData.event_key) |
|
666
|
1018
|
?.description || ""} |
|
667
|
1019
|
</p> |
|
668
|
1020
|
</div> |
|
@@ -677,9 +1029,9 @@ |
|
677
|
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" |
|
678
|
1030
|
> |
|
679
|
1031
|
{formData.event_key ? ( |
|
680
|
1032
|
<div className="flex items-center gap-2"> |
|
681
|
|
- <Zap className="w-4 h-4 text-indigo-500" /> |
|
1033
|
+ <Zap className="w-4 h-4 text-blue-500" /> |
|
682
|
1034
|
<span className="text-sm font-medium text-gray-900 dark:text-white"> |
|
683
|
1035
|
{events.find( |
|
684
|
1036
|
(e: any) => e.key === formData.event_key, |
|
685
|
1037
|
)?.name || formData.event_key} |
|
@@ -725,9 +1077,9 @@ |
|
725
|
1077
|
<div className="flex items-start gap-2"> |
|
726
|
1078
|
<Mail |
|
727
|
1079
|
className={`w-4 h-4 mt-0.5 flex-shrink-0 ${ |
|
728
|
1080
|
!formData.event_key |
|
729
|
|
- ? "text-indigo-500" |
|
1081
|
+ ? "text-blue-500" |
|
730
|
1082
|
: "text-gray-400" |
|
731
|
1083
|
}`} |
|
732
|
1084
|
/> |
|
733
|
1085
|
<div className="flex-1 min-w-0"> |
|
@@ -734,9 +1086,9 @@ |
|
734
|
1086
|
<div className="flex items-center gap-2"> |
|
735
|
1087
|
<span |
|
736
|
1088
|
className={`text-sm font-medium ${ |
|
737
|
1089
|
!formData.event_key |
|
738
|
|
- ? "text-indigo-700 dark:text-indigo-400" |
|
1090
|
+ ? "text-blue-800 dark:text-blue-400" |
|
739
|
1091
|
: "text-gray-900 dark:text-white" |
|
740
|
1092
|
}`} |
|
741
|
1093
|
> |
|
742
|
1094
|
{__("No Event (Sequence Only)")} |
|
@@ -748,9 +1100,9 @@ |
|
748
|
1100
|
)} |
|
749
|
1101
|
</p> |
|
750
|
1102
|
</div> |
|
751
|
1103
|
{!formData.event_key && ( |
|
752
|
|
- <Check className="w-4 h-4 text-indigo-500 flex-shrink-0" /> |
|
1104
|
+ <Check className="w-4 h-4 text-blue-500 flex-shrink-0" /> |
|
753
|
1105
|
)} |
|
754
|
1106
|
</div> |
|
755
|
1107
|
</button> |
|
756
|
1108
|
{events.map((event: any) => ( |
|
@@ -773,9 +1125,9 @@ |
|
773
|
1125
|
<div className="flex items-start gap-2"> |
|
774
|
1126
|
<Zap |
|
775
|
1127
|
className={`w-4 h-4 mt-0.5 flex-shrink-0 ${ |
|
776
|
1128
|
formData.event_key === event.key |
|
777
|
|
- ? "text-indigo-500" |
|
1129
|
+ ? "text-blue-500" |
|
778
|
1130
|
: "text-gray-400" |
|
779
|
1131
|
}`} |
|
780
|
1132
|
/> |
|
781
|
1133
|
<div className="flex-1 min-w-0"> |
|
@@ -782,9 +1134,9 @@ |
|
782
|
1134
|
<div className="flex items-center gap-2"> |
|
783
|
1135
|
<span |
|
784
|
1136
|
className={`text-sm font-medium ${ |
|
785
|
1137
|
formData.event_key === event.key |
|
786
|
|
- ? "text-indigo-700 dark:text-indigo-400" |
|
1138
|
+ ? "text-blue-800 dark:text-blue-400" |
|
787
|
1139
|
: "text-gray-900 dark:text-white" |
|
788
|
1140
|
}`} |
|
789
|
1141
|
> |
|
790
|
1142
|
{event.name} |
|
@@ -797,13 +1149,20 @@ |
|
797
|
1149
|
{event.description} |
|
798
|
1150
|
</p> |
|
799
|
1151
|
</div> |
|
800
|
1152
|
{formData.event_key === event.key && ( |
|
801
|
|
- <Check className="w-4 h-4 text-indigo-500 flex-shrink-0" /> |
|
1153
|
+ <Check className="w-4 h-4 text-blue-500 flex-shrink-0" /> |
|
802
|
1154
|
)} |
|
803
|
1155
|
</div> |
|
804
|
1156
|
</button> |
|
805
|
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
|
+ )} |
|
806
|
1165
|
</div> |
|
807
|
1166
|
</div> |
|
808
|
1167
|
)} |
|
809
|
1168
|
|
|
@@ -865,8 +1224,42 @@ |
|
865
|
1224
|
</p> |
|
866
|
1225
|
</div> |
|
867
|
1226
|
</div> |
|
868
|
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
|
+ |
|
869
|
1262
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> |
|
870
|
1263
|
<div> |
|
871
|
1264
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"> |
|
872
|
1265
|
{__("To Email")} |
|
@@ -915,8 +1308,51 @@ |
|
915
|
1308
|
)} |
|
916
|
1309
|
</p> |
|
917
|
1310
|
)} |
|
918
|
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
|
+ |
|
919
|
1355
|
<div> |
|
920
|
1356
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"> |
|
921
|
1357
|
{__("Subject Line")} |
|
922
|
1358
|
</label> |
|
@@ -934,11 +1370,31 @@ |
|
934
|
1370
|
</p> |
|
935
|
1371
|
</div> |
|
936
|
1372
|
|
|
937
|
1373
|
<div> |
|
938
|
|
- <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"> |
|
939
|
|
- {__("Email Body (HTML)")} |
|
940
|
|
- </label> |
|
1374
|
+ {/* Label + AI sparkle on the same row — sparkle sits |
|
1375
|
+ to the right of the "Email Body (HTML)" label so |
|
1376
|
+ it's visually associated with the editor it |
|
1377
|
+ affects, not floating above Subject Line. */} |
|
1378
|
+ <div className="mb-1 flex items-center justify-between"> |
|
1379
|
+ <label className="block text-sm font-medium text-gray-700 dark:text-gray-300"> |
|
1380
|
+ {__("Email Body (HTML)")} |
|
1381
|
+ </label> |
|
1382
|
+ {!isCoreBodyReadOnly && |
|
1383
|
+ isAiEligible() && |
|
1384
|
+ isAiModuleEnabled() && ( |
|
1385
|
+ <Button |
|
1386
|
+ type="button" |
|
1387
|
+ variant="outline" |
|
1388
|
+ size="sm" |
|
1389
|
+ onClick={() => setShowAiModal(true)} |
|
1390
|
+ className="border-blue-300 text-blue-700 hover:bg-blue-50 dark:border-blue-500/40 dark:text-blue-200 dark:hover:bg-blue-900/30" |
|
1391
|
+ > |
|
1392
|
+ <Sparkles className="mr-1.5 h-3.5 w-3.5" /> |
|
1393
|
+ {__("Generate with AI", "yatra")} |
|
1394
|
+ </Button> |
|
1395
|
+ )} |
|
1396
|
+ </div> |
|
941
|
1397
|
<textarea |
|
942
|
1398
|
id="email-body" |
|
943
|
1399
|
value={formData.body} |
|
944
|
1400
|
onChange={(e) => |
|
@@ -956,8 +1412,117 @@ |
|
956
|
1412
|
</div> |
|
957
|
1413
|
</CardContent> |
|
958
|
1414
|
</Card> |
|
959
|
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
|
+ )} |
|
960
|
1525
|
{/* Test Email - Only show in edit mode */} |
|
961
|
1526
|
{!isCreateMode && !isCoreSettingsEdit && ( |
|
962
|
1527
|
<Card> |
|
963
|
1528
|
<CardHeader> |
|
@@ -1000,17 +1565,60 @@ |
|
1000
|
1565
|
<div className="space-y-6"> |
|
1001
|
1566
|
<Card> |
|
1002
|
1567
|
<CardHeader> |
|
1003
|
1568
|
<CardTitle className="flex items-center gap-2"> |
|
1004
|
|
- <Code className="w-5 h-5 text-purple-500" /> |
|
1569
|
+ <Code className="w-5 h-5 text-blue-500" /> |
|
1005
|
1570
|
{__("Available Variables")} |
|
1006
|
1571
|
</CardTitle> |
|
1007
|
1572
|
</CardHeader> |
|
1008
|
1573
|
<CardContent className="space-y-4"> |
|
1009
|
1574
|
{!isCoreSettingsEdit && ( |
|
1010
|
|
- <p className="text-sm text-gray-500 dark:text-gray-400"> |
|
1011
|
|
- {__("Click to copy, double-click to insert at cursor")} |
|
1012
|
|
- </p> |
|
1575
|
+ <> |
|
1576
|
+ <p className="text-sm text-gray-500 dark:text-gray-400"> |
|
1577
|
+ {__("Click to copy, double-click to insert at cursor")} |
|
1578
|
+ </p> |
|
1579
|
+ {/* |
|
1580
|
+ Subtitle showing which event the variables list is |
|
1581
|
+ scoped to. Without this hint, operators see a |
|
1582
|
+ different set of tags every time they switch the |
|
1583
|
+ event dropdown and have no clear explanation why. |
|
1584
|
+ */} |
|
1585
|
+ {formData.event_key ? ( |
|
1586
|
+ // Show the technical event KEY (dot-notation, monospace) |
|
1587
|
+ // as the primary label so it can't be confused with the |
|
1588
|
+ // template's display title — earlier versions showed |
|
1589
|
+ // the event's friendly name, which often matches what |
|
1590
|
+ // operators name their templates ("Customer email |
|
1591
|
+ // verification" appears as both the event name and a |
|
1592
|
+ // common template title) and made it unclear what the |
|
1593
|
+ // chip was scoped to. Friendly name still appears as |
|
1594
|
+ // the parenthesised clarifier. |
|
1595
|
+ <p className="text-xs text-blue-700 dark:text-blue-300 bg-blue-50 dark:bg-blue-950/30 rounded px-2 py-1.5"> |
|
1596
|
+ {__("Variables available for trigger event:", "yatra")}{" "} |
|
1597
|
+ <code className="font-mono text-blue-800 dark:text-blue-200 bg-blue-100 dark:bg-blue-900/40 px-1 rounded"> |
|
1598
|
+ {formData.event_key} |
|
1599
|
+ </code> |
|
1600
|
+ {(() => { |
|
1601
|
+ const match = events.find( |
|
1602
|
+ (e: any) => e.key === formData.event_key, |
|
1603
|
+ ); |
|
1604
|
+ return match?.name ? ( |
|
1605
|
+ <span className="text-blue-600/80 dark:text-blue-400/80"> |
|
1606
|
+ {" "} |
|
1607
|
+ ({match.name}) |
|
1608
|
+ </span> |
|
1609
|
+ ) : null; |
|
1610
|
+ })()} |
|
1611
|
+ </p> |
|
1612
|
+ ) : ( |
|
1613
|
+ <p className="text-xs text-amber-700 dark:text-amber-400 bg-amber-50 dark:bg-amber-950/30 rounded px-2 py-1.5"> |
|
1614
|
+ {__( |
|
1615
|
+ "Pick a trigger event below to see the variables that apply to that event. Showing the full catalog until then.", |
|
1616
|
+ "yatra", |
|
1617
|
+ )} |
|
1618
|
+ </p> |
|
1619
|
+ )} |
|
1620
|
+ </> |
|
1013
|
1621
|
)} |
|
1014
|
1622
|
|
|
1015
|
1623
|
{isCoreSettingsEdit && coreDef?.mergeTags ? ( |
|
1016
|
1624
|
<div className="space-y-2"> |
|
@@ -1094,13 +1702,38 @@ |
|
1094
|
1702
|
<div className="flex justify-between items-center"> |
|
1095
|
1703
|
<span className="text-gray-500 dark:text-gray-400"> |
|
1096
|
1704
|
{__("Event")} |
|
1097
|
1705
|
</span> |
|
1098
|
|
- <span className="inline-flex items-center gap-1.5 px-2 py-1 rounded-full text-xs font-medium bg-indigo-100 text-indigo-700 dark:bg-indigo-900/30 dark:text-indigo-400"> |
|
1706
|
+ <span className="inline-flex items-center gap-1.5 px-2 py-1 rounded-full text-xs font-medium bg-indigo-100 text-indigo-800 dark:bg-indigo-900/30 dark:text-indigo-400"> |
|
1099
|
1707
|
<Zap className="w-3 h-3" /> |
|
1100
|
1708
|
{formData.event_key || "-"} |
|
1101
|
1709
|
</span> |
|
1102
|
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
|
+ )} |
|
1103
|
1736
|
{formData.event_key && ( |
|
1104
|
1737
|
<p className="text-xs text-gray-500 dark:text-gray-400 italic"> |
|
1105
|
1738
|
{events.find((e: any) => e.key === formData.event_key) |
|
1106
|
1739
|
?.description || ""} |
|
@@ -1194,8 +1827,67 @@ |
|
1194
|
1827
|
}} |
|
1195
|
1828
|
subject={previewData?.subject ?? ""} |
|
1196
|
1829
|
body={previewData?.body ?? ""} |
|
1197
|
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
|
+ )} |
|
1198
|
1890
|
</div> |
|
1199
|
1891
|
); |
|
1200
|
1892
|
}; |
|
1201
|
1893
|
|