/** * AttributionDashboard — spend, CPL, cost-per-appointment, show rate, close rate, * 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, useCallback } from 'react'; import { BarChart3, DollarSign, Users, CalendarCheck, TrendingUp, Percent, Eye, Trophy, 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, 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'; 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, 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). // 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, cpl: live.leads ? reported.spend / live.leads : 0, costPerAppt: live.appointments ? reported.spend / live.appointments : 0, roas: reported.spend ? live.revenue / reported.spend : 0, }), [reported, live]); 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 (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 })); }, [filteredDaily]); 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); return (
{['platform', 'campaign', 'creative', 'playbook'].map(v => ( ))}
{/* 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 */}
{/* Revenue vs spend */} [money(v), n === 'revenue' ? 'Revenue' : 'Spend']} />
{/* CPL by source */} [money(v), 'CPL']} labelFormatter={labelFor} /> {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' ? : {labelFor(s.key)}}
{money(s.revenue)} = 1 ? 'text-emerald-500' : 'text-red-500'}`}>{s.roas.toFixed(1)}×
))}
{/* 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} Reviews Avg rating Referral leads Reactivation $
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-touch Last-touch Multi-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 */}
{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'}`}> ))}
{view} Spend Leads CPL Appts Show Close Revenue ROAS
{view === 'platform' ? : {labelFor(s.key)}} {money(s.spend)} {s.leads} {money(s.cpl)} {s.appointments} {pct(s.showRate)} {pct(s.closeRate)} {money(s.revenue)} = 1 ? 'text-emerald-500' : 'text-red-500'}`}>{s.roas.toFixed(1)}×
); } function LiftStat({ icon: Icon, color, value, label, sub }) { return (
{Icon && }

{value}

{label}

{sub &&

{sub}

}
); }