Session 19: Sports design overhaul — player cards with headshots, game card redesign, scan page tonight's players, odds diagnostic logging, tier gate utility (1444 tests)
This commit is contained in:
@@ -0,0 +1,173 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import PropRow, { PropRowProp, PropRowResult, propRowKey, Tier } from '@/components/PropRow';
|
||||
import { getHeadshotUrl, PLAYER_SILHOUETTE, HeadshotSport } from '@/lib/playerHeadshot';
|
||||
|
||||
/**
|
||||
* PlayerCard — one player and all their props (Session 19).
|
||||
*
|
||||
* Slate redesign: previously each prop was an independent row, so a
|
||||
* player with 4 props (Pts/Reb/Ast/3PT) appeared as 4 stripes that
|
||||
* repeated the player's name. That reads like a spreadsheet, not a
|
||||
* sports product. PlayerCard groups: a header with headshot + name +
|
||||
* team sits above N PropRow children.
|
||||
*
|
||||
* Headshot resolution lives in `lib/playerHeadshot.ts`. The `<img>`
|
||||
* fall-through swaps to the bundled silhouette if the CDN 404s on a
|
||||
* player whose league hasn't published a headshot yet.
|
||||
*/
|
||||
|
||||
export interface PlayerCardProps {
|
||||
player: string;
|
||||
sport: HeadshotSport;
|
||||
team?: string;
|
||||
position?: string;
|
||||
/** League ID — NBA stats.com ID, WNBA player ID, or MLB people ID. */
|
||||
playerId?: string | number | null;
|
||||
/** ESPN ID fallback (used when no league ID is known). */
|
||||
espnId?: string | number | null;
|
||||
/** Pre-cached headshot URL (soccer / API-Football). */
|
||||
photoUrl?: string | null;
|
||||
props: PropRowProp[];
|
||||
gradedProps: Map<string, PropRowResult>;
|
||||
loadingKey?: string | null;
|
||||
errorByKey?: Record<string, string | undefined>;
|
||||
tier?: Tier;
|
||||
onGrade: (prop: PropRowProp) => void;
|
||||
onUpgrade?: () => void;
|
||||
}
|
||||
|
||||
export default function PlayerCard(props: PlayerCardProps) {
|
||||
const {
|
||||
player, sport, team, position, playerId, espnId, photoUrl,
|
||||
props: propList, gradedProps, loadingKey, errorByKey,
|
||||
tier = 'free', onGrade, onUpgrade,
|
||||
} = props;
|
||||
|
||||
// Compute the initial headshot URL — onError swaps to silhouette.
|
||||
const initialUrl = getHeadshotUrl({
|
||||
sport,
|
||||
playerId,
|
||||
espnId,
|
||||
cachedPhotoUrl: photoUrl,
|
||||
});
|
||||
const [headshotSrc, setHeadshotSrc] = useState(initialUrl);
|
||||
|
||||
const subtitle = [team, position].filter(Boolean).join(' · ');
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
background: 'var(--bg-2, #12121A)',
|
||||
borderTop: '1px solid var(--border, #1A1A24)',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
padding: '10px 16px',
|
||||
background: 'rgba(255,255,255,0.02)',
|
||||
}}
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={headshotSrc}
|
||||
alt={`${player} headshot`}
|
||||
width={40}
|
||||
height={40}
|
||||
loading="lazy"
|
||||
onError={() => {
|
||||
if (headshotSrc !== PLAYER_SILHOUETTE) setHeadshotSrc(PLAYER_SILHOUETTE);
|
||||
}}
|
||||
style={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: '50%',
|
||||
objectFit: 'cover',
|
||||
background: 'var(--bg-elevated, #15151F)',
|
||||
border: '1px solid var(--border, #1A1A24)',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
<div style={{ minWidth: 0, flex: 1 }}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 14,
|
||||
fontWeight: 600,
|
||||
color: 'var(--text-0, #F0F0F5)',
|
||||
letterSpacing: '-0.005em',
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
}}
|
||||
>
|
||||
{player}
|
||||
</div>
|
||||
{subtitle && (
|
||||
<div
|
||||
className="mono"
|
||||
style={{
|
||||
marginTop: 2,
|
||||
fontSize: 11,
|
||||
color: 'var(--text-tertiary, #6B6B7B)',
|
||||
letterSpacing: '0.06em',
|
||||
textTransform: 'uppercase',
|
||||
}}
|
||||
>
|
||||
{subtitle}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className="mono"
|
||||
style={{
|
||||
fontSize: 10,
|
||||
color: 'var(--text-tertiary, #6B6B7B)',
|
||||
background: 'rgba(255,255,255,0.04)',
|
||||
padding: '3px 8px',
|
||||
borderRadius: 999,
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
aria-label={`${propList.length} props for ${player}`}
|
||||
>
|
||||
{propList.length} prop{propList.length === 1 ? '' : 's'}
|
||||
</div>
|
||||
</div>
|
||||
<ul style={{ listStyle: 'none', padding: 0, margin: 0 }}>
|
||||
{propList.map((p) => {
|
||||
const key = propRowKey(p);
|
||||
return (
|
||||
<PropRow
|
||||
key={key}
|
||||
prop={p}
|
||||
result={gradedProps.get(key) ?? null}
|
||||
loading={loadingKey === key}
|
||||
error={errorByKey?.[key] ?? null}
|
||||
tier={tier}
|
||||
onRead={onGrade}
|
||||
onUpgrade={onUpgrade}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* groupPropsByPlayer — preserves the original prop order (first
|
||||
* appearance of a player wins their slot), so the Slate's sort
|
||||
* (alphabetical by player) is the actual visual sort.
|
||||
*/
|
||||
export function groupPropsByPlayer(propList: PropRowProp[]): Array<{ player: string; props: PropRowProp[] }> {
|
||||
const byPlayer = new Map<string, PropRowProp[]>();
|
||||
for (const p of propList) {
|
||||
const existing = byPlayer.get(p.player);
|
||||
if (existing) existing.push(p);
|
||||
else byPlayer.set(p.player, [p]);
|
||||
}
|
||||
return Array.from(byPlayer.entries()).map(([player, props]) => ({ player, props }));
|
||||
}
|
||||
Reference in New Issue
Block a user