Files
vyndr/web/src/components/GameCard.tsx
T

412 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client';
import { useState } from 'react';
import type { PropRowProp, PropRowResult, Tier } from '@/components/PropRow';
// Session 19 — PlayerCard groups props by player so a single player
// with 4 props renders as ONE card with their headshot + 4 stat
// lines, instead of 4 independent stripes that repeat the name.
import PlayerCard, { groupPropsByPlayer } from '@/components/PlayerCard';
/**
* GameCard — one game in the Slate (Session 13). Header with teams +
* time + venue + sport emoji; expandable list of player props
* underneath, each a PropRow.
*
* State minimalism: this component only manages "show more props"
* expansion. The graded-props Map and the "is this prop loading right
* now" boolean both live on the Slate (one source of truth for the
* grading queue).
*/
export type SlateSport = 'nba' | 'wnba' | 'mlb' | 'soccer';
const SPORT_EMOJI: Record<SlateSport, string> = {
nba: '🏀',
wnba: '🏀',
mlb: '⚾',
soccer: '⚽',
};
const SPORT_ACCENT: Record<SlateSport, string> = {
nba: '#E94B3C',
wnba: '#FFB347',
mlb: '#1E90FF',
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;
awayTeam: string;
gameTime?: string; // ISO timestamp — empty when status is unknown
venue?: string;
context?: string; // 'Group A · Matchday 1', 'Game 4', etc.
props: PropRowProp[];
gradedProps: Map<string, PropRowResult>;
loadingKey?: string | null; // propRowKey of the prop currently grading
errorByKey?: Record<string, string | undefined>;
tier?: Tier;
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) {
if (!iso) return '';
try {
const d = new Date(iso);
return d.toLocaleString(undefined, {
weekday: 'short', month: 'short', day: 'numeric',
hour: 'numeric', minute: '2-digit',
});
} catch {
return iso;
}
}
/**
* Session 19 — produce an ESPN/DK-style team abbreviation from a
* full team name. The odds-api returns full names like
* "Phoenix Mercury" / "Boston Celtics"; the design calls for the
* compact city/club shorthand in the header. We hand-curate the few
* names that don't reduce well from a "take the first word"
* heuristic (St. Louis, Los Angeles, etc.) and otherwise fall back
* to the first three letters of the second word (so "Boston
* Celtics" → "BOS" and "Real Madrid" → "RM"). Soccer clubs are
* shorter and rarely follow city naming, so we just upper-case the
* full name up to 6 chars.
*/
const TEAM_ABBR_OVERRIDES: Record<string, string> = {
'Los Angeles Lakers': 'LAL',
'Los Angeles Clippers': 'LAC',
'Los Angeles Sparks': 'LAS',
'Los Angeles Angels': 'LAA',
'Los Angeles Dodgers': 'LAD',
'San Francisco Giants': 'SF',
'San Diego Padres': 'SD',
'St. Louis Cardinals': 'STL',
'New York Knicks': 'NYK',
'New York Yankees': 'NYY',
'New York Mets': 'NYM',
'New York Liberty': 'NYL',
'New Orleans Pelicans': 'NOP',
'Oklahoma City Thunder': 'OKC',
'Golden State Warriors': 'GSW',
'Portland Trail Blazers': 'POR',
'San Antonio Spurs': 'SA',
'Las Vegas Aces': 'LV',
'Washington Mystics': 'WAS',
'Washington Wizards': 'WAS',
'Washington Nationals': 'WSH',
'Tampa Bay Rays': 'TB',
'Kansas City Royals': 'KC',
'Toronto Blue Jays': 'TOR',
'Toronto Raptors': 'TOR',
'Chicago White Sox': 'CWS',
'Chicago Cubs': 'CHC',
};
export function teamAbbr(fullName: string, sport: SlateSport): string {
if (!fullName) return '';
if (TEAM_ABBR_OVERRIDES[fullName]) return TEAM_ABBR_OVERRIDES[fullName];
if (sport === 'soccer') {
// Clubs are typically one short name (Arsenal, Liverpool) or two
// ("Real Madrid"). Compose initials when there are 2+ words,
// otherwise truncate.
const words = fullName.trim().split(/\s+/);
if (words.length >= 2) return words.map((w) => w[0]).join('').toUpperCase().slice(0, 4);
return fullName.slice(0, 6).toUpperCase();
}
// US team-name pattern: "<City> <Nickname>" — take the nickname's
// first three letters (Boston Celtics → CEL → bumped to BOS via
// the override table for the well-known cases). For names we
// didn't override, the city-prefix usually carries more identity.
const words = fullName.trim().split(/\s+/);
if (words.length >= 2) {
// Two-word names: prefer the city (first word) → first 3 letters.
return words[0].slice(0, 3).toUpperCase();
}
return fullName.slice(0, 4).toUpperCase();
}
const SPORT_LABEL: Record<SlateSport, string> = {
nba: 'NBA',
wnba: 'WNBA',
mlb: 'MLB',
soccer: 'SOCCER',
};
export default function GameCard(props: GameCardProps) {
const {
sport, homeTeam, awayTeam, gameTime, venue, context,
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
// same player meant the user only saw one player's card before
// hitting "+ more." Grouping by player first lines up the budget
// with how the UI actually reads.
const playerGroups = groupPropsByPlayer(propList);
// Stable alphabetical ordering — matches Slate's groupByGame sort.
playerGroups.sort((a, b) => a.player.localeCompare(b.player));
const visiblePlayerGroups = expanded ? playerGroups : playerGroups.slice(0, defaultVisible);
const hiddenPlayerCount = playerGroups.length - visiblePlayerGroups.length;
const accent = SPORT_ACCENT[sport];
return (
<article
className="surface"
style={{
background: 'var(--bg-2, #12121A)',
border: '1px solid var(--border, #1A1A24)',
borderRadius: 8,
overflow: 'hidden',
}}
>
<header
style={{
padding: '14px 16px',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'flex-start',
gap: 12,
borderLeft: `3px solid ${accent}`,
}}
>
<div style={{ minWidth: 0, flex: 1 }}>
{/* Session 19 — team-abbreviation header: bold abbreviations
with the full names underneath in the meta line, plus a
sport-color badge to the right. ESPN/DK pattern. */}
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 10,
fontSize: 16,
fontWeight: 700,
color: 'var(--text-0, #F0F0F5)',
letterSpacing: '-0.01em',
}}
>
<span aria-hidden style={{ fontSize: 14 }}>{SPORT_EMOJI[sport]}</span>
<span className="mono" style={{ letterSpacing: '0.04em' }}>
{teamAbbr(awayTeam, sport)}
</span>
<span style={{ color: 'var(--text-tertiary, #6B6B7B)', fontWeight: 400, fontSize: 13 }}>
vs
</span>
<span className="mono" style={{ letterSpacing: '0.04em' }}>
{teamAbbr(homeTeam, sport)}
</span>
<span
className="mono"
style={{
marginLeft: 'auto',
fontSize: 10,
fontWeight: 700,
color: '#0A0A0F',
background: accent,
padding: '3px 8px',
borderRadius: 4,
letterSpacing: '0.08em',
}}
aria-label={`Sport: ${SPORT_LABEL[sport]}`}
>
{SPORT_LABEL[sport]}
</span>
</div>
<div
style={{
marginTop: 4,
fontSize: 12,
color: 'var(--text-secondary, #8A8A9A)',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
}}
>
{awayTeam} <span style={{ color: 'var(--text-tertiary, #6B6B7B)' }}>@</span> {homeTeam}
</div>
<div
className="mono"
style={{
marginTop: 4,
fontSize: 11,
color: 'var(--text-tertiary, #6B6B7B)',
letterSpacing: '0.06em',
textTransform: 'uppercase',
}}
>
{[formatTime(gameTime), venue, context].filter(Boolean).join(' · ') || ' '}
</div>
</div>
<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={{
padding: '20px 16px',
color: 'var(--text-tertiary, #6B6B7B)',
fontSize: 13,
textAlign: 'center',
}}
>
Props for this game aren&apos;t published yet.
</p>
) : (
<div>
{visiblePlayerGroups.map((g) => (
<PlayerCard
key={g.player}
player={g.player}
sport={sport}
// Per-player team would require a roster lookup that
// the current odds endpoints don't expose. Leave team
// empty — the game card header already shows both
// teams, so the user has the context.
team={undefined}
props={g.props}
gradedProps={gradedProps}
loadingKey={loadingKey}
errorByKey={errorByKey}
tier={tier}
onGrade={onGrade}
onUpgrade={onUpgrade}
/>
))}
{hiddenPlayerCount > 0 && (
<div
style={{
borderTop: '1px solid var(--border, #1A1A24)',
padding: '10px 16px',
textAlign: 'center',
}}
>
<button
type="button"
onClick={() => setExpanded(true)}
style={{
background: 'transparent',
border: 0,
cursor: 'pointer',
color: 'var(--grade-a, #00D4A0)',
fontSize: 12,
fontWeight: 600,
}}
>
+ {hiddenPlayerCount} more player{hiddenPlayerCount === 1 ? '' : 's'}
</button>
</div>
)}
</div>
)}
</article>
);
}