Session 8: Frontend Stripe cutover, soccer pages, sport selector, grade result cards, beta badge

This commit is contained in:
Kev
2026-06-10 15:34:23 -04:00
parent ad5ea8d5a8
commit 4db1c1c539
15 changed files with 1583 additions and 161 deletions
+190
View File
@@ -0,0 +1,190 @@
'use client';
import { useState, useEffect } from 'react';
/**
* SportSelector — pill tabs for the four launch verticals.
*
* Soccer reveals a secondary league pill row (WC default for the
* tournament launch; EPL/La Liga/etc available year-round). The
* selected `{ sport, league }` is emitted via `onChange` so the
* parent owns the actual scan/odds state and can refetch on switch.
*
* The component is intentionally pure-UI — no fetches, no auth, no
* persistence. A parent that wants the selection to stick should
* pass `initialSport` / `initialLeague` from URL params or
* localStorage.
*/
export type Sport = 'NBA' | 'WNBA' | 'MLB' | 'Soccer';
// Soccer league codes match the GET /api/odds/soccer/:league path
// segment AND the `SOCCER_LEAGUES` env on the backend. Source of truth
// is `src/services/oddsService.js SOCCER_SPORT_KEYS`.
export type SoccerLeague =
| 'wc'
| 'epl'
| 'laliga'
| 'bundesliga'
| 'seriea'
| 'ligue1'
| 'ucl'
| 'mls'
| 'ligamx';
export interface SportSelection {
sport: Sport;
league?: SoccerLeague;
}
const SPORTS: Array<{ id: Sport; label: string; status?: 'live' | 'beta' }> = [
{ id: 'NBA', label: 'NBA', status: 'live' },
{ id: 'WNBA', label: 'WNBA', status: 'live' },
{ id: 'MLB', label: 'MLB', status: 'live' },
{ id: 'Soccer', label: 'Soccer', status: 'beta' },
];
const SOCCER_LEAGUES: Array<{ id: SoccerLeague; label: string; sub?: string }> = [
{ id: 'wc', label: 'World Cup', sub: '2026' },
{ id: 'epl', label: 'EPL' },
{ id: 'laliga', label: 'La Liga' },
{ id: 'bundesliga', label: 'Bundesliga' },
{ id: 'seriea', label: 'Serie A' },
{ id: 'ligue1', label: 'Ligue 1' },
{ id: 'ucl', label: 'UCL' },
{ id: 'mls', label: 'MLS' },
{ id: 'ligamx', label: 'Liga MX' },
];
interface Props {
initialSport?: Sport;
initialLeague?: SoccerLeague;
onChange?: (selection: SportSelection) => void;
}
export default function SportSelector({
initialSport = 'NBA',
initialLeague = 'wc',
onChange,
}: Props) {
const [sport, setSport] = useState<Sport>(initialSport);
const [league, setLeague] = useState<SoccerLeague>(initialLeague);
// Emit on every change so parents stay in sync. Effect (not inline
// in setSport) so React batches both pieces of state correctly.
useEffect(() => {
if (onChange) {
onChange(sport === 'Soccer' ? { sport, league } : { sport });
}
}, [sport, league, onChange]);
function selectSport(next: Sport) {
setSport(next);
}
return (
<div data-testid="sport-selector" style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<div
role="tablist"
aria-label="Sport"
style={{
display: 'flex',
flexWrap: 'wrap',
gap: 8,
}}
>
{SPORTS.map((s) => {
const active = sport === s.id;
return (
<button
key={s.id}
type="button"
role="tab"
aria-selected={active}
onClick={() => selectSport(s.id)}
className="mono"
style={{
padding: '8px 16px',
fontSize: 13,
fontWeight: 700,
letterSpacing: '0.04em',
textTransform: 'uppercase',
border: active ? '1px solid var(--grade-a)' : '1px solid var(--border)',
background: active ? 'var(--grade-a)' : 'transparent',
color: active ? 'var(--bg-primary)' : 'var(--text-primary)',
cursor: 'pointer',
borderRadius: 6,
position: 'relative',
transition: 'all 0.15s ease',
}}
>
{s.label}
{s.status === 'beta' && (
<span
style={{
marginLeft: 6,
fontSize: 9,
padding: '1px 4px',
background: active ? 'var(--bg-primary)' : 'var(--grade-a)',
color: active ? 'var(--grade-a)' : 'var(--bg-primary)',
borderRadius: 3,
verticalAlign: 'middle',
}}
>
BETA
</span>
)}
</button>
);
})}
</div>
{sport === 'Soccer' && (
<div
role="tablist"
aria-label="Soccer league"
style={{
display: 'flex',
flexWrap: 'wrap',
gap: 6,
padding: '8px 0 4px',
borderTop: '1px solid var(--border)',
}}
>
{SOCCER_LEAGUES.map((l) => {
const active = league === l.id;
return (
<button
key={l.id}
type="button"
role="tab"
aria-selected={active}
onClick={() => setLeague(l.id)}
style={{
padding: '6px 10px',
fontSize: 12,
fontWeight: 600,
border: active ? '1px solid var(--grade-a)' : '1px solid var(--border)',
background: active ? 'var(--bg-elevated)' : 'transparent',
color: active ? 'var(--grade-a)' : 'var(--text-secondary)',
cursor: 'pointer',
borderRadius: 4,
display: 'inline-flex',
alignItems: 'baseline',
gap: 4,
}}
>
<span>{l.label}</span>
{l.sub && (
<span className="mono" style={{ fontSize: 10, opacity: 0.6 }}>
{l.sub}
</span>
)}
</button>
);
})}
</div>
)}
</div>
);
}