4f3f433aae
REPORT-FIRST correction. This order's premise — "S7 is a shell that doesn't update per selection" — is not what the code does. pricedForSelection is a useMemo on [pricedIndex, selectedPlayer, stat] and setSelectedPlayer/setStat fire on every user pick, so the chips already update per selection, and the prior session's verification of that stands. The genuine gap was FRESHNESS: the snapshot fetch depended on [sport] only, so pricedIndex was fetched once per sport-change and never refreshed. The pricing cron re-prices at five UTC hours, so a scanner left open across a cron boundary surfaced hour-stale priced lines. That is the real defect, and the only one fixed. FRESHNESS. The fetch is now a refreshPriced callback re-run when the held snapshot is older than PRICED_STALE_MS (30s, matching the /api/snapshot cache) at the moment of use — on selection change and on window focus — so a long-open page never shows a stale line. Sport change still clears the index first, so the old sport's lines never flash. STALE-TAP was already safe and is unchanged: the scan submit re-fetches the live snapshot server-side, so a chip that's gone stale between render and tap either lands on a real triplet (still priced) or degrades to the honest empty state (rotated away) — proven in the prior session and re-confirmed here (an off-snapshot line returns no market and shows the empty state). REVERSIBLE GATE. The whole nudge sits behind one PRICED_NUDGE_ENABLED flag: false empties the surfaced set, so the scanner falls back to S6's link-only empty state with the chips gone. Shipping enabled only after the cases are proven this session; the flag is the instant revert lever. DISPLAY-LAYER ONLY. Only scan/page.tsx changed. GradeResultCard, PriceTriplet, gradeAdapter, valueState, both scan routes and the pure pricedLines helper are byte-identical — Scan A and the scan-submit resolution are untouched, and the change is independently revertible. Tests 3765 passed / 303 suites, web build exit 0. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VCNgGSt5qvcLxaeQqa7Zpj
1018 lines
40 KiB
TypeScript
1018 lines
40 KiB
TypeScript
'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<string, string>;
|
|
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<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',
|
|
};
|
|
|
|
// 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<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>('');
|
|
// 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<string | null>(null);
|
|
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);
|
|
// 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<Map<string, PricedLine[]> | 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<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]);
|
|
|
|
// 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<string, { name: string; stats: Set<string> }>();
|
|
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<string>() };
|
|
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 (
|
|
<section style={{ maxWidth: 720, margin: '0 auto', padding: '32px 16px 96px' }} aria-busy="true">
|
|
<Skeleton height={30} width={200} radius={8} style={{ marginBottom: 8 }} />
|
|
<Skeleton height={16} width={320} radius={6} style={{ marginBottom: 28 }} />
|
|
<SkeletonList count={3} height={64} />
|
|
<Skeleton height={52} radius={12} style={{ marginTop: 20 }} />
|
|
</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. Check back soon.
|
|
</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 selected = selectedPlayer === p.name;
|
|
return (
|
|
<button
|
|
key={p.name}
|
|
type="button"
|
|
onClick={() => {
|
|
setSelectedPlayer(p.name);
|
|
setSelectedPlayerId(null); // tonight chips carry no id → monogram
|
|
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,
|
|
}}
|
|
>
|
|
{/* Wave 2A — tonight's players carry no id → team-colored
|
|
monogram (branded, never a gray silhouette). */}
|
|
<PlayerAvatar name={p.name} sport={sport.toLowerCase() as HeadshotSport} size={24} />
|
|
<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('');
|
|
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 && (
|
|
<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);
|
|
setSelectedPlayerId(sport.toLowerCase() === 'mlb' && /^\d+$/.test(String(p.id)) ? String(p.id) : null);
|
|
setPlayerQuery(p.full_name);
|
|
setPlayerSuggestions([]);
|
|
}}
|
|
style={suggestionStyle}
|
|
>
|
|
{/* Wave 2A — real headshot in search suggestions. MLB's
|
|
/api/players/search carries the MLBAM id on p.id (numeric);
|
|
NBA/WNBA ids are synthetic → guarded out → team-colored
|
|
monogram. Never a gray silhouette. */}
|
|
<span style={{ marginRight: 10, display: 'inline-flex' }}>
|
|
<PlayerAvatar
|
|
name={p.full_name}
|
|
sport={sport.toLowerCase() as HeadshotSport}
|
|
playerId={sport.toLowerCase() === 'mlb' && /^\d+$/.test(String(p.id)) ? p.id : undefined}
|
|
team={p.team}
|
|
size={28}
|
|
/>
|
|
</span>
|
|
<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)}
|
|
/>
|
|
{/* 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 && (
|
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginTop: 8 }}>
|
|
<span className="mono" style={{ fontSize: 9, color: 'var(--text-3)', letterSpacing: '0.1em', alignSelf: 'center' }}>
|
|
PRICED TONIGHT:
|
|
</span>
|
|
{pricedForSelection.map((l, i) => (
|
|
<button
|
|
key={`${l.direction}-${l.line}-${i}`}
|
|
type="button"
|
|
onClick={() => { setLine(String(l.line)); setDirection(l.direction); }}
|
|
className="mono"
|
|
style={{ cursor: 'pointer', fontSize: 11, fontWeight: 700, padding: '4px 9px', borderRadius: 999, border: '1px solid var(--border-hi)', background: 'transparent', color: 'var(--text-1)', fontVariantNumeric: 'tabular-nums' }}
|
|
>
|
|
{l.direction === 'under' ? 'U' : 'O'} {l.line}
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</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>
|
|
)}
|
|
|
|
{/* 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 && (
|
|
<div
|
|
className="surface scanlines"
|
|
style={{ marginTop: 32, padding: 32, textAlign: 'center', border: '1px solid var(--border-hi)', borderRadius: 10, display: 'grid', gap: 10, justifyItems: 'center' }}
|
|
>
|
|
<p className="mono" style={{ fontSize: 12, fontWeight: 800, letterSpacing: '0.14em', color: 'var(--amber)' }}>
|
|
INSUFFICIENT DATA — NO READ
|
|
</p>
|
|
<p style={{ color: 'var(--text-1)', fontSize: 14, maxWidth: 460 }}>
|
|
The model has no projection for this prop, so it refuses to grade it.
|
|
No number gets invented here — that's the deal.
|
|
</p>
|
|
<button onClick={reset} className="btn-ghost" style={{ marginTop: 6, padding: '10px 18px' }}>
|
|
Read another prop →
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{/* 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 && (
|
|
<div style={{ marginTop: 32, display: 'grid', gap: 16 }}>
|
|
<ProcessingGrade
|
|
key={`${selectedPlayer}-${stat}-${line}-${direction}`}
|
|
data={mapScanToGradeResult({
|
|
player: selectedPlayer,
|
|
playerId: selectedPlayerId,
|
|
sport,
|
|
stat,
|
|
line: Number(line),
|
|
direction,
|
|
grade: result.grade,
|
|
projection: result.projection,
|
|
confidence: result.confidence,
|
|
sample_size: result.sample_size,
|
|
factors: result.factors,
|
|
alt_lines: result.alt_lines,
|
|
kelly: result.kelly,
|
|
kill_conditions: result.kill_conditions,
|
|
tier,
|
|
// Session 44 — forward the engine's intel fields so the grade
|
|
// card's STAT CONTEXT + VYNDR INTELLIGENCE sections populate.
|
|
season_avg: result.season_avg,
|
|
last10_avg: result.last10_avg,
|
|
vs_opp_avg: result.vs_opp_avg,
|
|
form: result.form,
|
|
usage: result.usage,
|
|
matchup_grade: result.matchup_grade,
|
|
rest: result.rest,
|
|
archetype: result.archetype,
|
|
archetype_blend: result.archetype_blend,
|
|
prop_dna: result.prop_dna,
|
|
// Session 66 — the PRICE LAYER. Without these forwarded the
|
|
// triplet stays hidden no matter what the engine computed
|
|
// (the Session-44 lesson: this call site is a hardcoded
|
|
// whitelist, not a spread).
|
|
book_odds: result.book_odds,
|
|
fair_odds: result.fair_odds,
|
|
model_odds: result.model_odds,
|
|
ev_pct: result.ev_pct,
|
|
model_price_locked: result.model_price_locked,
|
|
}) as GradeResultData}
|
|
onAddToParlay={() => {
|
|
addLeg({
|
|
sport,
|
|
player: selectedPlayer,
|
|
stat,
|
|
line: Number(line),
|
|
direction,
|
|
grade: result.grade,
|
|
confidence: result.confidence ?? 50,
|
|
});
|
|
open();
|
|
}}
|
|
onReadAnother={reset}
|
|
/>
|
|
|
|
{/* Session 79 — HONEST EMPTY STATE. A marketless scan (the board never
|
|
priced this exact player+stat+line) renders no triplet on the card
|
|
above; this says so truthfully and surfaces the REAL priced lines
|
|
(one-tap to a genuine triplet), or points at the live board. The
|
|
working card/join/triplet is byte-identical — this is a separate
|
|
block that only appears when book_odds/fair_odds came back null. */}
|
|
{(result.book_odds == null || result.fair_odds == null) && (
|
|
<NoMarketState
|
|
player={selectedPlayer}
|
|
stat={stat}
|
|
line={line}
|
|
pricedLines={pricedForSelection}
|
|
onPick={(l) => {
|
|
setLine(String(l.line));
|
|
setDirection(l.direction);
|
|
setRescanKey((k) => k + 1);
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
{/* Session 60 (4.3) — the model's public history on this player.
|
|
Deferred-render: shows only when real ledger rows exist. */}
|
|
<PriorReads player={selectedPlayer} stat={stat} />
|
|
|
|
{/* Session 55 — the self-learning loop's track record for this sport.
|
|
"The system learns" — real hit rate on graded props, misses shown. */}
|
|
<div style={{ display: 'flex', justifyContent: 'center' }}>
|
|
<AccuracyBadge variant="chip" sport={sport?.toLowerCase()} />
|
|
</div>
|
|
|
|
{/* Sportsbook hand-off (preserved feature) */}
|
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, justifyContent: 'center' }}>
|
|
{SUPPORTED_BOOKS.map((b) => {
|
|
const link = buildBookLink({ book: b.id, player: selectedPlayer, sport });
|
|
if (!link) return null;
|
|
return (
|
|
<a
|
|
key={b.id}
|
|
href={link.url}
|
|
target="_blank"
|
|
rel={BOOK_LINK_REL}
|
|
className="mono"
|
|
style={{ padding: '7px 13px', fontSize: 11, fontWeight: 700, borderRadius: 6, border: '1px solid var(--border-hi)', color: 'var(--text-1)', textDecoration: 'none' }}
|
|
>
|
|
{b.label} ↗
|
|
</a>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
{/* Free-tier nudge — full paywall treatment returns in Phase G */}
|
|
{tier === 'free' && (
|
|
<button
|
|
onClick={() => {
|
|
trackUpgradeClicked({ current_tier: tier, target_tier: 'analyst', trigger_location: 'grade_card_teaser' });
|
|
router.push('/api/checkout?tier=analyst');
|
|
}}
|
|
className="mono"
|
|
style={{ padding: '12px 16px', borderRadius: 8, border: '1px solid rgba(255,179,71,.4)', background: 'rgba(255,179,71,.06)', color: 'var(--amber)', fontWeight: 700, fontSize: 13, cursor: 'pointer' }}
|
|
>
|
|
Unlock every signal + kill conditions — $14.99/mo
|
|
</button>
|
|
)}
|
|
|
|
<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;
|
|
}
|
|
}
|