social ads engine module: develo all features

This commit is contained in:
Mayur Shinde
2026-06-12 16:43:12 +05:30
parent e53c38ce59
commit a2c77878ff
11 changed files with 2230 additions and 80 deletions
@@ -3,17 +3,18 @@
* revenue, and ROAS by source/campaign (spec §8, §10). Analytics are built from CRM
* events (the analyticsDaily series), not impression-level assumptions.
*/
import React, { useMemo, useState } from 'react';
import React, { useMemo, useState, useCallback } from 'react';
import {
BarChart3, DollarSign, Users, CalendarCheck, TrendingUp, Percent, Eye, Trophy,
Zap, FileText, Crown, Activity,
Zap, FileText, Crown, Activity, Filter, X, Star, Gift, Repeat, Route,
ThumbsDown, Copy, CalendarX, Pause, Check, ShieldAlert,
} from 'lucide-react';
import {
BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, Cell,
LineChart, Line, CartesianGrid, Legend,
} from 'recharts';
import { useSocialAds } from '../SocialAdsContext.jsx';
import { totals, metrics, metricsBy, timeSeries, liveMetrics } from '../engine/attribution.js';
import { totals, metrics, metricsBy, metricsByFn, timeSeries, liveMetrics, reviewTotals, reviewsByFn, attributionByModel, budgetPacing } from '../engine/attribution.js';
import { getPlatform } from '../data/platforms.js';
import { Card, KpiTile, SectionHeader, PlatformBadge } from '../components/ui.jsx';
@@ -21,13 +22,72 @@ const money = (n) => `$${Math.round(n).toLocaleString('en-US')}`;
const pct = (n) => `${Math.round(n * 100)}%`;
const mmss = (s) => s == null ? '—' : `${Math.floor(s / 60)}m ${String(s % 60).padStart(2, '0')}s`;
const MODEL_NOTE = {
first: 'First-touch credits 100% to the source that first introduced the customer.',
last: 'Last-touch credits 100% to the final source before the sale (the default single-touch view).',
multi: 'Multi-touch splits credit evenly (linear) across every source in the journey.',
};
const ANOMALY_META = {
cpl_spike: { label: 'CPL spike', icon: TrendingUp, tone: 'red' },
quality_drop: { label: 'Lead quality drop', icon: ThumbsDown, tone: 'amber' },
duplicate_rate: { label: 'Abnormal duplicate rate', icon: Copy, tone: 'amber' },
appt_decline: { label: 'Source-to-appointment decline', icon: CalendarX, tone: 'red' },
};
const PACE_CHIP = {
over: 'text-red-600 dark:text-red-400 bg-red-500/10',
on: 'text-emerald-600 dark:text-emerald-400 bg-emerald-500/10',
under: 'text-amber-600 dark:text-amber-400 bg-amber-500/10',
};
const PACE_LABEL = { over: 'Pacing hot', on: 'On track', under: 'Under-pacing' };
export default function AttributionDashboard() {
const { analyticsDaily, campaigns, leads } = useSocialAds();
const [view, setView] = useState('platform'); // platform | campaign
const { analyticsDaily, campaigns, creatives, leads, reviews, journeys, anomalies, resolveAnomaly } = useSocialAds();
const [view, setView] = useState('platform'); // platform | campaign | creative | playbook
const [selected, setSelected] = useState(null); // drill-down: a key within the current view
const [model, setModel] = useState('last'); // attribution model: first | last | multi
// Lookups for the creative + playbook breakdowns (spec §10 dimensions).
const campById = useMemo(() => Object.fromEntries(campaigns.map(c => [c.id, c])), [campaigns]);
const creativeById = useMemo(() => Object.fromEntries(creatives.map(c => [c.id, c])), [creatives]);
const primaryCreativeByCampaign = useMemo(() => {
const m = {};
for (const cr of creatives) if (!m[cr.campaignId]) m[cr.campaignId] = cr;
return m;
}, [creatives]);
// The dimension key for any object carrying { platform, campaignId } — works for
// both analyticsDaily rows and leads, so a drill-down filters both consistently.
const keyOf = useCallback((v, o) => {
if (v === 'platform') return o.platform;
if (v === 'campaign') return o.campaignId;
if (v === 'creative') return primaryCreativeByCampaign[o.campaignId]?.id || `nocre:${o.campaignId}`;
return campById[o.campaignId]?.playbook || 'Unmapped';
}, [primaryCreativeByCampaign, campById]);
// Scope spend + leads to the drilled-down entity (top KPIs + trend), if any.
const switchView = (v) => { setView(v); setSelected(null); };
const filteredDaily = useMemo(() => selected ? analyticsDaily.filter(r => keyOf(view, r) === selected) : analyticsDaily, [analyticsDaily, selected, view, keyOf]);
const filteredLeads = useMemo(() => selected ? leads.filter(l => keyOf(view, l) === selected) : leads, [leads, selected, view, keyOf]);
const filteredReviews = useMemo(() => selected ? reviews.filter(r => keyOf(view, r) === selected) : reviews, [reviews, selected, view, keyOf]);
// Review & referral lift (spec §10) — totals scope to the drill-down; the breakdown
// groups all reviews by the current dimension.
const reviewAgg = useMemo(() => reviewTotals(filteredReviews), [filteredReviews]);
const reviewsBreakdown = useMemo(() => reviewsByFn(reviews, r => keyOf(view, r)).sort((a, b) => b.count - a.count), [reviews, view, keyOf]);
// Multi-touch attribution by model (spec §11), grouped by the current dimension.
const modelRows = useMemo(() => attributionByModel(journeys, t => keyOf(view, t)).sort((a, b) => b[model] - a[model]), [journeys, view, keyOf, model]);
const modelTotal = useMemo(() => modelRows.reduce((s, r) => s + r[model], 0), [modelRows, model]);
// Budget pacing + open anomaly alerts (spec §11).
const pacing = useMemo(() => budgetPacing(campaigns, analyticsDaily), [campaigns, analyticsDaily]);
const openAlerts = useMemo(() => anomalies.filter(a => a.status === 'open'), [anomalies]);
// Spend/ROAS = platform-reported; lead→won + rates = live CRM events (spec §6, §10).
const reported = useMemo(() => metrics(totals(analyticsDaily)), [analyticsDaily]);
const live = useMemo(() => liveMetrics(leads), [leads]);
// Both honor the active drill-down filter so the KPI cards reconcile with it.
const reported = useMemo(() => metrics(totals(filteredDaily)), [filteredDaily]);
const live = useMemo(() => liveMetrics(filteredLeads), [filteredLeads]);
const m = useMemo(() => ({
...live,
spend: reported.spend,
@@ -35,16 +95,41 @@ export default function AttributionDashboard() {
costPerAppt: live.appointments ? reported.spend / live.appointments : 0,
roas: reported.spend ? live.revenue / reported.spend : 0,
}), [reported, live]);
const bySource = useMemo(() => metricsBy(analyticsDaily, view === 'platform' ? 'platform' : 'campaignId'), [analyticsDaily, view]);
const bySource = useMemo(() => {
if (view === 'platform') return metricsBy(analyticsDaily, 'platform');
if (view === 'campaign') return metricsBy(analyticsDaily, 'campaignId');
if (view === 'creative') return metricsByFn(analyticsDaily, r => primaryCreativeByCampaign[r.campaignId]?.id || `nocre:${r.campaignId}`);
if (view === 'playbook') return metricsByFn(analyticsDaily, r => campById[r.campaignId]?.playbook || 'Unmapped');
return [];
}, [analyticsDaily, view, primaryCreativeByCampaign, campById]);
const revenueSeries = useMemo(() => {
// Merge revenue + spend per date for a dual-line chart.
const rev = timeSeries(analyticsDaily, 'revenue');
const spend = timeSeries(analyticsDaily, 'spend');
// Merge revenue + spend per date for a dual-line chart (respects drill-down).
const rev = timeSeries(filteredDaily, 'revenue');
const spend = timeSeries(filteredDaily, 'spend');
return rev.map((r, i) => ({ date: r.date, revenue: r.value, spend: spend[i]?.value || 0 }));
}, [analyticsDaily]);
}, [filteredDaily]);
const campName = (id) => campaigns.find(c => c.id === id)?.name || id;
const campName = (id) => campById[id]?.name || id;
// Full + short labels for whichever dimension is selected.
const labelFor = (k) => {
if (view === 'platform') return getPlatform(k)?.name || k;
if (view === 'campaign') return campName(k);
if (view === 'creative') return k.startsWith('nocre:') ? `${campName(k.slice(6))} — no creative on file` : (creativeById[k]?.headline || k);
return k; // playbook key is the name itself
};
const shortLabel = (k) => {
if (view === 'platform') return getPlatform(k)?.short || k;
if (view === 'campaign') return campName(k).slice(0, 10);
if (view === 'creative') return (k.startsWith('nocre:') ? campName(k.slice(6)) : (creativeById[k]?.headline || k)).slice(0, 12);
return k.split(' ').slice(0, 2).join(' ');
};
const barColor = (k) => {
if (view === 'platform') return getPlatform(k)?.color || '#3b82f6';
if (view === 'creative' && !k.startsWith('nocre:')) return getPlatform(creativeById[k]?.platform)?.color || '#3b82f6';
return '#3b82f6';
};
// ROAS leaderboard
const ranked = [...bySource].sort((a, b) => b.roas - a.roas);
@@ -52,9 +137,9 @@ export default function AttributionDashboard() {
return (
<div className="space-y-6">
<SectionHeader icon={BarChart3} title="Attribution Dashboard" description="Source-to-close economics built from CRM events — leads alone are not enough.">
<div className="flex rounded-lg overflow-hidden border border-zinc-200 dark:border-white/10">
{['platform', 'campaign'].map(v => (
<button key={v} onClick={() => setView(v)}
<div className="flex flex-wrap rounded-lg overflow-hidden border border-zinc-200 dark:border-white/10">
{['platform', 'campaign', 'creative', 'playbook'].map(v => (
<button key={v} onClick={() => switchView(v)}
className={`px-3 py-1.5 text-xs font-semibold capitalize ${view === v ? 'bg-amber-500 text-white' : 'bg-white dark:bg-zinc-900 text-zinc-500'}`}>
By {v}
</button>
@@ -62,13 +147,82 @@ export default function AttributionDashboard() {
</div>
</SectionHeader>
{/* Live banner */}
<div className="flex items-center gap-2 text-[11px] text-emerald-600 dark:text-emerald-400 -mb-2">
<Activity size={13} className="animate-pulse" />
<span className="font-semibold">Live</span>
<span className="text-zinc-400"> lead-to-won metrics update from CRM events as you act in the Lead Inbox. Spend is platform-reported.</span>
{/* Live banner + drill-down filter chip */}
<div className="flex flex-wrap items-center justify-between gap-2 -mb-2">
<div className="flex items-center gap-2 text-[11px] text-emerald-600 dark:text-emerald-400">
<Activity size={13} className="animate-pulse" />
<span className="font-semibold">Live</span>
<span className="text-zinc-400"> lead-to-won metrics update from CRM events as you act in the Lead Inbox. Spend is platform-reported.</span>
</div>
{selected && (
<button onClick={() => setSelected(null)}
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-[11px] font-semibold bg-amber-500/15 text-amber-700 dark:text-amber-400 hover:bg-amber-500/25 transition-colors">
<Filter size={12} /> {labelFor(selected)} <span className="text-amber-500/60">· by {view}</span> <X size={13} />
</button>
)}
</div>
{/* Budget pacing & anomaly alerts (spec §11) */}
<Card title="Budget pacing & anomaly alerts" subtitle="AI flags spend/quality anomalies; you pause or review" icon={ShieldAlert}>
{/* Alerts */}
{openAlerts.length === 0 ? (
<div className="flex items-center gap-2 text-sm text-emerald-600 dark:text-emerald-400 mb-4">
<Check size={16} /> No open anomalies all campaigns within guardrails.
</div>
) : (
<div className="space-y-2 mb-5">
{openAlerts.map(a => {
const meta = ANOMALY_META[a.type] || ANOMALY_META.cpl_spike;
const AIcon = meta.icon;
const toneCls = meta.tone === 'red' ? 'text-red-500 bg-red-500/10' : 'text-amber-500 bg-amber-500/10';
return (
<div key={a.id} className="flex flex-col sm:flex-row sm:items-center gap-3 p-3 rounded-xl border border-zinc-200 dark:border-white/10 bg-zinc-50 dark:bg-white/[0.02]">
<span className={`p-2 rounded-lg shrink-0 self-start ${toneCls}`}>{AIcon && <AIcon size={16} />}</span>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-bold text-sm text-zinc-900 dark:text-white">{campName(a.campaignId)}</span>
<span className={`text-[10px] font-bold uppercase tracking-wider px-1.5 py-0.5 rounded ${a.severity === 'high' ? 'text-red-600 dark:text-red-400 bg-red-500/10' : 'text-amber-600 dark:text-amber-400 bg-amber-500/10'}`}>{a.severity}</span>
<span className="text-[11px] text-zinc-400">{meta.label}</span>
</div>
<p className="text-xs font-mono text-zinc-700 dark:text-zinc-200 mt-0.5">{a.metric}</p>
<p className="text-[11px] text-zinc-500 dark:text-zinc-400">{a.detail}</p>
<p className="text-[11px] text-purple-500 mt-0.5 flex items-center gap-1"><Activity size={11} /> AI recommends: <b>{a.recommendation === 'pause' ? 'pause the campaign' : 'review & optimize'}</b></p>
</div>
<div className="flex items-center gap-1.5 shrink-0 self-start sm:self-center">
<button onClick={() => resolveAnomaly(a.id, 'paused')} title="Pause campaign"
className={`inline-flex items-center gap-1 px-2.5 py-1.5 rounded-lg text-xs font-semibold ${a.recommendation === 'pause' ? 'bg-red-500/10 text-red-600 dark:text-red-400 hover:bg-red-500/20' : 'bg-zinc-100 dark:bg-white/5 text-zinc-600 dark:text-zinc-300 hover:bg-zinc-200 dark:hover:bg-white/10'}`}>
<Pause size={13} /> Pause
</button>
<button onClick={() => resolveAnomaly(a.id, 'reviewed')} title="Mark reviewed"
className="inline-flex items-center gap-1 px-2.5 py-1.5 rounded-lg text-xs font-semibold bg-zinc-100 dark:bg-white/5 text-zinc-600 dark:text-zinc-300 hover:bg-zinc-200 dark:hover:bg-white/10">
<Check size={13} /> Review
</button>
<button onClick={() => resolveAnomaly(a.id, 'dismissed')} title="Dismiss" className="p-1.5 text-zinc-400 hover:text-zinc-600 dark:hover:text-zinc-200"><X size={15} /></button>
</div>
</div>
);
})}
</div>
)}
{/* Budget pacing per active campaign */}
<p className="text-[10px] font-bold uppercase tracking-wider text-zinc-400 mb-2">Budget pacing active campaigns</p>
<div className="space-y-2">
{pacing.map(p => (
<div key={p.id} className="flex items-center gap-3">
<span className="text-xs font-semibold text-zinc-700 dark:text-zinc-200 w-40 sm:w-52 truncate shrink-0" title={p.name}>{p.name}</span>
<div className="flex-1 min-w-0">
<div className="h-2 rounded-full bg-zinc-200 dark:bg-zinc-700 overflow-hidden">
<div className={`h-full ${p.status === 'over' ? 'bg-red-500' : p.status === 'under' ? 'bg-amber-500' : 'bg-emerald-500'}`} style={{ width: `${Math.min(100, p.pacePct * 100)}%` }} />
</div>
</div>
<span className="text-[11px] font-mono text-zinc-400 w-28 text-right shrink-0 hidden sm:block">{money(p.projected)} / {money(p.monthlyCap)}</span>
<span className={`text-[10px] font-bold uppercase tracking-wider px-2 py-0.5 rounded-full shrink-0 ${PACE_CHIP[p.status]}`}>{PACE_LABEL[p.status]}</span>
</div>
))}
</div>
</Card>
{/* KPI row */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
<KpiTile label="Spend (reported)" value={money(m.spend)} icon={DollarSign} color="amber" />
@@ -111,26 +265,28 @@ export default function AttributionDashboard() {
<BarChart data={bySource} margin={{ top: 5, right: 20, left: -10, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#71717a22" vertical={false} />
<XAxis dataKey="key" tick={{ fill: '#71717a', fontSize: 10 }} axisLine={false} tickLine={false}
tickFormatter={(k) => view === 'platform' ? (getPlatform(k)?.short || k) : campName(k).slice(0, 8)} />
interval={0} tickFormatter={shortLabel} />
<YAxis tick={{ fill: '#71717a', fontSize: 11 }} axisLine={false} tickLine={false} />
<Tooltip cursor={{ fill: 'transparent' }} contentStyle={{ background: 'rgba(24,24,27,0.95)', border: '1px solid #3f3f46', borderRadius: 12, fontSize: 12 }}
formatter={(v) => [money(v), 'CPL']} labelFormatter={(k) => view === 'platform' ? getPlatform(k)?.name : campName(k)} />
formatter={(v) => [money(v), 'CPL']} labelFormatter={labelFor} />
<Bar dataKey="cpl" radius={[6, 6, 0, 0]} barSize={36}>
{bySource.map((s, i) => <Cell key={i} fill={view === 'platform' ? (getPlatform(s.key)?.color || '#3b82f6') : '#3b82f6'} />)}
{bySource.map((s, i) => <Cell key={i} fill={barColor(s.key)} />)}
</Bar>
</BarChart>
</ResponsiveContainer>
</Card>
{/* ROAS leaderboard */}
<Card title="ROAS leaderboard" subtitle="Ranked by return — not lead count" icon={Trophy}>
<Card title="ROAS leaderboard" subtitle="Ranked by return — click a row to filter" icon={Trophy}>
<div className="space-y-2">
{ranked.map((s, i) => (
<div key={s.key} className="flex items-center gap-3 px-3 py-2 rounded-lg bg-zinc-50 dark:bg-white/5">
<div key={s.key} onClick={() => setSelected(sel => sel === s.key ? null : s.key)}
title="Click to filter the dashboard to this row"
className={`flex items-center gap-3 px-3 py-2 rounded-lg cursor-pointer transition-colors ${selected === s.key ? 'bg-amber-50 dark:bg-amber-500/10 ring-1 ring-amber-400/60' : 'bg-zinc-50 dark:bg-white/5 hover:bg-zinc-100 dark:hover:bg-white/10'}`}>
<span className="text-xs font-bold text-zinc-400 w-4">{i + 1}</span>
{view === 'platform'
? <PlatformBadge platformId={s.key} />
: <span className="text-sm font-semibold text-zinc-700 dark:text-zinc-200 truncate flex-1">{campName(s.key)}</span>}
: <span className="text-sm font-semibold text-zinc-700 dark:text-zinc-200 truncate flex-1" title={labelFor(s.key)}>{labelFor(s.key)}</span>}
<div className="flex items-center gap-3 ml-auto text-xs">
<span className="text-zinc-400">{money(s.revenue)}</span>
<span className={`font-mono font-bold w-12 text-right ${s.roas >= 1 ? 'text-emerald-500' : 'text-red-500'}`}>{s.roas.toFixed(1)}×</span>
@@ -141,8 +297,98 @@ export default function AttributionDashboard() {
</Card>
</div>
{/* Review & referral lift (spec §8 "reviews"; §10 review/referral lift) */}
<Card title="Review & referral lift" subtitle={`Post-job reviews attributed back to source${selected ? ` · filtered to ${labelFor(selected)}` : ''}`} icon={Star}>
<div className="grid grid-cols-3 gap-2 mb-4">
<LiftStat icon={Star} color="text-amber-500" value={reviewAgg.count} label="Reviews" sub={reviewAgg.count ? `${reviewAgg.avgRating.toFixed(1)}★ avg` : 'no reviews'} />
<LiftStat icon={Gift} color="text-emerald-500" value={reviewAgg.referralLeads} label="Referral leads" sub="generated" />
<LiftStat icon={Repeat} color="text-purple-500" value={money(reviewAgg.reactivationRevenue)} label="Reactivation $" sub="repeat revenue" />
</div>
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead>
<tr className="text-left text-[10px] uppercase tracking-wider text-zinc-400 border-b border-zinc-100 dark:border-white/5">
<th className="py-2 pr-3 font-bold">{view}</th>
<th className="py-2 px-2 font-bold text-right">Reviews</th>
<th className="py-2 px-2 font-bold text-right">Avg rating</th>
<th className="py-2 px-2 font-bold text-right">Referral leads</th>
<th className="py-2 pl-2 font-bold text-right">Reactivation $</th>
</tr>
</thead>
<tbody>
{reviewsBreakdown.length === 0 && <tr><td colSpan={5} className="py-4 text-center text-zinc-400">No reviews for this dimension yet.</td></tr>}
{reviewsBreakdown.map(b => (
<tr key={b.key} onClick={() => setSelected(sel => sel === b.key ? null : b.key)}
className={`border-b border-zinc-50 dark:border-white/5 cursor-pointer transition-colors ${selected === b.key ? 'bg-amber-50 dark:bg-amber-500/10' : 'hover:bg-zinc-50 dark:hover:bg-white/5'}`}>
<td className="py-2 pr-3">
{view === 'platform' ? <PlatformBadge platformId={b.key} size="sm" /> : <span className="font-semibold text-zinc-700 dark:text-zinc-200">{labelFor(b.key)}</span>}
</td>
<td className="py-2 px-2 text-right font-mono">{b.count}</td>
<td className="py-2 px-2 text-right font-mono text-amber-600 dark:text-amber-400">{b.avgRating.toFixed(1)}</td>
<td className="py-2 px-2 text-right font-mono text-emerald-600 dark:text-emerald-400">{b.referralLeads}</td>
<td className="py-2 pl-2 text-right font-mono">{money(b.reactivationRevenue)}</td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
{/* Multi-touch attribution models (spec §11) */}
<Card title="Multi-touch attribution" subtitle={`How closed-won revenue is credited across sources, by ${view}`} icon={Route}
action={
<div className="flex rounded-lg overflow-hidden border border-zinc-200 dark:border-white/10">
{[['first', 'First-touch'], ['last', 'Last-touch'], ['multi', 'Multi-touch']].map(([m, label]) => (
<button key={m} onClick={() => setModel(m)}
className={`px-2.5 py-1.5 text-[11px] font-semibold ${model === m ? 'bg-amber-500 text-white' : 'bg-white dark:bg-zinc-900 text-zinc-500'}`}>
{label}
</button>
))}
</div>
}>
<p className="text-[11px] text-zinc-500 dark:text-zinc-400 mb-3 flex items-start gap-1.5">
<Route size={13} className="text-amber-500 mt-0.5 shrink-0" />
{MODEL_NOTE[model]} Highlighted column is the active model; compare to see how credit shifts.
</p>
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead>
<tr className="text-left text-[10px] uppercase tracking-wider text-zinc-400 border-b border-zinc-100 dark:border-white/5">
<th className="py-2 pr-3 font-bold">{view}</th>
<th className={`py-2 px-2 font-bold text-right ${model === 'first' ? 'text-amber-600 dark:text-amber-400' : ''}`}>First-touch</th>
<th className={`py-2 px-2 font-bold text-right ${model === 'last' ? 'text-amber-600 dark:text-amber-400' : ''}`}>Last-touch</th>
<th className={`py-2 pl-2 font-bold text-right ${model === 'multi' ? 'text-amber-600 dark:text-amber-400' : ''}`}>Multi-touch</th>
</tr>
</thead>
<tbody>
{modelRows.length === 0 && <tr><td colSpan={4} className="py-4 text-center text-zinc-400">No closed-won journeys for this dimension yet.</td></tr>}
{modelRows.map(r => (
<tr key={r.key} onClick={() => setSelected(sel => sel === r.key ? null : r.key)}
className={`border-b border-zinc-50 dark:border-white/5 cursor-pointer transition-colors ${selected === r.key ? 'bg-amber-50 dark:bg-amber-500/10' : 'hover:bg-zinc-50 dark:hover:bg-white/5'}`}>
<td className="py-2 pr-3">
{view === 'platform' ? <PlatformBadge platformId={r.key} size="sm" /> : <span className="font-semibold text-zinc-700 dark:text-zinc-200">{labelFor(r.key)}</span>}
</td>
<td className={`py-2 px-2 text-right font-mono ${model === 'first' ? 'font-bold text-amber-600 dark:text-amber-400' : 'text-zinc-500'}`}>{money(r.first)}</td>
<td className={`py-2 px-2 text-right font-mono ${model === 'last' ? 'font-bold text-amber-600 dark:text-amber-400' : 'text-zinc-500'}`}>{money(r.last)}</td>
<td className={`py-2 pl-2 text-right font-mono ${model === 'multi' ? 'font-bold text-amber-600 dark:text-amber-400' : 'text-zinc-500'}`}>{money(r.multi)}</td>
</tr>
))}
</tbody>
{modelRows.length > 0 && (
<tfoot>
<tr className="text-[11px] font-bold text-zinc-600 dark:text-zinc-300">
<td className="py-2 pr-3">Total ({model}-touch)</td>
<td colSpan={3} className="py-2 pl-2 text-right font-mono text-emerald-600 dark:text-emerald-400">{money(modelTotal)}</td>
</tr>
</tfoot>
)}
</table>
</div>
</Card>
{/* Detail table */}
<Card title={`Performance by ${view}`} icon={BarChart3}>
<Card title={`Performance by ${view}`} subtitle="Click a row to filter the KPIs above" icon={BarChart3}>
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead>
@@ -160,9 +406,10 @@ export default function AttributionDashboard() {
</thead>
<tbody>
{bySource.map(s => (
<tr key={s.key} className="border-b border-zinc-50 dark:border-white/5">
<tr key={s.key} onClick={() => setSelected(sel => sel === s.key ? null : s.key)}
className={`border-b border-zinc-50 dark:border-white/5 cursor-pointer transition-colors ${selected === s.key ? 'bg-amber-50 dark:bg-amber-500/10' : 'hover:bg-zinc-50 dark:hover:bg-white/5'}`}>
<td className="py-2 pr-3">
{view === 'platform' ? <PlatformBadge platformId={s.key} size="sm" /> : <span className="font-semibold text-zinc-700 dark:text-zinc-200">{campName(s.key)}</span>}
{view === 'platform' ? <PlatformBadge platformId={s.key} size="sm" /> : <span className="font-semibold text-zinc-700 dark:text-zinc-200">{labelFor(s.key)}</span>}
</td>
<td className="py-2 px-2 text-right font-mono">{money(s.spend)}</td>
<td className="py-2 px-2 text-right font-mono">{s.leads}</td>
@@ -181,3 +428,14 @@ export default function AttributionDashboard() {
</div>
);
}
function LiftStat({ icon: Icon, color, value, label, sub }) {
return (
<div className="rounded-xl bg-zinc-50 dark:bg-white/5 p-3 text-center">
{Icon && <Icon size={16} className={`${color} mx-auto mb-1`} />}
<p className="text-lg font-mono font-bold text-zinc-800 dark:text-zinc-100">{value}</p>
<p className="text-[10px] uppercase tracking-wider text-zinc-400">{label}</p>
{sub && <p className="text-[10px] text-zinc-400 mt-0.5">{sub}</p>}
</div>
);
}