mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 17:29:45 +00:00
Merge pull request #23 from ArnasDon/feat/broadcasts-audience-c
feat(broadcasts) C: custom-field audience, exclude tags, live preview
This commit is contained in:
commit
5dbb26e4fc
@ -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,
|
||||
});
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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
|
||||
): string[] {
|
||||
// Keys typically are "1","2",... so a numeric-aware sort keeps {{1}}
|
||||
// before {{10}}.
|
||||
/**
|
||||
* Per-contact resolution of custom-field placeholders. Static and
|
||||
* built-in-field mappings resolve synchronously; custom fields read
|
||||
* from a pre-built index to avoid N+1 queries during the send loop.
|
||||
*/
|
||||
export function resolveVariables(
|
||||
variables: Record<string, VariableMapping>,
|
||||
contact: Contact,
|
||||
customValues?: Map<string, string>,
|
||||
): string[] {
|
||||
// Keys are typically "1","2",... — numeric-aware sort keeps
|
||||
// {{1}} before {{10}}.
|
||||
const keys = Object.keys(variables).sort((a, b) => {
|
||||
const an = Number(a);
|
||||
const bn = Number(b);
|
||||
@ -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,7 +102,144 @@ 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> {
|
||||
@ -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);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user