Sessions 5-7a: 955 tests, deployment ready

This commit is contained in:
Kev
2026-06-08 18:35:13 -04:00
parent 06b82624a2
commit 1fa04dc776
371 changed files with 49366 additions and 955 deletions
+499
View File
@@ -0,0 +1,499 @@
'use client';
import { useEffect, useMemo, useState } from 'react';
import { useRouter } from 'next/navigation';
import { useAuth } from '@/contexts/AuthContext';
import { useParlay } from '@/contexts/ParlayContext';
import { GradePill } from '@/components/GradeCard';
type Sport = 'NBA' | 'MLB' | 'WNBA';
interface Game {
id: string;
away: string;
home: string;
start_time: string;
sport: Sport;
status: 'scheduled' | 'live' | 'final';
prop_count?: number;
ab_grade_count?: number;
injury_note?: string;
}
interface TopGrade {
player: string;
stat: string;
line: number;
direction: 'over' | 'under';
sport: Sport;
grade: string;
confidence?: number;
}
interface ParlayLegStat {
player: string;
stat: string;
line: number;
direction: 'over' | 'under';
sport: Sport;
grade?: string;
parlay_count: number;
}
interface RecentScan {
id: string;
player_name: string;
stat: string;
line: number;
direction: 'over' | 'under';
grade: string;
sport: Sport;
created_at: string;
}
const SPORT_TABS: Sport[] = ['NBA', 'MLB', 'WNBA'];
const SPORT_COLOR: Record<Sport, string> = {
NBA: '#E94B3C',
MLB: '#1E90FF',
WNBA: '#FFB347',
};
export default function DashboardPage() {
const router = useRouter();
const { user, tier, scansRemaining, loading: authLoading } = useAuth();
const { addLeg, open } = useParlay();
const [sport, setSport] = useState<Sport>('NBA');
const [games, setGames] = useState<Game[] | null>(null);
const [topGrades, setTopGrades] = useState<TopGrade[] | null>(null);
const [mostParlayed, setMostParlayed] = useState<ParlayLegStat[] | null>(null);
const [recentScans, setRecentScans] = useState<RecentScan[] | null>(null);
// Gate
useEffect(() => {
if (!authLoading && !user) router.replace('/login?next=/dashboard');
}, [authLoading, user, router]);
// Fetch slate when sport changes
useEffect(() => {
let cancelled = false;
setGames(null);
setTopGrades(null);
Promise.all([
fetch(`/api/games/tonight?sport=${sport}`).then((r) => r.json()).catch(() => ({ games: [] })),
fetch(`/api/props/top-graded?sport=${sport}`).then((r) => r.json()).catch(() => ({ props: [] })),
]).then(([gamesData, gradesData]) => {
if (cancelled) return;
setGames(Array.isArray(gamesData?.games) ? gamesData.games : []);
setTopGrades(Array.isArray(gradesData?.props) ? gradesData.props.slice(0, 10) : []);
});
return () => {
cancelled = true;
};
}, [sport]);
// Most parlayed + recent scans don't depend on sport
useEffect(() => {
fetch('/api/props/most-parlayed')
.then((r) => r.json())
.then((data) => setMostParlayed(Array.isArray(data?.props) ? data.props.slice(0, 5) : []))
.catch(() => setMostParlayed([]));
if (user) {
const token = typeof window !== 'undefined' ? localStorage.getItem('sb-token') : null;
fetch('/api/user/recent-scans', {
headers: token ? { Authorization: `Bearer ${token}` } : {},
})
.then((r) => r.json())
.then((data) => setRecentScans(Array.isArray(data?.scans) ? data.scans.slice(0, 5) : []))
.catch(() => setRecentScans([]));
}
}, [user]);
const gameCountsBySport = useMemo(() => {
// Updated whenever the sport's slate refreshes. We only know counts for
// the current sport — others show a dash until clicked.
return { [sport]: games?.length ?? 0 } as Partial<Record<Sport, number>>;
}, [sport, games]);
if (authLoading || !user) {
return (
<section style={{ minHeight: '80vh', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<p className="mono" style={{ color: 'var(--text-tertiary)' }}>Loading the slate</p>
</section>
);
}
const isFirstTimer = recentScans?.length === 0;
const slateEmpty = games?.length === 0;
return (
<section style={{ maxWidth: 1100, margin: '0 auto', padding: '24px 16px 120px' }}>
{/* Top welcome strip */}
<header style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', marginBottom: 24, gap: 16, flexWrap: 'wrap' }}>
<div>
<h1 style={{ fontSize: 26, fontWeight: 700, letterSpacing: '-0.02em' }}>
Tonight&apos;s slate
</h1>
<p className="mono" style={{ fontSize: 12, color: 'var(--text-tertiary)', letterSpacing: '0.05em', marginTop: 4 }}>
{new Date().toLocaleDateString([], { weekday: 'long', month: 'short', day: 'numeric' }).toUpperCase()}
</p>
</div>
{tier === 'free' && scansRemaining != null && (
<div
className="mono"
style={{
padding: '8px 14px',
borderRadius: 999,
background: 'var(--bg-surface)',
border: '1px solid var(--border)',
fontSize: 12,
color: scansRemaining <= 1 ? 'var(--grade-c)' : 'var(--text-secondary)',
}}
>
{scansRemaining}/5 READS · MO
</div>
)}
</header>
{/* Sport tabs */}
<div role="tablist" aria-label="Sport" style={{ display: 'flex', gap: 4, marginBottom: 32, borderBottom: '1px solid var(--border)' }}>
{SPORT_TABS.map((s) => {
const active = s === sport;
const count = gameCountsBySport[s];
return (
<button
key={s}
role="tab"
aria-selected={active}
onClick={() => setSport(s)}
style={{
padding: '12px 20px',
background: 'transparent',
border: 'none',
borderBottom: `2px solid ${active ? SPORT_COLOR[s] : 'transparent'}`,
color: active ? 'var(--text-primary)' : 'var(--text-secondary)',
fontFamily: 'inherit',
fontWeight: active ? 600 : 500,
fontSize: 14,
cursor: 'pointer',
marginBottom: -1,
}}
>
{s}{' '}
<span className="mono" style={{ fontSize: 11, color: 'var(--text-tertiary)', marginLeft: 4 }}>
{active && count != null ? `(${count})` : '·'}
</span>
</button>
);
})}
</div>
{/* Top grades horizontal scroll */}
<Section
title="Top grades tonight"
subtitle="The A-tier picks. Tap to read or add to parlay."
>
{topGrades === null ? (
<SkeletonRow />
) : topGrades.length === 0 ? (
<p style={emptyCopy}>No grades yet. The model is waiting on lines.</p>
) : (
<div
style={{
display: 'flex',
gap: 12,
overflowX: 'auto',
padding: '4px 4px 12px',
scrollSnapType: 'x mandatory',
}}
>
{topGrades.map((g, i) => (
<button
key={`${g.player}-${g.stat}-${i}`}
onClick={() => router.push(`/scan?sport=${g.sport}&player=${encodeURIComponent(g.player)}&stat=${g.stat}&line=${g.line}`)}
className="surface diagonal-cut surface-hover"
style={{
minWidth: 200,
padding: 16,
textAlign: 'left',
scrollSnapAlign: 'start',
cursor: 'pointer',
border: '1px solid var(--border)',
borderRadius: 16,
background: 'var(--bg-surface)',
color: 'inherit',
fontFamily: 'inherit',
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 12 }}>
<SportPill sport={g.sport} />
<GradePill grade={g.grade} />
</div>
<h3 style={{ fontSize: 14, fontWeight: 600, marginBottom: 4 }}>{g.player}</h3>
<p className="mono" style={{ fontSize: 12, color: 'var(--text-secondary)', textTransform: 'capitalize' }}>
{g.direction} {g.line} {g.stat.replace(/_/g, ' ')}
</p>
</button>
))}
</div>
)}
</Section>
{/* Tonight's games */}
<Section title={`Tonight's ${sport} games`} subtitle={slateEmpty ? null : `${games?.length ?? 0} games tipping`}>
{games === null ? (
<SkeletonRow stacked />
) : games.length === 0 ? (
<div
className="surface diagonal-cut tex-scan"
style={{ padding: 32, textAlign: 'center', display: 'grid', gap: 8, justifyItems: 'center' }}
>
<p className="lbl" style={{ color: 'var(--grade-c)' }}>NO SLATE</p>
<h3 style={{ fontSize: 18, fontWeight: 700 }}>No games on the slate tonight.</h3>
<p style={{ color: 'var(--text-1)', fontSize: 14, maxWidth: 420 }}>
Check the Ledger to review past grades, or explore the Leaderboard.
</p>
<div style={{ display: 'flex', gap: 12, marginTop: 8 }}>
<a href="/ledger" className="btn-primary" style={{ padding: '10px 18px' }}>View the Ledger </a>
</div>
</div>
) : (
<div style={{ display: 'grid', gap: 12 }}>
{games.map((g, idx) => (
<a
key={g.id}
href={`/game/${g.id}`}
className={`surface surface-hover diagonal-cut animate-fade-up stagger-${(idx % 6) + 1}`}
style={{
padding: 20,
textDecoration: 'none',
color: 'inherit',
display: 'grid',
gridTemplateColumns: '1fr auto',
gap: 12,
alignItems: 'center',
border: isFirstTimer && idx === 0 ? '1px solid var(--grade-a)' : '1px solid var(--border)',
boxShadow: isFirstTimer && idx === 0 ? '0 0 0 4px var(--accent-glow)' : 'none',
}}
>
<div>
<div style={{ fontSize: 16, fontWeight: 600, marginBottom: 4 }}>
{g.away} @ {g.home}
</div>
<div className="mono" style={{ fontSize: 12, color: 'var(--text-secondary)' }}>
{formatTime(g.start_time)}
{g.injury_note ? ` · ${g.injury_note}` : ' · Full squad'}
{g.ab_grade_count != null ? ` · ${g.ab_grade_count} A/B grades` : ''}
</div>
</div>
<div className="mono" style={{ fontSize: 11, color: 'var(--text-tertiary)', letterSpacing: '0.08em' }}>
{g.prop_count ?? '—'} props
</div>
</a>
))}
</div>
)}
</Section>
{/* Most parlayed tonight */}
<Section title="Most parlayed tonight" subtitle="What other bettors are stacking." right={<span className="mono" style={{ fontSize: 11, color: 'var(--text-tertiary)' }}>🔥 TRENDING</span>}>
{mostParlayed === null ? (
<SkeletonRow />
) : mostParlayed.length === 0 ? (
<p style={emptyCopy}>No parlays built yet tonight. Be the first.</p>
) : (
<ul style={{ display: 'grid', gap: 8 }}>
{mostParlayed.map((p, i) => (
<li key={`${p.player}-${p.stat}-${i}`}
className="surface"
style={{
padding: 14,
display: 'grid',
gridTemplateColumns: '1fr auto auto',
gap: 12,
alignItems: 'center',
}}
>
<div>
<div style={{ fontSize: 14, fontWeight: 600 }}>🔥 {p.player}</div>
<div className="mono" style={{ fontSize: 12, color: 'var(--text-secondary)', textTransform: 'capitalize' }}>
{p.sport} · {p.direction} {p.line} {p.stat.replace(/_/g, ' ')}
</div>
</div>
{p.grade && <GradePill grade={p.grade} />}
<button
className="btn-ghost"
style={{ padding: '6px 12px', fontSize: 12 }}
onClick={() => {
addLeg({
sport: p.sport,
player: p.player,
stat: p.stat,
line: p.line,
direction: p.direction,
grade: p.grade ?? 'B',
confidence: 55,
});
open();
}}
>
+ Parlay
</button>
</li>
))}
</ul>
)}
</Section>
{/* Recent scans OR first-timer onboarding */}
<Section title={isFirstTimer ? 'Your first read' : 'Your recent reads'} subtitle={null}>
{recentScans === null ? (
<SkeletonRow />
) : recentScans.length === 0 ? (
<div
className="surface diagonal-cut-strong"
style={{
padding: 32,
textAlign: 'center',
border: '1px solid var(--accent-light)',
background: 'var(--bg-elevated)',
}}
>
<p className="mono" style={{ fontSize: 11, fontWeight: 700, letterSpacing: '0.08em', color: 'var(--grade-a)', marginBottom: 12 }}>
WELCOME TO THE LEDGER
</p>
<h3 style={{ fontSize: 20, fontWeight: 700, marginBottom: 8 }}>
Tonight&apos;s slate is loaded. {games?.length ?? 0} {games?.length === 1 ? 'game' : 'games'} across 3 sports.
</h3>
<p style={{ color: 'var(--text-secondary)', fontSize: 14, marginBottom: 20 }}>
Pick a game and read your first prop it&apos;s on us.
</p>
<a href="/scan" className="btn-primary" style={{ padding: '12px 24px' }}>
Run a read
</a>
</div>
) : (
<ul style={{ display: 'grid', gap: 8 }}>
{recentScans.map((s) => (
<li key={s.id}
className="surface"
style={{
padding: 14,
display: 'grid',
gridTemplateColumns: '1fr auto',
gap: 12,
alignItems: 'center',
}}
>
<div>
<div style={{ fontSize: 14, fontWeight: 600 }}>{s.player_name}</div>
<div className="mono" style={{ fontSize: 12, color: 'var(--text-secondary)', textTransform: 'capitalize' }}>
{s.sport} · {s.direction} {s.line} {s.stat.replace(/_/g, ' ')}
</div>
</div>
<GradePill grade={s.grade} />
</li>
))}
</ul>
)}
</Section>
</section>
);
}
function Section({ title, subtitle, right, children }: { title: string; subtitle: string | null; right?: React.ReactNode; children: React.ReactNode }) {
return (
<section style={{ marginBottom: 40 }}>
<header style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', marginBottom: 12 }}>
<div>
<h2 style={{ fontSize: 16, fontWeight: 700, letterSpacing: '-0.01em' }}>{title}</h2>
{subtitle && (
<p className="mono" style={{ fontSize: 11, color: 'var(--text-tertiary)', letterSpacing: '0.05em', marginTop: 2 }}>
{subtitle}
</p>
)}
</div>
{right}
</header>
{children}
</section>
);
}
function SportPill({ sport }: { sport: Sport }) {
return (
<span
className="mono"
style={{
fontSize: 10,
fontWeight: 700,
padding: '2px 8px',
borderRadius: 999,
background: `${SPORT_COLOR[sport]}1F`,
color: SPORT_COLOR[sport],
}}
>
{sport}
</span>
);
}
function SkeletonRow({ stacked = false }: { stacked?: boolean }) {
if (stacked) {
return (
<div style={{ display: 'grid', gap: 12 }}>
{[0, 1, 2].map((i) => (
<div
key={i}
style={{
height: 80,
borderRadius: 16,
background: 'linear-gradient(90deg, var(--bg-surface) 0%, var(--bg-surface-hover) 50%, var(--bg-surface) 100%)',
backgroundSize: '200% 100%',
animation: 'shimmer 1.5s linear infinite',
}}
/>
))}
</div>
);
}
return (
<div style={{ display: 'flex', gap: 12 }}>
{[0, 1, 2, 3].map((i) => (
<div
key={i}
style={{
minWidth: 200,
height: 110,
borderRadius: 16,
background: 'linear-gradient(90deg, var(--bg-surface) 0%, var(--bg-surface-hover) 50%, var(--bg-surface) 100%)',
backgroundSize: '200% 100%',
animation: 'shimmer 1.5s linear infinite',
}}
/>
))}
</div>
);
}
function formatTime(iso: string): string {
try {
return new Date(iso).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit', timeZoneName: 'short' });
} catch {
return iso;
}
}
const emptyCopy: React.CSSProperties = {
padding: '24px 16px',
textAlign: 'center',
color: 'var(--text-secondary)',
fontSize: 14,
background: 'var(--bg-surface)',
border: '1px solid var(--border)',
borderRadius: 12,
};