social ads engine module screens
This commit is contained in:
@@ -0,0 +1,183 @@
|
||||
/**
|
||||
* 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 } from 'react';
|
||||
import {
|
||||
BarChart3, DollarSign, Users, CalendarCheck, TrendingUp, Percent, Eye, Trophy,
|
||||
Zap, FileText, Crown, Activity,
|
||||
} 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 { 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`;
|
||||
|
||||
export default function AttributionDashboard() {
|
||||
const { analyticsDaily, campaigns, leads } = useSocialAds();
|
||||
const [view, setView] = useState('platform'); // platform | campaign
|
||||
|
||||
// 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]);
|
||||
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(() => metricsBy(analyticsDaily, view === 'platform' ? 'platform' : 'campaignId'), [analyticsDaily, view]);
|
||||
|
||||
const revenueSeries = useMemo(() => {
|
||||
// Merge revenue + spend per date for a dual-line chart.
|
||||
const rev = timeSeries(analyticsDaily, 'revenue');
|
||||
const spend = timeSeries(analyticsDaily, 'spend');
|
||||
return rev.map((r, i) => ({ date: r.date, revenue: r.value, spend: spend[i]?.value || 0 }));
|
||||
}, [analyticsDaily]);
|
||||
|
||||
const campName = (id) => campaigns.find(c => c.id === id)?.name || id;
|
||||
|
||||
// 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 rounded-lg overflow-hidden border border-zinc-200 dark:border-white/10">
|
||||
{['platform', 'campaign'].map(v => (
|
||||
<button key={v} onClick={() => setView(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 */}
|
||||
<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>
|
||||
</div>
|
||||
|
||||
{/* 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}
|
||||
tickFormatter={(k) => view === 'platform' ? (getPlatform(k)?.short || k) : campName(k).slice(0, 8)} />
|
||||
<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)} />
|
||||
<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'} />)}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</Card>
|
||||
|
||||
{/* ROAS leaderboard */}
|
||||
<Card title="ROAS leaderboard" subtitle="Ranked by return — not lead count" 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">
|
||||
<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>}
|
||||
<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>
|
||||
|
||||
{/* Detail table */}
|
||||
<Card title={`Performance by ${view}`} 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} className="border-b border-zinc-50 dark:border-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>}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user