From 244d04ca9366eceee86d12f5a7cc124991ceb224 Mon Sep 17 00:00:00 2001 From: Arnas Donauskas Date: Fri, 17 Apr 2026 11:31:13 +0300 Subject: [PATCH] feat(broadcasts) B: list rates, detail funnel + CSV, save-as-draft MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UI polish for the Broadcasts feature. Stacked on PR A (correctness). List page /broadcasts - Replaces raw Sent/Delivered/Read/Failed columns with Delivery Rate + Read Rate cells (percent + inline mini progress bar). Raw counts still available on the detail page. - Sending badge gets a live-looking pulsing dot. - Indeterminate top-of-page progress bar appears while any broadcast has status='sending'. Page polls the broadcasts table every 5 s while that condition holds, and stops polling otherwise. - Mobile: table wrapped in overflow-x-auto. Detail page /broadcasts/[id] - Adds a Replied stat card (was missing). Final grid: Total / Sent / Delivered / Read / Replied / Failed — 6 cards on desktop, 2 on mobile. Color spectrum matches the spec's funnel gradient. - Pure-CSS horizontal funnel chart: Sent → Delivered → Read → Replied with decreasing widths in emerald → teal → blue → indigo. Each bar shows its absolute value and percent of Sent. - Status filter dropdown above the recipients table. Filters client-side; header reflects filtered / total. - "Export CSV" downloads all recipients with status + every milestone timestamp + error_message. RFC-4180 quoting. - Recipients table gains Delivered / Read timestamp columns so the CSV columns are also visible at a glance. Wizard Step 4 - Save as Draft button next to Send Broadcast. Persists a broadcasts row with status='draft' and zero recipients — enough for the user to recognize the draft in the list. Full resume-into-wizard is a future follow-up. - Confirmation modal before send was already in place; kept as-is. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/app/(dashboard)/broadcasts/[id]/page.tsx | 324 +++++++++++++++--- src/app/(dashboard)/broadcasts/new/page.tsx | 54 +++ src/app/(dashboard)/broadcasts/page.tsx | 178 +++++++--- .../broadcasts/step4-schedule-send.tsx | 22 +- 4 files changed, 481 insertions(+), 97 deletions(-) diff --git a/src/app/(dashboard)/broadcasts/[id]/page.tsx b/src/app/(dashboard)/broadcasts/[id]/page.tsx index 9053935..8ded950 100644 --- a/src/app/(dashboard)/broadcasts/[id]/page.tsx +++ b/src/app/(dashboard)/broadcasts/[id]/page.tsx @@ -1,9 +1,9 @@ 'use client'; -import { useEffect, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { useParams, useRouter } from 'next/navigation'; import { createClient } from '@/lib/supabase/client'; -import { Broadcast, BroadcastRecipient } from '@/types'; +import { Broadcast, BroadcastRecipient, RecipientStatus } from '@/types'; import { Button } from '@/components/ui/button'; import { Table, @@ -13,6 +13,12 @@ import { TableHeader, TableRow, } from '@/components/ui/table'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; import { ArrowLeft, Loader2, @@ -21,6 +27,10 @@ import { CheckCheck, Eye, AlertCircle, + MessageCircle, + Filter, + Download, + ChevronDown, } from 'lucide-react'; import { getBroadcastStatus, @@ -51,6 +61,84 @@ function StatCard({ label, value, total, icon, color }: StatCardProps) { ); } +interface FunnelStep { + label: string; + value: number; + color: string; +} + +/** + * Pure-CSS funnel chart: decreasing-width rounded bars. + * Width is relative to the largest step (typically Sent) so we + * always render a full bar at the top and proportional tails. + */ +function FunnelChart({ steps }: { steps: FunnelStep[] }) { + const max = Math.max(...steps.map((s) => s.value), 1); + return ( +
+

Funnel

+
+ {steps.map((step) => { + const pctOfMax = Math.max(5, Math.round((step.value / max) * 100)); + const pctOfSent = + steps[0].value > 0 + ? Math.round((step.value / steps[0].value) * 100) + : 0; + return ( +
+ + {step.label} + +
+
+ + {step.value.toLocaleString()} + + ({pctOfSent}%) + + +
+
+ ); + })} +
+
+ ); +} + +const RECIPIENT_STATUSES: readonly RecipientStatus[] = [ + 'pending', + 'sent', + 'delivered', + 'read', + 'replied', + 'failed', +]; + +/** + * CSV export helper — RFC 4180 quoting. Quote every field so + * commas/newlines/quotes round-trip cleanly. + */ +function toCsv(rows: string[][]): string { + const escape = (v: string) => `"${v.replace(/"/g, '""')}"`; + return rows.map((r) => r.map(escape).join(',')).join('\n'); +} + +function downloadBlob(filename: string, content: string) { + const blob = new Blob([content], { type: 'text/csv;charset=utf-8;' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); +} + export default function BroadcastDetailPage() { const params = useParams(); const router = useRouter(); @@ -60,6 +148,9 @@ export default function BroadcastDetailPage() { const [recipients, setRecipients] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + const [statusFilter, setStatusFilter] = useState( + 'all', + ); useEffect(() => { async function fetchData() { @@ -93,6 +184,41 @@ export default function BroadcastDetailPage() { fetchData(); }, [broadcastId]); + const filteredRecipients = useMemo( + () => + statusFilter === 'all' + ? recipients + : recipients.filter((r) => r.status === statusFilter), + [recipients, statusFilter], + ); + + function handleExport() { + if (!broadcast) return; + const header = [ + 'Contact', + 'Phone', + 'Status', + 'Sent At', + 'Delivered At', + 'Read At', + 'Replied At', + 'Error', + ]; + const rows = recipients.map((r) => [ + r.contact?.name ?? '', + r.contact?.phone ?? '', + r.status, + r.sent_at ?? '', + r.delivered_at ?? '', + r.read_at ?? '', + r.replied_at ?? '', + r.error_message ?? '', + ]); + const csv = toCsv([header, ...rows]); + const safeName = broadcast.name.replace(/[^a-z0-9-_]+/gi, '-').toLowerCase(); + downloadBlob(`broadcast-${safeName}-${broadcastId.slice(0, 8)}.csv`, csv); + } + if (loading) { return (
@@ -114,6 +240,13 @@ export default function BroadcastDetailPage() { const status = getBroadcastStatus(broadcast.status); + const funnelSteps: FunnelStep[] = [ + { label: 'Sent', value: broadcast.sent_count, color: 'bg-emerald-500' }, + { label: 'Delivered', value: broadcast.delivered_count, color: 'bg-teal-500' }, + { label: 'Read', value: broadcast.read_count, color: 'bg-blue-500' }, + { label: 'Replied', value: broadcast.replied_count, color: 'bg-indigo-500' }, + ]; + return (
{/* Header */} @@ -139,14 +272,16 @@ export default function BroadcastDetailPage() {
Template: {broadcast.template_name} - - Created {new Date(broadcast.created_at).toLocaleDateString()} + + Created {new Date(broadcast.created_at).toLocaleDateString()} +
- {/* Stats */} -
+ {/* Stats — 6 cards: Total / Sent / Delivered / Read / Replied / Failed */} +
} - color="bg-blue-500/10 text-blue-400" + color="bg-emerald-500/10 text-emerald-400" /> } - color="bg-emerald-500/10 text-emerald-400" + color="bg-teal-500/10 text-teal-400" /> } - color="bg-emerald-500/10 text-emerald-300" + color="bg-blue-500/10 text-blue-400" + /> + } + color="bg-indigo-500/10 text-indigo-400" />
+ + {/* Recipients Table */}
-
+

- Recipients ({recipients.length}) + Recipients ({filteredRecipients.length} + {statusFilter !== 'all' ? ` of ${recipients.length}` : ''})

+
+ + + } + > + + {statusFilter === 'all' + ? 'All statuses' + : getRecipientStatus(statusFilter).label} + + + + setStatusFilter('all')} + className={ + statusFilter === 'all' ? 'text-emerald-400' : 'text-slate-300' + } + > + All statuses + + {RECIPIENT_STATUSES.map((s) => ( + setStatusFilter(s)} + className={ + statusFilter === s + ? 'text-emerald-400' + : 'text-slate-300' + } + > + {getRecipientStatus(s).label} + + ))} + + + + +
- {recipients.length === 0 ? ( + + {filteredRecipients.length === 0 ? (
-

No recipients found.

+

+ {recipients.length === 0 + ? 'No recipients found.' + : 'No recipients match this filter.'} +

) : ( - - - - Contact - Phone - Status - Sent At - Error - - - - {recipients.map((recipient) => { - const rStatus = getRecipientStatus(recipient.status); - - return ( - - - {recipient.contact?.name ?? 'Unknown'} - - - {recipient.contact?.phone ?? '-'} - - - - {rStatus.label} - - - - {recipient.sent_at - ? new Date(recipient.sent_at).toLocaleString() - : '-'} - - - {recipient.error_message ?? '-'} - - - ); - })} - -
+
+ + + + Contact + Phone + Status + Sent + Delivered + Read + Error + + + + {filteredRecipients.map((recipient) => { + const rStatus = getRecipientStatus(recipient.status); + return ( + + + {recipient.contact?.name ?? 'Unknown'} + + + {recipient.contact?.phone ?? '-'} + + + + {rStatus.label} + + + + {recipient.sent_at + ? new Date(recipient.sent_at).toLocaleString() + : '-'} + + + {recipient.delivered_at + ? new Date(recipient.delivered_at).toLocaleString() + : '-'} + + + {recipient.read_at + ? new Date(recipient.read_at).toLocaleString() + : '-'} + + + {recipient.error_message ?? '-'} + + + ); + })} + +
+
)}
diff --git a/src/app/(dashboard)/broadcasts/new/page.tsx b/src/app/(dashboard)/broadcasts/new/page.tsx index ec8e0e5..8d59704 100644 --- a/src/app/(dashboard)/broadcasts/new/page.tsx +++ b/src/app/(dashboard)/broadcasts/new/page.tsx @@ -2,6 +2,8 @@ import { useState } from 'react'; import { useRouter } from 'next/navigation'; +import { createClient } from '@/lib/supabase/client'; +import { toast } from 'sonner'; import { MessageTemplate } from '@/types'; import { Step1ChooseTemplate } from '@/components/broadcasts/step1-choose-template'; import { Step2SelectAudience } from '@/components/broadcasts/step2-select-audience'; @@ -53,6 +55,57 @@ export default function NewBroadcastPage() { } } + /** + * Writes a draft broadcast row — no recipients, no sending. The user + * can revisit it via the list page to finish the flow later. We + * don't persist the in-progress audience/variable config here + * because the current schema doesn't carry it past `audience_filter` + * and `template_variables`; those are enough for the user to + * recognize the draft but not to exactly round-trip into the wizard. + * A full resume-draft UX is a future polish. + */ + async function handleSaveDraft() { + if (!template || !name.trim()) { + toast.error('Give the broadcast a name before saving a draft.'); + return; + } + const supabase = createClient(); + const { + data: { session }, + } = await supabase.auth.getSession(); + const user = session?.user; + if (!user) { + toast.error('Not signed in.'); + return; + } + + const { error } = await supabase.from('broadcasts').insert({ + user_id: user.id, + name: name.trim(), + template_name: template.name, + template_language: template.language ?? 'en_US', + template_variables: variables, + audience_filter: { + type: audience.type, + tagIds: audience.tagIds, + }, + status: 'draft', + total_recipients: 0, + sent_count: 0, + delivered_count: 0, + read_count: 0, + replied_count: 0, + failed_count: 0, + }); + + if (error) { + toast.error(`Failed to save draft: ${error.message}`); + return; + } + toast.success('Draft saved'); + router.push('/broadcasts'); + } + return (
{/* Header */} @@ -144,6 +197,7 @@ export default function NewBroadcastPage() { template={template} audience={audience} onSend={handleSend} + onSaveDraft={handleSaveDraft} onBack={() => setCurrentStep(2)} isProcessing={isProcessing} progress={progress} diff --git a/src/app/(dashboard)/broadcasts/page.tsx b/src/app/(dashboard)/broadcasts/page.tsx index b069caf..491b8dd 100644 --- a/src/app/(dashboard)/broadcasts/page.tsx +++ b/src/app/(dashboard)/broadcasts/page.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useEffect, useState } from 'react'; +import { useEffect, useState, useMemo, useRef } from 'react'; import { useRouter } from 'next/navigation'; import { createClient } from '@/lib/supabase/client'; import { Broadcast } from '@/types'; @@ -16,33 +16,95 @@ import { import { Radio, Plus, Loader2 } from 'lucide-react'; import { getBroadcastStatus } from '@/lib/broadcast-status'; +/** + * Poll cadence while any broadcast is sending. Kept modest so we don't + * beat on Supabase — the aggregate trigger in migration 003 keeps + * counts consistent; we just need to surface the freshest snapshot. + */ +const POLL_INTERVAL_MS = 5_000; + +function percent(numerator: number, denominator: number): number { + if (!denominator) return 0; + return Math.round((numerator / denominator) * 100); +} + +function RateCell({ + value, + total, + color, +}: { + value: number; + total: number; + /** Tailwind bg class for the fill, e.g. "bg-emerald-500" */ + color: string; +}) { + const pct = percent(value, total); + return ( +
+ + {pct}% + +
+
+
+
+ ); +} + export default function BroadcastsPage() { const router = useRouter(); const [broadcasts, setBroadcasts] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); - useEffect(() => { - async function fetchBroadcasts() { - try { - const supabase = createClient(); - const { data, error: fetchError } = await supabase - .from('broadcasts') - .select('*') - .order('created_at', { ascending: false }); + // Used to kick off polling only while something is actively sending. + const pollTimer = useRef | null>(null); - if (fetchError) throw fetchError; - setBroadcasts(data ?? []); - } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to load broadcasts'); - } finally { - setLoading(false); - } + async function fetchBroadcasts() { + try { + const supabase = createClient(); + const { data, error: fetchError } = await supabase + .from('broadcasts') + .select('*') + .order('created_at', { ascending: false }); + + if (fetchError) throw fetchError; + setBroadcasts(data ?? []); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to load broadcasts'); + } finally { + setLoading(false); } + } + useEffect(() => { fetchBroadcasts(); }, []); + const anySending = useMemo( + () => broadcasts.some((b) => b.status === 'sending'), + [broadcasts], + ); + + useEffect(() => { + // Start / stop the poller based on whether any row is sending. + if (anySending && !pollTimer.current) { + pollTimer.current = setInterval(fetchBroadcasts, POLL_INTERVAL_MS); + } else if (!anySending && pollTimer.current) { + clearInterval(pollTimer.current); + pollTimer.current = null; + } + return () => { + if (pollTimer.current) { + clearInterval(pollTimer.current); + pollTimer.current = null; + } + }; + }, [anySending]); + if (loading) { return (
@@ -64,6 +126,34 @@ export default function BroadcastsPage() { return (
+ {/* Top indeterminate progress bar: only visible while a broadcast + is mid-send. Pure CSS animation so no extra deps. */} + {anySending && ( +
+
+ +
+ )} +

Broadcasts

@@ -96,19 +186,19 @@ export default function BroadcastsPage() {
) : ( -
+
Name Template + + Recipients + + Delivery + Read Status - Recipients - Sent - Delivered - Read - Failed - Created + Date @@ -126,27 +216,35 @@ export default function BroadcastsPage() { {broadcast.template_name} - - - {status.label} - - - + {broadcast.total_recipients} - - {broadcast.sent_count} + + - - {broadcast.delivered_count} + + - - {broadcast.read_count} - - - {broadcast.failed_count} + + + {status.pulse && ( + + + + + )} + {status.label} + {new Date(broadcast.created_at).toLocaleDateString()} diff --git a/src/components/broadcasts/step4-schedule-send.tsx b/src/components/broadcasts/step4-schedule-send.tsx index dfaf3dd..8f31649 100644 --- a/src/components/broadcasts/step4-schedule-send.tsx +++ b/src/components/broadcasts/step4-schedule-send.tsx @@ -14,7 +14,7 @@ import { DialogTitle, DialogTrigger, } from '@/components/ui/dialog'; -import { ArrowLeft, Send, Clock, Loader2, Users, Lock } from 'lucide-react'; +import { ArrowLeft, Send, Clock, Loader2, Users, Lock, Save } from 'lucide-react'; interface AudienceConfig { type: string; @@ -28,6 +28,7 @@ interface Step4Props { template: MessageTemplate; audience: AudienceConfig; onSend: () => void; + onSaveDraft?: () => void; onBack: () => void; isProcessing: boolean; progress: number; @@ -39,6 +40,7 @@ export function Step4ScheduleSend({ template, audience, onSend, + onSaveDraft, onBack, isProcessing, progress, @@ -195,7 +197,7 @@ export function Step4ScheduleSend({ )} -
+
+ )} + + +
);