Session 8: Frontend Stripe cutover, soccer pages, sport selector, grade result cards, beta badge
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user