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

471 lines
17 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';
// Session 25 — a computed streak for a player whose team is in this game.
export interface GameStreak {
player: string;
team?: string | null;
description: string;
currentStreak: number;
}
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;
// Session 25 — streaks for players in THIS game, matched by team in the
// Slate. Renders inline below the props/lines so the streak context
// lives with the game it belongs to.
streaks?: GameStreak[];
}
// 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, streaks,
} = props;
const [expanded, setExpanded] = useState(false);
const badge = statusBadge(status, score);
const bookRows = gameLines?.books ? Object.entries(gameLines.books) : [];
const streakRows = (streaks || []).filter((s) => s && s.player && s.description);
// 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: '16px 20px',
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.
Session 26 — larger/bolder abbreviations for hierarchy. */}
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 11,
fontSize: 18,
fontWeight: 800,
color: 'var(--text-0, #F0F0F5)',
letterSpacing: '-0.02em',
}}
>
<span aria-hidden style={{ fontSize: 15 }}>{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: '12px 20px 14px',
borderTop: '1px solid var(--border, #1A1A24)',
display: 'grid',
gap: 8,
background: 'rgba(255,255,255,0.015)',
}}
>
<div
className="mono"
style={{ fontSize: 10, fontWeight: 700, letterSpacing: '0.1em', color: 'var(--text-tertiary, #6B6B7B)', textTransform: 'uppercase' }}
>
Game Lines
</div>
{bookRows.map(([book, line]) => (
<div
key={book}
className="mono"
style={{
display: 'grid',
gridTemplateColumns: '72px 1fr 1fr 1fr',
gap: 8,
fontSize: 11.5,
alignItems: 'baseline',
color: 'var(--text-secondary, #8A8A9A)',
}}
>
<span style={{ color: 'var(--text-0, #F0F0F5)', fontWeight: 700, textTransform: 'capitalize' }}>{book}</span>
<span>{line.awayML ? `${teamAbbr(awayTeam, sport)} ${line.awayML}` : '—'}</span>
<span>{line.homeML ? `${teamAbbr(homeTeam, sport)} ${line.homeML}` : '—'}</span>
<span style={{ textAlign: 'right' }}>{line.total ? `O/U ${line.total}` : '—'}</span>
</div>
))}
</div>
)}
{propList.length === 0 ? (
<p
style={{
padding: '14px 20px',
color: 'var(--text-tertiary, #6B6B7B)',
fontSize: 12,
// Subtle, informational — not an error. Left-aligned so it
// reads as a quiet status line, not a centered empty wall.
textAlign: 'left',
opacity: 0.8,
}}
>
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>
)}
{/* Session 25 — streaks for players in THIS game, inline. The Slate
matches streaks to games by team, so a card shows the streak
context for the players actually on the floor/field. Renders
only when there's at least one — no empty section. */}
{streakRows.length > 0 && (
<div
style={{
padding: '12px 20px 14px',
borderTop: '1px solid var(--border, #1A1A24)',
display: 'grid',
gap: 9,
background: 'linear-gradient(180deg, rgba(233,75,60,0.04) 0%, transparent 100%)',
}}
>
<div
className="mono"
style={{ fontSize: 10, fontWeight: 700, letterSpacing: '0.1em', color: 'var(--accent, #E94B3C)', textTransform: 'uppercase' }}
>
🔥 Streaks
</div>
{streakRows.map((s) => (
<div
key={`${s.player}-${s.description}`}
style={{ display: 'flex', justifyContent: 'space-between', gap: 12, fontSize: 12.5, alignItems: 'baseline' }}
>
<span style={{ color: 'var(--text-0, #F0F0F5)', fontWeight: 700 }}>
{s.player}
{s.team ? <span style={{ color: 'var(--text-tertiary, #6B6B7B)', fontWeight: 400 }}> · {s.team}</span> : null}
</span>
<span style={{ color: 'var(--text-secondary, #8A8A9A)', textAlign: 'right' }}>{s.description}</span>
</div>
))}
</div>
)}
</article>
);
}