442 lines
32 KiB
React
442 lines
32 KiB
React
/**
|
||
* 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 (
|
||
<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 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>
|
||
))}
|
||
</div>
|
||
</SectionHeader>
|
||
|
||
{/* 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" />
|
||
<KpiTile label="CPL" value={money(m.cpl)} icon={Users} color="blue" sub={`${m.leads} leads`} />
|
||
<KpiTile label="Cost / appt" value={money(m.costPerAppt)} icon={CalendarCheck} color="purple" sub={`${m.appointments} appts`} />
|
||
<KpiTile label="ROAS" value={`${m.roas.toFixed(1)}×`} icon={TrendingUp} color="emerald" sub={money(m.revenue)} />
|
||
</div>
|
||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
|
||
<KpiTile label="Appt rate" value={pct(m.apptRate)} icon={CalendarCheck} color="blue" />
|
||
<KpiTile label="Show rate" value={pct(m.showRate)} icon={Eye} color="cyan" />
|
||
<KpiTile label="Estimate rate" value={pct(m.estimateRate)} icon={FileText} color="blue" sub={`${m.estimates} sent`} />
|
||
<KpiTile label="Close rate" value={pct(m.closeRate)} icon={Percent} color="emerald" sub={`${m.wins} won`} />
|
||
</div>
|
||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
|
||
<KpiTile label="Speed-to-lead" value={mmss(m.avgSpeedToLead)} icon={Zap} color="amber" sub="avg to first contact" />
|
||
<KpiTile label="Premium mix" value={pct(m.premiumMix)} icon={Crown} color="purple" sub={`${m.premiumWins} premium`} />
|
||
<KpiTile label="Qualified leads" value={m.qualified} icon={Users} color="emerald" sub={`spam ${pct(m.spamRate)}`} />
|
||
<KpiTile label="Revenue (won)" value={money(m.revenue)} icon={Trophy} color="amber" />
|
||
</div>
|
||
|
||
{/* Revenue vs spend */}
|
||
<Card title="Revenue vs. spend" subtitle="Daily, all sources" icon={TrendingUp}>
|
||
<ResponsiveContainer width="100%" height={240}>
|
||
<LineChart data={revenueSeries} margin={{ top: 5, right: 10, left: 0, bottom: 0 }}>
|
||
<CartesianGrid strokeDasharray="3 3" stroke="#71717a22" vertical={false} />
|
||
<XAxis dataKey="date" tick={{ fill: '#71717a', fontSize: 11 }} axisLine={false} tickLine={false} />
|
||
<YAxis tick={{ fill: '#71717a', fontSize: 11 }} axisLine={false} tickLine={false} />
|
||
<Tooltip contentStyle={{ background: 'rgba(24,24,27,0.95)', border: '1px solid #3f3f46', borderRadius: 12, fontSize: 12 }} labelStyle={{ color: '#fff' }} formatter={(v, n) => [money(v), n === 'revenue' ? 'Revenue' : 'Spend']} />
|
||
<Legend wrapperStyle={{ fontSize: 12 }} />
|
||
<Line type="monotone" dataKey="revenue" stroke="#10b981" strokeWidth={2.5} dot={false} name="Revenue" />
|
||
<Line type="monotone" dataKey="spend" stroke="#f59e0b" strokeWidth={2} dot={false} name="Spend" />
|
||
</LineChart>
|
||
</ResponsiveContainer>
|
||
</Card>
|
||
|
||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||
{/* CPL by source */}
|
||
<Card title={`Cost per lead by ${view}`} icon={Users}>
|
||
<ResponsiveContainer width="100%" height={240}>
|
||
<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}
|
||
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={labelFor} />
|
||
<Bar dataKey="cpl" radius={[6, 6, 0, 0]} barSize={36}>
|
||
{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 — click a row to filter" icon={Trophy}>
|
||
<div className="space-y-2">
|
||
{ranked.map((s, i) => (
|
||
<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" 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>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</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}`} subtitle="Click a row to filter the KPIs above" icon={BarChart3}>
|
||
<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">Spend</th>
|
||
<th className="py-2 px-2 font-bold text-right">Leads</th>
|
||
<th className="py-2 px-2 font-bold text-right">CPL</th>
|
||
<th className="py-2 px-2 font-bold text-right">Appts</th>
|
||
<th className="py-2 px-2 font-bold text-right">Show</th>
|
||
<th className="py-2 px-2 font-bold text-right">Close</th>
|
||
<th className="py-2 px-2 font-bold text-right">Revenue</th>
|
||
<th className="py-2 pl-2 font-bold text-right">ROAS</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{bySource.map(s => (
|
||
<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">{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>
|
||
<td className="py-2 px-2 text-right font-mono">{money(s.cpl)}</td>
|
||
<td className="py-2 px-2 text-right font-mono">{s.appointments}</td>
|
||
<td className="py-2 px-2 text-right font-mono">{pct(s.showRate)}</td>
|
||
<td className="py-2 px-2 text-right font-mono">{pct(s.closeRate)}</td>
|
||
<td className="py-2 px-2 text-right font-mono text-emerald-600 dark:text-emerald-400">{money(s.revenue)}</td>
|
||
<td className={`py-2 pl-2 text-right font-mono font-bold ${s.roas >= 1 ? 'text-emerald-500' : 'text-red-500'}`}>{s.roas.toFixed(1)}×</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</Card>
|
||
</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>
|
||
);
|
||
}
|