'use client'; import { useState } from 'react'; import type { PropRowProp, PropRowResult, Tier } from '@/components/PropRow'; import SportBadge from '@/components/vyndr/SportBadge'; import SectionHead from '@/components/vyndr/SectionHead'; import BookChip from '@/components/vyndr/BookChip'; import { detectBestLines } from '@/lib/slateAdapter'; // 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_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(); } // Bloomberg line cell (Session 36): best = green tint + green left border, // worst = subtle red. The #1 visual upgrade (§13). function LineCell({ value, best, worst }: { value: string; best?: boolean; worst?: boolean }) { return (
{value}
); } 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); // Session 36 — best/worst line detection (Bloomberg pattern) via slateAdapter. const lineRows = gameLines?.books ? detectBestLines(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. */}
{teamAbbr(awayTeam, sport)} vs {teamAbbr(homeTeam, 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. */} {lineRows.length > 0 && (
GAME LINES · {lineRows.length} BOOK{lineRows.length === 1 ? '' : 'S'}
BOOK
{teamAbbr(awayTeam, sport)} ML
{teamAbbr(homeTeam, sport)} ML
O/U
{lineRows.map((r) => ( {/* Session 43 — brand-colored book chip (was plain grey text). */}
))}
)} {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}
))}
)}
); }