Sessions 5-7a: 955 tests, deployment ready
This commit is contained in:
@@ -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'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'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'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,
|
||||
};
|
||||
Reference in New Issue
Block a user