social ads engine module screens

This commit is contained in:
Mayur Shinde
2026-06-12 13:22:03 +05:30
parent ed87e097f1
commit e53c38ce59
31 changed files with 5406 additions and 2 deletions
@@ -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>
);
}