mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 09:19:51 +00:00
Merge pull request #25 from ArnasDon/fix/broadcasts-promote-b-c-and-send
fix(broadcasts): promote B+C + attach user_id on send
This commit is contained in:
commit
ed8dd6de84
@ -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 (
|
||||
<div className="rounded-xl border border-slate-800 bg-slate-900 p-4">
|
||||
<h3 className="mb-4 text-sm font-medium text-white">Funnel</h3>
|
||||
<div className="space-y-2">
|
||||
{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 (
|
||||
<div key={step.label} className="flex items-center gap-3">
|
||||
<span className="w-20 shrink-0 text-xs text-slate-400">
|
||||
{step.label}
|
||||
</span>
|
||||
<div className="relative h-7 flex-1 rounded-full bg-slate-800">
|
||||
<div
|
||||
className={`h-7 rounded-full ${step.color} transition-[width] duration-500`}
|
||||
style={{ width: `${pctOfMax}%` }}
|
||||
/>
|
||||
<span className="absolute inset-0 flex items-center px-3 text-xs font-medium text-white">
|
||||
{step.value.toLocaleString()}
|
||||
<span className="ml-2 text-slate-300/80">
|
||||
({pctOfSent}%)
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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<BroadcastRecipient[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [statusFilter, setStatusFilter] = useState<RecipientStatus | 'all'>(
|
||||
'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 (
|
||||
<div className="flex h-64 items-center justify-center">
|
||||
@ -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 (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
@ -139,14 +272,16 @@ export default function BroadcastDetailPage() {
|
||||
<div className="mt-1 flex items-center gap-3 text-sm text-slate-400">
|
||||
<span>Template: {broadcast.template_name}</span>
|
||||
<span>-</span>
|
||||
<span>Created {new Date(broadcast.created_at).toLocaleDateString()}</span>
|
||||
<span>
|
||||
Created {new Date(broadcast.created_at).toLocaleDateString()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5">
|
||||
{/* Stats — 6 cards: Total / Sent / Delivered / Read / Replied / Failed */}
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-6">
|
||||
<StatCard
|
||||
label="Total Recipients"
|
||||
value={broadcast.total_recipients}
|
||||
@ -159,21 +294,28 @@ export default function BroadcastDetailPage() {
|
||||
value={broadcast.sent_count}
|
||||
total={broadcast.total_recipients}
|
||||
icon={<Send className="h-4 w-4" />}
|
||||
color="bg-blue-500/10 text-blue-400"
|
||||
color="bg-emerald-500/10 text-emerald-400"
|
||||
/>
|
||||
<StatCard
|
||||
label="Delivered"
|
||||
value={broadcast.delivered_count}
|
||||
total={broadcast.total_recipients}
|
||||
icon={<CheckCheck className="h-4 w-4" />}
|
||||
color="bg-emerald-500/10 text-emerald-400"
|
||||
color="bg-teal-500/10 text-teal-400"
|
||||
/>
|
||||
<StatCard
|
||||
label="Read"
|
||||
value={broadcast.read_count}
|
||||
total={broadcast.total_recipients}
|
||||
icon={<Eye className="h-4 w-4" />}
|
||||
color="bg-emerald-500/10 text-emerald-300"
|
||||
color="bg-blue-500/10 text-blue-400"
|
||||
/>
|
||||
<StatCard
|
||||
label="Replied"
|
||||
value={broadcast.replied_count}
|
||||
total={broadcast.total_recipients}
|
||||
icon={<MessageCircle className="h-4 w-4" />}
|
||||
color="bg-indigo-500/10 text-indigo-400"
|
||||
/>
|
||||
<StatCard
|
||||
label="Failed"
|
||||
@ -184,60 +326,134 @@ export default function BroadcastDetailPage() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FunnelChart steps={funnelSteps} />
|
||||
|
||||
{/* Recipients Table */}
|
||||
<div className="rounded-xl border border-slate-800 bg-slate-900">
|
||||
<div className="border-b border-slate-800 px-4 py-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-slate-800 px-4 py-3">
|
||||
<h2 className="text-sm font-medium text-white">
|
||||
Recipients ({recipients.length})
|
||||
Recipients ({filteredRecipients.length}
|
||||
{statusFilter !== 'all' ? ` of ${recipients.length}` : ''})
|
||||
</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="border-slate-700 text-slate-300 hover:bg-slate-800"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Filter className="h-3.5 w-3.5" />
|
||||
{statusFilter === 'all'
|
||||
? 'All statuses'
|
||||
: getRecipientStatus(statusFilter).label}
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="border-slate-700 bg-slate-900">
|
||||
<DropdownMenuItem
|
||||
onClick={() => setStatusFilter('all')}
|
||||
className={
|
||||
statusFilter === 'all' ? 'text-emerald-400' : 'text-slate-300'
|
||||
}
|
||||
>
|
||||
All statuses
|
||||
</DropdownMenuItem>
|
||||
{RECIPIENT_STATUSES.map((s) => (
|
||||
<DropdownMenuItem
|
||||
key={s}
|
||||
onClick={() => setStatusFilter(s)}
|
||||
className={
|
||||
statusFilter === s
|
||||
? 'text-emerald-400'
|
||||
: 'text-slate-300'
|
||||
}
|
||||
>
|
||||
{getRecipientStatus(s).label}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleExport}
|
||||
disabled={recipients.length === 0}
|
||||
className="border-slate-700 text-slate-300 hover:bg-slate-800"
|
||||
>
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
Export CSV
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{recipients.length === 0 ? (
|
||||
|
||||
{filteredRecipients.length === 0 ? (
|
||||
<div className="flex h-32 items-center justify-center">
|
||||
<p className="text-sm text-slate-400">No recipients found.</p>
|
||||
<p className="text-sm text-slate-400">
|
||||
{recipients.length === 0
|
||||
? 'No recipients found.'
|
||||
: 'No recipients match this filter.'}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="border-slate-800 hover:bg-transparent">
|
||||
<TableHead className="text-slate-400">Contact</TableHead>
|
||||
<TableHead className="text-slate-400">Phone</TableHead>
|
||||
<TableHead className="text-slate-400">Status</TableHead>
|
||||
<TableHead className="text-slate-400">Sent At</TableHead>
|
||||
<TableHead className="text-slate-400">Error</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{recipients.map((recipient) => {
|
||||
const rStatus = getRecipientStatus(recipient.status);
|
||||
|
||||
return (
|
||||
<TableRow key={recipient.id} className="border-slate-800">
|
||||
<TableCell className="font-medium text-white">
|
||||
{recipient.contact?.name ?? 'Unknown'}
|
||||
</TableCell>
|
||||
<TableCell className="text-slate-300">
|
||||
{recipient.contact?.phone ?? '-'}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span
|
||||
className={`inline-flex items-center rounded-full border px-2 py-0.5 text-xs font-medium ${rStatus.classes}`}
|
||||
>
|
||||
{rStatus.label}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-slate-400">
|
||||
{recipient.sent_at
|
||||
? new Date(recipient.sent_at).toLocaleString()
|
||||
: '-'}
|
||||
</TableCell>
|
||||
<TableCell className="max-w-xs truncate text-xs text-red-400">
|
||||
{recipient.error_message ?? '-'}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="border-slate-800 hover:bg-transparent">
|
||||
<TableHead className="text-slate-400">Contact</TableHead>
|
||||
<TableHead className="text-slate-400">Phone</TableHead>
|
||||
<TableHead className="text-slate-400">Status</TableHead>
|
||||
<TableHead className="text-slate-400">Sent</TableHead>
|
||||
<TableHead className="text-slate-400">Delivered</TableHead>
|
||||
<TableHead className="text-slate-400">Read</TableHead>
|
||||
<TableHead className="text-slate-400">Error</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredRecipients.map((recipient) => {
|
||||
const rStatus = getRecipientStatus(recipient.status);
|
||||
return (
|
||||
<TableRow key={recipient.id} className="border-slate-800">
|
||||
<TableCell className="font-medium text-white">
|
||||
{recipient.contact?.name ?? 'Unknown'}
|
||||
</TableCell>
|
||||
<TableCell className="text-slate-300">
|
||||
{recipient.contact?.phone ?? '-'}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span
|
||||
className={`inline-flex items-center rounded-full border px-2 py-0.5 text-xs font-medium ${rStatus.classes}`}
|
||||
>
|
||||
{rStatus.label}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-slate-400">
|
||||
{recipient.sent_at
|
||||
? new Date(recipient.sent_at).toLocaleString()
|
||||
: '-'}
|
||||
</TableCell>
|
||||
<TableCell className="text-slate-400">
|
||||
{recipient.delivered_at
|
||||
? new Date(recipient.delivered_at).toLocaleString()
|
||||
: '-'}
|
||||
</TableCell>
|
||||
<TableCell className="text-slate-400">
|
||||
{recipient.read_at
|
||||
? new Date(recipient.read_at).toLocaleString()
|
||||
: '-'}
|
||||
</TableCell>
|
||||
<TableCell className="max-w-xs truncate text-xs text-red-400">
|
||||
{recipient.error_message ?? '-'}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -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';
|
||||
@ -26,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<string, { type: 'static' | 'field'; value: string }>
|
||||
Record<string, { type: 'static' | 'field' | 'custom_field'; value: string }>
|
||||
>({});
|
||||
const [name, setName] = useState('');
|
||||
|
||||
@ -41,18 +49,75 @@ 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,
|
||||
});
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 (
|
||||
<div className="mx-auto max-w-3xl space-y-8">
|
||||
{/* Header */}
|
||||
@ -144,6 +209,7 @@ export default function NewBroadcastPage() {
|
||||
template={template}
|
||||
audience={audience}
|
||||
onSend={handleSend}
|
||||
onSaveDraft={handleSaveDraft}
|
||||
onBack={() => setCurrentStep(2)}
|
||||
isProcessing={isProcessing}
|
||||
progress={progress}
|
||||
|
||||
@ -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 (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-10 text-right text-xs tabular-nums text-slate-300">
|
||||
{pct}%
|
||||
</span>
|
||||
<div className="h-1.5 w-20 overflow-hidden rounded-full bg-slate-800">
|
||||
<div
|
||||
className={`h-1.5 rounded-full ${color}`}
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function BroadcastsPage() {
|
||||
const router = useRouter();
|
||||
const [broadcasts, setBroadcasts] = useState<Broadcast[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(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<ReturnType<typeof setInterval> | 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 (
|
||||
<div className="flex h-64 items-center justify-center">
|
||||
@ -64,6 +126,34 @@ export default function BroadcastsPage() {
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Top indeterminate progress bar: only visible while a broadcast
|
||||
is mid-send. Pure CSS animation so no extra deps. */}
|
||||
{anySending && (
|
||||
<div
|
||||
role="progressbar"
|
||||
aria-label="Broadcast in progress"
|
||||
className="broadcast-indeterminate fixed inset-x-0 top-0 z-40 h-0.5 overflow-hidden bg-slate-800"
|
||||
>
|
||||
<div className="broadcast-indeterminate-bar h-0.5 bg-emerald-500" />
|
||||
<style jsx>{`
|
||||
.broadcast-indeterminate-bar {
|
||||
width: 33%;
|
||||
transform: translateX(-100%);
|
||||
animation: broadcast-slide 1.6s cubic-bezier(0.4, 0, 0.2, 1)
|
||||
infinite;
|
||||
}
|
||||
@keyframes broadcast-slide {
|
||||
0% {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
100% {
|
||||
transform: translateX(400%);
|
||||
}
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">Broadcasts</h1>
|
||||
@ -96,19 +186,19 @@ export default function BroadcastsPage() {
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-xl border border-slate-800 bg-slate-900">
|
||||
<div className="overflow-x-auto rounded-xl border border-slate-800 bg-slate-900">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="border-slate-800 hover:bg-transparent">
|
||||
<TableHead className="text-slate-400">Name</TableHead>
|
||||
<TableHead className="text-slate-400">Template</TableHead>
|
||||
<TableHead className="text-slate-400 text-right">
|
||||
Recipients
|
||||
</TableHead>
|
||||
<TableHead className="text-slate-400">Delivery</TableHead>
|
||||
<TableHead className="text-slate-400">Read</TableHead>
|
||||
<TableHead className="text-slate-400">Status</TableHead>
|
||||
<TableHead className="text-slate-400 text-right">Recipients</TableHead>
|
||||
<TableHead className="text-slate-400 text-right">Sent</TableHead>
|
||||
<TableHead className="text-slate-400 text-right">Delivered</TableHead>
|
||||
<TableHead className="text-slate-400 text-right">Read</TableHead>
|
||||
<TableHead className="text-slate-400 text-right">Failed</TableHead>
|
||||
<TableHead className="text-slate-400">Created</TableHead>
|
||||
<TableHead className="text-slate-400">Date</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
@ -126,27 +216,35 @@ export default function BroadcastsPage() {
|
||||
<TableCell className="text-slate-300">
|
||||
{broadcast.template_name}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span
|
||||
className={`inline-flex items-center rounded-full border px-2 py-0.5 text-xs font-medium ${status.classes}`}
|
||||
>
|
||||
{status.label}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-right text-slate-300">
|
||||
<TableCell className="text-right text-slate-300 tabular-nums">
|
||||
{broadcast.total_recipients}
|
||||
</TableCell>
|
||||
<TableCell className="text-right text-slate-300">
|
||||
{broadcast.sent_count}
|
||||
<TableCell>
|
||||
<RateCell
|
||||
value={broadcast.delivered_count}
|
||||
total={broadcast.total_recipients}
|
||||
color="bg-emerald-500"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="text-right text-slate-300">
|
||||
{broadcast.delivered_count}
|
||||
<TableCell>
|
||||
<RateCell
|
||||
value={broadcast.read_count}
|
||||
total={broadcast.total_recipients}
|
||||
color="bg-blue-500"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="text-right text-slate-300">
|
||||
{broadcast.read_count}
|
||||
</TableCell>
|
||||
<TableCell className="text-right text-slate-300">
|
||||
{broadcast.failed_count}
|
||||
<TableCell>
|
||||
<span
|
||||
className={`inline-flex items-center gap-1.5 rounded-full border px-2 py-0.5 text-xs font-medium ${status.classes}`}
|
||||
>
|
||||
{status.pulse && (
|
||||
<span className="relative flex h-1.5 w-1.5">
|
||||
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-yellow-400 opacity-75" />
|
||||
<span className="relative inline-flex h-1.5 w-1.5 rounded-full bg-yellow-400" />
|
||||
</span>
|
||||
)}
|
||||
{status.label}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-slate-400">
|
||||
{new Date(broadcast.created_at).toLocaleDateString()}
|
||||
|
||||
@ -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<Tag[]>([]);
|
||||
const [customFields, setCustomFields] = useState<CustomField[]>([]);
|
||||
const [loadingTags, setLoadingTags] = useState(false);
|
||||
const [loadingFields, setLoadingFields] = useState(false);
|
||||
const [estimatedCount, setEstimatedCount] = useState<number | null>(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<string> | 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<string> | 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<CustomFieldFilter>) {
|
||||
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 (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-white">Select Audience</h2>
|
||||
<p className="mt-1 text-sm text-slate-400">Choose who will receive this broadcast.</p>
|
||||
<p className="mt-1 text-sm text-slate-400">
|
||||
Choose who will receive this broadcast.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
@ -113,7 +262,21 @@ export function Step2SelectAudience({ audience, onUpdate, onNext, onBack }: Step
|
||||
return (
|
||||
<button
|
||||
key={option.type}
|
||||
onClick={() => onUpdate({ type: option.type, tagIds: option.type === 'tags' ? audience.tagIds : undefined })}
|
||||
onClick={() =>
|
||||
onUpdate({
|
||||
...audience,
|
||||
type: option.type,
|
||||
// Wipe shape fields from other types to avoid stale
|
||||
// config leaking across selections.
|
||||
tagIds: option.type === 'tags' ? audience.tagIds : undefined,
|
||||
customField:
|
||||
option.type === 'custom_field'
|
||||
? audience.customField
|
||||
: undefined,
|
||||
csvContacts:
|
||||
option.type === 'csv' ? audience.csvContacts : undefined,
|
||||
})
|
||||
}
|
||||
className={`flex items-start gap-3 rounded-xl border p-4 text-left transition-all ${
|
||||
isSelected
|
||||
? 'border-emerald-500 bg-emerald-500/5 ring-1 ring-emerald-500/30'
|
||||
@ -122,14 +285,18 @@ export function Step2SelectAudience({ audience, onUpdate, onNext, onBack }: Step
|
||||
>
|
||||
<div
|
||||
className={`flex h-9 w-9 shrink-0 items-center justify-center rounded-lg ${
|
||||
isSelected ? 'bg-emerald-500/10 text-emerald-400' : 'bg-slate-800 text-slate-400'
|
||||
isSelected
|
||||
? 'bg-emerald-500/10 text-emerald-400'
|
||||
: 'bg-slate-800 text-slate-400'
|
||||
}`}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-white">{option.label}</p>
|
||||
<p className="mt-0.5 text-xs text-slate-400">{option.description}</p>
|
||||
<p className="mt-0.5 text-xs text-slate-400">
|
||||
{option.description}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
@ -142,7 +309,9 @@ export function Step2SelectAudience({ audience, onUpdate, onNext, onBack }: Step
|
||||
{loadingTags ? (
|
||||
<Loader2 className="h-5 w-5 animate-spin text-emerald-500" />
|
||||
) : tags.length === 0 ? (
|
||||
<p className="text-xs text-slate-400">No tags found. Create tags in Settings.</p>
|
||||
<p className="text-xs text-slate-400">
|
||||
No tags found. Create tags in Settings.
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{tags.map((tag) => {
|
||||
@ -170,27 +339,122 @@ export function Step2SelectAudience({ audience, onUpdate, onNext, onBack }: Step
|
||||
</div>
|
||||
)}
|
||||
|
||||
{audience.type === 'custom_field' && (
|
||||
<div className="space-y-3 rounded-xl border border-slate-800 bg-slate-900/50 p-4">
|
||||
<p className="text-sm font-medium text-white">Custom Field Filter</p>
|
||||
{loadingFields ? (
|
||||
<Loader2 className="h-5 w-5 animate-spin text-emerald-500" />
|
||||
) : customFields.length === 0 ? (
|
||||
<p className="text-xs text-slate-400">
|
||||
No custom fields defined. Create one in Settings → Custom Fields.
|
||||
</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-[minmax(0,1fr)_140px_minmax(0,1fr)]">
|
||||
<select
|
||||
value={audience.customField?.fieldId ?? ''}
|
||||
onChange={(e) => updateCustomField({ fieldId: e.target.value })}
|
||||
className="h-9 rounded-lg border border-slate-700 bg-slate-800 px-2.5 text-sm text-white outline-none focus:border-emerald-500 focus:ring-1 focus:ring-emerald-500"
|
||||
>
|
||||
<option value="">Select field…</option>
|
||||
{customFields.map((f) => (
|
||||
<option key={f.id} value={f.id}>
|
||||
{f.field_name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
value={audience.customField?.operator ?? 'is'}
|
||||
onChange={(e) =>
|
||||
updateCustomField({
|
||||
operator: e.target.value as CustomFieldOperator,
|
||||
})
|
||||
}
|
||||
className="h-9 rounded-lg border border-slate-700 bg-slate-800 px-2.5 text-sm text-white outline-none focus:border-emerald-500 focus:ring-1 focus:ring-emerald-500"
|
||||
>
|
||||
{OPERATOR_OPTIONS.map((op) => (
|
||||
<option key={op.value} value={op.value}>
|
||||
{op.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<input
|
||||
type="text"
|
||||
value={audience.customField?.value ?? ''}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Exclude list — applies regardless of audience type */}
|
||||
<div className="rounded-xl border border-slate-800 bg-slate-900/50 p-4">
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<X className="h-4 w-4 text-red-400" />
|
||||
<p className="text-sm font-medium text-white">
|
||||
Exclude contacts with these tags
|
||||
</p>
|
||||
<span className="text-xs text-slate-500">(optional)</span>
|
||||
</div>
|
||||
{tags.length === 0 ? (
|
||||
<p className="text-xs text-slate-500">No tags available.</p>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{tags.map((tag) => {
|
||||
const isExcluded = audience.excludeTagIds?.includes(tag.id);
|
||||
return (
|
||||
<button
|
||||
key={tag.id}
|
||||
onClick={() => toggleExcludeTag(tag.id)}
|
||||
className={`inline-flex items-center rounded-full border px-3 py-1 text-xs font-medium transition-all ${
|
||||
isExcluded
|
||||
? 'border-red-500/30 bg-red-500/10 text-red-300'
|
||||
: 'border-slate-700 bg-slate-800 text-slate-300 hover:border-slate-600'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className="mr-1.5 h-2 w-2 rounded-full"
|
||||
style={{ backgroundColor: tag.color }}
|
||||
/>
|
||||
{tag.name}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Audience Summary */}
|
||||
<div className="rounded-xl border border-slate-800 bg-slate-900/50 p-4">
|
||||
<p className="mb-2 text-sm font-medium text-white">Audience Summary</p>
|
||||
{loadingCount ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Loader2 className="h-4 w-4 animate-spin text-emerald-500" />
|
||||
<span className="text-xs text-slate-400">Calculating...</span>
|
||||
<span className="text-xs text-slate-400">Calculating…</span>
|
||||
</div>
|
||||
) : estimatedCount !== null ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Users className="h-4 w-4 text-emerald-400" />
|
||||
<span className="text-sm text-white">{estimatedCount.toLocaleString()}</span>
|
||||
<span className="text-sm text-white">
|
||||
{estimatedCount.toLocaleString()}
|
||||
</span>
|
||||
<span className="text-xs text-slate-400">estimated recipients</span>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-slate-500">Select an audience type to see the estimate.</p>
|
||||
<p className="text-xs text-slate-500">
|
||||
Select an audience type to see the estimate.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between border-t border-slate-800 pt-4">
|
||||
<Button variant="outline" onClick={onBack} className="border-slate-700 text-slate-300">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onBack}
|
||||
className="border-slate-700 text-slate-300"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Back
|
||||
</Button>
|
||||
|
||||
@ -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<CustomField[]>([]);
|
||||
const [loadingFields, setLoadingFields] = useState(true);
|
||||
const [firstContact, setFirstContact] = useState<Contact | null>(null);
|
||||
const [firstContactCustomValues, setFirstContactCustomValues] = useState<
|
||||
Map<string, string>
|
||||
>(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<string, string>();
|
||||
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<VariableMapping>) {
|
||||
const current = variables[key] ?? { type: 'static', value: '' };
|
||||
function updateVariable(key: string, patch: Partial<VariableMapping>) {
|
||||
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<string, string>();
|
||||
|
||||
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<string, string | undefined> = {
|
||||
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 (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-white">Personalize Message</h2>
|
||||
<p className="mt-1 text-sm text-slate-400">
|
||||
Map template variables to contact fields or static values.
|
||||
Map template variables to contact fields, custom fields, or static
|
||||
values.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{placeholders.length === 0 ? (
|
||||
<div className="rounded-xl border border-slate-800 bg-slate-900/50 p-6 text-center">
|
||||
<p className="text-sm text-slate-400">This template has no variables to personalize.</p>
|
||||
<p className="text-sm text-slate-400">
|
||||
This template has no variables to personalize.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{placeholders.map((placeholder) => {
|
||||
const mapping = variables[placeholder] ?? { type: 'static', value: '' };
|
||||
const key = placeholder.replace(/^\{\{|\}\}$/g, '');
|
||||
const mapping = variables[key] ?? { type: 'static', value: '' };
|
||||
|
||||
return (
|
||||
<div key={placeholder} className="rounded-xl border border-slate-800 bg-slate-900/50 p-4">
|
||||
<div
|
||||
key={placeholder}
|
||||
className="rounded-xl border border-slate-800 bg-slate-900/50 p-4"
|
||||
>
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<span className="inline-flex items-center rounded-md bg-emerald-500/10 px-2 py-0.5 text-xs font-mono font-medium text-emerald-400">
|
||||
{placeholder}
|
||||
@ -110,7 +206,10 @@ export function Step3Personalize({ template, variables, onUpdate, onNext, onBack
|
||||
<Select
|
||||
value={mapping.type}
|
||||
onValueChange={(val) =>
|
||||
updateVariable(placeholder, { type: val as 'static' | 'field', value: '' })
|
||||
updateVariable(key, {
|
||||
type: val as VariableType,
|
||||
value: '',
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-full border-slate-700 bg-slate-800 text-white">
|
||||
@ -119,6 +218,9 @@ export function Step3Personalize({ template, variables, onUpdate, onNext, onBack
|
||||
<SelectContent className="border-slate-700 bg-slate-800">
|
||||
<SelectItem value="static">Static Value</SelectItem>
|
||||
<SelectItem value="field">Contact Field</SelectItem>
|
||||
<SelectItem value="custom_field">
|
||||
Custom Field
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
@ -130,14 +232,18 @@ export function Step3Personalize({ template, variables, onUpdate, onNext, onBack
|
||||
{mapping.type === 'static' ? (
|
||||
<Input
|
||||
value={mapping.value}
|
||||
onChange={(e) => 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' ? (
|
||||
<Select
|
||||
value={mapping.value || undefined}
|
||||
onValueChange={(val) => updateVariable(placeholder, { value: val || '' })}
|
||||
onValueChange={(val) =>
|
||||
updateVariable(key, { value: val || '' })
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-full border-slate-700 bg-slate-800 text-white">
|
||||
<SelectValue placeholder="Select field..." />
|
||||
@ -150,6 +256,32 @@ export function Step3Personalize({ template, variables, onUpdate, onNext, onBack
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<Select
|
||||
value={mapping.value || undefined}
|
||||
onValueChange={(val) =>
|
||||
updateVariable(key, { value: val || '' })
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-full border-slate-700 bg-slate-800 text-white">
|
||||
<SelectValue
|
||||
placeholder={
|
||||
loadingFields
|
||||
? 'Loading…'
|
||||
: customFields.length === 0
|
||||
? 'No custom fields'
|
||||
: 'Select custom field…'
|
||||
}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent className="border-slate-700 bg-slate-800">
|
||||
{customFields.map((f) => (
|
||||
<SelectItem key={f.id} value={f.id}>
|
||||
{f.field_name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@ -159,20 +291,32 @@ export function Step3Personalize({ template, variables, onUpdate, onNext, onBack
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Preview */}
|
||||
{/* Live Preview — rendered as a WhatsApp-style bubble so the user
|
||||
sees approximately what the recipient will see. */}
|
||||
<div className="rounded-xl border border-slate-800 bg-slate-900/50 p-4">
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<Eye className="h-4 w-4 text-emerald-400" />
|
||||
<p className="text-sm font-medium text-white">Preview</p>
|
||||
<span className="text-xs text-slate-500">(with sample data)</span>
|
||||
<p className="text-sm font-medium text-white">Live Preview</p>
|
||||
<span className="text-xs text-slate-500">({previewLabel})</span>
|
||||
{loadingPreview && (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin text-emerald-500" />
|
||||
)}
|
||||
</div>
|
||||
<div className="rounded-lg bg-slate-800/50 p-3">
|
||||
<p className="whitespace-pre-wrap text-sm text-slate-200">{previewText}</p>
|
||||
<div className="rounded-lg bg-[#0e1a12] p-3">
|
||||
<div className="ml-auto max-w-[85%] rounded-lg bg-emerald-700/30 px-3 py-2 shadow-sm">
|
||||
<p className="whitespace-pre-wrap text-sm text-emerald-50">
|
||||
{previewText}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between border-t border-slate-800 pt-4">
|
||||
<Button variant="outline" onClick={onBack} className="border-slate-700 text-slate-300">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onBack}
|
||||
className="border-slate-700 text-slate-300"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Back
|
||||
</Button>
|
||||
|
||||
@ -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({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between border-t border-slate-800 pt-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 border-t border-slate-800 pt-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onBack}
|
||||
@ -206,7 +208,20 @@ export function Step4ScheduleSend({
|
||||
Back
|
||||
</Button>
|
||||
|
||||
<Dialog open={showConfirm} onOpenChange={setShowConfirm}>
|
||||
<div className="flex items-center gap-2">
|
||||
{onSaveDraft && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onSaveDraft}
|
||||
disabled={!name.trim() || isProcessing}
|
||||
className="border-slate-700 text-slate-300 hover:bg-slate-800 disabled:opacity-50"
|
||||
>
|
||||
<Save className="h-4 w-4" />
|
||||
Save as Draft
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Dialog open={showConfirm} onOpenChange={setShowConfirm}>
|
||||
<DialogTrigger
|
||||
render={
|
||||
<Button
|
||||
@ -250,6 +265,7 @@ export function Step4ScheduleSend({
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -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<string, { type: 'static' | 'field'; value: string }>;
|
||||
variables: Record<string, VariableMapping>;
|
||||
}
|
||||
|
||||
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<string, Map<string, string>>;
|
||||
|
||||
/**
|
||||
* 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<string, VariableMapping>,
|
||||
contact: Contact,
|
||||
customValues?: Map<string, string>,
|
||||
): 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<string, string | undefined> = {
|
||||
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<typeof createClient>,
|
||||
contactIds: string[],
|
||||
): Promise<CustomValueIndex> {
|
||||
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<string, string>();
|
||||
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<Contact[]> {
|
||||
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<string, { type: 'static' | 'field'; value: string }>,
|
||||
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<typeof createClient>,
|
||||
filter: CustomFieldFilter,
|
||||
): Promise<Contact[]> {
|
||||
const { fieldId, operator, value } = filter;
|
||||
|
||||
return keys.map((key) => {
|
||||
const v = variables[key];
|
||||
if (v.type === 'static') return v.value;
|
||||
const fieldMap: Record<string, string | undefined> = {
|
||||
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<string> {
|
||||
@ -128,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);
|
||||
@ -141,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',
|
||||
@ -148,6 +283,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,
|
||||
@ -161,10 +298,12 @@ 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 (batched for request size) ──
|
||||
// ── Step 3: Insert recipient rows ─────────────────────────────
|
||||
setProgress(20);
|
||||
const recipientRows = contacts.map((contact) => ({
|
||||
broadcast_id: broadcast.id,
|
||||
@ -177,13 +316,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 +332,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 +380,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<string, BroadcastApiResult>();
|
||||
for (const r of (data.results ?? []) as BroadcastApiResult[]) {
|
||||
resultsByPhone.set(r.phone, r);
|
||||
@ -242,8 +390,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 +402,6 @@ export function useBroadcastSending(): UseBroadcastSendingReturn {
|
||||
}
|
||||
|
||||
if (result.status === 'sent') {
|
||||
sentCount++;
|
||||
await supabase
|
||||
.from('broadcast_recipients')
|
||||
.update({
|
||||
@ -278,8 +423,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 +435,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 +455,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);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user