'use client'; import { useCallback, useEffect, useMemo, useState } from 'react'; import { useRouter } from 'next/navigation'; import ProcessingGrade from '@/components/vyndr/ProcessingGrade'; import PriorReads from '@/components/vyndr/PriorReads'; import { AccuracyBadge, Skeleton, SkeletonList } from '@/components/vyndr'; import type { GradeResultData } from '@/components/vyndr/GradeResultCard'; import { mapScanToGradeResult } from '@/lib/gradeAdapter'; import { normalizeName, nameKey } from '@/lib/playerName'; import { markReadComplete } from '@/lib/reads'; import { useAuth } from '@/contexts/AuthContext'; import { useParlay } from '@/contexts/ParlayContext'; import { trackScanCompleted, trackScanLimitHit, trackUpgradeClicked, } from '@/lib/analytics'; import PlayerAvatar from '@/components/vyndr/PlayerAvatar'; import NoMarketState, { type PricedLine } from '@/components/vyndr/NoMarketState'; import { indexPricedLines, pricedLinesFor } from '@/lib/pricedLines'; import { type HeadshotSport } from '@/lib/playerHeadshot'; import { buildBookLink, SUPPORTED_BOOKS, BOOK_LINK_REL } from '@/lib/bookLinks'; 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; // Session 58 (work-order 1.5) — the model refused: no projection, no read. insufficient_data?: boolean; projection?: number; confidence?: number; sample_size?: number; factors?: Record; alt_lines?: { line: number; grade: string; hit_rate?: number; edge_pct?: number; base?: boolean }[]; kelly?: { pct: number; quarter: number; full: number; odds: string }; 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 }; // Session 43/44 — Player-Intelligence fields the engine attaches; the grade // card's STAT CONTEXT + VYNDR INTELLIGENCE sections read these. season_avg?: number; last10_avg?: number; vs_opp_avg?: number; form?: number; usage?: string; matchup_grade?: string; rest?: string; archetype?: string; archetype_blend?: { archetype: string; weight: number }[]; prop_dna?: { reliable: string[]; volatile: string[] }; // Session 66 — the PRICE LAYER. `model_odds` is derived from p_win by the // engine and STRIPPED for unentitled tiers, which is what sets // `model_price_locked` — so a locked leg and a missing leg stay distinct. book_odds?: number | null; fair_odds?: number | null; model_odds?: number | null; ev_pct?: number | null; model_price_locked?: boolean; } 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', }; // Sportsbook deep-links — A1 S3: built by lib/bookLinks (organic until the // affiliate config flips a book on). rel is BOOK_LINK_REL on every anchor. // Session 80 — PRICED-LINE NUDGE gate + freshness. The flag is the reversibility // lever: false → the nudge disappears and the scanner falls back to S6's // link-only empty state (Scan A / the working triplet are untouched either way). // STALE_MS matches /api/snapshot's 30s cache — no point re-fetching more often. const PRICED_NUDGE_ENABLED = true; const PRICED_STALE_MS = 30_000; export default function ScanPage() { const router = useRouter(); const { user, session, 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(''); // Wave 2A — the MLBAM id of the player picked from search (MLB only; numeric). // Feeds the grade card's real headshot. null → team-colored monogram. const [selectedPlayerId, setSelectedPlayerId] = useState(null); 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); // Session 79 — the CURRENT snapshot's priced lines, indexed by player+stat, so // a marketless scan can surface REAL priced lines (never suggested/nearest). const [pricedIndex, setPricedIndex] = useState | null>(null); // Session 80 — freshness clock: when the held snapshot was last fetched, so a // long-open scanner re-fetches instead of surfacing hour-stale priced lines. const [pricedFetchedAt, setPricedFetchedAt] = useState(0); // One-tap re-scan of a surfaced priced line: bump this to re-run runScan AFTER // line/direction state has committed. const [rescanKey, setRescanKey] = useState(0); 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]); // Session 79 — the REAL priced lines for the EXACT selected player+stat. Empty // for a player/stat the board didn't price — never suggested or interpolated. const pricedForSelection = useMemo( () => (PRICED_NUDGE_ENABLED && pricedIndex && selectedPlayer && stat ? pricedLinesFor(pricedIndex, selectedPlayer, stat) : []), [pricedIndex, selectedPlayer, stat], ); // One-tap re-scan after a surfaced priced line commits its line/direction. useEffect(() => { if (rescanKey === 0) return; void runScan(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [rescanKey]); // 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 79/80 — index the current snapshot's priced lines for the surfacer, // kept FRESH. /api/snapshot is public + 30s-cached and re-validated // server-side at scan time (so a stale chip that's tapped degrades to the // honest empty state, never a vanishing triplet). The display is kept current // by re-fetching when it's older than PRICED_STALE_MS at the moment of use — // on selection change and on window focus — so a long-open page never shows // an hour-stale priced line. const refreshPriced = useCallback(() => { if (!PRICED_NUDGE_ENABLED) return; fetch(`/api/snapshot/${sport.toLowerCase()}`) .then((r) => (r.ok ? r.json() : null)) .then((data: { grades?: unknown[] } | null) => { setPricedIndex(indexPricedLines((data && data.grades) || [])); setPricedFetchedAt(Date.now()); }) .catch(() => { setPricedIndex(new Map()); setPricedFetchedAt(Date.now()); }); }, [sport]); // Sport change: clear (never show the old sport's lines) then fetch fresh. useEffect(() => { setPricedIndex(null); setPricedFetchedAt(0); refreshPriced(); }, [refreshPriced]); // Selection change: re-fetch only if the held snapshot has gone stale, so the // chips shown for the new selection come from current data, not a mount copy. useEffect(() => { if (!selectedPlayer) return; if (Date.now() - pricedFetchedAt > PRICED_STALE_MS) refreshPriced(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [selectedPlayer, stat]); // Long-open page returning to focus: refresh if stale. useEffect(() => { const onFocus = () => { if (Date.now() - pricedFetchedAt > PRICED_STALE_MS) refreshPriced(); }; window.addEventListener('focus', onFocus); return () => window.removeEventListener('focus', onFocus); // eslint-disable-next-line react-hooks/exhaustive-deps }, [pricedFetchedAt]); // 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; // Session 48 — group by the normalized name key so variants // ("A.J."/"AJ", "Matt"/"Matthew", "Jazz Chisholm"/"Jr.") show as ONE // tile; display the normalized (longest) name. const byPlayer = new Map }>(); for (const p of data.props || []) { if (!p.player || !p.stat_type) continue; const key = nameKey(p.player); const disp = normalizeName(p.player).display || p.player; const entry = byPlayer.get(key) || { name: disp, stats: new Set() }; if (disp.length > entry.name.length) entry.name = disp; entry.stats.add(p.stat_type); byPlayer.set(key, entry); } const list = Array.from(byPlayer.values()) .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 { // DS1 (§17 — scan→ledger persistence). The authoritative bearer token is // the live Supabase session's access_token (set for EVERY sign-in method). // The legacy `localStorage['sb-token']` key is written ONLY by the OAuth // callback — so email/password users sent NO Authorization header, the // /api/scan route saw an anonymous request, and the completed read was // silently dropped from the ledger (the write is gated on an authed user). // Prefer the session token; keep the legacy key as a fallback. const token = session?.access_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); // Session 58 — a refused read (insufficient data) doesn't burn a scan. if (!data.insufficient_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); } }; // Count a completed read once per prop per session (drives the Install/Push // prompt gates) — preserved from the legacy GradeCard's reveal effect. useEffect(() => { if (!result || typeof window === 'undefined') return; const readKey = `vyndr_read_${sport}_${selectedPlayer}_${stat}_${line}_${direction}`; if (!window.sessionStorage.getItem(readKey)) { window.sessionStorage.setItem(readKey, '1'); markReadComplete(); } }, [result, sport, selectedPlayer, stat, line, direction]); const reset = () => { setResult(null); setError(''); setPlayerQuery(''); setSelectedPlayer(''); setSelectedPlayerId(null); setLine(''); }; if (authLoading || !user) { // DS1 (§4) — layout-matched skeleton of the scan form, not a text wall. return (
); } 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. Check back soon.

) : ( )}
{/* 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 selected = selectedPlayer === p.name; return ( ); })}
)} {/* Player search */}
{ setPlayerQuery(e.target.value); setSelectedPlayer(''); setSelectedPlayerId(null); }} 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)} /> {/* Session 79 — HELP, not restriction: the board's REAL priced lines for this exact player+stat. Tap to pre-fill a line that will yield a real triplet. The scanner still accepts any free-typed line. */} {pricedForSelection.length > 0 && (
PRICED TONIGHT: {pricedForSelection.map((l, i) => ( ))}
)}
{(['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}
)} {/* Session 58 (work-order 1.5) — the honest refusal. When the model has no projection there is NO read: no grade letter, no fake +0% edge, and nothing writes to the ledger. A refused read builds more trust than a hollow one. */} {result && result.insufficient_data && (

INSUFFICIENT DATA — NO READ

The model has no projection for this prop, so it refuses to grade it. No number gets invented here — that's the deal.

)} {/* Grade result — VYNDR 2.0 ProcessingGrade → GradeResultCard (Session 35). Engine output is mapped to the §7 contract and tier-gated by the adapter. */} {result && !result.insufficient_data && (
)} {/* 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; } }