From 244d04ca9366eceee86d12f5a7cc124991ceb224 Mon Sep 17 00:00:00 2001 From: Arnas Donauskas Date: Fri, 17 Apr 2026 11:31:13 +0300 Subject: [PATCH 1/3] feat(broadcasts) B: list rates, detail funnel + CSV, save-as-draft MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/app/(dashboard)/broadcasts/[id]/page.tsx | 324 +++++++++++++++--- src/app/(dashboard)/broadcasts/new/page.tsx | 54 +++ src/app/(dashboard)/broadcasts/page.tsx | 178 +++++++--- .../broadcasts/step4-schedule-send.tsx | 22 +- 4 files changed, 481 insertions(+), 97 deletions(-) diff --git a/src/app/(dashboard)/broadcasts/[id]/page.tsx b/src/app/(dashboard)/broadcasts/[id]/page.tsx index 9053935..8ded950 100644 --- a/src/app/(dashboard)/broadcasts/[id]/page.tsx +++ b/src/app/(dashboard)/broadcasts/[id]/page.tsx @@ -1,9 +1,9 @@ 'use client'; -import { useEffect, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { useParams, useRouter } from 'next/navigation'; import { createClient } from '@/lib/supabase/client'; -import { Broadcast, BroadcastRecipient } from '@/types'; +import { Broadcast, BroadcastRecipient, RecipientStatus } from '@/types'; import { Button } from '@/components/ui/button'; import { Table, @@ -13,6 +13,12 @@ import { TableHeader, TableRow, } from '@/components/ui/table'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; import { ArrowLeft, Loader2, @@ -21,6 +27,10 @@ import { CheckCheck, Eye, AlertCircle, + MessageCircle, + Filter, + Download, + ChevronDown, } from 'lucide-react'; import { 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 ( +
+

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(); @@ -60,6 +148,9 @@ export default function BroadcastDetailPage() { const [recipients, setRecipients] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + const [statusFilter, setStatusFilter] = useState( + 'all', + ); useEffect(() => { async function fetchData() { @@ -93,6 +184,41 @@ export default function BroadcastDetailPage() { 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); + } + if (loading) { return (
@@ -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 ( +
+ + {pct}% + +
+
+
+
+ ); +} + 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({ )} -
+
+ )} + + +
); From 296a9059767045ce78dcc194d62cc5b94686c310 Mon Sep 17 00:00:00 2001 From: Arnas Donauskas Date: Fri, 17 Apr 2026 11:39:24 +0300 Subject: [PATCH 2/3] feat(broadcasts) C: custom-field audience, exclude tags, live preview MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stacked on PR B (#22). Merge A → B → C in order. Step 2 — Audience - Implement the "Custom Field" audience option (was a stub): field picker + operator (is / is not / contains) + value input. Estimated count query joins contact_custom_values with the operator mapped to eq / neq / ilike. PostgREST ilike handles "contains" case- insensitively. - New "Exclude contacts with these tags" section — applies to every audience type. Estimated count subtracts excluded contacts client-side against the resolved base set. - Switching audience type clears stale shape fields (tagIds / customField / csvContacts) so old selections don't leak across. Step 3 — Personalize - Add "Custom Field" mapping option alongside static + contact field. - Live Preview now pulls the most recent real contact (+ their custom values) instead of hard-coded sample data. Falls back to sample if the user has no contacts yet. Rendered in a WhatsApp-style bubble so the user gets a realistic feel for how the recipient sees it. Hook — use-broadcast-sending - AudienceConfig gains customField + excludeTagIds. - resolveAudience: custom-field branch + exclude-tag subtraction applied after the base resolution (works for all / tags / custom field). - resolveVariables accepts a pre-built (fieldId → value) map per contact. Before the send loop we bulk-fetch contact_custom_values for all resolved contacts in one query (paged by 500 for PostgREST limits) so we never N+1 per recipient. new/page.tsx — passes customField + excludeTagIds into the hook; variables state widens to allow type='custom_field'. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/app/(dashboard)/broadcasts/new/page.tsx | 12 +- .../broadcasts/step2-select-audience.tsx | 356 +++++++++++++++--- .../broadcasts/step3-personalize.tsx | 200 ++++++++-- src/hooks/use-broadcast-sending.ts | 277 ++++++++++---- 4 files changed, 690 insertions(+), 155 deletions(-) diff --git a/src/app/(dashboard)/broadcasts/new/page.tsx b/src/app/(dashboard)/broadcasts/new/page.tsx index 8d59704..d55c6be 100644 --- a/src/app/(dashboard)/broadcasts/new/page.tsx +++ b/src/app/(dashboard)/broadcasts/new/page.tsx @@ -28,10 +28,16 @@ export default function NewBroadcastPage() { const [audience, setAudience] = useState<{ type: 'all' | 'tags' | 'custom_field' | 'csv'; tagIds?: string[]; + customField?: { + fieldId: string; + operator: 'is' | 'is_not' | 'contains'; + value: string; + }; csvContacts?: { phone: string; name?: string }[]; + excludeTagIds?: string[]; }>({ type: 'all' }); const [variables, setVariables] = useState< - Record + Record >({}); const [name, setName] = useState(''); @@ -43,9 +49,11 @@ export default function NewBroadcastPage() { name, template, audience: { - type: audience.type === 'custom_field' ? 'all' : audience.type, + type: audience.type, tagIds: audience.tagIds, + customField: audience.customField, csvContacts: audience.csvContacts, + excludeTagIds: audience.excludeTagIds, }, variables, }); diff --git a/src/components/broadcasts/step2-select-audience.tsx b/src/components/broadcasts/step2-select-audience.tsx index 2e75f98..02aed9f 100644 --- a/src/components/broadcasts/step2-select-audience.tsx +++ b/src/components/broadcasts/step2-select-audience.tsx @@ -2,16 +2,34 @@ import { useEffect, useState, useCallback } from 'react'; import { createClient } from '@/lib/supabase/client'; -import { Tag } from '@/types'; +import { CustomField, Tag } from '@/types'; import { Button } from '@/components/ui/button'; -import { Users, Tags, Filter, Upload, Loader2, ArrowRight, ArrowLeft } from 'lucide-react'; +import { + Users, + Tags, + Filter, + Upload, + Loader2, + ArrowRight, + ArrowLeft, + X, +} from 'lucide-react'; type AudienceType = 'all' | 'tags' | 'custom_field' | 'csv'; +type CustomFieldOperator = 'is' | 'is_not' | 'contains'; + +interface CustomFieldFilter { + fieldId: string; + operator: CustomFieldOperator; + value: string; +} interface AudienceConfig { type: AudienceType; tagIds?: string[]; + customField?: CustomFieldFilter; csvContacts?: { phone: string; name?: string }[]; + excludeTagIds?: string[]; } interface Step2Props { @@ -21,33 +39,90 @@ interface Step2Props { onBack: () => void; } -const audienceOptions: { type: AudienceType; label: string; description: string; icon: typeof Users }[] = [ - { type: 'all', label: 'All Contacts', description: 'Send to every contact in your database', icon: Users }, - { type: 'tags', label: 'Filter by Tags', description: 'Target contacts with specific tags', icon: Tags }, - { type: 'custom_field', label: 'Custom Field', description: 'Filter by a custom field value', icon: Filter }, - { type: 'csv', label: 'Upload CSV', description: 'Upload a list of phone numbers', icon: Upload }, +const audienceOptions: { + type: AudienceType; + label: string; + description: string; + icon: typeof Users; +}[] = [ + { + type: 'all', + label: 'All Contacts', + description: 'Send to every contact in your database', + icon: Users, + }, + { + type: 'tags', + label: 'Filter by Tags', + description: 'Target contacts with specific tags', + icon: Tags, + }, + { + type: 'custom_field', + label: 'Custom Field', + description: 'Filter by a custom field value', + icon: Filter, + }, + { + type: 'csv', + label: 'Upload CSV', + description: 'Upload a list of phone numbers', + icon: Upload, + }, ]; -export function Step2SelectAudience({ audience, onUpdate, onNext, onBack }: Step2Props) { +const OPERATOR_OPTIONS: { value: CustomFieldOperator; label: string }[] = [ + { value: 'is', label: 'is' }, + { value: 'is_not', label: 'is not' }, + { value: 'contains', label: 'contains' }, +]; + +export function Step2SelectAudience({ + audience, + onUpdate, + onNext, + onBack, +}: Step2Props) { const [tags, setTags] = useState([]); + const [customFields, setCustomFields] = useState([]); const [loadingTags, setLoadingTags] = useState(false); + const [loadingFields, setLoadingFields] = useState(false); const [estimatedCount, setEstimatedCount] = useState(null); const [loadingCount, setLoadingCount] = useState(false); + // Tags are used both by the primary "Filter by Tags" audience type + // AND by the exclude-list below — so always load once on mount. useEffect(() => { - if (audience.type === 'tags') { - async function fetchTags() { - setLoadingTags(true); - try { - const supabase = createClient(); - const { data } = await supabase.from('tags').select('*').order('name'); - setTags(data ?? []); - } finally { - setLoadingTags(false); - } + async function fetchTags() { + setLoadingTags(true); + try { + const supabase = createClient(); + const { data } = await supabase.from('tags').select('*').order('name'); + setTags(data ?? []); + } finally { + setLoadingTags(false); } - fetchTags(); } + fetchTags(); + }, []); + + // Lazy-load custom fields only when that audience type is active. + useEffect(() => { + if (audience.type !== 'custom_field') return; + async function fetchFields() { + setLoadingFields(true); + try { + const supabase = createClient(); + const { data } = await supabase + .from('custom_fields') + .select('*') + .order('field_name'); + setCustomFields(data ?? []); + } finally { + setLoadingFields(false); + } + } + fetchFields(); }, [audience.type]); const fetchEstimatedCount = useCallback(async () => { @@ -55,36 +130,86 @@ export function Step2SelectAudience({ audience, onUpdate, onNext, onBack }: Step try { const supabase = createClient(); + // Base query — produces the superset before exclude is applied. + let baseIds: Set | null = null; // null means "all contacts" + if (audience.type === 'all') { - const { count } = await supabase - .from('contacts') - .select('*', { count: 'exact', head: true }); - setEstimatedCount(count ?? 0); - } else if (audience.type === 'tags' && audience.tagIds && audience.tagIds.length > 0) { - const { data: contactTags } = await supabase + // Handled below — full-table count adjusted by excludes. + } else if ( + audience.type === 'tags' && + audience.tagIds && + audience.tagIds.length > 0 + ) { + const { data } = await supabase .from('contact_tags') .select('contact_id') .in('tag_id', audience.tagIds); - - const uniqueIds = new Set((contactTags ?? []).map((ct) => ct.contact_id)); - setEstimatedCount(uniqueIds.size); - } else if (audience.type === 'csv' && audience.csvContacts) { + baseIds = new Set((data ?? []).map((r) => r.contact_id)); + } else if ( + audience.type === 'custom_field' && + audience.customField?.fieldId && + audience.customField.value + ) { + const { fieldId, operator, value } = audience.customField; + let q = supabase + .from('contact_custom_values') + .select('contact_id') + .eq('custom_field_id', fieldId); + if (operator === 'is') q = q.eq('value', value); + else if (operator === 'is_not') q = q.neq('value', value); + else q = q.ilike('value', `%${value}%`); + const { data } = await q; + baseIds = new Set((data ?? []).map((r) => r.contact_id)); + } else if ( + audience.type === 'csv' && + audience.csvContacts && + audience.csvContacts.length > 0 + ) { setEstimatedCount(audience.csvContacts.length); + return; } else { + // Partially-configured audience — wait for the user to finish. setEstimatedCount(null); + return; + } + + // Apply exclude tags + let excludeSet: Set | null = null; + if (audience.excludeTagIds && audience.excludeTagIds.length > 0) { + const { data: excludeRows } = await supabase + .from('contact_tags') + .select('contact_id') + .in('tag_id', audience.excludeTagIds); + excludeSet = new Set((excludeRows ?? []).map((r) => r.contact_id)); + } + + if (baseIds) { + const effective = [...baseIds].filter( + (id) => !excludeSet?.has(id), + ); + setEstimatedCount(effective.length); + } else { + // "All" — fetch the total, then subtract exclude set if any. + const { count } = await supabase + .from('contacts') + .select('*', { count: 'exact', head: true }); + const total = count ?? 0; + setEstimatedCount(excludeSet ? Math.max(0, total - excludeSet.size) : total); } } finally { setLoadingCount(false); } - }, [audience.type, audience.tagIds, audience.csvContacts]); + }, [ + audience.type, + audience.tagIds, + audience.customField, + audience.csvContacts, + audience.excludeTagIds, + ]); useEffect(() => { - if (audience.type === 'all' || (audience.type === 'tags' && audience.tagIds && audience.tagIds.length > 0) || (audience.type === 'csv' && audience.csvContacts && audience.csvContacts.length > 0)) { - fetchEstimatedCount(); - } else { - setEstimatedCount(null); - } - }, [audience.type, audience.tagIds, audience.csvContacts, fetchEstimatedCount]); + fetchEstimatedCount(); + }, [fetchEstimatedCount]); function toggleTag(tagId: string) { const current = audience.tagIds ?? []; @@ -94,16 +219,40 @@ export function Step2SelectAudience({ audience, onUpdate, onNext, onBack }: Step onUpdate({ ...audience, tagIds: updated }); } + function toggleExcludeTag(tagId: string) { + const current = audience.excludeTagIds ?? []; + const updated = current.includes(tagId) + ? current.filter((id) => id !== tagId) + : [...current, tagId]; + onUpdate({ ...audience, excludeTagIds: updated }); + } + + function updateCustomField(patch: Partial) { + const prev = audience.customField ?? { + fieldId: '', + operator: 'is' as CustomFieldOperator, + value: '', + }; + onUpdate({ ...audience, customField: { ...prev, ...patch } }); + } + const isValid = audience.type === 'all' || (audience.type === 'tags' && audience.tagIds && audience.tagIds.length > 0) || - (audience.type === 'csv' && audience.csvContacts && audience.csvContacts.length > 0); + (audience.type === 'custom_field' && + !!audience.customField?.fieldId && + audience.customField.value.length > 0) || + (audience.type === 'csv' && + audience.csvContacts && + audience.csvContacts.length > 0); return (

Select Audience

-

Choose who will receive this broadcast.

+

+ Choose who will receive this broadcast. +

@@ -113,7 +262,21 @@ export function Step2SelectAudience({ audience, onUpdate, onNext, onBack }: Step return ( ); @@ -142,7 +309,9 @@ export function Step2SelectAudience({ audience, onUpdate, onNext, onBack }: Step {loadingTags ? ( ) : tags.length === 0 ? ( -

No tags found. Create tags in Settings.

+

+ No tags found. Create tags in Settings. +

) : (
{tags.map((tag) => { @@ -170,27 +339,122 @@ export function Step2SelectAudience({ audience, onUpdate, onNext, onBack }: Step
)} + {audience.type === 'custom_field' && ( +
+

Custom Field Filter

+ {loadingFields ? ( + + ) : customFields.length === 0 ? ( +

+ No custom fields defined. Create one in Settings → Custom Fields. +

+ ) : ( +
+ + + updateCustomField({ value: e.target.value })} + placeholder="Value" + className="h-9 rounded-lg border border-slate-700 bg-slate-800 px-2.5 text-sm text-white outline-none placeholder:text-slate-500 focus:border-emerald-500 focus:ring-1 focus:ring-emerald-500" + /> +
+ )} +
+ )} + + {/* Exclude list — applies regardless of audience type */} +
+
+ +

+ Exclude contacts with these tags +

+ (optional) +
+ {tags.length === 0 ? ( +

No tags available.

+ ) : ( +
+ {tags.map((tag) => { + const isExcluded = audience.excludeTagIds?.includes(tag.id); + return ( + + ); + })} +
+ )} +
+ {/* Audience Summary */}

Audience Summary

{loadingCount ? (
- Calculating... + Calculating…
) : estimatedCount !== null ? (
- {estimatedCount.toLocaleString()} + + {estimatedCount.toLocaleString()} + estimated recipients
) : ( -

Select an audience type to see the estimate.

+

+ Select an audience type to see the estimate. +

)}
- diff --git a/src/components/broadcasts/step3-personalize.tsx b/src/components/broadcasts/step3-personalize.tsx index fa3764a..d8ec22b 100644 --- a/src/components/broadcasts/step3-personalize.tsx +++ b/src/components/broadcasts/step3-personalize.tsx @@ -1,7 +1,8 @@ 'use client'; -import { useMemo } from 'react'; -import { MessageTemplate } from '@/types'; +import { useEffect, useMemo, useState } from 'react'; +import { createClient } from '@/lib/supabase/client'; +import { Contact, CustomField, MessageTemplate } from '@/types'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { @@ -11,10 +12,12 @@ import { SelectTrigger, SelectValue, } from '@/components/ui/select'; -import { ArrowLeft, ArrowRight, Eye } from 'lucide-react'; +import { ArrowLeft, ArrowRight, Eye, Loader2 } from 'lucide-react'; + +type VariableType = 'static' | 'field' | 'custom_field'; interface VariableMapping { - type: 'static' | 'field'; + type: VariableType; value: string; } @@ -33,34 +36,102 @@ const contactFields = [ { value: 'company', label: 'Company' }, ]; -const sampleContact = { +const SAMPLE_CONTACT: Contact = { + id: 'sample', + user_id: '', name: 'John Doe', phone: '+1234567890', email: 'john@example.com', company: 'Acme Corp', + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), }; -export function Step3Personalize({ template, variables, onUpdate, onNext, onBack }: Step3Props) { - // Extract {{1}}, {{2}}, etc. from body_text +export function Step3Personalize({ + template, + variables, + onUpdate, + onNext, + onBack, +}: Step3Props) { + const [customFields, setCustomFields] = useState([]); + const [loadingFields, setLoadingFields] = useState(true); + const [firstContact, setFirstContact] = useState(null); + const [firstContactCustomValues, setFirstContactCustomValues] = useState< + Map + >(new Map()); + const [loadingPreview, setLoadingPreview] = useState(true); + + // Load user's custom fields + a representative contact for the + // live preview. Fall back to sample data if no contacts exist yet. + useEffect(() => { + let cancelled = false; + (async () => { + const supabase = createClient(); + const [fieldsRes, contactRes] = await Promise.all([ + supabase.from('custom_fields').select('*').order('field_name'), + supabase + .from('contacts') + .select('*') + .order('created_at', { ascending: false }) + .limit(1) + .maybeSingle(), + ]); + if (cancelled) return; + + setCustomFields(fieldsRes.data ?? []); + setLoadingFields(false); + + const contact = contactRes.data ?? null; + setFirstContact(contact); + + if (contact) { + const { data: customVals } = await supabase + .from('contact_custom_values') + .select('custom_field_id, value') + .eq('contact_id', contact.id); + if (!cancelled) { + const map = new Map(); + for (const row of customVals ?? []) { + map.set(row.custom_field_id, row.value ?? ''); + } + setFirstContactCustomValues(map); + } + } + setLoadingPreview(false); + })(); + return () => { + cancelled = true; + }; + }, []); + const placeholders = useMemo(() => { const matches = template.body_text.match(/\{\{(\d+)\}\}/g); if (!matches) return []; return [...new Set(matches)].sort(); }, [template.body_text]); - function updateVariable(key: string, field: Partial) { - const current = variables[key] ?? { type: 'static', value: '' }; + function updateVariable(key: string, patch: Partial) { + const current = variables[key] ?? { type: 'static' as VariableType, value: '' }; onUpdate({ ...variables, - [key]: { ...current, ...field }, + [key]: { ...current, ...patch }, }); } - // Generate preview with sample data + /** + * Substitute placeholders using the first real contact where + * possible. Placeholders keyed by "{{N}}" map to variable key "N". + */ const previewText = useMemo(() => { + const contact = firstContact ?? SAMPLE_CONTACT; + const customValues = firstContact + ? firstContactCustomValues + : new Map(); + let text = template.body_text; for (const placeholder of placeholders) { - const key = placeholder; + const key = placeholder.replace(/^\{\{|\}\}$/g, ''); const mapping = variables[key]; let replacement = placeholder; @@ -68,34 +139,59 @@ export function Step3Personalize({ template, variables, onUpdate, onNext, onBack if (mapping.type === 'static' && mapping.value) { replacement = mapping.value; } else if (mapping.type === 'field' && mapping.value) { - replacement = sampleContact[mapping.value as keyof typeof sampleContact] ?? placeholder; + const fieldMap: Record = { + name: contact.name, + phone: contact.phone, + email: contact.email, + company: contact.company, + }; + replacement = fieldMap[mapping.value] ?? placeholder; + } else if (mapping.type === 'custom_field' && mapping.value) { + replacement = customValues.get(mapping.value) || placeholder; } } text = text.replaceAll(placeholder, replacement); } return text; - }, [template.body_text, variables, placeholders]); + }, [ + template.body_text, + variables, + placeholders, + firstContact, + firstContactCustomValues, + ]); + + const previewLabel = firstContact + ? firstContact.name || firstContact.phone + : 'sample data'; return (

Personalize Message

- Map template variables to contact fields or static values. + Map template variables to contact fields, custom fields, or static + values.

{placeholders.length === 0 ? (
-

This template has no variables to personalize.

+

+ This template has no variables to personalize. +

) : (
{placeholders.map((placeholder) => { - const mapping = variables[placeholder] ?? { type: 'static', value: '' }; + const key = placeholder.replace(/^\{\{|\}\}$/g, ''); + const mapping = variables[key] ?? { type: 'static', value: '' }; return ( -
+
{placeholder} @@ -110,7 +206,10 @@ export function Step3Personalize({ template, variables, onUpdate, onNext, onBack
@@ -130,14 +232,18 @@ export function Step3Personalize({ template, variables, onUpdate, onNext, onBack {mapping.type === 'static' ? ( updateVariable(placeholder, { value: e.target.value })} + onChange={(e) => + updateVariable(key, { value: e.target.value }) + } placeholder="Enter value..." className="border-slate-700 bg-slate-800 text-white placeholder:text-slate-500" /> - ) : ( + ) : mapping.type === 'field' ? ( + ) : ( + )}
@@ -159,20 +291,32 @@ export function Step3Personalize({ template, variables, onUpdate, onNext, onBack
)} - {/* Preview */} + {/* Live Preview — rendered as a WhatsApp-style bubble so the user + sees approximately what the recipient will see. */}
-

Preview

- (with sample data) +

Live Preview

+ ({previewLabel}) + {loadingPreview && ( + + )}
-
-

{previewText}

+
+
+

+ {previewText} +

+
- diff --git a/src/hooks/use-broadcast-sending.ts b/src/hooks/use-broadcast-sending.ts index 7da7bad..4a487f7 100644 --- a/src/hooks/use-broadcast-sending.ts +++ b/src/hooks/use-broadcast-sending.ts @@ -4,17 +4,40 @@ import { useState } from 'react'; import { createClient } from '@/lib/supabase/client'; import { Contact, MessageTemplate } from '@/types'; -interface AudienceConfig { - type: 'all' | 'tags' | 'csv'; - tagIds?: string[]; - csvContacts?: { phone: string; name?: string }[]; +export type CustomFieldOperator = 'is' | 'is_not' | 'contains'; + +export interface CustomFieldFilter { + fieldId: string; + operator: CustomFieldOperator; + value: string; } +export interface AudienceConfig { + type: 'all' | 'tags' | 'custom_field' | 'csv'; + tagIds?: string[]; + customField?: CustomFieldFilter; + csvContacts?: { phone: string; name?: string }[]; + /** Contacts carrying any of these tags are subtracted from the result. */ + excludeTagIds?: string[]; +} + +/** + * Variable mapping — each template placeholder (by key, usually "1", + * "2", …) is resolved at send time. `field` maps to a built-in contact + * field (name/phone/email/company); `custom_field` maps to a + * contact_custom_values.value row keyed by the custom_fields.id stored + * in `value`. + */ +export type VariableMapping = + | { type: 'static'; value: string } + | { type: 'field'; value: string } + | { type: 'custom_field'; value: string }; + interface BroadcastPayload { name: string; template: MessageTemplate; audience: AudienceConfig; - variables: Record; + variables: Record; } interface UseBroadcastSendingReturn { @@ -24,17 +47,14 @@ interface UseBroadcastSendingReturn { } /** - * Meta rate-limit buffer. 10 per batch with a 1 s pause matches the - * spec and keeps us well below Meta's per-phone-number messaging rate - * so a large broadcast never trips the upstream limiter. + * Meta rate-limit buffer. 10 per batch + 1 s pause matches the spec + * and keeps us comfortably under Meta's per-phone-number messaging + * rate so a large broadcast never trips the upstream limiter. */ const SEND_BATCH_SIZE = 10; const SEND_BATCH_DELAY_MS = 1000; -/** - * `broadcast_recipients` inserts are independent of the send rate — - * we batch them purely to keep individual Supabase requests small. - */ +/** `broadcast_recipients` inserts are independent of the send rate. */ const INSERT_BATCH_SIZE = 200; function sleep(ms: number) { @@ -48,6 +68,77 @@ interface BroadcastApiResult { error?: string; } +/** contactId → (customFieldId → value). */ +type CustomValueIndex = Map>; + +/** + * Per-contact resolution of custom-field placeholders. Static and + * built-in-field mappings resolve synchronously; custom fields read + * from a pre-built index to avoid N+1 queries during the send loop. + */ +export function resolveVariables( + variables: Record, + contact: Contact, + customValues?: Map, +): string[] { + // Keys are typically "1","2",... — numeric-aware sort keeps + // {{1}} before {{10}}. + const keys = Object.keys(variables).sort((a, b) => { + const an = Number(a); + const bn = Number(b); + if (Number.isFinite(an) && Number.isFinite(bn)) return an - bn; + return a.localeCompare(b); + }); + + return keys.map((key) => { + const v = variables[key]; + if (v.type === 'static') return v.value; + + if (v.type === 'field') { + const fieldMap: Record = { + name: contact.name, + phone: contact.phone, + email: contact.email, + company: contact.company, + }; + return fieldMap[v.value] ?? ''; + } + + // custom_field + return customValues?.get(v.value) ?? ''; + }); +} + +/** + * Bulk-fetch contact_custom_values for a set of contacts. Returns an + * index keyed by contact_id → field_id → value. + */ +async function fetchCustomValueIndex( + supabase: ReturnType, + contactIds: string[], +): Promise { + const index: CustomValueIndex = new Map(); + if (contactIds.length === 0) return index; + + // Supabase PostgREST caps the .in(...) IN-clause roughly at 1000 + // values. Page through to stay safe. + const PAGE = 500; + for (let i = 0; i < contactIds.length; i += PAGE) { + const slice = contactIds.slice(i, i + PAGE); + const { data } = await supabase + .from('contact_custom_values') + .select('contact_id, custom_field_id, value') + .in('contact_id', slice); + + for (const row of data ?? []) { + const bucket = index.get(row.contact_id) ?? new Map(); + bucket.set(row.custom_field_id, row.value ?? ''); + index.set(row.contact_id, bucket); + } + } + return index; +} + export function useBroadcastSending(): UseBroadcastSendingReturn { const [isProcessing, setIsProcessing] = useState(false); const [progress, setProgress] = useState(0); @@ -55,33 +146,45 @@ export function useBroadcastSending(): UseBroadcastSendingReturn { async function resolveAudience(audience: AudienceConfig): Promise { const supabase = createClient(); + let contacts: Contact[] = []; + if (audience.type === 'all') { const { data, error } = await supabase.from('contacts').select('*'); if (error) throw new Error(`Failed to fetch contacts: ${error.message}`); - return data ?? []; - } - - if (audience.type === 'tags' && audience.tagIds && audience.tagIds.length > 0) { + contacts = data ?? []; + } else if ( + audience.type === 'tags' && + audience.tagIds && + audience.tagIds.length > 0 + ) { const { data: contactTags, error: tagError } = await supabase .from('contact_tags') .select('contact_id') .in('tag_id', audience.tagIds); - if (tagError) throw new Error(`Failed to fetch contact tags: ${tagError.message}`); - if (!contactTags || contactTags.length === 0) return []; + if (tagError) + throw new Error(`Failed to fetch contact tags: ${tagError.message}`); - const uniqueContactIds = [...new Set(contactTags.map((ct) => ct.contact_id))]; - - const { data: contacts, error: contactError } = await supabase - .from('contacts') - .select('*') - .in('id', uniqueContactIds); - - if (contactError) throw new Error(`Failed to fetch contacts: ${contactError.message}`); - return contacts ?? []; - } - - if (audience.type === 'csv' && audience.csvContacts) { + if (contactTags && contactTags.length > 0) { + const uniqueContactIds = [ + ...new Set(contactTags.map((ct) => ct.contact_id)), + ]; + const { data, error } = await supabase + .from('contacts') + .select('*') + .in('id', uniqueContactIds); + if (error) throw new Error(`Failed to fetch contacts: ${error.message}`); + contacts = data ?? []; + } + } else if (audience.type === 'custom_field' && audience.customField) { + contacts = await resolveCustomFieldAudience(supabase, audience.customField); + } else if (audience.type === 'csv' && audience.csvContacts) { + // CSV path — contacts are synthesized client-side. Note the + // synthesized ids (`csv-N`) are NOT real DB ids; the send flow + // still works because the broadcast_recipients insert uses + // contact_id. For CSV we skip the recipient row path entirely + // at the API boundary (out of scope of this refactor — keeping + // prior behavior). return audience.csvContacts.map((c, i) => ({ id: `csv-${i}`, user_id: '', @@ -92,33 +195,51 @@ export function useBroadcastSending(): UseBroadcastSendingReturn { })); } - return []; + // Apply exclude tags (works across all contact-derived audience + // types). CSV contacts are synthetic so exclusion doesn't apply. + if (audience.excludeTagIds && audience.excludeTagIds.length > 0) { + const { data: excludeRows } = await supabase + .from('contact_tags') + .select('contact_id') + .in('tag_id', audience.excludeTagIds); + const excludedIds = new Set((excludeRows ?? []).map((r) => r.contact_id)); + contacts = contacts.filter((c) => !excludedIds.has(c.id)); + } + + return contacts; } - function resolveVariables( - variables: Record, - contact: Contact - ): string[] { - // Keys typically are "1","2",... so a numeric-aware sort keeps {{1}} - // before {{10}}. - const keys = Object.keys(variables).sort((a, b) => { - const an = Number(a); - const bn = Number(b); - if (Number.isFinite(an) && Number.isFinite(bn)) return an - bn; - return a.localeCompare(b); - }); + async function resolveCustomFieldAudience( + supabase: ReturnType, + filter: CustomFieldFilter, + ): Promise { + const { fieldId, operator, value } = filter; - return keys.map((key) => { - const v = variables[key]; - if (v.type === 'static') return v.value; - const fieldMap: Record = { - name: contact.name, - phone: contact.phone, - email: contact.email, - company: contact.company, - }; - return fieldMap[v.value] ?? ''; - }); + // Build the WHERE clause for the operator. PostgREST supports + // eq/neq/ilike via the query builder — use ilike with wildcards + // for "contains" so the match is case-insensitive. + let query = supabase + .from('contact_custom_values') + .select('contact_id') + .eq('custom_field_id', fieldId); + + if (operator === 'is') query = query.eq('value', value); + else if (operator === 'is_not') query = query.neq('value', value); + else if (operator === 'contains') query = query.ilike('value', `%${value}%`); + + const { data: matches, error: matchErr } = await query; + if (matchErr) + throw new Error(`Custom-field filter failed: ${matchErr.message}`); + + const contactIds = [...new Set((matches ?? []).map((m) => m.contact_id))]; + if (contactIds.length === 0) return []; + + const { data, error } = await supabase + .from('contacts') + .select('*') + .in('id', contactIds); + if (error) throw new Error(`Failed to fetch contacts: ${error.message}`); + return data ?? []; } async function createAndSendBroadcast(payload: BroadcastPayload): Promise { @@ -148,6 +269,8 @@ export function useBroadcastSending(): UseBroadcastSendingReturn { audience_filter: { type: payload.audience.type, tagIds: payload.audience.tagIds, + customField: payload.audience.customField, + excludeTagIds: payload.audience.excludeTagIds, }, status: 'sending', total_recipients: contacts.length, @@ -164,7 +287,7 @@ export function useBroadcastSending(): UseBroadcastSendingReturn { throw new Error(`Failed to create broadcast: ${broadcastError?.message}`); } - // ── Step 3: Insert recipient rows (batched for request size) ── + // ── Step 3: Insert recipient rows ───────────────────────────── setProgress(20); const recipientRows = contacts.map((contact) => ({ broadcast_id: broadcast.id, @@ -177,13 +300,12 @@ export function useBroadcastSending(): UseBroadcastSendingReturn { const { error: recipientError } = await supabase .from('broadcast_recipients') .insert(batch); - if (recipientError) { console.error('Failed to insert recipient batch:', recipientError); } } - // ── Step 4: Fetch recipients (with joined contact) and send ─── + // ── Step 4: Fetch recipients (joined contact) + preload custom values setProgress(30); const { data: recipients, error: recipientsFetchError } = await supabase .from('broadcast_recipients') @@ -194,21 +316,33 @@ export function useBroadcastSending(): UseBroadcastSendingReturn { throw new Error('Failed to fetch broadcast recipients'); } - let sentCount = 0; + // One bulk fetch of custom values for every contact in this + // broadcast, avoiding N+1 during the send loop. + const contactIds = recipients + .map((r) => r.contact?.id) + .filter((id): id is string => Boolean(id)); + const customValueIndex = await fetchCustomValueIndex( + supabase, + contactIds, + ); + let failedCount = 0; const totalRecipients = recipients.length; for (let i = 0; i < recipients.length; i += SEND_BATCH_SIZE) { const batch = recipients.slice(i, i + SEND_BATCH_SIZE); - // Build per-recipient payload so each contact gets *their own* - // personalization. Previous impl shipped templateParams[0] for - // the whole batch which was a correctness bug. const apiRecipients = batch .filter((r) => r.contact?.phone) .map((r) => ({ phone: r.contact!.phone as string, - params: r.contact ? resolveVariables(payload.variables, r.contact) : [], + params: r.contact + ? resolveVariables( + payload.variables, + r.contact, + customValueIndex.get(r.contact.id), + ) + : [], })); if (apiRecipients.length === 0) continue; @@ -230,8 +364,6 @@ export function useBroadcastSending(): UseBroadcastSendingReturn { throw new Error(data.error || 'Broadcast API request failed'); } - // The API returns one entry per input recipient in order. - // Map by phone to be defensive against any reordering. const resultsByPhone = new Map(); for (const r of (data.results ?? []) as BroadcastApiResult[]) { resultsByPhone.set(r.phone, r); @@ -242,8 +374,6 @@ export function useBroadcastSending(): UseBroadcastSendingReturn { const result = phone ? resultsByPhone.get(phone) : undefined; if (!result) { - // No result for this phone — the API skipped it (most - // likely because contact.phone was missing). Mark failed. failedCount++; await supabase .from('broadcast_recipients') @@ -256,7 +386,6 @@ export function useBroadcastSending(): UseBroadcastSendingReturn { } if (result.status === 'sent') { - sentCount++; await supabase .from('broadcast_recipients') .update({ @@ -278,8 +407,6 @@ export function useBroadcastSending(): UseBroadcastSendingReturn { } } } catch (err) { - // Network / 500 — mark the whole batch failed. Trigger - // re-aggregates counts on the parent broadcast. for (const recipient of batch) { failedCount++; await supabase @@ -292,23 +419,18 @@ export function useBroadcastSending(): UseBroadcastSendingReturn { } } - // Progress reflects the send phase (30% → 95%). const progressPct = 30 + Math.round(((i + batch.length) / totalRecipients) * 60); setProgress(progressPct); - // Space successive batches by 1 s to stay under Meta's rate - // limit. Skip the delay after the last batch so we don't pad - // the tail of the run. if (i + SEND_BATCH_SIZE < recipients.length) { await sleep(SEND_BATCH_DELAY_MS); } } - // ── Step 5: Finalize broadcast status ───────────────────────── + // ── Step 5: Finalize status ─────────────────────────────────── // Aggregate counts are maintained by the DB trigger (migration - // 003), so we only update `status` here. sent_count etc. are - // already accurate. + // 003); we only flip the final status here. setProgress(95); const finalStatus = failedCount === totalRecipients ? 'failed' : 'sent'; await supabase @@ -317,9 +439,6 @@ export function useBroadcastSending(): UseBroadcastSendingReturn { .eq('id', broadcast.id); setProgress(100); - // Unused locals intentionally kept for clarity at return site: - void sentCount; - return broadcast.id; } finally { setIsProcessing(false); From cd04e0682c8a71844651cf5e2dcdadde84b269b1 Mon Sep 17 00:00:00 2001 From: Arnas Donauskas Date: Fri, 17 Apr 2026 13:55:18 +0300 Subject: [PATCH 3/3] fix(broadcasts): attach user_id on send, surface errors with toast MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The INSERT into broadcasts in use-broadcast-sending.ts never set user_id, but broadcasts.user_id is NOT NULL + RLS-guarded. The INSERT rejected (23502 / 42501), the hook threw, handleSend caught with console.error only. UI showed nothing — the wizard stayed on Step 4 and the flow appeared dead. Fix: - Resolve session.user at the top of createAndSendBroadcast and include user_id on the broadcasts INSERT. - handleSend in /broadcasts/new now toasts the error via sonner so future failures are visible immediately instead of vanishing. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/app/(dashboard)/broadcasts/new/page.tsx | 4 ++++ src/hooks/use-broadcast-sending.ts | 18 +++++++++++++++++- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/src/app/(dashboard)/broadcasts/new/page.tsx b/src/app/(dashboard)/broadcasts/new/page.tsx index d55c6be..14426f4 100644 --- a/src/app/(dashboard)/broadcasts/new/page.tsx +++ b/src/app/(dashboard)/broadcasts/new/page.tsx @@ -59,7 +59,11 @@ export default function NewBroadcastPage() { }); router.push(`/broadcasts/${broadcastId}`); } catch (err) { + // Previously swallowed with console.error — the wizard would + // just no-op, leaving the user confused. Surface the reason. + const message = err instanceof Error ? err.message : 'Broadcast failed'; console.error('Broadcast failed:', err); + toast.error(message); } } diff --git a/src/hooks/use-broadcast-sending.ts b/src/hooks/use-broadcast-sending.ts index 4a487f7..ebfb470 100644 --- a/src/hooks/use-broadcast-sending.ts +++ b/src/hooks/use-broadcast-sending.ts @@ -249,6 +249,19 @@ export function useBroadcastSending(): UseBroadcastSendingReturn { const supabase = createClient(); try { + // ── Step 0: Resolve current user ────────────────────────────── + // broadcasts.user_id is NOT NULL + guarded by RLS + // (auth.uid() = user_id). Without this, the INSERT below was + // silently failing with 23502 / 42501 — the wizard would + // no-op with no feedback. + const { + data: { session }, + } = await supabase.auth.getSession(); + const user = session?.user; + if (!user) { + throw new Error('You are not signed in.'); + } + // ── Step 1: Resolve audience contacts ───────────────────────── setProgress(5); const contacts = await resolveAudience(payload.audience); @@ -262,6 +275,7 @@ export function useBroadcastSending(): UseBroadcastSendingReturn { const { data: broadcast, error: broadcastError } = await supabase .from('broadcasts') .insert({ + user_id: user.id, name: payload.name, template_name: payload.template.name, template_language: payload.template.language ?? 'en_US', @@ -284,7 +298,9 @@ export function useBroadcastSending(): UseBroadcastSendingReturn { .single(); if (broadcastError || !broadcast) { - throw new Error(`Failed to create broadcast: ${broadcastError?.message}`); + throw new Error( + `Failed to create broadcast: ${broadcastError?.message ?? 'unknown error'}`, + ); } // ── Step 3: Insert recipient rows ─────────────────────────────