PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.16
Yatra – Travel Booking & Tour Operator Software v3.0.16
3.0.16 3.0.15 3.0.14 3.0.14.1 3.0.14.2 3.0.12 3.0.13 3.0.11 3.0.10 3.0.9 3.0.8 3.0.7 3.0.6 3.0.5 3.0.5.1 3.0.4 3.0.3 3.0.2.9 3.0.2.7 3.0.2.8 3.0.2.6 trunk 1.0.0 2.0.0 2.0.1 All 84 releases
← All changes | resources/js/pages/EmailAutomation.tsx +376 -15 3.0.3 → 3.0.16 View file →
@@ -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 )}