| @@ -5,8 +5,9 @@ | ||
| 5 | 5 | deleteEmailSequence, |
| 6 | 6 | fetchEmailLogs, |
| 7 | 7 | fetchEmailSequences, |
| 8 | 8 | updateEmailSequenceStatus, |
| 9 | + fetchEmailTemplates, | |
| 9 | 10 | } from "../api/email-automation-api"; |
| 10 | 11 | import { useToast } from "../components/ui/toast"; |
| 11 | 12 | import { |
| 12 | 13 | Card, |
| @@ -18,8 +19,11 @@ | ||
| 18 | 19 | import { Button } from "../components/ui/button"; |
| 19 | 20 | import { Badge } from "../components/ui/badge"; |
| 20 | 21 | import { PageHeader } from "../components/common/PageHeader"; |
| 21 | 22 | import { ConfirmationDialog } from "../components/ui/confirmation-dialog"; |
| 23 | +import { Modal } from "../components/ui/modal"; | |
| 24 | +import { Alert } from "../components/ui/alert"; | |
| 25 | +import { Label } from "../components/ui/label"; | |
| 22 | 26 | import PremiumUpgradeCard from "./premium-pages/EmailAutomation"; |
| 23 | 27 | import { |
| 24 | 28 | Mail, |
| 25 | 29 | Edit, |
| @@ -47,13 +51,25 @@ | ||
| 47 | 51 | import { Pagination } from "../components/shared"; |
| 48 | 52 | |
| 49 | 53 | interface EmailLog { |
| 50 | 54 | id: number; |
| 55 | + template_id?: number | null; | |
| 51 | 56 | template_key: string; |
| 57 | + sequence_id?: number | null; | |
| 52 | 58 | recipient_email: string; |
| 53 | 59 | recipient_name: string; |
| 54 | 60 | subject: string; |
| 61 | + // Full HTML body — only loaded when the detail modal asks for it | |
| 62 | + // (the list query also returns it, but it can be large; the modal | |
| 63 | + // is the only consumer of this field). | |
| 64 | + body?: string; | |
| 65 | + context_type?: string | null; | |
| 66 | + context_id?: number | null; | |
| 55 | 67 | status: "sent" | "failed" | "opened" | "clicked"; |
| 68 | + error_message?: string | null; | |
| 69 | + // Server-side JSON-encoded — may arrive as a string OR a parsed object | |
| 70 | + // depending on the response shape. | |
| 71 | + metadata?: string | Record<string, unknown> | null; | |
| 56 | 72 | sent_at: string; |
| 57 | 73 | } |
| 58 | 74 | |
| 59 | 75 | interface EmailSequence { |
| @@ -77,8 +93,11 @@ | ||
| 77 | 93 | queryFn: () => fetchEmailSequences(), |
| 78 | 94 | enabled: isEmailAutomationModuleEnabled(), |
| 79 | 95 | }); |
| 80 | 96 | |
| 97 | + // Shared dialog rather than the browser's native confirm(). | |
| 98 | + const [sequenceToDelete, setSequenceToDelete] = useState<number | null>(null); | |
| 99 | + | |
| 81 | 100 | const deleteMutation = useMutation({ |
| 82 | 101 | mutationFn: async (id: number) => { |
| 83 | 102 | return await deleteEmailSequence(id); |
| 84 | 103 | }, |
| @@ -293,19 +312,9 @@ | ||
| 293 | 312 | </Button> |
| 294 | 313 | <Button |
| 295 | 314 | variant="ghost" |
| 296 | 315 | size="sm" |
| 297 | - onClick={() => { | |
| 298 | - if ( | |
| 299 | - confirm( | |
| 300 | - __( | |
| 301 | - "Are you sure you want to delete this sequence?", | |
| 302 | - ), | |
| 303 | - ) | |
| 304 | - ) { | |
| 305 | - deleteMutation.mutate(sequence.id); | |
| 306 | - } | |
| 307 | - }} | |
| 316 | + onClick={() => setSequenceToDelete(sequence.id)} | |
| 308 | 317 | > |
| 309 | 318 | <Trash2 className="w-4 h-4 text-red-500" /> |
| 310 | 319 | </Button> |
| 311 | 320 | </div> |
| @@ -314,8 +323,28 @@ | ||
| 314 | 323 | </Card> |
| 315 | 324 | ))} |
| 316 | 325 | </div> |
| 317 | 326 | )} |
| 327 | + | |
| 328 | + <ConfirmationDialog | |
| 329 | + isOpen={sequenceToDelete !== null} | |
| 330 | + onClose={() => setSequenceToDelete(null)} | |
| 331 | + onConfirm={() => { | |
| 332 | + if (sequenceToDelete !== null) { | |
| 333 | + deleteMutation.mutate(sequenceToDelete); | |
| 334 | + } | |
| 335 | + setSequenceToDelete(null); | |
| 336 | + }} | |
| 337 | + title={__("Delete Sequence", "yatra")} | |
| 338 | + message={__( | |
| 339 | + "Are you sure you want to delete this sequence? This action cannot be undone.", | |
| 340 | + "yatra", | |
| 341 | + )} | |
| 342 | + confirmText={__("Delete", "yatra")} | |
| 343 | + cancelText={__("Cancel", "yatra")} | |
| 344 | + variant="danger" | |
| 345 | + isLoading={deleteMutation.isPending} | |
| 346 | + /> | |
| 318 | 347 | </div> |
| 319 | 348 | ); |
| 320 | 349 | }; |
| 321 | 350 | |
| @@ -321,8 +350,13 @@ | ||
| 321 | 350 | |
| 322 | 351 | const EmailLogsList: React.FC = () => { |
| 323 | 352 | const [page, setPage] = useState(1); |
| 324 | 353 | const perPage = 20; |
| 354 | + // Modal state for the per-row "View details" action. Holds the | |
| 355 | + // selected log row so the modal can render the full payload without | |
| 356 | + // a second API roundtrip — the list query already returns body + | |
| 357 | + // metadata. | |
| 358 | + const [viewingLog, setViewingLog] = useState<EmailLog | null>(null); | |
| 325 | 359 | |
| 326 | 360 | const { data: logsData, isLoading } = useQuery({ |
| 327 | 361 | queryKey: ["email-logs", page, perPage], |
| 328 | 362 | queryFn: () => fetchEmailLogs({ page, per_page: perPage }), |
| @@ -327,8 +361,40 @@ | ||
| 327 | 361 | queryKey: ["email-logs", page, perPage], |
| 328 | 362 | queryFn: () => fetchEmailLogs({ page, per_page: perPage }), |
| 329 | 363 | enabled: isEmailAutomationModuleEnabled(), |
| 330 | 364 | }); |
| 365 | + // Template names for the log rows: an override's key is machine-generated | |
| 366 | + // ("booking_confirmation__ov_…"), so show "Global name · Override name". | |
| 367 | + const { data: templatesForNames } = useQuery({ | |
| 368 | + queryKey: ["email-templates"], | |
| 369 | + queryFn: () => fetchEmailTemplates(), | |
| 370 | + enabled: isEmailAutomationModuleEnabled(), | |
| 371 | + }); | |
| 372 | + const templateLabel = useCallback( | |
| 373 | + (key: string | null | undefined): string => { | |
| 374 | + if (!key) return "—"; | |
| 375 | + const list = Array.isArray(templatesForNames) | |
| 376 | + ? (templatesForNames as any[]) | |
| 377 | + : []; | |
| 378 | + const row = list.find((t) => t.template_key === key); | |
| 379 | + if (!row) { | |
| 380 | + // A deleted override: derive its global template from the key | |
| 381 | + // ("booking_confirmation__ov_1a2b3c4d") so the log stays readable. | |
| 382 | + const m = key.match(/^(.+)__ov_[a-z0-9]+$/); | |
| 383 | + if (m) { | |
| 384 | + const parent = list.find((t) => t.template_key === m[1]); | |
| 385 | + return `${parent?.name || m[1]} · ${__("Override (deleted)", "yatra")}`; | |
| 386 | + } | |
| 387 | + return key; | |
| 388 | + } | |
| 389 | + if (row.overrides) { | |
| 390 | + const parent = list.find((t) => t.template_key === row.overrides); | |
| 391 | + return `${parent?.name || row.overrides} · ${__("Override", "yatra")}: ${row.name}`; | |
| 392 | + } | |
| 393 | + return row.name || key; | |
| 394 | + }, | |
| 395 | + [templatesForNames], | |
| 396 | + ); | |
| 331 | 397 | |
| 332 | 398 | const logs = (logsData?.items ?? []) as EmailLog[]; |
| 333 | 399 | const totalItems = logsData?.total ?? 0; |
| 334 | 400 | const totalPages = Math.max(1, Math.ceil(totalItems / perPage)); |
| @@ -401,8 +467,11 @@ | ||
| 401 | 467 | </th> |
| 402 | 468 | <th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase"> |
| 403 | 469 | {__("Sent")} |
| 404 | 470 | </th> |
| 471 | + <th className="px-4 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase"> | |
| 472 | + {__("Actions")} | |
| 473 | + </th> | |
| 405 | 474 | </tr> |
| 406 | 475 | </thead> |
| 407 | 476 | <tbody className="divide-y divide-gray-200 dark:divide-gray-700"> |
| 408 | 477 | {logs.map((log) => ( |
| @@ -420,10 +489,13 @@ | ||
| 420 | 489 | </td> |
| 421 | 490 | <td className="px-4 py-3 text-sm text-gray-900 dark:text-white max-w-xs truncate"> |
| 422 | 491 | {log.subject} |
| 423 | 492 | </td> |
| 424 | - <td className="px-4 py-3 text-sm text-gray-500 dark:text-gray-400"> | |
| 425 | - {log.template_key} | |
| 493 | + <td | |
| 494 | + className="px-4 py-3 text-sm text-gray-500 dark:text-gray-400" | |
| 495 | + title={log.template_key} | |
| 496 | + > | |
| 497 | + {templateLabel(log.template_key)} | |
| 426 | 498 | </td> |
| 427 | 499 | <td className="px-4 py-3"> |
| 428 | 500 | <Badge |
| 429 | 501 | className={ |
| @@ -441,8 +513,17 @@ | ||
| 441 | 513 | {log.sent_at |
| 442 | 514 | ? new Date(log.sent_at).toLocaleString() |
| 443 | 515 | : "—"} |
| 444 | 516 | </td> |
| 517 | + <td className="px-4 py-3 text-right"> | |
| 518 | + <button | |
| 519 | + type="button" | |
| 520 | + onClick={() => setViewingLog(log)} | |
| 521 | + className="text-xs font-medium text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300 hover:underline" | |
| 522 | + > | |
| 523 | + {__("View details", "yatra")} | |
| 524 | + </button> | |
| 525 | + </td> | |
| 445 | 526 | </tr> |
| 446 | 527 | ))} |
| 447 | 528 | </tbody> |
| 448 | 529 | </table> |
| @@ -460,12 +541,270 @@ | ||
| 460 | 541 | /> |
| 461 | 542 | </div> |
| 462 | 543 | )} |
| 463 | 544 | </CardContent> |
| 545 | + | |
| 546 | + {viewingLog && ( | |
| 547 | + <EmailLogDetailsModal | |
| 548 | + log={viewingLog} | |
| 549 | + onClose={() => setViewingLog(null)} | |
| 550 | + /> | |
| 551 | + )} | |
| 464 | 552 | </Card> |
| 465 | 553 | ); |
| 466 | 554 | }; |
| 467 | 555 | |
| 556 | +/* -------------------------------------------------------------------------- */ | |
| 557 | +/* EmailLogDetailsModal */ | |
| 558 | +/* */ | |
| 559 | +/* Read-only inspector for a single email log row. Shows recipient, status, */ | |
| 560 | +/* template, full HTML body, error message (if failed), and metadata JSON. */ | |
| 561 | +/* */ | |
| 562 | +/* Body is rendered inside a sandboxed iframe (srcdoc + sandbox="") so: */ | |
| 563 | +/* 1. Operator-visible markup looks like the real email */ | |
| 564 | +/* 2. The iframe can't navigate the parent, run scripts, or read storage */ | |
| 565 | +/* 3. Plugin CSS doesn't leak into the preview (and vice versa) */ | |
| 566 | +/* -------------------------------------------------------------------------- */ | |
| 567 | + | |
| 568 | +const EmailLogDetailsModal: React.FC<{ | |
| 569 | + log: EmailLog; | |
| 570 | + onClose: () => void; | |
| 571 | +}> = ({ log, onClose }) => { | |
| 572 | + // Body view toggle — "preview" renders the HTML in a sandboxed | |
| 573 | + // iframe; "raw" shows the source markup in a <pre>. Operators | |
| 574 | + // debugging template variables / merge tags want the raw view to | |
| 575 | + // verify substitutions; visual proofing wants the preview. | |
| 576 | + const [bodyView, setBodyView] = useState<"preview" | "raw">("preview"); | |
| 577 | + // Tracks whether the raw HTML has been copied to clipboard so we | |
| 578 | + // can flash a "Copied!" label briefly. | |
| 579 | + const [copied, setCopied] = useState(false); | |
| 580 | + const copyRawToClipboard = async () => { | |
| 581 | + if (!log.body) return; | |
| 582 | + try { | |
| 583 | + await navigator.clipboard.writeText(log.body); | |
| 584 | + setCopied(true); | |
| 585 | + window.setTimeout(() => setCopied(false), 1500); | |
| 586 | + } catch { | |
| 587 | + /* clipboard denied — silently skip */ | |
| 588 | + } | |
| 589 | + }; | |
| 590 | + | |
| 591 | + // Parse metadata defensively — server stores it as a JSON string in | |
| 592 | + // longtext, but some code paths may deliver it as a parsed object. | |
| 593 | + const metadata: Record<string, unknown> | null = useMemo(() => { | |
| 594 | + if (!log.metadata) return null; | |
| 595 | + if (typeof log.metadata === "object") | |
| 596 | + return log.metadata as Record<string, unknown>; | |
| 597 | + try { | |
| 598 | + const parsed = JSON.parse(String(log.metadata)); | |
| 599 | + return typeof parsed === "object" && parsed !== null ? parsed : null; | |
| 600 | + } catch { | |
| 601 | + return null; | |
| 602 | + } | |
| 603 | + }, [log.metadata]); | |
| 604 | + | |
| 605 | + const statusClass = | |
| 606 | + log.status === "sent" | |
| 607 | + ? "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400" | |
| 608 | + : log.status === "failed" | |
| 609 | + ? "bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400" | |
| 610 | + : "bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400"; | |
| 611 | + | |
| 612 | + return ( | |
| 613 | + <Modal | |
| 614 | + isOpen | |
| 615 | + onClose={onClose} | |
| 616 | + title={ | |
| 617 | + <div className="flex items-center gap-2"> | |
| 618 | + <Mail className="w-5 h-5 text-blue-500" /> | |
| 619 | + {__("Email details", "yatra")} | |
| 620 | + </div> | |
| 621 | + } | |
| 622 | + size="full" | |
| 623 | + footer={ | |
| 624 | + <div className="flex justify-end"> | |
| 625 | + <Button variant="outline" onClick={onClose}> | |
| 626 | + {__("Close", "yatra")} | |
| 627 | + </Button> | |
| 628 | + </div> | |
| 629 | + } | |
| 630 | + > | |
| 631 | + <div className="space-y-4"> | |
| 632 | + {/* Header strip — status + sent_at + recipient at a glance */} | |
| 633 | + <div className="rounded-md border border-gray-200 dark:border-gray-700 p-3 space-y-2"> | |
| 634 | + <div className="flex flex-wrap items-center gap-2"> | |
| 635 | + <Badge className={statusClass}>{log.status}</Badge> | |
| 636 | + <span className="text-xs text-gray-500 dark:text-gray-400"> | |
| 637 | + {log.sent_at ? new Date(log.sent_at).toLocaleString() : "—"} | |
| 638 | + </span> | |
| 639 | + </div> | |
| 640 | + <dl className="grid grid-cols-1 sm:grid-cols-2 gap-x-4 gap-y-2 text-sm"> | |
| 641 | + <DetailRow label={__("Recipient", "yatra")}> | |
| 642 | + <div className="font-medium text-gray-900 dark:text-white"> | |
| 643 | + {log.recipient_name || "—"} | |
| 644 | + </div> | |
| 645 | + <div className="text-gray-500 dark:text-gray-400 break-all"> | |
| 646 | + {log.recipient_email} | |
| 647 | + </div> | |
| 648 | + </DetailRow> | |
| 649 | + <DetailRow label={__("Subject", "yatra")}> | |
| 650 | + <span className="text-gray-900 dark:text-white break-words"> | |
| 651 | + {log.subject || "—"} | |
| 652 | + </span> | |
| 653 | + </DetailRow> | |
| 654 | + <DetailRow label={__("Template", "yatra")}> | |
| 655 | + <code className="text-xs bg-gray-100 dark:bg-gray-800 px-1.5 py-0.5 rounded"> | |
| 656 | + {log.template_key || "—"} | |
| 657 | + </code> | |
| 658 | + {log.template_id ? ( | |
| 659 | + <span className="text-xs text-gray-500 ml-1"> | |
| 660 | + (id {log.template_id}) | |
| 661 | + </span> | |
| 662 | + ) : null} | |
| 663 | + </DetailRow> | |
| 664 | + <DetailRow label={__("Sequence", "yatra")}> | |
| 665 | + {log.sequence_id ? ( | |
| 666 | + <span className="text-gray-900 dark:text-white"> | |
| 667 | + #{log.sequence_id} | |
| 668 | + </span> | |
| 669 | + ) : ( | |
| 670 | + <span className="text-xs text-gray-400"> | |
| 671 | + {__("Transactional (no sequence)", "yatra")} | |
| 672 | + </span> | |
| 673 | + )} | |
| 674 | + </DetailRow> | |
| 675 | + {(log.context_type || log.context_id) && ( | |
| 676 | + <DetailRow label={__("Context", "yatra")}> | |
| 677 | + <span className="text-gray-900 dark:text-white"> | |
| 678 | + {log.context_type || "—"} | |
| 679 | + {log.context_id ? ` #${log.context_id}` : ""} | |
| 680 | + </span> | |
| 681 | + </DetailRow> | |
| 682 | + )} | |
| 683 | + </dl> | |
| 684 | + </div> | |
| 685 | + | |
| 686 | + {/* Error block — only shown on failed status */} | |
| 687 | + {log.status === "failed" && log.error_message && ( | |
| 688 | + <Alert variant="error" title={__("Send failed", "yatra")}> | |
| 689 | + <pre className="text-xs whitespace-pre-wrap break-words font-mono"> | |
| 690 | + {log.error_message} | |
| 691 | + </pre> | |
| 692 | + </Alert> | |
| 693 | + )} | |
| 694 | + | |
| 695 | + {/* Body section. Two view modes: */} | |
| 696 | + {/* - Preview: sandboxed iframe so the email's own CSS / inline */} | |
| 697 | + {/* styles render accurately without leaking into admin chrome. */} | |
| 698 | + {/* `sandbox=""` (empty string) is the strictest mode — no */} | |
| 699 | + {/* scripts, no forms, no top-nav, no clickable links. */} | |
| 700 | + {/* - Raw: HTML source in a <pre> so operators can verify merge */} | |
| 701 | + {/* tags, debug template variable substitution, copy for tickets. */} | |
| 702 | + <div> | |
| 703 | + <div className="flex flex-wrap items-center justify-between gap-2 mb-1.5"> | |
| 704 | + <Label className="text-sm font-medium"> | |
| 705 | + {__("Message body", "yatra")} | |
| 706 | + </Label> | |
| 707 | + <div className="flex items-center gap-2"> | |
| 708 | + {/* View-toggle pills. Single-purpose styling so the active */} | |
| 709 | + {/* mode is unmistakable. */} | |
| 710 | + <div | |
| 711 | + role="tablist" | |
| 712 | + aria-label={__("Body view", "yatra")} | |
| 713 | + className="inline-flex rounded-md border border-gray-200 dark:border-gray-700 overflow-hidden text-xs" | |
| 714 | + > | |
| 715 | + <button | |
| 716 | + type="button" | |
| 717 | + role="tab" | |
| 718 | + aria-selected={bodyView === "preview"} | |
| 719 | + onClick={() => setBodyView("preview")} | |
| 720 | + className={`px-3 py-1 font-medium transition-colors ${ | |
| 721 | + bodyView === "preview" | |
| 722 | + ? "bg-blue-600 text-white" | |
| 723 | + : "bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700" | |
| 724 | + }`} | |
| 725 | + > | |
| 726 | + {__("Preview", "yatra")} | |
| 727 | + </button> | |
| 728 | + <button | |
| 729 | + type="button" | |
| 730 | + role="tab" | |
| 731 | + aria-selected={bodyView === "raw"} | |
| 732 | + onClick={() => setBodyView("raw")} | |
| 733 | + className={`px-3 py-1 font-medium border-l border-gray-200 dark:border-gray-700 transition-colors ${ | |
| 734 | + bodyView === "raw" | |
| 735 | + ? "bg-blue-600 text-white" | |
| 736 | + : "bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700" | |
| 737 | + }`} | |
| 738 | + > | |
| 739 | + {__("Raw HTML", "yatra")} | |
| 740 | + </button> | |
| 741 | + </div> | |
| 742 | + {bodyView === "raw" && log.body && ( | |
| 743 | + <button | |
| 744 | + type="button" | |
| 745 | + onClick={copyRawToClipboard} | |
| 746 | + className="text-xs font-medium text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300 hover:underline" | |
| 747 | + > | |
| 748 | + {copied ? __("Copied!", "yatra") : __("Copy", "yatra")} | |
| 749 | + </button> | |
| 750 | + )} | |
| 751 | + {bodyView === "preview" && ( | |
| 752 | + <span className="text-xs text-gray-400 hidden sm:inline"> | |
| 753 | + {__("Sandboxed — links not clickable", "yatra")} | |
| 754 | + </span> | |
| 755 | + )} | |
| 756 | + </div> | |
| 757 | + </div> | |
| 758 | + {log.body && log.body.trim() !== "" ? ( | |
| 759 | + bodyView === "preview" ? ( | |
| 760 | + <iframe | |
| 761 | + title={__("Email body preview", "yatra")} | |
| 762 | + srcDoc={log.body} | |
| 763 | + sandbox="" | |
| 764 | + className="w-full h-[480px] rounded-md border border-gray-200 dark:border-gray-700 bg-white" | |
| 765 | + /> | |
| 766 | + ) : ( | |
| 767 | + <pre className="w-full h-[480px] text-xs bg-gray-50 dark:bg-gray-900/40 border border-gray-200 dark:border-gray-700 rounded-md p-3 overflow-auto whitespace-pre-wrap break-all font-mono text-gray-800 dark:text-gray-200"> | |
| 768 | + {log.body} | |
| 769 | + </pre> | |
| 770 | + ) | |
| 771 | + ) : ( | |
| 772 | + <div className="text-sm text-gray-400 italic rounded-md border border-dashed border-gray-200 dark:border-gray-700 p-4 text-center"> | |
| 773 | + {__("(Body not recorded for this log entry.)", "yatra")} | |
| 774 | + </div> | |
| 775 | + )} | |
| 776 | + </div> | |
| 777 | + | |
| 778 | + {/* Metadata JSON — collapsible-style block. Useful for debugging */} | |
| 779 | + {/* operator-side: queue id, retry count, headers, attachment names. */} | |
| 780 | + {metadata && Object.keys(metadata).length > 0 && ( | |
| 781 | + <div> | |
| 782 | + <Label className="text-sm font-medium block mb-1.5"> | |
| 783 | + {__("Metadata", "yatra")} | |
| 784 | + </Label> | |
| 785 | + <pre className="text-xs bg-gray-50 dark:bg-gray-900/40 border border-gray-200 dark:border-gray-700 rounded-md p-3 overflow-x-auto max-h-48 overflow-y-auto font-mono"> | |
| 786 | + {JSON.stringify(metadata, null, 2)} | |
| 787 | + </pre> | |
| 788 | + </div> | |
| 789 | + )} | |
| 790 | + </div> | |
| 791 | + </Modal> | |
| 792 | + ); | |
| 793 | +}; | |
| 794 | + | |
| 795 | +const DetailRow: React.FC<{ label: string; children: React.ReactNode }> = ({ | |
| 796 | + label, | |
| 797 | + children, | |
| 798 | +}) => ( | |
| 799 | + <div> | |
| 800 | + <dt className="text-[11px] uppercase tracking-wide text-gray-500 dark:text-gray-400 font-medium"> | |
| 801 | + {label} | |
| 802 | + </dt> | |
| 803 | + <dd className="mt-0.5">{children}</dd> | |
| 804 | + </div> | |
| 805 | +); | |
| 806 | + | |
| 468 | 807 | type EmailHubTab = "delivery" | "templates" | "sequences" | "logs"; |
| 469 | 808 | |
| 470 | 809 | function getInitialEmailHubTab(): EmailHubTab { |
| 471 | 810 | if (typeof window === "undefined") return "delivery"; |
| @@ -517,8 +856,30 @@ | ||
| 517 | 856 | } |
| 518 | 857 | return t; |
| 519 | 858 | }); |
| 520 | 859 | |
| 860 | + /** | |
| 861 | + * Persist tab changes to the URL so the operator can: | |
| 862 | + * - bookmark / share a direct link to a specific tab | |
| 863 | + * - use the browser back button to step through tab history | |
| 864 | + * - reload the page and stay on the same tab | |
| 865 | + * | |
| 866 | + * Using `replaceState` (not `pushState`) for the initial sync so we | |
| 867 | + * don't add a bogus history entry on first mount; subsequent user- | |
| 868 | + * driven tab switches go through `switchTab` which uses | |
| 869 | + * `replaceState` too — back/forward should navigate AWAY from this | |
| 870 | + * page entirely, not cycle through its tabs. | |
| 871 | + */ | |
| 872 | + React.useEffect(() => { | |
| 873 | + if (typeof window === "undefined") return; | |
| 874 | + const url = new URL(window.location.href); | |
| 875 | + if (url.searchParams.get("tab") === activeTab) return; | |
| 876 | + url.searchParams.set("tab", activeTab); | |
| 877 | + window.history.replaceState({}, "", url.toString()); | |
| 878 | + }, [activeTab]); | |
| 879 | + | |
| 880 | + const switchTab = (next: EmailHubTab) => setActiveTab(next); | |
| 881 | + | |
| 521 | 882 | const showProAutomationExtras = isProPluginActive(); |
| 522 | 883 | const automationReady = isEmailAutomationModuleEnabled(); |
| 523 | 884 | |
| 524 | 885 | const tabs = [ |
| @@ -563,9 +924,9 @@ | ||
| 563 | 924 | {tabs.map((tab) => ( |
| 564 | 925 | <button |
| 565 | 926 | key={tab.key} |
| 566 | 927 | type="button" |
| 567 | - onClick={() => setActiveTab(tab.key)} | |
| 928 | + onClick={() => switchTab(tab.key)} | |
| 568 | 929 | className={`flex items-center gap-2 px-4 py-3 border-b-2 font-medium text-sm transition-colors ${ |
| 569 | 930 | activeTab === tab.key |
| 570 | 931 | ? "border-blue-500 text-blue-600 dark:text-blue-400" |
| 571 | 932 | : "border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 hover:border-gray-300 dark:hover:border-gray-600" |
| @@ -598,9 +959,9 @@ | ||
| 598 | 959 | <> |
| 599 | 960 | {automationReady && ( |
| 600 | 961 | <p className="mb-6 text-sm text-gray-600 dark:text-gray-400"> |
| 601 | 962 | {__( |
| 602 | - "Customer booking, payment, cancellation, and trip-reminder emails use the matching template when its body is filled; otherwise the free plugin defaults or settings HTML apply. Admin: New Booking sends when that template has a body (plain-text admin notices from checkout and notifications are skipped to avoid duplicates). Other booking.created templates send in addition to the customer email. Booking Confirmed uses the “Booking Confirmed” row when a booking moves to confirmed; Trip Completed uses its template instead of the generic completed email when the body is filled.", | |
| 963 | + "Customer booking, payment, cancellation, and trip-reminder emails use the matching template when its body is filled; otherwise the free plugin defaults or settings HTML apply. Admin: New Booking sends when that template has a body (plain-text admin notices from checkout and notifications are skipped to avoid duplicates). Other booking.created templates send in addition to the customer email. Booking Confirmed uses the “Booking Confirmed” row when a booking moves to confirmed; Trip Completed uses its template instead of the generic completed email when the body is filled. Override templates replace their global template for the trips they target and are never sent in addition to it.", | |
| 603 | 964 | "yatra", |
| 604 | 965 | )} |
| 605 | 966 | </p> |
| 606 | 967 | )} |