/**
* 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 (
{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) */}
{/* 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) */}
{/* 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}
);
}