feat: add Emails tab to User Detail page (#1616)

This commit is contained in:
Konsti Wohlwend 2026-06-17 13:05:20 -07:00 committed by GitHub
parent 07449da7e9
commit c7036da485
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 195 additions and 24 deletions

View File

@ -0,0 +1,25 @@
import type { AdminEmailOutbox } from "@hexclave/next";
export type EmailWithDeliveredAt = AdminEmailOutbox & {
deliveredAt?: Date | string | null,
};
export function hasDeliveredAt(email: AdminEmailOutbox): email is EmailWithDeliveredAt {
return "deliveredAt" in email;
}
export function getRecipientDisplay(email: AdminEmailOutbox): string {
const to = email.to;
if (to.type === "user-primary-email") {
return `User: ${to.userId.slice(0, 8)}...`;
}
if (to.type === "user-custom-emails") {
return to.emails[0] ?? `User: ${to.userId.slice(0, 8)}...`;
}
return to.emails[0] ?? "No recipients";
}
export function getEmailTimestamp(email: AdminEmailOutbox): Date {
const deliveredAt = hasDeliveredAt(email) ? email.deliveredAt : undefined;
return deliveredAt != null ? new Date(deliveredAt) : email.scheduledAt;
}

View File

@ -17,32 +17,9 @@ import { type ReactNode, useCallback, useEffect, useMemo, useState } from "react
import { useAdminApp, useProjectId } from "../use-admin-app";
import { DomainReputationCard } from "./domain-reputation-card";
import { STATUS_LABELS, computeEmailStats, getStatusBadgeColor } from "./email-status-utils";
import { getRecipientDisplay, getEmailTimestamp } from "./email-outbox-utils";
import { StatsBar } from "./stats-bar";
type EmailWithDeliveredAt = AdminEmailOutbox & {
deliveredAt?: Date | string | null,
};
function hasDeliveredAt(email: AdminEmailOutbox): email is EmailWithDeliveredAt {
return "deliveredAt" in email;
}
function getRecipientDisplay(email: AdminEmailOutbox): string {
const to = email.to;
if (to.type === "user-primary-email") {
return `User: ${to.userId.slice(0, 8)}...`;
}
if (to.type === "user-custom-emails") {
return to.emails[0] ?? `User: ${to.userId.slice(0, 8)}...`;
}
return to.emails[0] ?? "No recipients";
}
function getEmailTimestamp(email: AdminEmailOutbox): Date {
const deliveredAt = hasDeliveredAt(email) ? email.deliveredAt : undefined;
return deliveredAt ? new Date(deliveredAt) : email.scheduledAt;
}
const emailColumns: DataGridColumnDef<AdminEmailOutbox>[] = [
{
id: "recipient",

View File

@ -63,6 +63,7 @@ import { useAdminApp } from "../../use-admin-app";
import { UserAnalyticsSection } from "./user-analytics";
import { UserPageTableSection } from "./user-page-table-section";
import { UserPaymentsSection } from "./user-payments";
import { UserEmailsSection } from "./user-emails";
import dynamic from "next/dynamic";
// The session-replays page is ~2k LOC and pulls rrweb in via dynamic imports.
@ -1869,6 +1870,7 @@ type UserPageTabConfig = {
const USER_PAGE_TABS = [
{ id: "authentication", label: "Authentication", appId: "authentication" },
{ id: "teams", label: "Teams", appId: "teams" },
{ id: "emails", label: "Emails", appId: "emails" },
{ id: "payments", label: "Payments", appId: "payments" },
{ id: "analytics", label: "Analytics", appId: "analytics" },
{ id: "session-replays", label: "Session Replays", appId: "session-replays" },
@ -1994,6 +1996,11 @@ function UserPage({ user }: { user: ServerUser }) {
<UserTeamsSection user={user} />
</Suspense>
)}
{activeTab === "emails" && (
<Suspense fallback={<TabContentSkeleton sections={1} />}>
<UserEmailsSection user={user} />
</Suspense>
)}
{activeTab === "payments" && (
<Suspense fallback={<TabContentSkeleton sections={1} />}>
<UserPaymentsSection user={user} />

View File

@ -0,0 +1,162 @@
"use client";
import { DesignBadge } from "@/components/design-components";
import { useRouter } from "@/components/router";
import { Skeleton, Typography } from "@/components/ui";
import { EnvelopeSimpleIcon } from "@phosphor-icons/react";
import type { DataGridColumnDef } from "@hexclave/dashboard-ui-components";
import type { AdminEmailOutbox, ServerUser } from "@hexclave/next";
import { runAsynchronouslyWithAlert } from "@hexclave/shared/dist/utils/promises";
import { urlString } from "@hexclave/shared/dist/utils/urls";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useAdminApp, useProjectId } from "../../use-admin-app";
import { UserPageTableSection } from "./user-page-table-section";
import { STATUS_LABELS, computeEmailStats, getStatusBadgeColor } from "../../email-sent/email-status-utils";
import { getRecipientDisplay, getEmailTimestamp } from "../../email-sent/email-outbox-utils";
import { StatsBar } from "../../email-sent/stats-bar";
function isEmailForUser(email: AdminEmailOutbox, userId: string): boolean {
const to = email.to;
if (to.type === "user-primary-email" && to.userId === userId) return true;
if (to.type === "user-custom-emails" && to.userId === userId) return true;
return false;
}
const emailColumns: DataGridColumnDef<AdminEmailOutbox>[] = [
{
id: "subject",
header: "Subject",
width: 240,
flex: 1,
sortable: false,
renderCell: ({ row }) => {
if (!row.hasRendered) {
return <span className="text-muted-foreground italic">Not yet rendered</span>;
}
return <span className="truncate">{row.subject}</span>;
},
},
{
id: "recipient",
header: "Recipient",
width: 200,
sortable: false,
renderCell: ({ row }) => (
<span className="truncate text-sm text-muted-foreground">{getRecipientDisplay(row)}</span>
),
},
{
id: "time",
header: "Time",
width: 160,
sortable: false,
renderCell: ({ row }) => (
<span className="text-sm text-muted-foreground">
{getEmailTimestamp(row).toLocaleString()}
</span>
),
},
{
id: "status",
header: "Status",
width: 120,
sortable: false,
renderCell: ({ row }) => (
<DesignBadge label={STATUS_LABELS[row.status]} color={getStatusBadgeColor(row.status)} size="sm" />
),
},
];
export function UserEmailsSection({ user }: { user: ServerUser }) {
const hexclaveAdminApp = useAdminApp();
const projectId = useProjectId();
const router = useRouter();
const [emails, setEmails] = useState<AdminEmailOutbox[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// Fetches all pages of the outbox so client-side filtering by userId
// doesn't silently miss emails on later pages.
const refreshEmails = useCallback(async () => {
setLoading(true);
setError(null);
try {
const allEmails: AdminEmailOutbox[] = [];
let cursor: string | undefined;
do {
const result = await hexclaveAdminApp.listOutboxEmails(cursor != null ? { cursor } : undefined);
allEmails.push(...result.items);
cursor = result.nextCursor ?? undefined;
} while (cursor != null);
setEmails(allEmails);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to load emails");
} finally {
setLoading(false);
}
}, [hexclaveAdminApp]);
useEffect(() => {
let cancelled = false;
runAsynchronouslyWithAlert(async () => {
await refreshEmails();
if (cancelled) return;
});
return () => {
cancelled = true;
};
}, [refreshEmails]);
const filtered = useMemo(
() => emails
.filter((e) => isEmailForUser(e, user.id))
.sort((a, b) => getEmailTimestamp(b).getTime() - getEmailTimestamp(a).getTime()),
[emails, user.id],
);
const stats = useMemo(() => computeEmailStats(filtered), [filtered]);
if (loading) {
return (
<div className="flex flex-col gap-4">
<Skeleton className="h-[40px] rounded-2xl" />
<Skeleton className="h-[180px] rounded-2xl" />
</div>
);
}
if (error != null) {
return (
<div className="flex flex-col items-center gap-3 py-8 text-center">
<EnvelopeSimpleIcon className="h-6 w-6 text-destructive" />
<Typography className="text-sm font-medium">Failed to load emails</Typography>
<Typography variant="secondary" className="text-sm">{error}</Typography>
</div>
);
}
return (
<div className="flex flex-col gap-4">
{filtered.length > 0 && (
<div className="py-1">
<div className="mb-2 text-sm text-center">
<span className="font-medium">{filtered.length} email{filtered.length !== 1 ? "s" : ""}</span>
</div>
<StatsBar data={stats} />
</div>
)}
<UserPageTableSection
title="Sent Emails"
urlStateKey="useremails"
columns={emailColumns}
rows={filtered}
getRowId={(email) => email.id}
emptyLabel="No emails sent to this user"
paginated
onRowClick={(row) => {
router.push(urlString`/projects/${projectId}/email-viewer/${row.id}`);
}}
/>
</div>
);
}