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 => (
-