766 lines
26 KiB
TypeScript
766 lines
26 KiB
TypeScript
'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'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 2–3 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'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 “{playerQuery}”. 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'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;
|
||
}
|
||
}
|