social ads engine module screens
This commit is contained in:
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* ComplianceCenter — pending creative/consent/campaign approvals, blocked claims,
|
||||
* review-disclosure checks, and an AI audit + conversion-sync log (spec §8, §10.1).
|
||||
* An unapproved campaign cannot publish and a risky message cannot send.
|
||||
*/
|
||||
import React from 'react';
|
||||
import {
|
||||
ShieldCheck, AlertTriangle, Ban, CheckCircle2, Clock, RefreshCw,
|
||||
FileWarning, Wand2, ArrowUpRight,
|
||||
} from 'lucide-react';
|
||||
import { useSocialAds } from '../SocialAdsContext.jsx';
|
||||
import { PlatformBadge, Card, SectionHeader, StatusChip, Btn } from '../components/ui.jsx';
|
||||
|
||||
const TYPE_LABEL = {
|
||||
creative: 'Creative', consent_text: 'Consent text',
|
||||
review_disclosure: 'Review disclosure', campaign: 'Campaign publish',
|
||||
};
|
||||
|
||||
const SYNC_ICON = {
|
||||
synced: { icon: CheckCircle2, color: 'text-emerald-500' },
|
||||
queued: { icon: Clock, color: 'text-amber-500' },
|
||||
failed: { icon: AlertTriangle, color: 'text-red-500' },
|
||||
};
|
||||
|
||||
export default function ComplianceCenter() {
|
||||
const { approvals, resolveApproval, conversionEvents, retryConversion } = useSocialAds();
|
||||
|
||||
const pending = approvals.filter(a => a.status === 'pending');
|
||||
const blocked = approvals.filter(a => a.status === 'blocked');
|
||||
const resolved = approvals.filter(a => a.status === 'approved');
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<SectionHeader icon={ShieldCheck} title="Compliance Center" description="Approval workflow, blocked claims, consent versions, and the AI / conversion audit log. Nothing risky publishes or sends until resolved.">
|
||||
<div className="flex items-center gap-2">
|
||||
<StatusChip status="pending" label={`${pending.length} pending`} />
|
||||
{blocked.length > 0 && <StatusChip status="blocked" label={`${blocked.length} blocked`} />}
|
||||
</div>
|
||||
</SectionHeader>
|
||||
|
||||
{/* Blocked first — must resolve */}
|
||||
{blocked.length > 0 && (
|
||||
<Card title="Blocked — resolve before publish" icon={Ban} subtitle="Risky claims detected by the compliance reviewer.">
|
||||
<div className="space-y-2">
|
||||
{blocked.map(a => <ApprovalRow key={a.id} item={a} onResolve={resolveApproval} />)}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Pending queue */}
|
||||
<Card title="Approval queue" icon={Clock} subtitle="Creative, consent text, review disclosure, and campaign publish approvals.">
|
||||
{pending.length === 0
|
||||
? <p className="text-sm text-zinc-500 py-4 text-center">Nothing pending — all clear.</p>
|
||||
: <div className="space-y-2">{pending.map(a => <ApprovalRow key={a.id} item={a} onResolve={resolveApproval} />)}</div>}
|
||||
</Card>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
{/* Conversion sync log */}
|
||||
<Card title="Conversion sync log" icon={ArrowUpRight} subtitle="Events sent back to ad platforms (deduped).">
|
||||
<div className="space-y-1.5">
|
||||
{conversionEvents.map(e => {
|
||||
const meta = SYNC_ICON[e.syncStatus];
|
||||
const Icon = meta.icon;
|
||||
return (
|
||||
<div key={e.id} className="flex items-center gap-2 px-2.5 py-2 rounded-lg bg-zinc-50 dark:bg-white/5">
|
||||
<Icon size={14} className={meta.color} />
|
||||
<PlatformBadge platformId={e.platform} size="sm" showName={false} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-xs font-semibold text-zinc-700 dark:text-zinc-200 truncate">{e.eventName}</p>
|
||||
<p className="text-[10px] text-zinc-400 font-mono truncate">dedup: {e.dedupKey}</p>
|
||||
</div>
|
||||
{e.value > 0 && <span className="text-[11px] font-mono text-emerald-600 dark:text-emerald-400">${e.value.toLocaleString()}</span>}
|
||||
{e.syncStatus === 'failed'
|
||||
? <Btn size="sm" variant="danger" icon={RefreshCw} onClick={() => retryConversion(e.id)}>Retry</Btn>
|
||||
: <StatusChip status={e.syncStatus} />}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Recently resolved + AI audit */}
|
||||
<Card title="AI audit & recent approvals" icon={Wand2} subtitle="Human corrections and resolved items.">
|
||||
<div className="space-y-1.5">
|
||||
{resolved.map(a => (
|
||||
<div key={a.id} className="flex items-center gap-2 px-2.5 py-2 rounded-lg bg-zinc-50 dark:bg-white/5">
|
||||
<CheckCircle2 size={14} className="text-emerald-500" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-xs font-semibold text-zinc-700 dark:text-zinc-200 truncate">{a.title}</p>
|
||||
<p className="text-[10px] text-zinc-400">{TYPE_LABEL[a.type]} · approved by {a.reviewer}</p>
|
||||
</div>
|
||||
<StatusChip status="approved" />
|
||||
</div>
|
||||
))}
|
||||
<div className="flex items-start gap-2 px-2.5 py-2 rounded-lg bg-purple-50 dark:bg-purple-500/10 mt-2">
|
||||
<Wand2 size={14} className="text-purple-500 mt-0.5" />
|
||||
<p className="text-[11px] text-zinc-600 dark:text-zinc-300">
|
||||
AI guardrail summary: <b>2</b> creatives auto-flagged this week (1 scarcity, 1 price claim), <b>0</b> auto-published. All AI copy required human approval.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ApprovalRow({ item, onResolve }) {
|
||||
const isBlocked = item.status === 'blocked';
|
||||
return (
|
||||
<div className={`flex flex-col sm:flex-row sm:items-center gap-3 p-3 rounded-xl ${isBlocked ? 'bg-red-50 dark:bg-red-500/10 border border-red-200 dark:border-red-500/20' : 'bg-zinc-50 dark:bg-white/5'}`}>
|
||||
<div className="flex items-start gap-2 min-w-0 flex-1">
|
||||
<FileWarning size={16} className={isBlocked ? 'text-red-500 mt-0.5' : 'text-amber-500 mt-0.5'} />
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-[10px] font-bold uppercase tracking-wide px-1.5 py-0.5 rounded bg-zinc-200 dark:bg-white/10 text-zinc-500">{TYPE_LABEL[item.type]}</span>
|
||||
<h4 className="font-semibold text-sm text-zinc-800 dark:text-zinc-100">{item.title}</h4>
|
||||
</div>
|
||||
{item.flags?.length > 0 && (
|
||||
<ul className="mt-1 space-y-0.5">
|
||||
{item.flags.map((f, i) => <li key={i} className="text-[11px] text-amber-600 dark:text-amber-400/80 flex items-start gap-1"><AlertTriangle size={11} className="mt-0.5 shrink-0" />{f}</li>)}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<Btn size="sm" variant="success" icon={CheckCircle2} disabled={isBlocked} onClick={() => onResolve(item.id, 'approved')}>Approve</Btn>
|
||||
<Btn size="sm" variant="danger" icon={Ban} onClick={() => onResolve(item.id, 'blocked')}>{isBlocked ? 'Keep blocked' : 'Block'}</Btn>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user