'use client'; import { useEffect, useState } from 'react'; import { useRouter } from 'next/navigation'; import { useAuth } from '@/contexts/AuthContext'; import { isAdmin } from '@/lib/isAdmin'; /** * /admin — internal dashboard (Session 18). * * Not linked from nav. Operator bookmarks the URL. * * Two-layer access control: * 1. Client-side `isAdmin(user.email)` redirect — UX-only. Prevents * a confused non-admin from seeing a 403 screen. Anyone with * devtools can bypass this. * 2. Server-side check in `/api/admin/stats/route.ts` — the real * security boundary. Non-admin tokens hit a 403 before any data * leaves Supabase. * * Data shape: see AdminStats interface in the API route. */ interface AdminStats { generated_at: string; users: { total: number; by_tier: Record; recent_24h: Array<{ email_masked: string; tier: string; created_at: string }>; }; grades: { total: number; today: number; }; health: { sports: Array<{ sport: string; status: 'ok' | 'error' | 'empty'; quota?: number | null; props?: number; error?: string }>; odds_quota_remaining: number | null; }; provider_quotas?: Array<{ provider: string; name?: string; used: number; limit: number; remaining: number; pct: number; period: string; quotaType: string; allowed: boolean; degraded?: boolean; }>; notes: string[]; } function timeAgo(iso: string): string { if (!iso) return ''; const then = Date.parse(iso); if (!Number.isFinite(then)) return ''; const diffSec = Math.floor((Date.now() - then) / 1000); if (diffSec < 60) return `${diffSec}s ago`; if (diffSec < 3600) return `${Math.floor(diffSec / 60)}m ago`; if (diffSec < 86400) return `${Math.floor(diffSec / 3600)}h ago`; return `${Math.floor(diffSec / 86400)}d ago`; } function pct(part: number, whole: number): string { if (!whole) return '0%'; return `${Math.round((part / whole) * 1000) / 10}%`; } const cellStyle: React.CSSProperties = { padding: 16, border: '1px solid var(--border, #1A1A24)', background: 'var(--bg-surface, #12121A)', borderRadius: 8, }; const labelStyle: React.CSSProperties = { fontSize: 10, textTransform: 'uppercase', letterSpacing: '0.12em', color: 'var(--text-tertiary, #6B6B7B)', marginBottom: 6, }; const numberStyle: React.CSSProperties = { fontSize: 32, fontWeight: 800, color: 'var(--text-0, #F0F0F5)', letterSpacing: '-0.02em', fontVariantNumeric: 'tabular-nums', }; export default function AdminPage() { const router = useRouter(); const { user, session, loading: authLoading } = useAuth(); const [stats, setStats] = useState(null); const [error, setError] = useState(null); const [fetching, setFetching] = useState(true); // Client-side guard — UX only. Server enforces the real boundary. useEffect(() => { if (authLoading) return; if (!user) { router.replace('/login?next=/admin'); return; } if (!isAdmin(user.email)) { router.replace('/dashboard'); } }, [authLoading, user, router]); // Stats fetch. Triggered once `session` is available; the server // 403s if the token isn't an admin's, which we surface inline. useEffect(() => { if (!session || !isAdmin(user?.email ?? null)) return; let alive = true; (async () => { try { const res = await fetch('/api/admin/stats', { method: 'GET', headers: { Authorization: `Bearer ${session.access_token}`, Accept: 'application/json' }, cache: 'no-store', }); const body = (await res.json().catch(() => null)) as AdminStats | { error?: string } | null; if (!alive) return; if (!res.ok) { setError((body as { error?: string })?.error || `HTTP ${res.status}`); setStats(null); } else { setStats(body as AdminStats); } } catch (err) { if (!alive) return; setError(err instanceof Error ? err.message : 'Fetch failed'); } finally { if (alive) setFetching(false); } })(); return () => { alive = false; }; }, [session, user?.email]); if (authLoading || !user || !isAdmin(user.email)) { return (

Resolving…

); } const totalUsers = stats?.users.total ?? 0; const byTier = stats?.users.by_tier ?? { free: 0, africa: 0, analyst: 0, desk: 0 }; const payingUsers = (byTier.africa ?? 0) + (byTier.analyst ?? 0) + (byTier.desk ?? 0); const freeUsers = byTier.free ?? 0; return (

Admin

{stats?.generated_at ? `Generated ${timeAgo(stats.generated_at)}` : fetching ? 'Loading…' : 'No data'}

{error && (
{error}
)} {/* Section 3.1 — Key metrics */}
Total Users
{totalUsers}
Paying Users
{payingUsers}
{pct(payingUsers, totalUsers)} of total
Free Users
{freeUsers}
Grades Today
{stats?.grades.today ?? 0}
{stats?.grades.total ?? 0} all-time
{/* Section 3.2 — Tier breakdown */}

Tier breakdown

{(['free', 'africa', 'analyst', 'desk'] as const).map((tier) => { const count = byTier[tier] ?? 0; const p = totalUsers > 0 ? (count / totalUsers) * 100 : 0; return (
{tier}
{count} ({pct(count, totalUsers)})
); })}
{/* Section 3.3 — Recent signups */}

Recent signups (24h)

{!stats?.users.recent_24h?.length ? (

No signups in the last 24 hours.

) : ( {stats.users.recent_24h.map((row, i) => ( ))}
Email Tier Signed up
{row.email_masked} {row.tier} {timeAgo(row.created_at)}
)}
{/* Section 3.4 — System health */}

System health

{stats?.health.sports.map((s) => { const indicator = s.status === 'ok' ? '✅ Live' : s.status === 'empty' ? '⚪ No props' : `❌ ${s.error || 'error'}`; const color = s.status === 'ok' ? 'var(--grade-a, #00D4A0)' : s.status === 'empty' ? 'var(--text-tertiary, #6B6B7B)' : 'var(--grade-d, #FF6B6B)'; return ( ); })}
Odds API quota remaining {stats?.health.odds_quota_remaining ?? '—'}
{s.sport} {indicator}{typeof s.props === 'number' ? ` · ${s.props} props` : ''}
{/* Session 20 — Provider Quotas. Pulled from /api/internal/quota; rendered as a per-provider table with a usage bar + status indicator. When the array is empty, the section auto-hides (likely VYNDR_INTERNAL_KEY unset on the Next.js side — surfaced in the notes section). */} {!!stats?.provider_quotas?.length && (

Provider quotas

{stats.provider_quotas.map((p) => { const pctNum = Math.round((p.pct || 0) * 100); const color = !p.allowed ? 'var(--grade-d, #FF6B6B)' : pctNum >= 80 ? 'var(--grade-c, #FFD93D)' : 'var(--grade-a, #00D4A0)'; const indicator = !p.allowed ? `❌ BLOCKED ${pctNum}%` : pctNum >= 80 ? `⚠️ ${pctNum}%` : `✅ ${pctNum}%`; return ( ); })}
Provider Used Type Status
{p.name || p.provider}
{p.period}
{p.used}/{p.limit}
{p.quotaType} {indicator}{p.degraded ? ' (degraded)' : ''}
)} {!!stats?.notes?.length && (

Query notes

    {stats.notes.map((n, i) =>
  • {n}
  • )}
)}
); }