500 lines
17 KiB
TypeScript
500 lines
17 KiB
TypeScript
'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,
|
|
};
|