Session 24: Connect everything — Slate wired to all sources, copy fixed, nav fixed, startup prefetch, language button removed (1571 tests)

This commit is contained in:
Kev
2026-06-12 15:45:19 -04:00
parent 0538205fab
commit 433e827103
15 changed files with 586 additions and 99 deletions
+31
View File
@@ -0,0 +1,31 @@
'use client';
import { useEffect } from 'react';
import { useRouter } from 'next/navigation';
/**
* /account (Session 24).
*
* Paid users see "Account" in the nav instead of "Pricing". Rather than
* duplicate the subscription UI, this route forwards to /profile, which
* already renders the current plan, usage, founder pricing, and the
* cancel/manage-subscription controls. Keeping one canonical surface
* avoids two screens drifting out of sync.
*/
export default function AccountPage() {
const router = useRouter();
useEffect(() => {
router.replace('/profile');
}, [router]);
return (
<section style={{ minHeight: '60vh', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<p
className="mono"
style={{ color: 'var(--text-tertiary)', fontSize: 13, letterSpacing: '0.08em', textTransform: 'uppercase' }}
>
Opening your account
</p>
</section>
);
}
+37 -4
View File
@@ -24,6 +24,16 @@ interface Game {
injury_note?: string;
}
// Session 24 — shape returned by the free ESPN schedule endpoint
// (/api/schedule/:sport), used as a fallback when the odds slate is empty.
interface ScheduleApiGame {
id: string;
homeTeam?: { name?: string | null; abbreviation?: string | null };
awayTeam?: { name?: string | null; abbreviation?: string | null };
gameTime?: string | null;
status?: 'pre' | 'in' | 'post' | null;
}
interface TopGrade {
player: string;
stat: string;
@@ -88,9 +98,30 @@ export default function DashboardPage() {
Promise.all([
fetch(`/api/games/tonight?sport=${sport}`).then((r) => r.json()).catch(() => ({ games: [] })),
fetch(`/api/props/top-graded?sport=${sport}`).then((r) => r.json()).catch(() => ({ props: [] })),
]).then(([gamesData, gradesData]) => {
]).then(async ([gamesData, gradesData]) => {
if (cancelled) return;
setGames(Array.isArray(gamesData?.games) ? gamesData.games : []);
let list: Game[] = Array.isArray(gamesData?.games) ? gamesData.games : [];
// Session 24 — when the odds-backed slate is empty (off-day or
// odds-api quota exhausted), fall back to the FREE ESPN schedule so
// the dashboard still shows today's matchups instead of "NO SLATE".
if (list.length === 0) {
try {
const sched = await fetch(`/api/schedule/${sport.toLowerCase()}`).then((r) => r.json());
const schedGames = Array.isArray(sched?.games) ? sched.games : [];
list = schedGames.map((sg: ScheduleApiGame) => ({
id: sg.id,
away: sg.awayTeam?.name || sg.awayTeam?.abbreviation || 'Away',
home: sg.homeTeam?.name || sg.homeTeam?.abbreviation || 'Home',
start_time: sg.gameTime || '',
sport,
status: sg.status === 'in' ? 'live' : sg.status === 'post' ? 'final' : 'scheduled',
}));
} catch { /* schedule unavailable too — leave list empty */ }
}
if (cancelled) return;
setGames(list);
setTopGrades(Array.isArray(gradesData?.props) ? gradesData.props.slice(0, 10) : []);
});
@@ -255,7 +286,7 @@ export default function DashboardPage() {
</Section>
{/* Tonight's games */}
<Section title={`Tonight's ${sport} games`} subtitle={slateEmpty ? null : `${games?.length ?? 0} games tipping`}>
<Section title={`Today's ${sport} games`} subtitle={slateEmpty ? null : `${games?.length ?? 0} game${games?.length === 1 ? '' : 's'} today`}>
{games === null ? (
<SkeletonRow stacked />
) : games.length === 0 ? (
@@ -378,7 +409,9 @@ export default function DashboardPage() {
WELCOME TO THE LEDGER
</p>
<h3 style={{ fontSize: 20, fontWeight: 700, marginBottom: 8 }}>
Tonight&apos;s slate is loaded. {games?.length ?? 0} {games?.length === 1 ? 'game' : 'games'} across 3 sports.
{(games?.length ?? 0) > 0
? `Today's slate is loaded. ${games?.length} ${games?.length === 1 ? 'game' : 'games'} on the ${sport} board.`
: 'Your ledger starts here.'}
</h3>
<p style={{ color: 'var(--text-secondary)', fontSize: 14, marginBottom: 20 }}>
Pick a game and read your first prop it&apos;s on us.
+1 -1
View File
@@ -13,7 +13,7 @@ const FAQS = [
},
{
q: 'What sports do you cover?',
a: 'NBA, MLB, and WNBA at launch. NFL is targeted for September 2026. Each sport has its own calibrated weights and sport-specific factor models.',
a: 'NBA, MLB, WNBA, and soccer today. NFL is targeted for September 2026, with more sports rolling out through 2026. Each sport has its own calibrated weights and sport-specific factor models.',
},
{
q: 'Can I cancel anytime?',
+2 -2
View File
@@ -31,8 +31,8 @@ const FEATURES = [
},
{
icon: '◯',
title: 'Three sports, one engine',
body: 'NBA. MLB. WNBA. Unified intelligence layer with sport-specific calibration. NFL coming September 2026.',
title: 'Every sport, one engine',
body: 'NBA. MLB. WNBA. Soccer. NFL coming September 2026 — more rolling out through 2026. A unified intelligence layer with sport-specific calibration.',
},
{
icon: '⌦',
+101 -12
View File
@@ -34,6 +34,24 @@ const SPORT_ACCENT: Record<SlateSport, string> = {
soccer: '#00D4A0',
};
// Session 24 — game-level book-by-book lines from Tank01. One row per
// sportsbook (bet365 / betmgm / caesars …). All fields optional/null —
// books publish lines independently, so we render only what exists.
export interface GameLineBook {
homeML?: string | null;
awayML?: string | null;
total?: string | null;
homeSpread?: string | null;
awaySpread?: string | null;
}
export interface GameLines {
homeTeam?: string | null;
awayTeam?: string | null;
books: Record<string, GameLineBook>;
}
export type GameStatus = 'pre' | 'in' | 'post';
export interface GameCardProps {
sport: SlateSport;
homeTeam: string;
@@ -49,6 +67,19 @@ export interface GameCardProps {
onGrade: (prop: PropRowProp) => void;
onUpgrade?: () => void;
defaultVisible?: number; // how many props to show before "+ N more"
// Session 24 — all-day intelligence layers, all optional. A game card
// shows whatever exists: schedule status/score (ESPN), game lines
// (Tank01), and props (odds-api) — nothing replaces anything else.
status?: GameStatus;
score?: { home: number; away: number } | null;
gameLines?: GameLines | null;
}
// Map ESPN status → a compact, human badge. 'in' is live; 'post' is final.
function statusBadge(status?: GameStatus, score?: { home: number; away: number } | null) {
if (status === 'in') return { label: 'LIVE', color: '#FF4D4D', score };
if (status === 'post') return { label: 'FINAL', color: '#6B6B7B', score };
return null; // 'pre' or unknown → no badge; the tip-off time carries it
}
function formatTime(iso?: string) {
@@ -142,8 +173,11 @@ export default function GameCard(props: GameCardProps) {
props: propList, gradedProps, loadingKey, errorByKey,
tier = 'free', onGrade, onUpgrade,
defaultVisible = 4,
status, score, gameLines,
} = props;
const [expanded, setExpanded] = useState(false);
const badge = statusBadge(status, score);
const bookRows = gameLines?.books ? Object.entries(gameLines.books) : [];
// Session 19 — visibility budget now applies to PLAYERS, not raw
// props. Showing the first 4 prop rows that all belonged to the
@@ -244,21 +278,76 @@ export default function GameCard(props: GameCardProps) {
{[formatTime(gameTime), venue, context].filter(Boolean).join(' · ') || ' '}
</div>
</div>
<div
className="mono"
style={{
fontSize: 10,
color: 'var(--text-tertiary, #6B6B7B)',
background: 'rgba(255,255,255,0.04)',
padding: '4px 8px',
borderRadius: 999,
whiteSpace: 'nowrap',
}}
>
{propList.length} prop{propList.length === 1 ? '' : 's'}
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 6 }}>
{/* Session 24 — live/final status + score (ESPN schedule). */}
{badge && (
<span
className="mono"
style={{
fontSize: 10,
fontWeight: 800,
letterSpacing: '0.08em',
color: '#0A0A0F',
background: badge.color,
padding: '3px 8px',
borderRadius: 4,
whiteSpace: 'nowrap',
}}
>
{badge.label}{badge.score ? ` · ${badge.score.away}${badge.score.home}` : ''}
</span>
)}
<div
className="mono"
style={{
fontSize: 10,
color: 'var(--text-tertiary, #6B6B7B)',
background: 'rgba(255,255,255,0.04)',
padding: '4px 8px',
borderRadius: 999,
whiteSpace: 'nowrap',
}}
>
{propList.length} prop{propList.length === 1 ? '' : 's'}
</div>
</div>
</header>
{/* Session 24 — game lines strip (Tank01). Book-by-book moneyline,
spread, total. Renders only when lines exist; never blocks the
card. The brand edge is props, but lines give immediate action
even when props haven't been published. */}
{bookRows.length > 0 && (
<div
style={{
padding: '10px 16px',
borderTop: '1px solid var(--border, #1A1A24)',
display: 'grid',
gap: 6,
background: 'rgba(255,255,255,0.015)',
}}
>
<div
className="mono"
style={{ fontSize: 10, letterSpacing: '0.08em', color: 'var(--text-tertiary, #6B6B7B)', textTransform: 'uppercase' }}
>
Game Lines
</div>
{bookRows.map(([book, line]) => (
<div
key={book}
className="mono"
style={{ display: 'flex', gap: 12, fontSize: 11, color: 'var(--text-secondary, #8A8A9A)', flexWrap: 'wrap' }}
>
<span style={{ minWidth: 64, color: 'var(--text-0, #F0F0F5)', fontWeight: 700, textTransform: 'capitalize' }}>{book}</span>
{line.awayML && <span>{teamAbbr(awayTeam, sport)} {line.awayML}</span>}
{line.homeML && <span>{teamAbbr(homeTeam, sport)} {line.homeML}</span>}
{line.total && <span>O/U {line.total}</span>}
</div>
))}
</div>
)}
{propList.length === 0 ? (
<p
style={{
+2 -2
View File
@@ -36,7 +36,7 @@ export default function Hero() {
textTransform: 'uppercase',
}}
>
NBA · MLB · WNBA
EVERY SPORT · EVERY PROP
</span>
<h1
className="text-balance"
@@ -61,7 +61,7 @@ export default function Hero() {
maxWidth: 600,
}}
>
Grade your NBA, MLB, and WNBA props with intelligence the books don&apos;t want you to have.
Grade your props across every sport with intelligence the books don&apos;t want you to have.
Forty-plus factors. Kill conditions. Alt-line ladders. The honest ledger.
</p>
<SportBadgeStrip />
+1 -1
View File
@@ -80,7 +80,7 @@ export default function LivePropsStrip() {
letterSpacing: '0.08em',
}}
>
TONIGHT&apos;S GRADES LOAD AT 5 PM ET
LIVE GRADES APPEAR HERE AS BOOKS POST LINES
</p>
</section>
);
+11 -4
View File
@@ -5,7 +5,10 @@ import { usePathname } from 'next/navigation';
import { useAuth } from '@/contexts/AuthContext';
import Wordmark from '@/components/Wordmark';
import NotificationBell from '@/components/NotificationBell';
import LocaleSwitcher from '@/components/LocaleSwitcher';
// Session 24 — LocaleSwitcher removed from the nav. The i18n
// infrastructure (react-i18next, LocaleContext, useT) stays in place,
// but a visible language toggle with no translations behind it is
// worse than none. Re-add the switcher when translations land.
import { useT } from '@/contexts/LocaleContext';
export default function Nav() {
@@ -27,10 +30,16 @@ export default function Nav() {
// /dashboard IS the scan surface (click [Read] on any prop). The
// /scan page still exists as a fallback for custom props and is
// reachable from the slate's "Scan manually" empty-state CTA.
// Session 24 — paid users (analyst / desk) get "Account" where free
// users and signed-out visitors see "Pricing". A subscriber shouldn't
// be pitched a plan they already pay for.
const isPaid = !!user && tier !== 'free';
const NAV_LINKS = [
{ label: t('nav.tracker'), href: '/tracker' },
{ label: t('nav.ledger'), href: '/ledger' },
{ label: t('nav.pricing'), href: '/pricing' },
isPaid
? { label: 'Account', href: '/account' }
: { label: t('nav.pricing'), href: '/pricing' },
{ label: 'Blog', href: '/blog' },
];
@@ -124,7 +133,6 @@ export default function Nav() {
</span>
)}
<NotificationBell />
<LocaleSwitcher />
<button
onClick={() => setMenuOpen((o) => !o)}
aria-haspopup="menu"
@@ -198,7 +206,6 @@ export default function Nav() {
</div>
) : (
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<LocaleSwitcher />
<a href="/login" className="btn-primary" style={{ padding: '8px 16px', fontSize: 13 }}>
{t('nav.login')}
</a>
+186 -71
View File
@@ -2,7 +2,7 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useRouter } from 'next/navigation';
import GameCard, { SlateSport } from '@/components/GameCard';
import GameCard, { SlateSport, GameLines } from '@/components/GameCard';
import { PropRowProp, PropRowResult, propRowKey, Tier } from '@/components/PropRow';
import { useAuth } from '@/contexts/AuthContext';
// Session 23 — all-day intelligence layer. The stat filter is the
@@ -134,6 +134,98 @@ interface SlateGame {
venue?: string;
context?: string;
props: PropRowProp[];
// Session 24 — schedule + game-lines layers overlaid onto each game.
status?: 'pre' | 'in' | 'post';
score?: { home: number; away: number } | null;
gameLines?: GameLines | null;
}
// ---- Session 24: schedule + game-lines response shapes ----
interface ScheduleTeam { name?: string | null; abbreviation?: string | null }
interface ScheduleGame {
id?: string;
homeTeam?: ScheduleTeam;
awayTeam?: ScheduleTeam;
gameTime?: string | null;
status?: 'pre' | 'in' | 'post' | null;
score?: { home: number; away: number } | null;
venue?: string | null;
broadcast?: string | null;
}
interface ScheduleResponse { games?: ScheduleGame[] }
interface GameLinesResponse { games?: Record<string, GameLines> }
// Nickname token (last word) — the most stable cross-source identifier
// between ESPN full names and odds-api full names ("San Antonio Spurs"
// ↔ "spurs"). Falls back to the whole normalized string.
function nickToken(name?: string | null): string {
const w = String(name || '').trim().split(/\s+/);
const last = w[w.length - 1] || '';
return last.toLowerCase().replace(/[^a-z]/g, '');
}
// Match an odds-derived game to a schedule game by both nicknames.
function gamesMatch(scheduleHome: string, scheduleAway: string, oddsHome: string, oddsAway: string): boolean {
const sh = nickToken(scheduleHome), sa = nickToken(scheduleAway);
const oh = nickToken(oddsHome), oa = nickToken(oddsAway);
if (!sh || !sa || !oh || !oa) return false;
return (sh === oh && sa === oa) || (sh === oa && sa === oh);
}
// Find the Tank01 game-lines entry for a schedule game by team
// abbreviation (ESPN + Tank01 both use standard team abbreviations).
function findGameLines(home?: ScheduleTeam, away?: ScheduleTeam, lines?: Record<string, GameLines>): GameLines | null {
if (!lines) return null;
const h = (home?.abbreviation || '').toUpperCase();
const a = (away?.abbreviation || '').toUpperCase();
if (!h && !a) return null;
for (const entry of Object.values(lines)) {
const eh = String(entry.homeTeam || '').toUpperCase();
const ea = String(entry.awayTeam || '').toUpperCase();
if ((eh === h && ea === a) || (eh === a && ea === h)) return entry;
}
return null;
}
/**
* Session 24 — merge the three free/cheap layers into one game list.
* Schedule is the FOUNDATION (always shows from ESPN); odds props and
* Tank01 lines overlay onto matching games. Unmatched odds games are
* appended so we never drop props. When schedule is empty, the odds
* games become the base (odds-only fallback).
*/
function mergeSlate(
sport: SlateSport,
scheduleGames: ScheduleGame[],
oddsGames: SlateGame[],
lines?: Record<string, GameLines>,
): SlateGame[] {
const base: SlateGame[] = scheduleGames.map((sg) => ({
sport,
homeTeam: sg.homeTeam?.name || '',
awayTeam: sg.awayTeam?.name || '',
gameTime: sg.gameTime || undefined,
venue: sg.venue || undefined,
status: sg.status || undefined,
score: sg.score || undefined,
props: [],
gameLines: findGameLines(sg.homeTeam, sg.awayTeam, lines),
}));
const unmatched: SlateGame[] = [];
for (const og of oddsGames) {
const target = base.find((b) => gamesMatch(b.homeTeam, b.awayTeam, og.homeTeam, og.awayTeam));
if (target) target.props.push(...og.props);
else unmatched.push(og);
}
const merged = [...base, ...unmatched];
// Stable order: scheduled tip-off time, unknowns last.
return merged.sort((a, b) => {
const ta = a.gameTime ? Date.parse(a.gameTime) : Number.MAX_SAFE_INTEGER;
const tb = b.gameTime ? Date.parse(b.gameTime) : Number.MAX_SAFE_INTEGER;
return ta - tb;
});
}
function groupByGame(rawProps: RawProp[], sport: SlateSport): SlateGame[] {
@@ -194,7 +286,9 @@ export default function Slate({ initialTab = 'all', tier = 'free' }: SlateProps)
const [games, setGames] = useState<SlateGame[]>([]);
const [loading, setLoading] = useState(false);
const [fetchError, setFetchError] = useState<string | null>(null);
const [unsupportedSports, setUnsupportedSports] = useState<SlateSport[]>([]);
// Session 24 — when odds are unavailable but the schedule still has
// games, this becomes a soft inline notice instead of a wall-of-error.
const [oddsNotice, setOddsNotice] = useState(false);
// Grade state — Map keyed by propRowKey.
const [gradedProps, setGradedProps] = useState<Map<string, PropRowResult>>(() => new Map());
@@ -204,19 +298,24 @@ export default function Slate({ initialTab = 'all', tier = 'free' }: SlateProps)
// 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.
// Session 24 — fetch ALL free/cheap layers per sport in parallel:
// odds (odds-api props) · schedule (ESPN) · gamelines (Tank01)
// Schedule is the foundation — games render even when odds are
// empty/503. Odds + lines overlay on top. The slate is never empty
// just because one provider is down.
const fetchSlate = useCallback(async (active: SlateTab) => {
setLoading(true);
setFetchError(null);
setOddsNotice(false);
const sportsToFetch: Array<{ sport: SlateSport; urls: string[] }> = [];
const unsupported: SlateSport[] = [];
// Sports that carry a schedule/streaks feed (ESPN-backed). Soccer
// has no schedule endpoint, so it stays odds-only.
const SCHEDULE_SPORTS = new Set<SlateSport>(['nba', 'wnba', 'mlb']);
const sportsToFetch: 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 (FETCH_URLS[s] !== null) sportsToFetch.push(s as SlateSport);
};
if (active === 'all') {
consider('nba'); consider('wnba'); consider('mlb'); consider('soccer');
} else {
@@ -225,64 +324,66 @@ export default function Slate({ initialTab = 'all', tier = 'free' }: SlateProps)
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 };
})
.catch((err) => {
// Re-throw so allSettled catches it, but attach the
// sport so the per-sport error-tracking below can
// surface "Soccer odds unavailable" without blanking
// the rest of the slate.
const e = err instanceof Error ? err : new Error(String(err));
(e as Error & { _vyndrSport?: SlateSport })._vyndrSport = sport;
throw e;
})
),
),
const getJson = async <T,>(url: string): Promise<T | null> => {
try {
const r = await fetch(url, { cache: 'no-store' });
if (!r.ok) return null;
return (await r.json()) as T;
} catch {
return null;
}
};
// Per sport: odds + schedule + gamelines, all settled independently.
const perSport = await Promise.all(
sportsToFetch.map(async (sport) => {
const oddsUrls = FETCH_URLS[sport] as string[];
const [oddsResults, schedule, lines] = await Promise.all([
Promise.all(oddsUrls.map((u) => getJson<OddsResponse>(u))),
SCHEDULE_SPORTS.has(sport) ? getJson<ScheduleResponse>(`/api/schedule/${sport}`) : Promise.resolve(null),
SCHEDULE_SPORTS.has(sport) ? getJson<GameLinesResponse>(`/api/gamelines/${sport}`) : Promise.resolve(null),
]);
const oddsOk = oddsResults.some((o) => o !== null);
const oddsProps = oddsResults.flatMap((o) => o?.props || []);
const oddsGames = groupByGame(oddsProps, sport);
const scheduleGames = schedule?.games || [];
const merged = mergeSlate(sport, scheduleGames, oddsGames, lines?.games);
return { sport, merged, oddsOk, hadSchedule: scheduleGames.length > 0 };
}),
);
const allGames: SlateGame[] = [];
const failedSports: SlateSport[] = [];
const sportsAttempted = new Set<SlateSport>(sportsToFetch.map((s) => s.sport));
const sportsThatSucceeded = new Set<SlateSport>();
for (const r of results) {
if (r.status === 'fulfilled') {
sportsThatSucceeded.add(r.value.sport);
const grouped = groupByGame(r.value.body.props || [], r.value.sport);
allGames.push(...grouped);
} else {
const failed = (r.reason as Error & { _vyndrSport?: SlateSport })._vyndrSport;
if (failed && !failedSports.includes(failed)) failedSports.push(failed);
}
let anyOddsOk = false;
let anyScheduleShown = false;
for (const s of perSport) {
allGames.push(...s.merged);
if (s.oddsOk) anyOddsOk = true;
if (s.hadSchedule) anyScheduleShown = true;
}
setGames(allGames);
setUnsupportedSports([...unsupported, ...failedSports.filter((s) => !sportsThatSucceeded.has(s))]);
// Session 17 — only surface a top-level error when EVERY sport
// attempted in this tab failed. Partial successes (NBA ok,
// soccer 503) silently drop the failed sport's row and surface
// it via the existing "endpoint not configured" footer note.
if (sportsAttempted.size > 0 && sportsThatSucceeded.size === 0) {
const firstError = results.find((r) => r.status === 'rejected') as PromiseRejectedResult | undefined;
setFetchError(firstError ? (firstError.reason as Error).message : 'Odds fetch failed');
// Odds down but schedule carried the slate → soft notice, not a wall.
if (!anyOddsOk && anyScheduleShown) setOddsNotice(true);
// Genuine total failure (no odds, no schedule, anywhere) → error.
if (!anyOddsOk && !anyScheduleShown && allGames.length === 0) {
setFetchError('No games available right now. Check back soon.');
}
setLoading(false);
}, []);
useEffect(() => { fetchSlate(tab); }, [tab, fetchSlate]);
// Session 24 — switching sport resets the stat filter. The categories
// differ per sport (Points vs Hits), so a stale "points" filter would
// silently blank the MLB panels. Always land back on 'all'.
useEffect(() => { setActiveStat('all'); }, [tab]);
// 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) => {
@@ -426,13 +527,17 @@ export default function Slate({ initialTab = 'all', tier = 'free' }: SlateProps)
);
})}
</div>
{/* Session 23 — stat filter pills, below the sport tabs and above
all content. Narrows the streaks + hot list panels. */}
<StatFilterPills
sport={tab === 'all' ? 'nba' : tab}
activeStat={activeStat}
onChange={setActiveStat}
/>
{/* Session 23/24 — stat filter pills, below the sport tabs and
above all content. Sport-specific categories. Hidden on the
ALL tab: filtering by "points" makes no sense when the slate
mixes NBA + MLB + soccer. Pills appear only on a single sport. */}
{tab !== 'all' && (
<StatFilterPills
sport={tab}
activeStat={activeStat}
onChange={setActiveStat}
/>
)}
</div>
{/* Body */}
@@ -467,6 +572,24 @@ export default function Slate({ initialTab = 'all', tier = 'free' }: SlateProps)
</div>
)}
{/* Session 24 — soft notice when props are loading but the schedule
(and lines) carry the slate. NOT a wall-of-error: the games are
right below it. */}
{oddsNotice && !loading && !fetchError && (
<div
style={{
padding: '10px 14px',
border: '1px solid var(--border, #1A1A24)',
background: 'rgba(255,255,255,0.02)',
color: 'var(--text-secondary, #8A8A9A)',
borderRadius: 6,
fontSize: 13,
}}
>
Player props are loading today&apos;s schedule, game lines, and stats are shown below.
</div>
)}
{fetchError && !loading && (
<div
role="alert"
@@ -532,6 +655,9 @@ export default function Slate({ initialTab = 'all', tier = 'free' }: SlateProps)
venue={g.venue}
context={g.context}
props={g.props}
status={g.status}
score={g.score}
gameLines={g.gameLines}
gradedProps={gradedProps}
loadingKey={gradingKey}
errorByKey={errorByKey}
@@ -548,20 +674,9 @@ export default function Slate({ initialTab = 'all', tier = 'free' }: SlateProps)
<StreaksPanel sport={tab === 'all' ? 'nba' : tab} tier={tier} stat={activeStat} />
<HotListPanel sport={tab === 'all' ? 'mlb' : tab} tier={tier} stat={activeStat} />
{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>
)}
{/* Session 24 — removed the developer-facing "odds endpoint not
configured yet" footer note. A sport with no data simply doesn't
render a row; users never see internal wiring state. */}
</div>
);
}