feat(broadcasts) B: list rates, detail funnel + CSV, save-as-draft

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) <noreply@anthropic.com>
This commit is contained in:
Arnas Donauskas 2026-04-17 11:31:13 +03:00
parent 5d5c5f0a20
commit 244d04ca93
No known key found for this signature in database
4 changed files with 481 additions and 97 deletions

View File

@ -1,9 +1,9 @@
'use client'; 'use client';
import { useEffect, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { useParams, useRouter } from 'next/navigation'; import { useParams, useRouter } from 'next/navigation';
import { createClient } from '@/lib/supabase/client'; import { createClient } from '@/lib/supabase/client';
import { Broadcast, BroadcastRecipient } from '@/types'; import { Broadcast, BroadcastRecipient, RecipientStatus } from '@/types';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { import {
Table, Table,
@ -13,6 +13,12 @@ import {
TableHeader, TableHeader,
TableRow, TableRow,
} from '@/components/ui/table'; } from '@/components/ui/table';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { import {
ArrowLeft, ArrowLeft,
Loader2, Loader2,
@ -21,6 +27,10 @@ import {
CheckCheck, CheckCheck,
Eye, Eye,
AlertCircle, AlertCircle,
MessageCircle,
Filter,
Download,
ChevronDown,
} from 'lucide-react'; } from 'lucide-react';
import { import {
getBroadcastStatus, 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 (
<div className="rounded-xl border border-slate-800 bg-slate-900 p-4">
<h3 className="mb-4 text-sm font-medium text-white">Funnel</h3>
<div className="space-y-2">
{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 (
<div key={step.label} className="flex items-center gap-3">
<span className="w-20 shrink-0 text-xs text-slate-400">
{step.label}
</span>
<div className="relative h-7 flex-1 rounded-full bg-slate-800">
<div
className={`h-7 rounded-full ${step.color} transition-[width] duration-500`}
style={{ width: `${pctOfMax}%` }}
/>
<span className="absolute inset-0 flex items-center px-3 text-xs font-medium text-white">
{step.value.toLocaleString()}
<span className="ml-2 text-slate-300/80">
({pctOfSent}%)
</span>
</span>
</div>
</div>
);
})}
</div>
</div>
);
}
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() { export default function BroadcastDetailPage() {
const params = useParams(); const params = useParams();
const router = useRouter(); const router = useRouter();
@ -60,6 +148,9 @@ export default function BroadcastDetailPage() {
const [recipients, setRecipients] = useState<BroadcastRecipient[]>([]); const [recipients, setRecipients] = useState<BroadcastRecipient[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [statusFilter, setStatusFilter] = useState<RecipientStatus | 'all'>(
'all',
);
useEffect(() => { useEffect(() => {
async function fetchData() { async function fetchData() {
@ -93,6 +184,41 @@ export default function BroadcastDetailPage() {
fetchData(); fetchData();
}, [broadcastId]); }, [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) { if (loading) {
return ( return (
<div className="flex h-64 items-center justify-center"> <div className="flex h-64 items-center justify-center">
@ -114,6 +240,13 @@ export default function BroadcastDetailPage() {
const status = getBroadcastStatus(broadcast.status); 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 ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* Header */} {/* Header */}
@ -139,14 +272,16 @@ export default function BroadcastDetailPage() {
<div className="mt-1 flex items-center gap-3 text-sm text-slate-400"> <div className="mt-1 flex items-center gap-3 text-sm text-slate-400">
<span>Template: {broadcast.template_name}</span> <span>Template: {broadcast.template_name}</span>
<span>-</span> <span>-</span>
<span>Created {new Date(broadcast.created_at).toLocaleDateString()}</span> <span>
Created {new Date(broadcast.created_at).toLocaleDateString()}
</span>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
{/* Stats */} {/* Stats — 6 cards: Total / Sent / Delivered / Read / Replied / Failed */}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5"> <div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-6">
<StatCard <StatCard
label="Total Recipients" label="Total Recipients"
value={broadcast.total_recipients} value={broadcast.total_recipients}
@ -159,21 +294,28 @@ export default function BroadcastDetailPage() {
value={broadcast.sent_count} value={broadcast.sent_count}
total={broadcast.total_recipients} total={broadcast.total_recipients}
icon={<Send className="h-4 w-4" />} icon={<Send className="h-4 w-4" />}
color="bg-blue-500/10 text-blue-400" color="bg-emerald-500/10 text-emerald-400"
/> />
<StatCard <StatCard
label="Delivered" label="Delivered"
value={broadcast.delivered_count} value={broadcast.delivered_count}
total={broadcast.total_recipients} total={broadcast.total_recipients}
icon={<CheckCheck className="h-4 w-4" />} icon={<CheckCheck className="h-4 w-4" />}
color="bg-emerald-500/10 text-emerald-400" color="bg-teal-500/10 text-teal-400"
/> />
<StatCard <StatCard
label="Read" label="Read"
value={broadcast.read_count} value={broadcast.read_count}
total={broadcast.total_recipients} total={broadcast.total_recipients}
icon={<Eye className="h-4 w-4" />} icon={<Eye className="h-4 w-4" />}
color="bg-emerald-500/10 text-emerald-300" color="bg-blue-500/10 text-blue-400"
/>
<StatCard
label="Replied"
value={broadcast.replied_count}
total={broadcast.total_recipients}
icon={<MessageCircle className="h-4 w-4" />}
color="bg-indigo-500/10 text-indigo-400"
/> />
<StatCard <StatCard
label="Failed" label="Failed"
@ -184,32 +326,95 @@ export default function BroadcastDetailPage() {
/> />
</div> </div>
<FunnelChart steps={funnelSteps} />
{/* Recipients Table */} {/* Recipients Table */}
<div className="rounded-xl border border-slate-800 bg-slate-900"> <div className="rounded-xl border border-slate-800 bg-slate-900">
<div className="border-b border-slate-800 px-4 py-3"> <div className="flex flex-wrap items-center justify-between gap-2 border-b border-slate-800 px-4 py-3">
<h2 className="text-sm font-medium text-white"> <h2 className="text-sm font-medium text-white">
Recipients ({recipients.length}) Recipients ({filteredRecipients.length}
{statusFilter !== 'all' ? ` of ${recipients.length}` : ''})
</h2> </h2>
<div className="flex items-center gap-2">
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
variant="outline"
size="sm"
className="border-slate-700 text-slate-300 hover:bg-slate-800"
/>
}
>
<Filter className="h-3.5 w-3.5" />
{statusFilter === 'all'
? 'All statuses'
: getRecipientStatus(statusFilter).label}
<ChevronDown className="h-3 w-3" />
</DropdownMenuTrigger>
<DropdownMenuContent className="border-slate-700 bg-slate-900">
<DropdownMenuItem
onClick={() => setStatusFilter('all')}
className={
statusFilter === 'all' ? 'text-emerald-400' : 'text-slate-300'
}
>
All statuses
</DropdownMenuItem>
{RECIPIENT_STATUSES.map((s) => (
<DropdownMenuItem
key={s}
onClick={() => setStatusFilter(s)}
className={
statusFilter === s
? 'text-emerald-400'
: 'text-slate-300'
}
>
{getRecipientStatus(s).label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
<Button
variant="outline"
size="sm"
onClick={handleExport}
disabled={recipients.length === 0}
className="border-slate-700 text-slate-300 hover:bg-slate-800"
>
<Download className="h-3.5 w-3.5" />
Export CSV
</Button>
</div> </div>
{recipients.length === 0 ? ( </div>
{filteredRecipients.length === 0 ? (
<div className="flex h-32 items-center justify-center"> <div className="flex h-32 items-center justify-center">
<p className="text-sm text-slate-400">No recipients found.</p> <p className="text-sm text-slate-400">
{recipients.length === 0
? 'No recipients found.'
: 'No recipients match this filter.'}
</p>
</div> </div>
) : ( ) : (
<div className="overflow-x-auto">
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow className="border-slate-800 hover:bg-transparent"> <TableRow className="border-slate-800 hover:bg-transparent">
<TableHead className="text-slate-400">Contact</TableHead> <TableHead className="text-slate-400">Contact</TableHead>
<TableHead className="text-slate-400">Phone</TableHead> <TableHead className="text-slate-400">Phone</TableHead>
<TableHead className="text-slate-400">Status</TableHead> <TableHead className="text-slate-400">Status</TableHead>
<TableHead className="text-slate-400">Sent At</TableHead> <TableHead className="text-slate-400">Sent</TableHead>
<TableHead className="text-slate-400">Delivered</TableHead>
<TableHead className="text-slate-400">Read</TableHead>
<TableHead className="text-slate-400">Error</TableHead> <TableHead className="text-slate-400">Error</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{recipients.map((recipient) => { {filteredRecipients.map((recipient) => {
const rStatus = getRecipientStatus(recipient.status); const rStatus = getRecipientStatus(recipient.status);
return ( return (
<TableRow key={recipient.id} className="border-slate-800"> <TableRow key={recipient.id} className="border-slate-800">
<TableCell className="font-medium text-white"> <TableCell className="font-medium text-white">
@ -230,6 +435,16 @@ export default function BroadcastDetailPage() {
? new Date(recipient.sent_at).toLocaleString() ? new Date(recipient.sent_at).toLocaleString()
: '-'} : '-'}
</TableCell> </TableCell>
<TableCell className="text-slate-400">
{recipient.delivered_at
? new Date(recipient.delivered_at).toLocaleString()
: '-'}
</TableCell>
<TableCell className="text-slate-400">
{recipient.read_at
? new Date(recipient.read_at).toLocaleString()
: '-'}
</TableCell>
<TableCell className="max-w-xs truncate text-xs text-red-400"> <TableCell className="max-w-xs truncate text-xs text-red-400">
{recipient.error_message ?? '-'} {recipient.error_message ?? '-'}
</TableCell> </TableCell>
@ -238,6 +453,7 @@ export default function BroadcastDetailPage() {
})} })}
</TableBody> </TableBody>
</Table> </Table>
</div>
)} )}
</div> </div>
</div> </div>

View File

@ -2,6 +2,8 @@
import { useState } from 'react'; import { useState } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { createClient } from '@/lib/supabase/client';
import { toast } from 'sonner';
import { MessageTemplate } from '@/types'; import { MessageTemplate } from '@/types';
import { Step1ChooseTemplate } from '@/components/broadcasts/step1-choose-template'; import { Step1ChooseTemplate } from '@/components/broadcasts/step1-choose-template';
import { Step2SelectAudience } from '@/components/broadcasts/step2-select-audience'; 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 ( return (
<div className="mx-auto max-w-3xl space-y-8"> <div className="mx-auto max-w-3xl space-y-8">
{/* Header */} {/* Header */}
@ -144,6 +197,7 @@ export default function NewBroadcastPage() {
template={template} template={template}
audience={audience} audience={audience}
onSend={handleSend} onSend={handleSend}
onSaveDraft={handleSaveDraft}
onBack={() => setCurrentStep(2)} onBack={() => setCurrentStep(2)}
isProcessing={isProcessing} isProcessing={isProcessing}
progress={progress} progress={progress}

View File

@ -1,6 +1,6 @@
'use client'; 'use client';
import { useEffect, useState } from 'react'; import { useEffect, useState, useMemo, useRef } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { createClient } from '@/lib/supabase/client'; import { createClient } from '@/lib/supabase/client';
import { Broadcast } from '@/types'; import { Broadcast } from '@/types';
@ -16,13 +16,53 @@ import {
import { Radio, Plus, Loader2 } from 'lucide-react'; import { Radio, Plus, Loader2 } from 'lucide-react';
import { getBroadcastStatus } from '@/lib/broadcast-status'; 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 (
<div className="flex items-center gap-2">
<span className="w-10 text-right text-xs tabular-nums text-slate-300">
{pct}%
</span>
<div className="h-1.5 w-20 overflow-hidden rounded-full bg-slate-800">
<div
className={`h-1.5 rounded-full ${color}`}
style={{ width: `${pct}%` }}
/>
</div>
</div>
);
}
export default function BroadcastsPage() { export default function BroadcastsPage() {
const router = useRouter(); const router = useRouter();
const [broadcasts, setBroadcasts] = useState<Broadcast[]>([]); const [broadcasts, setBroadcasts] = useState<Broadcast[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
useEffect(() => { // Used to kick off polling only while something is actively sending.
const pollTimer = useRef<ReturnType<typeof setInterval> | null>(null);
async function fetchBroadcasts() { async function fetchBroadcasts() {
try { try {
const supabase = createClient(); const supabase = createClient();
@ -40,9 +80,31 @@ export default function BroadcastsPage() {
} }
} }
useEffect(() => {
fetchBroadcasts(); 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) { if (loading) {
return ( return (
<div className="flex h-64 items-center justify-center"> <div className="flex h-64 items-center justify-center">
@ -64,6 +126,34 @@ export default function BroadcastsPage() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* Top indeterminate progress bar: only visible while a broadcast
is mid-send. Pure CSS animation so no extra deps. */}
{anySending && (
<div
role="progressbar"
aria-label="Broadcast in progress"
className="broadcast-indeterminate fixed inset-x-0 top-0 z-40 h-0.5 overflow-hidden bg-slate-800"
>
<div className="broadcast-indeterminate-bar h-0.5 bg-emerald-500" />
<style jsx>{`
.broadcast-indeterminate-bar {
width: 33%;
transform: translateX(-100%);
animation: broadcast-slide 1.6s cubic-bezier(0.4, 0, 0.2, 1)
infinite;
}
@keyframes broadcast-slide {
0% {
transform: translateX(-100%);
}
100% {
transform: translateX(400%);
}
}
`}</style>
</div>
)}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<h1 className="text-2xl font-bold text-white">Broadcasts</h1> <h1 className="text-2xl font-bold text-white">Broadcasts</h1>
@ -96,19 +186,19 @@ export default function BroadcastsPage() {
</Button> </Button>
</div> </div>
) : ( ) : (
<div className="rounded-xl border border-slate-800 bg-slate-900"> <div className="overflow-x-auto rounded-xl border border-slate-800 bg-slate-900">
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow className="border-slate-800 hover:bg-transparent"> <TableRow className="border-slate-800 hover:bg-transparent">
<TableHead className="text-slate-400">Name</TableHead> <TableHead className="text-slate-400">Name</TableHead>
<TableHead className="text-slate-400">Template</TableHead> <TableHead className="text-slate-400">Template</TableHead>
<TableHead className="text-slate-400 text-right">
Recipients
</TableHead>
<TableHead className="text-slate-400">Delivery</TableHead>
<TableHead className="text-slate-400">Read</TableHead>
<TableHead className="text-slate-400">Status</TableHead> <TableHead className="text-slate-400">Status</TableHead>
<TableHead className="text-slate-400 text-right">Recipients</TableHead> <TableHead className="text-slate-400">Date</TableHead>
<TableHead className="text-slate-400 text-right">Sent</TableHead>
<TableHead className="text-slate-400 text-right">Delivered</TableHead>
<TableHead className="text-slate-400 text-right">Read</TableHead>
<TableHead className="text-slate-400 text-right">Failed</TableHead>
<TableHead className="text-slate-400">Created</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
@ -126,27 +216,35 @@ export default function BroadcastsPage() {
<TableCell className="text-slate-300"> <TableCell className="text-slate-300">
{broadcast.template_name} {broadcast.template_name}
</TableCell> </TableCell>
<TableCell> <TableCell className="text-right text-slate-300 tabular-nums">
<span
className={`inline-flex items-center rounded-full border px-2 py-0.5 text-xs font-medium ${status.classes}`}
>
{status.label}
</span>
</TableCell>
<TableCell className="text-right text-slate-300">
{broadcast.total_recipients} {broadcast.total_recipients}
</TableCell> </TableCell>
<TableCell className="text-right text-slate-300"> <TableCell>
{broadcast.sent_count} <RateCell
value={broadcast.delivered_count}
total={broadcast.total_recipients}
color="bg-emerald-500"
/>
</TableCell> </TableCell>
<TableCell className="text-right text-slate-300"> <TableCell>
{broadcast.delivered_count} <RateCell
value={broadcast.read_count}
total={broadcast.total_recipients}
color="bg-blue-500"
/>
</TableCell> </TableCell>
<TableCell className="text-right text-slate-300"> <TableCell>
{broadcast.read_count} <span
</TableCell> className={`inline-flex items-center gap-1.5 rounded-full border px-2 py-0.5 text-xs font-medium ${status.classes}`}
<TableCell className="text-right text-slate-300"> >
{broadcast.failed_count} {status.pulse && (
<span className="relative flex h-1.5 w-1.5">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-yellow-400 opacity-75" />
<span className="relative inline-flex h-1.5 w-1.5 rounded-full bg-yellow-400" />
</span>
)}
{status.label}
</span>
</TableCell> </TableCell>
<TableCell className="text-slate-400"> <TableCell className="text-slate-400">
{new Date(broadcast.created_at).toLocaleDateString()} {new Date(broadcast.created_at).toLocaleDateString()}

View File

@ -14,7 +14,7 @@ import {
DialogTitle, DialogTitle,
DialogTrigger, DialogTrigger,
} from '@/components/ui/dialog'; } 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 { interface AudienceConfig {
type: string; type: string;
@ -28,6 +28,7 @@ interface Step4Props {
template: MessageTemplate; template: MessageTemplate;
audience: AudienceConfig; audience: AudienceConfig;
onSend: () => void; onSend: () => void;
onSaveDraft?: () => void;
onBack: () => void; onBack: () => void;
isProcessing: boolean; isProcessing: boolean;
progress: number; progress: number;
@ -39,6 +40,7 @@ export function Step4ScheduleSend({
template, template,
audience, audience,
onSend, onSend,
onSaveDraft,
onBack, onBack,
isProcessing, isProcessing,
progress, progress,
@ -195,7 +197,7 @@ export function Step4ScheduleSend({
</div> </div>
)} )}
<div className="flex items-center justify-between border-t border-slate-800 pt-4"> <div className="flex flex-wrap items-center justify-between gap-2 border-t border-slate-800 pt-4">
<Button <Button
variant="outline" variant="outline"
onClick={onBack} onClick={onBack}
@ -206,6 +208,19 @@ export function Step4ScheduleSend({
Back Back
</Button> </Button>
<div className="flex items-center gap-2">
{onSaveDraft && (
<Button
variant="outline"
onClick={onSaveDraft}
disabled={!name.trim() || isProcessing}
className="border-slate-700 text-slate-300 hover:bg-slate-800 disabled:opacity-50"
>
<Save className="h-4 w-4" />
Save as Draft
</Button>
)}
<Dialog open={showConfirm} onOpenChange={setShowConfirm}> <Dialog open={showConfirm} onOpenChange={setShowConfirm}>
<DialogTrigger <DialogTrigger
render={ render={
@ -252,5 +267,6 @@ export function Step4ScheduleSend({
</Dialog> </Dialog>
</div> </div>
</div> </div>
</div>
); );
} }