415 lines
14 KiB
TypeScript
415 lines
14 KiB
TypeScript
'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 || '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 (
|
||
<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>
|
||
);
|
||
}
|