'use client';
import { useEffect, useMemo, useState } from 'react';
import { useParams, useRouter } from 'next/navigation';
import { createClient } from '@/lib/supabase/client';
import { Broadcast, BroadcastRecipient, RecipientStatus } from '@/types';
import { Button } from '@/components/ui/button';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import {
ArrowLeft,
Loader2,
Users,
Send,
CheckCheck,
Eye,
AlertCircle,
MessageCircle,
Filter,
Download,
ChevronDown,
Trash2,
} from 'lucide-react';
import { toast } from 'sonner';
import {
getBroadcastStatus,
getRecipientStatus,
} from '@/lib/broadcast-status';
interface StatCardProps {
label: string;
value: number;
total: number;
icon: React.ReactNode;
color: string;
}
function StatCard({ label, value, total, icon, color }: StatCardProps) {
const pct = total > 0 ? Math.round((value / total) * 100) : 0;
return (
{value.toLocaleString()}
{label}
);
}
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();
const broadcastId = params.id as string;
const [broadcast, setBroadcast] = useState(null);
const [recipients, setRecipients] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [statusFilter, setStatusFilter] = useState(
'all',
);
const [confirmDelete, setConfirmDelete] = useState(false);
const [deleting, setDeleting] = useState(false);
useEffect(() => {
async function fetchData() {
try {
const supabase = createClient();
const { data: bc, error: bcError } = await supabase
.from('broadcasts')
.select('*')
.eq('id', broadcastId)
.single();
if (bcError) throw bcError;
setBroadcast(bc);
const { data: recs, error: recsError } = await supabase
.from('broadcast_recipients')
.select('*, contact:contacts(*)')
.eq('broadcast_id', broadcastId)
.order('created_at', { ascending: false });
if (recsError) throw recsError;
setRecipients(recs ?? []);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load broadcast');
} finally {
setLoading(false);
}
}
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);
}
async function handleDelete() {
setDeleting(true);
const supabase = createClient();
// broadcast_recipients cascades on broadcasts.id (migration 001), so a
// single delete is sufficient — the aggregate trigger in migration 003
// is defined on broadcast_recipients but fires only on its own row
// changes, not on a cascaded drop of the parent row.
const { error: delErr } = await supabase
.from('broadcasts')
.delete()
.eq('id', broadcastId);
setDeleting(false);
if (delErr) {
toast.error(`Failed to delete: ${delErr.message}`);
return;
}
toast.success('Broadcast deleted');
router.push('/broadcasts');
}
if (loading) {
return (
);
}
if (error || !broadcast) {
return (
{error ?? 'Broadcast not found'}
router.push('/broadcasts')}>
Back to Broadcasts
);
}
const status = getBroadcastStatus(broadcast.status);
const funnelSteps: FunnelStep[] = [
{ label: 'Sent', value: broadcast.sent_count, color: 'bg-primary' },
{ 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 */}
router.push('/broadcasts')}
className="border-slate-700"
>
{broadcast.name}
{status.label}
Template: {broadcast.template_name}
-
Created {new Date(broadcast.created_at).toLocaleDateString()}
{/* Delete — inline-confirm pattern matches the pipeline-settings
"Delete Pipeline" flow. Mid-send broadcasts can't be deleted
because orphaning in-flight Meta messages would leave the
funnel inconsistent. */}
{confirmDelete ? (
Delete this broadcast?
setConfirmDelete(false)}
disabled={deleting}
className="h-7 border-slate-700 bg-transparent text-slate-300 hover:bg-slate-800"
>
Cancel
{deleting ? 'Deleting…' : 'Confirm'}
) : (
setConfirmDelete(true)}
title={
broadcast.status === 'sending'
? 'Cannot delete while a broadcast is actively sending'
: 'Delete this broadcast'
}
className="border-red-500/30 bg-transparent text-red-400 hover:bg-red-500/10 disabled:opacity-40"
>
Delete
)}
{/* Stats — 6 cards: Total / Sent / Delivered / Read / Replied / Failed */}
}
color="bg-slate-800 text-slate-300"
/>
}
color="bg-primary/10 text-primary"
/>
}
color="bg-teal-500/10 text-teal-400"
/>
}
color="bg-blue-500/10 text-blue-400"
/>
}
color="bg-indigo-500/10 text-indigo-400"
/>
}
color="bg-red-500/10 text-red-400"
/>
{/* Recipients Table */}
Recipients ({filteredRecipients.length}
{statusFilter !== 'all' ? ` of ${recipients.length}` : ''})
}
>
{statusFilter === 'all'
? 'All statuses'
: getRecipientStatus(statusFilter).label}
setStatusFilter('all')}
className={
statusFilter === 'all' ? 'text-primary' : 'text-slate-300'
}
>
All statuses
{RECIPIENT_STATUSES.map((s) => (
setStatusFilter(s)}
className={
statusFilter === s
? 'text-primary'
: 'text-slate-300'
}
>
{getRecipientStatus(s).label}
))}
Export CSV
{filteredRecipients.length === 0 ? (
{recipients.length === 0
? 'No recipients found.'
: 'No recipients match this filter.'}
) : (
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 ?? '-'}
);
})}
)}
);
}