471 lines
17 KiB
TypeScript
471 lines
17 KiB
TypeScript
'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'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>
|
||
);
|
||
}
|