Files
vyndr/web/src/app/scan/page.tsx
T

766 lines
26 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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<string, string>;
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<Sport, { id: string; label: string }[]> = {
NBA: NBA_STATS,
MLB: MLB_STATS,
WNBA: WNBA_STATS,
};
const SPORT_ACCENT: Record<Sport, string> = {
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<Sport>('NBA');
const [games, setGames] = useState<Game[] | null>(null);
const [gameId, setGameId] = useState<string>('');
const [playerQuery, setPlayerQuery] = useState('');
const [playerSuggestions, setPlayerSuggestions] = useState<Player[]>([]);
const [selectedPlayer, setSelectedPlayer] = useState<string>('');
const [stat, setStat] = useState<string>('points');
const [line, setLine] = useState<string>('');
const [direction, setDirection] = useState<'over' | 'under'>('over');
const [scanning, setScanning] = useState(false);
const [result, setResult] = useState<ScanResponse | null>(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<Array<{ name: string; stats: string[] }> | 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<string, Set<string>>();
for (const p of data.props || []) {
if (!p.player || !p.stat_type) continue;
const set = byPlayer.get(p.player) || new Set<string>();
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 (
<section style={{ minHeight: '80vh', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<p className="mono" style={{ color: 'var(--text-tertiary)' }}>Loading the model</p>
</section>
);
}
return (
<section
className="diagonal-cut animate-fade-up"
style={{ maxWidth: 720, margin: '0 auto', padding: '32px 16px 96px', position: 'relative' }}
>
{/* Header */}
<header style={{ marginBottom: 24 }}>
<h1 style={{ fontSize: 28, fontWeight: 700, letterSpacing: '-0.02em', marginBottom: 6 }}>
Grade a prop.
</h1>
<p style={{ color: 'var(--text-secondary)', fontSize: 14 }}>
Pick a sport, find the player, set the line. We grade it in seconds.
</p>
</header>
{/* Scan counter */}
{tier === 'free' && scansRemaining != null && (
<div
style={{
marginBottom: 24,
padding: '12px 16px',
background: 'var(--bg-surface)',
border: '1px solid var(--border)',
borderRadius: 12,
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
gap: 12,
}}
>
<span className="mono" style={{ fontSize: 12, color: 'var(--text-secondary)', letterSpacing: '0.05em' }}>
{scansRemaining} OF 5 FREE READS REMAINING THIS MONTH
</span>
<div style={{ flex: 1, maxWidth: 160, height: 4, background: 'var(--border)', borderRadius: 2, overflow: 'hidden' }}>
<div
style={{
width: `${(scansRemaining / 5) * 100}%`,
height: '100%',
background: scansRemaining <= 1 ? 'var(--grade-c)' : 'var(--grade-a)',
transition: 'width 200ms ease',
}}
/>
</div>
</div>
)}
{/* Sport tabs */}
<div role="tablist" aria-label="Sport" style={{ display: 'flex', gap: 4, marginBottom: 24, borderBottom: '1px solid var(--border)' }}>
{(Object.keys(SPORT_STATS) as Sport[]).map((s) => {
const active = s === sport;
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_ACCENT[s] : 'transparent'}`,
color: active ? 'var(--text-primary)' : 'var(--text-secondary)',
fontFamily: 'inherit',
fontWeight: active ? 600 : 500,
fontSize: 14,
cursor: 'pointer',
transition: 'color 200ms ease',
marginBottom: -1,
boxShadow: active ? `0 4px 16px ${SPORT_ACCENT[s]}26` : 'none',
}}
>
{s}
</button>
);
})}
</div>
{/* Game selector */}
<div style={{ marginBottom: 16 }}>
<label className="mono" style={labelStyle}>Tonight&apos;s slate</label>
{games === null ? (
<div style={shimmerStyle} />
) : games.length === 0 ? (
<p className="surface" style={{ padding: 16, fontSize: 13, color: 'var(--text-secondary)' }}>
No games posted yet. Books usually open player props 23 hours before tip.
</p>
) : (
<select
value={gameId}
onChange={(e) => setGameId(e.target.value)}
className="input-field"
aria-label="Game"
>
<option value="">All games</option>
{games.map((g) => (
<option key={g.id} value={g.id}>
{g.away} @ {g.home} · {formatTime(g.start_time)}
</option>
))}
</select>
)}
</div>
{/* 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 && (
<div style={{ marginBottom: 20 }}>
<label className="mono" style={labelStyle}>Tonight&apos;s Players</label>
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(140px, 1fr))',
gap: 8,
maxHeight: 220,
overflowY: 'auto',
padding: 2,
}}
>
{tonightsPlayers.map((p) => {
const headshot = getHeadshotUrl({ sport: sport.toLowerCase() as HeadshotSport });
const selected = selectedPlayer === p.name;
return (
<button
key={p.name}
type="button"
onClick={() => {
setSelectedPlayer(p.name);
setPlayerQuery(p.name);
setPlayerSuggestions([]);
// If the player has exactly one stat type with
// props, prefill it — saves a tap for single-stat
// pitcher props (ERs/Ks) etc.
if (p.stats.length === 1 && SPORT_STATS[sport].some((s) => s.id === p.stats[0])) {
setStat(p.stats[0]);
}
}}
style={{
display: 'flex',
alignItems: 'center',
gap: 8,
padding: '6px 10px',
background: selected ? 'rgba(0,212,160,0.08)' : 'var(--bg-surface)',
border: `1px solid ${selected ? 'var(--grade-a)' : 'var(--border)'}`,
borderRadius: 999,
color: 'var(--text-primary)',
cursor: 'pointer',
fontSize: 12,
fontFamily: 'inherit',
textAlign: 'left',
minWidth: 0,
}}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={headshot}
alt=""
width={24}
height={24}
onError={(e) => { (e.currentTarget as HTMLImageElement).src = PLAYER_SILHOUETTE; }}
style={{
width: 24,
height: 24,
borderRadius: '50%',
background: 'var(--bg-elevated)',
flexShrink: 0,
objectFit: 'cover',
}}
/>
<span style={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', flex: 1 }}>
{p.name}
</span>
</button>
);
})}
</div>
</div>
)}
{/* Player search */}
<div style={{ marginBottom: 16, position: 'relative' }}>
<label className="mono" style={labelStyle}>Player</label>
<input
className="input-field"
placeholder={`Search ${sport} players`}
value={playerQuery}
onChange={(e) => {
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 && (
<div
className="surface-elevated"
style={{
position: 'absolute', top: '100%', left: 0, right: 0,
marginTop: 4, zIndex: 20, padding: 12,
fontSize: 12, color: 'var(--text-tertiary)',
}}
>
No {sport} players matched &ldquo;{playerQuery}&rdquo;. Check spelling or try a partial name.
</div>
)}
{playerSuggestions.length > 0 && playerQuery !== selectedPlayer && (
<div
className="surface-elevated"
style={{
position: 'absolute',
top: '100%',
left: 0,
right: 0,
marginTop: 4,
zIndex: 20,
padding: 4,
maxHeight: 280,
overflowY: 'auto',
}}
>
{playerSuggestions.map((p) => (
<button
key={p.id}
onMouseDown={(e) => {
e.preventDefault();
setSelectedPlayer(p.full_name);
setPlayerQuery(p.full_name);
setPlayerSuggestions([]);
}}
style={suggestionStyle}
>
{/* Session 19 — headshot in search suggestions. The
/api/players/search response doesn't (yet) include
league IDs, so we hit ESPN-CDN fallback or
silhouette. onError swaps in the silhouette when
the league hasn't published one. */}
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={getHeadshotUrl({ sport: sport.toLowerCase() as HeadshotSport })}
alt=""
width={28}
height={28}
onError={(e) => { (e.currentTarget as HTMLImageElement).src = PLAYER_SILHOUETTE; }}
style={{
width: 28,
height: 28,
borderRadius: '50%',
background: 'var(--bg-elevated)',
flexShrink: 0,
marginRight: 10,
objectFit: 'cover',
}}
/>
<span style={{ flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{p.full_name}</span>
{p.team && (
<span className="mono" style={{ fontSize: 11, color: 'var(--text-tertiary)' }}>
{p.team}
</span>
)}
</button>
))}
</div>
)}
</div>
{/* Stat + line + direction */}
<div style={{ display: 'grid', gridTemplateColumns: '1.2fr 1fr 0.8fr', gap: 12, marginBottom: 24 }}>
<div>
<label className="mono" style={labelStyle}>Stat</label>
<select className="input-field" value={stat} onChange={(e) => setStat(e.target.value)}>
{SPORT_STATS[sport].map((s) => (
<option key={s.id} value={s.id}>{s.label}</option>
))}
</select>
</div>
<div>
<label className="mono" style={labelStyle}>Line</label>
<input
type="number"
step="0.5"
inputMode="decimal"
className="input-field"
placeholder="0.0"
value={line}
onChange={(e) => setLine(e.target.value)}
/>
</div>
<div>
<label className="mono" style={labelStyle}>Side</label>
<div style={{ display: 'flex', borderRadius: 12, overflow: 'hidden', border: '1px solid var(--border)' }}>
{(['over', 'under'] as const).map((d) => (
<button
key={d}
onClick={() => setDirection(d)}
aria-pressed={direction === d}
style={{
flex: 1,
padding: '12px 0',
background: direction === d ? 'var(--accent)' : 'transparent',
color: direction === d ? 'var(--text-primary)' : 'var(--text-secondary)',
border: 'none',
fontFamily: 'inherit',
fontWeight: 600,
fontSize: 13,
textTransform: 'capitalize',
cursor: 'pointer',
}}
>
{d}
</button>
))}
</div>
</div>
</div>
{/* Grade button OR upgrade trigger */}
{!canScan ? (
<div className="surface diagonal-cut tex-scan" style={{ padding: 32, textAlign: 'center', maxWidth: 440, margin: '0 auto' }}>
<p className="lbl" style={{ color: 'var(--grade-c)', marginBottom: 12 }}>SIGNAL EXHAUSTED</p>
<h2 style={{ fontSize: 22, fontWeight: 700, marginBottom: 8 }}>
You&apos;ve used your 5 free reads this month.
</h2>
<p style={{ color: 'var(--text-1)', fontSize: 14, marginBottom: 20 }}>
Unlock unlimited reads plus kill conditions, alt lines, and the full intelligence layer.
</p>
<p className="num" style={{
fontSize: 32, color: 'var(--grade-a)', marginBottom: 4,
textShadow: '0 0 14px rgba(0, 212, 160, 0.7)',
}}>
$14.99<span style={{ fontSize: 14, color: 'var(--text-1)' }}>/mo</span>
</p>
<p style={{ color: 'var(--grade-c)', fontSize: 13, fontWeight: 600, marginBottom: 20 }}>
Locked for life. This rate disappears June 15.
</p>
<button
className="btn-primary"
style={{ width: '100%', padding: 14 }}
onClick={() => {
trackUpgradeClicked({ current_tier: tier, target_tier: 'analyst', trigger_location: 'scan_limit' });
router.push('/api/checkout?tier=analyst');
}}
>
Upgrade Now
</button>
<p style={{ color: 'var(--text-2)', fontSize: 12, marginTop: 12 }}>
Or come back next month for 5 more free reads.
</p>
</div>
) : (
<button
onClick={runScan}
disabled={!canSubmit}
className={scanning ? 'shimmer-loading' : 'btn-primary'}
style={{ width: '100%', padding: 16, fontSize: 15, color: 'var(--text-primary)', border: 'none', borderRadius: 12, fontWeight: 600, cursor: canSubmit ? 'pointer' : 'not-allowed', opacity: canSubmit ? 1 : 0.4 }}
>
{scanning ? 'Running the model…' : 'Read It'}
</button>
)}
{/* Inline error */}
{error && (
<div
style={{
marginTop: 16,
padding: 14,
borderRadius: 12,
background: 'rgba(255,107,107,0.10)',
border: '1px solid rgba(255,107,107,0.30)',
color: 'var(--grade-d)',
fontSize: 13,
}}
>
{error}
</div>
)}
{/* Grade card output */}
{result && (
<div style={{ marginTop: 32, display: 'grid', gap: 16 }}>
<GradeCard
sport={sport}
player={selectedPlayer}
stat={stat}
line={Number(line)}
direction={direction}
grade={result.grade}
projection={result.projection}
confidence={result.confidence}
sample_size={result.sample_size}
factors={result.factors}
alt_lines={result.alt_lines}
kill_conditions={result.kill_conditions}
reasoning={result.reasoning}
historical_hit_rate={result.historical_hit_rate}
tier={tier}
onUpgradeClick={(target, from) => {
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();
}}
/>
<div style={{ display: 'flex', gap: 12 }}>
<button onClick={reset} className="btn-ghost" style={{ flex: 1 }}>
Read another prop
</button>
<a href="/dashboard" className="btn-ghost" style={{ flex: 1 }}>
Back to slate
</a>
</div>
</div>
)}
{/* Helpful sticky parlay indicator */}
{legCount > 0 && (
<button
onClick={open}
className="mono"
style={{
position: 'fixed',
bottom: 88,
right: 16,
zIndex: 30,
padding: '10px 16px',
borderRadius: 999,
background: 'var(--accent)',
border: '1px solid var(--accent-light)',
color: 'var(--text-primary)',
fontWeight: 700,
fontSize: 12,
boxShadow: '0 8px 24px var(--accent-glow)',
cursor: 'pointer',
}}
>
Parlay · {legCount} leg{legCount === 1 ? '' : 's'}
</button>
)}
</section>
);
}
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;
}
}