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';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { useParams, useRouter } from 'next/navigation';
|
import { useParams, useRouter } from 'next/navigation';
|
||||||
import { createClient } from '@/lib/supabase/client';
|
import { createClient } from '@/lib/supabase/client';
|
||||||
import { Broadcast, BroadcastRecipient } from '@/types';
|
import { Broadcast, BroadcastRecipient, RecipientStatus } from '@/types';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
@ -13,6 +13,12 @@ import {
|
|||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow,
|
TableRow,
|
||||||
} from '@/components/ui/table';
|
} from '@/components/ui/table';
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@/components/ui/dropdown-menu';
|
||||||
import {
|
import {
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
Loader2,
|
Loader2,
|
||||||
@ -21,6 +27,10 @@ import {
|
|||||||
CheckCheck,
|
CheckCheck,
|
||||||
Eye,
|
Eye,
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
|
MessageCircle,
|
||||||
|
Filter,
|
||||||
|
Download,
|
||||||
|
ChevronDown,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
getBroadcastStatus,
|
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() {
|
export default function BroadcastDetailPage() {
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@ -60,6 +148,9 @@ export default function BroadcastDetailPage() {
|
|||||||
const [recipients, setRecipients] = useState<BroadcastRecipient[]>([]);
|
const [recipients, setRecipients] = useState<BroadcastRecipient[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [statusFilter, setStatusFilter] = useState<RecipientStatus | 'all'>(
|
||||||
|
'all',
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function fetchData() {
|
async function fetchData() {
|
||||||
@ -93,6 +184,41 @@ export default function BroadcastDetailPage() {
|
|||||||
fetchData();
|
fetchData();
|
||||||
}, [broadcastId]);
|
}, [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) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-64 items-center justify-center">
|
<div className="flex h-64 items-center justify-center">
|
||||||
@ -114,6 +240,13 @@ export default function BroadcastDetailPage() {
|
|||||||
|
|
||||||
const status = getBroadcastStatus(broadcast.status);
|
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 (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
@ -139,14 +272,16 @@ export default function BroadcastDetailPage() {
|
|||||||
<div className="mt-1 flex items-center gap-3 text-sm text-slate-400">
|
<div className="mt-1 flex items-center gap-3 text-sm text-slate-400">
|
||||||
<span>Template: {broadcast.template_name}</span>
|
<span>Template: {broadcast.template_name}</span>
|
||||||
<span>-</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>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Stats */}
|
{/* Stats — 6 cards: Total / Sent / Delivered / Read / Replied / Failed */}
|
||||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5">
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-6">
|
||||||
<StatCard
|
<StatCard
|
||||||
label="Total Recipients"
|
label="Total Recipients"
|
||||||
value={broadcast.total_recipients}
|
value={broadcast.total_recipients}
|
||||||
@ -159,21 +294,28 @@ export default function BroadcastDetailPage() {
|
|||||||
value={broadcast.sent_count}
|
value={broadcast.sent_count}
|
||||||
total={broadcast.total_recipients}
|
total={broadcast.total_recipients}
|
||||||
icon={<Send className="h-4 w-4" />}
|
icon={<Send className="h-4 w-4" />}
|
||||||
color="bg-blue-500/10 text-blue-400"
|
color="bg-emerald-500/10 text-emerald-400"
|
||||||
/>
|
/>
|
||||||
<StatCard
|
<StatCard
|
||||||
label="Delivered"
|
label="Delivered"
|
||||||
value={broadcast.delivered_count}
|
value={broadcast.delivered_count}
|
||||||
total={broadcast.total_recipients}
|
total={broadcast.total_recipients}
|
||||||
icon={<CheckCheck className="h-4 w-4" />}
|
icon={<CheckCheck className="h-4 w-4" />}
|
||||||
color="bg-emerald-500/10 text-emerald-400"
|
color="bg-teal-500/10 text-teal-400"
|
||||||
/>
|
/>
|
||||||
<StatCard
|
<StatCard
|
||||||
label="Read"
|
label="Read"
|
||||||
value={broadcast.read_count}
|
value={broadcast.read_count}
|
||||||
total={broadcast.total_recipients}
|
total={broadcast.total_recipients}
|
||||||
icon={<Eye className="h-4 w-4" />}
|
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
|
<StatCard
|
||||||
label="Failed"
|
label="Failed"
|
||||||
@ -184,32 +326,95 @@ export default function BroadcastDetailPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<FunnelChart steps={funnelSteps} />
|
||||||
|
|
||||||
{/* Recipients Table */}
|
{/* Recipients Table */}
|
||||||
<div className="rounded-xl border border-slate-800 bg-slate-900">
|
<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">
|
<h2 className="text-sm font-medium text-white">
|
||||||
Recipients ({recipients.length})
|
Recipients ({filteredRecipients.length}
|
||||||
|
{statusFilter !== 'all' ? ` of ${recipients.length}` : ''})
|
||||||
</h2>
|
</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 ? (
|
</div>
|
||||||
|
|
||||||
|
{filteredRecipients.length === 0 ? (
|
||||||
<div className="flex h-32 items-center justify-center">
|
<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>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow className="border-slate-800 hover:bg-transparent">
|
<TableRow className="border-slate-800 hover:bg-transparent">
|
||||||
<TableHead className="text-slate-400">Contact</TableHead>
|
<TableHead className="text-slate-400">Contact</TableHead>
|
||||||
<TableHead className="text-slate-400">Phone</TableHead>
|
<TableHead className="text-slate-400">Phone</TableHead>
|
||||||
<TableHead className="text-slate-400">Status</TableHead>
|
<TableHead className="text-slate-400">Status</TableHead>
|
||||||
<TableHead className="text-slate-400">Sent At</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>
|
<TableHead className="text-slate-400">Error</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{recipients.map((recipient) => {
|
{filteredRecipients.map((recipient) => {
|
||||||
const rStatus = getRecipientStatus(recipient.status);
|
const rStatus = getRecipientStatus(recipient.status);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TableRow key={recipient.id} className="border-slate-800">
|
<TableRow key={recipient.id} className="border-slate-800">
|
||||||
<TableCell className="font-medium text-white">
|
<TableCell className="font-medium text-white">
|
||||||
@ -230,6 +435,16 @@ export default function BroadcastDetailPage() {
|
|||||||
? new Date(recipient.sent_at).toLocaleString()
|
? new Date(recipient.sent_at).toLocaleString()
|
||||||
: '-'}
|
: '-'}
|
||||||
</TableCell>
|
</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">
|
<TableCell className="max-w-xs truncate text-xs text-red-400">
|
||||||
{recipient.error_message ?? '-'}
|
{recipient.error_message ?? '-'}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
@ -238,6 +453,7 @@ export default function BroadcastDetailPage() {
|
|||||||
})}
|
})}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { createClient } from '@/lib/supabase/client';
|
||||||
|
import { toast } from 'sonner';
|
||||||
import { MessageTemplate } from '@/types';
|
import { MessageTemplate } from '@/types';
|
||||||
import { Step1ChooseTemplate } from '@/components/broadcasts/step1-choose-template';
|
import { Step1ChooseTemplate } from '@/components/broadcasts/step1-choose-template';
|
||||||
import { Step2SelectAudience } from '@/components/broadcasts/step2-select-audience';
|
import { Step2SelectAudience } from '@/components/broadcasts/step2-select-audience';
|
||||||
@ -26,10 +28,16 @@ export default function NewBroadcastPage() {
|
|||||||
const [audience, setAudience] = useState<{
|
const [audience, setAudience] = useState<{
|
||||||
type: 'all' | 'tags' | 'custom_field' | 'csv';
|
type: 'all' | 'tags' | 'custom_field' | 'csv';
|
||||||
tagIds?: string[];
|
tagIds?: string[];
|
||||||
|
customField?: {
|
||||||
|
fieldId: string;
|
||||||
|
operator: 'is' | 'is_not' | 'contains';
|
||||||
|
value: string;
|
||||||
|
};
|
||||||
csvContacts?: { phone: string; name?: string }[];
|
csvContacts?: { phone: string; name?: string }[];
|
||||||
|
excludeTagIds?: string[];
|
||||||
}>({ type: 'all' });
|
}>({ type: 'all' });
|
||||||
const [variables, setVariables] = useState<
|
const [variables, setVariables] = useState<
|
||||||
Record<string, { type: 'static' | 'field'; value: string }>
|
Record<string, { type: 'static' | 'field' | 'custom_field'; value: string }>
|
||||||
>({});
|
>({});
|
||||||
const [name, setName] = useState('');
|
const [name, setName] = useState('');
|
||||||
|
|
||||||
@ -41,18 +49,75 @@ export default function NewBroadcastPage() {
|
|||||||
name,
|
name,
|
||||||
template,
|
template,
|
||||||
audience: {
|
audience: {
|
||||||
type: audience.type === 'custom_field' ? 'all' : audience.type,
|
type: audience.type,
|
||||||
tagIds: audience.tagIds,
|
tagIds: audience.tagIds,
|
||||||
|
customField: audience.customField,
|
||||||
csvContacts: audience.csvContacts,
|
csvContacts: audience.csvContacts,
|
||||||
|
excludeTagIds: audience.excludeTagIds,
|
||||||
},
|
},
|
||||||
variables,
|
variables,
|
||||||
});
|
});
|
||||||
router.push(`/broadcasts/${broadcastId}`);
|
router.push(`/broadcasts/${broadcastId}`);
|
||||||
} catch (err) {
|
} 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);
|
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 (
|
return (
|
||||||
<div className="mx-auto max-w-3xl space-y-8">
|
<div className="mx-auto max-w-3xl space-y-8">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
@ -144,6 +209,7 @@ export default function NewBroadcastPage() {
|
|||||||
template={template}
|
template={template}
|
||||||
audience={audience}
|
audience={audience}
|
||||||
onSend={handleSend}
|
onSend={handleSend}
|
||||||
|
onSaveDraft={handleSaveDraft}
|
||||||
onBack={() => setCurrentStep(2)}
|
onBack={() => setCurrentStep(2)}
|
||||||
isProcessing={isProcessing}
|
isProcessing={isProcessing}
|
||||||
progress={progress}
|
progress={progress}
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState, useMemo, useRef } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { createClient } from '@/lib/supabase/client';
|
import { createClient } from '@/lib/supabase/client';
|
||||||
import { Broadcast } from '@/types';
|
import { Broadcast } from '@/types';
|
||||||
@ -16,13 +16,53 @@ import {
|
|||||||
import { Radio, Plus, Loader2 } from 'lucide-react';
|
import { Radio, Plus, Loader2 } from 'lucide-react';
|
||||||
import { getBroadcastStatus } from '@/lib/broadcast-status';
|
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() {
|
export default function BroadcastsPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [broadcasts, setBroadcasts] = useState<Broadcast[]>([]);
|
const [broadcasts, setBroadcasts] = useState<Broadcast[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
// Used to kick off polling only while something is actively sending.
|
||||||
|
const pollTimer = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
|
|
||||||
async function fetchBroadcasts() {
|
async function fetchBroadcasts() {
|
||||||
try {
|
try {
|
||||||
const supabase = createClient();
|
const supabase = createClient();
|
||||||
@ -40,9 +80,31 @@ export default function BroadcastsPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
fetchBroadcasts();
|
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) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-64 items-center justify-center">
|
<div className="flex h-64 items-center justify-center">
|
||||||
@ -64,6 +126,34 @@ export default function BroadcastsPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<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 className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-white">Broadcasts</h1>
|
<h1 className="text-2xl font-bold text-white">Broadcasts</h1>
|
||||||
@ -96,19 +186,19 @@ export default function BroadcastsPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</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>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow className="border-slate-800 hover:bg-transparent">
|
<TableRow className="border-slate-800 hover:bg-transparent">
|
||||||
<TableHead className="text-slate-400">Name</TableHead>
|
<TableHead className="text-slate-400">Name</TableHead>
|
||||||
<TableHead className="text-slate-400">Template</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">Status</TableHead>
|
||||||
<TableHead className="text-slate-400 text-right">Recipients</TableHead>
|
<TableHead className="text-slate-400">Date</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>
|
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
@ -126,27 +216,35 @@ export default function BroadcastsPage() {
|
|||||||
<TableCell className="text-slate-300">
|
<TableCell className="text-slate-300">
|
||||||
{broadcast.template_name}
|
{broadcast.template_name}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell className="text-right text-slate-300 tabular-nums">
|
||||||
<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">
|
|
||||||
{broadcast.total_recipients}
|
{broadcast.total_recipients}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-right text-slate-300">
|
<TableCell>
|
||||||
{broadcast.sent_count}
|
<RateCell
|
||||||
|
value={broadcast.delivered_count}
|
||||||
|
total={broadcast.total_recipients}
|
||||||
|
color="bg-emerald-500"
|
||||||
|
/>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-right text-slate-300">
|
<TableCell>
|
||||||
{broadcast.delivered_count}
|
<RateCell
|
||||||
|
value={broadcast.read_count}
|
||||||
|
total={broadcast.total_recipients}
|
||||||
|
color="bg-blue-500"
|
||||||
|
/>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-right text-slate-300">
|
<TableCell>
|
||||||
{broadcast.read_count}
|
<span
|
||||||
</TableCell>
|
className={`inline-flex items-center gap-1.5 rounded-full border px-2 py-0.5 text-xs font-medium ${status.classes}`}
|
||||||
<TableCell className="text-right text-slate-300">
|
>
|
||||||
{broadcast.failed_count}
|
{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>
|
||||||
<TableCell className="text-slate-400">
|
<TableCell className="text-slate-400">
|
||||||
{new Date(broadcast.created_at).toLocaleDateString()}
|
{new Date(broadcast.created_at).toLocaleDateString()}
|
||||||
|
|||||||
@ -2,16 +2,34 @@
|
|||||||
|
|
||||||
import { useEffect, useState, useCallback } from 'react';
|
import { useEffect, useState, useCallback } from 'react';
|
||||||
import { createClient } from '@/lib/supabase/client';
|
import { createClient } from '@/lib/supabase/client';
|
||||||
import { Tag } from '@/types';
|
import { CustomField, Tag } from '@/types';
|
||||||
import { Button } from '@/components/ui/button';
|
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 AudienceType = 'all' | 'tags' | 'custom_field' | 'csv';
|
||||||
|
type CustomFieldOperator = 'is' | 'is_not' | 'contains';
|
||||||
|
|
||||||
|
interface CustomFieldFilter {
|
||||||
|
fieldId: string;
|
||||||
|
operator: CustomFieldOperator;
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface AudienceConfig {
|
interface AudienceConfig {
|
||||||
type: AudienceType;
|
type: AudienceType;
|
||||||
tagIds?: string[];
|
tagIds?: string[];
|
||||||
|
customField?: CustomFieldFilter;
|
||||||
csvContacts?: { phone: string; name?: string }[];
|
csvContacts?: { phone: string; name?: string }[];
|
||||||
|
excludeTagIds?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Step2Props {
|
interface Step2Props {
|
||||||
@ -21,21 +39,60 @@ interface Step2Props {
|
|||||||
onBack: () => void;
|
onBack: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const audienceOptions: { type: AudienceType; label: string; description: string; icon: typeof Users }[] = [
|
const audienceOptions: {
|
||||||
{ type: 'all', label: 'All Contacts', description: 'Send to every contact in your database', icon: Users },
|
type: AudienceType;
|
||||||
{ type: 'tags', label: 'Filter by Tags', description: 'Target contacts with specific tags', icon: Tags },
|
label: string;
|
||||||
{ type: 'custom_field', label: 'Custom Field', description: 'Filter by a custom field value', icon: Filter },
|
description: string;
|
||||||
{ type: 'csv', label: 'Upload CSV', description: 'Upload a list of phone numbers', icon: Upload },
|
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 [tags, setTags] = useState<Tag[]>([]);
|
||||||
|
const [customFields, setCustomFields] = useState<CustomField[]>([]);
|
||||||
const [loadingTags, setLoadingTags] = useState(false);
|
const [loadingTags, setLoadingTags] = useState(false);
|
||||||
|
const [loadingFields, setLoadingFields] = useState(false);
|
||||||
const [estimatedCount, setEstimatedCount] = useState<number | null>(null);
|
const [estimatedCount, setEstimatedCount] = useState<number | null>(null);
|
||||||
const [loadingCount, setLoadingCount] = useState(false);
|
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(() => {
|
useEffect(() => {
|
||||||
if (audience.type === 'tags') {
|
|
||||||
async function fetchTags() {
|
async function fetchTags() {
|
||||||
setLoadingTags(true);
|
setLoadingTags(true);
|
||||||
try {
|
try {
|
||||||
@ -47,7 +104,25 @@ export function Step2SelectAudience({ audience, onUpdate, onNext, onBack }: Step
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
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]);
|
}, [audience.type]);
|
||||||
|
|
||||||
const fetchEstimatedCount = useCallback(async () => {
|
const fetchEstimatedCount = useCallback(async () => {
|
||||||
@ -55,36 +130,86 @@ export function Step2SelectAudience({ audience, onUpdate, onNext, onBack }: Step
|
|||||||
try {
|
try {
|
||||||
const supabase = createClient();
|
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') {
|
if (audience.type === 'all') {
|
||||||
const { count } = await supabase
|
// Handled below — full-table count adjusted by excludes.
|
||||||
.from('contacts')
|
} else if (
|
||||||
.select('*', { count: 'exact', head: true });
|
audience.type === 'tags' &&
|
||||||
setEstimatedCount(count ?? 0);
|
audience.tagIds &&
|
||||||
} else if (audience.type === 'tags' && audience.tagIds && audience.tagIds.length > 0) {
|
audience.tagIds.length > 0
|
||||||
const { data: contactTags } = await supabase
|
) {
|
||||||
|
const { data } = await supabase
|
||||||
.from('contact_tags')
|
.from('contact_tags')
|
||||||
.select('contact_id')
|
.select('contact_id')
|
||||||
.in('tag_id', audience.tagIds);
|
.in('tag_id', audience.tagIds);
|
||||||
|
baseIds = new Set((data ?? []).map((r) => r.contact_id));
|
||||||
const uniqueIds = new Set((contactTags ?? []).map((ct) => ct.contact_id));
|
} else if (
|
||||||
setEstimatedCount(uniqueIds.size);
|
audience.type === 'custom_field' &&
|
||||||
} else if (audience.type === 'csv' && audience.csvContacts) {
|
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);
|
setEstimatedCount(audience.csvContacts.length);
|
||||||
|
return;
|
||||||
} else {
|
} else {
|
||||||
|
// Partially-configured audience — wait for the user to finish.
|
||||||
setEstimatedCount(null);
|
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 {
|
} finally {
|
||||||
setLoadingCount(false);
|
setLoadingCount(false);
|
||||||
}
|
}
|
||||||
}, [audience.type, audience.tagIds, audience.csvContacts]);
|
}, [
|
||||||
|
audience.type,
|
||||||
|
audience.tagIds,
|
||||||
|
audience.customField,
|
||||||
|
audience.csvContacts,
|
||||||
|
audience.excludeTagIds,
|
||||||
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (audience.type === 'all' || (audience.type === 'tags' && audience.tagIds && audience.tagIds.length > 0) || (audience.type === 'csv' && audience.csvContacts && audience.csvContacts.length > 0)) {
|
|
||||||
fetchEstimatedCount();
|
fetchEstimatedCount();
|
||||||
} else {
|
}, [fetchEstimatedCount]);
|
||||||
setEstimatedCount(null);
|
|
||||||
}
|
|
||||||
}, [audience.type, audience.tagIds, audience.csvContacts, fetchEstimatedCount]);
|
|
||||||
|
|
||||||
function toggleTag(tagId: string) {
|
function toggleTag(tagId: string) {
|
||||||
const current = audience.tagIds ?? [];
|
const current = audience.tagIds ?? [];
|
||||||
@ -94,16 +219,40 @@ export function Step2SelectAudience({ audience, onUpdate, onNext, onBack }: Step
|
|||||||
onUpdate({ ...audience, tagIds: updated });
|
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 =
|
const isValid =
|
||||||
audience.type === 'all' ||
|
audience.type === 'all' ||
|
||||||
(audience.type === 'tags' && audience.tagIds && audience.tagIds.length > 0) ||
|
(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 (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-lg font-semibold text-white">Select Audience</h2>
|
<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>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
<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 (
|
return (
|
||||||
<button
|
<button
|
||||||
key={option.type}
|
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 ${
|
className={`flex items-start gap-3 rounded-xl border p-4 text-left transition-all ${
|
||||||
isSelected
|
isSelected
|
||||||
? 'border-emerald-500 bg-emerald-500/5 ring-1 ring-emerald-500/30'
|
? '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
|
<div
|
||||||
className={`flex h-9 w-9 shrink-0 items-center justify-center rounded-lg ${
|
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" />
|
<Icon className="h-4 w-4" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium text-white">{option.label}</p>
|
<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>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
@ -142,7 +309,9 @@ export function Step2SelectAudience({ audience, onUpdate, onNext, onBack }: Step
|
|||||||
{loadingTags ? (
|
{loadingTags ? (
|
||||||
<Loader2 className="h-5 w-5 animate-spin text-emerald-500" />
|
<Loader2 className="h-5 w-5 animate-spin text-emerald-500" />
|
||||||
) : tags.length === 0 ? (
|
) : 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">
|
<div className="flex flex-wrap gap-2">
|
||||||
{tags.map((tag) => {
|
{tags.map((tag) => {
|
||||||
@ -170,27 +339,122 @@ export function Step2SelectAudience({ audience, onUpdate, onNext, onBack }: Step
|
|||||||
</div>
|
</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 */}
|
{/* Audience Summary */}
|
||||||
<div className="rounded-xl border border-slate-800 bg-slate-900/50 p-4">
|
<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>
|
<p className="mb-2 text-sm font-medium text-white">Audience Summary</p>
|
||||||
{loadingCount ? (
|
{loadingCount ? (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Loader2 className="h-4 w-4 animate-spin text-emerald-500" />
|
<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>
|
</div>
|
||||||
) : estimatedCount !== null ? (
|
) : estimatedCount !== null ? (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Users className="h-4 w-4 text-emerald-400" />
|
<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>
|
<span className="text-xs text-slate-400">estimated recipients</span>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<div className="flex items-center justify-between border-t border-slate-800 pt-4">
|
<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" />
|
<ArrowLeft className="h-4 w-4" />
|
||||||
Back
|
Back
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@ -1,7 +1,8 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useMemo } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { MessageTemplate } from '@/types';
|
import { createClient } from '@/lib/supabase/client';
|
||||||
|
import { Contact, CustomField, MessageTemplate } from '@/types';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import {
|
import {
|
||||||
@ -11,10 +12,12 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@/components/ui/select';
|
} 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 {
|
interface VariableMapping {
|
||||||
type: 'static' | 'field';
|
type: VariableType;
|
||||||
value: string;
|
value: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -33,34 +36,102 @@ const contactFields = [
|
|||||||
{ value: 'company', label: 'Company' },
|
{ value: 'company', label: 'Company' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const sampleContact = {
|
const SAMPLE_CONTACT: Contact = {
|
||||||
|
id: 'sample',
|
||||||
|
user_id: '',
|
||||||
name: 'John Doe',
|
name: 'John Doe',
|
||||||
phone: '+1234567890',
|
phone: '+1234567890',
|
||||||
email: 'john@example.com',
|
email: 'john@example.com',
|
||||||
company: 'Acme Corp',
|
company: 'Acme Corp',
|
||||||
|
created_at: new Date().toISOString(),
|
||||||
|
updated_at: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
|
|
||||||
export function Step3Personalize({ template, variables, onUpdate, onNext, onBack }: Step3Props) {
|
export function Step3Personalize({
|
||||||
// Extract {{1}}, {{2}}, etc. from body_text
|
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 placeholders = useMemo(() => {
|
||||||
const matches = template.body_text.match(/\{\{(\d+)\}\}/g);
|
const matches = template.body_text.match(/\{\{(\d+)\}\}/g);
|
||||||
if (!matches) return [];
|
if (!matches) return [];
|
||||||
return [...new Set(matches)].sort();
|
return [...new Set(matches)].sort();
|
||||||
}, [template.body_text]);
|
}, [template.body_text]);
|
||||||
|
|
||||||
function updateVariable(key: string, field: Partial<VariableMapping>) {
|
function updateVariable(key: string, patch: Partial<VariableMapping>) {
|
||||||
const current = variables[key] ?? { type: 'static', value: '' };
|
const current = variables[key] ?? { type: 'static' as VariableType, value: '' };
|
||||||
onUpdate({
|
onUpdate({
|
||||||
...variables,
|
...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 previewText = useMemo(() => {
|
||||||
|
const contact = firstContact ?? SAMPLE_CONTACT;
|
||||||
|
const customValues = firstContact
|
||||||
|
? firstContactCustomValues
|
||||||
|
: new Map<string, string>();
|
||||||
|
|
||||||
let text = template.body_text;
|
let text = template.body_text;
|
||||||
for (const placeholder of placeholders) {
|
for (const placeholder of placeholders) {
|
||||||
const key = placeholder;
|
const key = placeholder.replace(/^\{\{|\}\}$/g, '');
|
||||||
const mapping = variables[key];
|
const mapping = variables[key];
|
||||||
let replacement = placeholder;
|
let replacement = placeholder;
|
||||||
|
|
||||||
@ -68,34 +139,59 @@ export function Step3Personalize({ template, variables, onUpdate, onNext, onBack
|
|||||||
if (mapping.type === 'static' && mapping.value) {
|
if (mapping.type === 'static' && mapping.value) {
|
||||||
replacement = mapping.value;
|
replacement = mapping.value;
|
||||||
} else if (mapping.type === 'field' && 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);
|
text = text.replaceAll(placeholder, replacement);
|
||||||
}
|
}
|
||||||
return text;
|
return text;
|
||||||
}, [template.body_text, variables, placeholders]);
|
}, [
|
||||||
|
template.body_text,
|
||||||
|
variables,
|
||||||
|
placeholders,
|
||||||
|
firstContact,
|
||||||
|
firstContactCustomValues,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const previewLabel = firstContact
|
||||||
|
? firstContact.name || firstContact.phone
|
||||||
|
: 'sample data';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-lg font-semibold text-white">Personalize Message</h2>
|
<h2 className="text-lg font-semibold text-white">Personalize Message</h2>
|
||||||
<p className="mt-1 text-sm text-slate-400">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{placeholders.length === 0 ? (
|
{placeholders.length === 0 ? (
|
||||||
<div className="rounded-xl border border-slate-800 bg-slate-900/50 p-6 text-center">
|
<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>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{placeholders.map((placeholder) => {
|
{placeholders.map((placeholder) => {
|
||||||
const mapping = variables[placeholder] ?? { type: 'static', value: '' };
|
const key = placeholder.replace(/^\{\{|\}\}$/g, '');
|
||||||
|
const mapping = variables[key] ?? { type: 'static', value: '' };
|
||||||
|
|
||||||
return (
|
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">
|
<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">
|
<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}
|
{placeholder}
|
||||||
@ -110,7 +206,10 @@ export function Step3Personalize({ template, variables, onUpdate, onNext, onBack
|
|||||||
<Select
|
<Select
|
||||||
value={mapping.type}
|
value={mapping.type}
|
||||||
onValueChange={(val) =>
|
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">
|
<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">
|
<SelectContent className="border-slate-700 bg-slate-800">
|
||||||
<SelectItem value="static">Static Value</SelectItem>
|
<SelectItem value="static">Static Value</SelectItem>
|
||||||
<SelectItem value="field">Contact Field</SelectItem>
|
<SelectItem value="field">Contact Field</SelectItem>
|
||||||
|
<SelectItem value="custom_field">
|
||||||
|
Custom Field
|
||||||
|
</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
@ -130,14 +232,18 @@ export function Step3Personalize({ template, variables, onUpdate, onNext, onBack
|
|||||||
{mapping.type === 'static' ? (
|
{mapping.type === 'static' ? (
|
||||||
<Input
|
<Input
|
||||||
value={mapping.value}
|
value={mapping.value}
|
||||||
onChange={(e) => updateVariable(placeholder, { value: e.target.value })}
|
onChange={(e) =>
|
||||||
|
updateVariable(key, { value: e.target.value })
|
||||||
|
}
|
||||||
placeholder="Enter value..."
|
placeholder="Enter value..."
|
||||||
className="border-slate-700 bg-slate-800 text-white placeholder:text-slate-500"
|
className="border-slate-700 bg-slate-800 text-white placeholder:text-slate-500"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : mapping.type === 'field' ? (
|
||||||
<Select
|
<Select
|
||||||
value={mapping.value || undefined}
|
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">
|
<SelectTrigger className="w-full border-slate-700 bg-slate-800 text-white">
|
||||||
<SelectValue placeholder="Select field..." />
|
<SelectValue placeholder="Select field..." />
|
||||||
@ -150,6 +256,32 @@ export function Step3Personalize({ template, variables, onUpdate, onNext, onBack
|
|||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
@ -159,20 +291,32 @@ export function Step3Personalize({ template, variables, onUpdate, onNext, onBack
|
|||||||
</div>
|
</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="rounded-xl border border-slate-800 bg-slate-900/50 p-4">
|
||||||
<div className="mb-3 flex items-center gap-2">
|
<div className="mb-3 flex items-center gap-2">
|
||||||
<Eye className="h-4 w-4 text-emerald-400" />
|
<Eye className="h-4 w-4 text-emerald-400" />
|
||||||
<p className="text-sm font-medium text-white">Preview</p>
|
<p className="text-sm font-medium text-white">Live Preview</p>
|
||||||
<span className="text-xs text-slate-500">(with sample data)</span>
|
<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-[#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 className="rounded-lg bg-slate-800/50 p-3">
|
|
||||||
<p className="whitespace-pre-wrap text-sm text-slate-200">{previewText}</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-between border-t border-slate-800 pt-4">
|
<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" />
|
<ArrowLeft className="h-4 w-4" />
|
||||||
Back
|
Back
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@ -14,7 +14,7 @@ import {
|
|||||||
DialogTitle,
|
DialogTitle,
|
||||||
DialogTrigger,
|
DialogTrigger,
|
||||||
} from '@/components/ui/dialog';
|
} 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 {
|
interface AudienceConfig {
|
||||||
type: string;
|
type: string;
|
||||||
@ -28,6 +28,7 @@ interface Step4Props {
|
|||||||
template: MessageTemplate;
|
template: MessageTemplate;
|
||||||
audience: AudienceConfig;
|
audience: AudienceConfig;
|
||||||
onSend: () => void;
|
onSend: () => void;
|
||||||
|
onSaveDraft?: () => void;
|
||||||
onBack: () => void;
|
onBack: () => void;
|
||||||
isProcessing: boolean;
|
isProcessing: boolean;
|
||||||
progress: number;
|
progress: number;
|
||||||
@ -39,6 +40,7 @@ export function Step4ScheduleSend({
|
|||||||
template,
|
template,
|
||||||
audience,
|
audience,
|
||||||
onSend,
|
onSend,
|
||||||
|
onSaveDraft,
|
||||||
onBack,
|
onBack,
|
||||||
isProcessing,
|
isProcessing,
|
||||||
progress,
|
progress,
|
||||||
@ -195,7 +197,7 @@ export function Step4ScheduleSend({
|
|||||||
</div>
|
</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
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={onBack}
|
onClick={onBack}
|
||||||
@ -206,6 +208,19 @@ export function Step4ScheduleSend({
|
|||||||
Back
|
Back
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
<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}>
|
<Dialog open={showConfirm} onOpenChange={setShowConfirm}>
|
||||||
<DialogTrigger
|
<DialogTrigger
|
||||||
render={
|
render={
|
||||||
@ -252,5 +267,6 @@ export function Step4ScheduleSend({
|
|||||||
</Dialog>
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,17 +4,40 @@ import { useState } from 'react';
|
|||||||
import { createClient } from '@/lib/supabase/client';
|
import { createClient } from '@/lib/supabase/client';
|
||||||
import { Contact, MessageTemplate } from '@/types';
|
import { Contact, MessageTemplate } from '@/types';
|
||||||
|
|
||||||
interface AudienceConfig {
|
export type CustomFieldOperator = 'is' | 'is_not' | 'contains';
|
||||||
type: 'all' | 'tags' | 'csv';
|
|
||||||
tagIds?: string[];
|
export interface CustomFieldFilter {
|
||||||
csvContacts?: { phone: string; name?: string }[];
|
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 {
|
interface BroadcastPayload {
|
||||||
name: string;
|
name: string;
|
||||||
template: MessageTemplate;
|
template: MessageTemplate;
|
||||||
audience: AudienceConfig;
|
audience: AudienceConfig;
|
||||||
variables: Record<string, { type: 'static' | 'field'; value: string }>;
|
variables: Record<string, VariableMapping>;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface UseBroadcastSendingReturn {
|
interface UseBroadcastSendingReturn {
|
||||||
@ -24,17 +47,14 @@ interface UseBroadcastSendingReturn {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Meta rate-limit buffer. 10 per batch with a 1 s pause matches the
|
* Meta rate-limit buffer. 10 per batch + 1 s pause matches the spec
|
||||||
* spec and keeps us well below Meta's per-phone-number messaging rate
|
* and keeps us comfortably under Meta's per-phone-number messaging
|
||||||
* so a large broadcast never trips the upstream limiter.
|
* rate so a large broadcast never trips the upstream limiter.
|
||||||
*/
|
*/
|
||||||
const SEND_BATCH_SIZE = 10;
|
const SEND_BATCH_SIZE = 10;
|
||||||
const SEND_BATCH_DELAY_MS = 1000;
|
const SEND_BATCH_DELAY_MS = 1000;
|
||||||
|
|
||||||
/**
|
/** `broadcast_recipients` inserts are independent of the send rate. */
|
||||||
* `broadcast_recipients` inserts are independent of the send rate —
|
|
||||||
* we batch them purely to keep individual Supabase requests small.
|
|
||||||
*/
|
|
||||||
const INSERT_BATCH_SIZE = 200;
|
const INSERT_BATCH_SIZE = 200;
|
||||||
|
|
||||||
function sleep(ms: number) {
|
function sleep(ms: number) {
|
||||||
@ -48,59 +68,21 @@ interface BroadcastApiResult {
|
|||||||
error?: string;
|
error?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useBroadcastSending(): UseBroadcastSendingReturn {
|
/** contactId → (customFieldId → value). */
|
||||||
const [isProcessing, setIsProcessing] = useState(false);
|
type CustomValueIndex = Map<string, Map<string, string>>;
|
||||||
const [progress, setProgress] = useState(0);
|
|
||||||
|
|
||||||
async function resolveAudience(audience: AudienceConfig): Promise<Contact[]> {
|
/**
|
||||||
const supabase = createClient();
|
* Per-contact resolution of custom-field placeholders. Static and
|
||||||
|
* built-in-field mappings resolve synchronously; custom fields read
|
||||||
if (audience.type === 'all') {
|
* from a pre-built index to avoid N+1 queries during the send loop.
|
||||||
const { data, error } = await supabase.from('contacts').select('*');
|
*/
|
||||||
if (error) throw new Error(`Failed to fetch contacts: ${error.message}`);
|
export function resolveVariables(
|
||||||
return data ?? [];
|
variables: Record<string, VariableMapping>,
|
||||||
}
|
contact: Contact,
|
||||||
|
customValues?: Map<string, string>,
|
||||||
if (audience.type === 'tags' && audience.tagIds && audience.tagIds.length > 0) {
|
): string[] {
|
||||||
const { data: contactTags, error: tagError } = await supabase
|
// Keys are typically "1","2",... — numeric-aware sort keeps
|
||||||
.from('contact_tags')
|
// {{1}} before {{10}}.
|
||||||
.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 [];
|
|
||||||
|
|
||||||
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) {
|
|
||||||
return audience.csvContacts.map((c, i) => ({
|
|
||||||
id: `csv-${i}`,
|
|
||||||
user_id: '',
|
|
||||||
phone: c.phone,
|
|
||||||
name: c.name,
|
|
||||||
created_at: new Date().toISOString(),
|
|
||||||
updated_at: new Date().toISOString(),
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
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 keys = Object.keys(variables).sort((a, b) => {
|
||||||
const an = Number(a);
|
const an = Number(a);
|
||||||
const bn = Number(b);
|
const bn = Number(b);
|
||||||
@ -111,6 +93,8 @@ export function useBroadcastSending(): UseBroadcastSendingReturn {
|
|||||||
return keys.map((key) => {
|
return keys.map((key) => {
|
||||||
const v = variables[key];
|
const v = variables[key];
|
||||||
if (v.type === 'static') return v.value;
|
if (v.type === 'static') return v.value;
|
||||||
|
|
||||||
|
if (v.type === 'field') {
|
||||||
const fieldMap: Record<string, string | undefined> = {
|
const fieldMap: Record<string, string | undefined> = {
|
||||||
name: contact.name,
|
name: contact.name,
|
||||||
phone: contact.phone,
|
phone: contact.phone,
|
||||||
@ -118,7 +102,144 @@ export function useBroadcastSending(): UseBroadcastSendingReturn {
|
|||||||
company: contact.company,
|
company: contact.company,
|
||||||
};
|
};
|
||||||
return fieldMap[v.value] ?? '';
|
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);
|
||||||
|
|
||||||
|
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}`);
|
||||||
|
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) {
|
||||||
|
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: '',
|
||||||
|
phone: c.phone,
|
||||||
|
name: c.name,
|
||||||
|
created_at: new Date().toISOString(),
|
||||||
|
updated_at: new Date().toISOString(),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveCustomFieldAudience(
|
||||||
|
supabase: ReturnType<typeof createClient>,
|
||||||
|
filter: CustomFieldFilter,
|
||||||
|
): Promise<Contact[]> {
|
||||||
|
const { fieldId, operator, value } = filter;
|
||||||
|
|
||||||
|
// 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> {
|
async function createAndSendBroadcast(payload: BroadcastPayload): Promise<string> {
|
||||||
@ -128,6 +249,19 @@ export function useBroadcastSending(): UseBroadcastSendingReturn {
|
|||||||
const supabase = createClient();
|
const supabase = createClient();
|
||||||
|
|
||||||
try {
|
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 ─────────────────────────
|
// ── Step 1: Resolve audience contacts ─────────────────────────
|
||||||
setProgress(5);
|
setProgress(5);
|
||||||
const contacts = await resolveAudience(payload.audience);
|
const contacts = await resolveAudience(payload.audience);
|
||||||
@ -141,6 +275,7 @@ export function useBroadcastSending(): UseBroadcastSendingReturn {
|
|||||||
const { data: broadcast, error: broadcastError } = await supabase
|
const { data: broadcast, error: broadcastError } = await supabase
|
||||||
.from('broadcasts')
|
.from('broadcasts')
|
||||||
.insert({
|
.insert({
|
||||||
|
user_id: user.id,
|
||||||
name: payload.name,
|
name: payload.name,
|
||||||
template_name: payload.template.name,
|
template_name: payload.template.name,
|
||||||
template_language: payload.template.language ?? 'en_US',
|
template_language: payload.template.language ?? 'en_US',
|
||||||
@ -148,6 +283,8 @@ export function useBroadcastSending(): UseBroadcastSendingReturn {
|
|||||||
audience_filter: {
|
audience_filter: {
|
||||||
type: payload.audience.type,
|
type: payload.audience.type,
|
||||||
tagIds: payload.audience.tagIds,
|
tagIds: payload.audience.tagIds,
|
||||||
|
customField: payload.audience.customField,
|
||||||
|
excludeTagIds: payload.audience.excludeTagIds,
|
||||||
},
|
},
|
||||||
status: 'sending',
|
status: 'sending',
|
||||||
total_recipients: contacts.length,
|
total_recipients: contacts.length,
|
||||||
@ -161,10 +298,12 @@ export function useBroadcastSending(): UseBroadcastSendingReturn {
|
|||||||
.single();
|
.single();
|
||||||
|
|
||||||
if (broadcastError || !broadcast) {
|
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);
|
setProgress(20);
|
||||||
const recipientRows = contacts.map((contact) => ({
|
const recipientRows = contacts.map((contact) => ({
|
||||||
broadcast_id: broadcast.id,
|
broadcast_id: broadcast.id,
|
||||||
@ -177,13 +316,12 @@ export function useBroadcastSending(): UseBroadcastSendingReturn {
|
|||||||
const { error: recipientError } = await supabase
|
const { error: recipientError } = await supabase
|
||||||
.from('broadcast_recipients')
|
.from('broadcast_recipients')
|
||||||
.insert(batch);
|
.insert(batch);
|
||||||
|
|
||||||
if (recipientError) {
|
if (recipientError) {
|
||||||
console.error('Failed to insert recipient batch:', 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);
|
setProgress(30);
|
||||||
const { data: recipients, error: recipientsFetchError } = await supabase
|
const { data: recipients, error: recipientsFetchError } = await supabase
|
||||||
.from('broadcast_recipients')
|
.from('broadcast_recipients')
|
||||||
@ -194,21 +332,33 @@ export function useBroadcastSending(): UseBroadcastSendingReturn {
|
|||||||
throw new Error('Failed to fetch broadcast recipients');
|
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;
|
let failedCount = 0;
|
||||||
const totalRecipients = recipients.length;
|
const totalRecipients = recipients.length;
|
||||||
|
|
||||||
for (let i = 0; i < recipients.length; i += SEND_BATCH_SIZE) {
|
for (let i = 0; i < recipients.length; i += SEND_BATCH_SIZE) {
|
||||||
const batch = recipients.slice(i, 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
|
const apiRecipients = batch
|
||||||
.filter((r) => r.contact?.phone)
|
.filter((r) => r.contact?.phone)
|
||||||
.map((r) => ({
|
.map((r) => ({
|
||||||
phone: r.contact!.phone as string,
|
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;
|
if (apiRecipients.length === 0) continue;
|
||||||
@ -230,8 +380,6 @@ export function useBroadcastSending(): UseBroadcastSendingReturn {
|
|||||||
throw new Error(data.error || 'Broadcast API request failed');
|
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>();
|
const resultsByPhone = new Map<string, BroadcastApiResult>();
|
||||||
for (const r of (data.results ?? []) as BroadcastApiResult[]) {
|
for (const r of (data.results ?? []) as BroadcastApiResult[]) {
|
||||||
resultsByPhone.set(r.phone, r);
|
resultsByPhone.set(r.phone, r);
|
||||||
@ -242,8 +390,6 @@ export function useBroadcastSending(): UseBroadcastSendingReturn {
|
|||||||
const result = phone ? resultsByPhone.get(phone) : undefined;
|
const result = phone ? resultsByPhone.get(phone) : undefined;
|
||||||
|
|
||||||
if (!result) {
|
if (!result) {
|
||||||
// No result for this phone — the API skipped it (most
|
|
||||||
// likely because contact.phone was missing). Mark failed.
|
|
||||||
failedCount++;
|
failedCount++;
|
||||||
await supabase
|
await supabase
|
||||||
.from('broadcast_recipients')
|
.from('broadcast_recipients')
|
||||||
@ -256,7 +402,6 @@ export function useBroadcastSending(): UseBroadcastSendingReturn {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (result.status === 'sent') {
|
if (result.status === 'sent') {
|
||||||
sentCount++;
|
|
||||||
await supabase
|
await supabase
|
||||||
.from('broadcast_recipients')
|
.from('broadcast_recipients')
|
||||||
.update({
|
.update({
|
||||||
@ -278,8 +423,6 @@ export function useBroadcastSending(): UseBroadcastSendingReturn {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Network / 500 — mark the whole batch failed. Trigger
|
|
||||||
// re-aggregates counts on the parent broadcast.
|
|
||||||
for (const recipient of batch) {
|
for (const recipient of batch) {
|
||||||
failedCount++;
|
failedCount++;
|
||||||
await supabase
|
await supabase
|
||||||
@ -292,23 +435,18 @@ export function useBroadcastSending(): UseBroadcastSendingReturn {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Progress reflects the send phase (30% → 95%).
|
|
||||||
const progressPct =
|
const progressPct =
|
||||||
30 + Math.round(((i + batch.length) / totalRecipients) * 60);
|
30 + Math.round(((i + batch.length) / totalRecipients) * 60);
|
||||||
setProgress(progressPct);
|
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) {
|
if (i + SEND_BATCH_SIZE < recipients.length) {
|
||||||
await sleep(SEND_BATCH_DELAY_MS);
|
await sleep(SEND_BATCH_DELAY_MS);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Step 5: Finalize broadcast status ─────────────────────────
|
// ── Step 5: Finalize status ───────────────────────────────────
|
||||||
// Aggregate counts are maintained by the DB trigger (migration
|
// Aggregate counts are maintained by the DB trigger (migration
|
||||||
// 003), so we only update `status` here. sent_count etc. are
|
// 003); we only flip the final status here.
|
||||||
// already accurate.
|
|
||||||
setProgress(95);
|
setProgress(95);
|
||||||
const finalStatus = failedCount === totalRecipients ? 'failed' : 'sent';
|
const finalStatus = failedCount === totalRecipients ? 'failed' : 'sent';
|
||||||
await supabase
|
await supabase
|
||||||
@ -317,9 +455,6 @@ export function useBroadcastSending(): UseBroadcastSendingReturn {
|
|||||||
.eq('id', broadcast.id);
|
.eq('id', broadcast.id);
|
||||||
|
|
||||||
setProgress(100);
|
setProgress(100);
|
||||||
// Unused locals intentionally kept for clarity at return site:
|
|
||||||
void sentCount;
|
|
||||||
|
|
||||||
return broadcast.id;
|
return broadcast.id;
|
||||||
} finally {
|
} finally {
|
||||||
setIsProcessing(false);
|
setIsProcessing(false);
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user