'use client'; import { useCallback, useEffect, useMemo, useState } from 'react'; import { useRouter } from 'next/navigation'; import GradeCard from '@/components/GradeCard'; import { useAuth } from '@/contexts/AuthContext'; import { useParlay } from '@/contexts/ParlayContext'; import { trackScanCompleted, trackScanLimitHit, trackUpgradeClicked, } from '@/lib/analytics'; import { getHeadshotUrl, PLAYER_SILHOUETTE, type HeadshotSport } from '@/lib/playerHeadshot'; type Sport = 'NBA' | 'MLB' | 'WNBA'; interface Game { id: string; away: string; home: string; start_time: string; status: 'scheduled' | 'live' | 'final'; prop_count?: number; } interface Player { id: string; full_name: string; team?: string; position?: string; } interface ScanResponse { grade: string; projection?: number; confidence?: number; sample_size?: number; factors?: Record; alt_lines?: { line: number; grade: string; hit_rate?: number; edge_pct?: number }[]; kill_conditions?: { code: string; reason: string }[]; reasoning?: string; historical_hit_rate?: number; scans_remaining: number | null; tier: 'free' | 'analyst' | 'desk'; error?: string; upgrade?: { tier: string; price: number }; } const NBA_STATS = [ { id: 'points', label: 'Points' }, { id: 'rebounds', label: 'Rebounds' }, { id: 'assists', label: 'Assists' }, { id: 'threes', label: '3-Pointers' }, { id: 'steals', label: 'Steals' }, { id: 'blocks', label: 'Blocks' }, { id: 'pra', label: 'P+R+A' }, { id: 'turnovers', label: 'Turnovers' }, ]; const MLB_STATS = [ { id: 'strikeouts', label: 'Strikeouts (P)' }, { id: 'hits_allowed', label: 'Hits Allowed (P)' }, { id: 'earned_runs', label: 'Earned Runs (P)' }, { id: 'innings_pitched', label: 'Innings Pitched (P)' }, { id: 'hits', label: 'Hits' }, { id: 'total_bases', label: 'Total Bases' }, { id: 'rbi', label: 'RBI' }, { id: 'runs', label: 'Runs' }, { id: 'home_runs', label: 'Home Runs' }, ]; const WNBA_STATS = NBA_STATS; const SPORT_STATS: Record = { NBA: NBA_STATS, MLB: MLB_STATS, WNBA: WNBA_STATS, }; const SPORT_ACCENT: Record = { NBA: '#E94B3C', MLB: '#1E90FF', WNBA: '#FFB347', }; export default function ScanPage() { const router = useRouter(); const { user, tier, scansRemaining, canScan, loading: authLoading, bumpScanCount } = useAuth(); const { addLeg, legCount, open } = useParlay(); const [sport, setSport] = useState('NBA'); const [games, setGames] = useState(null); const [gameId, setGameId] = useState(''); const [playerQuery, setPlayerQuery] = useState(''); const [playerSuggestions, setPlayerSuggestions] = useState([]); const [selectedPlayer, setSelectedPlayer] = useState(''); const [stat, setStat] = useState('points'); const [line, setLine] = useState(''); const [direction, setDirection] = useState<'over' | 'under'>('over'); const [scanning, setScanning] = useState(false); const [result, setResult] = useState(null); const [error, setError] = useState(''); // Session 19 — tonight's players grid. Pulled from the odds proxy // (props array) so the chip set is real, not hard-coded. Each entry // unique by name + the set of stats that player has props for, so // clicking a chip can prefill the stat dropdown intelligently. const [tonightsPlayers, setTonightsPlayers] = useState | null>(null); // Auth gate — push anonymous users to signup useEffect(() => { if (!authLoading && !user) router.replace('/signup?next=/scan'); }, [authLoading, user, router]); // Reset stat selection when sport changes useEffect(() => { const list = SPORT_STATS[sport]; if (!list.some((s) => s.id === stat)) setStat(list[0].id); }, [sport, stat]); // Load tonight's slate useEffect(() => { let cancelled = false; setGames(null); setGameId(''); fetch(`/api/games/tonight?sport=${sport}`) .then((r) => r.json()) .then((data: { games: Game[] }) => { if (!cancelled) setGames(Array.isArray(data?.games) ? data.games : []); }) .catch(() => !cancelled && setGames([])); return () => { cancelled = true; }; }, [sport]); // Session 19 — fetch tonight's players from the odds proxy. The // odds endpoint returns the canonical list of players who have // props posted, which is exactly what the scan UI should surface // as quick-fill chips. Empty array on failure → the section // hides itself (we don't want a sad "couldn't load" stripe when // odds-api is rate-limited). useEffect(() => { let cancelled = false; setTonightsPlayers(null); const sportPath = sport.toLowerCase(); fetch(`/api/odds/${sportPath}`) .then(async (r) => { if (!r.ok) throw new Error(`HTTP ${r.status}`); return r.json(); }) .then((data: { props?: Array<{ player?: string; stat_type?: string }> }) => { if (cancelled) return; const byPlayer = new Map>(); for (const p of data.props || []) { if (!p.player || !p.stat_type) continue; const set = byPlayer.get(p.player) || new Set(); set.add(p.stat_type); byPlayer.set(p.player, set); } const list = Array.from(byPlayer.entries()) .map(([name, stats]) => ({ name, stats: Array.from(stats) })) .sort((a, b) => a.name.localeCompare(b.name)); setTonightsPlayers(list); }) .catch(() => { if (!cancelled) setTonightsPlayers([]); }); return () => { cancelled = true; }; }, [sport]); // Debounced player search — narrow to selected game when set const searchPlayers = useCallback( async (query: string) => { if (query.trim().length < 2) { setPlayerSuggestions([]); return; } try { const params = new URLSearchParams({ sport, q: query }); if (gameId) params.set('game_id', gameId); const res = await fetch(`/api/players/search?${params}`); if (!res.ok) return; const data = (await res.json()) as { players: Player[] }; setPlayerSuggestions((data.players || []).slice(0, 8)); } catch { setPlayerSuggestions([]); } }, [sport, gameId], ); useEffect(() => { const t = setTimeout(() => void searchPlayers(playerQuery), 200); return () => clearTimeout(t); }, [playerQuery, searchPlayers]); const canSubmit = useMemo( () => selectedPlayer && stat && line !== '' && !scanning && canScan, [selectedPlayer, stat, line, scanning, canScan], ); const runScan = async () => { if (!canSubmit) { if (!canScan) { trackScanLimitHit({ current_scan_count: 5, tier }); } return; } setScanning(true); setError(''); setResult(null); try { const token = typeof window !== 'undefined' ? localStorage.getItem('sb-token') : null; const res = await fetch('/api/scan', { method: 'POST', headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}), }, body: JSON.stringify({ sport, player: selectedPlayer, stat, line: Number(line), direction, book: 'draftkings', }), }); const data = (await res.json()) as ScanResponse; if (!res.ok) { setError(data.error || 'The engine hit a wall. Try that read again.'); if (res.status === 402) trackScanLimitHit({ current_scan_count: 5, tier }); return; } setResult(data); bumpScanCount(); trackScanCompleted({ sport, player: selectedPlayer, stat, line: Number(line), grade: data.grade, tier, }); } catch { setError('The engine hit a wall. Try that read again.'); } finally { setScanning(false); } }; const reset = () => { setResult(null); setError(''); setPlayerQuery(''); setSelectedPlayer(''); setLine(''); }; if (authLoading || !user) { return (

Loading the model…

); } return (
{/* Header */}

Grade a prop.

Pick a sport, find the player, set the line. We grade it in seconds.

{/* Scan counter */} {tier === 'free' && scansRemaining != null && (
{scansRemaining} OF 5 FREE READS REMAINING THIS MONTH
)} {/* Sport tabs */}
{(Object.keys(SPORT_STATS) as Sport[]).map((s) => { const active = s === sport; return ( ); })}
{/* Game selector */}
{games === null ? (
) : games.length === 0 ? (

No games posted yet. Books usually open player props 2–3 hours before tip.

) : ( )}
{/* Session 19 — tonight's players chip grid. Above the search input so the user sees who's actually playing before having to think about what to type. Tapping a chip prefills the player and, when only one stat is available, the stat too. */} {tonightsPlayers && tonightsPlayers.length > 0 && (
{tonightsPlayers.map((p) => { const headshot = getHeadshotUrl({ sport: sport.toLowerCase() as HeadshotSport }); const selected = selectedPlayer === p.name; return ( ); })}
)} {/* Player search */}
{ setPlayerQuery(e.target.value); setSelectedPlayer(''); }} autoComplete="off" /> {/* Session 17 — show "no results" when the search ran but returned nothing. Audit reported a silent dropdown failure; this gives the user feedback when the upstream player service is offline or the spelling didn't match. */} {playerQuery.trim().length >= 2 && playerSuggestions.length === 0 && playerQuery !== selectedPlayer && (
No {sport} players matched “{playerQuery}”. Check spelling or try a partial name.
)} {playerSuggestions.length > 0 && playerQuery !== selectedPlayer && (
{playerSuggestions.map((p) => ( ))}
)}
{/* Stat + line + direction */}
setLine(e.target.value)} />
{(['over', 'under'] as const).map((d) => ( ))}
{/* Grade button OR upgrade trigger */} {!canScan ? (

SIGNAL EXHAUSTED

You've used your 5 free reads this month.

Unlock unlimited reads — plus kill conditions, alt lines, and the full intelligence layer.

$14.99/mo

Locked for life. This rate disappears June 15.

Or come back next month for 5 more free reads.

) : ( )} {/* Inline error */} {error && (
{error}
)} {/* Grade card output */} {result && (
{ trackUpgradeClicked({ current_tier: tier, target_tier: target, trigger_location: from }); router.push(`/api/checkout?tier=${target}`); }} onAddToParlay={() => { addLeg({ sport, player: selectedPlayer, stat, line: Number(line), direction, grade: result.grade, confidence: result.confidence ?? 50, }); open(); }} />
Back to slate
)} {/* Helpful sticky parlay indicator */} {legCount > 0 && ( )}
); } const labelStyle: React.CSSProperties = { display: 'block', fontSize: 11, fontWeight: 700, letterSpacing: '0.08em', textTransform: 'uppercase', color: 'var(--text-tertiary)', marginBottom: 8, }; const shimmerStyle: React.CSSProperties = { height: 44, borderRadius: 12, 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', }; const suggestionStyle: React.CSSProperties = { width: '100%', display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '10px 12px', background: 'transparent', border: 'none', color: 'var(--text-primary)', fontFamily: 'inherit', fontSize: 14, cursor: 'pointer', borderRadius: 8, textAlign: 'left', }; function formatTime(iso: string): string { try { return new Date(iso).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit', timeZoneName: 'short' }); } catch { return iso; } }