Session 13: The Slate, Africa geo-restriction, OAuth providers, PropRow + GameCard (1311 tests)

This commit is contained in:
Kev
2026-06-11 03:48:07 -04:00
parent d957dee17b
commit 10159209fa
18 changed files with 1452 additions and 64 deletions
+438
View File
@@ -0,0 +1,438 @@
'use client';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useRouter } from 'next/navigation';
import GameCard, { SlateSport } from '@/components/GameCard';
import { PropRowProp, PropRowResult, propRowKey, Tier } from '@/components/PropRow';
import { useAuth } from '@/contexts/AuthContext';
/**
* The Slate (Session 13).
*
* Browse-first dashboard surface. Fetches today's odds across the
* selected sport(s), groups by game, hands off to GameCard. Owns the
* graded-prop Map and the in-flight grading key so PropRow loading
* states are accurate.
*
* Backend contract:
* /api/odds/nba — NBA props (existing proxy)
* /api/odds/soccer/:league — soccer per league (existing proxy)
* /api/odds/mlb — MLB props (may not exist yet —
* we surface a friendly "coming soon"
* if the endpoint 404s)
* /api/scan — submits a grade request (existing)
*
* State minimalism: one Map for graded props, one nullable loading
* key, one error-by-key map. The Slate component is the only writer.
*/
type SlateTab = 'all' | 'nba' | 'wnba' | 'mlb' | 'soccer';
const TABS: Array<{ id: SlateTab; label: string }> = [
{ id: 'all', label: 'All' },
{ id: 'nba', label: 'NBA' },
{ id: 'wnba', label: 'WNBA' },
{ id: 'mlb', label: 'MLB' },
{ id: 'soccer', label: 'Soccer' },
];
// Per-tab → list of fetch URLs. `null` indicates "no endpoint yet";
// the Slate renders a soft "coming soon" badge for that sport rather
// than 404-spamming the backend.
const FETCH_URLS: Record<Exclude<SlateTab, 'all'>, string[] | null> = {
nba: ['/api/odds/nba'],
wnba: null, // No /api/odds/wnba proxy yet.
mlb: null, // No /api/odds/mlb proxy yet.
soccer: ['/api/odds/soccer/wc'],
};
interface RawProp {
player?: string;
stat_type?: string;
line?: number;
direction?: 'over' | 'under';
book?: string;
game_time?: string;
home_team?: string;
away_team?: string;
}
interface OddsResponse {
sport?: string;
props?: RawProp[];
error?: string;
}
interface SlateGame {
sport: SlateSport;
homeTeam: string;
awayTeam: string;
gameTime?: string;
venue?: string;
context?: string;
props: PropRowProp[];
}
function groupByGame(rawProps: RawProp[], sport: SlateSport): SlateGame[] {
const games = new Map<string, SlateGame>();
for (const r of rawProps) {
if (!r.player || !r.stat_type || r.line == null) continue;
const home = r.home_team || '?';
const away = r.away_team || '?';
const time = r.game_time || '';
const key = `${away}__${home}__${time}`;
if (!games.has(key)) {
games.set(key, {
sport,
homeTeam: home,
awayTeam: away,
gameTime: time || undefined,
props: [],
});
}
games.get(key)!.props.push({
player: r.player,
stat_type: r.stat_type,
line: Number(r.line),
direction: (r.direction as PropRowProp['direction']) || 'over',
book: r.book,
});
}
// Sort each game's props by player + stat for stable rendering.
for (const g of games.values()) {
g.props.sort((a, b) => {
if (a.player !== b.player) return a.player.localeCompare(b.player);
return a.stat_type.localeCompare(b.stat_type);
});
}
return Array.from(games.values()).sort((a, b) => {
const ta = a.gameTime ? Date.parse(a.gameTime) : 0;
const tb = b.gameTime ? Date.parse(b.gameTime) : 0;
return ta - tb;
});
}
export interface SlateProps {
initialTab?: SlateTab;
tier?: Tier;
}
export default function Slate({ initialTab = 'all', tier = 'free' }: SlateProps) {
const router = useRouter();
const { session } = useAuth();
const [tab, setTab] = useState<SlateTab>(initialTab);
const [games, setGames] = useState<SlateGame[]>([]);
const [loading, setLoading] = useState(false);
const [fetchError, setFetchError] = useState<string | null>(null);
const [unsupportedSports, setUnsupportedSports] = useState<SlateSport[]>([]);
// Grade state — Map keyed by propRowKey.
const [gradedProps, setGradedProps] = useState<Map<string, PropRowResult>>(() => new Map());
const [gradingKey, setGradingKey] = useState<string | null>(null);
const [errorByKey, setErrorByKey] = useState<Record<string, string | undefined>>({});
// Search filter (Phase 3.4 — kept here so the Slate owns its own filtering).
const [searchQuery, setSearchQuery] = useState('');
// Fetch + group. Promise.allSettled so one sport failing doesn't blank the slate.
const fetchSlate = useCallback(async (active: SlateTab) => {
setLoading(true);
setFetchError(null);
const sportsToFetch: Array<{ sport: SlateSport; urls: string[] }> = [];
const unsupported: SlateSport[] = [];
const consider = (s: Exclude<SlateTab, 'all'>) => {
const urls = FETCH_URLS[s];
if (urls === null) unsupported.push(s as SlateSport);
else sportsToFetch.push({ sport: s as SlateSport, urls });
};
if (active === 'all') {
consider('nba'); consider('wnba'); consider('mlb'); consider('soccer');
} else {
consider(active);
}
if (sportsToFetch.length === 0) {
setGames([]);
setUnsupportedSports(unsupported);
setLoading(false);
return;
}
const results = await Promise.allSettled(
sportsToFetch.flatMap(({ sport, urls }) =>
urls.map((url) =>
fetch(url, { cache: 'no-store' })
.then(async (r) => {
const body = (await r.json().catch(() => ({}))) as OddsResponse;
if (!r.ok) throw new Error(body?.error || `HTTP ${r.status}`);
return { sport, body };
})
),
),
);
const allGames: SlateGame[] = [];
let firstError: string | null = null;
for (const r of results) {
if (r.status === 'fulfilled') {
const grouped = groupByGame(r.value.body.props || [], r.value.sport);
allGames.push(...grouped);
} else if (!firstError) {
firstError = r.reason instanceof Error ? r.reason.message : 'Odds fetch failed';
}
}
setGames(allGames);
setUnsupportedSports(unsupported);
if (allGames.length === 0 && firstError) setFetchError(firstError);
setLoading(false);
}, []);
useEffect(() => { fetchSlate(tab); }, [tab, fetchSlate]);
// Grading call site. Single source of truth so we never have two
// PropRows in-flight from the same prop (the loadingKey enforces it).
const onGrade = useCallback(async (prop: PropRowProp) => {
const key = propRowKey(prop);
if (gradingKey) return; // already a grade in flight — defer
setGradingKey(key);
setErrorByKey((prev) => ({ ...prev, [key]: undefined }));
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: 'NBA', // overwritten below per game card sport
player: prop.player,
stat: prop.stat_type,
line: prop.line,
direction: prop.direction,
book: prop.book || 'draftkings',
}),
});
const body = (await res.json().catch(() => ({}))) as Record<string, unknown> & { error?: string };
if (!res.ok) {
setErrorByKey((prev) => ({ ...prev, [key]: body.error || `HTTP ${res.status}` }));
return;
}
const result: PropRowResult = {
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 PropRowResult['reasoning']) || undefined,
kill_conditions_triggered: (body.kill_conditions_triggered as PropRowResult['kill_conditions_triggered']) || [],
tier_gated: !!body.tier_gated,
upgrade_hint: typeof body.upgrade_hint === 'string' ? body.upgrade_hint : undefined,
};
setGradedProps((prev) => {
const next = new Map(prev);
next.set(key, result);
return next;
});
} catch {
setErrorByKey((prev) => ({ ...prev, [key]: 'Network error. Try again.' }));
} finally {
setGradingKey(null);
}
}, [gradingKey, session]);
const onUpgrade = useCallback(() => router.push('/pricing'), [router]);
// Filter pipeline — searchQuery applied to games + props.
const filteredGames = useMemo(() => {
if (!searchQuery.trim()) return games;
const q = searchQuery.toLowerCase();
return games
.map((g) => {
const homeMatch = g.homeTeam.toLowerCase().includes(q);
const awayMatch = g.awayTeam.toLowerCase().includes(q);
if (homeMatch || awayMatch) return g;
const matchedProps = g.props.filter(
(p) => p.player.toLowerCase().includes(q) || p.stat_type.toLowerCase().includes(q),
);
if (matchedProps.length === 0) return null;
return { ...g, props: matchedProps };
})
.filter((g): g is SlateGame => g !== null);
}, [games, searchQuery]);
// Manual scan fallback URL — pre-fills /scan with the search query
// so the user lands on a partially-filled form instead of empty.
const manualScanHref = `/scan?q=${encodeURIComponent(searchQuery)}`;
return (
<div style={{ display: 'grid', gap: 24, paddingBottom: 24 }}>
{/* Sticky header — search + tabs */}
<div
style={{
position: 'sticky',
top: 64, // matches Nav height
zIndex: 5,
background: 'var(--bg-0, #0A0A0F)',
paddingTop: 12,
paddingBottom: 12,
}}
>
<input
type="search"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search teams, players, stat types…"
aria-label="Filter the slate"
style={{
width: '100%',
padding: '10px 14px',
background: 'var(--bg-2, #12121A)',
border: '1px solid var(--border, #1A1A24)',
borderRadius: 6,
color: 'var(--text-0, #F0F0F5)',
fontSize: 14,
marginBottom: 12,
}}
/>
<div
role="tablist"
aria-label="Sport"
style={{
display: 'flex',
gap: 6,
overflowX: 'auto',
paddingBottom: 2,
WebkitOverflowScrolling: 'touch',
}}
>
{TABS.map((t) => {
const active = t.id === tab;
return (
<button
key={t.id}
type="button"
role="tab"
aria-selected={active}
onClick={() => setTab(t.id)}
className="mono"
style={{
flexShrink: 0,
padding: '6px 14px',
fontSize: 11,
fontWeight: 700,
letterSpacing: '0.08em',
textTransform: 'uppercase',
border: active ? '1px solid var(--grade-a, #00D4A0)' : '1px solid var(--border, #1A1A24)',
background: active ? 'var(--grade-a, #00D4A0)' : 'transparent',
color: active ? 'var(--bg-0, #0A0A0F)' : 'var(--text-secondary, #8A8A9A)',
borderRadius: 4,
cursor: 'pointer',
}}
>
{t.label}
</button>
);
})}
</div>
</div>
{/* Body */}
{loading && (
<div style={{ padding: 40, textAlign: 'center', color: 'var(--text-tertiary, #6B6B7B)' }}>
Loading the slate
</div>
)}
{fetchError && !loading && (
<div
role="alert"
style={{
padding: 14,
border: '1px solid var(--grade-d, #FF6B6B)',
color: 'var(--grade-d, #FF6B6B)',
borderRadius: 6,
fontSize: 13,
}}
>
{fetchError}
</div>
)}
{!loading && !fetchError && filteredGames.length === 0 && (
<div
className="surface"
style={{
padding: 28,
border: '1px solid var(--border, #1A1A24)',
borderRadius: 8,
textAlign: 'center',
color: 'var(--text-secondary, #8A8A9A)',
}}
>
{searchQuery ? (
<>
<p style={{ marginBottom: 12 }}>
No props found for &ldquo;{searchQuery}&rdquo;.
</p>
<a
href={manualScanHref}
className="btn-primary"
style={{
display: 'inline-block',
padding: '8px 16px',
background: 'var(--grade-a, #00D4A0)',
color: 'var(--bg-0, #0A0A0F)',
borderRadius: 4,
textDecoration: 'none',
fontSize: 13,
fontWeight: 700,
}}
>
Scan it manually
</a>
</>
) : (
<p>No games published yet today. Check back closer to first pitch / tip-off / kickoff.</p>
)}
</div>
)}
<div style={{ display: 'grid', gap: 16 }}>
{filteredGames.map((g, i) => (
<GameCard
key={`${g.sport}-${g.homeTeam}-${g.awayTeam}-${i}`}
sport={g.sport}
homeTeam={g.homeTeam}
awayTeam={g.awayTeam}
gameTime={g.gameTime}
venue={g.venue}
context={g.context}
props={g.props}
gradedProps={gradedProps}
loadingKey={gradingKey}
errorByKey={errorByKey}
tier={tier}
onGrade={(p) => onGrade({ ...p })}
onUpgrade={onUpgrade}
/>
))}
</div>
{unsupportedSports.length > 0 && !loading && (
<p
className="mono"
style={{
fontSize: 11,
color: 'var(--text-tertiary, #6B6B7B)',
letterSpacing: '0.06em',
textTransform: 'uppercase',
textAlign: 'center',
}}
>
{unsupportedSports.map((s) => s.toUpperCase()).join(', ')} odds endpoint not configured yet.
</p>
)}
</div>
);
}