mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 09:19:51 +00:00
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:
parent
244d04ca93
commit
296a905976
@ -28,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('');
|
||||||
|
|
||||||
@ -43,9 +49,11 @@ 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,
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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,33 +39,90 @@ 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 {
|
const supabase = createClient();
|
||||||
const supabase = createClient();
|
const { data } = await supabase.from('tags').select('*').order('name');
|
||||||
const { data } = await supabase.from('tags').select('*').order('name');
|
setTags(data ?? []);
|
||||||
setTags(data ?? []);
|
} finally {
|
||||||
} finally {
|
setLoadingTags(false);
|
||||||
setLoadingTags(false);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
fetchTags();
|
|
||||||
}
|
}
|
||||||
|
fetchTags();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Lazy-load custom fields only when that audience type is active.
|
||||||
|
useEffect(() => {
|
||||||
|
if (audience.type !== 'custom_field') return;
|
||||||
|
async function fetchFields() {
|
||||||
|
setLoadingFields(true);
|
||||||
|
try {
|
||||||
|
const supabase = createClient();
|
||||||
|
const { data } = await supabase
|
||||||
|
.from('custom_fields')
|
||||||
|
.select('*')
|
||||||
|
.order('field_name');
|
||||||
|
setCustomFields(data ?? []);
|
||||||
|
} finally {
|
||||||
|
setLoadingFields(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fetchFields();
|
||||||
}, [audience.type]);
|
}, [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();
|
}, [fetchEstimatedCount]);
|
||||||
} else {
|
|
||||||
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>
|
||||||
<div className="rounded-lg bg-slate-800/50 p-3">
|
<div className="rounded-lg bg-[#0e1a12] p-3">
|
||||||
<p className="whitespace-pre-wrap text-sm text-slate-200">{previewText}</p>
|
<div className="ml-auto max-w-[85%] rounded-lg bg-emerald-700/30 px-3 py-2 shadow-sm">
|
||||||
|
<p className="whitespace-pre-wrap text-sm text-emerald-50">
|
||||||
|
{previewText}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
||||||
|
|||||||
@ -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,6 +68,77 @@ interface BroadcastApiResult {
|
|||||||
error?: string;
|
error?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** contactId → (customFieldId → value). */
|
||||||
|
type CustomValueIndex = Map<string, Map<string, string>>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-contact resolution of custom-field placeholders. Static and
|
||||||
|
* built-in-field mappings resolve synchronously; custom fields read
|
||||||
|
* from a pre-built index to avoid N+1 queries during the send loop.
|
||||||
|
*/
|
||||||
|
export function resolveVariables(
|
||||||
|
variables: Record<string, VariableMapping>,
|
||||||
|
contact: Contact,
|
||||||
|
customValues?: Map<string, string>,
|
||||||
|
): string[] {
|
||||||
|
// Keys are typically "1","2",... — numeric-aware sort keeps
|
||||||
|
// {{1}} before {{10}}.
|
||||||
|
const keys = Object.keys(variables).sort((a, b) => {
|
||||||
|
const an = Number(a);
|
||||||
|
const bn = Number(b);
|
||||||
|
if (Number.isFinite(an) && Number.isFinite(bn)) return an - bn;
|
||||||
|
return a.localeCompare(b);
|
||||||
|
});
|
||||||
|
|
||||||
|
return keys.map((key) => {
|
||||||
|
const v = variables[key];
|
||||||
|
if (v.type === 'static') return v.value;
|
||||||
|
|
||||||
|
if (v.type === 'field') {
|
||||||
|
const fieldMap: Record<string, string | undefined> = {
|
||||||
|
name: contact.name,
|
||||||
|
phone: contact.phone,
|
||||||
|
email: contact.email,
|
||||||
|
company: contact.company,
|
||||||
|
};
|
||||||
|
return fieldMap[v.value] ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// custom_field
|
||||||
|
return customValues?.get(v.value) ?? '';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bulk-fetch contact_custom_values for a set of contacts. Returns an
|
||||||
|
* index keyed by contact_id → field_id → value.
|
||||||
|
*/
|
||||||
|
async function fetchCustomValueIndex(
|
||||||
|
supabase: ReturnType<typeof createClient>,
|
||||||
|
contactIds: string[],
|
||||||
|
): Promise<CustomValueIndex> {
|
||||||
|
const index: CustomValueIndex = new Map();
|
||||||
|
if (contactIds.length === 0) return index;
|
||||||
|
|
||||||
|
// Supabase PostgREST caps the .in(...) IN-clause roughly at 1000
|
||||||
|
// values. Page through to stay safe.
|
||||||
|
const PAGE = 500;
|
||||||
|
for (let i = 0; i < contactIds.length; i += PAGE) {
|
||||||
|
const slice = contactIds.slice(i, i + PAGE);
|
||||||
|
const { data } = await supabase
|
||||||
|
.from('contact_custom_values')
|
||||||
|
.select('contact_id, custom_field_id, value')
|
||||||
|
.in('contact_id', slice);
|
||||||
|
|
||||||
|
for (const row of data ?? []) {
|
||||||
|
const bucket = index.get(row.contact_id) ?? new Map<string, string>();
|
||||||
|
bucket.set(row.custom_field_id, row.value ?? '');
|
||||||
|
index.set(row.contact_id, bucket);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return index;
|
||||||
|
}
|
||||||
|
|
||||||
export function useBroadcastSending(): UseBroadcastSendingReturn {
|
export function useBroadcastSending(): UseBroadcastSendingReturn {
|
||||||
const [isProcessing, setIsProcessing] = useState(false);
|
const [isProcessing, setIsProcessing] = useState(false);
|
||||||
const [progress, setProgress] = useState(0);
|
const [progress, setProgress] = useState(0);
|
||||||
@ -55,33 +146,45 @@ export function useBroadcastSending(): UseBroadcastSendingReturn {
|
|||||||
async function resolveAudience(audience: AudienceConfig): Promise<Contact[]> {
|
async function resolveAudience(audience: AudienceConfig): Promise<Contact[]> {
|
||||||
const supabase = createClient();
|
const supabase = createClient();
|
||||||
|
|
||||||
|
let contacts: Contact[] = [];
|
||||||
|
|
||||||
if (audience.type === 'all') {
|
if (audience.type === 'all') {
|
||||||
const { data, error } = await supabase.from('contacts').select('*');
|
const { data, error } = await supabase.from('contacts').select('*');
|
||||||
if (error) throw new Error(`Failed to fetch contacts: ${error.message}`);
|
if (error) throw new Error(`Failed to fetch contacts: ${error.message}`);
|
||||||
return data ?? [];
|
contacts = data ?? [];
|
||||||
}
|
} else if (
|
||||||
|
audience.type === 'tags' &&
|
||||||
if (audience.type === 'tags' && audience.tagIds && audience.tagIds.length > 0) {
|
audience.tagIds &&
|
||||||
|
audience.tagIds.length > 0
|
||||||
|
) {
|
||||||
const { data: contactTags, error: tagError } = await supabase
|
const { data: contactTags, error: tagError } = await supabase
|
||||||
.from('contact_tags')
|
.from('contact_tags')
|
||||||
.select('contact_id')
|
.select('contact_id')
|
||||||
.in('tag_id', audience.tagIds);
|
.in('tag_id', audience.tagIds);
|
||||||
|
|
||||||
if (tagError) throw new Error(`Failed to fetch contact tags: ${tagError.message}`);
|
if (tagError)
|
||||||
if (!contactTags || contactTags.length === 0) return [];
|
throw new Error(`Failed to fetch contact tags: ${tagError.message}`);
|
||||||
|
|
||||||
const uniqueContactIds = [...new Set(contactTags.map((ct) => ct.contact_id))];
|
if (contactTags && contactTags.length > 0) {
|
||||||
|
const uniqueContactIds = [
|
||||||
const { data: contacts, error: contactError } = await supabase
|
...new Set(contactTags.map((ct) => ct.contact_id)),
|
||||||
.from('contacts')
|
];
|
||||||
.select('*')
|
const { data, error } = await supabase
|
||||||
.in('id', uniqueContactIds);
|
.from('contacts')
|
||||||
|
.select('*')
|
||||||
if (contactError) throw new Error(`Failed to fetch contacts: ${contactError.message}`);
|
.in('id', uniqueContactIds);
|
||||||
return contacts ?? [];
|
if (error) throw new Error(`Failed to fetch contacts: ${error.message}`);
|
||||||
}
|
contacts = data ?? [];
|
||||||
|
}
|
||||||
if (audience.type === 'csv' && audience.csvContacts) {
|
} 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) => ({
|
return audience.csvContacts.map((c, i) => ({
|
||||||
id: `csv-${i}`,
|
id: `csv-${i}`,
|
||||||
user_id: '',
|
user_id: '',
|
||||||
@ -92,33 +195,51 @@ export function useBroadcastSending(): UseBroadcastSendingReturn {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
return [];
|
// Apply exclude tags (works across all contact-derived audience
|
||||||
|
// types). CSV contacts are synthetic so exclusion doesn't apply.
|
||||||
|
if (audience.excludeTagIds && audience.excludeTagIds.length > 0) {
|
||||||
|
const { data: excludeRows } = await supabase
|
||||||
|
.from('contact_tags')
|
||||||
|
.select('contact_id')
|
||||||
|
.in('tag_id', audience.excludeTagIds);
|
||||||
|
const excludedIds = new Set((excludeRows ?? []).map((r) => r.contact_id));
|
||||||
|
contacts = contacts.filter((c) => !excludedIds.has(c.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
return contacts;
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveVariables(
|
async function resolveCustomFieldAudience(
|
||||||
variables: Record<string, { type: 'static' | 'field'; value: string }>,
|
supabase: ReturnType<typeof createClient>,
|
||||||
contact: Contact
|
filter: CustomFieldFilter,
|
||||||
): string[] {
|
): Promise<Contact[]> {
|
||||||
// Keys typically are "1","2",... so a numeric-aware sort keeps {{1}}
|
const { fieldId, operator, value } = filter;
|
||||||
// before {{10}}.
|
|
||||||
const keys = Object.keys(variables).sort((a, b) => {
|
|
||||||
const an = Number(a);
|
|
||||||
const bn = Number(b);
|
|
||||||
if (Number.isFinite(an) && Number.isFinite(bn)) return an - bn;
|
|
||||||
return a.localeCompare(b);
|
|
||||||
});
|
|
||||||
|
|
||||||
return keys.map((key) => {
|
// Build the WHERE clause for the operator. PostgREST supports
|
||||||
const v = variables[key];
|
// eq/neq/ilike via the query builder — use ilike with wildcards
|
||||||
if (v.type === 'static') return v.value;
|
// for "contains" so the match is case-insensitive.
|
||||||
const fieldMap: Record<string, string | undefined> = {
|
let query = supabase
|
||||||
name: contact.name,
|
.from('contact_custom_values')
|
||||||
phone: contact.phone,
|
.select('contact_id')
|
||||||
email: contact.email,
|
.eq('custom_field_id', fieldId);
|
||||||
company: contact.company,
|
|
||||||
};
|
if (operator === 'is') query = query.eq('value', value);
|
||||||
return fieldMap[v.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> {
|
||||||
@ -148,6 +269,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,
|
||||||
@ -164,7 +287,7 @@ export function useBroadcastSending(): UseBroadcastSendingReturn {
|
|||||||
throw new Error(`Failed to create broadcast: ${broadcastError?.message}`);
|
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);
|
setProgress(20);
|
||||||
const recipientRows = contacts.map((contact) => ({
|
const recipientRows = contacts.map((contact) => ({
|
||||||
broadcast_id: broadcast.id,
|
broadcast_id: broadcast.id,
|
||||||
@ -177,13 +300,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 +316,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 +364,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 +374,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 +386,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 +407,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 +419,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 +439,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