Sessions 5-7a: 955 tests, deployment ready
This commit is contained in:
@@ -0,0 +1,348 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState, use } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import { useParlay } from '@/contexts/ParlayContext';
|
||||
import GradeCard, { GradePill } from '@/components/GradeCard';
|
||||
|
||||
type Sport = 'NBA' | 'MLB' | 'WNBA';
|
||||
|
||||
interface Player {
|
||||
name: string;
|
||||
position?: string;
|
||||
injury_status?: 'OUT' | 'DOUBTFUL' | 'QUESTIONABLE' | null;
|
||||
}
|
||||
|
||||
interface GameDetail {
|
||||
id: string;
|
||||
sport: Sport;
|
||||
away: string;
|
||||
home: string;
|
||||
start_time: string;
|
||||
spread?: number;
|
||||
spread_favorite?: 'home' | 'away';
|
||||
total?: number;
|
||||
moneyline_home?: number;
|
||||
moneyline_away?: number;
|
||||
away_lineup?: Player[];
|
||||
home_lineup?: Player[];
|
||||
pace?: number;
|
||||
pace_rank?: number;
|
||||
matchup_notes?: string[];
|
||||
}
|
||||
|
||||
interface PropEntry {
|
||||
id: string;
|
||||
player: string;
|
||||
stat: string;
|
||||
line: number;
|
||||
direction: 'over' | 'under';
|
||||
grade: string;
|
||||
projection?: number;
|
||||
confidence?: number;
|
||||
sample_size?: number;
|
||||
factors?: Record<string, string>;
|
||||
kill_conditions?: { code: string; reason: string }[];
|
||||
reasoning?: string;
|
||||
historical_hit_rate?: number;
|
||||
alt_lines?: { line: number; grade: string; hit_rate?: number }[];
|
||||
}
|
||||
|
||||
const SPORT_COLOR: Record<Sport, string> = {
|
||||
NBA: '#E94B3C',
|
||||
MLB: '#1E90FF',
|
||||
WNBA: '#FFB347',
|
||||
};
|
||||
|
||||
export default function GamePage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = use(params);
|
||||
const router = useRouter();
|
||||
const { user, tier, loading: authLoading } = useAuth();
|
||||
const { addLeg, open } = useParlay();
|
||||
|
||||
const [game, setGame] = useState<GameDetail | null>(null);
|
||||
const [props, setProps] = useState<PropEntry[] | null>(null);
|
||||
const [expanded, setExpanded] = useState<string | null>(null);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (!authLoading && !user) router.replace(`/login?next=/game/${id}`);
|
||||
}, [authLoading, user, id, router]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setError('');
|
||||
Promise.all([
|
||||
fetch(`/api/games/${id}`).then((r) => r.json()).catch(() => null),
|
||||
fetch(`/api/games/${id}/props`).then((r) => r.json()).catch(() => ({ props: [] })),
|
||||
]).then(([g, p]) => {
|
||||
if (cancelled) return;
|
||||
if (!g || g.error) {
|
||||
setError(g?.error || 'Game not found.');
|
||||
return;
|
||||
}
|
||||
setGame(g);
|
||||
setProps(Array.isArray(p?.props) ? p.props : []);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [id]);
|
||||
|
||||
if (authLoading || !user || (!game && !error)) {
|
||||
return (
|
||||
<section style={{ minHeight: '60vh', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<p className="mono" style={{ color: 'var(--text-tertiary)' }}>Loading game…</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<section style={{ maxWidth: 600, margin: '0 auto', padding: '64px 16px', textAlign: 'center' }}>
|
||||
<h1 style={{ fontSize: 24, fontWeight: 700, marginBottom: 12 }}>Game not found.</h1>
|
||||
<p style={{ color: 'var(--text-secondary)', marginBottom: 24 }}>
|
||||
That matchup isn't on tonight's slate. Maybe the line moved off the board.
|
||||
</p>
|
||||
<a href="/dashboard" className="btn-primary">Back to slate</a>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
if (!game) return null;
|
||||
|
||||
return (
|
||||
<section style={{ maxWidth: 900, margin: '0 auto', padding: '24px 16px 120px' }}>
|
||||
{/* Back nav */}
|
||||
<button
|
||||
onClick={() => router.push('/dashboard')}
|
||||
className="btn-ghost"
|
||||
style={{ padding: '6px 12px', fontSize: 12, marginBottom: 16 }}
|
||||
>
|
||||
← Slate
|
||||
</button>
|
||||
|
||||
{/* Game header */}
|
||||
<header
|
||||
className="surface diagonal-cut-strong animate-fade-up"
|
||||
style={{
|
||||
padding: 24,
|
||||
marginBottom: 16,
|
||||
borderColor: SPORT_COLOR[game.sport],
|
||||
background: 'var(--bg-elevated)',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', marginBottom: 8 }}>
|
||||
<span
|
||||
className="mono"
|
||||
style={{
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
padding: '2px 10px',
|
||||
borderRadius: 999,
|
||||
background: `${SPORT_COLOR[game.sport]}1F`,
|
||||
color: SPORT_COLOR[game.sport],
|
||||
letterSpacing: '0.08em',
|
||||
}}
|
||||
>
|
||||
{game.sport}
|
||||
</span>
|
||||
<span className="mono" style={{ fontSize: 12, color: 'var(--text-secondary)' }}>
|
||||
{formatTime(game.start_time)}
|
||||
</span>
|
||||
</div>
|
||||
<h1 style={{ fontSize: 28, fontWeight: 700, letterSpacing: '-0.02em', marginBottom: 12 }}>
|
||||
{game.away} <span style={{ color: 'var(--text-tertiary)' }}>@</span> {game.home}
|
||||
</h1>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(140px, 1fr))', gap: 8 }}>
|
||||
{game.spread != null && (
|
||||
<Stat label="Spread" value={`${game.spread_favorite === 'home' ? game.home : game.away} ${game.spread}`} />
|
||||
)}
|
||||
{game.total != null && <Stat label="Total" value={game.total.toString()} />}
|
||||
{game.moneyline_home != null && (
|
||||
<Stat label="Moneyline" value={`${game.home} ${formatOdds(game.moneyline_home)} / ${game.away} ${formatOdds(game.moneyline_away ?? 0)}`} />
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Starting lineups */}
|
||||
{(game.away_lineup?.length || game.home_lineup?.length) && (
|
||||
<Card title="Starting lineups">
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16 }}>
|
||||
<Lineup teamName={game.away} players={game.away_lineup ?? []} />
|
||||
<Lineup teamName={game.home} players={game.home_lineup ?? []} />
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Matchup stats */}
|
||||
{game.matchup_notes && game.matchup_notes.length > 0 && (
|
||||
<Card title="Key matchup stats">
|
||||
<ul style={{ display: 'grid', gap: 8 }}>
|
||||
{game.matchup_notes.map((note, i) => (
|
||||
<li key={i} style={{ fontSize: 14, color: 'var(--text-secondary)' }}>
|
||||
· {note}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Props list */}
|
||||
<Card title={`All props for this game`} subtitle={`${props?.length ?? 0} graded`}>
|
||||
{props === null ? (
|
||||
<p style={{ color: 'var(--text-tertiary)' }}>Loading props…</p>
|
||||
) : props.length === 0 ? (
|
||||
<p style={{ color: 'var(--text-secondary)', fontSize: 14 }}>
|
||||
No props posted for this game yet. Books usually open player props 2–3 hours before tip. Check back closer to game time.
|
||||
</p>
|
||||
) : (
|
||||
<ul style={{ display: 'grid', gap: 8 }}>
|
||||
{props.map((p) => {
|
||||
const isOpen = expanded === p.id;
|
||||
return (
|
||||
<li key={p.id} className="surface" style={{ overflow: 'hidden' }}>
|
||||
<div
|
||||
style={{
|
||||
padding: 14,
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '1fr auto auto',
|
||||
gap: 12,
|
||||
alignItems: 'center',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
onClick={() => setExpanded(isOpen ? null : p.id)}
|
||||
>
|
||||
<div>
|
||||
<div style={{ fontSize: 14, fontWeight: 600 }}>{p.player}</div>
|
||||
<div className="mono" style={{ fontSize: 12, color: 'var(--text-secondary)', textTransform: 'capitalize' }}>
|
||||
{p.direction} {p.line} {p.stat.replace(/_/g, ' ')}
|
||||
</div>
|
||||
</div>
|
||||
<GradePill grade={p.grade} />
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
addLeg({
|
||||
sport: game.sport,
|
||||
player: p.player,
|
||||
stat: p.stat,
|
||||
line: p.line,
|
||||
direction: p.direction,
|
||||
grade: p.grade,
|
||||
confidence: p.confidence ?? 55,
|
||||
});
|
||||
open();
|
||||
}}
|
||||
className="btn-ghost"
|
||||
style={{ padding: '6px 12px', fontSize: 11 }}
|
||||
aria-label="Add to parlay"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
{isOpen && (
|
||||
<div style={{ padding: 16, borderTop: '1px solid var(--border)' }}>
|
||||
<GradeCard
|
||||
sport={game.sport}
|
||||
player={p.player}
|
||||
stat={p.stat}
|
||||
line={p.line}
|
||||
direction={p.direction}
|
||||
grade={p.grade}
|
||||
projection={p.projection}
|
||||
confidence={p.confidence}
|
||||
sample_size={p.sample_size}
|
||||
factors={p.factors}
|
||||
alt_lines={p.alt_lines}
|
||||
kill_conditions={p.kill_conditions}
|
||||
reasoning={p.reasoning}
|
||||
historical_hit_rate={p.historical_hit_rate}
|
||||
tier={tier}
|
||||
onUpgradeClick={(target) => router.push(`/api/checkout?tier=${target}`)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<div style={{ marginTop: 24 }}>
|
||||
<a
|
||||
href={`/scan?sport=${game.sport}`}
|
||||
className="btn-primary"
|
||||
style={{ padding: '12px 24px' }}
|
||||
>
|
||||
Read a custom prop →
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function Card({ title, subtitle, children }: { title: string; subtitle?: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<section className="surface diagonal-cut animate-fade-up" style={{ padding: 20, marginBottom: 16 }}>
|
||||
<header style={{ marginBottom: 12, display: 'flex', justifyContent: 'space-between', alignItems: 'baseline' }}>
|
||||
<h2 style={{ fontSize: 14, fontWeight: 700, letterSpacing: '-0.01em' }}>{title}</h2>
|
||||
{subtitle && (
|
||||
<span className="mono" style={{ fontSize: 11, color: 'var(--text-tertiary)' }}>{subtitle}</span>
|
||||
)}
|
||||
</header>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function Stat({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div style={{ padding: '10px 14px', background: 'var(--bg-surface)', borderRadius: 10, border: '1px solid var(--border)' }}>
|
||||
<div className="mono" style={{ fontSize: 10, color: 'var(--text-tertiary)', letterSpacing: '0.08em' }}>
|
||||
{label.toUpperCase()}
|
||||
</div>
|
||||
<div className="mono" style={{ fontSize: 13, fontWeight: 700, marginTop: 4 }}>
|
||||
{value}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Lineup({ teamName, players }: { teamName: string; players: Player[] }) {
|
||||
return (
|
||||
<div>
|
||||
<div className="mono" style={{ fontSize: 11, fontWeight: 700, color: 'var(--text-tertiary)', marginBottom: 8 }}>
|
||||
{teamName.toUpperCase()}
|
||||
</div>
|
||||
<ul style={{ display: 'grid', gap: 6 }}>
|
||||
{players.map((p) => (
|
||||
<li key={p.name} style={{ display: 'flex', justifyContent: 'space-between', fontSize: 13, gap: 8 }}>
|
||||
<span>{p.name}</span>
|
||||
<span className="mono" style={{
|
||||
fontSize: 11,
|
||||
color: p.injury_status === 'OUT' ? 'var(--grade-d)' : p.injury_status ? 'var(--grade-c)' : 'var(--text-tertiary)',
|
||||
}}>
|
||||
{p.injury_status ? `${p.injury_status === 'OUT' ? '❌' : '⚠'} ${p.injury_status}` : p.position}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatTime(iso: string): string {
|
||||
try {
|
||||
return new Date(iso).toLocaleString([], { weekday: 'short', hour: 'numeric', minute: '2-digit', timeZoneName: 'short' });
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
|
||||
function formatOdds(odds: number): string {
|
||||
if (!odds) return '—';
|
||||
return odds > 0 ? `+${odds}` : `${odds}`;
|
||||
}
|
||||
Reference in New Issue
Block a user