'use client';
import { useEffect, useState, useMemo, useRef } from 'react';
import { useRouter } from 'next/navigation';
import { createClient } from '@/lib/supabase/client';
import { Broadcast } from '@/types';
import { Button } from '@/components/ui/button';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { Radio, Plus, Loader2 } from 'lucide-react';
import { useCan } from '@/hooks/use-can';
import { GatedButton } from '@/components/ui/gated-button';
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-primary" */
color: string;
}) {
const pct = percent(value, total);
return (
);
}
export default function BroadcastsPage() {
const router = useRouter();
const canCreate = useCan('send-messages');
const [broadcasts, setBroadcasts] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
// Used to kick off polling only while something is actively sending.
const pollTimer = useRef | null>(null);
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(() => {
function startPolling() {
if (pollTimer.current) return;
pollTimer.current = setInterval(fetchBroadcasts, POLL_INTERVAL_MS);
}
function stopPolling() {
if (!pollTimer.current) return;
clearInterval(pollTimer.current);
pollTimer.current = null;
}
// Pause polling while the tab is hidden — keeps Supabase cold when
// the user is away, and ensures a fresh fetch the moment they
// refocus so they don't see stale data on return.
function handleVisibilityChange() {
if (!anySending) return;
if (document.visibilityState === 'hidden') {
stopPolling();
} else {
fetchBroadcasts();
startPolling();
}
}
if (anySending && document.visibilityState === 'visible') {
startPolling();
} else {
stopPolling();
}
document.addEventListener('visibilitychange', handleVisibilityChange);
return () => {
stopPolling();
document.removeEventListener('visibilitychange', handleVisibilityChange);
};
}, [anySending]);
if (loading) {
return (
);
}
if (error) {
return (
{error}
);
}
return (
{/* Top indeterminate progress bar: only visible while a broadcast
is mid-send. Pure CSS animation so no extra deps. */}
{anySending && (
)}
Broadcasts
Send bulk messages to your contacts using approved templates.
router.push('/broadcasts/new')}
className="bg-primary text-primary-foreground hover:bg-primary/90"
>
New Broadcast
{broadcasts.length === 0 ? (
No broadcasts yet
Create your first broadcast to reach your contacts at scale.
router.push('/broadcasts/new')}
className="mt-4 bg-primary text-primary-foreground hover:bg-primary/90"
>
New Broadcast
) : (
Name
Template
Recipients
Delivery
Read
Status
Date
{broadcasts.map((broadcast) => {
const status = getBroadcastStatus(broadcast.status);
return (
router.push(`/broadcasts/${broadcast.id}`)}
>
{broadcast.name}
{broadcast.template_name}
{broadcast.total_recipients}
{status.pulse && (
)}
{status.label}
{new Date(broadcast.created_at).toLocaleDateString()}
);
})}
)}
);
}