Files
LynkedUpPro_CRM/src/modules/socialAdsEngine/screens/AudienceSegments.jsx
T
2026-06-12 16:43:12 +05:30

380 lines
23 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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>
);
}