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

415 lines
14 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, useState } from 'react';
import { useRouter } from 'next/navigation';
import SportSelector, { SoccerLeague, SportSelection } from '@/components/SportSelector';
import SoccerGradeResult, { SoccerGradeResultProps } from '@/components/SoccerGradeResult';
import { useAuth } from '@/contexts/AuthContext';
/**
* /soccer — live soccer odds feed.
*
* Shows match cards for the selected league (defaults to World Cup
* 2026). Each match expands to reveal player props grouped by stat
* type. Clicking a prop hands it off to /scan for grading.
*
* Data path: this page → /api/odds/soccer/:league → Express
* /api/odds/soccer/:league → odds-api. The Express route falls back
* to cache when the API quota is low; the response carries `source:
* 'cache' | 'live'` so we can tag the freshness.
*/
interface NormalizedProp {
player: string;
stat_type: string;
line: number;
direction: 'over' | 'under';
book: string;
odds: number;
game_time?: string;
home_team?: string;
away_team?: string;
fetched_at?: string;
}
interface GroupedProp {
player: string;
stat_type: string;
line: number;
game_time?: string;
home_team?: string;
away_team?: string;
// best line per direction across books
over?: { book: string; odds: number };
under?: { book: string; odds: number };
}
interface OddsResponse {
sport: string;
updated_at?: string;
source?: string;
quota_remaining?: number;
props: GroupedProp[];
message?: string;
error?: string;
}
// Group a flat props array by (player, stat_type, line) so each row
// represents a SINGLE prop with both directions next to each other.
// The Express response already does some grouping but ships per-direction
// rows — collapse them.
function groupProps(props: GroupedProp[]): GroupedProp[] {
return props || [];
}
// Group props under their match for the card layout.
function groupByMatch(props: GroupedProp[]) {
const matches = new Map<string, { home: string; away: string; time?: string; propsByStatType: Map<string, GroupedProp[]> }>();
for (const p of props) {
const home = p.home_team || '?';
const away = p.away_team || '?';
const key = `${home}__${away}__${p.game_time || ''}`;
if (!matches.has(key)) {
matches.set(key, { home, away, time: p.game_time, propsByStatType: new Map() });
}
const m = matches.get(key)!;
const list = m.propsByStatType.get(p.stat_type) || [];
list.push(p);
m.propsByStatType.set(p.stat_type, list);
}
return Array.from(matches.values());
}
const STAT_LABELS: Record<string, string> = {
goals: 'Anytime / Total Goals',
shots_on_target: 'Shots on Target',
shots: 'Total Shots',
tackles: 'Tackles',
cards: 'Cards',
corners: 'Corners',
saves: 'Saves',
goals_conceded: 'Goals Conceded',
passes: 'Passes',
clean_sheet: 'Clean Sheet',
assists: 'Assists',
};
function formatTime(iso?: string) {
if (!iso) return '';
try {
const d = new Date(iso);
return d.toLocaleString(undefined, {
weekday: 'short', hour: 'numeric', minute: '2-digit',
});
} catch {
return iso;
}
}
export default function SoccerOddsPage() {
const router = useRouter();
const { session } = useAuth();
const [selection, setSelection] = useState<SportSelection>({ sport: 'Soccer', league: 'wc' });
const [data, setData] = useState<OddsResponse | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [expanded, setExpanded] = useState<Set<string>>(new Set());
const [scanning, setScanning] = useState(false);
const [scanResult, setScanResult] = useState<SoccerGradeResultProps | null>(null);
const [scanError, setScanError] = useState<string | null>(null);
const league: SoccerLeague = selection.league || 'wc';
// Redirect non-Soccer sport selections back to /scan — that page
// owns NBA/MLB/WNBA. Soccer is the only one this page serves.
useEffect(() => {
if (selection.sport !== 'Soccer') {
router.push('/scan');
}
}, [selection.sport, router]);
async function gradeProp(player: string, stat_type: string, lineVal: number) {
setScanError(null);
setScanResult(null);
setScanning(true);
try {
const res = await fetch('/api/scan', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(session?.access_token ? { Authorization: `Bearer ${session.access_token}` } : {}),
},
body: JSON.stringify({
sport: 'Soccer',
player,
stat: stat_type,
line: lineVal,
direction: 'over',
book: 'draftkings',
}),
});
const body = (await res.json().catch(() => ({}))) as Record<string, unknown> & { error?: string };
if (!res.ok) {
setScanError(body.error || 'The engine hit a wall. Try that read again.');
setScanning(false);
return;
}
const result: SoccerGradeResultProps = {
player,
stat_type,
line: lineVal,
direction: 'over',
league,
grade: String(body.grade || 'C'),
confidence: typeof body.confidence === 'number' ? body.confidence : undefined,
edge_pct: typeof body.edge_pct === 'number' ? body.edge_pct : undefined,
reasoning: (body.reasoning as SoccerGradeResultProps['reasoning']) || undefined,
kill_conditions_triggered: (body.kill_conditions_triggered as SoccerGradeResultProps['kill_conditions_triggered']) || [],
tier_gated: !!body.tier_gated,
upgrade_hint: typeof body.upgrade_hint === 'string' ? body.upgrade_hint : undefined,
onUpgradeClick: () => router.push('/#pricing'),
onClose: () => setScanResult(null),
};
setScanResult(result);
} catch {
setScanError('Network error. Try again.');
} finally {
setScanning(false);
}
}
const fetchOdds = useCallback(async () => {
setLoading(true);
setError(null);
try {
const res = await fetch(`/api/odds/soccer/${league}`, { cache: 'no-store' });
const body = (await res.json().catch(() => ({}))) as OddsResponse;
if (!res.ok) {
setError(body.error || 'Couldnt load odds. Try again.');
setData(null);
} else {
setData(body);
}
} catch {
setError('Network error. Try again.');
setData(null);
} finally {
setLoading(false);
}
}, [league]);
useEffect(() => {
fetchOdds();
}, [fetchOdds]);
const matches = data ? groupByMatch(groupProps(data.props)) : [];
return (
<main style={{ minHeight: '100vh', padding: '24px 16px 80px' }}>
<div style={{ maxWidth: 1100, margin: '0 auto' }}>
<header style={{ marginBottom: 24 }}>
<h1 style={{ fontSize: 'clamp(24px, 3vw, 36px)', fontWeight: 700, letterSpacing: '-0.02em', marginBottom: 8 }}>
Soccer odds
</h1>
<p style={{ color: 'var(--text-secondary)', fontSize: 14 }}>
Live odds across our launch leagues. Click any prop to grade it through the VYNDR engine.
</p>
</header>
<div style={{ marginBottom: 20 }}>
<SportSelector
initialSport="Soccer"
initialLeague={league}
onChange={(sel) => setSelection(sel)}
/>
</div>
{data?.source && (
<p
className="mono"
style={{
fontSize: 11,
color: 'var(--text-tertiary)',
marginBottom: 16,
letterSpacing: '0.06em',
textTransform: 'uppercase',
}}
>
{data.updated_at ? `Updated ${formatTime(data.updated_at)} · ` : ''}
source: {data.source}
{typeof data.quota_remaining === 'number' ? ` · quota: ${data.quota_remaining}` : ''}
</p>
)}
{loading && (
<div style={{ padding: 40, textAlign: 'center', color: 'var(--text-tertiary)' }}>Loading odds</div>
)}
{error && (
<div
role="alert"
style={{
padding: 16,
border: '1px solid var(--grade-d, #ff5a5a)',
color: 'var(--grade-d, #ff5a5a)',
borderRadius: 8,
marginBottom: 20,
}}
>
{error}
</div>
)}
{scanError && (
<div
role="alert"
style={{
padding: 12,
border: '1px solid var(--grade-d, #ff5a5a)',
color: 'var(--grade-d, #ff5a5a)',
borderRadius: 6,
marginBottom: 16,
fontSize: 13,
}}
>
{scanError}
</div>
)}
{scanResult && (
<SoccerGradeResult {...scanResult} />
)}
{!loading && !error && matches.length === 0 && (
<div
className="surface"
style={{
padding: 32,
border: '1px solid var(--border)',
borderRadius: 8,
textAlign: 'center',
color: 'var(--text-secondary)',
}}
>
No live matches with props in this league right now.
{league !== 'wc' && (
<p style={{ fontSize: 13, marginTop: 8, color: 'var(--text-tertiary)' }}>
Off-season or between matchdays World Cup props are running through July 19, 2026.
</p>
)}
</div>
)}
<div style={{ display: 'grid', gap: 16 }}>
{matches.map((m, idx) => {
const matchKey = `${m.home}-${m.away}-${idx}`;
const isOpen = expanded.has(matchKey);
return (
<article
key={matchKey}
className="surface diagonal-cut"
style={{
padding: 20,
border: '1px solid var(--border)',
background: 'var(--bg-surface)',
borderRadius: 8,
}}
>
<button
type="button"
onClick={() => {
const next = new Set(expanded);
if (next.has(matchKey)) next.delete(matchKey);
else next.add(matchKey);
setExpanded(next);
}}
style={{
width: '100%',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
background: 'transparent',
border: 0,
cursor: 'pointer',
color: 'inherit',
padding: 0,
textAlign: 'left',
}}
>
<div>
<div style={{ fontSize: 18, fontWeight: 700, letterSpacing: '-0.01em' }}>
{m.away} <span style={{ color: 'var(--text-tertiary)' }}>vs</span> {m.home}
</div>
<div className="mono" style={{ fontSize: 11, color: 'var(--text-tertiary)', marginTop: 4, letterSpacing: '0.06em' }}>
{formatTime(m.time)} · {m.propsByStatType.size} stat type(s)
</div>
</div>
<span style={{ color: 'var(--text-secondary)', fontSize: 14 }}>{isOpen ? '' : '+'}</span>
</button>
{isOpen && (
<div style={{ marginTop: 16, display: 'grid', gap: 12 }}>
{Array.from(m.propsByStatType.entries()).map(([statType, list]) => (
<section key={statType}>
<h3
className="mono"
style={{
fontSize: 11,
color: 'var(--grade-a)',
letterSpacing: '0.08em',
textTransform: 'uppercase',
marginBottom: 8,
}}
>
{STAT_LABELS[statType] || statType}
</h3>
<ul style={{ display: 'grid', gap: 6 }}>
{list.slice(0, 8).map((p, j) => (
<li
key={`${p.player}-${p.line}-${j}`}
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
padding: '8px 12px',
border: '1px solid var(--border)',
borderRadius: 6,
background: 'var(--bg-elevated)',
fontSize: 14,
}}
>
<span style={{ fontWeight: 600 }}>{p.player}</span>
<span className="mono" style={{ color: 'var(--text-secondary)', fontSize: 13 }}>
{p.line.toFixed(1)}
</span>
<button
type="button"
onClick={() => gradeProp(p.player, statType, p.line)}
disabled={scanning}
className="btn-ghost"
style={{
padding: '4px 12px',
fontSize: 12,
cursor: scanning ? 'not-allowed' : 'pointer',
opacity: scanning ? 0.6 : 1,
}}
>
{scanning ? '…' : 'Grade'}
</button>
</li>
))}
</ul>
</section>
))}
</div>
)}
</article>
);
})}
</div>
</div>
</main>
);
}