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
@@ -18,11 +18,17 @@ import {
leads as seedLeads,
nurtureRules as seedRules,
audiences as seedAudiences,
conversations as seedConversations,
reviews as seedReviews,
journeys as seedJourneys,
anomalies as seedAnomalies,
consentVersions as seedConsentVersions,
conversionEvents as seedConversions,
approvals as seedApprovals,
analyticsDaily as seedAnalytics,
} from './data/seed.js';
import { getPublicLeads, subscribeToPublicLeads } from './data/sessionStore.js';
import { previewSegment, summarizeFilters } from './data/contactPool.js';
const SocialAdsContext = createContext(null);
@@ -67,6 +73,11 @@ export function SocialAdsProvider({ children }) {
const [leads, setLeads] = useState(initLeads);
const [nurtureRules, setNurtureRules] = useState(seedRules);
const [audiences, setAudiences] = useState(seedAudiences);
const [conversations, setConversations] = useState(seedConversations);
const [reviews, setReviews] = useState(seedReviews);
const [journeys] = useState(seedJourneys);
const [anomalies, setAnomalies] = useState(seedAnomalies);
const [consentVersions, setConsentVersions] = useState(seedConsentVersions);
const [conversionEvents, setConversionEvents] = useState(seedConversions);
const [approvals, setApprovals] = useState(seedApprovals);
const [analyticsDaily] = useState(seedAnalytics);
@@ -97,6 +108,22 @@ export function SocialAdsProvider({ children }) {
}));
}, []);
// Reconnect / refresh a platform token (spec §11 token-expiration recovery).
// Restores a healthy token, re-verifies the webhook, and re-grants default scopes
// if the prior grant was revoked/empty.
const reconnectAccount = useCallback((id) => {
setAccounts(prev => prev.map(a => {
if (a.id !== id) return a;
const expiry = new Date(Date.now() + 90 * 864e5).toISOString().slice(0, 10);
return {
...a, status: 'connected', tokenStatus: 'healthy', webhookStatus: 'verified',
lastSyncAt: nowIso(), tokenExpiresAt: expiry,
scopes: a.scopes?.length ? a.scopes : ['ads_read', 'leads_retrieval'],
};
}));
toast.success('Reconnected — token refreshed and permissions re-granted');
}, []);
const testWebhook = useCallback((id) => {
const acc = accounts.find(a => a.id === id);
if (!acc) return;
@@ -308,6 +335,80 @@ export function SocialAdsProvider({ children }) {
setLeads(prev => prev.map(l => l.id === id ? { ...l, status } : l));
}, []);
// Manual import fallback (spec §11): when a platform webhook/token is down, leads
// can be uploaded by CSV instead of being stranded. Imported leads land in the
// Unified Lead Inbox; consent must be explicit per row or it's manual-only.
const importLeads = useCallback((platform, rows) => {
const base = nextId('lead');
const newLeads = rows.map((r, i) => ({
id: `${base}_${i}`, platform, campaignId: null, formId: null,
externalLeadId: `manual_${base}_${i}`, receivedAt: nowIso(), status: 'new', stage: 'lead',
name: r.name || 'Imported lead', phone: r.phone || '', email: r.email || '', address: r.address || '',
consent: /^(y|yes|true|1)$/i.test(r.consent || ''), role: 'homeowner', activeLeak: false,
triage: {
serviceType: r.service || 'replacement', urgency: 'medium', role: 'homeowner',
intent: 'inquiry', quality: 60, confidence: 0.7,
},
duplicateOf: null, assignedTo: null, dealValue: 0, premium: false,
firstContactSec: null, slaStartTs: Date.now(), manualImport: true,
}));
setLeads(prev => [...newLeads, ...prev]);
toast.success(`Imported ${newLeads.length} lead${newLeads.length > 1 ? 's' : ''} — now in the Lead Inbox`);
return newLeads.length;
}, []);
// Low-confidence AI triage (spec §5): instead of auto-nurturing on a shaky
// classification, the admin either creates a rep review task or sends one
// clarifying question. Either holds the lead from auto-nurture (reviewAction flag).
const flagLeadReview = useCallback((id, mode) => {
setLeads(prev => prev.map(l => l.id === id ? { ...l, reviewAction: mode } : l));
toast.success(mode === 'question'
? 'Sent 1 clarifying question — lead held from auto-nurture until answered'
: 'Review task created for a rep — lead held from auto-nurture');
}, []);
// ── Conversations / Comment-DM triage (spec §4, §6) ────────────────────────
// Route a triaged comment/DM to the right human/queue. Criticism is never
// auto-handled — complaints route to a person (spec §6).
const routeConversation = useCallback((id, routedTo) => {
setConversations(prev => prev.map(c => c.id === id ? { ...c, status: 'routed', routedTo } : c));
toast.success(`Routed to ${routedTo}`);
}, []);
// Promote a lead-intent comment/DM into the Unified Lead Inbox. A social comment
// carries no form consent, so the lead is consent=false → manual follow-up only.
const convertConversationToLead = useCallback((id) => {
const cv = conversations.find(c => c.id === id);
if (!cv) return;
const lead = {
id: nextId('lead'), platform: cv.platform, campaignId: null, formId: null,
externalLeadId: cv.id, receivedAt: nowIso(), status: 'new', stage: 'lead',
name: cv.author, phone: '', email: '', address: '',
consent: false, role: 'homeowner', activeLeak: /leak/i.test(cv.text),
triage: {
serviceType: /commercial|flat roof|propert/i.test(cv.text) ? 'commercial' : /leak|emergency/i.test(cv.text) ? 'emergency' : 'replacement',
urgency: /leak|emergency|asap|urgent/i.test(cv.text) ? 'high' : 'medium',
role: /propert|facilit|manage/i.test(cv.text) ? 'property_manager' : 'homeowner',
intent: 'inquiry', quality: Math.round((cv.classification.confidence || 0.7) * 80), confidence: cv.classification.confidence || 0.7,
},
duplicateOf: null, assignedTo: null, dealValue: 0, premium: false,
firstContactSec: null, slaStartTs: Date.now(), fromConversation: true,
};
setLeads(prev => [lead, ...prev]);
setConversations(prev => prev.map(c => c.id === id ? { ...c, status: 'converted' } : c));
toast.success('Converted to lead — now in the Unified Lead Inbox (manual follow-up: no form consent)');
}, [conversations]);
const dismissConversation = useCallback((id) => {
setConversations(prev => prev.map(c => c.id === id ? { ...c, status: 'dismissed' } : c));
toast.message('Marked as spam and dismissed');
}, []);
const replyConversation = useCallback((id) => {
setConversations(prev => prev.map(c => c.id === id && c.status === 'new' ? { ...c, status: 'routed', routedTo: 'Replied (approved template)' } : c));
toast.success('Reply sent using an approved template');
}, []);
// ── Ad-to-Nurture rules ───────────────────────────────────────────────────
const toggleRule = useCallback((id) => {
setNurtureRules(prev => prev.map(r => r.id === id ? { ...r, enabled: !r.enabled } : r));
@@ -367,7 +468,46 @@ export function SocialAdsProvider({ children }) {
});
}, []);
// ── Audiences ─────────────────────────────────────────────────────────────
// ── Audiences (segment builder, spec §12 E1/E2 + §8 preview count) ─────────
// Counts are computed from the mock CRM pool through previewSegment, which always
// applies consent + suppression — so the stored figures honor opt-outs (spec §6).
const buildAudience = (def, id) => {
const p = previewSegment(def.filterDef);
return {
id: id || nextId('aud'),
name: def.name?.trim() || 'New segment',
segmentType: def.segmentType || 'crm_stage',
filters: summarizeFilters(def.filterDef),
filterDef: def.filterDef,
totalCount: p.matched, consentCount: p.consented,
suppressionCount: p.suppressed, eligibleCount: p.eligible,
frequencyCap: def.filterDef.frequencyCap || null,
lastCount: nowIso().slice(0, 10),
syncStatus: 'draft', lastSyncedAt: null, syncDestination: null,
};
};
const createAudience = useCallback((def) => {
const a = buildAudience(def);
setAudiences(prev => [a, ...prev]);
toast.success(`Created “${a.name}” — ${a.eligibleCount.toLocaleString()} eligible after consent + suppression`);
return a;
}, []);
const updateAudience = useCallback((id, def) => {
setAudiences(prev => prev.map(a => a.id === id ? { ...a, ...buildAudience(def, id) } : a));
toast.success('Segment updated');
}, []);
// Persist the platform sync result (spec §8 "export/sync status"; §12 E2). Only the
// eligible (consented, non-suppressed) contacts are pushed to the platform audience.
const syncAudience = useCallback((id, destination) => {
setAudiences(prev => prev.map(a => a.id === id
? { ...a, syncStatus: 'synced', syncDestination: destination, lastSyncedAt: nowIso() }
: a));
toast.success(`Synced to ${destination} — eligible contacts only (consent + suppression applied)`);
}, []);
const refreshAudience = useCallback((id) => {
toast.promise(new Promise(res => setTimeout(res, 700)), {
loading: 'Recomputing audience with consent + suppression filters…',
@@ -403,25 +543,77 @@ export function SocialAdsProvider({ children }) {
toast.success('Conversion event re-synced ✓');
}, []);
// ── Anomaly alerts (spec §11) ─────────────────────────────────────────────
// AI recommends pause/review; the tenant executes. Pausing here also stops the
// underlying campaign, closing the optimization loop.
const resolveAnomaly = useCallback((id, decision) => {
const an = anomalies.find(a => a.id === id);
setAnomalies(prev => prev.map(a => a.id === id ? { ...a, status: decision } : a));
if (decision === 'paused' && an) {
setCampaigns(prev => prev.map(c => c.id === an.campaignId ? { ...c, status: 'paused' } : c));
}
toast[decision === 'dismissed' ? 'message' : 'success'](
decision === 'paused' ? 'Campaign paused on anomaly' : decision === 'reviewed' ? 'Anomaly marked reviewed' : 'Alert dismissed',
);
}, [anomalies]);
// ── Consent text versions (spec §8 "consent text versions") ───────────────
// Approving a pending version supersedes the form's prior live version, so only
// one approved consent language is ever live per form (spec §9).
const approveConsentVersion = useCallback((id) => {
setConsentVersions(prev => {
const target = prev.find(v => v.id === id);
if (!target) return prev;
return prev.map(v => {
if (v.id === id) return { ...v, status: 'approved', reviewer: 'fe@lynkeduppro.com' };
if (v.formId === target.formId && v.status === 'approved') return { ...v, status: 'superseded' };
return v;
});
});
toast.success('Consent text approved — now the live version for this form');
}, []);
const blockConsentVersion = useCallback((id) => {
setConsentVersions(prev => prev.map(v => v.id === id ? { ...v, status: 'blocked' } : v));
toast.message('Consent text version blocked');
}, []);
// ── Review usage permission + FTC disclosure (spec §8, §11) ───────────────
// A review used in an ad needs written usage permission AND an FTC disclosure;
// until both are on file the review creative is blocked from publishing.
const setReviewPermission = useCallback((id, usagePermission) => {
setReviews(prev => prev.map(r => r.id === id ? { ...r, usagePermission } : r));
toast[usagePermission === 'denied' ? 'message' : 'success'](`Review usage permission ${usagePermission}`);
}, []);
const setReviewDisclosure = useCallback((id, disclosureStatus) => {
setReviews(prev => prev.map(r => r.id === id ? { ...r, disclosureStatus } : r));
toast.success(`FTC disclosure marked ${disclosureStatus}`);
}, []);
const value = useMemo(() => ({
accounts, campaigns, creatives, mediaAssets, leadForms, leads, nurtureRules,
audiences, conversionEvents, approvals, analyticsDaily,
toggleAccount, testWebhook,
audiences, conversations, reviews, journeys, anomalies, consentVersions, conversionEvents, approvals, analyticsDaily,
toggleAccount, testWebhook, reconnectAccount,
createCampaign, setCampaignStatus, publishCampaign, completeManualSetup,
addCreative, setCreativeApproval, updateCreative, revertCreative, addMediaAsset,
updateLeadForm, updateFormField, addFormField, removeFormField, toggleFormStatus, addLeadForm,
assignLead, mergeLead, setLeadStatus, advanceLead,
assignLead, mergeLead, setLeadStatus, advanceLead, flagLeadReview, importLeads,
routeConversation, convertConversationToLead, dismissConversation, replyConversation,
toggleRule, updateRule, addRule, removeRule, moveRule, addRuleFromSuggestion,
refreshAudience, resolveApproval, retryConversion,
createAudience, updateAudience, syncAudience, refreshAudience, resolveApproval, retryConversion, resolveAnomaly,
approveConsentVersion, blockConsentVersion, setReviewPermission, setReviewDisclosure,
}), [
accounts, campaigns, creatives, mediaAssets, leadForms, leads, nurtureRules,
audiences, conversionEvents, approvals, analyticsDaily,
toggleAccount, testWebhook, createCampaign, setCampaignStatus, publishCampaign, completeManualSetup,
audiences, conversations, reviews, journeys, anomalies, consentVersions, conversionEvents, approvals, analyticsDaily,
toggleAccount, testWebhook, reconnectAccount, createCampaign, setCampaignStatus, publishCampaign, completeManualSetup,
addCreative, setCreativeApproval, updateCreative, revertCreative, addMediaAsset,
updateLeadForm, updateFormField, addFormField, removeFormField, toggleFormStatus, addLeadForm,
assignLead, mergeLead, setLeadStatus, advanceLead,
assignLead, mergeLead, setLeadStatus, advanceLead, flagLeadReview, importLeads,
routeConversation, convertConversationToLead, dismissConversation, replyConversation,
toggleRule, updateRule, addRule, removeRule, moveRule, addRuleFromSuggestion,
refreshAudience, resolveApproval, retryConversion,
createAudience, updateAudience, syncAudience, refreshAudience, resolveApproval, retryConversion, resolveAnomaly,
approveConsentVersion, blockConsentVersion, setReviewPermission, setReviewDisclosure,
]);
return <SocialAdsContext.Provider value={value}>{children}</SocialAdsContext.Provider>;
@@ -0,0 +1,70 @@
/**
* audienceAdvisor.js — mock "Audience suggestion" AI (spec §6).
*
* Recommends the four ready-made retargeting/prospecting segments the spec calls
* out: past-customer maintenance retargeting, unsold-estimate retargeting,
* storm-event neighborhoods, and a commercial property-manager list. Each carries a
* filterDef (fed through previewSegment so consent + suppression always apply),
* visible AI reasoning, and a confidence score. Front-end demo: static recipes, no
* real model. Guardrail (spec §6): no sensitive-attribute targeting — only CRM fields.
*/
import { History, FileX2, CloudHail, Building2 } from 'lucide-react';
export const AUDIENCE_SUGGESTIONS = [
{
key: 'maint_retarget',
name: 'Past customers — maintenance due',
segmentType: 'reactivation',
icon: History,
confidence: 0.9,
filterDef: { stage: 'won', source: 'any', service: 'maintenance', geo: [], lastJobWithinDays: 540, frequencyCap: 2 },
rationale: 'Closed jobs aging 1218 months are prime for a maintenance/tune-up retarget before warranty lapses.',
reasons: [
'Targets won deals with a completed job in the last 18 months',
'Maintenance service interest → seasonal tune-up offer',
'Low frequency cap (2/wk) keeps existing customers warm, not spammed',
],
},
{
key: 'unsold_retarget',
name: 'Unsold estimates — retarget',
segmentType: 'crm_stage',
icon: FileX2,
confidence: 0.88,
filterDef: { stage: 'estimate', source: 'any', service: 'any', geo: [], lastJobWithinDays: 180, frequencyCap: 3 },
rationale: 'Estimates that never closed are the cheapest pipeline to reactivate with a financing or limited-offer nudge.',
reasons: [
'Targets the estimate stage that has not advanced to won',
'Recency < 180 days → still in-market',
'Pair with a financing / good-better-best creative',
],
},
{
key: 'storm_geo',
name: 'Storm-event neighborhoods',
segmentType: 'storm_geo',
icon: CloudHail,
confidence: 0.92,
filterDef: { stage: 'any', source: 'any', service: 'storm', geo: ['75023', '75025'], lastJobWithinDays: null, frequencyCap: 4 },
rationale: 'Hail-affected ZIPs concentrate high-intent inspection demand right after an event.',
reasons: [
'Geo-scoped to storm-hit ZIPs (75023 / 75025)',
'Storm service intent → inspection + insurance education',
'Higher cap (4/wk) to capture a short demand window',
],
},
{
key: 'commercial_pm',
name: 'Commercial property managers',
segmentType: 'b2b',
icon: Building2,
confidence: 0.85,
filterDef: { stage: 'any', source: 'linkedin', service: 'commercial', geo: [], lastJobWithinDays: null, frequencyCap: 2 },
rationale: 'B2B facilities/property managers from LinkedIn map to the commercial bid track with a decision-maker offer.',
reasons: [
'LinkedIn-sourced commercial contacts',
'Commercial service intent → capability statement offer',
'Lower cap (2/wk) suits a considered B2B buying cycle',
],
},
];
@@ -0,0 +1,61 @@
/**
* commentTriage.js — mock "Comment / DM triage" (spec §6).
*
* Classifies an inbound social comment or direct message as one of five intents:
* lead, complaint, spam, referral, or support. Front-end demo: keyword scoring, no
* real model. Guardrail (spec §6): AI never auto-deletes criticism — complaints are
* always routed to a human; only clear spam is dismissable.
*/
import { MessageSquare, AlertOctagon, Ban, Gift, LifeBuoy } from 'lucide-react';
export const CONVO_TYPES = {
lead: { label: 'Lead', tone: 'blue', icon: MessageSquare, action: 'Create lead', route: 'Sales / nurture' },
complaint: { label: 'Complaint', tone: 'red', icon: AlertOctagon, action: 'Route to human', route: 'Customer success (human)', human: true },
referral: { label: 'Referral', tone: 'emerald', icon: Gift, action: 'Send promoter path', route: 'Referral nurture' },
support: { label: 'Support', tone: 'amber', icon: LifeBuoy, action: 'Route to support', route: 'Support queue' },
spam: { label: 'Spam', tone: 'zinc', icon: Ban, action: 'Dismiss', route: null },
};
const SIGNALS = [
{ type: 'complaint', words: ['terrible', 'worst', 'scam', 'rude', 'refund', 'disappointed', 'avoid', 'never again', 'damaged my', 'complaint', 'unhappy', 'ripoff', 'overcharged'] },
{ type: 'spam', words: ['http://', 'https://', 'crypto', 'bitcoin', 'click here', 'free money', 'make $', 'loan', 'dm me to earn', 'followers', 'promo code 🔥'] },
{ type: 'referral', words: ['recommend', 'referral', 'referred', 'my neighbor', 'told my', 'a friend', 'gave them your', 'highly recommend', 'sent you'] },
{ type: 'support', words: ['warranty', 'reschedule', 'invoice', 'when will', 'appointment time', 'already a customer', 'my existing', 'follow up on my', 'still waiting', 'status of'] },
{ type: 'lead', words: ['quote', 'estimate', 'how much', 'price', 'interested', 'my roof', 'need a', 'leak', 'inspection', 'replace', 'cost to', 'do you service'] },
];
/** Classify a comment/DM. Returns { type, confidence (0-1), rationale }. */
export function classifyComment(text = '') {
const t = text.toLowerCase();
const scores = {};
for (const sig of SIGNALS) {
scores[sig.type] = sig.words.reduce((n, w) => n + (t.includes(w) ? 1 : 0), 0);
}
let best = 'support', hits = 0;
for (const [type, n] of Object.entries(scores)) {
if (n > hits) { hits = n; best = type; }
}
// No keyword hit at all → low-confidence support catch-all (never auto-dismiss).
if (hits === 0) return { type: 'support', confidence: 0.45, rationale: 'No strong intent signal — routed to a human to review.' };
const confidence = Math.min(0.97, 0.6 + hits * 0.12);
return { type: best, confidence, rationale: RATIONALE[best] };
}
const RATIONALE = {
lead: 'Buying-intent language (quote / price / my roof) → treat as a new lead.',
complaint: 'Negative-sentiment language detected → never auto-handle; route to a human.',
referral: 'Word-of-mouth language (recommend / referred) → promoter path.',
support: 'Existing-customer / service-status language → support queue.',
spam: 'Promotional / link-spam signals → dismissable, not a real lead.',
};
/** Suggested approved-template reply per type (spec §6: approved templates, human for complex). */
export function suggestReply(type) {
switch (type) {
case 'lead': return 'Thanks for reaching out! Wed love to help with your roof. Whats the best number to text you a quick quote?';
case 'referral': return 'That means a lot — thank you for the referral! Send them our way and well take great care of them.';
case 'support': return 'Thanks for the message — pulling up your account now. A team member will follow up shortly with the details.';
case 'complaint': return '(Routed to a human — complaints are handled personally, not by template.)';
default: return '';
}
}
@@ -0,0 +1,76 @@
/**
* contactPool.js — mock CRM contact universe for the Audience segment builder
* (spec §12 E1 "filter CRM contacts by stage, source, service, last job,
* geography, consent" and §8 preview count).
*
* Front-end demo: a deterministic, stable pool generated from each index (no
* Math.random / Date so it never shifts between reloads). previewSegment() filters
* it and returns counts AFTER opt-out and suppression filtering — exactly what the
* spec's /v1/audience-segments/preview endpoint is meant to return.
*/
export const FILTER_OPTIONS = {
stages: ['lead', 'appointment', 'estimate', 'won', 'lost', 'customer'],
sources: ['meta', 'google', 'linkedin', 'nextdoor', 'reddit', 'referral', 'organic'],
services: ['storm', 'replacement', 'emergency', 'commercial', 'maintenance', 'referral'],
zips: ['75023', '75024', '75025', '75074', '75075', '75093', '75094'],
};
const POOL_SIZE = 1400;
// Knuth multiplicative hash → stable per-index pseudo-randomness.
const hash = (i) => (Math.imul(i + 1, 2654435761) >>> 0);
export const CONTACT_POOL = Array.from({ length: POOL_SIZE }, (_, i) => {
const h = hash(i);
return {
id: `ct_${i}`,
stage: FILTER_OPTIONS.stages[h % FILTER_OPTIONS.stages.length],
source: FILTER_OPTIONS.sources[(h >> 3) % FILTER_OPTIONS.sources.length],
service: FILTER_OPTIONS.services[(h >> 6) % FILTER_OPTIONS.services.length],
zip: FILTER_OPTIONS.zips[(h >> 9) % FILTER_OPTIONS.zips.length],
lastJobDaysAgo: (h >> 12) % 540, // 0540 days
consent: ((h >> 16) % 100) < 88, // ~88% have marketing consent
suppressed: ((h >> 20) % 100) < 9, // ~9% on the suppression list
};
});
const isAny = (v) => !v || v === 'any';
/**
* Estimate a segment's reach. Returns matched / consented / suppressed / eligible,
* where eligible = matched ∩ consented ∩ not-suppressed (consent + suppression are
* mandatory per spec §6, so the eligible figure always applies both).
*/
export function previewSegment(f = {}) {
const geo = Array.isArray(f.geo) && f.geo.length ? f.geo : null;
const matched = CONTACT_POOL.filter(c =>
(isAny(f.stage) || c.stage === f.stage) &&
(isAny(f.source) || c.source === f.source) &&
(isAny(f.service) || c.service === f.service) &&
(!geo || geo.includes(c.zip)) &&
(!f.lastJobWithinDays || c.lastJobDaysAgo <= f.lastJobWithinDays),
);
const consented = matched.filter(c => c.consent).length;
const suppressed = matched.filter(c => c.suppressed).length;
const eligible = matched.filter(c => c.consent && !c.suppressed).length;
return { matched: matched.length, consented, suppressed, eligible };
}
/** Human-readable one-liner for a segment's filters (shown on the card). */
export function summarizeFilters(f = {}) {
const bits = [];
if (!isAny(f.stage)) bits.push(`stage: ${f.stage}`);
if (!isAny(f.source)) bits.push(f.source);
if (!isAny(f.service)) bits.push(f.service);
if (Array.isArray(f.geo) && f.geo.length) bits.push(`ZIP ${f.geo.join('/')}`);
if (f.lastJobWithinDays) bits.push(`last job < ${f.lastJobWithinDays}d`);
if (f.frequencyCap) bits.push(`freq cap ${f.frequencyCap}/wk`);
bits.push('consented • suppression applied');
return bits.join(' • ');
}
export const DEFAULT_FILTER_DEF = {
stage: 'any', source: 'any', service: 'any', geo: [],
lastJobWithinDays: null, frequencyCap: 3,
};
+98
View File
@@ -359,6 +359,104 @@ export const mediaAssets = [
{ id: 'med_6', name: 'Branded crew + truck', type: 'image', kind: 'referral', w: 1080, h: 1080, gradient: ['#06b6d4', '#0e7490'], photo: 'https://loremflickr.com/600/400/construction,truck?lock=26' },
];
// ── consent text versions (spec §8 "consent text versions"; §9 consent text) ────
// Tenant-approved SMS/email consent language with opt-out instruction + privacy URL,
// versioned and approval-gated. One approved version is live per form at a time.
export const consentVersions = [
{ id: 'cv1_1', formId: 'form_1', version: 1, channel: 'SMS + Email', status: 'superseded', reviewer: 'fe@lynkeduppro.com', createdAt: '2026-05-20T10:00:00Z', privacyPolicyUrl: 'https://lynkeduppro.com/privacy', optOut: 'Reply STOP to opt out.', text: 'By submitting, I agree LynkedUp Roofing may contact me about my inspection.' },
{ id: 'cv1_2', formId: 'form_1', version: 2, channel: 'SMS + Email', status: 'approved', reviewer: 'fe@lynkeduppro.com', createdAt: '2026-06-05T10:00:00Z', privacyPolicyUrl: 'https://lynkeduppro.com/privacy', optOut: 'Reply STOP to opt out.', text: 'By submitting, I agree LynkedUp Roofing may contact me by call, text, and email about my inspection. Msg/data rates may apply.' },
{ id: 'cv2_1', formId: 'form_2', version: 1, channel: 'Email', status: 'approved', reviewer: 'fe@lynkeduppro.com', createdAt: '2026-05-25T09:00:00Z', privacyPolicyUrl: 'https://lynkeduppro.com/privacy', optOut: 'Unsubscribe link in every email.', text: 'I agree to be contacted regarding a commercial roofing assessment. LynkedUp will store my details per its privacy policy.' },
{ id: 'cv3_1', formId: 'form_3', version: 1, channel: 'SMS + Voice', status: 'superseded', reviewer: 'fe@lynkeduppro.com', createdAt: '2026-05-10T08:00:00Z', privacyPolicyUrl: 'https://lynkeduppro.com/privacy', optOut: 'Reply STOP to opt out.', text: 'I consent to urgent contact by phone and text about my roof emergency.' },
{ id: 'cv3_2', formId: 'form_3', version: 2, channel: 'SMS + Voice', status: 'pending', reviewer: null, createdAt: '2026-06-10T07:00:00Z', privacyPolicyUrl: 'https://lynkeduppro.com/privacy', optOut: 'Reply STOP to opt out anytime.', text: 'I consent to urgent contact by phone, text, and email about my roof emergency. Standard message and data rates may apply.' },
];
// ── anomaly alerts (spec §11: CPL spike, lead-quality drop, duplicate rate, ──────
// source-to-appointment decline → AI recommends pause/review; tenant executes).
export const anomalies = [
{ id: 'an_1', campaignId: 'camp_4', type: 'cpl_spike', severity: 'high', metric: 'CPL $26 vs $11 baseline (+136%)', detail: 'Cost per lead more than doubled over the last 2 days while volume held flat.', recommendation: 'review', detectedAt: '2026-06-10T09:10:00Z', status: 'open' },
{ id: 'an_2', campaignId: 'camp_2', type: 'appt_decline', severity: 'medium', metric: 'Appt rate 31% vs 48% (35% WoW)', detail: 'Source-to-appointment conversion dropped sharply — check speed-to-lead and form quality.', recommendation: 'review', detectedAt: '2026-06-10T08:40:00Z', status: 'open' },
{ id: 'an_3', campaignId: 'camp_1', type: 'duplicate_rate', severity: 'medium', metric: 'Duplicate rate 24% (threshold 15%)', detail: 'Abnormal duplicate submissions — likely repeat clicks or bot/form traffic.', recommendation: 'review', detectedAt: '2026-06-10T07:55:00Z', status: 'open' },
{ id: 'an_4', campaignId: 'camp_3', type: 'quality_drop', severity: 'high', metric: 'Avg lead quality 44 (down from 71)', detail: 'Lead quality fell well below the qualified threshold — likely low-intent traffic.', recommendation: 'pause', detectedAt: '2026-06-10T07:30:00Z', status: 'open' },
];
// ── multi-touch journeys (spec §11: first/last/multi-touch attribution models) ──
// Each closed-won customer's ordered touch path across sources. Revenue is credited
// differently per model: first-touch → touches[0], last-touch → final touch, multi
// (linear) → split evenly across all touches. Touches carry platform + campaignId so
// the same keyOf() mapping resolves creative/playbook dimensions too.
export const journeys = [
{ id: 'jny_1', customer: 'Helen Ortiz', revenue: 28100, touches: [{ platform: 'meta', campaignId: 'camp_1' }, { platform: 'google', campaignId: 'camp_2' }] },
{ id: 'jny_2', customer: 'Greg Tanaka', revenue: 24850, touches: [{ platform: 'meta', campaignId: 'camp_1' }] },
{ id: 'jny_3', customer: 'Summit Properties', revenue: 86000, touches: [{ platform: 'linkedin', campaignId: 'camp_3' }, { platform: 'google', campaignId: 'camp_2' }, { platform: 'linkedin', campaignId: 'camp_3' }] },
{ id: 'jny_4', customer: 'Lena Brooks', revenue: 31200, touches: [{ platform: 'google', campaignId: 'camp_2' }] },
{ id: 'jny_5', customer: 'Marco Rivera', revenue: 22400, touches: [{ platform: 'nextdoor', campaignId: 'camp_5' }, { platform: 'meta', campaignId: 'camp_1' }] },
{ id: 'jny_6', customer: 'Tina Nguyen', revenue: 46200, touches: [{ platform: 'google', campaignId: 'camp_4' }, { platform: 'meta', campaignId: 'camp_1' }] },
{ id: 'jny_7', customer: 'Paul Adler', revenue: 19800, touches: [{ platform: 'meta', campaignId: 'camp_1' }] },
{ id: 'jny_8', customer: 'Carla Boone', revenue: 25400, touches: [{ platform: 'google', campaignId: 'camp_2' }, { platform: 'linkedin', campaignId: 'camp_3' }] },
];
// ── reviews + referral lift (spec §8 "reviews"; §10 review/referral lift) ───────
// Post-job reviews tied back to the acquisition source. referralLeads = new leads the
// review/promoter generated; reactivationRevenue = repeat/reactivation $ by source.
export const reviews = [
{ id: 'rev_1', platform: 'meta', campaignId: 'camp_1', leadId: 'lead_7', rating: 5, referralLeads: 2, reactivationRevenue: 0, text: 'Fast storm inspection, handled the insurance paperwork for us.', submittedAt: '2026-06-09T18:00:00Z', usagePermission: 'granted', disclosureStatus: 'compliant', usedIn: 'camp_1' },
{ id: 'rev_2', platform: 'google', campaignId: 'camp_2', leadId: 'lead_6', rating: 5, referralLeads: 1, reactivationRevenue: 6500, text: 'Second roof they have done for us — came back for the rental property.', submittedAt: '2026-06-09T20:10:00Z' },
{ id: 'rev_3', platform: 'google', campaignId: 'camp_2', leadId: 'lead_8', rating: 4, referralLeads: 0, reactivationRevenue: 0, text: 'Good work, scheduling took a couple tries.', submittedAt: '2026-06-08T15:30:00Z' },
{ id: 'rev_4', platform: 'nextdoor', campaignId: 'camp_5', leadId: null, rating: 5, referralLeads: 3, reactivationRevenue: 0, text: 'Recommended to three neighbors after the hail storm.', submittedAt: '2026-06-08T12:00:00Z', usagePermission: 'pending', disclosureStatus: 'missing', usedIn: 'camp_5' },
{ id: 'rev_5', platform: 'linkedin', campaignId: 'camp_3', leadId: 'lead_3', rating: 5, referralLeads: 1, reactivationRevenue: 0, text: 'Professional crew for our commercial portfolio.', submittedAt: '2026-06-07T09:45:00Z', usagePermission: 'granted', disclosureStatus: 'compliant', usedIn: 'camp_3' },
{ id: 'rev_6', platform: 'meta', campaignId: 'camp_1', leadId: null, rating: 4, referralLeads: 0, reactivationRevenue: 0, text: 'Solid job on the ridge cap.', submittedAt: '2026-06-07T17:20:00Z' },
{ id: 'rev_7', platform: 'google', campaignId: 'camp_2', leadId: null, rating: 5, referralLeads: 1, reactivationRevenue: 8200, text: 'Called us back for annual maintenance — great value.', submittedAt: '2026-06-06T14:05:00Z' },
{ id: 'rev_8', platform: 'nextdoor', campaignId: 'camp_5', leadId: null, rating: 5, referralLeads: 2, reactivationRevenue: 0, text: 'Whole street is using them now.', submittedAt: '2026-06-05T11:30:00Z', usagePermission: 'granted', disclosureStatus: 'compliant', usedIn: 'camp_5' },
];
// ── inbound conversations (spec §4 "leads AND conversations"; §6 Comment/DM triage) ──
// Comments + DMs on ad posts, pre-classified by AI as lead/complaint/spam/referral/
// support. status: new → routed | converted | dismissed.
export const conversations = [
{
id: 'cv_1', platform: 'meta', channel: 'comment', author: 'Brian Keller', handle: '@bkeller',
postRef: 'Plano Hail Response — June (ad)', receivedAt: '2026-06-10T14:10:00Z', status: 'new',
text: 'How much for an inspection? My roof took a beating in the last hail storm and I think I have a leak.',
classification: { type: 'lead', confidence: 0.93 },
},
{
id: 'cv_2', platform: 'meta', channel: 'comment', author: 'Donna Pratt', handle: '@dpratt75',
postRef: 'Plano Hail Response — June (ad)', receivedAt: '2026-06-10T13:30:00Z', status: 'new',
text: 'Worst experience ever. Crew showed up late and damaged my gutters. Still waiting on a callback. Avoid!',
classification: { type: 'complaint', confidence: 0.95 },
},
{
id: 'cv_3', platform: 'meta', channel: 'dm', author: 'Maria Lopez', handle: '@maria.l',
postRef: 'Storm inspection reel', receivedAt: '2026-06-10T12:05:00Z', status: 'new',
text: 'I highly recommend you all — my neighbor asked who did our roof so I sent her your page!',
classification: { type: 'referral', confidence: 0.9 },
},
{
id: 'cv_4', platform: 'google', channel: 'comment', author: 'Anon', handle: '@deal_hunter',
postRef: 'Roof Replacement — North Dallas Search', receivedAt: '2026-06-10T11:40:00Z', status: 'new',
text: 'Make $5000/week from home 🔥 click here → http://spam.example/earn',
classification: { type: 'spam', confidence: 0.97 },
},
{
id: 'cv_5', platform: 'nextdoor', channel: 'comment', author: 'Greg Tanaka', handle: '@gtanaka',
postRef: 'Neighborhood Review Flywheel', receivedAt: '2026-06-10T10:20:00Z', status: 'new',
text: 'Already a customer here — when will the crew come back to finish the ridge cap? Reschedule please.',
classification: { type: 'support', confidence: 0.88 },
},
{
id: 'cv_6', platform: 'linkedin', channel: 'dm', author: 'Dana Cole', handle: 'Dana Cole',
postRef: 'Commercial Bid — Property Managers', receivedAt: '2026-06-09T17:50:00Z', status: 'routed',
text: 'We manage 6 properties in Plano and need bids on two flat roofs. Can you send a capability statement?',
classification: { type: 'lead', confidence: 0.91 }, routedTo: 'Commercial estimator',
},
{
id: 'cv_7', platform: 'meta', channel: 'dm', author: 'Unknown', handle: '@user_8841',
postRef: 'Unsold Estimate Retarget — Q2', receivedAt: '2026-06-09T16:00:00Z', status: 'dismissed',
text: 'buy cheap followers + crypto giveaway, dm me to earn',
classification: { type: 'spam', confidence: 0.96 },
},
];
// ── conversion_events (spec §10.1) ─────────────────────────────────────────────
export const conversionEvents = [
{ id: 'ce_1', platform: 'meta', eventName: 'LeadCreated', entityType: 'lead', entityId: 'lead_1', value: 0, currency: 'USD', dedupKey: 'meta:fbl_99120', syncStatus: 'synced', syncedAt: '2026-06-10T13:43:00Z' },
@@ -41,14 +41,99 @@ export function metrics(t) {
/** Group rows by a key field and return metrics per group. */
export function metricsBy(rows, key) {
return metricsByFn(rows, (r) => r[key]);
}
/**
* Group rows by a derived key (function) and return metrics per group. Lets the
* dashboard break down by creative or playbook (spec §10 "by source/campaign/
* creative/playbook") even though the daily rows are keyed by campaign/platform.
* Rows whose keyFn returns null/undefined are skipped.
*/
export function metricsByFn(rows, keyFn) {
const buckets = {};
for (const r of rows) {
const k = r[key];
const k = keyFn(r);
if (k == null) continue;
(buckets[k] ||= []).push(r);
}
return Object.entries(buckets).map(([k, rs]) => ({ key: k, ...metrics(totals(rs)) }));
}
// ── Budget pacing (spec §4 Phase 4, §11 anomaly detection) ─────────────────────
/**
* Per active campaign: spend-to-date, average daily spend, a 30-day projection, and
* how that projection paces against the monthly cap. status: over | on | under.
*/
export function budgetPacing(campaigns, analyticsDaily) {
const spendBy = {};
const daysBy = {};
for (const r of analyticsDaily) {
spendBy[r.campaignId] = (spendBy[r.campaignId] || 0) + r.spend;
(daysBy[r.campaignId] ||= new Set()).add(r.date);
}
return campaigns.filter(c => c.status === 'active').map(c => {
const spent = spendBy[c.id] || 0;
const days = daysBy[c.id]?.size || 1;
const avgDaily = spent / days;
const monthlyCap = c.guardrails?.monthlyCap || c.budgetDaily * 30;
const projected = avgDaily * 30;
const pacePct = monthlyCap ? projected / monthlyCap : 0;
const status = pacePct > 1.05 ? 'over' : pacePct < 0.6 ? 'under' : 'on';
return { id: c.id, name: c.name, platform: c.platform, spent, avgDaily, monthlyCap, projected, pacePct, status };
});
}
// ── Multi-touch attribution models (spec §11: first/last/multi-touch) ──────────
/**
* Credit each journey's revenue to its touches under three models at once:
* first → 100% to the first touch's source
* last → 100% to the final (converting) touch
* multi → split evenly (linear) across every touch
* keyFn maps a touch { platform, campaignId } to the active dimension key, so the
* same function powers platform/campaign/creative/playbook attribution.
*/
export function attributionByModel(journeys, keyFn) {
const acc = {};
const add = (k, model, v) => {
if (k == null) return;
(acc[k] ||= { first: 0, last: 0, multi: 0 });
acc[k][model] += v;
};
for (const j of journeys) {
const t = j.touches || [];
if (!t.length) continue;
add(keyFn(t[0]), 'first', j.revenue);
add(keyFn(t[t.length - 1]), 'last', j.revenue);
const share = j.revenue / t.length;
for (const touch of t) add(keyFn(touch), 'multi', share);
}
return Object.entries(acc).map(([key, v]) => ({ key, ...v }));
}
// ── Review & referral lift (spec §8 "reviews"; §10 review/referral lift) ───────
/** Aggregate a set of reviews into count, avg rating, referral leads, reactivation $. */
export function reviewTotals(reviews) {
const count = reviews.length;
return {
count,
avgRating: count ? reviews.reduce((s, r) => s + r.rating, 0) / count : 0,
referralLeads: reviews.reduce((s, r) => s + (r.referralLeads || 0), 0),
reactivationRevenue: reviews.reduce((s, r) => s + (r.reactivationRevenue || 0), 0),
};
}
/** Group reviews by a derived key and return the lift per group. */
export function reviewsByFn(reviews, keyFn) {
const buckets = {};
for (const r of reviews) {
const k = keyFn(r);
if (k == null) continue;
(buckets[k] ||= []).push(r);
}
return Object.entries(buckets).map(([k, rs]) => ({ key: k, ...reviewTotals(rs) }));
}
/** Time series of one numeric field, sorted by date (for line/area charts). */
export function timeSeries(rows, field) {
const byDate = {};
@@ -3,17 +3,18 @@
* revenue, and ROAS by source/campaign (spec §8, §10). Analytics are built from CRM
* events (the analyticsDaily series), not impression-level assumptions.
*/
import React, { useMemo, useState } from 'react';
import React, { useMemo, useState, useCallback } from 'react';
import {
BarChart3, DollarSign, Users, CalendarCheck, TrendingUp, Percent, Eye, Trophy,
Zap, FileText, Crown, Activity,
Zap, FileText, Crown, Activity, Filter, X, Star, Gift, Repeat, Route,
ThumbsDown, Copy, CalendarX, Pause, Check, ShieldAlert,
} from 'lucide-react';
import {
BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, Cell,
LineChart, Line, CartesianGrid, Legend,
} from 'recharts';
import { useSocialAds } from '../SocialAdsContext.jsx';
import { totals, metrics, metricsBy, timeSeries, liveMetrics } from '../engine/attribution.js';
import { totals, metrics, metricsBy, metricsByFn, timeSeries, liveMetrics, reviewTotals, reviewsByFn, attributionByModel, budgetPacing } from '../engine/attribution.js';
import { getPlatform } from '../data/platforms.js';
import { Card, KpiTile, SectionHeader, PlatformBadge } from '../components/ui.jsx';
@@ -21,13 +22,72 @@ const money = (n) => `$${Math.round(n).toLocaleString('en-US')}`;
const pct = (n) => `${Math.round(n * 100)}%`;
const mmss = (s) => s == null ? '—' : `${Math.floor(s / 60)}m ${String(s % 60).padStart(2, '0')}s`;
const MODEL_NOTE = {
first: 'First-touch credits 100% to the source that first introduced the customer.',
last: 'Last-touch credits 100% to the final source before the sale (the default single-touch view).',
multi: 'Multi-touch splits credit evenly (linear) across every source in the journey.',
};
const ANOMALY_META = {
cpl_spike: { label: 'CPL spike', icon: TrendingUp, tone: 'red' },
quality_drop: { label: 'Lead quality drop', icon: ThumbsDown, tone: 'amber' },
duplicate_rate: { label: 'Abnormal duplicate rate', icon: Copy, tone: 'amber' },
appt_decline: { label: 'Source-to-appointment decline', icon: CalendarX, tone: 'red' },
};
const PACE_CHIP = {
over: 'text-red-600 dark:text-red-400 bg-red-500/10',
on: 'text-emerald-600 dark:text-emerald-400 bg-emerald-500/10',
under: 'text-amber-600 dark:text-amber-400 bg-amber-500/10',
};
const PACE_LABEL = { over: 'Pacing hot', on: 'On track', under: 'Under-pacing' };
export default function AttributionDashboard() {
const { analyticsDaily, campaigns, leads } = useSocialAds();
const [view, setView] = useState('platform'); // platform | campaign
const { analyticsDaily, campaigns, creatives, leads, reviews, journeys, anomalies, resolveAnomaly } = useSocialAds();
const [view, setView] = useState('platform'); // platform | campaign | creative | playbook
const [selected, setSelected] = useState(null); // drill-down: a key within the current view
const [model, setModel] = useState('last'); // attribution model: first | last | multi
// Lookups for the creative + playbook breakdowns (spec §10 dimensions).
const campById = useMemo(() => Object.fromEntries(campaigns.map(c => [c.id, c])), [campaigns]);
const creativeById = useMemo(() => Object.fromEntries(creatives.map(c => [c.id, c])), [creatives]);
const primaryCreativeByCampaign = useMemo(() => {
const m = {};
for (const cr of creatives) if (!m[cr.campaignId]) m[cr.campaignId] = cr;
return m;
}, [creatives]);
// The dimension key for any object carrying { platform, campaignId } — works for
// both analyticsDaily rows and leads, so a drill-down filters both consistently.
const keyOf = useCallback((v, o) => {
if (v === 'platform') return o.platform;
if (v === 'campaign') return o.campaignId;
if (v === 'creative') return primaryCreativeByCampaign[o.campaignId]?.id || `nocre:${o.campaignId}`;
return campById[o.campaignId]?.playbook || 'Unmapped';
}, [primaryCreativeByCampaign, campById]);
// Scope spend + leads to the drilled-down entity (top KPIs + trend), if any.
const switchView = (v) => { setView(v); setSelected(null); };
const filteredDaily = useMemo(() => selected ? analyticsDaily.filter(r => keyOf(view, r) === selected) : analyticsDaily, [analyticsDaily, selected, view, keyOf]);
const filteredLeads = useMemo(() => selected ? leads.filter(l => keyOf(view, l) === selected) : leads, [leads, selected, view, keyOf]);
const filteredReviews = useMemo(() => selected ? reviews.filter(r => keyOf(view, r) === selected) : reviews, [reviews, selected, view, keyOf]);
// Review & referral lift (spec §10) — totals scope to the drill-down; the breakdown
// groups all reviews by the current dimension.
const reviewAgg = useMemo(() => reviewTotals(filteredReviews), [filteredReviews]);
const reviewsBreakdown = useMemo(() => reviewsByFn(reviews, r => keyOf(view, r)).sort((a, b) => b.count - a.count), [reviews, view, keyOf]);
// Multi-touch attribution by model (spec §11), grouped by the current dimension.
const modelRows = useMemo(() => attributionByModel(journeys, t => keyOf(view, t)).sort((a, b) => b[model] - a[model]), [journeys, view, keyOf, model]);
const modelTotal = useMemo(() => modelRows.reduce((s, r) => s + r[model], 0), [modelRows, model]);
// Budget pacing + open anomaly alerts (spec §11).
const pacing = useMemo(() => budgetPacing(campaigns, analyticsDaily), [campaigns, analyticsDaily]);
const openAlerts = useMemo(() => anomalies.filter(a => a.status === 'open'), [anomalies]);
// Spend/ROAS = platform-reported; lead→won + rates = live CRM events (spec §6, §10).
const reported = useMemo(() => metrics(totals(analyticsDaily)), [analyticsDaily]);
const live = useMemo(() => liveMetrics(leads), [leads]);
// Both honor the active drill-down filter so the KPI cards reconcile with it.
const reported = useMemo(() => metrics(totals(filteredDaily)), [filteredDaily]);
const live = useMemo(() => liveMetrics(filteredLeads), [filteredLeads]);
const m = useMemo(() => ({
...live,
spend: reported.spend,
@@ -35,16 +95,41 @@ export default function AttributionDashboard() {
costPerAppt: live.appointments ? reported.spend / live.appointments : 0,
roas: reported.spend ? live.revenue / reported.spend : 0,
}), [reported, live]);
const bySource = useMemo(() => metricsBy(analyticsDaily, view === 'platform' ? 'platform' : 'campaignId'), [analyticsDaily, view]);
const bySource = useMemo(() => {
if (view === 'platform') return metricsBy(analyticsDaily, 'platform');
if (view === 'campaign') return metricsBy(analyticsDaily, 'campaignId');
if (view === 'creative') return metricsByFn(analyticsDaily, r => primaryCreativeByCampaign[r.campaignId]?.id || `nocre:${r.campaignId}`);
if (view === 'playbook') return metricsByFn(analyticsDaily, r => campById[r.campaignId]?.playbook || 'Unmapped');
return [];
}, [analyticsDaily, view, primaryCreativeByCampaign, campById]);
const revenueSeries = useMemo(() => {
// Merge revenue + spend per date for a dual-line chart.
const rev = timeSeries(analyticsDaily, 'revenue');
const spend = timeSeries(analyticsDaily, 'spend');
// Merge revenue + spend per date for a dual-line chart (respects drill-down).
const rev = timeSeries(filteredDaily, 'revenue');
const spend = timeSeries(filteredDaily, 'spend');
return rev.map((r, i) => ({ date: r.date, revenue: r.value, spend: spend[i]?.value || 0 }));
}, [analyticsDaily]);
}, [filteredDaily]);
const campName = (id) => campaigns.find(c => c.id === id)?.name || id;
const campName = (id) => campById[id]?.name || id;
// Full + short labels for whichever dimension is selected.
const labelFor = (k) => {
if (view === 'platform') return getPlatform(k)?.name || k;
if (view === 'campaign') return campName(k);
if (view === 'creative') return k.startsWith('nocre:') ? `${campName(k.slice(6))} — no creative on file` : (creativeById[k]?.headline || k);
return k; // playbook key is the name itself
};
const shortLabel = (k) => {
if (view === 'platform') return getPlatform(k)?.short || k;
if (view === 'campaign') return campName(k).slice(0, 10);
if (view === 'creative') return (k.startsWith('nocre:') ? campName(k.slice(6)) : (creativeById[k]?.headline || k)).slice(0, 12);
return k.split(' ').slice(0, 2).join(' ');
};
const barColor = (k) => {
if (view === 'platform') return getPlatform(k)?.color || '#3b82f6';
if (view === 'creative' && !k.startsWith('nocre:')) return getPlatform(creativeById[k]?.platform)?.color || '#3b82f6';
return '#3b82f6';
};
// ROAS leaderboard
const ranked = [...bySource].sort((a, b) => b.roas - a.roas);
@@ -52,9 +137,9 @@ export default function AttributionDashboard() {
return (
<div className="space-y-6">
<SectionHeader icon={BarChart3} title="Attribution Dashboard" description="Source-to-close economics built from CRM events — leads alone are not enough.">
<div className="flex rounded-lg overflow-hidden border border-zinc-200 dark:border-white/10">
{['platform', 'campaign'].map(v => (
<button key={v} onClick={() => setView(v)}
<div className="flex flex-wrap rounded-lg overflow-hidden border border-zinc-200 dark:border-white/10">
{['platform', 'campaign', 'creative', 'playbook'].map(v => (
<button key={v} onClick={() => switchView(v)}
className={`px-3 py-1.5 text-xs font-semibold capitalize ${view === v ? 'bg-amber-500 text-white' : 'bg-white dark:bg-zinc-900 text-zinc-500'}`}>
By {v}
</button>
@@ -62,12 +147,81 @@ export default function AttributionDashboard() {
</div>
</SectionHeader>
{/* Live banner */}
<div className="flex items-center gap-2 text-[11px] text-emerald-600 dark:text-emerald-400 -mb-2">
{/* Live banner + drill-down filter chip */}
<div className="flex flex-wrap items-center justify-between gap-2 -mb-2">
<div className="flex items-center gap-2 text-[11px] text-emerald-600 dark:text-emerald-400">
<Activity size={13} className="animate-pulse" />
<span className="font-semibold">Live</span>
<span className="text-zinc-400"> lead-to-won metrics update from CRM events as you act in the Lead Inbox. Spend is platform-reported.</span>
</div>
{selected && (
<button onClick={() => setSelected(null)}
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-[11px] font-semibold bg-amber-500/15 text-amber-700 dark:text-amber-400 hover:bg-amber-500/25 transition-colors">
<Filter size={12} /> {labelFor(selected)} <span className="text-amber-500/60">· by {view}</span> <X size={13} />
</button>
)}
</div>
{/* Budget pacing & anomaly alerts (spec §11) */}
<Card title="Budget pacing & anomaly alerts" subtitle="AI flags spend/quality anomalies; you pause or review" icon={ShieldAlert}>
{/* Alerts */}
{openAlerts.length === 0 ? (
<div className="flex items-center gap-2 text-sm text-emerald-600 dark:text-emerald-400 mb-4">
<Check size={16} /> No open anomalies all campaigns within guardrails.
</div>
) : (
<div className="space-y-2 mb-5">
{openAlerts.map(a => {
const meta = ANOMALY_META[a.type] || ANOMALY_META.cpl_spike;
const AIcon = meta.icon;
const toneCls = meta.tone === 'red' ? 'text-red-500 bg-red-500/10' : 'text-amber-500 bg-amber-500/10';
return (
<div key={a.id} className="flex flex-col sm:flex-row sm:items-center gap-3 p-3 rounded-xl border border-zinc-200 dark:border-white/10 bg-zinc-50 dark:bg-white/[0.02]">
<span className={`p-2 rounded-lg shrink-0 self-start ${toneCls}`}>{AIcon && <AIcon size={16} />}</span>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-bold text-sm text-zinc-900 dark:text-white">{campName(a.campaignId)}</span>
<span className={`text-[10px] font-bold uppercase tracking-wider px-1.5 py-0.5 rounded ${a.severity === 'high' ? 'text-red-600 dark:text-red-400 bg-red-500/10' : 'text-amber-600 dark:text-amber-400 bg-amber-500/10'}`}>{a.severity}</span>
<span className="text-[11px] text-zinc-400">{meta.label}</span>
</div>
<p className="text-xs font-mono text-zinc-700 dark:text-zinc-200 mt-0.5">{a.metric}</p>
<p className="text-[11px] text-zinc-500 dark:text-zinc-400">{a.detail}</p>
<p className="text-[11px] text-purple-500 mt-0.5 flex items-center gap-1"><Activity size={11} /> AI recommends: <b>{a.recommendation === 'pause' ? 'pause the campaign' : 'review & optimize'}</b></p>
</div>
<div className="flex items-center gap-1.5 shrink-0 self-start sm:self-center">
<button onClick={() => resolveAnomaly(a.id, 'paused')} title="Pause campaign"
className={`inline-flex items-center gap-1 px-2.5 py-1.5 rounded-lg text-xs font-semibold ${a.recommendation === 'pause' ? 'bg-red-500/10 text-red-600 dark:text-red-400 hover:bg-red-500/20' : 'bg-zinc-100 dark:bg-white/5 text-zinc-600 dark:text-zinc-300 hover:bg-zinc-200 dark:hover:bg-white/10'}`}>
<Pause size={13} /> Pause
</button>
<button onClick={() => resolveAnomaly(a.id, 'reviewed')} title="Mark reviewed"
className="inline-flex items-center gap-1 px-2.5 py-1.5 rounded-lg text-xs font-semibold bg-zinc-100 dark:bg-white/5 text-zinc-600 dark:text-zinc-300 hover:bg-zinc-200 dark:hover:bg-white/10">
<Check size={13} /> Review
</button>
<button onClick={() => resolveAnomaly(a.id, 'dismissed')} title="Dismiss" className="p-1.5 text-zinc-400 hover:text-zinc-600 dark:hover:text-zinc-200"><X size={15} /></button>
</div>
</div>
);
})}
</div>
)}
{/* Budget pacing per active campaign */}
<p className="text-[10px] font-bold uppercase tracking-wider text-zinc-400 mb-2">Budget pacing active campaigns</p>
<div className="space-y-2">
{pacing.map(p => (
<div key={p.id} className="flex items-center gap-3">
<span className="text-xs font-semibold text-zinc-700 dark:text-zinc-200 w-40 sm:w-52 truncate shrink-0" title={p.name}>{p.name}</span>
<div className="flex-1 min-w-0">
<div className="h-2 rounded-full bg-zinc-200 dark:bg-zinc-700 overflow-hidden">
<div className={`h-full ${p.status === 'over' ? 'bg-red-500' : p.status === 'under' ? 'bg-amber-500' : 'bg-emerald-500'}`} style={{ width: `${Math.min(100, p.pacePct * 100)}%` }} />
</div>
</div>
<span className="text-[11px] font-mono text-zinc-400 w-28 text-right shrink-0 hidden sm:block">{money(p.projected)} / {money(p.monthlyCap)}</span>
<span className={`text-[10px] font-bold uppercase tracking-wider px-2 py-0.5 rounded-full shrink-0 ${PACE_CHIP[p.status]}`}>{PACE_LABEL[p.status]}</span>
</div>
))}
</div>
</Card>
{/* KPI row */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
@@ -111,26 +265,28 @@ export default function AttributionDashboard() {
<BarChart data={bySource} margin={{ top: 5, right: 20, left: -10, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#71717a22" vertical={false} />
<XAxis dataKey="key" tick={{ fill: '#71717a', fontSize: 10 }} axisLine={false} tickLine={false}
tickFormatter={(k) => view === 'platform' ? (getPlatform(k)?.short || k) : campName(k).slice(0, 8)} />
interval={0} tickFormatter={shortLabel} />
<YAxis tick={{ fill: '#71717a', fontSize: 11 }} axisLine={false} tickLine={false} />
<Tooltip cursor={{ fill: 'transparent' }} contentStyle={{ background: 'rgba(24,24,27,0.95)', border: '1px solid #3f3f46', borderRadius: 12, fontSize: 12 }}
formatter={(v) => [money(v), 'CPL']} labelFormatter={(k) => view === 'platform' ? getPlatform(k)?.name : campName(k)} />
formatter={(v) => [money(v), 'CPL']} labelFormatter={labelFor} />
<Bar dataKey="cpl" radius={[6, 6, 0, 0]} barSize={36}>
{bySource.map((s, i) => <Cell key={i} fill={view === 'platform' ? (getPlatform(s.key)?.color || '#3b82f6') : '#3b82f6'} />)}
{bySource.map((s, i) => <Cell key={i} fill={barColor(s.key)} />)}
</Bar>
</BarChart>
</ResponsiveContainer>
</Card>
{/* ROAS leaderboard */}
<Card title="ROAS leaderboard" subtitle="Ranked by return — not lead count" icon={Trophy}>
<Card title="ROAS leaderboard" subtitle="Ranked by return — click a row to filter" icon={Trophy}>
<div className="space-y-2">
{ranked.map((s, i) => (
<div key={s.key} className="flex items-center gap-3 px-3 py-2 rounded-lg bg-zinc-50 dark:bg-white/5">
<div key={s.key} onClick={() => setSelected(sel => sel === s.key ? null : s.key)}
title="Click to filter the dashboard to this row"
className={`flex items-center gap-3 px-3 py-2 rounded-lg cursor-pointer transition-colors ${selected === s.key ? 'bg-amber-50 dark:bg-amber-500/10 ring-1 ring-amber-400/60' : 'bg-zinc-50 dark:bg-white/5 hover:bg-zinc-100 dark:hover:bg-white/10'}`}>
<span className="text-xs font-bold text-zinc-400 w-4">{i + 1}</span>
{view === 'platform'
? <PlatformBadge platformId={s.key} />
: <span className="text-sm font-semibold text-zinc-700 dark:text-zinc-200 truncate flex-1">{campName(s.key)}</span>}
: <span className="text-sm font-semibold text-zinc-700 dark:text-zinc-200 truncate flex-1" title={labelFor(s.key)}>{labelFor(s.key)}</span>}
<div className="flex items-center gap-3 ml-auto text-xs">
<span className="text-zinc-400">{money(s.revenue)}</span>
<span className={`font-mono font-bold w-12 text-right ${s.roas >= 1 ? 'text-emerald-500' : 'text-red-500'}`}>{s.roas.toFixed(1)}×</span>
@@ -141,8 +297,98 @@ export default function AttributionDashboard() {
</Card>
</div>
{/* Review & referral lift (spec §8 "reviews"; §10 review/referral lift) */}
<Card title="Review & referral lift" subtitle={`Post-job reviews attributed back to source${selected ? ` · filtered to ${labelFor(selected)}` : ''}`} icon={Star}>
<div className="grid grid-cols-3 gap-2 mb-4">
<LiftStat icon={Star} color="text-amber-500" value={reviewAgg.count} label="Reviews" sub={reviewAgg.count ? `${reviewAgg.avgRating.toFixed(1)}★ avg` : 'no reviews'} />
<LiftStat icon={Gift} color="text-emerald-500" value={reviewAgg.referralLeads} label="Referral leads" sub="generated" />
<LiftStat icon={Repeat} color="text-purple-500" value={money(reviewAgg.reactivationRevenue)} label="Reactivation $" sub="repeat revenue" />
</div>
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead>
<tr className="text-left text-[10px] uppercase tracking-wider text-zinc-400 border-b border-zinc-100 dark:border-white/5">
<th className="py-2 pr-3 font-bold">{view}</th>
<th className="py-2 px-2 font-bold text-right">Reviews</th>
<th className="py-2 px-2 font-bold text-right">Avg rating</th>
<th className="py-2 px-2 font-bold text-right">Referral leads</th>
<th className="py-2 pl-2 font-bold text-right">Reactivation $</th>
</tr>
</thead>
<tbody>
{reviewsBreakdown.length === 0 && <tr><td colSpan={5} className="py-4 text-center text-zinc-400">No reviews for this dimension yet.</td></tr>}
{reviewsBreakdown.map(b => (
<tr key={b.key} onClick={() => setSelected(sel => sel === b.key ? null : b.key)}
className={`border-b border-zinc-50 dark:border-white/5 cursor-pointer transition-colors ${selected === b.key ? 'bg-amber-50 dark:bg-amber-500/10' : 'hover:bg-zinc-50 dark:hover:bg-white/5'}`}>
<td className="py-2 pr-3">
{view === 'platform' ? <PlatformBadge platformId={b.key} size="sm" /> : <span className="font-semibold text-zinc-700 dark:text-zinc-200">{labelFor(b.key)}</span>}
</td>
<td className="py-2 px-2 text-right font-mono">{b.count}</td>
<td className="py-2 px-2 text-right font-mono text-amber-600 dark:text-amber-400">{b.avgRating.toFixed(1)}</td>
<td className="py-2 px-2 text-right font-mono text-emerald-600 dark:text-emerald-400">{b.referralLeads}</td>
<td className="py-2 pl-2 text-right font-mono">{money(b.reactivationRevenue)}</td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
{/* Multi-touch attribution models (spec §11) */}
<Card title="Multi-touch attribution" subtitle={`How closed-won revenue is credited across sources, by ${view}`} icon={Route}
action={
<div className="flex rounded-lg overflow-hidden border border-zinc-200 dark:border-white/10">
{[['first', 'First-touch'], ['last', 'Last-touch'], ['multi', 'Multi-touch']].map(([m, label]) => (
<button key={m} onClick={() => setModel(m)}
className={`px-2.5 py-1.5 text-[11px] font-semibold ${model === m ? 'bg-amber-500 text-white' : 'bg-white dark:bg-zinc-900 text-zinc-500'}`}>
{label}
</button>
))}
</div>
}>
<p className="text-[11px] text-zinc-500 dark:text-zinc-400 mb-3 flex items-start gap-1.5">
<Route size={13} className="text-amber-500 mt-0.5 shrink-0" />
{MODEL_NOTE[model]} Highlighted column is the active model; compare to see how credit shifts.
</p>
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead>
<tr className="text-left text-[10px] uppercase tracking-wider text-zinc-400 border-b border-zinc-100 dark:border-white/5">
<th className="py-2 pr-3 font-bold">{view}</th>
<th className={`py-2 px-2 font-bold text-right ${model === 'first' ? 'text-amber-600 dark:text-amber-400' : ''}`}>First-touch</th>
<th className={`py-2 px-2 font-bold text-right ${model === 'last' ? 'text-amber-600 dark:text-amber-400' : ''}`}>Last-touch</th>
<th className={`py-2 pl-2 font-bold text-right ${model === 'multi' ? 'text-amber-600 dark:text-amber-400' : ''}`}>Multi-touch</th>
</tr>
</thead>
<tbody>
{modelRows.length === 0 && <tr><td colSpan={4} className="py-4 text-center text-zinc-400">No closed-won journeys for this dimension yet.</td></tr>}
{modelRows.map(r => (
<tr key={r.key} onClick={() => setSelected(sel => sel === r.key ? null : r.key)}
className={`border-b border-zinc-50 dark:border-white/5 cursor-pointer transition-colors ${selected === r.key ? 'bg-amber-50 dark:bg-amber-500/10' : 'hover:bg-zinc-50 dark:hover:bg-white/5'}`}>
<td className="py-2 pr-3">
{view === 'platform' ? <PlatformBadge platformId={r.key} size="sm" /> : <span className="font-semibold text-zinc-700 dark:text-zinc-200">{labelFor(r.key)}</span>}
</td>
<td className={`py-2 px-2 text-right font-mono ${model === 'first' ? 'font-bold text-amber-600 dark:text-amber-400' : 'text-zinc-500'}`}>{money(r.first)}</td>
<td className={`py-2 px-2 text-right font-mono ${model === 'last' ? 'font-bold text-amber-600 dark:text-amber-400' : 'text-zinc-500'}`}>{money(r.last)}</td>
<td className={`py-2 pl-2 text-right font-mono ${model === 'multi' ? 'font-bold text-amber-600 dark:text-amber-400' : 'text-zinc-500'}`}>{money(r.multi)}</td>
</tr>
))}
</tbody>
{modelRows.length > 0 && (
<tfoot>
<tr className="text-[11px] font-bold text-zinc-600 dark:text-zinc-300">
<td className="py-2 pr-3">Total ({model}-touch)</td>
<td colSpan={3} className="py-2 pl-2 text-right font-mono text-emerald-600 dark:text-emerald-400">{money(modelTotal)}</td>
</tr>
</tfoot>
)}
</table>
</div>
</Card>
{/* Detail table */}
<Card title={`Performance by ${view}`} icon={BarChart3}>
<Card title={`Performance by ${view}`} subtitle="Click a row to filter the KPIs above" icon={BarChart3}>
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead>
@@ -160,9 +406,10 @@ export default function AttributionDashboard() {
</thead>
<tbody>
{bySource.map(s => (
<tr key={s.key} className="border-b border-zinc-50 dark:border-white/5">
<tr key={s.key} onClick={() => setSelected(sel => sel === s.key ? null : s.key)}
className={`border-b border-zinc-50 dark:border-white/5 cursor-pointer transition-colors ${selected === s.key ? 'bg-amber-50 dark:bg-amber-500/10' : 'hover:bg-zinc-50 dark:hover:bg-white/5'}`}>
<td className="py-2 pr-3">
{view === 'platform' ? <PlatformBadge platformId={s.key} size="sm" /> : <span className="font-semibold text-zinc-700 dark:text-zinc-200">{campName(s.key)}</span>}
{view === 'platform' ? <PlatformBadge platformId={s.key} size="sm" /> : <span className="font-semibold text-zinc-700 dark:text-zinc-200">{labelFor(s.key)}</span>}
</td>
<td className="py-2 px-2 text-right font-mono">{money(s.spend)}</td>
<td className="py-2 px-2 text-right font-mono">{s.leads}</td>
@@ -181,3 +428,14 @@ export default function AttributionDashboard() {
</div>
);
}
function LiftStat({ icon: Icon, color, value, label, sub }) {
return (
<div className="rounded-xl bg-zinc-50 dark:bg-white/5 p-3 text-center">
{Icon && <Icon size={16} className={`${color} mx-auto mb-1`} />}
<p className="text-lg font-mono font-bold text-zinc-800 dark:text-zinc-100">{value}</p>
<p className="text-[10px] uppercase tracking-wider text-zinc-400">{label}</p>
{sub && <p className="text-[10px] text-zinc-400 mt-0.5">{sub}</p>}
</div>
);
}
@@ -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 gap-1.5 ml-auto">
<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>
<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>
);
}
@@ -3,13 +3,31 @@
* 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 React, { useState, useMemo } from 'react';
import {
ShieldCheck, AlertTriangle, Ban, CheckCircle2, Clock, RefreshCw,
FileWarning, Wand2, ArrowUpRight,
FileWarning, Wand2, ArrowUpRight, FileText, History, ChevronDown, Lock,
Megaphone, Image, MessageSquare, Download, Search, ScrollText,
Quote, ShieldAlert, Bot,
} from 'lucide-react';
import { useSocialAds } from '../SocialAdsContext.jsx';
import { PLATFORMS } from '../data/platforms.js';
import { PlatformBadge, Card, SectionHeader, StatusChip, Btn } from '../components/ui.jsx';
import { toast } from 'sonner';
const fmtDate = (iso) => iso ? new Date(iso).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }) : '';
const fmtDateTime = (iso) => iso ? new Date(iso).toLocaleString(undefined, { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' }) : '';
const AUDIT_CAT = {
campaign: { label: 'Campaign', color: 'text-blue-500', icon: Megaphone },
creative: { label: 'Creative', color: 'text-purple-500', icon: Image },
consent: { label: 'Consent', color: 'text-emerald-500', icon: FileText },
ai: { label: 'AI', color: 'text-amber-500', icon: Wand2 },
conversion: { label: 'Conversion', color: 'text-cyan-500', icon: ArrowUpRight },
message: { label: 'Message', color: 'text-pink-500', icon: MessageSquare },
};
const csvCell = (v) => { const s = String(v ?? '').replace(/"/g, '""'); return /[",\n]/.test(s) ? `"${s}"` : s; };
const TYPE_LABEL = {
creative: 'Creative', consent_text: 'Consent text',
@@ -23,12 +41,112 @@ const SYNC_ICON = {
};
export default function ComplianceCenter() {
const { approvals, resolveApproval, conversionEvents, retryConversion } = useSocialAds();
const { approvals, resolveApproval, conversionEvents, retryConversion, leadForms, consentVersions, approveConsentVersion, blockConsentVersion, anomalies, conversations, campaigns, creatives, reviews, setReviewPermission, setReviewDisclosure } = 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');
// ── Unified audit log (spec §12 H2) — derived live from every compliance-
// relevant data source so it never drifts from the underlying records.
const auditEntries = useMemo(() => {
const campById = Object.fromEntries(campaigns.map(c => [c.id, c]));
const creById = Object.fromEntries(creatives.map(c => [c.id, c]));
const formById = Object.fromEntries(leadForms.map(f => [f.id, f]));
const out = [];
for (const a of approvals) {
const cat = a.type === 'consent_text' ? 'consent' : a.type === 'campaign' ? 'campaign' : 'creative';
const cre = a.type === 'creative' ? creById[a.refId] : null;
const form = a.type === 'consent_text' ? formById[a.refId] : null;
const camp = a.type === 'campaign' ? campById[a.refId] : (cre ? campById[cre.campaignId] : form ? campById[form.campaignId] : null);
const action = a.status === 'approved' ? 'Approved' : a.status === 'blocked' ? 'Blocked' : 'Submitted for review';
out.push({
id: `aud_apr_${a.id}`, ts: a.submittedAt, category: cat, action, detail: a.title + (a.flags?.length ? `${a.flags.join('; ')}` : ''),
actor: a.reviewer || (a.flags?.length ? 'AI compliance reviewer' : 'system'),
platform: camp?.platform || cre?.platform || form?.platform || '', campaignId: camp?.id || '', serviceType: camp?.serviceType || '',
});
}
for (const v of consentVersions) {
const form = formById[v.formId];
const camp = form ? campById[form.campaignId] : null;
out.push({
id: `aud_cv_${v.id}`, ts: v.createdAt, category: 'consent', action: `Consent v${v.version} ${v.status}`,
detail: `${form?.title || v.formId}: "${v.text.slice(0, 60)}…"`, actor: v.reviewer || 'system',
platform: form?.platform || camp?.platform || '', campaignId: camp?.id || '', serviceType: camp?.serviceType || '',
});
}
for (const e of conversionEvents) {
out.push({
id: `aud_ce_${e.id}`, ts: e.syncedAt || e.submittedAt, category: 'conversion', action: `${e.eventName} · ${e.syncStatus}`,
detail: `dedup ${e.dedupKey}${e.value ? ` · $${e.value.toLocaleString()}` : ''}`, actor: 'system',
platform: e.platform, campaignId: '', serviceType: '',
});
}
for (const an of anomalies) {
const camp = campById[an.campaignId];
out.push({
id: `aud_an_${an.id}`, ts: an.detectedAt, category: 'ai', action: `Anomaly: ${an.type}${an.status !== 'open' ? ` · ${an.status}` : ''}`,
detail: `${camp?.name || an.campaignId}${an.metric}`, actor: 'AI anomaly detector',
platform: camp?.platform || '', campaignId: an.campaignId, serviceType: camp?.serviceType || '',
});
}
for (const c of conversations.filter(cv => cv.status !== 'new')) {
out.push({
id: `aud_cv2_${c.id}`, ts: c.receivedAt, category: 'message', action: `${c.classification.type} · ${c.status}`,
detail: `${c.author}: "${c.text.slice(0, 50)}…"`, actor: 'AI triage',
platform: c.platform, campaignId: '', serviceType: '',
});
}
return out.map(e => ({ ...e, campaignName: campById[e.campaignId]?.name || '' }))
.sort((a, b) => String(b.ts).localeCompare(String(a.ts)));
}, [approvals, consentVersions, conversionEvents, anomalies, conversations, campaigns, creatives, leadForms]);
const [auditCat, setAuditCat] = useState('all');
const [auditPlatform, setAuditPlatform] = useState('all');
const [auditSearch, setAuditSearch] = useState('');
const filteredAudit = auditEntries.filter(e =>
(auditCat === 'all' || e.category === auditCat) &&
(auditPlatform === 'all' || e.platform === auditPlatform) &&
(!auditSearch || `${e.action} ${e.detail} ${e.actor}`.toLowerCase().includes(auditSearch.toLowerCase())),
);
const exportAudit = () => {
const headers = ['Timestamp', 'Category', 'Action', 'Detail', 'Actor', 'Source', 'Campaign', 'Service type'];
const lines = [headers.join(',')];
for (const e of filteredAudit) {
lines.push([e.ts, AUDIT_CAT[e.category]?.label || e.category, e.action, e.detail, e.actor, e.platform, e.campaignName, e.serviceType].map(csvCell).join(','));
}
const blob = new Blob([lines.join('\n')], { type: 'text/csv;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = 'ad-engine-audit-log.csv'; a.click();
URL.revokeObjectURL(url);
toast.success(`Exported ${filteredAudit.length} audit entries to CSV`);
};
// Reviews used in ads (spec §11 review/testimonial disclosure).
const usedReviews = reviews.filter(r => r.usedIn);
const campName = (id) => campaigns.find(c => c.id === id)?.name || id;
// AI quality metrics (spec §8 "AI audit queue", §10 AI quality).
const aiMetrics = useMemo(() => {
const aiCreatives = creatives.filter(c => c.aiGenerated);
const corrections = creatives.reduce((s, c) => s + (c.versions?.length || 0), 0);
const blockedResp = approvals.filter(a => a.status === 'blocked').length;
const handoffs = conversations.filter(c => c.classification.type === 'complaint' && c.status !== 'new').length;
const totalConv = conversations.length || 1;
const contained = conversations.filter(c => c.classification.type !== 'complaint').length;
const avgRating = reviews.length ? reviews.reduce((s, r) => s + r.rating, 0) / reviews.length : 0;
return {
aiPending: aiCreatives.filter(c => c.approvalStatus === 'pending').length,
containment: contained / totalConv,
handoffs, blockedResp, corrections,
sentiment: avgRating,
};
}, [creatives, approvals, conversations, reviews]);
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.">
@@ -54,6 +172,59 @@ export default function ComplianceCenter() {
: <div className="space-y-2">{pending.map(a => <ApprovalRow key={a.id} item={a} onResolve={resolveApproval} />)}</div>}
</Card>
{/* Consent text versions (spec §8) */}
<Card title="Consent text versions" icon={FileText} subtitle="Tenant-approved SMS/email consent language per form. One version is live at a time; changes need re-approval.">
<div className="space-y-3">
{leadForms.map(form => {
const versions = consentVersions.filter(v => v.formId === form.id);
if (versions.length === 0) return null;
return <ConsentFormBlock key={form.id} form={form} versions={versions} onApprove={approveConsentVersion} onBlock={blockConsentVersion} />;
})}
</div>
</Card>
{/* Review/testimonial usage + FTC disclosure (spec §8, §11) */}
<Card title="Review usage & disclosure" icon={Quote} subtitle="Reviews used in ads need written usage permission and an FTC disclosure — otherwise the review creative is blocked.">
<div className="space-y-2">
{usedReviews.length === 0 && <p className="text-sm text-zinc-500 py-3 text-center">No reviews are currently used in ad creative.</p>}
{usedReviews.map(r => {
const cleared = r.usagePermission === 'granted' && r.disclosureStatus === 'compliant';
return (
<div key={r.id} className={`p-3 rounded-xl border ${cleared ? 'border-zinc-200 dark:border-white/10 bg-zinc-50 dark:bg-white/[0.02]' : 'border-red-200 dark:border-red-500/20 bg-red-50/60 dark:bg-red-500/5'}`}>
<div className="flex flex-col sm:flex-row sm:items-start gap-3">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap mb-1">
<PlatformBadge platformId={r.platform} size="sm" showName={false} />
<span className="text-amber-500 text-xs font-mono">{'★'.repeat(r.rating)}</span>
<span className="text-[11px] text-zinc-400">used in {campName(r.usedIn)}</span>
{cleared
? <span className="inline-flex items-center gap-1 text-[10px] font-bold uppercase tracking-wider text-emerald-600 dark:text-emerald-400 bg-emerald-500/10 px-1.5 py-0.5 rounded"><ShieldCheck size={10} /> Cleared to use</span>
: <span className="inline-flex items-center gap-1 text-[10px] font-bold uppercase tracking-wider text-red-600 dark:text-red-400 bg-red-500/10 px-1.5 py-0.5 rounded"><ShieldAlert size={10} /> Creative blocked</span>}
</div>
<p className="text-[11px] text-zinc-600 dark:text-zinc-300 italic">"{r.text}"</p>
<div className="flex items-center gap-3 mt-1.5 text-[11px]">
<span className={`inline-flex items-center gap-1 ${r.usagePermission === 'granted' ? 'text-emerald-600 dark:text-emerald-400' : r.usagePermission === 'denied' ? 'text-red-500' : 'text-amber-600 dark:text-amber-400'}`}>
<ShieldCheck size={12} /> Permission: {r.usagePermission}
</span>
<span className={`inline-flex items-center gap-1 ${r.disclosureStatus === 'compliant' ? 'text-emerald-600 dark:text-emerald-400' : 'text-red-500'}`}>
<FileText size={12} /> FTC disclosure: {r.disclosureStatus}
</span>
</div>
</div>
<div className="flex items-center gap-1.5 shrink-0">
{r.usagePermission !== 'granted'
? <Btn size="sm" variant="success" icon={CheckCircle2} onClick={() => setReviewPermission(r.id, 'granted')}>Grant permission</Btn>
: <Btn size="sm" variant="danger" icon={Ban} onClick={() => setReviewPermission(r.id, 'denied')}>Revoke</Btn>}
{r.disclosureStatus !== 'compliant' && <Btn size="sm" variant="outline" icon={FileText} onClick={() => setReviewDisclosure(r.id, 'compliant')}>Add disclosure</Btn>}
</div>
</div>
</div>
);
})}
</div>
<p className="text-[11px] text-zinc-400 mt-3 flex items-start gap-1"><AlertTriangle size={12} className="mt-0.5 shrink-0" /> Per FTC and platform guidance, testimonials in ads require documented permission and clear disclosure. Unapproved review creative cannot publish.</p>
</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).">
@@ -79,12 +250,29 @@ export default function ComplianceCenter() {
</div>
</Card>
{/* Recently resolved + AI audit */}
<Card title="AI audit & recent approvals" icon={Wand2} subtitle="Human corrections and resolved items.">
{/* AI audit & quality (spec §8 "AI audit queue", §10 AI quality) */}
<Card title="AI audit & quality" icon={Bot} subtitle="AI containment, handoffs, blocked copy, and human corrections.">
<div className="grid grid-cols-3 gap-2 mb-3">
<AiStat value={`${Math.round(aiMetrics.containment * 100)}%`} label="Containment" color="text-emerald-500" />
<AiStat value={aiMetrics.handoffs} label="Human handoffs" color="text-amber-500" />
<AiStat value={aiMetrics.blockedResp} label="Blocked copy" color="text-red-500" />
<AiStat value={aiMetrics.corrections} label="Human corrections" color="text-blue-500" />
<AiStat value={aiMetrics.sentiment.toFixed(1)} label="Sentiment ★" color="text-purple-500" />
<AiStat value={aiMetrics.aiPending} label="AI items pending" color="text-amber-500" />
</div>
<div className="flex items-start gap-2 px-2.5 py-2 rounded-lg bg-purple-50 dark:bg-purple-500/10 mb-2">
<Wand2 size={14} className="text-purple-500 mt-0.5 shrink-0" />
<p className="text-[11px] text-zinc-600 dark:text-zinc-300">
All AI copy required human approval <b>0</b> auto-published. {aiMetrics.aiPending > 0 ? `${aiMetrics.aiPending} AI-generated item(s) still awaiting review in the queue above.` : 'No AI items pending.'}
</p>
</div>
<p className="text-[10px] font-bold uppercase tracking-wider text-zinc-400 mb-1.5">Recently approved</p>
<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" />
<CheckCircle2 size={14} className="text-emerald-500 shrink-0" />
<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>
@@ -92,15 +280,153 @@ export default function ComplianceCenter() {
<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>
{/* Audit log + export (spec §12 H2) */}
<Card title="Audit log" icon={ScrollText} subtitle="Campaign, consent, message, AI, and conversion events — filter and export."
action={<Btn size="sm" variant="outline" icon={Download} onClick={exportAudit}>Export CSV</Btn>}>
{/* Filters */}
<div className="flex flex-wrap items-center gap-2 mb-3">
<div className="flex flex-wrap items-center gap-1.5">
{['all', ...Object.keys(AUDIT_CAT)].map(c => (
<button key={c} onClick={() => setAuditCat(c)}
className={`px-2.5 py-1 rounded-lg text-[11px] font-semibold transition-colors ${auditCat === c ? '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'}`}>
{c === 'all' ? 'All' : AUDIT_CAT[c].label}
</button>
))}
</div>
<span className="w-px h-5 bg-zinc-200 dark:bg-white/10 mx-1 hidden sm:block" />
<select value={auditPlatform} onChange={e => setAuditPlatform(e.target.value)}
className="rounded-lg border border-zinc-200 dark:border-white/10 bg-white dark:bg-zinc-900 px-2 py-1.5 text-xs text-zinc-700 dark:text-zinc-200 focus:outline-none focus:ring-2 focus:ring-amber-500">
<option value="all">All sources</option>
{PLATFORMS.map(p => <option key={p.id} value={p.id}>{p.short}</option>)}
</select>
<div className="relative flex-1 min-w-[140px]">
<Search size={13} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-zinc-400" />
<input value={auditSearch} onChange={e => setAuditSearch(e.target.value)} placeholder="Search audit log…"
className="w-full rounded-lg border border-zinc-200 dark:border-white/10 bg-white dark:bg-zinc-900 pl-8 pr-2 py-1.5 text-xs text-zinc-700 dark:text-zinc-200 focus:outline-none focus:ring-2 focus:ring-amber-500" />
</div>
<span className="text-[11px] text-zinc-400 ml-auto">{filteredAudit.length} entries</span>
</div>
{/* Table */}
<div className="overflow-x-auto max-h-96 overflow-y-auto">
<table className="w-full text-xs">
<thead className="sticky top-0 bg-white dark:bg-zinc-900">
<tr className="text-left text-[10px] uppercase tracking-wider text-zinc-400 border-b border-zinc-100 dark:border-white/5">
<th className="py-2 pr-2 font-bold">When</th>
<th className="py-2 px-2 font-bold">Category</th>
<th className="py-2 px-2 font-bold">Action</th>
<th className="py-2 px-2 font-bold">Detail</th>
<th className="py-2 px-2 font-bold">Actor</th>
<th className="py-2 pl-2 font-bold">Source</th>
</tr>
</thead>
<tbody>
{filteredAudit.length === 0 && <tr><td colSpan={6} className="py-4 text-center text-zinc-400">No audit entries match these filters.</td></tr>}
{filteredAudit.map(e => {
const cat = AUDIT_CAT[e.category] || AUDIT_CAT.campaign;
const CIcon = cat.icon;
return (
<tr key={e.id} className="border-b border-zinc-50 dark:border-white/5 align-top">
<td className="py-2 pr-2 text-zinc-400 whitespace-nowrap">{fmtDateTime(e.ts)}</td>
<td className="py-2 px-2">
<span className={`inline-flex items-center gap-1 font-semibold ${cat.color}`}>{CIcon && <CIcon size={12} />}{cat.label}</span>
</td>
<td className="py-2 px-2 font-semibold text-zinc-700 dark:text-zinc-200 whitespace-nowrap">{e.action}</td>
<td className="py-2 px-2 text-zinc-500 max-w-[280px] truncate" title={e.detail}>{e.detail}</td>
<td className="py-2 px-2 text-zinc-500 whitespace-nowrap">{e.actor}</td>
<td className="py-2 pl-2">{e.platform ? <PlatformBadge platformId={e.platform} size="sm" showName={false} /> : <span className="text-zinc-300 dark:text-zinc-600"></span>}</td>
</tr>
);
})}
</tbody>
</table>
</div>
</Card>
</div>
);
}
function AiStat({ value, label, color }) {
return (
<div className="rounded-lg bg-zinc-50 dark:bg-white/5 p-2 text-center">
<p className={`text-base font-mono font-bold ${color}`}>{value}</p>
<p className="text-[9px] uppercase tracking-wider text-zinc-400 leading-tight">{label}</p>
</div>
);
}
const CONSENT_CHIP = { approved: 'approved', pending: 'pending', superseded: 'merged', blocked: 'blocked' };
function ConsentFormBlock({ form, versions, onApprove, onBlock }) {
const [showHistory, setShowHistory] = useState(false);
const sorted = [...versions].sort((a, b) => b.version - a.version);
const live = sorted.find(v => v.status === 'approved');
const pending = sorted.filter(v => v.status === 'pending');
const history = sorted.filter(v => v.status === 'superseded' || v.status === 'blocked');
return (
<div className="rounded-xl border border-zinc-200 dark:border-white/10 p-3">
<div className="flex items-center justify-between gap-2 mb-2">
<div className="flex items-center gap-2 min-w-0">
<PlatformBadge platformId={form.platform} size="sm" showName={false} />
<h4 className="font-bold text-sm text-zinc-900 dark:text-white truncate">{form.title}</h4>
</div>
{live && <span className="inline-flex items-center gap-1 text-[10px] font-bold uppercase tracking-wider text-emerald-600 dark:text-emerald-400 bg-emerald-500/10 px-2 py-0.5 rounded-full shrink-0"><Lock size={10} /> Live v{live.version}</span>}
</div>
{/* Live version */}
{live && <ConsentVersionView v={live} live />}
{/* Pending versions awaiting approval */}
{pending.map(v => (
<div key={v.id} className="mt-2 rounded-lg border border-amber-200 dark:border-amber-500/20 bg-amber-50/60 dark:bg-amber-500/5 p-2.5">
<div className="flex items-center justify-between gap-2 mb-1">
<span className="text-[10px] font-bold uppercase tracking-wider text-amber-600 dark:text-amber-400 flex items-center gap-1"><AlertTriangle size={11} /> v{v.version} pending approval</span>
<div className="flex items-center gap-1.5">
<Btn size="sm" variant="success" icon={CheckCircle2} onClick={() => onApprove(v.id)}>Approve</Btn>
<Btn size="sm" variant="danger" icon={Ban} onClick={() => onBlock(v.id)}>Block</Btn>
</div>
</div>
<ConsentVersionView v={v} />
</div>
))}
{/* History */}
{history.length > 0 && (
<div className="mt-2">
<button onClick={() => setShowHistory(s => !s)} className="text-[11px] text-zinc-400 hover:text-zinc-600 dark:hover:text-zinc-200 flex items-center gap-1">
<History size={12} /> {history.length} previous version{history.length > 1 ? 's' : ''}
<ChevronDown size={12} className={`transition-transform ${showHistory ? 'rotate-180' : ''}`} />
</button>
{showHistory && (
<div className="mt-2 space-y-2 pl-2 border-l-2 border-zinc-100 dark:border-white/10">
{history.map(v => <ConsentVersionView key={v.id} v={v} muted />)}
</div>
)}
</div>
)}
</div>
);
}
function ConsentVersionView({ v, live, muted }) {
return (
<div className={muted ? 'opacity-70' : ''}>
<div className="flex items-center gap-2 flex-wrap mb-1">
<span className="text-[10px] font-mono font-bold text-zinc-500">v{v.version}</span>
<span className="text-[10px] text-zinc-400">· {v.channel}</span>
{!live && <StatusChip status={CONSENT_CHIP[v.status] || 'draft'} label={v.status} />}
<span className="text-[10px] text-zinc-400 ml-auto">{v.reviewer ? `${v.reviewer} · ` : ''}{fmtDate(v.createdAt)}</span>
</div>
<p className="text-[11px] text-zinc-600 dark:text-zinc-300 italic leading-relaxed">"{v.text}"</p>
<div className="flex items-center gap-3 mt-1 text-[10px] text-zinc-400 flex-wrap">
<span>Opt-out: {v.optOut}</span>
{v.privacyPolicyUrl && <span className="font-mono truncate">Privacy: {v.privacyPolicyUrl}</span>}
</div>
</div>
);
}
@@ -6,11 +6,13 @@
* which platforms are ingest-only / report-only / full-publish, so nobody assumes
* automation that doesn't exist.
*/
import React from 'react';
import { Plug, RefreshCw, Webhook, KeyRound, Clock, AlertTriangle, CheckCircle2 } from 'lucide-react';
import React, { useState } from 'react';
import { createPortal } from 'react-dom';
import { Plug, RefreshCw, Webhook, KeyRound, Clock, AlertTriangle, CheckCircle2, AlarmClock, ShieldCheck, ShieldAlert, Upload, Download, X, FileUp } from 'lucide-react';
import { useSocialAds } from '../SocialAdsContext.jsx';
import { PLATFORMS, CAPABILITY } from '../data/platforms.js';
import { PLATFORMS, CAPABILITY, getPlatform } from '../data/platforms.js';
import { Card, StatusChip, CapabilityBadge, SectionHeader, Btn } from '../components/ui.jsx';
import { toast } from 'sonner';
const ALL_CAPS = [
CAPABILITY.INGEST, CAPABILITY.REPORT, CAPABILITY.CONVERSION_SYNC,
@@ -29,12 +31,28 @@ function fmtDate(iso) {
return new Date(iso).toLocaleString('en-US', { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' });
}
// Permission/scope status from the granted scopes (spec §8 "permission status").
function permState(acc) {
if (acc?.status === 'connected') return acc.scopes?.length ? 'granted' : 'insufficient';
if (acc?.status === 'action_required') return 'insufficient';
return 'none';
}
const PERM_META = {
granted: { icon: ShieldCheck, color: 'text-emerald-600 dark:text-emerald-400', label: 'Granted' },
insufficient: { icon: ShieldAlert, color: 'text-amber-600 dark:text-amber-400', label: 'Insufficient' },
none: { icon: KeyRound, color: 'text-zinc-400', label: 'Not connected' },
};
export default function ConnectedAccounts() {
const { accounts, toggleAccount, testWebhook } = useSocialAds();
const { accounts, toggleAccount, testWebhook, reconnectAccount, importLeads } = useSocialAds();
const accountByPlatform = Object.fromEntries(accounts.map(a => [a.platform, a]));
const [importFor, setImportFor] = useState(null); // account being manually imported into
const connectedCount = accounts.filter(a => a.status === 'connected').length;
// Token-expiration / failure alerts (spec §8 MVP, §11, §12 A1 "token failure displays alert").
const tokenAlerts = accounts.filter(a => a.tokenStatus === 'expiring' || a.tokenStatus === 'revoked' || a.status === 'action_required');
return (
<div>
<SectionHeader
@@ -45,6 +63,34 @@ export default function ConnectedAccounts() {
<StatusChip status="connected" label={`${connectedCount}/${PLATFORMS.length} connected`} />
</SectionHeader>
{/* Token-expiration / failure alerts (spec §8 MVP, §11, §12 A1) */}
{tokenAlerts.length > 0 && (
<div className="mb-5 rounded-xl border border-red-200 dark:border-red-500/30 bg-red-50 dark:bg-red-500/10 p-4">
<div className="flex items-center gap-2 mb-2 text-sm font-semibold text-red-700 dark:text-red-400">
<AlarmClock size={18} className="animate-pulse shrink-0" />
{tokenAlerts.length} account token{tokenAlerts.length > 1 ? 's' : ''} need attention leads can be lost while a connection is down.
</div>
<div className="space-y-1.5">
{tokenAlerts.map(a => {
const p = getPlatform(a.platform);
const PIcon = p?.icon;
const reason = a.tokenStatus === 'revoked' ? 'token revoked' : a.tokenStatus === 'expiring' ? `token expiring ${a.tokenExpiresAt}` : 'action required';
return (
<div key={a.id} className="flex items-center gap-2 px-2.5 py-2 rounded-lg bg-white/70 dark:bg-white/5">
{PIcon && <span className="shrink-0" style={{ color: p.color }}><PIcon size={15} /></span>}
<span className="text-xs font-semibold text-zinc-700 dark:text-zinc-200">{p?.short || a.platform}</span>
<span className="text-[11px] text-red-600 dark:text-red-400"> {reason}</span>
<div className="ml-auto flex items-center gap-1.5">
<Btn size="sm" variant="outline" icon={Upload} onClick={() => setImportFor(a)}>Manual import</Btn>
<Btn size="sm" variant="primary" icon={RefreshCw} onClick={() => reconnectAccount(a.id)}>Reconnect</Btn>
</div>
</div>
);
})}
</div>
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
{PLATFORMS.map(p => {
const acc = accountByPlatform[p.id];
@@ -92,6 +138,32 @@ export default function ConnectedAccounts() {
</div>
</div>
{/* Permissions / scopes (spec §8 "permission status", §7 scopes_json) */}
{(() => {
const perm = permState(acc);
const pm = PERM_META[perm];
const PermIcon = pm.icon;
return (
<div className="border-t border-zinc-100 dark:border-white/5 pt-3 mb-3">
<div className="flex items-center justify-between text-xs mb-1.5">
<span className="flex items-center gap-1.5 text-zinc-500"><KeyRound size={13} /> Permissions</span>
<span className={`flex items-center gap-1 font-semibold ${pm.color}`}>{PermIcon && <PermIcon size={13} />} {pm.label}</span>
</div>
{acc?.scopes?.length > 0 ? (
<div className="flex flex-wrap gap-1">
{acc.scopes.map(s => (
<span key={s} className="text-[10px] font-mono px-1.5 py-0.5 rounded bg-zinc-100 dark:bg-white/10 text-zinc-600 dark:text-zinc-300">{s}</span>
))}
</div>
) : (
<p className="text-[11px] text-amber-600 dark:text-amber-400/80">
{perm === 'none' ? 'No permissions granted — connect to authorize scopes.' : 'Missing required scopes — reconnect to re-grant.'}
</p>
)}
</div>
);
})()}
{acc?.cautions && (
<p className="text-[11px] text-amber-600 dark:text-amber-400/80 mb-3 flex items-start gap-1">
<AlertTriangle size={12} className="mt-0.5 shrink-0" /> {p.cautions}
@@ -110,11 +182,141 @@ export default function ConnectedAccounts() {
<Btn size="sm" variant="ghost" icon={RefreshCw} onClick={() => testWebhook(acc?.id)} disabled={!connected}>
Test lead
</Btn>
<Btn size="sm" variant="ghost" icon={Upload} onClick={() => setImportFor(acc)} disabled={!acc} title="Manually import leads from a CSV">
Import
</Btn>
</div>
</Card>
);
})}
</div>
{importFor && (
<ManualImportModal
account={importFor}
onClose={() => setImportFor(null)}
onImport={(rows) => { importLeads(importFor.platform, rows); setImportFor(null); }}
/>
)}
</div>
);
}
// ── Manual lead import (spec §11 fallback) ─────────────────────────────────────
function splitCsvLine(line) {
const out = []; let cur = ''; let q = false;
for (let i = 0; i < line.length; i++) {
const ch = line[i];
if (q) {
if (ch === '"') { if (line[i + 1] === '"') { cur += '"'; i++; } else q = false; }
else cur += ch;
} else if (ch === '"') q = true;
else if (ch === ',') { out.push(cur); cur = ''; }
else cur += ch;
}
out.push(cur);
return out;
}
function parseLeadsCsv(text) {
const lines = text.trim().split(/\r?\n/).filter(l => l.trim());
if (!lines.length) return [];
const header = splitCsvLine(lines[0]).map(h => h.trim().toLowerCase());
const idx = (names) => header.findIndex(h => names.includes(h));
const cols = {
name: idx(['name', 'full_name', 'full name', 'contact']),
phone: idx(['phone', 'phone_number', 'phone number', 'mobile']),
email: idx(['email', 'email_address', 'e-mail']),
address: idx(['address', 'street_address', 'street', 'property']),
service: idx(['service', 'service_type', 'servicetype', 'type']),
consent: idx(['consent', 'opt_in', 'optin', 'sms_consent']),
};
const hasHeader = [cols.name, cols.phone, cols.email, cols.address].some(i => i >= 0);
const dataLines = hasHeader ? lines.slice(1) : lines;
const pick = (c, i) => (i >= 0 ? (c[i] || '').trim() : '');
return dataLines.map(line => {
const c = splitCsvLine(line);
return hasHeader
? { name: pick(c, cols.name), phone: pick(c, cols.phone), email: pick(c, cols.email), address: pick(c, cols.address), service: pick(c, cols.service), consent: pick(c, cols.consent) }
: { name: (c[0] || '').trim(), phone: (c[1] || '').trim(), email: (c[2] || '').trim(), address: (c[3] || '').trim(), service: (c[4] || '').trim(), consent: (c[5] || '').trim() };
}).filter(r => r.name || r.phone || r.email);
}
const SAMPLE_CSV = `name,phone,email,address,service,consent
John Carter,(972) 555-0190,john.carter@example.com,"410 Oak St, Plano, TX 75023",storm,yes
Maria Gomez,(469) 555-0177,maria.g@example.com,"88 Elm Dr, Allen, TX",replacement,yes
Sam Two Rivers,(214) 555-0162,sam.tr@example.com,"7 Birch Ct, Frisco, TX",emergency,no`;
function ManualImportModal({ account, onClose, onImport }) {
const [text, setText] = useState('');
const platform = getPlatform(account.platform);
const rows = parseLeadsCsv(text);
const withConsent = rows.filter(r => /^(y|yes|true|1)$/i.test(r.consent)).length;
const onFile = (e) => {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = () => setText(String(reader.result || ''));
reader.readAsText(file);
};
const downloadTemplate = () => {
const blob = new Blob(['name,phone,email,address,service,consent\n'], { type: 'text/csv;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = 'lead-import-template.csv'; a.click();
URL.revokeObjectURL(url);
toast.success('Template downloaded');
};
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-lg 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">
<div className="sticky top-0 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 min-w-0">
{platform && <span className="shrink-0" style={{ color: platform.color }}><platform.icon size={18} /></span>}
<div className="min-w-0">
<h3 className="font-bold text-sm text-zinc-900 dark:text-white truncate">Manual lead import {platform?.short || account.platform}</h3>
<p className="text-[11px] text-zinc-400">Fallback for when a webhook or token is down</p>
</div>
</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 shrink-0"><X size={18} /></button>
</div>
<div className="p-5 space-y-3">
<p className="text-[11px] text-zinc-500 dark:text-zinc-400">Paste CSV or upload a file exported from the platform. Columns: <span className="font-mono">name, phone, email, address, service, consent</span>. Rows without explicit consent import as manual-follow-up only.</p>
<div className="flex flex-wrap items-center gap-2">
<label className="inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-xl border border-zinc-200 dark:border-white/15 text-xs font-semibold text-zinc-700 dark:text-zinc-200 hover:bg-zinc-100 dark:hover:bg-white/5 cursor-pointer">
<FileUp size={14} /> Upload CSV
<input type="file" accept=".csv,text/csv" onChange={onFile} className="hidden" />
</label>
<Btn size="sm" variant="ghost" onClick={() => setText(SAMPLE_CSV)}>Load sample</Btn>
<Btn size="sm" variant="ghost" icon={Download} onClick={downloadTemplate}>Template</Btn>
</div>
<textarea value={text} onChange={e => setText(e.target.value)} rows={7} placeholder="name,phone,email,address,service,consent&#10;Jane Doe,(972) 555-0101,jane@example.com,..."
className="w-full rounded-lg border border-zinc-200 dark:border-white/10 bg-white dark:bg-zinc-900 px-2.5 py-2 text-xs font-mono text-zinc-700 dark:text-zinc-200 focus:outline-none focus:ring-2 focus:ring-amber-500" />
{rows.length > 0 && (
<div className="rounded-lg bg-zinc-50 dark:bg-white/[0.02] border border-zinc-200 dark:border-white/10 p-2.5 text-[11px] text-zinc-600 dark:text-zinc-300">
<b>{rows.length}</b> lead{rows.length > 1 ? 's' : ''} parsed · <b>{withConsent}</b> with consent · {rows.length - withConsent} manual-follow-up only.
<div className="mt-1.5 space-y-0.5 max-h-24 overflow-y-auto">
{rows.slice(0, 5).map((r, i) => <div key={i} className="truncate text-zinc-500"> {r.name || '(no name)'} {r.phone || r.email || '—'} {r.service ? `· ${r.service}` : ''}</div>)}
{rows.length > 5 && <div className="text-zinc-400">and {rows.length - 5} more</div>}
</div>
</div>
)}
</div>
<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={Upload} disabled={rows.length === 0} onClick={() => onImport(rows)}>Import {rows.length || ''} lead{rows.length === 1 ? '' : 's'}</Btn>
</div>
</div>
</div>,
document.body,
);
}
@@ -4,14 +4,18 @@
* (spec §4, §8). No lead remains unassigned; SLA breaches surface as urgent.
*/
import React, { useState, useMemo, useEffect } from 'react';
import { createPortal } from 'react-dom';
import { useNavigate } from 'react-router-dom';
import {
Inbox, Sparkles, AlertTriangle, Copy, ShieldCheck, ShieldAlert,
UserPlus, Merge, Phone, Mail, MapPin, Filter, CalendarCheck, Eye,
FileText, Trophy, CheckCircle2, Timer, AlarmClock, ExternalLink,
X, Link2, Hash, Calendar, ClipboardList, GitBranch, ChevronRight,
MessageSquare, Send, CornerUpRight, ShieldQuestion,
} from 'lucide-react';
import { useSocialAds } from '../SocialAdsContext.jsx';
import { PLATFORMS } from '../data/platforms.js';
import { CONVO_TYPES, suggestReply } from '../ai/commentTriage.js';
import { LIFECYCLE } from '../engine/attribution.js';
import { slaState, fmtClock, countBreaches } from '../data/sla.js';
import { Card, SectionHeader, PlatformBadge, StatusChip, Btn } from '../components/ui.jsx';
@@ -42,6 +46,47 @@ const URGENCY = {
low: 'text-zinc-500 bg-zinc-500/10',
};
// Source / attribution derivation (spec §3: UTMs + click IDs preserved)
// Leads carry platform/campaign/form/externalLeadId; the UTM + click-ID params a
// real platform would stamp on the click are reconstructed deterministically here.
const UTM_SOURCE = { meta: 'facebook', google: 'google', linkedin: 'linkedin', reddit: 'reddit', nextdoor: 'nextdoor', whatsapp: 'whatsapp' };
const UTM_MEDIUM = { meta: 'paid_social', google: 'cpc', linkedin: 'paid_social', reddit: 'paid_social', nextdoor: 'paid_social', whatsapp: 'message' };
const CLICK_ID_KEY = { meta: 'fbclid', google: 'gclid', linkedin: 'li_fat_id', reddit: 'rdt_cid', nextdoor: 'nd_click', whatsapp: 'wa_ref' };
const slug = (s) => String(s || '').toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '');
function deriveAttribution(lead, campaign) {
const organic = !lead.campaignId;
const cid = String(lead.externalLeadId || lead.id).replace(/[^a-z0-9]/gi, '').slice(-10).padEnd(10, '0');
const clickKey = CLICK_ID_KEY[lead.platform] || 'click_id';
return {
organic,
utm_source: UTM_SOURCE[lead.platform] || lead.platform,
utm_medium: organic ? 'organic' : (UTM_MEDIUM[lead.platform] || 'paid'),
utm_campaign: organic ? '(none)' : (slug(campaign?.name) || lead.campaignId),
utm_content: lead.formId || '(none)',
clickKey,
clickId: organic ? null : `${lead.platform.slice(0, 3)}.${cid}.${slug(lead.triage.serviceType).slice(0, 4)}`,
};
}
// Best-effort value for a CRM target field, pulled from the lead record.
const TARGET_VALUE = {
'contact.name': l => l.name,
'contact.phone': l => l.phone,
'contact.email': l => l.email,
'contact.company': l => (/,/.test(l.address) ? l.address.split(',')[0] : null),
'property.address': l => l.address,
'lead.role': l => l.role,
'lead.urgencyFlag': l => (l.activeLeak ? 'Yes — active leak' : 'No'),
};
function fmtDateTime(iso) {
if (!iso) return '—';
const d = new Date(iso);
return d.toLocaleString(undefined, { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' });
}
function QualityBar({ value }) {
const color = value >= 75 ? 'bg-emerald-500' : value >= 50 ? 'bg-amber-500' : 'bg-red-500';
return (
@@ -55,10 +100,14 @@ function QualityBar({ value }) {
}
export default function UnifiedLeadInbox() {
const { leads, assignLead, mergeLead, advanceLead } = useSocialAds();
const { leads, campaigns, leadForms, conversations, assignLead, mergeLead, advanceLead, flagLeadReview } = useSocialAds();
const navigate = useNavigate();
const [view, setView] = useState('leads');
const [filter, setFilter] = useState('all');
const [platformFilter, setPlatformFilter] = useState('all');
const [detailId, setDetailId] = useState(null);
const detailLead = leads.find(l => l.id === detailId) || null;
const newConvos = conversations.filter(c => c.status === 'new').length;
// Ticking clock drives the live speed-to-lead SLA countdowns (spec §10).
const [nowMs, setNowMs] = useState(() => Date.now());
@@ -92,6 +141,20 @@ export default function UnifiedLeadInbox() {
<Btn size="sm" variant="outline" icon={ExternalLink} onClick={() => navigate('/ads/lp')}>Preview public form</Btn>
</SectionHeader>
{/* Leads vs. conversations (spec §4: the inbox unifies both by source) */}
<div className="inline-flex items-center gap-1 p-1 mb-4 rounded-xl bg-zinc-100 dark:bg-white/5">
{[['leads', 'Leads', Inbox, leads.length], ['conversations', 'Conversations', MessageSquare, newConvos]].map(([id, label, TabIcon, count]) => (
<button key={id} onClick={() => setView(id)}
className={`inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-semibold transition-colors ${view === id ? 'bg-white dark:bg-zinc-800 text-amber-600 dark:text-amber-400 shadow-sm' : 'text-zinc-500 dark:text-zinc-400 hover:text-zinc-700 dark:hover:text-zinc-200'}`}>
{TabIcon && <TabIcon size={14} />} {label}
{count > 0 && <span className={`px-1.5 rounded-full text-[10px] ${view === id ? 'bg-amber-500/15' : 'bg-zinc-200 dark:bg-white/10'}`}>{count}</span>}
</button>
))}
</div>
{view === 'conversations' && <ConversationsView />}
{view === 'leads' && <>
{/* Speed-to-lead SLA breach banner (spec §11 "Lost leads": breach creates task/alert) */}
{breaches > 0 && (
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-2 mb-4 rounded-xl border border-red-200 dark:border-red-500/30 bg-red-50 dark:bg-red-500/10 px-4 py-3">
@@ -136,7 +199,7 @@ export default function UnifiedLeadInbox() {
<PlatformBadge platformId={l.platform} showName={false} />
<div className="min-w-0">
<div className="flex items-center gap-1.5 flex-wrap">
<h4 className="font-bold text-sm text-zinc-900 dark:text-white truncate">{l.name}</h4>
<button onClick={() => setDetailId(l.id)} className="font-bold text-sm text-zinc-900 dark:text-white truncate hover:text-amber-600 dark:hover:text-amber-400 transition-colors text-left" title="View lead details">{l.name}</button>
{l.duplicateOf && <span className="inline-flex items-center gap-0.5 text-[10px] font-bold text-amber-500"><Copy size={10} /> Dup</span>}
</div>
<div className="text-[11px] text-zinc-500 space-y-0.5 mt-0.5">
@@ -161,7 +224,7 @@ export default function UnifiedLeadInbox() {
<QualityBar value={l.triage.quality} />
</div>
{l.triage.confidence < 0.6 && (
<p className="text-[10px] text-amber-600 dark:text-amber-400/80 mt-1 flex items-center gap-1"><AlertTriangle size={10} /> Low confidence review before auto-nurture.</p>
<LowConfidenceActions lead={l} onFlag={flagLeadReview} compact />
)}
</div>
@@ -194,6 +257,10 @@ export default function UnifiedLeadInbox() {
return <Btn size="sm" variant="primary" icon={step.icon} onClick={() => advanceLead(l.id, step.to, { value, premium })}>{step.label}</Btn>;
})()
)}
<button onClick={() => setDetailId(l.id)} title="View lead details"
className="p-1.5 rounded-lg text-zinc-400 hover:text-amber-500 hover:bg-amber-500/10 transition-colors">
<ChevronRight size={18} />
</button>
</div>
</div>
{!l.consent && l.status === 'new' && (
@@ -205,6 +272,430 @@ export default function UnifiedLeadInbox() {
);
})}
</div>
</>}
<LeadDetailDrawer
lead={detailLead}
campaign={detailLead ? campaigns.find(c => c.id === detailLead.campaignId) : null}
form={detailLead ? leadForms.find(f => f.id === detailLead.formId) : null}
rep={detailLead ? REPS.find(r => r.id === detailLead.assignedTo) : null}
onClose={() => setDetailId(null)}
onAssign={(rep) => { assignLead(detailLead.id, rep); }}
onMerge={() => { mergeLead(detailLead.id); setDetailId(null); }}
onFlag={flagLeadReview}
/>
</div>
);
}
// Low-confidence triage actions (spec §5: ask one question OR create rep task)
function LowConfidenceActions({ lead, onFlag, compact }) {
if (lead.reviewAction) {
return (
<p className={`flex items-center gap-1 text-emerald-600 dark:text-emerald-400 ${compact ? 'text-[10px] mt-1' : 'text-[11px]'}`}>
<CheckCircle2 size={compact ? 11 : 13} />
{lead.reviewAction === 'question' ? 'Clarifying question sent — held from auto-nurture' : 'Review task created — held from auto-nurture'}
</p>
);
}
if (compact) {
return (
<div className="mt-1.5">
<p className="text-[10px] text-amber-600 dark:text-amber-400/80 flex items-center gap-1 mb-1"><AlertTriangle size={10} /> Low confidence don't auto-nurture.</p>
<div className="flex items-center gap-1.5">
<button onClick={() => onFlag(lead.id, 'task')} className="inline-flex items-center gap-1 px-2 py-1 rounded-md text-[10px] font-semibold bg-amber-500/10 text-amber-600 dark:text-amber-400 hover:bg-amber-500/20 transition-colors"><ClipboardList size={11} /> Create task</button>
<button onClick={() => onFlag(lead.id, 'question')} className="inline-flex items-center gap-1 px-2 py-1 rounded-md text-[10px] font-semibold bg-zinc-100 dark:bg-white/5 text-zinc-600 dark:text-zinc-300 hover:bg-zinc-200 dark:hover:bg-white/10 transition-colors"><MessageSquare size={11} /> Ask 1Q</button>
</div>
</div>
);
}
return (
<div className="rounded-lg border border-amber-200 dark:border-amber-500/20 bg-amber-50/60 dark:bg-amber-500/5 p-2.5">
<p className="text-[11px] text-amber-700 dark:text-amber-400 flex items-start gap-1 mb-2"><AlertTriangle size={12} className="mt-0.5 shrink-0" /> Low-confidence triage hold from auto-nurture. Ask one clarifying question or create a rep review task (spec §5).</p>
<div className="flex flex-wrap gap-2">
<Btn size="sm" variant="outline" icon={ClipboardList} onClick={() => onFlag(lead.id, 'task')}>Create review task</Btn>
<Btn size="sm" variant="outline" icon={MessageSquare} onClick={() => onFlag(lead.id, 'question')}>Ask 1 question</Btn>
</div>
</div>
);
}
// Conversations view: comment/DM triage (spec §4, §6)
const TYPE_TONE = {
blue: 'text-blue-600 dark:text-blue-400 bg-blue-500/10',
red: 'text-red-600 dark:text-red-400 bg-red-500/10',
emerald: 'text-emerald-600 dark:text-emerald-400 bg-emerald-500/10',
amber: 'text-amber-600 dark:text-amber-400 bg-amber-500/10',
zinc: 'text-zinc-500 bg-zinc-500/10',
};
const CONVO_FILTERS = [['all', 'All'], ['lead', 'Leads'], ['complaint', 'Complaints'], ['referral', 'Referrals'], ['support', 'Support'], ['spam', 'Spam']];
function ConversationsView() {
const { conversations, routeConversation, convertConversationToLead, dismissConversation, replyConversation } = useSocialAds();
const [typeFilter, setTypeFilter] = useState('all');
const counts = useMemo(() => {
const c = { all: conversations.length };
for (const cv of conversations) c[cv.classification.type] = (c[cv.classification.type] || 0) + 1;
return c;
}, [conversations]);
const visible = conversations.filter(cv => typeFilter === 'all' || cv.classification.type === typeFilter);
return (
<div>
{/* Guardrail note (spec §6) */}
<div className="flex items-start gap-2 mb-4 rounded-xl border border-purple-200 dark:border-purple-500/20 bg-purple-50/60 dark:bg-purple-500/5 px-3 py-2.5">
<Sparkles size={15} className="text-purple-500 shrink-0 mt-0.5" />
<p className="text-[11px] text-zinc-600 dark:text-zinc-300">AI classifies every inbound comment & DM as lead, complaint, spam, referral, or support. <b>Criticism is never auto-deleted</b> complaints always route to a human; only clear spam is dismissable.</p>
</div>
{/* Type filters */}
<div className="flex flex-wrap items-center gap-2 mb-4">
{CONVO_FILTERS.map(([id, label]) => (
<button key={id} onClick={() => setTypeFilter(id)}
className={`px-3 py-1.5 rounded-lg text-xs font-semibold transition-colors ${typeFilter === id ? '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'}`}>
{label} <span className="opacity-60">{counts[id] || 0}</span>
</button>
))}
</div>
<div className="space-y-3">
{visible.length === 0 && <p className="text-sm text-zinc-500 text-center py-8">No conversations match this filter.</p>}
{visible.map(cv => (
<ConversationCard key={cv.id} cv={cv}
onRoute={routeConversation} onConvert={convertConversationToLead}
onDismiss={dismissConversation} onReply={replyConversation} />
))}
</div>
</div>
);
}
function ConversationCard({ cv, onRoute, onConvert, onDismiss, onReply }) {
const [replyOpen, setReplyOpen] = useState(false);
const meta = CONVO_TYPES[cv.classification.type] || CONVO_TYPES.support;
const TypeIcon = meta.icon;
const platform = PLATFORMS.find(p => p.id === cv.platform);
const resolved = cv.status !== 'new';
const lowConf = cv.classification.confidence < 0.6;
return (
<Card className={`!h-auto ${resolved ? 'opacity-70' : ''}`} pad="p-4">
<div className="flex flex-col gap-3">
{/* Header row */}
<div className="flex items-start justify-between gap-3">
<div className="flex items-start gap-3 min-w-0">
<PlatformBadge platformId={cv.platform} showName={false} />
<div className="min-w-0">
<div className="flex items-center gap-1.5 flex-wrap">
<h4 className="font-bold text-sm text-zinc-900 dark:text-white truncate">{cv.author}</h4>
<span className="text-[11px] text-zinc-400">{cv.handle}</span>
<span className="text-[10px] font-bold uppercase tracking-wider px-1.5 py-0.5 rounded bg-zinc-100 dark:bg-white/10 text-zinc-500">{cv.channel === 'dm' ? 'DM' : 'Comment'}</span>
</div>
<p className="text-[11px] text-zinc-400 truncate">{platform?.short || cv.platform} · {cv.postRef} · {fmtDateTime(cv.receivedAt)}</p>
</div>
</div>
<span className={`inline-flex items-center gap-1 text-[10px] font-bold uppercase tracking-wider px-2 py-1 rounded-lg shrink-0 ${TYPE_TONE[meta.tone]}`}>
{TypeIcon && <TypeIcon size={12} />} {meta.label}
</span>
</div>
{/* Message body */}
<p className="text-sm text-zinc-700 dark:text-zinc-200 leading-relaxed">{cv.text}</p>
{/* AI triage line */}
<div className="flex flex-wrap items-center gap-2 text-[11px]">
<span className="inline-flex items-center gap-1 text-purple-500"><Sparkles size={12} /> AI triage</span>
<span className="text-zinc-400">{Math.round(cv.classification.confidence * 100)}% confidence</span>
{lowConf && <span className="inline-flex items-center gap-1 text-amber-600 dark:text-amber-400"><ShieldQuestion size={12} /> low confidence sent to a human</span>}
</div>
{/* Actions / resolved state */}
{resolved ? (
<div className="flex items-center gap-2 text-xs">
<CheckCircle2 size={14} className="text-emerald-500" />
<span className="text-zinc-500">
{cv.status === 'converted' && 'Converted to a lead'}
{cv.status === 'dismissed' && 'Dismissed as spam'}
{cv.status === 'routed' && `Routed${cv.routedTo ? `${cv.routedTo}` : ''}`}
</span>
</div>
) : (
<div className="flex flex-wrap items-center gap-2 pt-1 border-t border-zinc-100 dark:border-white/5">
{/* Primary action per classification */}
{cv.classification.type === 'lead' && <Btn size="sm" variant="primary" icon={UserPlus} onClick={() => onConvert(cv.id)}>Create lead</Btn>}
{cv.classification.type === 'complaint' && <Btn size="sm" variant="danger" icon={CornerUpRight} onClick={() => onRoute(cv.id, meta.route)}>Route to human</Btn>}
{cv.classification.type === 'referral' && <Btn size="sm" variant="success" icon={CornerUpRight} onClick={() => onRoute(cv.id, meta.route)}>Send promoter path</Btn>}
{cv.classification.type === 'support' && <Btn size="sm" variant="outline" icon={CornerUpRight} onClick={() => onRoute(cv.id, meta.route)}>Route to support</Btn>}
{cv.classification.type === 'spam'
? <Btn size="sm" variant="outline" icon={meta.icon} onClick={() => onDismiss(cv.id)}>Dismiss spam</Btn>
: <Btn size="sm" variant="ghost" icon={Send} onClick={() => setReplyOpen(o => !o)}>Reply</Btn>}
</div>
)}
{/* Reply composer (approved template, spec §6) */}
{replyOpen && !resolved && (
<div className="rounded-xl border border-zinc-200 dark:border-white/10 bg-zinc-50 dark:bg-white/[0.02] p-3 space-y-2">
<p className="text-[10px] font-bold uppercase tracking-wider text-zinc-400">Suggested approved reply</p>
{cv.classification.type === 'complaint' ? (
<p className="text-[11px] text-red-500 flex items-start gap-1"><AlertTriangle size={12} className="mt-0.5 shrink-0" /> Complaints are handled personally by a human, not by template. Use "Route to human".</p>
) : (
<>
<textarea defaultValue={suggestReply(cv.classification.type)} rows={3}
className="w-full rounded-lg border border-zinc-200 dark:border-white/10 bg-white dark:bg-zinc-900 px-2.5 py-2 text-xs text-zinc-700 dark:text-zinc-200 focus:outline-none focus:ring-2 focus:ring-amber-500" />
<div className="flex justify-end">
<Btn size="sm" variant="primary" icon={Send} onClick={() => { onReply(cv.id); setReplyOpen(false); }}>Send reply</Btn>
</div>
</>
)}
</div>
)}
</div>
</Card>
);
}
// Lead detail slide-over: full source/attribution, submitted fields, consent
function LeadDetailDrawer({ lead, campaign, form, rep, onClose, onAssign, onMerge, onFlag }) {
// Lock background scroll while open (hook must run before any early return).
useEffect(() => {
if (!lead) return;
const prev = document.body.style.overflow;
document.body.style.overflow = 'hidden';
const onKey = (e) => { if (e.key === 'Escape') onClose(); };
window.addEventListener('keydown', onKey);
return () => { document.body.style.overflow = prev; window.removeEventListener('keydown', onKey); };
}, [lead, onClose]);
const [showPolicy, setShowPolicy] = useState(false);
if (!lead) return null;
const attr = deriveAttribution(lead, campaign);
const platform = PLATFORMS.find(p => p.id === lead.platform);
const t = lead.triage;
const copyVal = (text) => { navigator.clipboard?.writeText(text); toast.success('Copied'); };
// Portal to body so the fixed drawer escapes any transformed/animated module
// ancestor and sits above the sticky Ad Engine header.
return createPortal(
<div className="fixed inset-0 z-[1000] flex justify-end">
<div className="absolute inset-0 bg-black/40 backdrop-blur-[1px]" onClick={onClose} />
<div className="relative w-full sm:max-w-md h-full max-h-screen bg-white dark:bg-zinc-900 shadow-2xl border-l border-zinc-200 dark:border-white/10 overflow-y-auto animate-in slide-in-from-right">
{/* 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-4 sm:px-5 py-3 flex items-start justify-between gap-3">
<div className="flex items-center gap-2.5 min-w-0">
<PlatformBadge platformId={lead.platform} showName={false} />
<div className="min-w-0">
<h3 className="font-bold text-sm text-zinc-900 dark:text-white truncate">{lead.name}</h3>
<p className="text-[11px] text-zinc-500">{platform?.short || lead.platform} lead · {fmtDateTime(lead.receivedAt)}</p>
</div>
</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 shrink-0"><X size={18} /></button>
</div>
<div className="p-4 sm:p-5 space-y-5">
{/* Status row */}
<div className="flex flex-wrap items-center gap-2">
<StatusChip status={lead.status} />
<StageStepper stage={lead.stage} />
{lead.duplicateOf && <span className="inline-flex items-center gap-0.5 text-[10px] font-bold text-amber-500"><Copy size={11} /> Duplicate of {lead.duplicateOf}</span>}
</div>
{/* Contact */}
<DrawerSection icon={UserPlus} title="Contact">
<Row label="Name" value={lead.name} />
<Row label="Phone" value={lead.phone || '—'} onCopy={lead.phone && (() => copyVal(lead.phone))} />
<Row label="Email" value={lead.email || '—'} onCopy={lead.email && (() => copyVal(lead.email))} />
<Row label="Address" value={lead.address || '—'} />
{rep && <Row label="Assigned to" value={rep.name} />}
</DrawerSection>
{/* Source & attribution */}
<DrawerSection icon={Link2} title="Source & attribution">
<Row label="Platform" value={platform?.short || lead.platform} />
<Row label="Campaign" value={campaign?.name || (attr.organic ? 'Organic / no campaign' : lead.campaignId)} />
<Row label="Lead form" value={form?.title || (lead.formId || '(none)')} />
{form?.platformFormId && <Row label="Platform form ID" value={form.platformFormId} mono />}
<Row label="External lead ID" value={lead.externalLeadId || '—'} mono onCopy={lead.externalLeadId && (() => copyVal(lead.externalLeadId))} />
<Row label="Received" value={fmtDateTime(lead.receivedAt)} icon={Calendar} />
<div className="pt-1 mt-1 border-t border-dashed border-zinc-200 dark:border-white/10">
<p className="text-[10px] font-bold uppercase tracking-wider text-zinc-400 mb-1.5 flex items-center gap-1"><Hash size={11} /> Tracking params</p>
<Row label="utm_source" value={attr.utm_source} mono />
<Row label="utm_medium" value={attr.utm_medium} mono />
<Row label="utm_campaign" value={attr.utm_campaign} mono />
<Row label="utm_content" value={attr.utm_content} mono />
<Row label={attr.clickKey} value={attr.clickId || '— (organic)'} mono onCopy={attr.clickId && (() => copyVal(attr.clickId))} />
</div>
</DrawerSection>
{/* AI triage */}
<DrawerSection icon={Sparkles} title="AI triage" accent="purple">
<Row label="Service type" value={t.serviceType} />
<Row label="Urgency" value={<span className={`text-[10px] font-bold uppercase px-1.5 py-0.5 rounded ${URGENCY[t.urgency]}`}>{t.urgency}</span>} />
<Row label="Role" value={t.role.replace(/_/g, ' ')} />
<Row label="Intent" value={t.intent} />
<Row label="Lead quality" value={<QualityBar value={t.quality} />} />
<Row label="Confidence" value={`${Math.round(t.confidence * 100)}%`} />
{t.confidence < 0.6 && (
<div className="mt-2"><LowConfidenceActions lead={lead} onFlag={onFlag} /></div>
)}
</DrawerSection>
{/* Submitted form fields */}
{form && (
<DrawerSection icon={ClipboardList} title="Submitted form fields">
<div className="space-y-1.5">
{form.fields.map(f => {
const val = TARGET_VALUE[f.target]?.(lead);
return (
<div key={f.source} className="flex items-start justify-between gap-3 text-xs">
<div className="min-w-0">
<span className="text-zinc-700 dark:text-zinc-200">{f.source.replace(/_/g, ' ')}</span>
<span className="text-[10px] text-zinc-400 flex items-center gap-1"><ChevronRight size={9} /> {f.target}{f.required && <span className="text-red-400">*</span>}</span>
</div>
<span className="text-zinc-500 text-right shrink-0 max-w-[45%] truncate">{val ?? '—'}</span>
</div>
);
})}
</div>
</DrawerSection>
)}
{/* Consent proof */}
<DrawerSection icon={lead.consent ? ShieldCheck : ShieldAlert} title="Consent proof" accent={lead.consent ? 'emerald' : 'red'}>
{lead.consent ? (
<>
<span className="inline-flex items-center gap-1 text-[11px] font-semibold text-emerald-600 dark:text-emerald-400 mb-1.5"><ShieldCheck size={13} /> Consent captured at submission</span>
{form?.consentText && <p className="text-[11px] text-zinc-600 dark:text-zinc-300 leading-relaxed italic">"{form.consentText}"</p>}
<div className="mt-2 space-y-1">
<Row label="Channel/purpose" value="Call · Text · Email — marketing" />
<Row label="Captured" value={fmtDateTime(lead.receivedAt)} />
{form?.privacyPolicyUrl && <Row label="Privacy policy" value={<button onClick={() => setShowPolicy(true)} className="text-blue-500 hover:underline inline-flex items-center gap-1">View <ExternalLink size={10} /></button>} />}
</div>
</>
) : (
<p className="text-[11px] text-red-500 flex items-start gap-1"><ShieldAlert size={13} className="mt-0.5 shrink-0" /> No consent proof on file restricted to transactional/manual follow-up only. Cannot enter marketing automation (spec §6).</p>
)}
</DrawerSection>
{/* Routed playbook (spec §5 nurture launch) */}
<DrawerSection icon={GitBranch} title="Mapped nurture">
<p className="text-[11px] text-zinc-500">On assignment this lead enters the <b className="text-zinc-700 dark:text-zinc-200">{NURTURE_FOR[t.serviceType] || 'Default Inbound'}</b> playbook with a speed-to-lead SLA for {t.serviceType} intent.</p>
</DrawerSection>
{/* Actions */}
<div className="flex flex-wrap gap-2 pt-1">
{lead.duplicateOf && lead.status !== 'merged' && <Btn size="sm" variant="outline" icon={Merge} onClick={onMerge}>Merge duplicate</Btn>}
{lead.status === 'new' && !lead.duplicateOf && (
lead.consent
? <DrawerAssign onAssign={onAssign} />
: <span className="text-[11px] text-amber-600 dark:text-amber-400/80 flex items-center gap-1"><ShieldAlert size={12} /> Assignment blocked no consent</span>
)}
<Btn size="sm" variant="ghost" icon={X} onClick={onClose} className="ml-auto">Close</Btn>
</div>
</div>
</div>
{showPolicy && <PrivacyPolicyModal url={form?.privacyPolicyUrl} consentText={form?.consentText} onClose={() => setShowPolicy(false)} />}
</div>,
document.body,
);
}
// In-app privacy policy (the seeded URL is a placeholder prod domain; rendering the
// policy here keeps the consent-proof reference auditable without a dead external link).
function PrivacyPolicyModal({ url, consentText, onClose }) {
return (
<div className="fixed inset-0 z-[1100] 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-lg max-h-[85vh] 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">
<div className="sticky top-0 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 min-w-0">
<ShieldCheck size={18} className="text-emerald-500 shrink-0" />
<div className="min-w-0">
<h3 className="font-bold text-sm text-zinc-900 dark:text-white">Privacy Policy</h3>
{url && <p className="text-[10px] text-zinc-400 font-mono truncate">{url}</p>}
</div>
</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 shrink-0"><X size={18} /></button>
</div>
<div className="px-5 py-4 text-xs text-zinc-600 dark:text-zinc-300 leading-relaxed space-y-3">
<p className="text-[11px] text-zinc-400">LynkedUp Roofing last updated June 2026</p>
{consentText && (
<div className="rounded-lg bg-zinc-50 dark:bg-white/[0.03] border border-zinc-200 dark:border-white/10 p-3">
<p className="text-[10px] font-bold uppercase tracking-wider text-zinc-400 mb-1">Consent shown at submission</p>
<p className="italic">"{consentText}"</p>
</div>
)}
<PolicyBlock title="Information we collect">Name, phone, email, and property address you submit through our ad lead forms, plus the ad source, campaign, and tracking identifiers used to attribute your inquiry.</PolicyBlock>
<PolicyBlock title="How we use it">To respond to your inspection or service request, schedule appointments, and send service-related updates. With your consent, we may contact you by call, text, and email about your project.</PolicyBlock>
<PolicyBlock title="Your choices">You can opt out of marketing messages at any time by replying STOP to texts, clicking unsubscribe in emails, or contacting us. Opting out does not affect transactional messages about an active job.</PolicyBlock>
<PolicyBlock title="Data sharing">We do not sell your personal information. We share it only with service providers who help us deliver our services, under contract and consistent with this policy.</PolicyBlock>
<PolicyBlock title="Contact">Questions about this policy or your data? Email privacy@lynkeduppro.com or call (972) 555-0100.</PolicyBlock>
<p className="text-[10px] text-zinc-400 pt-1 border-t border-zinc-200 dark:border-white/10">Demo content this in-app policy stands in for the tenant's hosted privacy page.</p>
</div>
</div>
</div>
);
}
function PolicyBlock({ title, children }) {
return (
<div>
<h4 className="font-semibold text-zinc-800 dark:text-zinc-100 text-[12px] mb-0.5">{title}</h4>
<p>{children}</p>
</div>
);
}
const NURTURE_FOR = {
storm: 'Storm Inspection Nurture',
emergency: 'Emergency Dispatch Nurture',
commercial: 'Commercial Bid Nurture',
replacement: 'Retail Replacement Nurture',
referral: 'Completion Promoter Path',
maintenance: 'Reactivation Nurture',
};
function DrawerSection({ icon: Icon, title, accent = 'amber', children }) {
const tone = { amber: 'text-amber-500', purple: 'text-purple-500', emerald: 'text-emerald-500', red: 'text-red-500' }[accent] || 'text-amber-500';
return (
<div>
<p className={`text-[10px] font-bold uppercase tracking-wider ${tone} mb-2 flex items-center gap-1.5`}>{Icon && <Icon size={13} />}{title}</p>
<div className="rounded-xl border border-zinc-200 dark:border-white/10 bg-zinc-50 dark:bg-white/[0.02] p-3 space-y-1">{children}</div>
</div>
);
}
function Row({ label, value, mono, icon: Icon, onCopy }) {
return (
<div className="flex items-start justify-between gap-3 text-xs">
<span className="text-zinc-400 shrink-0 flex items-center gap-1">{Icon && <Icon size={11} />}{label}</span>
<span className={`text-right text-zinc-700 dark:text-zinc-200 break-words min-w-0 flex items-center gap-1.5 justify-end ${mono ? 'font-mono text-[11px]' : ''}`}>
{value}
{onCopy && <button onClick={onCopy} title="Copy" className="text-zinc-400 hover:text-amber-500 shrink-0"><Copy size={11} /></button>}
</span>
</div>
);
}
function DrawerAssign({ onAssign }) {
const [open, setOpen] = useState(false);
return (
<div className="relative">
<Btn size="sm" variant="primary" icon={UserPlus} onClick={() => setOpen(o => !o)}>Assign rep</Btn>
{open && (
<div className="absolute left-0 bottom-full mb-1 w-44 rounded-xl border border-zinc-200 dark:border-white/10 bg-white dark:bg-zinc-900 shadow-xl z-20 p-1">
{REPS.map(r => (
<button key={r.id} onClick={() => { onAssign(r.id); 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">
{r.name}
</button>
))}
</div>
)}
</div>
);
}