'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 = { nba: '๐Ÿ€', wnba: '๐Ÿ€', mlb: 'โšพ', soccer: 'โšฝ', }; const SPORT_ACCENT: Record = { 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; } 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; loadingKey?: string | null; // propRowKey of the prop currently grading errorByKey?: Record; 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 = { '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: " " โ€” 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 = { 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 (
{/* 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. */}
{SPORT_EMOJI[sport]} {teamAbbr(awayTeam, sport)} vs {teamAbbr(homeTeam, sport)} {SPORT_LABEL[sport]}
{awayTeam} @ {homeTeam}
{[formatTime(gameTime), venue, context].filter(Boolean).join(' ยท ') || ' '}
{/* Session 24 โ€” live/final status + score (ESPN schedule). */} {badge && ( {badge.label}{badge.score ? ` ยท ${badge.score.away}โ€“${badge.score.home}` : ''} )}
{propList.length} prop{propList.length === 1 ? '' : 's'}
{/* 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 && (
Game Lines
{bookRows.map(([book, line]) => (
{book} {line.awayML ? `${teamAbbr(awayTeam, sport)} ${line.awayML}` : 'โ€”'} {line.homeML ? `${teamAbbr(homeTeam, sport)} ${line.homeML}` : 'โ€”'} {line.total ? `O/U ${line.total}` : 'โ€”'}
))}
)} {propList.length === 0 ? (

Props for this game aren't published yet.

) : (
{visiblePlayerGroups.map((g) => ( ))} {hiddenPlayerCount > 0 && (
)}
)} {/* 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 && (
๐Ÿ”ฅ Streaks
{streakRows.map((s) => (
{s.player} {s.team ? ยท {s.team} : null} {s.description}
))}
)}
); }