| @@ -1,7 +1,22 @@ | ||
| 1 | 1 | import React, { useState, useEffect, useMemo } from "react"; |
| 2 | 2 | import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; |
| 3 | -import { __ } from "../lib/i18n"; | |
| 3 | +import { __, sprintf } from "../lib/i18n"; | |
| 4 | +import { MultiSelect } from "../components/ui/multi-select"; | |
| 5 | +import { SearchableSelect } from "../components/ui/searchable-select"; | |
| 6 | +import { | |
| 7 | + decodeTargets, | |
| 8 | + describeTargets, | |
| 9 | + encodeTargets, | |
| 10 | + hasTargets, | |
| 11 | + useTripTargets, | |
| 12 | + type TripTargets, | |
| 13 | +} from "../hooks/useTripTargets"; | |
| 14 | +import { EmailOverrideCreateModal } from "../components/email/EmailOverrideCreateModal"; | |
| 15 | +import { | |
| 16 | + fetchEmailTemplates, | |
| 17 | + resolveEmailTemplatesForTrip, | |
| 18 | +} from "../api/email-automation-api"; | |
| 4 | 19 | import { useToast } from "../components/ui/toast"; |
| 5 | 20 | import { |
| 6 | 21 | Card, |
| 7 | 22 | CardContent, |
| @@ -29,8 +44,9 @@ | ||
| 29 | 44 | import { AiEmailTemplateModal } from "../components/ai/AiEmailTemplateModal"; |
| 30 | 45 | import { isAiEligible, isAiModuleEnabled } from "../lib/ai-availability"; |
| 31 | 46 | import { Switch } from "../components/ui/switch"; |
| 32 | 47 | import { |
| 48 | + EMAIL_TEMPLATES_CATALOG, | |
| 33 | 49 | getCatalogEntryByTemplateKey, |
| 34 | 50 | getCoreTemplateDefinition, |
| 35 | 51 | isCoreTemplateSlug, |
| 36 | 52 | } from "../lib/email-templates-catalog"; |
| @@ -75,8 +91,18 @@ | ||
| 75 | 91 | }; |
| 76 | 92 | }, []); |
| 77 | 93 | |
| 78 | 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 | + | |
| 79 | 105 | const coreDef = isCoreSettingsEdit |
| 80 | 106 | ? getCoreTemplateDefinition(coreTemplateSlug) |
| 81 | 107 | : undefined; |
| 82 | 108 | |
| @@ -120,13 +146,21 @@ | ||
| 120 | 146 | from_name: "", |
| 121 | 147 | from_email: "", |
| 122 | 148 | to_email: "", |
| 123 | 149 | reply_to: "", |
| 150 | + bcc: "", | |
| 151 | + cc: "", | |
| 152 | + category: "booking", | |
| 124 | 153 | subject: "", |
| 125 | 154 | body: "", |
| 126 | 155 | event_key: "", |
| 127 | 156 | is_active: true, |
| 128 | 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); | |
| 129 | 163 | const [showPreview, setShowPreview] = useState(false); |
| 130 | 164 | const [previewData, setPreviewData] = useState<PreviewData | null>(null); |
| 131 | 165 | const [testEmail, setTestEmail] = useState(""); |
| 132 | 166 | const [copiedVar, setCopiedVar] = useState<string | null>(null); |
| @@ -161,8 +195,67 @@ | ||
| 161 | 195 | queryFn: () => fetchEmailTemplate(id as string), |
| 162 | 196 | enabled: Boolean(id) && !isCoreSettingsEdit, |
| 163 | 197 | }); |
| 164 | 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 | + | |
| 165 | 258 | // Event-scoped variables: the sidebar re-fetches whenever the |
| 166 | 259 | // operator switches the trigger event so the "Available |
| 167 | 260 | // Variables" panel reflects only tags that will actually |
| 168 | 261 | // resolve at send-time for the chosen event. Empty event_key |
| @@ -185,16 +278,34 @@ | ||
| 185 | 278 | queryKey: ["email-template-events"], |
| 186 | 279 | queryFn: fetchEmailTemplateEvents, |
| 187 | 280 | enabled: !isCoreSettingsEdit && isEmailAutomationModuleEnabled(), |
| 188 | 281 | }); |
| 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 || []; | |
| 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]); | |
| 197 | 308 | |
| 198 | 309 | useEffect(() => { |
| 199 | 310 | if (!templateData || typeof templateData !== "object") return; |
| 200 | 311 | const t = templateData as Record<string, unknown>; |
| @@ -204,13 +315,23 @@ | ||
| 204 | 315 | from_name: String(t.from_name ?? ""), |
| 205 | 316 | from_email: String(t.from_email ?? ""), |
| 206 | 317 | to_email: String(t.to_email ?? ""), |
| 207 | 318 | reply_to: String(t.reply_to ?? ""), |
| 319 | + bcc: String(t.bcc ?? ""), | |
| 320 | + cc: String(t.cc ?? ""), | |
| 321 | + category: String(t.category ?? "booking"), | |
| 208 | 322 | subject: String(t.subject ?? ""), |
| 209 | 323 | body: String(t.body ?? ""), |
| 210 | - 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 || ""), | |
| 211 | 327 | is_active: Boolean(t.is_active ?? true), |
| 212 | 328 | }); |
| 329 | + setTargets( | |
| 330 | + t.overrides && t.targets && typeof t.targets === "object" | |
| 331 | + ? (t.targets as TripTargets) | |
| 332 | + : {}, | |
| 333 | + ); | |
| 213 | 334 | }, [templateData]); |
| 214 | 335 | |
| 215 | 336 | useEffect(() => { |
| 216 | 337 | if (!isCoreSettingsEdit || !settings || !coreDef) return; |
| @@ -225,8 +346,11 @@ | ||
| 225 | 346 | from_name: "", |
| 226 | 347 | from_email: "", |
| 227 | 348 | to_email: coreDef.to_email || "", |
| 228 | 349 | reply_to: "", |
| 350 | + bcc: String(s[coreAddressKey(subj, "bcc") ?? ""] ?? ""), | |
| 351 | + cc: String(s[coreAddressKey(subj, "cc") ?? ""] ?? ""), | |
| 352 | + category: String(coreDef.category ?? "booking"), | |
| 229 | 353 | subject: String(s[subj] ?? ""), |
| 230 | 354 | body: String(s[bodyKey] ?? ""), |
| 231 | 355 | event_key: coreDef.event_key, |
| 232 | 356 | is_active: Boolean(s[flag]), |
| @@ -301,8 +425,13 @@ | ||
| 301 | 425 | } |
| 302 | 426 | base[coreDef.settingsSubject] = formData.subject; |
| 303 | 427 | base[coreDef.settingsBody] = formData.body; |
| 304 | 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; | |
| 305 | 434 | await saveSettings(base); |
| 306 | 435 | return base; |
| 307 | 436 | }, |
| 308 | 437 | onSuccess: (saved) => { |
| @@ -317,9 +446,9 @@ | ||
| 317 | 446 | }); |
| 318 | 447 | |
| 319 | 448 | const previewMutation = useMutation({ |
| 320 | 449 | mutationFn: async () => { |
| 321 | - return previewEmailTemplate(id as string); | |
| 450 | + return previewEmailTemplate(id as string, previewTrip); | |
| 322 | 451 | }, |
| 323 | 452 | onSuccess: (data) => { |
| 324 | 453 | setPreviewData(data); |
| 325 | 454 | setShowPreview(true); |
| @@ -352,9 +481,9 @@ | ||
| 352 | 481 | }); |
| 353 | 482 | |
| 354 | 483 | const testMutation = useMutation({ |
| 355 | 484 | mutationFn: async (email: string) => { |
| 356 | - return sendEmailTemplateTest(id as string, email); | |
| 485 | + return sendEmailTemplateTest(id as string, email, previewTrip); | |
| 357 | 486 | }, |
| 358 | 487 | onSuccess: () => { |
| 359 | 488 | showToast(__("Test email sent successfully"), "success"); |
| 360 | 489 | setTestEmail(""); |
| @@ -382,16 +511,69 @@ | ||
| 382 | 511 | if (!formData.subject.trim()) { |
| 383 | 512 | showToast(__("Subject line is required"), "error"); |
| 384 | 513 | return; |
| 385 | 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 | + } | |
| 386 | 525 | |
| 387 | - 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 | + ); | |
| 388 | 536 | }; |
| 389 | 537 | |
| 390 | 538 | const copyVariable = (variable: string) => { |
| 391 | - navigator.clipboard.writeText(`{{${variable}}}`); | |
| 392 | - setCopiedVar(variable); | |
| 393 | - 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 | + } | |
| 394 | 576 | }; |
| 395 | 577 | |
| 396 | 578 | const insertVariable = (variable: string) => { |
| 397 | 579 | if (isCoreBodyReadOnly) { |
| @@ -550,8 +732,33 @@ | ||
| 550 | 732 | "Core customer email (saved with site settings). From address is set under Email → Delivery.", |
| 551 | 733 | "yatra", |
| 552 | 734 | ) |
| 553 | 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 | + )} | |
| 554 | 761 | </p> |
| 555 | 762 | </div> |
| 556 | 763 | </div> |
| 557 | 764 | <div className="flex items-center gap-3"> |
| @@ -613,8 +820,118 @@ | ||
| 613 | 820 | |
| 614 | 821 | <div className="grid grid-cols-1 lg:grid-cols-3 gap-6"> |
| 615 | 822 | {/* Main Form */} |
| 616 | 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 | + )} | |
| 617 | 934 | <Card> |
| 618 | 935 | <CardHeader> |
| 619 | 936 | <CardTitle className="flex items-center justify-between"> |
| 620 | 937 | <span>{__("Template Details")}</span> |
| @@ -675,9 +992,11 @@ | ||
| 675 | 992 | <div> |
| 676 | 993 | <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"> |
| 677 | 994 | {__("Trigger Event")} |
| 678 | 995 | </label> |
| 679 | - {Boolean(templateData?.is_system) || isCoreSettingsEdit ? ( | |
| 996 | + {Boolean(templateData?.is_system) || | |
| 997 | + isCoreSettingsEdit || | |
| 998 | + isOverride ? ( | |
| 680 | 999 | <div className="space-y-2"> |
| 681 | 1000 | <div className="flex items-center gap-2"> |
| 682 | 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"> |
| 683 | 1002 | <Zap className="w-4 h-4" /> |
| @@ -683,9 +1002,14 @@ | ||
| 683 | 1002 | <Zap className="w-4 h-4" /> |
| 684 | 1003 | {formData.event_key || "-"} |
| 685 | 1004 | </span> |
| 686 | 1005 | <span className="text-xs text-gray-500 dark:text-gray-400"> |
| 687 | - {__("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")} | |
| 688 | 1012 | </span> |
| 689 | 1013 | </div> |
| 690 | 1014 | {formData.event_key && ( |
| 691 | 1015 | <div className="p-3 bg-indigo-50 dark:bg-indigo-900/20 rounded-lg border border-indigo-200 dark:border-indigo-800"> |
| @@ -900,8 +1224,42 @@ | ||
| 900 | 1224 | </p> |
| 901 | 1225 | </div> |
| 902 | 1226 | </div> |
| 903 | 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 | + | |
| 904 | 1262 | <div className="grid grid-cols-1 md:grid-cols-2 gap-4"> |
| 905 | 1263 | <div> |
| 906 | 1264 | <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"> |
| 907 | 1265 | {__("To Email")} |
| @@ -950,8 +1308,51 @@ | ||
| 950 | 1308 | )} |
| 951 | 1309 | </p> |
| 952 | 1310 | )} |
| 953 | 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 | + | |
| 954 | 1355 | <div> |
| 955 | 1356 | <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"> |
| 956 | 1357 | {__("Subject Line")} |
| 957 | 1358 | </label> |
| @@ -1011,8 +1412,117 @@ | ||
| 1011 | 1412 | </div> |
| 1012 | 1413 | </CardContent> |
| 1013 | 1414 | </Card> |
| 1014 | 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 | + )} | |
| 1015 | 1525 | {/* Test Email - Only show in edit mode */} |
| 1016 | 1526 | {!isCreateMode && !isCoreSettingsEdit && ( |
| 1017 | 1527 | <Card> |
| 1018 | 1528 | <CardHeader> |
| @@ -1197,8 +1707,33 @@ | ||
| 1197 | 1707 | <Zap className="w-3 h-3" /> |
| 1198 | 1708 | {formData.event_key || "-"} |
| 1199 | 1709 | </span> |
| 1200 | 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 | + )} | |
| 1201 | 1736 | {formData.event_key && ( |
| 1202 | 1737 | <p className="text-xs text-gray-500 dark:text-gray-400 italic"> |
| 1203 | 1738 | {events.find((e: any) => e.key === formData.event_key) |
| 1204 | 1739 | ?.description || ""} |
| @@ -1343,8 +1878,16 @@ | ||
| 1343 | 1878 | onAccept={({ subject, body }) => |
| 1344 | 1879 | setFormData({ ...formData, subject, body }) |
| 1345 | 1880 | } |
| 1346 | 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 | + )} | |
| 1347 | 1890 | </div> |
| 1348 | 1891 | ); |
| 1349 | 1892 | }; |
| 1350 | 1893 | |