diff --git a/src/modules/socialAdsEngine/SocialAdsContext.jsx b/src/modules/socialAdsEngine/SocialAdsContext.jsx index 63911bb..7881d4e 100644 --- a/src/modules/socialAdsEngine/SocialAdsContext.jsx +++ b/src/modules/socialAdsEngine/SocialAdsContext.jsx @@ -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 {children}; diff --git a/src/modules/socialAdsEngine/ai/audienceAdvisor.js b/src/modules/socialAdsEngine/ai/audienceAdvisor.js new file mode 100644 index 0000000..8de9bd7 --- /dev/null +++ b/src/modules/socialAdsEngine/ai/audienceAdvisor.js @@ -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 12–18 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', + ], + }, +]; diff --git a/src/modules/socialAdsEngine/ai/commentTriage.js b/src/modules/socialAdsEngine/ai/commentTriage.js new file mode 100644 index 0000000..c2dd7ce --- /dev/null +++ b/src/modules/socialAdsEngine/ai/commentTriage.js @@ -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! We’d love to help with your roof. What’s 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 we’ll 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 ''; + } +} diff --git a/src/modules/socialAdsEngine/data/contactPool.js b/src/modules/socialAdsEngine/data/contactPool.js new file mode 100644 index 0000000..a519e8d --- /dev/null +++ b/src/modules/socialAdsEngine/data/contactPool.js @@ -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, // 0–540 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, +}; diff --git a/src/modules/socialAdsEngine/data/seed.js b/src/modules/socialAdsEngine/data/seed.js index 8289925..a9a5499 100644 --- a/src/modules/socialAdsEngine/data/seed.js +++ b/src/modules/socialAdsEngine/data/seed.js @@ -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' }, diff --git a/src/modules/socialAdsEngine/engine/attribution.js b/src/modules/socialAdsEngine/engine/attribution.js index daf6f46..3b0af84 100644 --- a/src/modules/socialAdsEngine/engine/attribution.js +++ b/src/modules/socialAdsEngine/engine/attribution.js @@ -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 = {}; diff --git a/src/modules/socialAdsEngine/screens/AttributionDashboard.jsx b/src/modules/socialAdsEngine/screens/AttributionDashboard.jsx index 1cd6681..46b518e 100644 --- a/src/modules/socialAdsEngine/screens/AttributionDashboard.jsx +++ b/src/modules/socialAdsEngine/screens/AttributionDashboard.jsx @@ -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 (
-
- {['platform', 'campaign'].map(v => ( - @@ -62,13 +147,82 @@ export default function AttributionDashboard() {
- {/* Live banner */} -
- - Live - — lead-to-won metrics update from CRM events as you act in the Lead Inbox. Spend is platform-reported. + {/* Live banner + drill-down filter chip */} +
+
+ + Live + — lead-to-won metrics update from CRM events as you act in the Lead Inbox. Spend is platform-reported. +
+ {selected && ( + + )}
+ {/* Budget pacing & anomaly alerts (spec §11) */} + + {/* Alerts */} + {openAlerts.length === 0 ? ( +
+ No open anomalies — all campaigns within guardrails. +
+ ) : ( +
+ {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 ( +
+ {AIcon && } +
+
+ {campName(a.campaignId)} + {a.severity} + {meta.label} +
+

{a.metric}

+

{a.detail}

+

AI recommends: {a.recommendation === 'pause' ? 'pause the campaign' : 'review & optimize'}

+
+
+ + + +
+
+ ); + })} +
+ )} + + {/* Budget pacing per active campaign */} +

Budget pacing — active campaigns

+
+ {pacing.map(p => ( +
+ {p.name} +
+
+
+
+
+ {money(p.projected)} / {money(p.monthlyCap)} + {PACE_LABEL[p.status]} +
+ ))} +
+ + {/* KPI row */}
@@ -111,26 +265,28 @@ export default function AttributionDashboard() { view === 'platform' ? (getPlatform(k)?.short || k) : campName(k).slice(0, 8)} /> + interval={0} tickFormatter={shortLabel} /> [money(v), 'CPL']} labelFormatter={(k) => view === 'platform' ? getPlatform(k)?.name : campName(k)} /> + formatter={(v) => [money(v), 'CPL']} labelFormatter={labelFor} /> - {bySource.map((s, i) => )} + {bySource.map((s, i) => )} {/* ROAS leaderboard */} - +
{ranked.map((s, i) => ( -
+
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'}`}> {i + 1} {view === 'platform' ? - : {campName(s.key)}} + : {labelFor(s.key)}}
{money(s.revenue)} = 1 ? 'text-emerald-500' : 'text-red-500'}`}>{s.roas.toFixed(1)}× @@ -141,8 +297,98 @@ export default function AttributionDashboard() {
+ {/* Review & referral lift (spec §8 "reviews"; §10 review/referral lift) */} + +
+ + + +
+ +
+ + + + + + + + + + + + {reviewsBreakdown.length === 0 && } + {reviewsBreakdown.map(b => ( + 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'}`}> + + + + + + + ))} + +
{view}ReviewsAvg ratingReferral leadsReactivation $
No reviews for this dimension yet.
+ {view === 'platform' ? : {labelFor(b.key)}} + {b.count}{b.avgRating.toFixed(1)}★{b.referralLeads}{money(b.reactivationRevenue)}
+
+
+ + {/* Multi-touch attribution models (spec §11) */} + + {[['first', 'First-touch'], ['last', 'Last-touch'], ['multi', 'Multi-touch']].map(([m, label]) => ( + + ))} +
+ }> +

+ + {MODEL_NOTE[model]} Highlighted column is the active model; compare to see how credit shifts. +

+
+ + + + + + + + + + + {modelRows.length === 0 && } + {modelRows.map(r => ( + 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'}`}> + + + + + + ))} + + {modelRows.length > 0 && ( + + + + + + + )} +
{view}First-touchLast-touchMulti-touch
No closed-won journeys for this dimension yet.
+ {view === 'platform' ? : {labelFor(r.key)}} + {money(r.first)}{money(r.last)}{money(r.multi)}
Total ({model}-touch){money(modelTotal)}
+
+ + {/* Detail table */} - +
@@ -160,9 +406,10 @@ export default function AttributionDashboard() { {bySource.map(s => ( - + 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'}`}> @@ -181,3 +428,14 @@ export default function AttributionDashboard() { ); } + +function LiftStat({ icon: Icon, color, value, label, sub }) { + return ( +
+ {Icon && } +

{value}

+

{label}

+ {sub &&

{sub}

} +
+ ); +} diff --git a/src/modules/socialAdsEngine/screens/AudienceSegments.jsx b/src/modules/socialAdsEngine/screens/AudienceSegments.jsx index 5f4b256..068cc9d 100644 --- a/src/modules/socialAdsEngine/screens/AudienceSegments.jsx +++ b/src/modules/socialAdsEngine/screens/AudienceSegments.jsx @@ -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 (
- + + setEditing({})}>New segment + + + {/* AI audience suggestions (spec §6) */} + {suggestions.length > 0 && ( +
+
+ +

AI suggested segments — consent + suppression always applied; no sensitive-attribute targeting.

+
+
+ {suggestions.map(s => ( + createAudience({ name: s.name, segmentType: s.segmentType, filterDef: s.filterDef })} + onCustomize={() => setEditing({ preset: s })} + onDismiss={() => setDismissed(d => [...d, s.key])} /> + ))} +
+
+ )}
{audiences.map(a => { - const 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 (
@@ -50,29 +85,47 @@ export default function AudienceSegments() {
- {/* Eligible bar */} + {/* Eligible bar (consent ∩ not-suppressed) */}
- Eligible to sync - {eligiblePct}% + Eligible to sync (consent + suppression) + {eligible.toLocaleString()} · {eligiblePct}%
-
- Updated {a.lastCount} +
+ Updated {a.lastCount}{a.frequencyCap ? ` · freq cap ${a.frequencyCap}/wk` : ''} + +
+ +
+ setEditing({ audience: a })}>Edit + refreshAudience(a.id)}>Refresh
- refreshAudience(a.id)}>Refresh exportCsv(a)}>CSV - syncAudience(a)}>Sync + handleSync(a, dest)} />
); })}
+ + {editing && ( + setEditing(null)} + onSave={(def) => { + if (editing.audience) updateAudience(editing.audience.id, def); + else createAudience(def); + setEditing(null); + }} + /> + )}
); } @@ -80,9 +133,247 @@ export default function AudienceSegments() { function Stat({ icon: Icon, label, value, color }) { return (
- + {Icon && }

{value.toLocaleString()}

{label}

); } + +// ── Sync status + destination menu (spec §8 "export/sync status"; §12 E2) ────── +const SYNC_DESTINATIONS = ['Meta Custom Audience', 'Google Customer Match', 'LinkedIn Matched Audiences']; + +function SyncStatusChip({ audience }) { + if (audience.syncStatus === 'synced') { + const when = audience.lastSyncedAt + ? new Date(audience.lastSyncedAt).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) + : ''; + const short = (audience.syncDestination || '').split(' ')[0]; + return ( + + Synced · {short}{when ? ` · ${when}` : ''} + + ); + } + return ( + + Not synced + + ); +} + +function SyncMenu({ onSync }) { + const [open, setOpen] = useState(false); + return ( +
+ + {open && ( + <> +
setOpen(false)} /> +
+

Sync to platform

+ {SYNC_DESTINATIONS.map(d => ( + + ))} +
+ + )} +
+ ); +} + +// ── AI suggestion card (spec §6): reasons visible, one-click create, opt-out aware ── +function SuggestionCard({ s, onCreate, onCustomize, onDismiss }) { + const SIcon = s.icon; + const reach = useMemo(() => previewSegment(s.filterDef), [s.filterDef]); + return ( +
+
+
+ {SIcon && } +
+

{s.name}

+ AI · {Math.round(s.confidence * 100)}% match +
+
+ +
+ +

{s.rationale}

+ + {/* AI reasons visible (spec §12 E1) */} +
    + {s.reasons.map((r, i) => ( +
  • + {r} +
  • + ))} +
+ +
+ + {reach.eligible.toLocaleString()} eligible after consent + suppression +
+ +
+ Create + Customize +
+
+ ); +} + +// ── Segment builder modal: filter CRM contacts with a live, opt-out-aware count ── +function SegmentBuilder({ audience, preset, onClose, onSave }) { + const init = audience || preset; // edit an existing audience, or seed a new one from an AI suggestion + const [name, setName] = useState(init?.name || ''); + const [segmentType, setSegmentType] = useState(init?.segmentType || 'crm_stage'); + const [f, setF] = useState(() => ({ ...DEFAULT_FILTER_DEF, ...(init?.filterDef || {}) })); + + const set = (patch) => setF(prev => ({ ...prev, ...patch })); + const toggleZip = (z) => set({ geo: f.geo.includes(z) ? f.geo.filter(x => x !== z) : [...f.geo, z] }); + + const preview = useMemo(() => previewSegment(f), [f]); + const optOuts = preview.matched - preview.consented; + + return createPortal( +
+
+
e.stopPropagation()} className="relative w-full max-w-2xl max-h-[90vh] overflow-y-auto rounded-2xl bg-white dark:bg-zinc-900 shadow-2xl border border-zinc-200 dark:border-white/10 animate-in zoom-in-95"> + {/* Header */} +
+
+ +

{audience ? 'Edit segment' : 'New segment'}

+
+ +
+ +
+ {/* Name + type */} +
+ + setName(e.target.value)} placeholder="e.g. Plano storm — past 30 days" + className={inCls} /> + + + + +
+ + {/* CRM filters (spec §12 E1) */} +
+

Filter CRM contacts

+
+ + + + + + + + + + + + + + set({ frequencyCap: e.target.value ? Number(e.target.value) : null })} className={inCls} /> + +
+
+ + {/* Geography */} + Geography (service-area ZIPs)}> +
+ {FILTER_OPTIONS.zips.map(z => ( + + ))} + {f.geo.length > 0 && } +
+
+ + {/* Mandatory consent + suppression (spec §6) */} +
+

Always applied (mandatory)

+

Consent filter — only opted-in contacts are reachable.

+

Suppression list — opt-outs/DNC removed, including for retargeting.

+

No sensitive-attribute targeting — only the CRM fields above can be used.

+
+ + {/* Live preview (spec §8 / §12 E2: count respects opt-outs + suppressions) */} +
+

Estimated reach (live)

+
+ + + + + +
+
+
+ + {/* Footer */} +
+ Cancel + onSave({ name, segmentType, filterDef: f })}> + {audience ? 'Save segment' : 'Create segment'} · {preview.eligible.toLocaleString()} eligible + +
+
+
, + document.body, + ); +} + +const inCls = 'w-full rounded-lg border border-zinc-200 dark:border-white/10 bg-white dark:bg-zinc-900 px-2.5 py-2 text-sm text-zinc-800 dark:text-zinc-100 focus:outline-none focus:ring-2 focus:ring-amber-500'; + +function Field({ label, children }) { + return ( + + ); +} + +function FunnelStep({ label, value, highlight }) { + return ( +
+

{value.toLocaleString()}

+

{label}

+
+ ); +} + +function FunnelArrow({ note }) { + return ( +
+ + {note} +
+ ); +} diff --git a/src/modules/socialAdsEngine/screens/ComplianceCenter.jsx b/src/modules/socialAdsEngine/screens/ComplianceCenter.jsx index ed70f22..a25ed3d 100644 --- a/src/modules/socialAdsEngine/screens/ComplianceCenter.jsx +++ b/src/modules/socialAdsEngine/screens/ComplianceCenter.jsx @@ -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 (
@@ -54,6 +172,59 @@ export default function ComplianceCenter() { :
{pending.map(a => )}
} + {/* Consent text versions (spec §8) */} + +
+ {leadForms.map(form => { + const versions = consentVersions.filter(v => v.formId === form.id); + if (versions.length === 0) return null; + return ; + })} +
+
+ + {/* Review/testimonial usage + FTC disclosure (spec §8, §11) */} + +
+ {usedReviews.length === 0 &&

No reviews are currently used in ad creative.

} + {usedReviews.map(r => { + const cleared = r.usagePermission === 'granted' && r.disclosureStatus === 'compliant'; + return ( +
+
+
+
+ + {'★'.repeat(r.rating)} + used in {campName(r.usedIn)} + {cleared + ? Cleared to use + : Creative blocked} +
+

"{r.text}"

+
+ + Permission: {r.usagePermission} + + + FTC disclosure: {r.disclosureStatus} + +
+
+
+ {r.usagePermission !== 'granted' + ? setReviewPermission(r.id, 'granted')}>Grant permission + : setReviewPermission(r.id, 'denied')}>Revoke} + {r.disclosureStatus !== 'compliant' && setReviewDisclosure(r.id, 'compliant')}>Add disclosure} +
+
+
+ ); + })} +
+

Per FTC and platform guidance, testimonials in ads require documented permission and clear disclosure. Unapproved review creative cannot publish.

+
+
{/* Conversion sync log */} @@ -79,12 +250,29 @@ export default function ComplianceCenter() {
- {/* Recently resolved + AI audit */} - + {/* AI audit & quality (spec §8 "AI audit queue", §10 AI quality) */} + +
+ + + + + + +
+ +
+ +

+ All AI copy required human approval — 0 auto-published. {aiMetrics.aiPending > 0 ? `${aiMetrics.aiPending} AI-generated item(s) still awaiting review in the queue above.` : 'No AI items pending.'} +

+
+ +

Recently approved

{resolved.map(a => (
- +

{a.title}

{TYPE_LABEL[a.type]} · approved by {a.reviewer}

@@ -92,15 +280,153 @@ export default function ComplianceCenter() {
))} -
- -

- AI guardrail summary: 2 creatives auto-flagged this week (1 scarcity, 1 price claim), 0 auto-published. All AI copy required human approval. -

-
+ + {/* Audit log + export (spec §12 H2) */} + Export CSV}> + {/* Filters */} +
+
+ {['all', ...Object.keys(AUDIT_CAT)].map(c => ( + + ))} +
+ + +
+ + 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" /> +
+ {filteredAudit.length} entries +
+ + {/* Table */} +
+
- {view === 'platform' ? : {campName(s.key)}} + {view === 'platform' ? : {labelFor(s.key)}} {money(s.spend)} {s.leads}
+ + + + + + + + + + + + {filteredAudit.length === 0 && } + {filteredAudit.map(e => { + const cat = AUDIT_CAT[e.category] || AUDIT_CAT.campaign; + const CIcon = cat.icon; + return ( + + + + + + + + + ); + })} + +
WhenCategoryActionDetailActorSource
No audit entries match these filters.
{fmtDateTime(e.ts)} + {CIcon && }{cat.label} + {e.action}{e.detail}{e.actor}{e.platform ? : }
+
+
+
+ ); +} + +function AiStat({ value, label, color }) { + return ( +
+

{value}

+

{label}

+
+ ); +} + +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 ( +
+
+
+ +

{form.title}

+
+ {live && Live v{live.version}} +
+ + {/* Live version */} + {live && } + + {/* Pending versions awaiting approval */} + {pending.map(v => ( +
+
+ v{v.version} pending approval +
+ onApprove(v.id)}>Approve + onBlock(v.id)}>Block +
+
+ +
+ ))} + + {/* History */} + {history.length > 0 && ( +
+ + {showHistory && ( +
+ {history.map(v => )} +
+ )} +
+ )} +
+ ); +} + +function ConsentVersionView({ v, live, muted }) { + return ( +
+
+ v{v.version} + · {v.channel} + {!live && } + {v.reviewer ? `${v.reviewer} · ` : ''}{fmtDate(v.createdAt)} +
+

"{v.text}"

+
+ Opt-out: {v.optOut} + {v.privacyPolicyUrl && Privacy: {v.privacyPolicyUrl}} +
); } diff --git a/src/modules/socialAdsEngine/screens/ConnectedAccounts.jsx b/src/modules/socialAdsEngine/screens/ConnectedAccounts.jsx index 6325d2f..18f72a5 100644 --- a/src/modules/socialAdsEngine/screens/ConnectedAccounts.jsx +++ b/src/modules/socialAdsEngine/screens/ConnectedAccounts.jsx @@ -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 (
+ {/* Token-expiration / failure alerts (spec §8 MVP, §11, §12 A1) */} + {tokenAlerts.length > 0 && ( +
+
+ + {tokenAlerts.length} account token{tokenAlerts.length > 1 ? 's' : ''} need attention — leads can be lost while a connection is down. +
+
+ {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 ( +
+ {PIcon && } + {p?.short || a.platform} + — {reason} +
+ setImportFor(a)}>Manual import + reconnectAccount(a.id)}>Reconnect +
+
+ ); + })} +
+
+ )} +
{PLATFORMS.map(p => { const acc = accountByPlatform[p.id]; @@ -92,6 +138,32 @@ export default function ConnectedAccounts() {
+ {/* Permissions / scopes (spec §8 "permission status", §7 scopes_json) */} + {(() => { + const perm = permState(acc); + const pm = PERM_META[perm]; + const PermIcon = pm.icon; + return ( +
+
+ Permissions + {PermIcon && } {pm.label} +
+ {acc?.scopes?.length > 0 ? ( +
+ {acc.scopes.map(s => ( + {s} + ))} +
+ ) : ( +

+ {perm === 'none' ? 'No permissions granted — connect to authorize scopes.' : 'Missing required scopes — reconnect to re-grant.'} +

+ )} +
+ ); + })()} + {acc?.cautions && (

{p.cautions} @@ -110,11 +182,141 @@ export default function ConnectedAccounts() { testWebhook(acc?.id)} disabled={!connected}> Test lead + setImportFor(acc)} disabled={!acc} title="Manually import leads from a CSV"> + Import +

); })}
+ + {importFor && ( + setImportFor(null)} + onImport={(rows) => { importLeads(importFor.platform, rows); setImportFor(null); }} + /> + )}
); } + +// ── 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( +
+
+
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"> +
+
+ {platform && } +
+

Manual lead import — {platform?.short || account.platform}

+

Fallback for when a webhook or token is down

+
+
+ +
+ +
+

Paste CSV or upload a file exported from the platform. Columns: name, phone, email, address, service, consent. Rows without explicit consent import as manual-follow-up only.

+ +
+ + setText(SAMPLE_CSV)}>Load sample + Template +
+ +