import { useActionQuery } from "@agent-native/core/client/hooks"; import { useT } from "@agent-native/core/client/i18n"; import { IconAlertTriangle, IconChevronRight, IconEye, IconInfoCircle, IconList, IconMail, } from "@tabler/icons-react"; import { useQueries, useQuery } from "@tanstack/react-query"; import { useMemo, useState } from "react"; import { Link, useLocation } from "react-router"; import { aggregateSharedEmails, callAppAction, fetchAppEmailCatalog, type AppEmailCatalog, type AppTransactionalEmail, type LocalTransactionalEmailCatalog, } from "../../client/transactional-emails"; import { ActionQueryError } from "../../components/action-query-error"; import { DispatchShell } from "../../components/dispatch-shell"; import { ActivityTable, type EmailActivityEntry, } from "../../components/transactional-email-activity"; import { OpenRateCell, SendsCell, LastSentCell, type EmailEngagement, type ProviderMetricsResult, } from "../../components/transactional-email-metrics"; import { EmailPreviewPane } from "../../components/transactional-email-preview"; import { Alert, AlertDescription, AlertTitle } from "../../components/ui/alert"; import { Button } from "../../components/ui/button"; import { Collapsible, CollapsibleContent, CollapsibleTrigger, } from "../../components/ui/collapsible"; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, } from "../../components/ui/dialog"; import { Skeleton } from "../../components/ui/skeleton"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from "../../components/ui/table"; export function meta() { return [{ title: "Transactional email — Dispatch" }]; } const WINDOW_DAYS = 30; const ACTIVITY_LIMIT = 50; interface WorkspaceAppRef { id: string; name: string; path: string; status?: "ready" | "pending"; } /** Shape of the local `list-transactional-emails` action response. */ function PreviewDialog({ email, appId, appPath, open, onOpenChange, }: { email: AppTransactionalEmail; appId: string; appPath: string; open: boolean; onOpenChange: (next: boolean) => void; }) { const t = useT(); return ( {email.name} {t("dispatch.transactionalEmail.previewDescription")} {/* Dialog content only mounts while open, so this fetches on open and drops the request when closed rather than needing an enabled flag. */} ); } function ActivityDialog({ email, appPath, unavailableReason, open, onOpenChange, }: { email: AppTransactionalEmail; appPath: string; unavailableReason: string | null; open: boolean; onOpenChange: (next: boolean) => void; }) { const t = useT(); const activityQuery = useQuery({ queryKey: ["list-email-activity", appPath, email.id, ACTIVITY_LIMIT], queryFn: () => callAppAction>( appPath, "list-email-activity", { templateId: email.id, limit: ACTIVITY_LIMIT }, "GET", ), enabled: open && !unavailableReason, }); return ( {t("dispatch.transactionalEmail.activityTitle", { name: email.name, })} {t("dispatch.transactionalEmail.retentionNote")} void activityQuery.refetch()} /> ); } function EmailRow({ email, appId, appPath, engagement, engagementUnavailable, engagementLoading, }: { email: AppTransactionalEmail; appId: string; appPath: string; engagement: EmailEngagement | undefined; engagementUnavailable: string | null; engagementLoading: boolean; }) { const t = useT(); const location = useLocation(); const [previewOpen, setPreviewOpen] = useState(false); const [activityOpen, setActivityOpen] = useState(false); const detailBase = location.pathname.startsWith("/admin/") ? "/admin/transactional-email" : "/transactional-email"; return ( {email.name}
{email.id}
{email.trigger} {email.recipientLabel} {email.senderLabel}
); } function AppEmailTable({ catalog, engagementByTemplate, engagementUnavailable, engagementLoading, }: { catalog: AppEmailCatalog; engagementByTemplate: Map; engagementUnavailable: string | null; engagementLoading: boolean; }) { const t = useT(); if (catalog.error) { return ( {t("dispatch.transactionalEmail.catalogUnreadable")} {catalog.error} ); } return ( <> {catalog.statsError ? ( {t("dispatch.transactionalEmail.countsUnreadable")} {catalog.statsError} ) : null} {catalog.emails.length === 0 ? (
{t("dispatch.transactionalEmail.appSendsNoEmail")}
) : ( {t("dispatch.transactionalEmail.email")} {t("dispatch.transactionalEmail.trigger")} {t("dispatch.transactionalEmail.recipient")} {t("dispatch.transactionalEmail.sender")} {t("dispatch.transactionalEmail.sends")} {t("dispatch.transactionalEmail.openRate")} {t("dispatch.transactionalEmail.lastSent")} {catalog.emails.map((email) => ( ))}
)} ); } function AppEmailCard({ name, path, open, onOpenChange, catalog, isLoading, isError, error, onRetry, engagementByTemplate, engagementUnavailable, engagementLoading, }: { name: string; path: string; open: boolean; onOpenChange: (next: boolean) => void; catalog: AppEmailCatalog | undefined; isLoading: boolean; isError: boolean; error: unknown; onRetry: () => void; engagementByTemplate: Map; engagementUnavailable: string | null; engagementLoading: boolean; }) { return ( {name} {path} {isError ? ( ) : isLoading || !catalog ? ( ) : ( )} ); } export default function TransactionalEmailRoute() { const t = useT(); const appsQuery = useActionQuery("list-workspace-apps", { includeAgentCards: false, }); const apps = useMemo( () => (appsQuery.data ?? []) .filter((app) => app.status !== "pending") .map((app) => ({ id: app.id, name: app.name, path: app.path })), [appsQuery.data], ); const [expanded, setExpanded] = useState>({}); const [sharedOpen, setSharedOpen] = useState(true); const sharedQuery = useActionQuery( "list-transactional-emails", { windowDays: WINDOW_DAYS }, ); const catalogQueries = useQueries({ queries: apps.map((app) => ({ queryKey: ["transactional-email-catalog", app.id, WINDOW_DAYS], queryFn: () => fetchAppEmailCatalog(app, WINDOW_DAYS), enabled: sharedOpen || expanded[app.id] === true, staleTime: Infinity, })), }); const sharedCatalog = useMemo(() => { if (!sharedQuery.data) return undefined; const aggregate = aggregateSharedEmails( sharedQuery.data, catalogQueries .map((query) => query.data) .filter((catalog): catalog is AppEmailCatalog => Boolean(catalog)), ); return { appId: "core", appName: t("dispatch.transactionalEmail.sharedTitle"), appPath: t("dispatch.transactionalEmail.sharedSubtitle"), emails: aggregate.emails, error: null, statsError: aggregate.statsError, }; }, [sharedQuery.data, catalogQueries, t]); const engagementQueries = useQueries({ queries: apps.map((app, index) => { const catalog = catalogQueries[index]?.data; const templateIds = catalog && !catalog.error ? catalog.emails.map((email) => email.id) : []; return { queryKey: [ "list-email-engagement", app.id, templateIds.join(","), WINDOW_DAYS, ], queryFn: () => callAppAction>( app.path, "list-email-engagement", { templateIds, windowDays: WINDOW_DAYS }, "POST", ), enabled: expanded[app.id] === true && templateIds.length > 0, }; }), }); const sharedLoading = sharedQuery.isLoading || (sharedOpen && catalogQueries.some((query) => query.isLoading)); const sharedError = catalogQueries.find((query) => query.isError)?.error; const sharedProviderReason = t( "dispatch.transactionalEmail.sharedProviderMetricsUnavailable", ); return (
{t("dispatch.transactionalEmail.retentionTitle")} {t("dispatch.transactionalEmail.retentionNote")} {appsQuery.isError ? ( void appsQuery.refetch()} /> ) : null} {appsQuery.isLoading ? (
) : null} {!appsQuery.isLoading && apps.length === 0 ? (
{t("dispatch.transactionalEmail.noApps")}
) : null} { void sharedQuery.refetch(); for (const query of catalogQueries) void query.refetch(); }} engagementByTemplate={new Map()} engagementUnavailable={sharedProviderReason} engagementLoading={false} /> {apps.map((app, index) => { const query = catalogQueries[index]; const engagementQuery = engagementQueries[index]; const engagementResult = engagementQuery?.data; const engagementUnavailable = engagementResult && !engagementResult.available ? engagementResult.reason : engagementQuery?.isError ? engagementQuery.error instanceof Error ? engagementQuery.error.message : String(engagementQuery.error) : null; const engagementByTemplate = new Map(); if (engagementResult?.available) { for (const entry of engagementResult.data) { engagementByTemplate.set(entry.templateId, entry); } } return ( setExpanded((current) => ({ ...current, [app.id]: next })) } catalog={query?.data} isLoading={query?.isLoading ?? false} isError={query?.isError ?? false} error={query?.error} onRetry={() => void query?.refetch()} engagementByTemplate={engagementByTemplate} engagementUnavailable={engagementUnavailable} engagementLoading={engagementQuery?.isLoading ?? false} /> ); })}
); }