feat: show the new failed lead state in the campaign Leads view (pill with the worker's reason on hover, strip chip and counts) and toast user-addressed mailbox and send errors from the realtime ERROR event, which previously only refreshed the dashboard so a reply that could not be sent was never surfaced

This commit is contained in:
Matthew Meszaros
2026-08-23 10:18:50 -07:00
parent 6b614ad4bb
commit 07f671150b
3 changed files with 36 additions and 2 deletions
@@ -990,6 +990,7 @@ const LEAD_META: Record<
completed: { label: "Done", dot: "bg-indigo-500", text: "text-indigo-700", Icon: CheckIcon },
replied: { label: "Replied", dot: "bg-emerald-500", text: "text-emerald-700", Icon: CornerUpLeftIcon },
bounced: { label: "Bounced", dot: "bg-rose-500", text: "text-rose-600", Icon: AlertTriangleIcon },
failed: { label: "Failed", dot: "bg-rose-500", text: "text-rose-600", Icon: AlertTriangleIcon },
unsubscribed: { label: "Unsubscribed", dot: "bg-slate-300", text: "text-slate-400", Icon: BanIcon },
};
@@ -997,9 +998,16 @@ function LeadStatusPill({ lead }: { lead?: ContactCampaignProgress | null }) {
const status: LeadStatus = lead?.status ?? "pending";
const meta = LEAD_META[status];
const Icon = meta.Icon;
// A failed lead carries the worker's reason; surface it on hover since the
// pill itself only has room for the word.
const title =
status === "failed" && lead?.failure_reason
? `Could not send: ${lead.failure_reason}`
: undefined;
return (
<span
className={`inline-flex items-center gap-1.5 text-[10.5px] font-medium uppercase tracking-[0.08em] ${meta.text}`}
title={title}
>
{status === "active" ? (
<span className="campaign-grid text-sky-600 shrink-0" aria-hidden />
@@ -1030,6 +1038,7 @@ function LeadProgressStrip({
completed: 0,
replied: 0,
bounced: 0,
failed: 0,
unsubscribed: 0,
};
for (const ct of contacts) c[ct.campaign_lead?.status ?? "pending"]++;
@@ -1045,6 +1054,7 @@ function LeadProgressStrip({
{ key: "replied", color: "bg-emerald-500" },
{ key: "pending", color: "bg-slate-300" },
{ key: "bounced", color: "bg-rose-400" },
{ key: "failed", color: "bg-rose-500" },
{ key: "unsubscribed", color: "bg-slate-200" },
];
@@ -1069,6 +1079,7 @@ function LeadProgressStrip({
<StripChip dot="bg-emerald-500" label="Replied" n={counts.replied} />
<StripChip dot="bg-slate-300" label="Queued" n={counts.pending} />
<StripChip dot="bg-rose-400" label="Bounced" n={counts.bounced} />
{counts.failed > 0 && <StripChip dot="bg-rose-500" label="Failed" n={counts.failed} />}
<StripChip dot="bg-slate-300" label="Unsub" n={counts.unsubscribed} />
</div>
<div className="ml-auto flex items-center gap-2 text-[10.5px] text-slate-400 tabular-nums">
+18
View File
@@ -171,6 +171,24 @@ export function useRealtimeEvents() {
return
}
// A user-addressed mailbox/send error (a compose or reply the worker
// could not send, a mailbox that needs re-authorizing). Nothing else
// would tell the user, so toast it, then refresh the mailbox views.
if (event === 'ERROR') {
const data = payload.data as Record<string, unknown> | undefined
const title =
typeof data?.title === 'string' && data.title
? data.title
: (getString('message') ?? 'Something went wrong')
const detail = typeof data?.message === 'string' ? data.message : ''
toast.error(detail ? `${title}: ${detail}` : title, {
id: `email-error-${getString('task_id') ?? getString('email_id') ?? 'general'}`,
duration: 8000,
})
invalidate([['emails', 'list'], ['unibox']])
return
}
if (includes('ACCOUNT', 'EMAIL_STATUS', 'EMAIL_ERROR', 'WARMUP')) {
// ACCOUNT_SYNC_STATE: the mailbox's import finished or fair use
// started/stopped holding it; the drawer's sync card refetches.
@@ -1,15 +1,17 @@
import type MiniCampaign from "../campaigns/MiniCampaign";
import type MiniCategory from "./MiniCategory";
// LeadStatus mirrors models.ContactCampaignProgress.Status — a contact's
// LeadStatus mirrors models.ContactCampaignProgress.Status, a contact's
// processing state inside a single campaign. "completed" = every step sent, no
// reply (done); "active" = some but not all steps sent (still processing).
// reply (done); "active" = some but not all steps sent (still processing);
// "failed" = the mailbox could not send a step after every retry.
export type LeadStatus =
| "pending"
| "active"
| "completed"
| "replied"
| "bounced"
| "failed"
| "unsubscribed";
// ContactCampaignProgress is set only on contacts returned by a single-campaign
@@ -25,6 +27,9 @@ export interface ContactCampaignProgress {
// Label of the step the lead is on now (latest step sent). Empty when the
// lead hasn't been contacted yet.
current_step?: string;
// The worker's reason for the last failed send; set only when status is
// "failed".
failure_reason?: string;
}
export default interface Contact {