Files
avbeat-com/src/app/admin/newsletter/analytics/page.tsx
2026-05-07 16:39:17 +00:00

474 lines
20 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 {
LineChart,
Line,
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
Legend,
} from 'recharts';
// ─── Types ────────────────────────────────────────────────────────────────────
interface SubscriberGrowthPoint {
month: string;
subscribers: number;
unsubscribes: number;
net: number;
}
interface DigestPerformance {
id: string;
subject: string;
sent_at: string;
recipients: number;
opens: number;
clicks: number;
unsubscribes: number;
open_rate: number;
click_rate: number;
}
interface OverviewMetric {
label: string;
value: string;
sub: string;
accent: string;
icon: React.ReactNode;
}
// ─── Helpers ──────────────────────────────────────────────────────────────────
function fmt(n: number): string {
if (n >= 1000) return (n / 1000).toFixed(1) + 'k';
return String(n);
}
function pct(n: number): string {
return n.toFixed(1) + '%';
}
function fmtDate(dateStr: string): string {
return new Date(dateStr).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
});
}
// ─── Sub-components ───────────────────────────────────────────────────────────
function StatCard({ label, value, sub, accent = '#3b82f6', icon }: {
label: string; value: string; sub: string; accent?: string; icon: React.ReactNode;
}) {
return (
<div className="bg-[#111] border border-[#252525] rounded-lg p-5 flex items-start gap-4">
<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>
<div className="min-w-0">
<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-[#1a1a1a] border border-[#333] rounded-lg px-3 py-2 text-xs font-body shadow-xl">
{label && <p className="text-[#888] mb-1">{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">{entry.value}</span>
</p>
))}
</div>
);
}
function SectionHeader({ title, subtitle }: { title: string; subtitle?: string }) {
return (
<div className="mb-5">
<h2 className="text-white text-base font-bold font-heading uppercase tracking-wider">{title}</h2>
{subtitle && <p className="text-[#555] text-xs font-body mt-0.5">{subtitle}</p>}
</div>
);
}
// ─── Mock data generators (replace with real API when available) ──────────────
function generateGrowthData(): SubscriberGrowthPoint[] {
const months = ['Aug', 'Sep', 'Oct', 'Nov', 'Dec', 'Jan', 'Feb', 'Mar'];
let running = 120;
return months.map((month) => {
const newSubs = Math.floor(Math.random() * 80) + 20;
const unsubs = Math.floor(Math.random() * 15) + 2;
running += newSubs - unsubs;
return { month, subscribers: running, unsubscribes: unsubs, net: newSubs - unsubs };
});
}
function generateDigestData(): DigestPerformance[] {
const subjects = [
'Weekly Digest: Top Stories This Week',
'Breaking: Major Industry Shifts',
'Monthly Roundup — February 2026',
'Special Report: Trends to Watch',
'Weekly Digest: Editor\'s Picks',
'Flash Update: Breaking News',
'Weekly Digest: Community Highlights',
'Monthly Roundup — January 2026',
];
return subjects.map((subject, i) => {
const recipients = Math.floor(Math.random() * 300) + 200;
const opens = Math.floor(recipients * (Math.random() * 0.3 + 0.2));
const clicks = Math.floor(opens * (Math.random() * 0.25 + 0.05));
const unsubscribes = Math.floor(Math.random() * 5);
const daysAgo = (i + 1) * 7;
const sentAt = new Date(Date.now() - daysAgo * 86400000).toISOString();
return {
id: `digest-${i + 1}`,
subject,
sent_at: sentAt,
recipients,
opens,
clicks,
unsubscribes,
open_rate: (opens / recipients) * 100,
click_rate: (clicks / recipients) * 100,
};
});
}
function generateRatesTrend(digests: DigestPerformance[]) {
return [...digests].reverse().map((d) => ({
label: fmtDate(d.sent_at),
'Open Rate': parseFloat(d.open_rate.toFixed(1)),
'Click Rate': parseFloat(d.click_rate.toFixed(1)),
}));
}
// ─── Main Page ────────────────────────────────────────────────────────────────
export default function NewsletterAnalyticsPage() {
const { user, loading } = useAuth();
const router = useRouter();
const [growthData, setGrowthData] = useState<SubscriberGrowthPoint[]>([]);
const [digestData, setDigestData] = useState<DigestPerformance[]>([]);
const [ratesTrend, setRatesTrend] = useState<any[]>([]);
const [totalSubscribers, setTotalSubscribers] = useState(0);
const [loadingData, setLoadingData] = useState(true);
const [sortField, setSortField] = useState<keyof DigestPerformance>('sent_at');
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('desc');
useEffect(() => {
if (!loading && !user) router.replace('/account');
}, [user, loading, router]);
const fetchData = useCallback(async () => {
setLoadingData(true);
try {
// Fetch real subscriber count
const res = await fetch('/api/newsletter/subscribers');
if (res.ok) {
const data = await res.json();
const subs: any[] = data.subscribers ?? [];
setTotalSubscribers(subs.filter((s) => s.status === 'active').length);
}
} catch {
// silent
} finally {
// Generate derived/mock analytics data
const growth = generateGrowthData();
const digests = generateDigestData();
setGrowthData(growth);
setDigestData(digests);
setRatesTrend(generateRatesTrend(digests));
setLoadingData(false);
}
}, []);
useEffect(() => {
if (user) fetchData();
}, [user, fetchData]);
const handleSort = (field: keyof DigestPerformance) => {
if (sortField === field) {
setSortDir((d) => (d === 'asc' ? 'desc' : 'asc'));
} else {
setSortField(field);
setSortDir('desc');
}
};
const sortedDigests = [...digestData].sort((a, b) => {
const av = a[sortField];
const bv = b[sortField];
if (typeof av === 'number' && typeof bv === 'number') {
return sortDir === 'asc' ? av - bv : bv - av;
}
return sortDir === 'asc'
? String(av).localeCompare(String(bv))
: String(bv).localeCompare(String(av));
});
// Aggregate metrics
const avgOpenRate =
digestData.length > 0
? digestData.reduce((s, d) => s + d.open_rate, 0) / digestData.length
: 0;
const avgClickRate =
digestData.length > 0
? digestData.reduce((s, d) => s + d.click_rate, 0) / digestData.length
: 0;
const totalSent = digestData.reduce((s, d) => s + d.recipients, 0);
const totalOpens = digestData.reduce((s, d) => s + d.opens, 0);
if (loading || loadingData) {
return (
<div className="min-h-screen bg-[#0a0a0a] flex items-center justify-center">
<div className="w-8 h-8 border-2 border-[#3b82f6] border-t-transparent rounded-full animate-spin" />
</div>
);
}
const SortIcon = ({ field }: { field: keyof DigestPerformance }) => (
<span className="ml-1 inline-flex flex-col leading-none">
<span className={sortField === field && sortDir === 'asc' ? 'text-white' : 'text-[#444]'}></span>
<span className={sortField === field && sortDir === 'desc' ? 'text-white' : 'text-[#444]'}></span>
</span>
);
return (
<div className="min-h-screen bg-[#0a0a0a] text-white">
{/* Header */}
<div className="border-b border-[#1a1a1a] bg-[#0d0d0d] 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-[#252525]" />
<Link href="/admin/newsletter" className="text-[#555] hover:text-white text-sm font-body transition-colors">
Newsletter
</Link>
<svg className="w-3 h-3 text-[#333]" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
</svg>
<span className="text-white text-sm font-body font-semibold">Analytics</span>
</div>
<div className="flex items-center gap-3">
<Link
href="/admin/newsletter/templates"
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-[#252525] 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="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 className="w-px h-8 bg-[#252525]" />
<div className="text-right">
<p className="text-white text-sm font-bold font-heading">{fmt(totalSubscribers)}</p>
<p className="text-[#555] text-xs font-body">active subscribers</p>
</div>
</div>
</div>
</div>
<div className="max-w-7xl mx-auto px-6 py-8 space-y-10">
{/* Page title */}
<div>
<h1 className="text-2xl font-bold font-heading text-white">Newsletter Analytics</h1>
<p className="text-[#555] text-sm font-body mt-1">Performance overview across all sent digests</p>
</div>
{/* Overview stat cards */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<StatCard
label="Avg Open Rate"
value={pct(avgOpenRate)}
sub={`across ${digestData.length} digests`}
accent="#3b82f6"
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 Rate"
value={pct(avgClickRate)}
sub="clicks per recipient"
accent="#10b981"
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(totalSent)}
sub={`${fmt(totalOpens)} total opens`}
accent="#f59e0b"
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>
}
/>
<StatCard
label="Active Subscribers"
value={fmt(totalSubscribers)}
sub="confirmed opt-ins"
accent="#8b5cf6"
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>
}
/>
</div>
{/* Open & Click Rate Trend */}
<div className="bg-[#111] border border-[#252525] rounded-lg p-6">
<SectionHeader title="Open & Click Rate Trend" subtitle="Rate per digest over time" />
<div className="h-64">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={ratesTrend} margin={{ top: 4, right: 8, left: -20, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#1e1e1e" />
<XAxis dataKey="label" tick={{ fill: '#555', fontSize: 10 }} 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' }} />
<Line type="monotone" dataKey="Open Rate" stroke="#3b82f6" strokeWidth={2} dot={{ r: 3, fill: '#3b82f6' }} activeDot={{ r: 5 }} />
<Line type="monotone" dataKey="Click Rate" stroke="#10b981" strokeWidth={2} dot={{ r: 3, fill: '#10b981' }} activeDot={{ r: 5 }} />
</LineChart>
</ResponsiveContainer>
</div>
</div>
{/* Subscriber Growth Chart */}
<div className="bg-[#111] border border-[#252525] rounded-lg p-6">
<SectionHeader title="Subscriber Growth" subtitle="Monthly net new subscribers vs unsubscribes" />
<div className="h-64">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={growthData} margin={{ top: 4, right: 8, left: -20, 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: 11 }} tickLine={false} axisLine={false} />
<Tooltip content={<ChartTooltip />} />
<Legend wrapperStyle={{ fontSize: 11, color: '#888' }} />
<Bar dataKey="subscribers" name="Total Subscribers" fill="#3b82f6" radius={[3, 3, 0, 0]} />
<Bar dataKey="unsubscribes" name="Unsubscribes" fill="#ef4444" radius={[3, 3, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</div>
</div>
{/* Per-Digest Performance Table */}
<div className="bg-[#111] border border-[#252525] rounded-lg overflow-hidden">
<div className="px-6 py-5 border-b border-[#1e1e1e]">
<SectionHeader title="Per-Digest Performance" subtitle="Click column headers to sort" />
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm font-body">
<thead>
<tr className="border-b border-[#1e1e1e]">
{[
{ label: 'Subject', field: 'subject' as keyof DigestPerformance },
{ label: 'Sent', field: 'sent_at' as keyof DigestPerformance },
{ label: 'Recipients', field: 'recipients' as keyof DigestPerformance },
{ label: 'Opens', field: 'opens' as keyof DigestPerformance },
{ label: 'Open Rate', field: 'open_rate' as keyof DigestPerformance },
{ label: 'Clicks', field: 'clicks' as keyof DigestPerformance },
{ label: 'Click Rate', field: 'click_rate' as keyof DigestPerformance },
{ label: 'Unsubs', field: 'unsubscribes' as keyof DigestPerformance },
].map(({ label, field }) => (
<th
key={field}
onClick={() => handleSort(field)}
className="px-4 py-3 text-left text-xs text-[#555] uppercase tracking-wider cursor-pointer hover:text-white transition-colors select-none whitespace-nowrap"
>
{label}
<SortIcon field={field} />
</th>
))}
</tr>
</thead>
<tbody>
{sortedDigests.map((digest, i) => (
<tr
key={digest.id}
className={`border-b border-[#1a1a1a] hover:bg-[#161616] transition-colors ${i % 2 === 0 ? '' : 'bg-[#0d0d0d]'}`}
>
<td className="px-4 py-3 text-white max-w-xs">
<p className="truncate font-medium" title={digest.subject}>{digest.subject}</p>
</td>
<td className="px-4 py-3 text-[#888] whitespace-nowrap">{fmtDate(digest.sent_at)}</td>
<td className="px-4 py-3 text-[#ccc]">{digest.recipients.toLocaleString()}</td>
<td className="px-4 py-3 text-[#ccc]">{digest.opens.toLocaleString()}</td>
<td className="px-4 py-3">
<span
className="inline-flex items-center px-2 py-0.5 rounded text-xs font-semibold"
style={{
background: digest.open_rate >= 25 ? '#10b98120' : digest.open_rate >= 15 ? '#f59e0b20' : '#ef444420',
color: digest.open_rate >= 25 ? '#10b981' : digest.open_rate >= 15 ? '#f59e0b' : '#ef4444',
}}
>
{pct(digest.open_rate)}
</span>
</td>
<td className="px-4 py-3 text-[#ccc]">{digest.clicks.toLocaleString()}</td>
<td className="px-4 py-3">
<span
className="inline-flex items-center px-2 py-0.5 rounded text-xs font-semibold"
style={{
background: digest.click_rate >= 5 ? '#10b98120' : digest.click_rate >= 2 ? '#f59e0b20' : '#ef444420',
color: digest.click_rate >= 5 ? '#10b981' : digest.click_rate >= 2 ? '#f59e0b' : '#ef4444',
}}
>
{pct(digest.click_rate)}
</span>
</td>
<td className="px-4 py-3 text-[#888]">{digest.unsubscribes}</td>
</tr>
))}
{sortedDigests.length === 0 && (
<tr>
<td colSpan={8} className="px-4 py-12 text-center text-[#555] font-body text-sm">
No digest data available yet.
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
</div>
</div>
);
}