@@ -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 (
+
+ );
+}
+
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({
)}
-
+
);