'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 }>(); 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 = { 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({ sport: 'Soccer', league: 'wc' }); const [data, setData] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [expanded, setExpanded] = useState>(new Set()); const [scanning, setScanning] = useState(false); const [scanResult, setScanResult] = useState(null); const [scanError, setScanError] = useState(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 & { 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 || 'Couldn’t 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 (

Soccer odds

Live odds across our launch leagues. Click any prop to grade it through the VYNDR engine.

setSelection(sel)} />
{data?.source && (

{data.updated_at ? `Updated ${formatTime(data.updated_at)} · ` : ''} source: {data.source} {typeof data.quota_remaining === 'number' ? ` · quota: ${data.quota_remaining}` : ''}

)} {loading && (
Loading odds…
)} {error && (
{error}
)} {scanError && (
{scanError}
)} {scanResult && ( )} {!loading && !error && matches.length === 0 && (
No live matches with props in this league right now. {league !== 'wc' && (

Off-season or between matchdays — World Cup props are running through July 19, 2026.

)}
)}
{matches.map((m, idx) => { const matchKey = `${m.home}-${m.away}-${idx}`; const isOpen = expanded.has(matchKey); return (
{isOpen && (
{Array.from(m.propsByStatType.entries()).map(([statType, list]) => (

{STAT_LABELS[statType] || statType}

    {list.slice(0, 8).map((p, j) => (
  • {p.player} {p.line.toFixed(1)}
  • ))}
))}
)}
); })}
); }