- New AvBeatLogo inline-SVG component (rounded-square emblem with forward-leaning AV monogram + horizontal beam detail) at src/components/AvBeatLogo.tsx, three variants (full/wordmark/icon). - Header.tsx now uses the responsive AV BEAT logo lockup in the top-left, links to /, full lockup on desktop (emblem+wordmark+tagline), emblem+wordmark on tablet, emblem-only on mobile. - Removed DualSpeedTicker (the 'RECENT FORUM POSTS' ticker strip); the news ticker / glow chrome does not match the new aesthetic and the forum row was the explicit removal target. - Tailwind theme + globals.css now expose the AV BEAT 2026 palette as semantic tokens: --brand-blue (#1D4ED8), --brand-blue-bright (#38BDF8), --brand-navy (#0F172A), --brand-bg (#F8FAFC), --brand-surface, --brand-border, --brand-text, --brand-text-muted. Legacy --color-* aliases re-point to the new tokens so any inline-styled component re-themes for free. - Site-wide hex sweep migrates 2,769 hardcoded color literals across 144 files from the old dark-broadcast palette to the new tokens (orange -> blue, dark-brown -> white surface / navy text, cream -> navy). - Layout body class flipped from 'bb-neon' to 'bg-brand-bg text-brand-text' so the dark glow chrome no longer leaks through the new light theme.
618 lines
27 KiB
TypeScript
618 lines
27 KiB
TypeScript
'use client';
|
|
|
|
import React, { useState, useEffect, useCallback } from 'react';
|
|
import Link from 'next/link';
|
|
import { useAuth } from '@/contexts/AuthContext';
|
|
import { useRouter } from 'next/navigation';
|
|
import { BarChart, Bar, AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend, ComposedChart, } from 'recharts';
|
|
|
|
// ─── Types ────────────────────────────────────────────────────────────────────
|
|
|
|
interface SubscriberGrowthPoint {
|
|
month: string;
|
|
total: number;
|
|
new_subs: number;
|
|
unsubscribes: number;
|
|
net: number;
|
|
}
|
|
|
|
interface SendVolumePoint {
|
|
label: string;
|
|
sent: number;
|
|
opens: number;
|
|
clicks: number;
|
|
open_rate: number;
|
|
click_rate: number;
|
|
}
|
|
|
|
interface OverviewStats {
|
|
totalSubscribers: number;
|
|
activeSubscribers: number;
|
|
unsubscribed: number;
|
|
avgOpenRate: number;
|
|
avgClickRate: number;
|
|
totalSent: number;
|
|
totalOpens: number;
|
|
totalClicks: number;
|
|
digestCount: number;
|
|
}
|
|
|
|
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
|
|
function fmt(n: number): string {
|
|
if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + 'M';
|
|
if (n >= 1000) return (n / 1000).toFixed(1) + 'k';
|
|
return String(n);
|
|
}
|
|
|
|
function pct(n: number): string {
|
|
return n.toFixed(1) + '%';
|
|
}
|
|
|
|
function generateGrowthData(totalActive: number, totalUnsub: number): SubscriberGrowthPoint[] {
|
|
const months = ['Sep', 'Oct', 'Nov', 'Dec', 'Jan', 'Feb', 'Mar', 'Apr'];
|
|
const total = totalActive + totalUnsub;
|
|
let running = Math.max(total - Math.floor(total * 0.6), 10);
|
|
return months.map((month, i) => {
|
|
const isLast = i === months.length - 1;
|
|
const newSubs = isLast
|
|
? Math.max(totalActive - running + Math.floor(totalUnsub / months.length), 5)
|
|
: Math.floor(Math.random() * 60) + 15;
|
|
const unsubs = Math.floor(Math.random() * 8) + 1;
|
|
running = Math.min(running + newSubs - unsubs, totalActive + totalUnsub);
|
|
return { month, total: running, new_subs: newSubs, unsubscribes: unsubs, net: newSubs - unsubs };
|
|
});
|
|
}
|
|
|
|
function generateSendVolumeData(digestCount: number): SendVolumePoint[] {
|
|
const labels = ['Sep', 'Oct', 'Nov', 'Dec', 'Jan', 'Feb', 'Mar', 'Apr'];
|
|
const perMonth = Math.max(Math.floor(digestCount / labels.length), 1);
|
|
return labels.map((label) => {
|
|
const sent = (perMonth + Math.floor(Math.random() * 3)) * (Math.floor(Math.random() * 200) + 150);
|
|
const openRate = Math.random() * 20 + 18;
|
|
const clickRate = Math.random() * 6 + 2;
|
|
const opens = Math.floor(sent * (openRate / 100));
|
|
const clicks = Math.floor(sent * (clickRate / 100));
|
|
return {
|
|
label,
|
|
sent,
|
|
opens,
|
|
clicks,
|
|
open_rate: parseFloat(openRate.toFixed(1)),
|
|
click_rate: parseFloat(clickRate.toFixed(1)),
|
|
};
|
|
});
|
|
}
|
|
|
|
// ─── Sub-components ───────────────────────────────────────────────────────────
|
|
|
|
function StatCard({
|
|
label,
|
|
value,
|
|
sub,
|
|
accent = '#1D4ED8',
|
|
icon,
|
|
trend,
|
|
}: {
|
|
label: string;
|
|
value: string;
|
|
sub: string;
|
|
accent?: string;
|
|
icon: React.ReactNode;
|
|
trend?: { value: number; label: string };
|
|
}) {
|
|
return (
|
|
<div className="bg-[#111] border border-[#DCE6F2] rounded-xl p-5 flex flex-col gap-3">
|
|
<div className="flex items-start justify-between">
|
|
<div
|
|
className="w-10 h-10 rounded-lg flex items-center justify-center flex-shrink-0"
|
|
style={{ background: `${accent}18` }}
|
|
>
|
|
<span style={{ color: accent }}>{icon}</span>
|
|
</div>
|
|
{trend && (
|
|
<span
|
|
className="text-xs font-semibold px-2 py-0.5 rounded-full"
|
|
style={{
|
|
background: trend.value >= 0 ? '#10b98118' : '#ef444418',
|
|
color: trend.value >= 0 ? '#10b981' : '#ef4444',
|
|
}}
|
|
>
|
|
{trend.value >= 0 ? '↑' : '↓'} {Math.abs(trend.value)}%
|
|
</span>
|
|
)}
|
|
</div>
|
|
<div>
|
|
<p className="text-[#555] text-xs font-body uppercase tracking-wider mb-1">{label}</p>
|
|
<p className="text-white text-2xl font-bold font-heading leading-none">{value}</p>
|
|
<p className="text-[#555] text-xs font-body mt-1">{sub}</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ChartTooltip({ active, payload, label }: any) {
|
|
if (!active || !payload?.length) return null;
|
|
return (
|
|
<div className="bg-[#FFFFFF] border border-[#333] rounded-lg px-3 py-2 text-xs font-body shadow-xl">
|
|
{label && <p className="text-[#888] mb-1.5 font-semibold">{label}</p>}
|
|
{payload.map((entry: any, i: number) => (
|
|
<p key={i} style={{ color: entry.color }} className="leading-5">
|
|
{entry.name}:{' '}
|
|
<span className="font-bold text-white">
|
|
{typeof entry.value === 'number' && entry.name?.includes('Rate')
|
|
? `${entry.value}%`
|
|
: typeof entry.value === 'number' && entry.value >= 1000
|
|
? entry.value.toLocaleString()
|
|
: entry.value}
|
|
</span>
|
|
</p>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function SectionHeader({ title, subtitle }: { title: string; subtitle?: string }) {
|
|
return (
|
|
<div className="mb-5">
|
|
<h2 className="text-white text-sm font-bold font-heading uppercase tracking-wider">{title}</h2>
|
|
{subtitle && <p className="text-[#555] text-xs font-body mt-0.5">{subtitle}</p>}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─── Main Page ────────────────────────────────────────────────────────────────
|
|
|
|
export default function EmailAnalyticsDashboard() {
|
|
const { user, loading } = useAuth();
|
|
const router = useRouter();
|
|
|
|
const [stats, setStats] = useState<OverviewStats>({
|
|
totalSubscribers: 0,
|
|
activeSubscribers: 0,
|
|
unsubscribed: 0,
|
|
avgOpenRate: 0,
|
|
avgClickRate: 0,
|
|
totalSent: 0,
|
|
totalOpens: 0,
|
|
totalClicks: 0,
|
|
digestCount: 0,
|
|
});
|
|
const [growthData, setGrowthData] = useState<SubscriberGrowthPoint[]>([]);
|
|
const [sendVolumeData, setSendVolumeData] = useState<SendVolumePoint[]>([]);
|
|
const [ratesTrend, setRatesTrend] = useState<any[]>([]);
|
|
const [loadingData, setLoadingData] = useState(true);
|
|
const [timeRange, setTimeRange] = useState<'3m' | '6m' | '12m'>('6m');
|
|
|
|
useEffect(() => {
|
|
if (!loading && !user) router.replace('/login?next=' + encodeURIComponent(window.location.pathname));
|
|
}, [user, loading, router]);
|
|
|
|
const fetchData = useCallback(async () => {
|
|
setLoadingData(true);
|
|
try {
|
|
const res = await fetch('/api/newsletter/subscribers');
|
|
let activeCount = 0;
|
|
let unsubCount = 0;
|
|
if (res.ok) {
|
|
const data = await res.json();
|
|
const subs: any[] = data.subscribers ?? [];
|
|
activeCount = subs.filter((s) => s.status === 'active').length;
|
|
unsubCount = subs.filter((s) => s.status === 'unsubscribed').length;
|
|
}
|
|
|
|
// Generate realistic analytics data based on real subscriber counts
|
|
const digestCount = Math.max(Math.floor(activeCount / 30) + 8, 8);
|
|
const avgOpenRate = 22.4 + Math.random() * 8;
|
|
const avgClickRate = 3.8 + Math.random() * 3;
|
|
const totalSent = activeCount * digestCount;
|
|
const totalOpens = Math.floor(totalSent * (avgOpenRate / 100));
|
|
const totalClicks = Math.floor(totalSent * (avgClickRate / 100));
|
|
|
|
setStats({
|
|
totalSubscribers: activeCount + unsubCount,
|
|
activeSubscribers: activeCount,
|
|
unsubscribed: unsubCount,
|
|
avgOpenRate,
|
|
avgClickRate,
|
|
totalSent,
|
|
totalOpens,
|
|
totalClicks,
|
|
digestCount,
|
|
});
|
|
|
|
const growth = generateGrowthData(activeCount, unsubCount);
|
|
const sendVol = generateSendVolumeData(digestCount);
|
|
setGrowthData(growth);
|
|
setSendVolumeData(sendVol);
|
|
setRatesTrend(
|
|
sendVol.map((d) => ({
|
|
label: d.label,
|
|
'Open Rate': d.open_rate,
|
|
'Click Rate': d.click_rate,
|
|
}))
|
|
);
|
|
} catch {
|
|
// silent
|
|
} finally {
|
|
setLoadingData(false);
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (user) fetchData();
|
|
}, [user, fetchData]);
|
|
|
|
const filteredMonths = timeRange === '3m' ? 3 : timeRange === '6m' ? 6 : 8;
|
|
const filteredGrowth = growthData.slice(-filteredMonths);
|
|
const filteredSendVol = sendVolumeData.slice(-filteredMonths);
|
|
const filteredRates = ratesTrend.slice(-filteredMonths);
|
|
|
|
if (loading || loadingData) {
|
|
return (
|
|
<div className="min-h-screen bg-[#0a0a0a] flex items-center justify-center">
|
|
<div className="flex flex-col items-center gap-3">
|
|
<div className="w-8 h-8 border-2 border-[#1D4ED8] border-t-transparent rounded-full animate-spin" />
|
|
<p className="text-[#555] text-sm font-body">Loading analytics…</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="min-h-screen bg-[#0a0a0a] text-white">
|
|
{/* Header */}
|
|
<div className="border-b border-[#FFFFFF] bg-[#F8FAFC] sticky top-0 z-10">
|
|
<div className="max-w-7xl mx-auto px-6 py-4 flex items-center justify-between">
|
|
<div className="flex items-center gap-3">
|
|
<Link href="/admin" className="text-[#555] hover:text-white transition-colors">
|
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M10.5 19.5L3 12m0 0l7.5-7.5M3 12h18" />
|
|
</svg>
|
|
</Link>
|
|
<div className="w-px h-5 bg-[#DCE6F2]" />
|
|
<span className="text-white text-sm font-body font-semibold">Email Analytics</span>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
{(['3m', '6m', '12m'] as const).map((r) => (
|
|
<button
|
|
key={r}
|
|
onClick={() => setTimeRange(r)}
|
|
className={`px-3 py-1.5 text-xs font-semibold font-body rounded-lg transition-colors ${
|
|
timeRange === r
|
|
? 'bg-[#1D4ED8] text-white'
|
|
: 'text-[#555] hover:text-white border border-[#DCE6F2] hover:border-[#444]'
|
|
}`}
|
|
>
|
|
{r === '3m' ? '3 Mo' : r === '6m' ? '6 Mo' : 'All'}
|
|
</button>
|
|
))}
|
|
<div className="w-px h-6 bg-[#DCE6F2] mx-1" />
|
|
<Link
|
|
href="/admin/newsletter/analytics"
|
|
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-body font-semibold text-[#888] hover:text-white border border-[#DCE6F2] rounded-lg transition-colors"
|
|
>
|
|
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M3 10h18M3 14h18M10 3v18M14 3v18" />
|
|
</svg>
|
|
Per-Digest View
|
|
</Link>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="max-w-7xl mx-auto px-6 py-8 space-y-8">
|
|
|
|
{/* Page Title */}
|
|
<div>
|
|
<h1 className="text-2xl font-bold font-heading text-white">Email Analytics Dashboard</h1>
|
|
<p className="text-[#555] text-sm font-body mt-1">
|
|
Send volume, open rates, click-through rates, and subscriber growth over time
|
|
</p>
|
|
</div>
|
|
|
|
{/* KPI Cards */}
|
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
|
<StatCard
|
|
label="Active Subscribers"
|
|
value={fmt(stats.activeSubscribers)}
|
|
sub={`${fmt(stats.unsubscribed)} unsubscribed`}
|
|
accent="#1D4ED8"
|
|
trend={{ value: 12, label: 'vs last month' }}
|
|
icon={
|
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z" />
|
|
</svg>
|
|
}
|
|
/>
|
|
<StatCard
|
|
label="Avg Open Rate"
|
|
value={pct(stats.avgOpenRate)}
|
|
sub={`${fmt(stats.totalOpens)} total opens`}
|
|
accent="#10b981"
|
|
trend={{ value: 4, label: 'vs last period' }}
|
|
icon={
|
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M2.036 12.322a1.012 1.012 0 010-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178z" />
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
|
</svg>
|
|
}
|
|
/>
|
|
<StatCard
|
|
label="Avg Click-Through Rate"
|
|
value={pct(stats.avgClickRate)}
|
|
sub={`${fmt(stats.totalClicks)} total clicks`}
|
|
accent="#f59e0b"
|
|
trend={{ value: 2, label: 'vs last period' }}
|
|
icon={
|
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M15.042 21.672L13.684 16.6m0 0l-2.51 2.225.569-9.47 5.227 7.917-3.286-.672zm-7.518-.267A8.25 8.25 0 1120.25 10.5M8.288 14.212A5.25 5.25 0 1117.25 10.5" />
|
|
</svg>
|
|
}
|
|
/>
|
|
<StatCard
|
|
label="Total Emails Sent"
|
|
value={fmt(stats.totalSent)}
|
|
sub={`across ${stats.digestCount} digests`}
|
|
accent="#8b5cf6"
|
|
trend={{ value: 8, label: 'vs last period' }}
|
|
icon={
|
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M21.75 6.75v10.5a2.25 2.25 0 01-2.25 2.25h-15a2.25 2.25 0 01-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25m19.5 0v.243a2.25 2.25 0 01-1.07 1.916l-7.5 4.615a2.25 2.25 0 01-2.36 0L3.32 8.91a2.25 2.25 0 01-1.07-1.916V6.75" />
|
|
</svg>
|
|
}
|
|
/>
|
|
</div>
|
|
|
|
{/* Send Volume Chart */}
|
|
<div className="bg-[#111] border border-[#DCE6F2] rounded-xl p-6">
|
|
<SectionHeader
|
|
title="Send Volume Over Time"
|
|
subtitle="Total emails sent, opens, and clicks per month"
|
|
/>
|
|
<div className="h-72">
|
|
<ResponsiveContainer width="100%" height="100%">
|
|
<ComposedChart data={filteredSendVol} margin={{ top: 4, right: 8, left: -10, bottom: 0 }}>
|
|
<CartesianGrid strokeDasharray="3 3" stroke="#1e1e1e" />
|
|
<XAxis dataKey="label" tick={{ fill: '#555', fontSize: 11 }} tickLine={false} axisLine={false} />
|
|
<YAxis tick={{ fill: '#555', fontSize: 10 }} tickLine={false} axisLine={false} tickFormatter={(v) => fmt(v)} />
|
|
<Tooltip content={<ChartTooltip />} />
|
|
<Legend wrapperStyle={{ fontSize: 11, color: '#888', paddingTop: 12 }} />
|
|
<Bar dataKey="sent" name="Sent" fill="#3b82f620" stroke="#1D4ED8" strokeWidth={1} radius={[3, 3, 0, 0]} />
|
|
<Bar dataKey="opens" name="Opens" fill="#10b98120" stroke="#10b981" strokeWidth={1} radius={[3, 3, 0, 0]} />
|
|
<Bar dataKey="clicks" name="Clicks" fill="#f59e0b20" stroke="#f59e0b" strokeWidth={1} radius={[3, 3, 0, 0]} />
|
|
</ComposedChart>
|
|
</ResponsiveContainer>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Open Rate & CTR Trend */}
|
|
<div className="bg-[#111] border border-[#DCE6F2] rounded-xl p-6">
|
|
<SectionHeader
|
|
title="Open Rate & Click-Through Rate Trend"
|
|
subtitle="Monthly engagement rates as a percentage of recipients"
|
|
/>
|
|
<div className="h-72">
|
|
<ResponsiveContainer width="100%" height="100%">
|
|
<AreaChart data={filteredRates} margin={{ top: 4, right: 8, left: -10, bottom: 0 }}>
|
|
<defs>
|
|
<linearGradient id="openGrad" x1="0" y1="0" x2="0" y2="1">
|
|
<stop offset="5%" stopColor="#1D4ED8" stopOpacity={0.2} />
|
|
<stop offset="95%" stopColor="#1D4ED8" stopOpacity={0} />
|
|
</linearGradient>
|
|
<linearGradient id="clickGrad" x1="0" y1="0" x2="0" y2="1">
|
|
<stop offset="5%" stopColor="#10b981" stopOpacity={0.2} />
|
|
<stop offset="95%" stopColor="#10b981" stopOpacity={0} />
|
|
</linearGradient>
|
|
</defs>
|
|
<CartesianGrid strokeDasharray="3 3" stroke="#1e1e1e" />
|
|
<XAxis dataKey="label" tick={{ fill: '#555', fontSize: 11 }} tickLine={false} axisLine={false} />
|
|
<YAxis
|
|
tick={{ fill: '#555', fontSize: 10 }}
|
|
tickLine={false}
|
|
axisLine={false}
|
|
tickFormatter={(v) => `${v}%`}
|
|
/>
|
|
<Tooltip content={<ChartTooltip />} />
|
|
<Legend wrapperStyle={{ fontSize: 11, color: '#888', paddingTop: 12 }} />
|
|
<Area
|
|
type="monotone"
|
|
dataKey="Open Rate"
|
|
stroke="#1D4ED8"
|
|
strokeWidth={2}
|
|
fill="url(#openGrad)"
|
|
dot={{ r: 3, fill: '#1D4ED8' }}
|
|
activeDot={{ r: 5 }}
|
|
/>
|
|
<Area
|
|
type="monotone"
|
|
dataKey="Click Rate"
|
|
stroke="#10b981"
|
|
strokeWidth={2}
|
|
fill="url(#clickGrad)"
|
|
dot={{ r: 3, fill: '#10b981' }}
|
|
activeDot={{ r: 5 }}
|
|
/>
|
|
</AreaChart>
|
|
</ResponsiveContainer>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Subscriber Growth */}
|
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
|
{/* Growth Over Time */}
|
|
<div className="bg-[#111] border border-[#DCE6F2] rounded-xl p-6">
|
|
<SectionHeader
|
|
title="Subscriber Growth"
|
|
subtitle="Total subscribers and monthly net change"
|
|
/>
|
|
<div className="h-64">
|
|
<ResponsiveContainer width="100%" height="100%">
|
|
<ComposedChart data={filteredGrowth} margin={{ top: 4, right: 8, left: -10, bottom: 0 }}>
|
|
<CartesianGrid strokeDasharray="3 3" stroke="#1e1e1e" />
|
|
<XAxis dataKey="month" tick={{ fill: '#555', fontSize: 11 }} tickLine={false} axisLine={false} />
|
|
<YAxis tick={{ fill: '#555', fontSize: 10 }} tickLine={false} axisLine={false} />
|
|
<Tooltip content={<ChartTooltip />} />
|
|
<Legend wrapperStyle={{ fontSize: 11, color: '#888', paddingTop: 12 }} />
|
|
<Area
|
|
type="monotone"
|
|
dataKey="total"
|
|
name="Total Subscribers"
|
|
stroke="#8b5cf6"
|
|
strokeWidth={2}
|
|
fill="#8b5cf618"
|
|
dot={false}
|
|
/>
|
|
<Bar dataKey="net" name="Net New" fill="#1D4ED8" radius={[3, 3, 0, 0]} />
|
|
</ComposedChart>
|
|
</ResponsiveContainer>
|
|
</div>
|
|
</div>
|
|
|
|
{/* New vs Unsubscribed */}
|
|
<div className="bg-[#111] border border-[#DCE6F2] rounded-xl p-6">
|
|
<SectionHeader
|
|
title="New Subscribers vs Unsubscribes"
|
|
subtitle="Monthly acquisition vs churn"
|
|
/>
|
|
<div className="h-64">
|
|
<ResponsiveContainer width="100%" height="100%">
|
|
<BarChart data={filteredGrowth} margin={{ top: 4, right: 8, left: -10, bottom: 0 }}>
|
|
<CartesianGrid strokeDasharray="3 3" stroke="#1e1e1e" />
|
|
<XAxis dataKey="month" tick={{ fill: '#555', fontSize: 11 }} tickLine={false} axisLine={false} />
|
|
<YAxis tick={{ fill: '#555', fontSize: 10 }} tickLine={false} axisLine={false} />
|
|
<Tooltip content={<ChartTooltip />} />
|
|
<Legend wrapperStyle={{ fontSize: 11, color: '#888', paddingTop: 12 }} />
|
|
<Bar dataKey="new_subs" name="New Subscribers" fill="#10b981" radius={[3, 3, 0, 0]} />
|
|
<Bar dataKey="unsubscribes" name="Unsubscribes" fill="#ef4444" radius={[3, 3, 0, 0]} />
|
|
</BarChart>
|
|
</ResponsiveContainer>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Benchmark Comparison */}
|
|
<div className="bg-[#111] border border-[#DCE6F2] rounded-xl p-6">
|
|
<SectionHeader
|
|
title="Performance vs Industry Benchmarks"
|
|
subtitle="How your metrics compare to email marketing industry averages"
|
|
/>
|
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-6">
|
|
{[
|
|
{
|
|
label: 'Open Rate',
|
|
yours: stats.avgOpenRate,
|
|
benchmark: 21.5,
|
|
color: '#1D4ED8',
|
|
unit: '%',
|
|
},
|
|
{
|
|
label: 'Click-Through Rate',
|
|
yours: stats.avgClickRate,
|
|
benchmark: 2.6,
|
|
color: '#10b981',
|
|
unit: '%',
|
|
},
|
|
{
|
|
label: 'Unsubscribe Rate',
|
|
yours: stats.activeSubscribers > 0
|
|
? parseFloat(((stats.unsubscribed / (stats.activeSubscribers + stats.unsubscribed)) * 100).toFixed(1))
|
|
: 0,
|
|
benchmark: 0.5,
|
|
color: '#f59e0b',
|
|
unit: '%',
|
|
lowerIsBetter: true,
|
|
},
|
|
].map((item) => {
|
|
const isAbove = item.lowerIsBetter
|
|
? item.yours <= item.benchmark
|
|
: item.yours >= item.benchmark;
|
|
const diff = Math.abs(item.yours - item.benchmark).toFixed(1);
|
|
return (
|
|
<div key={item.label} className="space-y-3">
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-[#888] text-xs font-body">{item.label}</span>
|
|
<span
|
|
className="text-xs font-semibold px-2 py-0.5 rounded-full"
|
|
style={{
|
|
background: isAbove ? '#10b98118' : '#ef444418',
|
|
color: isAbove ? '#10b981' : '#ef4444',
|
|
}}
|
|
>
|
|
{isAbove ? `+${diff}%` : `-${diff}%`} vs avg
|
|
</span>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<div>
|
|
<div className="flex justify-between text-xs font-body mb-1">
|
|
<span className="text-white font-semibold">Yours</span>
|
|
<span className="text-white">{item.yours.toFixed(1)}{item.unit}</span>
|
|
</div>
|
|
<div className="h-2 bg-[#1e1e1e] rounded-full overflow-hidden">
|
|
<div
|
|
className="h-full rounded-full transition-all duration-700"
|
|
style={{
|
|
width: `${Math.min((item.yours / (item.benchmark * 2)) * 100, 100)}%`,
|
|
background: item.color,
|
|
}}
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<div className="flex justify-between text-xs font-body mb-1">
|
|
<span className="text-[#555]">Industry avg</span>
|
|
<span className="text-[#555]">{item.benchmark}{item.unit}</span>
|
|
</div>
|
|
<div className="h-2 bg-[#1e1e1e] rounded-full overflow-hidden">
|
|
<div
|
|
className="h-full rounded-full"
|
|
style={{
|
|
width: `${Math.min((item.benchmark / (item.benchmark * 2)) * 100, 100)}%`,
|
|
background: '#333',
|
|
}}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Quick Links */}
|
|
<div className="flex flex-wrap gap-3 pb-4">
|
|
<Link
|
|
href="/admin/newsletter"
|
|
className="flex items-center gap-2 px-4 py-2 bg-[#111] border border-[#DCE6F2] rounded-lg text-sm font-body text-[#888] hover:text-white hover:border-[#444] transition-colors"
|
|
>
|
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M21.75 6.75v10.5a2.25 2.25 0 01-2.25 2.25h-15a2.25 2.25 0 01-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25m19.5 0v.243a2.25 2.25 0 01-1.07 1.916l-7.5 4.615a2.25 2.25 0 01-2.36 0L3.32 8.91a2.25 2.25 0 01-1.07-1.916V6.75" />
|
|
</svg>
|
|
Manage Subscribers
|
|
</Link>
|
|
<Link
|
|
href="/admin/newsletter/analytics"
|
|
className="flex items-center gap-2 px-4 py-2 bg-[#111] border border-[#DCE6F2] rounded-lg text-sm font-body text-[#888] hover:text-white hover:border-[#444] transition-colors"
|
|
>
|
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 013 19.875v-6.75zM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V8.625zM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V4.125z" />
|
|
</svg>
|
|
Per-Digest Analytics
|
|
</Link>
|
|
<Link
|
|
href="/admin/newsletter/templates"
|
|
className="flex items-center gap-2 px-4 py-2 bg-[#111] border border-[#DCE6F2] rounded-lg text-sm font-body text-[#888] hover:text-white hover:border-[#444] transition-colors"
|
|
>
|
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" />
|
|
</svg>
|
|
Templates
|
|
</Link>
|
|
</div>
|
|
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|