/** * 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 (
setEditing({})}>New segment {/* AI audience suggestions (spec §6) */} {suggestions.length > 0 && (

AI suggested segments — consent + suppression always applied; no sensitive-attribute targeting.

{suggestions.map(s => ( createAudience({ name: s.name, segmentType: s.segmentType, filterDef: s.filterDef })} onCustomize={() => setEditing({ preset: s })} onDismiss={() => setDismissed(d => [...d, s.key])} /> ))}
)}
{audiences.map(a => { const eligible = a.eligibleCount ?? a.consentCount; const eligiblePct = a.totalCount ? Math.round((eligible / a.totalCount) * 100) : 0; return (

{a.name}

{a.filters}

{SEGMENT_LABEL[a.segmentType] || a.segmentType}
{/* Count breakdown */}
{/* Eligible bar (consent ∩ not-suppressed) */}
Eligible to sync (consent + suppression) {eligible.toLocaleString()} · {eligiblePct}%
Updated {a.lastCount}{a.frequencyCap ? ` · freq cap ${a.frequencyCap}/wk` : ''}
setEditing({ audience: a })}>Edit refreshAudience(a.id)}>Refresh
exportCsv(a)}>CSV handleSync(a, dest)} />
); })}
{editing && ( setEditing(null)} onSave={(def) => { if (editing.audience) updateAudience(editing.audience.id, def); else createAudience(def); setEditing(null); }} /> )}
); } function Stat({ icon: Icon, label, value, color }) { return (
{Icon && }

{value.toLocaleString()}

{label}

); } // ── 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 ( Synced · {short}{when ? ` · ${when}` : ''} ); } return ( Not synced ); } function SyncMenu({ onSync }) { const [open, setOpen] = useState(false); return (
{open && ( <>
setOpen(false)} />

Sync to platform

{SYNC_DESTINATIONS.map(d => ( ))}
)}
); } // ── 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 (
{SIcon && }

{s.name}

AI · {Math.round(s.confidence * 100)}% match

{s.rationale}

{/* AI reasons visible (spec §12 E1) */}
    {s.reasons.map((r, i) => (
  • {r}
  • ))}
{reach.eligible.toLocaleString()} eligible after consent + suppression
Create Customize
); } // ── 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(
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 */}

{audience ? 'Edit segment' : 'New segment'}

{/* Name + type */}
setName(e.target.value)} placeholder="e.g. Plano storm — past 30 days" className={inCls} />
{/* CRM filters (spec §12 E1) */}

Filter CRM contacts

set({ frequencyCap: e.target.value ? Number(e.target.value) : null })} className={inCls} />
{/* Geography */} Geography (service-area ZIPs)}>
{FILTER_OPTIONS.zips.map(z => ( ))} {f.geo.length > 0 && }
{/* Mandatory consent + suppression (spec §6) */}

Always applied (mandatory)

Consent filter — only opted-in contacts are reachable.

Suppression list — opt-outs/DNC removed, including for retargeting.

No sensitive-attribute targeting — only the CRM fields above can be used.

{/* Live preview (spec §8 / §12 E2: count respects opt-outs + suppressions) */}

Estimated reach (live)

{/* Footer */}
Cancel onSave({ name, segmentType, filterDef: f })}> {audience ? 'Save segment' : 'Create segment'} · {preview.eligible.toLocaleString()} eligible
, 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 ( ); } function FunnelStep({ label, value, highlight }) { return (

{value.toLocaleString()}

{label}

); } function FunnelArrow({ note }) { return (
{note}
); }