Sessions 5-7a: 955 tests, deployment ready

This commit is contained in:
Kev
2026-06-08 18:35:13 -04:00
parent 06b82624a2
commit 1fa04dc776
371 changed files with 49366 additions and 955 deletions
+566 -249
View File
@@ -1,292 +1,609 @@
'use client';
import { useState, useCallback } from 'react';
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';
interface Leg {
player: string;
stat_type: string;
line: number | string;
direction: string;
book: string;
type Sport = 'NBA' | 'MLB' | 'WNBA';
interface Game {
id: string;
away: string;
home: string;
start_time: string;
status: 'scheduled' | 'live' | 'final';
prop_count?: number;
}
interface LegResult {
index: number;
player: string;
stat_type: string;
line: number;
direction: string;
interface Player {
id: string;
full_name: string;
team?: string;
position?: string;
}
interface ScanResponse {
grade: string;
confidence: number;
edge_pct: number;
kill_conditions: { code: string; reason: string }[];
reasoning_summary: string;
}
interface ScanResult {
scan_id: string;
parlay_grade: string;
parlay_confidence: number;
correlation_flags: { type: string; legs: number[]; detail: string; impact: string }[];
legs: LegResult[];
scan_count: number;
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;
upgrade_pitch: {
hook: string;
insight: string;
cta: string;
tier_recommended: string;
founder_price: string;
} | null;
tier: 'free' | 'analyst' | 'desk';
error?: string;
upgrade?: { tier: string; price: number };
}
const STAT_TYPES = ['points', 'rebounds', 'assists', 'threes', 'blocks', 'steals', 'pra', 'turnovers'];
const BOOKS = ['draftkings', 'fanduel', 'betmgm'];
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 emptyLeg: Leg = { player: '', stat_type: 'points', line: '', direction: 'over', book: 'draftkings' };
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 [legs, setLegs] = useState<Leg[]>([{ ...emptyLeg }]);
const [results, setResults] = useState<ScanResult | null>(null);
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('');
const [playerSuggestions, setPlayerSuggestions] = useState<string[]>([]);
const [activeInput, setActiveInput] = useState(-1);
const updateLeg = (index: number, field: keyof Leg, value: string | number) => {
setLegs((prev) => prev.map((l, i) => (i === index ? { ...l, [field]: value } : l)));
};
// Auth gate — push anonymous users to signup
useEffect(() => {
if (!authLoading && !user) router.replace('/signup?next=/scan');
}, [authLoading, user, router]);
const addLeg = () => {
if (legs.length < 12) setLegs([...legs, { ...emptyLeg }]);
};
// 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]);
const removeLeg = (index: number) => {
if (legs.length > 1) setLegs(legs.filter((_, i) => i !== index));
};
// 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]);
const searchPlayer = useCallback(async (name: string, index: number) => {
if (name.length < 2) { setPlayerSuggestions([]); return; }
setActiveInput(index);
try {
const res = await fetch(`${process.env.NEXT_PUBLIC_NBA_SERVICE_URL || 'http://localhost:8000'}/players/search?name=${encodeURIComponent(name)}`);
const data = await res.json();
setPlayerSuggestions((data.results || []).map((r: any) => r.full_name).slice(0, 5));
} catch { setPlayerSuggestions([]); }
}, []);
// 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],
);
const scan = async () => {
const validLegs = legs.filter((l) => l.player && l.line);
if (validLegs.length < 2) { setError('Add at least 2 legs'); return; }
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('');
setResults(null);
setResult(null);
try {
const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000'}/api/scan/parlay`, {
const token = typeof window !== 'undefined' ? localStorage.getItem('sb-token') : null;
const res = await fetch('/api/scan', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(typeof window !== 'undefined' && localStorage.getItem('sb-token')
? { Authorization: `Bearer ${localStorage.getItem('sb-token')}` }
: {}),
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: JSON.stringify({ legs: validLegs.map((l) => ({ ...l, line: Number(l.line) })) }),
body: JSON.stringify({
sport,
player: selectedPlayer,
stat,
line: Number(line),
direction,
book: 'draftkings',
}),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Scan failed');
setResults(data);
} catch (e: any) {
setError(e.message);
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="py-8 px-4 max-w-3xl mx-auto">
<div className="flex justify-between items-center mb-8">
<h1 className="text-3xl font-bold">Scan Parlay</h1>
{results && results.scans_remaining != null && (
<div className="text-sm text-[var(--text-muted)]">
<span className="font-mono">{results.scan_count}</span> of 5 scans used
</div>
)}
</div>
<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>
{/* Leg Builder */}
<div className="space-y-4 mb-6">
{legs.map((leg, i) => (
<div key={i} className="p-4 rounded-xl bg-[var(--card)] border border-[var(--border)]">
<div className="flex justify-between items-center mb-3">
<span className="text-xs text-[var(--text-muted)] font-mono">Leg {i + 1}</span>
{legs.length > 1 && (
<button onClick={() => removeLeg(i)} className="text-xs text-[var(--grade-d)] hover:text-white">Remove</button>
)}
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<div className="col-span-2 relative">
<input
placeholder="Player name"
value={leg.player}
onChange={(e) => {
updateLeg(i, 'player', e.target.value);
searchPlayer(e.target.value, i);
}}
onBlur={() => setTimeout(() => setPlayerSuggestions([]), 200)}
className="w-full px-3 py-2 rounded-lg bg-[var(--bg)] border border-[var(--border)] text-sm text-white placeholder:text-[var(--text-muted)] focus:outline-none focus:border-[var(--accent)]"
/>
{activeInput === i && playerSuggestions.length > 0 && (
<div className="absolute z-10 top-full mt-1 w-full bg-[var(--card)] border border-[var(--border)] rounded-lg overflow-hidden">
{playerSuggestions.map((name) => (
<button
key={name}
onMouseDown={() => { updateLeg(i, 'player', name); setPlayerSuggestions([]); }}
className="block w-full text-left px-3 py-2 text-sm hover:bg-[var(--border)] transition"
>
{name}
</button>
))}
</div>
)}
</div>
<select
value={leg.stat_type}
onChange={(e) => updateLeg(i, 'stat_type', e.target.value)}
className="px-3 py-2 rounded-lg bg-[var(--bg)] border border-[var(--border)] text-sm text-white"
>
{STAT_TYPES.map((s) => <option key={s} value={s}>{s}</option>)}
</select>
<input
type="number"
step="0.5"
placeholder="Line"
value={leg.line}
onChange={(e) => updateLeg(i, 'line', e.target.value)}
className="px-3 py-2 rounded-lg bg-[var(--bg)] border border-[var(--border)] text-sm text-white placeholder:text-[var(--text-muted)]"
/>
<select
value={leg.direction}
onChange={(e) => updateLeg(i, 'direction', e.target.value)}
className="px-3 py-2 rounded-lg bg-[var(--bg)] border border-[var(--border)] text-sm text-white"
>
<option value="over">Over</option>
<option value="under">Under</option>
</select>
<select
value={leg.book}
onChange={(e) => updateLeg(i, 'book', e.target.value)}
className="px-3 py-2 rounded-lg bg-[var(--bg)] border border-[var(--border)] text-sm text-white"
>
{BOOKS.map((b) => <option key={b} value={b}>{b}</option>)}
</select>
</div>
</div>
))}
</div>
<div className="flex gap-3 mb-8">
{legs.length < 12 && (
<button onClick={addLeg} className="px-4 py-2 text-sm border border-[var(--border)] rounded-lg hover:border-[var(--accent)] transition">
+ Add Leg
</button>
)}
<button
onClick={scan}
disabled={scanning || legs.filter((l) => l.player && l.line).length < 2}
className="flex-1 py-3 bg-[var(--accent)] text-white rounded-xl font-medium hover:opacity-90 transition disabled:opacity-40"
{/* 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,
}}
>
{scanning ? 'Scanning...' : 'Scan Parlay'}
</button>
</div>
{error && <div className="p-4 mb-6 rounded-xl bg-[var(--grade-d)]/10 border border-[var(--grade-d)] text-[var(--grade-d)] text-sm">{error}</div>}
{/* Results */}
{results && (
<div className="space-y-6">
{/* Overall Grade */}
<div className="p-6 rounded-2xl bg-[var(--card)] border border-[var(--border)] text-center">
<p className="text-sm text-[var(--text-muted)] mb-3">Parlay Grade</p>
<div className="flex justify-center mb-3">
<GradeCard grade={results.parlay_grade} confidence={results.parlay_confidence} />
</div>
{results.correlation_flags.length > 0 && (
<p className="text-xs text-[var(--text-muted)]">
{results.correlation_flags.length} correlation flag{results.correlation_flags.length > 1 ? 's' : ''} detected
</p>
)}
</div>
{/* Individual Legs */}
{results.legs.map((leg) => (
<div key={leg.index} className="p-5 rounded-xl bg-[var(--card)] border border-[var(--border)]">
<div className="flex justify-between items-start mb-3">
<div>
<h3 className="font-semibold">{leg.player}</h3>
<p className="text-sm text-[var(--text-muted)]">
{leg.direction.charAt(0).toUpperCase() + leg.direction.slice(1)} {leg.line} {leg.stat_type}
</p>
</div>
<GradeCard grade={leg.grade} confidence={leg.confidence} />
</div>
<p className="text-sm text-[var(--text-muted)] leading-relaxed mb-2">{leg.reasoning_summary}</p>
{leg.edge_pct !== 0 && (
<span className={`text-xs font-mono ${leg.edge_pct > 0 ? 'text-[var(--grade-a)]' : 'text-[var(--grade-d)]'}`}>
Edge: {leg.edge_pct > 0 ? '+' : ''}{leg.edge_pct}%
</span>
)}
{leg.kill_conditions.length > 0 && (
<div className="mt-2 flex flex-wrap gap-1">
{leg.kill_conditions.map((k) => (
<span key={k.code} className="px-2 py-0.5 text-xs bg-[var(--grade-d)]/10 text-[var(--grade-d)] rounded">
{k.code}
</span>
))}
</div>
)}
</div>
))}
{/* Correlations */}
{results.correlation_flags.length > 0 && (
<div className="p-5 rounded-xl bg-[var(--card)] border border-[var(--border)]">
<h3 className="font-semibold mb-3">Correlations</h3>
{results.correlation_flags.map((flag, i) => (
<div key={i} className="flex items-start gap-2 text-sm mb-2">
<span className={flag.impact === 'major_negative' ? 'text-[var(--grade-d)]' : flag.impact === 'positive' ? 'text-[var(--grade-a)]' : 'text-[var(--grade-c)]'}>
{flag.impact === 'major_negative' ? '!!' : flag.impact === 'positive' ? '+' : '!'}
</span>
<span className="text-[var(--text-muted)]">{flag.detail}</span>
</div>
))}
</div>
)}
{/* Upgrade Pitch */}
{results.upgrade_pitch && (
<div className="p-6 rounded-2xl bg-[var(--accent)]/10 border border-[var(--accent)]">
<p className="font-semibold mb-2">{results.upgrade_pitch.hook}</p>
<p className="text-sm text-[var(--text-muted)] mb-4">{results.upgrade_pitch.insight}</p>
<a href="#pricing" className="inline-block px-6 py-2 bg-[var(--accent)] text-white rounded-lg text-sm font-medium hover:opacity-90 transition">
{results.upgrade_pitch.cta}
</a>
</div>
)}
{/* Actions */}
<div className="flex gap-3">
<button
onClick={() => { setResults(null); setLegs([{ ...emptyLeg }]); }}
className="flex-1 py-3 border border-[var(--border)] rounded-xl text-sm hover:border-[var(--accent)] transition"
>
New Scan
</button>
<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>
{/* 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"
/>
{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}
>
<span>{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;
}
}