Files
avbeat-com/src/app/admin/analytics/page.tsx
Ryan Salazar 17e8d9914e fix: BB admin login no longer bounces to distribute
Problem: /admin/* pages redirect unauthed users to /login or /account,
both of which then hit middleware redirecting to distribute.relevant
mediaproperties.com. Distribute is on a different domain, so its session
cookie doesn't carry to broadcastbeat.com — sign-in there left admins
stranded on distribute's dashboard.

Fix:
  - Remove /login (and /register) from middleware REDIRECT_PATHS. wp-login,
    wp-admin, client-login still bounce to distribute.
  - /login is now a real BB sign-in form (was a redirect to /client-login).
  - Admin pages preserve return path via /login?next=<encoded path>.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 14:28:29 +00:00

585 lines
28 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,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
LineChart,
Line,
PieChart,
Pie,
Cell,
Legend,
} from 'recharts';
// ─── Types ────────────────────────────────────────────────────────────────────
interface ArticleMetrics {
totalArticles: number;
publishedArticles: number;
draftArticles: number;
pendingArticles: number;
archivedArticles: number;
importedArticles: number;
nativeArticles: number;
}
interface AuthorStat {
author_name: string;
total: number;
published: number;
draft: number;
pending: number;
}
interface CategoryStat {
category: string;
count: number;
published: number;
}
interface ImportStat {
label: string;
total: number;
success: number;
failed: number;
rate: number;
}
interface MonthlyTrend {
month: string;
published: number;
imported: number;
native: number;
}
// ─── Mock / derived data helpers ──────────────────────────────────────────────
const CATEGORY_COLORS = [
'#3b82f6', '#f59e0b', '#10b981', '#8b5cf6',
'#ef4444', '#06b6d4', '#f97316', '#84cc16',
];
// ─── Stat Card ────────────────────────────────────────────────────────────────
interface StatCardProps {
label: string;
value: number | string;
sub?: string;
accent?: string;
icon: React.ReactNode;
}
function StatCard({ label, value, sub, accent = '#3b82f6', icon }: StatCardProps) {
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>
{sub && <p className="text-[#555] text-xs font-body mt-1">{sub}</p>}
</div>
</div>
);
}
// ─── Section Header ───────────────────────────────────────────────────────────
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>
);
}
// ─── Custom Tooltip ───────────────────────────────────────────────────────────
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>
);
}
// ─── Main Page ────────────────────────────────────────────────────────────────
export default function AdminAnalyticsPage() {
const { user, loading } = useAuth();
const router = useRouter();
const [metrics, setMetrics] = useState<ArticleMetrics | null>(null);
const [authorStats, setAuthorStats] = useState<AuthorStat[]>([]);
const [categoryStats, setCategoryStats] = useState<CategoryStat[]>([]);
const [importStats, setImportStats] = useState<ImportStat[]>([]);
const [monthlyTrends, setMonthlyTrends] = useState<MonthlyTrend[]>([]);
const [loadingData, setLoadingData] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!loading && !user) router.replace('/login?next=' + encodeURIComponent(window.location.pathname));
}, [user, loading, router]);
const fetchAnalytics = useCallback(async () => {
setLoadingData(true);
setError(null);
try {
// Fetch all articles to derive analytics
const res = await fetch('/api/admin/articles');
if (!res.ok) throw new Error('Failed to fetch articles');
const data = await res.json();
const articles: any[] = data.articles ?? [];
// ── Article Metrics ──────────────────────────────────────────────────
const m: ArticleMetrics = {
totalArticles: articles.length,
publishedArticles: articles.filter((a) => a.status === 'published').length,
draftArticles: articles.filter((a) => a.status === 'draft').length,
pendingArticles: articles.filter((a) => a.status === 'pending').length,
archivedArticles: articles.filter((a) => a.status === 'archived').length,
importedArticles: articles.filter((a) => a.source === 'imported').length,
nativeArticles: articles.filter((a) => a.source === 'native').length,
};
setMetrics(m);
// ── Author Stats ─────────────────────────────────────────────────────
const authorMap: Record<string, AuthorStat> = {};
articles.forEach((a) => {
const name = a.author_name || 'Unknown';
if (!authorMap[name]) {
authorMap[name] = { author_name: name, total: 0, published: 0, draft: 0, pending: 0 };
}
authorMap[name].total++;
if (a.status === 'published') authorMap[name].published++;
if (a.status === 'draft') authorMap[name].draft++;
if (a.status === 'pending') authorMap[name].pending++;
});
const sortedAuthors = Object.values(authorMap)
.sort((a, b) => b.total - a.total)
.slice(0, 10);
setAuthorStats(sortedAuthors);
// ── Category Stats ───────────────────────────────────────────────────
const catMap: Record<string, CategoryStat> = {};
articles.forEach((a) => {
const cat = a.category || 'Uncategorized';
if (!catMap[cat]) catMap[cat] = { category: cat, count: 0, published: 0 };
catMap[cat].count++;
if (a.status === 'published') catMap[cat].published++;
});
const sortedCats = Object.values(catMap)
.sort((a, b) => b.count - a.count)
.slice(0, 8);
setCategoryStats(sortedCats);
// ── Import Stats ─────────────────────────────────────────────────────
const imported = articles.filter((a) => a.source === 'imported');
const native = articles.filter((a) => a.source === 'native');
const importedPublished = imported.filter((a) => a.status === 'published').length;
const nativePublished = native.filter((a) => a.status === 'published').length;
setImportStats([
{
label: 'WP Imported',
total: imported.length,
success: importedPublished,
failed: imported.filter((a) => a.status === 'archived').length,
rate: imported.length > 0 ? Math.round((importedPublished / imported.length) * 100) : 0,
},
{
label: 'Native',
total: native.length,
success: nativePublished,
failed: native.filter((a) => a.status === 'archived').length,
rate: native.length > 0 ? Math.round((nativePublished / native.length) * 100) : 0,
},
]);
// ── Monthly Trends (last 6 months from article dates) ────────────────
const now = new Date();
const months: MonthlyTrend[] = [];
for (let i = 5; i >= 0; i--) {
const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`;
const label = d.toLocaleString('default', { month: 'short', year: '2-digit' });
const monthArticles = articles.filter((a) => {
const date = a.date ? new Date(a.date) : null;
if (!date) return false;
return (
date.getFullYear() === d.getFullYear() && date.getMonth() === d.getMonth()
);
});
months.push({
month: label,
published: monthArticles.filter((a) => a.status === 'published').length,
imported: monthArticles.filter((a) => a.source === 'imported').length,
native: monthArticles.filter((a) => a.source === 'native').length,
});
}
setMonthlyTrends(months);
} catch (err: any) {
setError(err.message ?? 'Unknown error');
} finally {
setLoadingData(false);
}
}, []);
useEffect(() => {
if (user) fetchAnalytics();
}, [user, fetchAnalytics]);
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-[#3b82f6] border-t-transparent rounded-full animate-spin" />
<p className="text-[#555] text-sm font-body">Loading analytics</p>
</div>
</div>
);
}
const publishRate = metrics && metrics.totalArticles > 0
? Math.round((metrics.publishedArticles / metrics.totalArticles) * 100)
: 0;
return (
<div className="min-h-screen bg-[#0a0a0a] text-white">
{/* ── Page Header ─────────────────────────────────────────────────── */}
<div className="bg-[#111] border-b border-[#252525]">
<div className="max-w-7xl mx-auto px-4 sm:px-6 py-5 flex items-center justify-between gap-4">
<div>
<h1 className="text-white text-xl font-bold font-heading uppercase tracking-wider">Analytics</h1>
<p className="text-[#555] text-xs font-body mt-0.5">
Article performance, author contributions &amp; import health
</p>
</div>
<div className="flex items-center gap-2 flex-wrap">
<Link href="/admin/articles" className="text-[#555] hover:text-[#3b82f6] text-xs font-body uppercase tracking-wider transition-colors">
Articles
</Link>
<span className="text-[#333]">·</span>
<Link href="/admin/users" className="text-[#555] hover:text-[#3b82f6] text-xs font-body uppercase tracking-wider transition-colors">
Users
</Link>
<span className="text-[#333]">·</span>
<Link href="/admin/import" className="text-[#555] hover:text-[#3b82f6] text-xs font-body uppercase tracking-wider transition-colors">
WP Import
</Link>
<button
onClick={fetchAnalytics}
className="ml-2 px-3 py-1.5 bg-[#1a1a1a] border border-[#333] hover:border-[#3b82f6] text-[#888] hover:text-[#3b82f6] text-xs font-body rounded transition-colors"
>
Refresh
</button>
</div>
</div>
</div>
<div className="max-w-7xl mx-auto px-4 sm:px-6 py-8 space-y-10">
{error && (
<div className="bg-red-500/10 border border-red-500/30 rounded-lg px-4 py-3 text-red-400 text-sm font-body">
{error}
</div>
)}
{/* ── Overview Stats ───────────────────────────────────────────── */}
<section>
<SectionHeader title="Article Performance" subtitle="Overall content health across all sources" />
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-3">
<StatCard
label="Total Articles"
value={metrics?.totalArticles ?? 0}
icon={<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>}
accent="#3b82f6"
/>
<StatCard
label="Published"
value={metrics?.publishedArticles ?? 0}
sub={`${publishRate}% publish rate`}
icon={<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><polyline points="20 6 9 17 4 12"/></svg>}
accent="#10b981"
/>
<StatCard
label="Pending Review"
value={metrics?.pendingArticles ?? 0}
sub="Awaiting approval"
icon={<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>}
accent="#f59e0b"
/>
<StatCard
label="Drafts"
value={metrics?.draftArticles ?? 0}
sub="In progress"
icon={<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>}
accent="#8b5cf6"
/>
<StatCard
label="WP Imported"
value={metrics?.importedArticles ?? 0}
sub="From WordPress"
icon={<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><polyline points="8 17 12 21 16 17"/><line x1="12" y1="12" x2="12" y2="21"/><path d="M20.88 18.09A5 5 0 0 0 18 9h-1.26A8 8 0 1 0 3 16.29"/></svg>}
accent="#06b6d4"
/>
<StatCard
label="Native Articles"
value={metrics?.nativeArticles ?? 0}
sub="Created in-platform"
icon={<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="16"/><line x1="8" y1="12" x2="16" y2="12"/></svg>}
accent="#f97316"
/>
<StatCard
label="Archived"
value={metrics?.archivedArticles ?? 0}
sub="No longer active"
icon={<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><polyline points="21 8 21 21 3 21 3 8"/><rect x="1" y="3" width="22" height="5"/><line x1="10" y1="12" x2="14" y2="12"/></svg>}
accent="#555"
/>
<StatCard
label="Publish Rate"
value={`${publishRate}%`}
sub="Published / total"
icon={<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><line x1="18" y1="20" x2="18" y2="10"/><line x1="12" y1="20" x2="12" y2="4"/><line x1="6" y1="20" x2="6" y2="14"/></svg>}
accent="#3b82f6"
/>
</div>
</section>
{/* ── Monthly Trend ────────────────────────────────────────────── */}
<section>
<SectionHeader title="Monthly Trends" subtitle="Published, imported, and native articles over the last 6 months" />
<div className="bg-[#111] border border-[#252525] rounded-lg p-5">
{monthlyTrends.length > 0 ? (
<ResponsiveContainer width="100%" height={240}>
<LineChart data={monthlyTrends} margin={{ top: 4, right: 8, left: -20, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#1e1e1e" />
<XAxis dataKey="month" tick={{ fill: '#555', fontSize: 11 }} axisLine={false} tickLine={false} />
<YAxis tick={{ fill: '#555', fontSize: 11 }} axisLine={false} tickLine={false} allowDecimals={false} />
<Tooltip content={<ChartTooltip />} />
<Legend wrapperStyle={{ fontSize: 11, color: '#666' }} />
<Line type="monotone" dataKey="published" stroke="#10b981" strokeWidth={2} dot={{ r: 3, fill: '#10b981' }} name="Published" />
<Line type="monotone" dataKey="imported" stroke="#06b6d4" strokeWidth={2} dot={{ r: 3, fill: '#06b6d4' }} name="Imported" />
<Line type="monotone" dataKey="native" stroke="#f97316" strokeWidth={2} dot={{ r: 3, fill: '#f97316' }} name="Native" />
</LineChart>
</ResponsiveContainer>
) : (
<p className="text-[#444] text-sm font-body text-center py-10">No trend data available</p>
)}
</div>
</section>
{/* ── Author Contributions + Category Performance ──────────────── */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Author Contributions */}
<section>
<SectionHeader title="Author Contributions" subtitle="Top contributors by article count" />
<div className="bg-[#111] border border-[#252525] rounded-lg p-5">
{authorStats.length > 0 ? (
<ResponsiveContainer width="100%" height={280}>
<BarChart
data={authorStats}
layout="vertical"
margin={{ top: 0, right: 8, left: 0, bottom: 0 }}
>
<CartesianGrid strokeDasharray="3 3" stroke="#1e1e1e" horizontal={false} />
<XAxis type="number" tick={{ fill: '#555', fontSize: 11 }} axisLine={false} tickLine={false} allowDecimals={false} />
<YAxis
type="category"
dataKey="author_name"
tick={{ fill: '#888', fontSize: 11 }}
axisLine={false}
tickLine={false}
width={100}
tickFormatter={(v: string) => v.length > 14 ? v.slice(0, 13) + '…' : v}
/>
<Tooltip content={<ChartTooltip />} />
<Bar dataKey="published" stackId="a" fill="#10b981" name="Published" radius={[0, 0, 0, 0]} />
<Bar dataKey="draft" stackId="a" fill="#f59e0b" name="Draft" />
<Bar dataKey="pending" stackId="a" fill="#f97316" name="Pending" radius={[0, 3, 3, 0]} />
</BarChart>
</ResponsiveContainer>
) : (
<p className="text-[#444] text-sm font-body text-center py-10">No author data available</p>
)}
</div>
</section>
{/* Top Categories */}
<section>
<SectionHeader title="Top-Performing Categories" subtitle="Article distribution by category" />
<div className="bg-[#111] border border-[#252525] rounded-lg p-5">
{categoryStats.length > 0 ? (
<>
<ResponsiveContainer width="100%" height={200}>
<PieChart>
<Pie
data={categoryStats}
dataKey="count"
nameKey="category"
cx="50%"
cy="50%"
outerRadius={80}
innerRadius={44}
paddingAngle={2}
>
{categoryStats.map((_, i) => (
<Cell key={i} fill={CATEGORY_COLORS[i % CATEGORY_COLORS.length]} />
))}
</Pie>
<Tooltip content={<ChartTooltip />} />
</PieChart>
</ResponsiveContainer>
<div className="mt-4 space-y-2">
{categoryStats.map((cat, i) => (
<div key={cat.category} className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2 min-w-0">
<span
className="w-2.5 h-2.5 rounded-full flex-shrink-0"
style={{ background: CATEGORY_COLORS[i % CATEGORY_COLORS.length] }}
/>
<span className="text-[#888] text-xs font-body truncate">{cat.category}</span>
</div>
<div className="flex items-center gap-3 flex-shrink-0">
<span className="text-[#555] text-xs font-body">{cat.published} pub</span>
<span className="text-white text-xs font-bold font-body">{cat.count}</span>
</div>
</div>
))}
</div>
</>
) : (
<p className="text-[#444] text-sm font-body text-center py-10">No category data available</p>
)}
</div>
</section>
</div>
{/* ── Import Success Rates ─────────────────────────────────────── */}
<section>
<SectionHeader title="Import Success Rates" subtitle="Publishing success across content sources" />
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{importStats.map((stat) => (
<div key={stat.label} className="bg-[#111] border border-[#252525] rounded-lg p-5">
<div className="flex items-center justify-between mb-4">
<p className="text-white text-sm font-bold font-heading uppercase tracking-wider">{stat.label}</p>
<span
className="text-xs font-bold font-body px-2 py-0.5 rounded"
style={{
color: stat.rate >= 70 ? '#10b981' : stat.rate >= 40 ? '#f59e0b' : '#ef4444',
background: stat.rate >= 70 ? '#10b98118' : stat.rate >= 40 ? '#f59e0b18' : '#ef444418',
}}
>
{stat.rate}% success
</span>
</div>
{/* Progress bar */}
<div className="w-full h-2 bg-[#1e1e1e] rounded-full overflow-hidden mb-4">
<div
className="h-full rounded-full transition-all duration-700"
style={{
width: `${stat.rate}%`,
background: stat.rate >= 70 ? '#10b981' : stat.rate >= 40 ? '#f59e0b' : '#ef4444',
}}
/>
</div>
<div className="grid grid-cols-3 gap-3">
<div>
<p className="text-[#555] text-xs font-body uppercase tracking-wider mb-0.5">Total</p>
<p className="text-white text-lg font-bold font-heading">{stat.total}</p>
</div>
<div>
<p className="text-[#555] text-xs font-body uppercase tracking-wider mb-0.5">Published</p>
<p className="text-[#10b981] text-lg font-bold font-heading">{stat.success}</p>
</div>
<div>
<p className="text-[#555] text-xs font-body uppercase tracking-wider mb-0.5">Archived</p>
<p className="text-[#ef4444] text-lg font-bold font-heading">{stat.failed}</p>
</div>
</div>
</div>
))}
</div>
</section>
{/* ── Author Detail Table ──────────────────────────────────────── */}
<section>
<SectionHeader title="Author Detail Breakdown" subtitle="Per-author article status distribution" />
<div className="bg-[#111] border border-[#252525] rounded-lg overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-sm font-body">
<thead>
<tr className="border-b border-[#1e1e1e]">
<th className="text-left text-[#555] text-xs uppercase tracking-wider px-4 py-3 font-normal">Author</th>
<th className="text-right text-[#555] text-xs uppercase tracking-wider px-4 py-3 font-normal">Total</th>
<th className="text-right text-[#555] text-xs uppercase tracking-wider px-4 py-3 font-normal">Published</th>
<th className="text-right text-[#555] text-xs uppercase tracking-wider px-4 py-3 font-normal">Draft</th>
<th className="text-right text-[#555] text-xs uppercase tracking-wider px-4 py-3 font-normal">Pending</th>
<th className="text-right text-[#555] text-xs uppercase tracking-wider px-4 py-3 font-normal">Pub Rate</th>
</tr>
</thead>
<tbody>
{authorStats.length === 0 ? (
<tr>
<td colSpan={6} className="text-center text-[#444] py-8">No author data available</td>
</tr>
) : (
authorStats.map((a, i) => {
const rate = a.total > 0 ? Math.round((a.published / a.total) * 100) : 0;
return (
<tr
key={a.author_name}
className={`border-b border-[#1a1a1a] hover:bg-[#151515] transition-colors ${i % 2 === 0 ? '' : 'bg-[#0d0d0d]'}`}
>
<td className="px-4 py-3 text-white font-medium">{a.author_name}</td>
<td className="px-4 py-3 text-right text-[#888]">{a.total}</td>
<td className="px-4 py-3 text-right text-[#10b981]">{a.published}</td>
<td className="px-4 py-3 text-right text-[#f59e0b]">{a.draft}</td>
<td className="px-4 py-3 text-right text-[#f97316]">{a.pending}</td>
<td className="px-4 py-3 text-right">
<span
className="text-xs font-bold px-2 py-0.5 rounded"
style={{
color: rate >= 70 ? '#10b981' : rate >= 40 ? '#f59e0b' : '#ef4444',
background: rate >= 70 ? '#10b98118' : rate >= 40 ? '#f59e0b18' : '#ef444418',
}}
>
{rate}%
</span>
</td>
</tr>
);
})
)}
</tbody>
</table>
</div>
</div>
</section>
</div>
</div>
);
}