380 lines
23 KiB
React
380 lines
23 KiB
React
/**
|
||
* AudienceSegments — reusable audiences with consent count, suppression count,
|
||
* export/sync status, refresh, AND an editable segment builder with a live preview
|
||
* count that always applies opt-out + suppression filtering (spec §4, §8, §12 E1/E2).
|
||
* Exports/syncs only permitted, consented, suppression-filtered contacts.
|
||
*/
|
||
import React, { useState, useMemo } from 'react';
|
||
import { createPortal } from 'react-dom';
|
||
import {
|
||
Users, RefreshCw, Download, Upload, ShieldCheck, ShieldX, UserCheck,
|
||
Plus, Pencil, X, Target, MapPin, Lock, ShieldQuestion, Sparkles, SlidersHorizontal,
|
||
CheckCircle2, ChevronDown, CircleDashed,
|
||
} from 'lucide-react';
|
||
import { useSocialAds } from '../SocialAdsContext.jsx';
|
||
import { FILTER_OPTIONS, previewSegment, DEFAULT_FILTER_DEF } from '../data/contactPool.js';
|
||
import { AUDIENCE_SUGGESTIONS } from '../ai/audienceAdvisor.js';
|
||
import { Card, SectionHeader, Btn } from '../components/ui.jsx';
|
||
import { toast } from 'sonner';
|
||
|
||
const SEGMENT_LABEL = {
|
||
storm_geo: 'Storm geo', crm_stage: 'CRM stage', reactivation: 'Reactivation',
|
||
b2b: 'B2B', referral: 'Referral',
|
||
};
|
||
const LAST_JOB_OPTIONS = [['', 'Any time'], ['90', '< 90 days'], ['180', '< 180 days'], ['365', '< 1 year'], ['540', '< 18 months']];
|
||
|
||
export default function AudienceSegments() {
|
||
const { audiences, createAudience, updateAudience, syncAudience, refreshAudience } = useSocialAds();
|
||
const [editing, setEditing] = useState(null); // { audience } edit · { preset } new-from-AI · {} blank new · null closed
|
||
const [dismissed, setDismissed] = useState([]);
|
||
|
||
// Hide a suggestion once a segment with that name exists, or the user dismisses it.
|
||
const existingNames = useMemo(() => new Set(audiences.map(a => a.name)), [audiences]);
|
||
const suggestions = AUDIENCE_SUGGESTIONS.filter(s => !existingNames.has(s.name) && !dismissed.includes(s.key));
|
||
|
||
const exportCsv = (a) => toast.success(`Exported ${(a.eligibleCount ?? a.consentCount).toLocaleString()} eligible contacts from "${a.name}" to CSV`);
|
||
const handleSync = (a, dest) => {
|
||
const tid = toast.loading(`Syncing "${a.name}" to ${dest}…`);
|
||
setTimeout(() => { toast.dismiss(tid); syncAudience(a.id, dest); }, 700);
|
||
};
|
||
|
||
return (
|
||
<div>
|
||
<SectionHeader icon={Users} title="Audience Segments" description="Reusable CRM audiences. Only consented contacts sync, and suppression lists are always applied — including for retargeting.">
|
||
<Btn icon={Plus} onClick={() => setEditing({})}>New segment</Btn>
|
||
</SectionHeader>
|
||
|
||
{/* AI audience suggestions (spec §6) */}
|
||
{suggestions.length > 0 && (
|
||
<div className="rounded-xl border border-purple-200 dark:border-purple-500/20 bg-purple-50/60 dark:bg-purple-500/5 p-3 mb-5">
|
||
<div className="flex items-center gap-2 mb-3">
|
||
<Sparkles size={15} className="text-purple-500 shrink-0" />
|
||
<p className="text-xs text-zinc-600 dark:text-zinc-300">AI suggested segments — consent + suppression always applied; no sensitive-attribute targeting.</p>
|
||
</div>
|
||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
|
||
{suggestions.map(s => (
|
||
<SuggestionCard key={s.key} s={s}
|
||
onCreate={() => createAudience({ name: s.name, segmentType: s.segmentType, filterDef: s.filterDef })}
|
||
onCustomize={() => setEditing({ preset: s })}
|
||
onDismiss={() => setDismissed(d => [...d, s.key])} />
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||
{audiences.map(a => {
|
||
const eligible = a.eligibleCount ?? a.consentCount;
|
||
const eligiblePct = a.totalCount ? Math.round((eligible / a.totalCount) * 100) : 0;
|
||
return (
|
||
<Card key={a.id} className="!h-auto" pad="p-4">
|
||
<div className="flex items-start justify-between mb-2 gap-2">
|
||
<div className="min-w-0">
|
||
<h4 className="font-bold text-sm text-zinc-900 dark:text-white">{a.name}</h4>
|
||
<p className="text-[11px] text-zinc-400">{a.filters}</p>
|
||
</div>
|
||
<span className="text-[10px] font-bold uppercase tracking-wide px-2 py-0.5 rounded-full bg-zinc-100 dark:bg-white/10 text-zinc-500 shrink-0">
|
||
{SEGMENT_LABEL[a.segmentType] || a.segmentType}
|
||
</span>
|
||
</div>
|
||
|
||
{/* Count breakdown */}
|
||
<div className="grid grid-cols-3 gap-2 my-3">
|
||
<Stat icon={Users} label="Total" value={a.totalCount} color="text-zinc-500" />
|
||
<Stat icon={ShieldCheck} label="Consented" value={a.consentCount} color="text-emerald-500" />
|
||
<Stat icon={ShieldX} label="Suppressed" value={a.suppressionCount} color="text-red-500" />
|
||
</div>
|
||
|
||
{/* Eligible bar (consent ∩ not-suppressed) */}
|
||
<div className="mb-3">
|
||
<div className="flex items-center justify-between text-[10px] text-zinc-400 mb-1">
|
||
<span className="flex items-center gap-1"><UserCheck size={11} /> Eligible to sync (consent + suppression)</span>
|
||
<span className="font-mono font-semibold text-emerald-600 dark:text-emerald-400">{eligible.toLocaleString()} · {eligiblePct}%</span>
|
||
</div>
|
||
<div className="h-2 rounded-full bg-zinc-200 dark:bg-zinc-700 overflow-hidden">
|
||
<div className="h-full bg-gradient-to-r from-emerald-400 to-emerald-500" style={{ width: `${eligiblePct}%` }} />
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex items-center justify-between gap-2 mb-2">
|
||
<span className="text-[10px] text-zinc-400">Updated {a.lastCount}{a.frequencyCap ? ` · freq cap ${a.frequencyCap}/wk` : ''}</span>
|
||
<SyncStatusChip audience={a} />
|
||
</div>
|
||
|
||
<div className="flex flex-wrap items-center gap-1.5 pt-3 border-t border-zinc-100 dark:border-white/5">
|
||
<Btn size="sm" variant="ghost" icon={Pencil} onClick={() => setEditing({ audience: a })}>Edit</Btn>
|
||
<Btn size="sm" variant="ghost" icon={RefreshCw} onClick={() => refreshAudience(a.id)}>Refresh</Btn>
|
||
<div className="flex items-center gap-1.5 ml-auto">
|
||
<Btn size="sm" variant="outline" icon={Download} onClick={() => exportCsv(a)}>CSV</Btn>
|
||
<SyncMenu onSync={(dest) => handleSync(a, dest)} />
|
||
</div>
|
||
</div>
|
||
</Card>
|
||
);
|
||
})}
|
||
</div>
|
||
|
||
{editing && (
|
||
<SegmentBuilder
|
||
audience={editing.audience}
|
||
preset={editing.preset}
|
||
onClose={() => setEditing(null)}
|
||
onSave={(def) => {
|
||
if (editing.audience) updateAudience(editing.audience.id, def);
|
||
else createAudience(def);
|
||
setEditing(null);
|
||
}}
|
||
/>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function Stat({ icon: Icon, label, value, color }) {
|
||
return (
|
||
<div className="rounded-lg bg-zinc-50 dark:bg-white/5 p-2 text-center">
|
||
{Icon && <Icon size={14} className={`${color} mx-auto mb-0.5`} />}
|
||
<p className="text-sm font-mono font-bold text-zinc-800 dark:text-zinc-100">{value.toLocaleString()}</p>
|
||
<p className="text-[9px] uppercase tracking-wider text-zinc-400">{label}</p>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ── Sync status + destination menu (spec §8 "export/sync status"; §12 E2) ──────
|
||
const SYNC_DESTINATIONS = ['Meta Custom Audience', 'Google Customer Match', 'LinkedIn Matched Audiences'];
|
||
|
||
function SyncStatusChip({ audience }) {
|
||
if (audience.syncStatus === 'synced') {
|
||
const when = audience.lastSyncedAt
|
||
? new Date(audience.lastSyncedAt).toLocaleDateString(undefined, { month: 'short', day: 'numeric' })
|
||
: '';
|
||
const short = (audience.syncDestination || '').split(' ')[0];
|
||
return (
|
||
<span className="inline-flex items-center gap-1 text-[10px] font-semibold text-emerald-600 dark:text-emerald-400 bg-emerald-500/10 px-2 py-0.5 rounded-full" title={`Synced to ${audience.syncDestination}`}>
|
||
<CheckCircle2 size={11} /> Synced · {short}{when ? ` · ${when}` : ''}
|
||
</span>
|
||
);
|
||
}
|
||
return (
|
||
<span className="inline-flex items-center gap-1 text-[10px] font-semibold text-zinc-400 bg-zinc-100 dark:bg-white/5 px-2 py-0.5 rounded-full">
|
||
<CircleDashed size={11} /> Not synced
|
||
</span>
|
||
);
|
||
}
|
||
|
||
function SyncMenu({ onSync }) {
|
||
const [open, setOpen] = useState(false);
|
||
return (
|
||
<div className="relative">
|
||
<button onClick={() => setOpen(o => !o)}
|
||
className="inline-flex items-center justify-center gap-1 font-semibold rounded-xl transition-all focus:outline-none focus:ring-2 focus:ring-amber-500 px-2.5 py-1.5 text-xs bg-gradient-to-r from-amber-400 to-orange-500 text-white shadow-lg shadow-amber-500/25 hover:shadow-amber-500/40">
|
||
<Upload size={14} /> Sync <ChevronDown size={13} />
|
||
</button>
|
||
{open && (
|
||
<>
|
||
<div className="fixed inset-0 z-10" onClick={() => setOpen(false)} />
|
||
<div className="absolute right-0 mt-1 w-52 rounded-xl border border-zinc-200 dark:border-white/10 bg-white dark:bg-zinc-900 shadow-xl z-20 p-1">
|
||
<p className="px-3 py-1.5 text-[10px] font-bold uppercase tracking-wider text-zinc-400">Sync to platform</p>
|
||
{SYNC_DESTINATIONS.map(d => (
|
||
<button key={d} onClick={() => { onSync(d); setOpen(false); }}
|
||
className="w-full text-left px-3 py-2 rounded-lg text-xs text-zinc-700 dark:text-zinc-200 hover:bg-amber-500/10 hover:text-amber-600 dark:hover:text-amber-400 transition-colors">
|
||
{d}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ── AI suggestion card (spec §6): reasons visible, one-click create, opt-out aware ──
|
||
function SuggestionCard({ s, onCreate, onCustomize, onDismiss }) {
|
||
const SIcon = s.icon;
|
||
const reach = useMemo(() => previewSegment(s.filterDef), [s.filterDef]);
|
||
return (
|
||
<div className="rounded-xl border border-zinc-200 dark:border-white/10 bg-white dark:bg-zinc-900 p-3 flex flex-col">
|
||
<div className="flex items-start justify-between gap-2 mb-1.5">
|
||
<div className="flex items-center gap-2 min-w-0">
|
||
<span className="p-1.5 rounded-lg bg-purple-500/10 text-purple-500 shrink-0">{SIcon && <SIcon size={15} />}</span>
|
||
<div className="min-w-0">
|
||
<h4 className="font-bold text-sm text-zinc-900 dark:text-white truncate">{s.name}</h4>
|
||
<span className="text-[10px] font-bold uppercase tracking-wide text-purple-500">AI · {Math.round(s.confidence * 100)}% match</span>
|
||
</div>
|
||
</div>
|
||
<button onClick={onDismiss} title="Dismiss" className="p-1 text-zinc-400 hover:text-zinc-600 dark:hover:text-zinc-200 shrink-0"><X size={14} /></button>
|
||
</div>
|
||
|
||
<p className="text-[11px] text-zinc-500 dark:text-zinc-400 mb-2">{s.rationale}</p>
|
||
|
||
{/* AI reasons visible (spec §12 E1) */}
|
||
<ul className="space-y-1 mb-2">
|
||
{s.reasons.map((r, i) => (
|
||
<li key={i} className="text-[11px] text-zinc-600 dark:text-zinc-300 flex items-start gap-1.5">
|
||
<span className="text-purple-400 mt-1.5 w-1 h-1 rounded-full bg-purple-400 shrink-0" /> {r}
|
||
</li>
|
||
))}
|
||
</ul>
|
||
|
||
<div className="flex items-center gap-1.5 text-[11px] text-zinc-400 mb-3 mt-auto pt-2 border-t border-zinc-100 dark:border-white/5">
|
||
<UserCheck size={12} className="text-emerald-500" />
|
||
<span className="text-emerald-600 dark:text-emerald-400 font-mono font-semibold">{reach.eligible.toLocaleString()}</span> eligible after consent + suppression
|
||
</div>
|
||
|
||
<div className="flex items-center gap-2">
|
||
<Btn size="sm" variant="primary" icon={Plus} onClick={onCreate}>Create</Btn>
|
||
<Btn size="sm" variant="outline" icon={SlidersHorizontal} onClick={onCustomize}>Customize</Btn>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ── Segment builder modal: filter CRM contacts with a live, opt-out-aware count ──
|
||
function SegmentBuilder({ audience, preset, onClose, onSave }) {
|
||
const init = audience || preset; // edit an existing audience, or seed a new one from an AI suggestion
|
||
const [name, setName] = useState(init?.name || '');
|
||
const [segmentType, setSegmentType] = useState(init?.segmentType || 'crm_stage');
|
||
const [f, setF] = useState(() => ({ ...DEFAULT_FILTER_DEF, ...(init?.filterDef || {}) }));
|
||
|
||
const set = (patch) => setF(prev => ({ ...prev, ...patch }));
|
||
const toggleZip = (z) => set({ geo: f.geo.includes(z) ? f.geo.filter(x => x !== z) : [...f.geo, z] });
|
||
|
||
const preview = useMemo(() => previewSegment(f), [f]);
|
||
const optOuts = preview.matched - preview.consented;
|
||
|
||
return createPortal(
|
||
<div className="fixed inset-0 z-[1000] flex items-center justify-center p-4" onClick={onClose}>
|
||
<div className="absolute inset-0 bg-black/50 backdrop-blur-[1px]" />
|
||
<div onClick={e => e.stopPropagation()} className="relative w-full max-w-2xl max-h-[90vh] overflow-y-auto rounded-2xl bg-white dark:bg-zinc-900 shadow-2xl border border-zinc-200 dark:border-white/10 animate-in zoom-in-95">
|
||
{/* Header */}
|
||
<div className="sticky top-0 z-10 bg-white/95 dark:bg-zinc-900/95 backdrop-blur border-b border-zinc-200 dark:border-white/10 px-5 py-3 flex items-center justify-between gap-3">
|
||
<div className="flex items-center gap-2">
|
||
<Target size={18} className="text-amber-500" />
|
||
<h3 className="font-bold text-sm text-zinc-900 dark:text-white">{audience ? 'Edit segment' : 'New segment'}</h3>
|
||
</div>
|
||
<button onClick={onClose} className="p-1.5 rounded-lg text-zinc-400 hover:text-zinc-700 dark:hover:text-zinc-200 hover:bg-zinc-100 dark:hover:bg-white/10"><X size={18} /></button>
|
||
</div>
|
||
|
||
<div className="p-5 space-y-4">
|
||
{/* Name + type */}
|
||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||
<Field label="Segment name">
|
||
<input value={name} onChange={e => setName(e.target.value)} placeholder="e.g. Plano storm — past 30 days"
|
||
className={inCls} />
|
||
</Field>
|
||
<Field label="Segment type">
|
||
<select value={segmentType} onChange={e => setSegmentType(e.target.value)} className={inCls}>
|
||
{Object.entries(SEGMENT_LABEL).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
|
||
</select>
|
||
</Field>
|
||
</div>
|
||
|
||
{/* CRM filters (spec §12 E1) */}
|
||
<div>
|
||
<p className="text-[10px] font-bold uppercase tracking-wider text-zinc-400 mb-2">Filter CRM contacts</p>
|
||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||
<Field label="Lifecycle stage">
|
||
<select value={f.stage} onChange={e => set({ stage: e.target.value })} className={inCls}>
|
||
<option value="any">Any stage</option>
|
||
{FILTER_OPTIONS.stages.map(s => <option key={s} value={s}>{s}</option>)}
|
||
</select>
|
||
</Field>
|
||
<Field label="Source">
|
||
<select value={f.source} onChange={e => set({ source: e.target.value })} className={inCls}>
|
||
<option value="any">Any source</option>
|
||
{FILTER_OPTIONS.sources.map(s => <option key={s} value={s}>{s}</option>)}
|
||
</select>
|
||
</Field>
|
||
<Field label="Service">
|
||
<select value={f.service} onChange={e => set({ service: e.target.value })} className={inCls}>
|
||
<option value="any">Any service</option>
|
||
{FILTER_OPTIONS.services.map(s => <option key={s} value={s}>{s}</option>)}
|
||
</select>
|
||
</Field>
|
||
<Field label="Last job within">
|
||
<select value={f.lastJobWithinDays || ''} onChange={e => set({ lastJobWithinDays: e.target.value ? Number(e.target.value) : null })} className={inCls}>
|
||
{LAST_JOB_OPTIONS.map(([v, l]) => <option key={v} value={v}>{l}</option>)}
|
||
</select>
|
||
</Field>
|
||
<Field label="Frequency cap (per week)">
|
||
<input type="number" min={0} max={14} value={f.frequencyCap ?? ''} onChange={e => set({ frequencyCap: e.target.value ? Number(e.target.value) : null })} className={inCls} />
|
||
</Field>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Geography */}
|
||
<Field label={<span className="flex items-center gap-1"><MapPin size={11} /> Geography (service-area ZIPs)</span>}>
|
||
<div className="flex flex-wrap gap-1.5">
|
||
{FILTER_OPTIONS.zips.map(z => (
|
||
<button key={z} onClick={() => toggleZip(z)}
|
||
className={`px-2.5 py-1 rounded-lg text-xs font-mono font-semibold transition-colors ${f.geo.includes(z) ? 'bg-amber-500 text-white' : 'bg-zinc-100 dark:bg-white/5 text-zinc-600 dark:text-zinc-300 hover:bg-zinc-200 dark:hover:bg-white/10'}`}>
|
||
{z}
|
||
</button>
|
||
))}
|
||
{f.geo.length > 0 && <button onClick={() => set({ geo: [] })} className="px-2.5 py-1 rounded-lg text-xs text-zinc-400 hover:text-red-500">clear</button>}
|
||
</div>
|
||
</Field>
|
||
|
||
{/* Mandatory consent + suppression (spec §6) */}
|
||
<div className="rounded-xl border border-emerald-200 dark:border-emerald-500/20 bg-emerald-50/60 dark:bg-emerald-500/5 p-3 space-y-1.5">
|
||
<p className="text-[11px] font-semibold text-emerald-700 dark:text-emerald-400 flex items-center gap-1.5"><Lock size={12} /> Always applied (mandatory)</p>
|
||
<p className="text-[11px] text-zinc-600 dark:text-zinc-300 flex items-center gap-1.5"><ShieldCheck size={12} className="text-emerald-500" /> Consent filter — only opted-in contacts are reachable.</p>
|
||
<p className="text-[11px] text-zinc-600 dark:text-zinc-300 flex items-center gap-1.5"><ShieldX size={12} className="text-red-500" /> Suppression list — opt-outs/DNC removed, including for retargeting.</p>
|
||
<p className="text-[11px] text-zinc-500 flex items-start gap-1.5 pt-1 border-t border-emerald-200/50 dark:border-emerald-500/10"><ShieldQuestion size={12} className="text-zinc-400 mt-0.5 shrink-0" /> No sensitive-attribute targeting — only the CRM fields above can be used.</p>
|
||
</div>
|
||
|
||
{/* Live preview (spec §8 / §12 E2: count respects opt-outs + suppressions) */}
|
||
<div className="rounded-xl border border-zinc-200 dark:border-white/10 bg-zinc-50 dark:bg-white/[0.02] p-4">
|
||
<p className="text-[10px] font-bold uppercase tracking-wider text-zinc-400 mb-3">Estimated reach (live)</p>
|
||
<div className="flex items-center justify-between gap-2 flex-wrap">
|
||
<FunnelStep label="Matched" value={preview.matched} />
|
||
<FunnelArrow note={`− ${optOuts.toLocaleString()} no consent`} />
|
||
<FunnelStep label="Consented" value={preview.consented} />
|
||
<FunnelArrow note={`− ${preview.suppressed.toLocaleString()} suppressed`} />
|
||
<FunnelStep label="Eligible" value={preview.eligible} highlight />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Footer */}
|
||
<div className="sticky bottom-0 bg-white/95 dark:bg-zinc-900/95 backdrop-blur border-t border-zinc-200 dark:border-white/10 px-5 py-3 flex justify-end gap-2">
|
||
<Btn variant="ghost" onClick={onClose}>Cancel</Btn>
|
||
<Btn variant="primary" icon={Target} onClick={() => onSave({ name, segmentType, filterDef: f })}>
|
||
{audience ? 'Save segment' : 'Create segment'} · {preview.eligible.toLocaleString()} eligible
|
||
</Btn>
|
||
</div>
|
||
</div>
|
||
</div>,
|
||
document.body,
|
||
);
|
||
}
|
||
|
||
const inCls = 'w-full rounded-lg border border-zinc-200 dark:border-white/10 bg-white dark:bg-zinc-900 px-2.5 py-2 text-sm text-zinc-800 dark:text-zinc-100 focus:outline-none focus:ring-2 focus:ring-amber-500';
|
||
|
||
function Field({ label, children }) {
|
||
return (
|
||
<label className="block">
|
||
<span className="text-[11px] font-semibold text-zinc-500 dark:text-zinc-400 mb-1 block">{label}</span>
|
||
{children}
|
||
</label>
|
||
);
|
||
}
|
||
|
||
function FunnelStep({ label, value, highlight }) {
|
||
return (
|
||
<div className="text-center">
|
||
<p className={`text-xl font-mono font-bold ${highlight ? 'text-emerald-600 dark:text-emerald-400' : 'text-zinc-800 dark:text-zinc-100'}`}>{value.toLocaleString()}</p>
|
||
<p className="text-[10px] uppercase tracking-wider text-zinc-400">{label}</p>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function FunnelArrow({ note }) {
|
||
return (
|
||
<div className="flex flex-col items-center text-zinc-400 min-w-0">
|
||
<span className="text-lg leading-none">→</span>
|
||
<span className="text-[9px] text-red-400 whitespace-nowrap">{note}</span>
|
||
</div>
|
||
);
|
||
}
|