social ads engine module: develo all features

This commit is contained in:
Mayur Shinde
2026-06-12 16:43:12 +05:30
parent e53c38ce59
commit a2c77878ff
11 changed files with 2230 additions and 80 deletions
@@ -1,11 +1,19 @@
/**
* AudienceSegments — reusable audiences with consent count, suppression count,
* export/sync status, and refresh (spec §4, §8). Exports/syncs only permitted,
* consented, suppression-filtered contacts — the count math makes that visible.
* 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 from 'react';
import { Users, RefreshCw, Download, Upload, ShieldCheck, ShieldX, UserCheck } from 'lucide-react';
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';
@@ -13,24 +21,51 @@ 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, refreshAudience } = useSocialAds();
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([]);
const exportCsv = (a) => toast.success(`Exported ${a.consentCount.toLocaleString()} consented contacts from "${a.name}" to CSV`);
const syncAudience = (a) => toast.promise(new Promise(r => setTimeout(r, 800)), {
loading: `Syncing "${a.name}" to platform…`,
success: `Synced ${a.consentCount.toLocaleString()} contacts (suppression list applied)`,
error: 'Sync failed',
});
// 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." />
<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 eligiblePct = Math.round((a.consentCount / a.totalCount) * 100);
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">
@@ -50,29 +85,47 @@ export default function AudienceSegments() {
<Stat icon={ShieldX} label="Suppressed" value={a.suppressionCount} color="text-red-500" />
</div>
{/* Eligible bar */}
{/* 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</span>
<span className="font-mono font-semibold">{eligiblePct}%</span>
<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 flex-wrap items-center justify-between gap-2 pt-3 border-t border-zinc-100 dark:border-white/5">
<span className="text-[10px] text-zinc-400">Updated {a.lastCount}</span>
<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="ghost" icon={RefreshCw} onClick={() => refreshAudience(a.id)}>Refresh</Btn>
<Btn size="sm" variant="outline" icon={Download} onClick={() => exportCsv(a)}>CSV</Btn>
<Btn size="sm" variant="primary" icon={Upload} onClick={() => syncAudience(a)}>Sync</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>
);
}
@@ -80,9 +133,247 @@ export default function AudienceSegments() {
function Stat({ icon: Icon, label, value, color }) {
return (
<div className="rounded-lg bg-zinc-50 dark:bg-white/5 p-2 text-center">
<Icon size={14} className={`${color} mx-auto mb-0.5`} />
{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>
);
}