'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 (
{pct}%
); } 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()} ); })}
)}
); }