feat(broadcasts) C: custom-field audience, exclude tags, live preview

Stacked on PR B (#22). Merge A → B → C in order.

Step 2 — Audience
- Implement the "Custom Field" audience option (was a stub): field
  picker + operator (is / is not / contains) + value input. Estimated
  count query joins contact_custom_values with the operator mapped
  to eq / neq / ilike. PostgREST ilike handles "contains" case-
  insensitively.
- New "Exclude contacts with these tags" section — applies to every
  audience type. Estimated count subtracts excluded contacts
  client-side against the resolved base set.
- Switching audience type clears stale shape fields (tagIds /
  customField / csvContacts) so old selections don't leak across.

Step 3 — Personalize
- Add "Custom Field" mapping option alongside static + contact field.
- Live Preview now pulls the most recent real contact (+ their custom
  values) instead of hard-coded sample data. Falls back to sample if
  the user has no contacts yet. Rendered in a WhatsApp-style bubble
  so the user gets a realistic feel for how the recipient sees it.

Hook — use-broadcast-sending
- AudienceConfig gains customField + excludeTagIds.
- resolveAudience: custom-field branch + exclude-tag subtraction
  applied after the base resolution (works for all / tags / custom
  field).
- resolveVariables accepts a pre-built (fieldId → value) map per
  contact. Before the send loop we bulk-fetch contact_custom_values
  for all resolved contacts in one query (paged by 500 for PostgREST
  limits) so we never N+1 per recipient.

new/page.tsx — passes customField + excludeTagIds into the hook;
variables state widens to allow type='custom_field'.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Arnas Donauskas 2026-04-17 11:39:24 +03:00
parent 244d04ca93
commit 296a905976
No known key found for this signature in database
4 changed files with 690 additions and 155 deletions

View File

@ -28,10 +28,16 @@ export default function NewBroadcastPage() {
const [audience, setAudience] = useState<{
type: 'all' | 'tags' | 'custom_field' | 'csv';
tagIds?: string[];
customField?: {
fieldId: string;
operator: 'is' | 'is_not' | 'contains';
value: string;
};
csvContacts?: { phone: string; name?: string }[];
excludeTagIds?: string[];
}>({ type: 'all' });
const [variables, setVariables] = useState<
Record<string, { type: 'static' | 'field'; value: string }>
Record<string, { type: 'static' | 'field' | 'custom_field'; value: string }>
>({});
const [name, setName] = useState('');
@ -43,9 +49,11 @@ export default function NewBroadcastPage() {
name,
template,
audience: {
type: audience.type === 'custom_field' ? 'all' : audience.type,
type: audience.type,
tagIds: audience.tagIds,
customField: audience.customField,
csvContacts: audience.csvContacts,
excludeTagIds: audience.excludeTagIds,
},
variables,
});

View File

@ -2,16 +2,34 @@
import { useEffect, useState, useCallback } from 'react';
import { createClient } from '@/lib/supabase/client';
import { Tag } from '@/types';
import { CustomField, Tag } from '@/types';
import { Button } from '@/components/ui/button';
import { Users, Tags, Filter, Upload, Loader2, ArrowRight, ArrowLeft } from 'lucide-react';
import {
Users,
Tags,
Filter,
Upload,
Loader2,
ArrowRight,
ArrowLeft,
X,
} from 'lucide-react';
type AudienceType = 'all' | 'tags' | 'custom_field' | 'csv';
type CustomFieldOperator = 'is' | 'is_not' | 'contains';
interface CustomFieldFilter {
fieldId: string;
operator: CustomFieldOperator;
value: string;
}
interface AudienceConfig {
type: AudienceType;
tagIds?: string[];
customField?: CustomFieldFilter;
csvContacts?: { phone: string; name?: string }[];
excludeTagIds?: string[];
}
interface Step2Props {
@ -21,21 +39,60 @@ interface Step2Props {
onBack: () => void;
}
const audienceOptions: { type: AudienceType; label: string; description: string; icon: typeof Users }[] = [
{ type: 'all', label: 'All Contacts', description: 'Send to every contact in your database', icon: Users },
{ type: 'tags', label: 'Filter by Tags', description: 'Target contacts with specific tags', icon: Tags },
{ type: 'custom_field', label: 'Custom Field', description: 'Filter by a custom field value', icon: Filter },
{ type: 'csv', label: 'Upload CSV', description: 'Upload a list of phone numbers', icon: Upload },
const audienceOptions: {
type: AudienceType;
label: string;
description: string;
icon: typeof Users;
}[] = [
{
type: 'all',
label: 'All Contacts',
description: 'Send to every contact in your database',
icon: Users,
},
{
type: 'tags',
label: 'Filter by Tags',
description: 'Target contacts with specific tags',
icon: Tags,
},
{
type: 'custom_field',
label: 'Custom Field',
description: 'Filter by a custom field value',
icon: Filter,
},
{
type: 'csv',
label: 'Upload CSV',
description: 'Upload a list of phone numbers',
icon: Upload,
},
];
export function Step2SelectAudience({ audience, onUpdate, onNext, onBack }: Step2Props) {
const OPERATOR_OPTIONS: { value: CustomFieldOperator; label: string }[] = [
{ value: 'is', label: 'is' },
{ value: 'is_not', label: 'is not' },
{ value: 'contains', label: 'contains' },
];
export function Step2SelectAudience({
audience,
onUpdate,
onNext,
onBack,
}: Step2Props) {
const [tags, setTags] = useState<Tag[]>([]);
const [customFields, setCustomFields] = useState<CustomField[]>([]);
const [loadingTags, setLoadingTags] = useState(false);
const [loadingFields, setLoadingFields] = useState(false);
const [estimatedCount, setEstimatedCount] = useState<number | null>(null);
const [loadingCount, setLoadingCount] = useState(false);
// Tags are used both by the primary "Filter by Tags" audience type
// AND by the exclude-list below — so always load once on mount.
useEffect(() => {
if (audience.type === 'tags') {
async function fetchTags() {
setLoadingTags(true);
try {
@ -47,7 +104,25 @@ export function Step2SelectAudience({ audience, onUpdate, onNext, onBack }: Step
}
}
fetchTags();
}, []);
// Lazy-load custom fields only when that audience type is active.
useEffect(() => {
if (audience.type !== 'custom_field') return;
async function fetchFields() {
setLoadingFields(true);
try {
const supabase = createClient();
const { data } = await supabase
.from('custom_fields')
.select('*')
.order('field_name');
setCustomFields(data ?? []);
} finally {
setLoadingFields(false);
}
}
fetchFields();
}, [audience.type]);
const fetchEstimatedCount = useCallback(async () => {
@ -55,36 +130,86 @@ export function Step2SelectAudience({ audience, onUpdate, onNext, onBack }: Step
try {
const supabase = createClient();
// Base query — produces the superset before exclude is applied.
let baseIds: Set<string> | null = null; // null means "all contacts"
if (audience.type === 'all') {
const { count } = await supabase
.from('contacts')
.select('*', { count: 'exact', head: true });
setEstimatedCount(count ?? 0);
} else if (audience.type === 'tags' && audience.tagIds && audience.tagIds.length > 0) {
const { data: contactTags } = await supabase
// Handled below — full-table count adjusted by excludes.
} else if (
audience.type === 'tags' &&
audience.tagIds &&
audience.tagIds.length > 0
) {
const { data } = await supabase
.from('contact_tags')
.select('contact_id')
.in('tag_id', audience.tagIds);
const uniqueIds = new Set((contactTags ?? []).map((ct) => ct.contact_id));
setEstimatedCount(uniqueIds.size);
} else if (audience.type === 'csv' && audience.csvContacts) {
baseIds = new Set((data ?? []).map((r) => r.contact_id));
} else if (
audience.type === 'custom_field' &&
audience.customField?.fieldId &&
audience.customField.value
) {
const { fieldId, operator, value } = audience.customField;
let q = supabase
.from('contact_custom_values')
.select('contact_id')
.eq('custom_field_id', fieldId);
if (operator === 'is') q = q.eq('value', value);
else if (operator === 'is_not') q = q.neq('value', value);
else q = q.ilike('value', `%${value}%`);
const { data } = await q;
baseIds = new Set((data ?? []).map((r) => r.contact_id));
} else if (
audience.type === 'csv' &&
audience.csvContacts &&
audience.csvContacts.length > 0
) {
setEstimatedCount(audience.csvContacts.length);
return;
} else {
// Partially-configured audience — wait for the user to finish.
setEstimatedCount(null);
return;
}
// Apply exclude tags
let excludeSet: Set<string> | null = null;
if (audience.excludeTagIds && audience.excludeTagIds.length > 0) {
const { data: excludeRows } = await supabase
.from('contact_tags')
.select('contact_id')
.in('tag_id', audience.excludeTagIds);
excludeSet = new Set((excludeRows ?? []).map((r) => r.contact_id));
}
if (baseIds) {
const effective = [...baseIds].filter(
(id) => !excludeSet?.has(id),
);
setEstimatedCount(effective.length);
} else {
// "All" — fetch the total, then subtract exclude set if any.
const { count } = await supabase
.from('contacts')
.select('*', { count: 'exact', head: true });
const total = count ?? 0;
setEstimatedCount(excludeSet ? Math.max(0, total - excludeSet.size) : total);
}
} finally {
setLoadingCount(false);
}
}, [audience.type, audience.tagIds, audience.csvContacts]);
}, [
audience.type,
audience.tagIds,
audience.customField,
audience.csvContacts,
audience.excludeTagIds,
]);
useEffect(() => {
if (audience.type === 'all' || (audience.type === 'tags' && audience.tagIds && audience.tagIds.length > 0) || (audience.type === 'csv' && audience.csvContacts && audience.csvContacts.length > 0)) {
fetchEstimatedCount();
} else {
setEstimatedCount(null);
}
}, [audience.type, audience.tagIds, audience.csvContacts, fetchEstimatedCount]);
}, [fetchEstimatedCount]);
function toggleTag(tagId: string) {
const current = audience.tagIds ?? [];
@ -94,16 +219,40 @@ export function Step2SelectAudience({ audience, onUpdate, onNext, onBack }: Step
onUpdate({ ...audience, tagIds: updated });
}
function toggleExcludeTag(tagId: string) {
const current = audience.excludeTagIds ?? [];
const updated = current.includes(tagId)
? current.filter((id) => id !== tagId)
: [...current, tagId];
onUpdate({ ...audience, excludeTagIds: updated });
}
function updateCustomField(patch: Partial<CustomFieldFilter>) {
const prev = audience.customField ?? {
fieldId: '',
operator: 'is' as CustomFieldOperator,
value: '',
};
onUpdate({ ...audience, customField: { ...prev, ...patch } });
}
const isValid =
audience.type === 'all' ||
(audience.type === 'tags' && audience.tagIds && audience.tagIds.length > 0) ||
(audience.type === 'csv' && audience.csvContacts && audience.csvContacts.length > 0);
(audience.type === 'custom_field' &&
!!audience.customField?.fieldId &&
audience.customField.value.length > 0) ||
(audience.type === 'csv' &&
audience.csvContacts &&
audience.csvContacts.length > 0);
return (
<div className="space-y-6">
<div>
<h2 className="text-lg font-semibold text-white">Select Audience</h2>
<p className="mt-1 text-sm text-slate-400">Choose who will receive this broadcast.</p>
<p className="mt-1 text-sm text-slate-400">
Choose who will receive this broadcast.
</p>
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
@ -113,7 +262,21 @@ export function Step2SelectAudience({ audience, onUpdate, onNext, onBack }: Step
return (
<button
key={option.type}
onClick={() => onUpdate({ type: option.type, tagIds: option.type === 'tags' ? audience.tagIds : undefined })}
onClick={() =>
onUpdate({
...audience,
type: option.type,
// Wipe shape fields from other types to avoid stale
// config leaking across selections.
tagIds: option.type === 'tags' ? audience.tagIds : undefined,
customField:
option.type === 'custom_field'
? audience.customField
: undefined,
csvContacts:
option.type === 'csv' ? audience.csvContacts : undefined,
})
}
className={`flex items-start gap-3 rounded-xl border p-4 text-left transition-all ${
isSelected
? 'border-emerald-500 bg-emerald-500/5 ring-1 ring-emerald-500/30'
@ -122,14 +285,18 @@ export function Step2SelectAudience({ audience, onUpdate, onNext, onBack }: Step
>
<div
className={`flex h-9 w-9 shrink-0 items-center justify-center rounded-lg ${
isSelected ? 'bg-emerald-500/10 text-emerald-400' : 'bg-slate-800 text-slate-400'
isSelected
? 'bg-emerald-500/10 text-emerald-400'
: 'bg-slate-800 text-slate-400'
}`}
>
<Icon className="h-4 w-4" />
</div>
<div>
<p className="text-sm font-medium text-white">{option.label}</p>
<p className="mt-0.5 text-xs text-slate-400">{option.description}</p>
<p className="mt-0.5 text-xs text-slate-400">
{option.description}
</p>
</div>
</button>
);
@ -142,7 +309,9 @@ export function Step2SelectAudience({ audience, onUpdate, onNext, onBack }: Step
{loadingTags ? (
<Loader2 className="h-5 w-5 animate-spin text-emerald-500" />
) : tags.length === 0 ? (
<p className="text-xs text-slate-400">No tags found. Create tags in Settings.</p>
<p className="text-xs text-slate-400">
No tags found. Create tags in Settings.
</p>
) : (
<div className="flex flex-wrap gap-2">
{tags.map((tag) => {
@ -170,27 +339,122 @@ export function Step2SelectAudience({ audience, onUpdate, onNext, onBack }: Step
</div>
)}
{audience.type === 'custom_field' && (
<div className="space-y-3 rounded-xl border border-slate-800 bg-slate-900/50 p-4">
<p className="text-sm font-medium text-white">Custom Field Filter</p>
{loadingFields ? (
<Loader2 className="h-5 w-5 animate-spin text-emerald-500" />
) : customFields.length === 0 ? (
<p className="text-xs text-slate-400">
No custom fields defined. Create one in Settings → Custom Fields.
</p>
) : (
<div className="grid grid-cols-1 gap-2 sm:grid-cols-[minmax(0,1fr)_140px_minmax(0,1fr)]">
<select
value={audience.customField?.fieldId ?? ''}
onChange={(e) => updateCustomField({ fieldId: e.target.value })}
className="h-9 rounded-lg border border-slate-700 bg-slate-800 px-2.5 text-sm text-white outline-none focus:border-emerald-500 focus:ring-1 focus:ring-emerald-500"
>
<option value="">Select field…</option>
{customFields.map((f) => (
<option key={f.id} value={f.id}>
{f.field_name}
</option>
))}
</select>
<select
value={audience.customField?.operator ?? 'is'}
onChange={(e) =>
updateCustomField({
operator: e.target.value as CustomFieldOperator,
})
}
className="h-9 rounded-lg border border-slate-700 bg-slate-800 px-2.5 text-sm text-white outline-none focus:border-emerald-500 focus:ring-1 focus:ring-emerald-500"
>
{OPERATOR_OPTIONS.map((op) => (
<option key={op.value} value={op.value}>
{op.label}
</option>
))}
</select>
<input
type="text"
value={audience.customField?.value ?? ''}
onChange={(e) => updateCustomField({ value: e.target.value })}
placeholder="Value"
className="h-9 rounded-lg border border-slate-700 bg-slate-800 px-2.5 text-sm text-white outline-none placeholder:text-slate-500 focus:border-emerald-500 focus:ring-1 focus:ring-emerald-500"
/>
</div>
)}
</div>
)}
{/* Exclude list — applies regardless of audience type */}
<div className="rounded-xl border border-slate-800 bg-slate-900/50 p-4">
<div className="mb-3 flex items-center gap-2">
<X className="h-4 w-4 text-red-400" />
<p className="text-sm font-medium text-white">
Exclude contacts with these tags
</p>
<span className="text-xs text-slate-500">(optional)</span>
</div>
{tags.length === 0 ? (
<p className="text-xs text-slate-500">No tags available.</p>
) : (
<div className="flex flex-wrap gap-2">
{tags.map((tag) => {
const isExcluded = audience.excludeTagIds?.includes(tag.id);
return (
<button
key={tag.id}
onClick={() => toggleExcludeTag(tag.id)}
className={`inline-flex items-center rounded-full border px-3 py-1 text-xs font-medium transition-all ${
isExcluded
? 'border-red-500/30 bg-red-500/10 text-red-300'
: 'border-slate-700 bg-slate-800 text-slate-300 hover:border-slate-600'
}`}
>
<span
className="mr-1.5 h-2 w-2 rounded-full"
style={{ backgroundColor: tag.color }}
/>
{tag.name}
</button>
);
})}
</div>
)}
</div>
{/* Audience Summary */}
<div className="rounded-xl border border-slate-800 bg-slate-900/50 p-4">
<p className="mb-2 text-sm font-medium text-white">Audience Summary</p>
{loadingCount ? (
<div className="flex items-center gap-2">
<Loader2 className="h-4 w-4 animate-spin text-emerald-500" />
<span className="text-xs text-slate-400">Calculating...</span>
<span className="text-xs text-slate-400">Calculating…</span>
</div>
) : estimatedCount !== null ? (
<div className="flex items-center gap-2">
<Users className="h-4 w-4 text-emerald-400" />
<span className="text-sm text-white">{estimatedCount.toLocaleString()}</span>
<span className="text-sm text-white">
{estimatedCount.toLocaleString()}
</span>
<span className="text-xs text-slate-400">estimated recipients</span>
</div>
) : (
<p className="text-xs text-slate-500">Select an audience type to see the estimate.</p>
<p className="text-xs text-slate-500">
Select an audience type to see the estimate.
</p>
)}
</div>
<div className="flex items-center justify-between border-t border-slate-800 pt-4">
<Button variant="outline" onClick={onBack} className="border-slate-700 text-slate-300">
<Button
variant="outline"
onClick={onBack}
className="border-slate-700 text-slate-300"
>
<ArrowLeft className="h-4 w-4" />
Back
</Button>

View File

@ -1,7 +1,8 @@
'use client';
import { useMemo } from 'react';
import { MessageTemplate } from '@/types';
import { useEffect, useMemo, useState } from 'react';
import { createClient } from '@/lib/supabase/client';
import { Contact, CustomField, MessageTemplate } from '@/types';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
@ -11,10 +12,12 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { ArrowLeft, ArrowRight, Eye } from 'lucide-react';
import { ArrowLeft, ArrowRight, Eye, Loader2 } from 'lucide-react';
type VariableType = 'static' | 'field' | 'custom_field';
interface VariableMapping {
type: 'static' | 'field';
type: VariableType;
value: string;
}
@ -33,34 +36,102 @@ const contactFields = [
{ value: 'company', label: 'Company' },
];
const sampleContact = {
const SAMPLE_CONTACT: Contact = {
id: 'sample',
user_id: '',
name: 'John Doe',
phone: '+1234567890',
email: 'john@example.com',
company: 'Acme Corp',
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
};
export function Step3Personalize({ template, variables, onUpdate, onNext, onBack }: Step3Props) {
// Extract {{1}}, {{2}}, etc. from body_text
export function Step3Personalize({
template,
variables,
onUpdate,
onNext,
onBack,
}: Step3Props) {
const [customFields, setCustomFields] = useState<CustomField[]>([]);
const [loadingFields, setLoadingFields] = useState(true);
const [firstContact, setFirstContact] = useState<Contact | null>(null);
const [firstContactCustomValues, setFirstContactCustomValues] = useState<
Map<string, string>
>(new Map());
const [loadingPreview, setLoadingPreview] = useState(true);
// Load user's custom fields + a representative contact for the
// live preview. Fall back to sample data if no contacts exist yet.
useEffect(() => {
let cancelled = false;
(async () => {
const supabase = createClient();
const [fieldsRes, contactRes] = await Promise.all([
supabase.from('custom_fields').select('*').order('field_name'),
supabase
.from('contacts')
.select('*')
.order('created_at', { ascending: false })
.limit(1)
.maybeSingle(),
]);
if (cancelled) return;
setCustomFields(fieldsRes.data ?? []);
setLoadingFields(false);
const contact = contactRes.data ?? null;
setFirstContact(contact);
if (contact) {
const { data: customVals } = await supabase
.from('contact_custom_values')
.select('custom_field_id, value')
.eq('contact_id', contact.id);
if (!cancelled) {
const map = new Map<string, string>();
for (const row of customVals ?? []) {
map.set(row.custom_field_id, row.value ?? '');
}
setFirstContactCustomValues(map);
}
}
setLoadingPreview(false);
})();
return () => {
cancelled = true;
};
}, []);
const placeholders = useMemo(() => {
const matches = template.body_text.match(/\{\{(\d+)\}\}/g);
if (!matches) return [];
return [...new Set(matches)].sort();
}, [template.body_text]);
function updateVariable(key: string, field: Partial<VariableMapping>) {
const current = variables[key] ?? { type: 'static', value: '' };
function updateVariable(key: string, patch: Partial<VariableMapping>) {
const current = variables[key] ?? { type: 'static' as VariableType, value: '' };
onUpdate({
...variables,
[key]: { ...current, ...field },
[key]: { ...current, ...patch },
});
}
// Generate preview with sample data
/**
* Substitute placeholders using the first real contact where
* possible. Placeholders keyed by "{{N}}" map to variable key "N".
*/
const previewText = useMemo(() => {
const contact = firstContact ?? SAMPLE_CONTACT;
const customValues = firstContact
? firstContactCustomValues
: new Map<string, string>();
let text = template.body_text;
for (const placeholder of placeholders) {
const key = placeholder;
const key = placeholder.replace(/^\{\{|\}\}$/g, '');
const mapping = variables[key];
let replacement = placeholder;
@ -68,34 +139,59 @@ export function Step3Personalize({ template, variables, onUpdate, onNext, onBack
if (mapping.type === 'static' && mapping.value) {
replacement = mapping.value;
} else if (mapping.type === 'field' && mapping.value) {
replacement = sampleContact[mapping.value as keyof typeof sampleContact] ?? placeholder;
const fieldMap: Record<string, string | undefined> = {
name: contact.name,
phone: contact.phone,
email: contact.email,
company: contact.company,
};
replacement = fieldMap[mapping.value] ?? placeholder;
} else if (mapping.type === 'custom_field' && mapping.value) {
replacement = customValues.get(mapping.value) || placeholder;
}
}
text = text.replaceAll(placeholder, replacement);
}
return text;
}, [template.body_text, variables, placeholders]);
}, [
template.body_text,
variables,
placeholders,
firstContact,
firstContactCustomValues,
]);
const previewLabel = firstContact
? firstContact.name || firstContact.phone
: 'sample data';
return (
<div className="space-y-6">
<div>
<h2 className="text-lg font-semibold text-white">Personalize Message</h2>
<p className="mt-1 text-sm text-slate-400">
Map template variables to contact fields or static values.
Map template variables to contact fields, custom fields, or static
values.
</p>
</div>
{placeholders.length === 0 ? (
<div className="rounded-xl border border-slate-800 bg-slate-900/50 p-6 text-center">
<p className="text-sm text-slate-400">This template has no variables to personalize.</p>
<p className="text-sm text-slate-400">
This template has no variables to personalize.
</p>
</div>
) : (
<div className="space-y-4">
{placeholders.map((placeholder) => {
const mapping = variables[placeholder] ?? { type: 'static', value: '' };
const key = placeholder.replace(/^\{\{|\}\}$/g, '');
const mapping = variables[key] ?? { type: 'static', value: '' };
return (
<div key={placeholder} className="rounded-xl border border-slate-800 bg-slate-900/50 p-4">
<div
key={placeholder}
className="rounded-xl border border-slate-800 bg-slate-900/50 p-4"
>
<div className="mb-3 flex items-center gap-2">
<span className="inline-flex items-center rounded-md bg-emerald-500/10 px-2 py-0.5 text-xs font-mono font-medium text-emerald-400">
{placeholder}
@ -110,7 +206,10 @@ export function Step3Personalize({ template, variables, onUpdate, onNext, onBack
<Select
value={mapping.type}
onValueChange={(val) =>
updateVariable(placeholder, { type: val as 'static' | 'field', value: '' })
updateVariable(key, {
type: val as VariableType,
value: '',
})
}
>
<SelectTrigger className="w-full border-slate-700 bg-slate-800 text-white">
@ -119,6 +218,9 @@ export function Step3Personalize({ template, variables, onUpdate, onNext, onBack
<SelectContent className="border-slate-700 bg-slate-800">
<SelectItem value="static">Static Value</SelectItem>
<SelectItem value="field">Contact Field</SelectItem>
<SelectItem value="custom_field">
Custom Field
</SelectItem>
</SelectContent>
</Select>
</div>
@ -130,14 +232,18 @@ export function Step3Personalize({ template, variables, onUpdate, onNext, onBack
{mapping.type === 'static' ? (
<Input
value={mapping.value}
onChange={(e) => updateVariable(placeholder, { value: e.target.value })}
onChange={(e) =>
updateVariable(key, { value: e.target.value })
}
placeholder="Enter value..."
className="border-slate-700 bg-slate-800 text-white placeholder:text-slate-500"
/>
) : (
) : mapping.type === 'field' ? (
<Select
value={mapping.value || undefined}
onValueChange={(val) => updateVariable(placeholder, { value: val || '' })}
onValueChange={(val) =>
updateVariable(key, { value: val || '' })
}
>
<SelectTrigger className="w-full border-slate-700 bg-slate-800 text-white">
<SelectValue placeholder="Select field..." />
@ -150,6 +256,32 @@ export function Step3Personalize({ template, variables, onUpdate, onNext, onBack
))}
</SelectContent>
</Select>
) : (
<Select
value={mapping.value || undefined}
onValueChange={(val) =>
updateVariable(key, { value: val || '' })
}
>
<SelectTrigger className="w-full border-slate-700 bg-slate-800 text-white">
<SelectValue
placeholder={
loadingFields
? 'Loading…'
: customFields.length === 0
? 'No custom fields'
: 'Select custom field…'
}
/>
</SelectTrigger>
<SelectContent className="border-slate-700 bg-slate-800">
{customFields.map((f) => (
<SelectItem key={f.id} value={f.id}>
{f.field_name}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
</div>
@ -159,20 +291,32 @@ export function Step3Personalize({ template, variables, onUpdate, onNext, onBack
</div>
)}
{/* Preview */}
{/* Live Preview — rendered as a WhatsApp-style bubble so the user
sees approximately what the recipient will see. */}
<div className="rounded-xl border border-slate-800 bg-slate-900/50 p-4">
<div className="mb-3 flex items-center gap-2">
<Eye className="h-4 w-4 text-emerald-400" />
<p className="text-sm font-medium text-white">Preview</p>
<span className="text-xs text-slate-500">(with sample data)</span>
<p className="text-sm font-medium text-white">Live Preview</p>
<span className="text-xs text-slate-500">({previewLabel})</span>
{loadingPreview && (
<Loader2 className="h-3.5 w-3.5 animate-spin text-emerald-500" />
)}
</div>
<div className="rounded-lg bg-[#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 className="rounded-lg bg-slate-800/50 p-3">
<p className="whitespace-pre-wrap text-sm text-slate-200">{previewText}</p>
</div>
</div>
<div className="flex items-center justify-between border-t border-slate-800 pt-4">
<Button variant="outline" onClick={onBack} className="border-slate-700 text-slate-300">
<Button
variant="outline"
onClick={onBack}
className="border-slate-700 text-slate-300"
>
<ArrowLeft className="h-4 w-4" />
Back
</Button>

View File

@ -4,17 +4,40 @@ import { useState } from 'react';
import { createClient } from '@/lib/supabase/client';
import { Contact, MessageTemplate } from '@/types';
interface AudienceConfig {
type: 'all' | 'tags' | 'csv';
tagIds?: string[];
csvContacts?: { phone: string; name?: string }[];
export type CustomFieldOperator = 'is' | 'is_not' | 'contains';
export interface CustomFieldFilter {
fieldId: string;
operator: CustomFieldOperator;
value: string;
}
export interface AudienceConfig {
type: 'all' | 'tags' | 'custom_field' | 'csv';
tagIds?: string[];
customField?: CustomFieldFilter;
csvContacts?: { phone: string; name?: string }[];
/** Contacts carrying any of these tags are subtracted from the result. */
excludeTagIds?: string[];
}
/**
* Variable mapping — each template placeholder (by key, usually "1",
* "2", …) is resolved at send time. `field` maps to a built-in contact
* field (name/phone/email/company); `custom_field` maps to a
* contact_custom_values.value row keyed by the custom_fields.id stored
* in `value`.
*/
export type VariableMapping =
| { type: 'static'; value: string }
| { type: 'field'; value: string }
| { type: 'custom_field'; value: string };
interface BroadcastPayload {
name: string;
template: MessageTemplate;
audience: AudienceConfig;
variables: Record<string, { type: 'static' | 'field'; value: string }>;
variables: Record<string, VariableMapping>;
}
interface UseBroadcastSendingReturn {
@ -24,17 +47,14 @@ interface UseBroadcastSendingReturn {
}
/**
* Meta rate-limit buffer. 10 per batch with a 1 s pause matches the
* spec and keeps us well below Meta's per-phone-number messaging rate
* so a large broadcast never trips the upstream limiter.
* Meta rate-limit buffer. 10 per batch + 1 s pause matches the spec
* and keeps us comfortably under Meta's per-phone-number messaging
* rate so a large broadcast never trips the upstream limiter.
*/
const SEND_BATCH_SIZE = 10;
const SEND_BATCH_DELAY_MS = 1000;
/**
* `broadcast_recipients` inserts are independent of the send rate —
* we batch them purely to keep individual Supabase requests small.
*/
/** `broadcast_recipients` inserts are independent of the send rate. */
const INSERT_BATCH_SIZE = 200;
function sleep(ms: number) {
@ -48,59 +68,21 @@ interface BroadcastApiResult {
error?: string;
}
export function useBroadcastSending(): UseBroadcastSendingReturn {
const [isProcessing, setIsProcessing] = useState(false);
const [progress, setProgress] = useState(0);
/** contactId → (customFieldId → value). */
type CustomValueIndex = Map<string, Map<string, string>>;
async function resolveAudience(audience: AudienceConfig): Promise<Contact[]> {
const supabase = createClient();
if (audience.type === 'all') {
const { data, error } = await supabase.from('contacts').select('*');
if (error) throw new Error(`Failed to fetch contacts: ${error.message}`);
return data ?? [];
}
if (audience.type === 'tags' && audience.tagIds && audience.tagIds.length > 0) {
const { data: contactTags, error: tagError } = await supabase
.from('contact_tags')
.select('contact_id')
.in('tag_id', audience.tagIds);
if (tagError) throw new Error(`Failed to fetch contact tags: ${tagError.message}`);
if (!contactTags || contactTags.length === 0) return [];
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
/**
* Per-contact resolution of custom-field placeholders. Static and
* built-in-field mappings resolve synchronously; custom fields read
* from a pre-built index to avoid N+1 queries during the send loop.
*/
export function resolveVariables(
variables: Record<string, VariableMapping>,
contact: Contact,
customValues?: Map<string, string>,
): string[] {
// Keys typically are "1","2",... so a numeric-aware sort keeps {{1}}
// before {{10}}.
// Keys are typically "1","2",... — numeric-aware sort keeps
// {{1}} before {{10}}.
const keys = Object.keys(variables).sort((a, b) => {
const an = Number(a);
const bn = Number(b);
@ -111,6 +93,8 @@ export function useBroadcastSending(): UseBroadcastSendingReturn {
return keys.map((key) => {
const v = variables[key];
if (v.type === 'static') return v.value;
if (v.type === 'field') {
const fieldMap: Record<string, string | undefined> = {
name: contact.name,
phone: contact.phone,
@ -118,9 +102,146 @@ export function useBroadcastSending(): UseBroadcastSendingReturn {
company: contact.company,
};
return fieldMap[v.value] ?? '';
}
// custom_field
return customValues?.get(v.value) ?? '';
});
}
/**
* Bulk-fetch contact_custom_values for a set of contacts. Returns an
* index keyed by contact_id → field_id → value.
*/
async function fetchCustomValueIndex(
supabase: ReturnType<typeof createClient>,
contactIds: string[],
): Promise<CustomValueIndex> {
const index: CustomValueIndex = new Map();
if (contactIds.length === 0) return index;
// Supabase PostgREST caps the .in(...) IN-clause roughly at 1000
// values. Page through to stay safe.
const PAGE = 500;
for (let i = 0; i < contactIds.length; i += PAGE) {
const slice = contactIds.slice(i, i + PAGE);
const { data } = await supabase
.from('contact_custom_values')
.select('contact_id, custom_field_id, value')
.in('contact_id', slice);
for (const row of data ?? []) {
const bucket = index.get(row.contact_id) ?? new Map<string, string>();
bucket.set(row.custom_field_id, row.value ?? '');
index.set(row.contact_id, bucket);
}
}
return index;
}
export function useBroadcastSending(): UseBroadcastSendingReturn {
const [isProcessing, setIsProcessing] = useState(false);
const [progress, setProgress] = useState(0);
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> {
setIsProcessing(true);
setProgress(0);
@ -148,6 +269,8 @@ export function useBroadcastSending(): UseBroadcastSendingReturn {
audience_filter: {
type: payload.audience.type,
tagIds: payload.audience.tagIds,
customField: payload.audience.customField,
excludeTagIds: payload.audience.excludeTagIds,
},
status: 'sending',
total_recipients: contacts.length,
@ -164,7 +287,7 @@ export function useBroadcastSending(): UseBroadcastSendingReturn {
throw new Error(`Failed to create broadcast: ${broadcastError?.message}`);
}
// ── Step 3: Insert recipient rows (batched for request size) ──
// ── Step 3: Insert recipient rows ─────────────────────────────
setProgress(20);
const recipientRows = contacts.map((contact) => ({
broadcast_id: broadcast.id,
@ -177,13 +300,12 @@ export function useBroadcastSending(): UseBroadcastSendingReturn {
const { error: recipientError } = await supabase
.from('broadcast_recipients')
.insert(batch);
if (recipientError) {
console.error('Failed to insert recipient batch:', recipientError);
}
}
// ── Step 4: Fetch recipients (with joined contact) and send ───
// ── Step 4: Fetch recipients (joined contact) + preload custom values
setProgress(30);
const { data: recipients, error: recipientsFetchError } = await supabase
.from('broadcast_recipients')
@ -194,21 +316,33 @@ export function useBroadcastSending(): UseBroadcastSendingReturn {
throw new Error('Failed to fetch broadcast recipients');
}
let sentCount = 0;
// One bulk fetch of custom values for every contact in this
// broadcast, avoiding N+1 during the send loop.
const contactIds = recipients
.map((r) => r.contact?.id)
.filter((id): id is string => Boolean(id));
const customValueIndex = await fetchCustomValueIndex(
supabase,
contactIds,
);
let failedCount = 0;
const totalRecipients = recipients.length;
for (let i = 0; i < recipients.length; i += SEND_BATCH_SIZE) {
const batch = recipients.slice(i, i + SEND_BATCH_SIZE);
// Build per-recipient payload so each contact gets *their own*
// personalization. Previous impl shipped templateParams[0] for
// the whole batch which was a correctness bug.
const apiRecipients = batch
.filter((r) => r.contact?.phone)
.map((r) => ({
phone: r.contact!.phone as string,
params: r.contact ? resolveVariables(payload.variables, r.contact) : [],
params: r.contact
? resolveVariables(
payload.variables,
r.contact,
customValueIndex.get(r.contact.id),
)
: [],
}));
if (apiRecipients.length === 0) continue;
@ -230,8 +364,6 @@ export function useBroadcastSending(): UseBroadcastSendingReturn {
throw new Error(data.error || 'Broadcast API request failed');
}
// The API returns one entry per input recipient in order.
// Map by phone to be defensive against any reordering.
const resultsByPhone = new Map<string, BroadcastApiResult>();
for (const r of (data.results ?? []) as BroadcastApiResult[]) {
resultsByPhone.set(r.phone, r);
@ -242,8 +374,6 @@ export function useBroadcastSending(): UseBroadcastSendingReturn {
const result = phone ? resultsByPhone.get(phone) : undefined;
if (!result) {
// No result for this phone — the API skipped it (most
// likely because contact.phone was missing). Mark failed.
failedCount++;
await supabase
.from('broadcast_recipients')
@ -256,7 +386,6 @@ export function useBroadcastSending(): UseBroadcastSendingReturn {
}
if (result.status === 'sent') {
sentCount++;
await supabase
.from('broadcast_recipients')
.update({
@ -278,8 +407,6 @@ export function useBroadcastSending(): UseBroadcastSendingReturn {
}
}
} catch (err) {
// Network / 500 — mark the whole batch failed. Trigger
// re-aggregates counts on the parent broadcast.
for (const recipient of batch) {
failedCount++;
await supabase
@ -292,23 +419,18 @@ export function useBroadcastSending(): UseBroadcastSendingReturn {
}
}
// Progress reflects the send phase (30% → 95%).
const progressPct =
30 + Math.round(((i + batch.length) / totalRecipients) * 60);
setProgress(progressPct);
// Space successive batches by 1 s to stay under Meta's rate
// limit. Skip the delay after the last batch so we don't pad
// the tail of the run.
if (i + SEND_BATCH_SIZE < recipients.length) {
await sleep(SEND_BATCH_DELAY_MS);
}
}
// ── Step 5: Finalize broadcast status ─────────────────────────
// ── Step 5: Finalize status ───────────────────────────────────
// Aggregate counts are maintained by the DB trigger (migration
// 003), so we only update `status` here. sent_count etc. are
// already accurate.
// 003); we only flip the final status here.
setProgress(95);
const finalStatus = failedCount === totalRecipients ? 'failed' : 'sent';
await supabase
@ -317,9 +439,6 @@ export function useBroadcastSending(): UseBroadcastSendingReturn {
.eq('id', broadcast.id);
setProgress(100);
// Unused locals intentionally kept for clarity at return site:
void sentCount;
return broadcast.id;
} finally {
setIsProcessing(false);