Wave 2A: real player headshots — sport-agnostic id threaded from ingestion
Threads a REAL athlete id from the snapshot's per-player stats resolve (zero
new I/O) → enriched grade → grades:{sport} → slate strip → PlayerAvatar. Real
photo where an id resolves; team-colored monogram (never a gray silhouette,
never a broken image) where it can't. Ids are never fabricated.
Ingestion (Addition 1):
- espnStatsAdapter.getSeasonAverages now RETURNS the resolved ESPN athlete id
(was discarded) as espnId; non-numeric uid degrades to null.
- playerIntelService surfaces MLBAM playerId (MLB) / ESPN espnId (NBA/WNBA).
- snapshotService captures both per player and stores them on the enriched
grade beside archetype/team (null when unresolved → monogram path).
Thread → component:
- slateAdapter.buildPlayerStripsFromProps carries playerId/espnId onto each
strip; StatStrip → PlayerAvatar (accepts both ids; getHeadshotUrl routes by
sport: MLB→mlbstatic, NBA/WNBA→a.espncdn).
- Silhouette surfaces rewired to PlayerAvatar (branded monogram on null):
scan search dropdown (guarded MLBAM p.id) + tonight chips, SearchModal,
HotListPanel, GradeResultCard header. Scan grade card feeds the picked
MLBAM id through gradeAdapter.
- playerHeadshot pure URL logic extracted to CommonJS playerHeadshotUrl.js
(unit-testable; the .ts re-exports it). nfl/nhl added to ESPN_SPORT_PATH.
Tests: tests/unit/headshotThread.test.js (per-league URL + id thread + monogram
null path) + extended snapshotService/espnStatsAdapter suites. Full suite
241 suites / 2915 green; next build exit 0.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+26
-40
@@ -16,7 +16,8 @@ import {
|
||||
trackScanLimitHit,
|
||||
trackUpgradeClicked,
|
||||
} from '@/lib/analytics';
|
||||
import { getHeadshotUrl, PLAYER_SILHOUETTE, type HeadshotSport } from '@/lib/playerHeadshot';
|
||||
import PlayerAvatar from '@/components/vyndr/PlayerAvatar';
|
||||
import { type HeadshotSport } from '@/lib/playerHeadshot';
|
||||
import { buildBookLink, SUPPORTED_BOOKS, BOOK_LINK_REL } from '@/lib/bookLinks';
|
||||
|
||||
type Sport = 'NBA' | 'MLB' | 'WNBA';
|
||||
@@ -119,6 +120,9 @@ export default function ScanPage() {
|
||||
const [playerQuery, setPlayerQuery] = useState('');
|
||||
const [playerSuggestions, setPlayerSuggestions] = useState<Player[]>([]);
|
||||
const [selectedPlayer, setSelectedPlayer] = useState<string>('');
|
||||
// Wave 2A — the MLBAM id of the player picked from search (MLB only; numeric).
|
||||
// Feeds the grade card's real headshot. null → team-colored monogram.
|
||||
const [selectedPlayerId, setSelectedPlayerId] = useState<string | null>(null);
|
||||
const [stat, setStat] = useState<string>('points');
|
||||
const [line, setLine] = useState<string>('');
|
||||
const [direction, setDirection] = useState<'over' | 'under'>('over');
|
||||
@@ -306,6 +310,7 @@ export default function ScanPage() {
|
||||
setError('');
|
||||
setPlayerQuery('');
|
||||
setSelectedPlayer('');
|
||||
setSelectedPlayerId(null);
|
||||
setLine('');
|
||||
};
|
||||
|
||||
@@ -442,7 +447,6 @@ export default function ScanPage() {
|
||||
}}
|
||||
>
|
||||
{tonightsPlayers.map((p) => {
|
||||
const headshot = getHeadshotUrl({ sport: sport.toLowerCase() as HeadshotSport });
|
||||
const selected = selectedPlayer === p.name;
|
||||
return (
|
||||
<button
|
||||
@@ -450,6 +454,7 @@ export default function ScanPage() {
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSelectedPlayer(p.name);
|
||||
setSelectedPlayerId(null); // tonight chips carry no id → monogram
|
||||
setPlayerQuery(p.name);
|
||||
setPlayerSuggestions([]);
|
||||
// If the player has exactly one stat type with
|
||||
@@ -475,22 +480,9 @@ export default function ScanPage() {
|
||||
minWidth: 0,
|
||||
}}
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={headshot}
|
||||
alt=""
|
||||
width={24}
|
||||
height={24}
|
||||
onError={(e) => { (e.currentTarget as HTMLImageElement).src = PLAYER_SILHOUETTE; }}
|
||||
style={{
|
||||
width: 24,
|
||||
height: 24,
|
||||
borderRadius: '50%',
|
||||
background: 'var(--bg-elevated)',
|
||||
flexShrink: 0,
|
||||
objectFit: 'cover',
|
||||
}}
|
||||
/>
|
||||
{/* Wave 2A — tonight's players carry no id → team-colored
|
||||
monogram (branded, never a gray silhouette). */}
|
||||
<PlayerAvatar name={p.name} sport={sport.toLowerCase() as HeadshotSport} size={24} />
|
||||
<span style={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', flex: 1 }}>
|
||||
{p.name}
|
||||
</span>
|
||||
@@ -511,6 +503,7 @@ export default function ScanPage() {
|
||||
onChange={(e) => {
|
||||
setPlayerQuery(e.target.value);
|
||||
setSelectedPlayer('');
|
||||
setSelectedPlayerId(null);
|
||||
}}
|
||||
autoComplete="off"
|
||||
/>
|
||||
@@ -551,33 +544,25 @@ export default function ScanPage() {
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
setSelectedPlayer(p.full_name);
|
||||
setSelectedPlayerId(sport.toLowerCase() === 'mlb' && /^\d+$/.test(String(p.id)) ? String(p.id) : null);
|
||||
setPlayerQuery(p.full_name);
|
||||
setPlayerSuggestions([]);
|
||||
}}
|
||||
style={suggestionStyle}
|
||||
>
|
||||
{/* Session 19 — headshot in search suggestions. The
|
||||
/api/players/search response doesn't (yet) include
|
||||
league IDs, so we hit ESPN-CDN fallback or
|
||||
silhouette. onError swaps in the silhouette when
|
||||
the league hasn't published one. */}
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={getHeadshotUrl({ sport: sport.toLowerCase() as HeadshotSport })}
|
||||
alt=""
|
||||
width={28}
|
||||
height={28}
|
||||
onError={(e) => { (e.currentTarget as HTMLImageElement).src = PLAYER_SILHOUETTE; }}
|
||||
style={{
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: '50%',
|
||||
background: 'var(--bg-elevated)',
|
||||
flexShrink: 0,
|
||||
marginRight: 10,
|
||||
objectFit: 'cover',
|
||||
}}
|
||||
/>
|
||||
{/* Wave 2A — real headshot in search suggestions. MLB's
|
||||
/api/players/search carries the MLBAM id on p.id (numeric);
|
||||
NBA/WNBA ids are synthetic → guarded out → team-colored
|
||||
monogram. Never a gray silhouette. */}
|
||||
<span style={{ marginRight: 10, display: 'inline-flex' }}>
|
||||
<PlayerAvatar
|
||||
name={p.full_name}
|
||||
sport={sport.toLowerCase() as HeadshotSport}
|
||||
playerId={sport.toLowerCase() === 'mlb' && /^\d+$/.test(String(p.id)) ? p.id : undefined}
|
||||
team={p.team}
|
||||
size={28}
|
||||
/>
|
||||
</span>
|
||||
<span style={{ flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{p.full_name}</span>
|
||||
{p.team && (
|
||||
<span className="mono" style={{ fontSize: 11, color: 'var(--text-tertiary)' }}>
|
||||
@@ -731,6 +716,7 @@ export default function ScanPage() {
|
||||
key={`${selectedPlayer}-${stat}-${line}-${direction}`}
|
||||
data={mapScanToGradeResult({
|
||||
player: selectedPlayer,
|
||||
playerId: selectedPlayerId,
|
||||
sport,
|
||||
stat,
|
||||
line: Number(line),
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { getHeadshotUrl, PLAYER_SILHOUETTE } from '@/lib/playerHeadshot';
|
||||
import PlayerAvatar from '@/components/vyndr/PlayerAvatar';
|
||||
import type { HeadshotSport } from '@/lib/playerHeadshot';
|
||||
import { getVisibleCount, getHiddenCount, type Tier } from '@/lib/tierGate';
|
||||
|
||||
/**
|
||||
@@ -65,13 +66,14 @@ export default function HotListPanel({ sport, tier = 'free', stat = 'all', limit
|
||||
{visible.map((p) => (
|
||||
<div key={`${p.name}-${p.stat}`} style={rowStyle}>
|
||||
<span style={rankStyle}>#{p.rank}</span>
|
||||
<img
|
||||
src={getHeadshotUrl({ sport, playerId: p.playerId })}
|
||||
alt={p.name}
|
||||
width={36}
|
||||
height={36}
|
||||
style={avatarStyle}
|
||||
onError={(e) => { (e.target as HTMLImageElement).src = PLAYER_SILHOUETTE; }}
|
||||
{/* Wave 2A — real headshot where the rosterlogs id resolves (MLBAM);
|
||||
team-colored monogram otherwise. Never a gray silhouette. */}
|
||||
<PlayerAvatar
|
||||
name={p.name}
|
||||
sport={sport as HeadshotSport}
|
||||
playerId={p.playerId}
|
||||
team={p.team}
|
||||
size={36}
|
||||
/>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={playerName}>{p.name}{p.team ? ` · ${p.team}` : ''}</div>
|
||||
@@ -103,9 +105,6 @@ const rankStyle: React.CSSProperties = {
|
||||
flex: '0 0 auto', fontSize: 13, fontWeight: 800, width: 28,
|
||||
color: 'var(--text-tertiary, #6A6A78)',
|
||||
};
|
||||
const avatarStyle: React.CSSProperties = {
|
||||
borderRadius: '50%', objectFit: 'cover', background: '#1A1A24', flex: '0 0 auto',
|
||||
};
|
||||
const playerName: React.CSSProperties = {
|
||||
fontSize: 14, fontWeight: 700, color: 'var(--text-primary, #F0F0F4)',
|
||||
whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis',
|
||||
|
||||
@@ -37,6 +37,9 @@ export interface GameProp {
|
||||
export interface PlayerStrip {
|
||||
player: string;
|
||||
team: string;
|
||||
// Wave 2A — real headshot ids threaded from the snapshot grade.
|
||||
playerId?: string | number | null;
|
||||
espnId?: string | number | null;
|
||||
archetype?: StripArchetype;
|
||||
// Session 64 (A1-S5) — prop viability (lineup confirmation + injury wire).
|
||||
lineup?: { status: string; slot?: number } | null;
|
||||
@@ -366,6 +369,8 @@ export default function GameCard({ game: g, onAddParlay, onOpen, preferredBooks
|
||||
player={ps.player}
|
||||
team={ps.team}
|
||||
sport={g.sport}
|
||||
playerId={ps.playerId}
|
||||
espnId={ps.espnId}
|
||||
archetype={ps.archetype}
|
||||
lineup={ps.lineup}
|
||||
injury={ps.injury}
|
||||
|
||||
@@ -6,6 +6,8 @@ import SectionHead from '@/components/vyndr/SectionHead';
|
||||
import VBtn from '@/components/vyndr/VBtn';
|
||||
import ArchetypeBlend from '@/components/vyndr/ArchetypeBlend';
|
||||
import GradeBadge from '@/components/vyndr/GradeBadge';
|
||||
import PlayerAvatar from '@/components/vyndr/PlayerAvatar';
|
||||
import { type HeadshotSport } from '@/lib/playerHeadshot';
|
||||
import { gradeColor, gradeHex } from '@/lib/vyndrTokens';
|
||||
import { edgeColor, gradeGlows } from '@/lib/colorContract';
|
||||
import { playerHref } from '@/lib/playerHref';
|
||||
@@ -14,6 +16,10 @@ export interface GradeResultData {
|
||||
player: string;
|
||||
team: string;
|
||||
sport: string;
|
||||
// Wave 2A — real headshot ids (optional; self-hide to a team-colored
|
||||
// monogram). MLB → MLBAM playerId; NBA/WNBA → ESPN espnId.
|
||||
playerId?: string | number | null;
|
||||
espnId?: string | number | null;
|
||||
stat: string;
|
||||
line: number;
|
||||
side: 'Over' | 'Under';
|
||||
@@ -96,12 +102,17 @@ export default function GradeResultCard({
|
||||
|
||||
{/* 1. HEADER — player name links to the full profile (Session 42) */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '16px 20px', background: 'var(--bg-2)', borderBottom: '1px solid var(--border)' }}>
|
||||
<div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, minWidth: 0 }}>
|
||||
{/* Wave 2A — player identity block: real headshot / team-colored
|
||||
monogram (never a gray silhouette). */}
|
||||
<PlayerAvatar name={d.player} sport={d.sport as HeadshotSport} playerId={d.playerId} espnId={d.espnId} team={d.team} size={40} />
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<a href={playerHref(d.player, d.sport)} style={{ fontSize: 22, fontWeight: 800, letterSpacing: '-0.01em', lineHeight: 1.1, color: 'inherit', textDecoration: 'none' }}>{d.player}</a>
|
||||
<div className="mono" style={{ fontSize: 12, color: 'var(--text-1)', marginTop: 3 }}>
|
||||
<span style={{ color: sideColor, fontWeight: 700 }}>{d.side.toUpperCase()} {d.line}</span>
|
||||
<span style={{ color: 'var(--text-2)', margin: '0 7px' }}>·</span>{d.stat}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 6 }}>
|
||||
<SportBadge sport={d.sport} />
|
||||
|
||||
@@ -21,17 +21,26 @@ export default function PlayerAvatar({
|
||||
name,
|
||||
sport = 'mlb',
|
||||
playerId,
|
||||
espnId,
|
||||
team,
|
||||
size = 36,
|
||||
}: {
|
||||
name: string;
|
||||
sport?: HeadshotSport;
|
||||
/** League-native id (MLBAM for MLB, cdn.nba/cdn.wnba for NBA/WNBA). */
|
||||
playerId?: string | number | null;
|
||||
/** ESPN athlete id — the NBA/WNBA (and dormant NFL/NHL) headshot source. */
|
||||
espnId?: string | number | null;
|
||||
team?: string | null;
|
||||
size?: number;
|
||||
}) {
|
||||
const [broken, setBroken] = useState(false);
|
||||
const url = playerId != null ? getHeadshotUrl({ sport, playerId }) : null;
|
||||
// Wave 2A — resolve from whichever real id we have. getHeadshotUrl routes by
|
||||
// sport: MLB→mlbstatic via playerId; NBA/WNBA→a.espncdn via espnId. No id at
|
||||
// all → null → team-colored monogram (never a gray silhouette).
|
||||
const url = (playerId != null || espnId != null)
|
||||
? getHeadshotUrl({ sport, playerId, espnId })
|
||||
: null;
|
||||
const accent = (team && accentColor(team, String(sport))) || '#4A9EFF';
|
||||
const showImg = url && url !== '/images/player-silhouette.svg' && !broken;
|
||||
|
||||
|
||||
@@ -4,6 +4,8 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { playerHref } from '@/lib/playerHref';
|
||||
import { searchTeams, teamHref } from '@/lib/teams';
|
||||
import PlayerAvatar from '@/components/vyndr/PlayerAvatar';
|
||||
import { type HeadshotSport } from '@/lib/playerHeadshot';
|
||||
|
||||
/**
|
||||
* SearchModal — S6 (A1 board). Global search over players + teams.
|
||||
@@ -31,6 +33,8 @@ interface ResultItem {
|
||||
sub: string; // team / abbr context
|
||||
sport: string;
|
||||
href: string;
|
||||
// Wave 2A — MLBAM headshot id (MLB only; NBA/WNBA search ids are synthetic).
|
||||
playerId?: string | number | null;
|
||||
}
|
||||
|
||||
export default function SearchModal({ open, onClose }: { open: boolean; onClose: () => void }) {
|
||||
@@ -79,18 +83,22 @@ export default function SearchModal({ open, onClose }: { open: boolean; onClose:
|
||||
const seen = new Set<string>();
|
||||
const merged: ResultItem[] = [];
|
||||
for (const res of responses) {
|
||||
for (const p of res.players as Array<{ full_name?: string; team?: string }>) {
|
||||
for (const p of res.players as Array<{ full_name?: string; team?: string; id?: string | number }>) {
|
||||
const name = String(p.full_name || '').trim();
|
||||
if (!name) continue;
|
||||
const key = `${name.toLowerCase()}|${res.sport}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
// MLB's search id is the real MLBAM id → headshot. Other sports'
|
||||
// ids are synthetic → guarded out → monogram.
|
||||
const playerId = res.sport === 'mlb' && p.id != null && /^\d+$/.test(String(p.id)) ? p.id : null;
|
||||
merged.push({
|
||||
kind: 'player',
|
||||
label: name,
|
||||
sub: p.team ? String(p.team) : '',
|
||||
sport: res.sport,
|
||||
href: playerHref(name, res.sport),
|
||||
playerId,
|
||||
});
|
||||
if (merged.length >= 9) break;
|
||||
}
|
||||
@@ -179,8 +187,20 @@ export default function SearchModal({ open, onClose }: { open: boolean; onClose:
|
||||
fontSize: 13,
|
||||
}}
|
||||
>
|
||||
{/* ROW-GRAMMAR: never truncate a name — wrap instead. */}
|
||||
<span style={{ fontWeight: 700, whiteSpace: 'normal' }}>{it.label}</span>
|
||||
{/* ROW-GRAMMAR: never truncate a name — wrap instead. Wave 2A adds
|
||||
the player identity avatar (real headshot / branded monogram). */}
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 10, minWidth: 0 }}>
|
||||
{it.kind === 'player' && (
|
||||
<PlayerAvatar
|
||||
name={it.label}
|
||||
sport={it.sport as HeadshotSport}
|
||||
playerId={it.playerId}
|
||||
team={it.sub || null}
|
||||
size={26}
|
||||
/>
|
||||
)}
|
||||
<span style={{ fontWeight: 700, whiteSpace: 'normal' }}>{it.label}</span>
|
||||
</span>
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 8, flexShrink: 0 }}>
|
||||
{it.sub && <span style={{ fontSize: 11, color: 'var(--text-1)' }}>{it.sub}</span>}
|
||||
<span style={{ fontSize: 9.5, fontWeight: 800, letterSpacing: '0.08em', color: SPORT_COLOR[it.sport] || 'var(--text-1)' }}>
|
||||
|
||||
@@ -195,6 +195,10 @@ interface StatStripProps {
|
||||
player: string;
|
||||
team: string;
|
||||
sport?: string;
|
||||
// Wave 2A — real headshot ids threaded from the snapshot grade. MLB → MLBAM
|
||||
// playerId; NBA/WNBA → ESPN espnId. Absent → PlayerAvatar monogram.
|
||||
playerId?: string | number | null;
|
||||
espnId?: string | number | null;
|
||||
archetype?: StripArchetype;
|
||||
// Session 64 (A1-S5) — viability (lineup confirmation + injury wire).
|
||||
lineup?: { status: string; slot?: number } | null;
|
||||
@@ -225,6 +229,8 @@ export default function StatStrip({
|
||||
player,
|
||||
team,
|
||||
sport,
|
||||
playerId,
|
||||
espnId,
|
||||
archetype,
|
||||
lineup,
|
||||
injury,
|
||||
@@ -378,7 +384,7 @@ export default function StatStrip({
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 9, flexWrap: 'wrap' }}>
|
||||
{/* DS0 — player identity block: real headshot / team-colored monogram
|
||||
(never the gray silhouette). Team drives the accent color. */}
|
||||
<PlayerAvatar name={player} sport={sport} team={team} size={30} />
|
||||
<PlayerAvatar name={player} sport={sport} playerId={playerId} espnId={espnId} team={team} size={30} />
|
||||
<PlayerName style={{ fontWeight: 700, fontSize: 14, color: '#fff', ...nameStyle }}>{player}</PlayerName>
|
||||
<span className="mono" style={{ fontSize: 11, color: 'var(--text-1)' }}>{team}</span>
|
||||
{archetype && <ArchetypeBadge archetype={archetype.primary} size="sm" variant="full" />}
|
||||
|
||||
@@ -78,6 +78,9 @@ function mapScanToGradeResult(input = {}) {
|
||||
player: input.player || '',
|
||||
team: input.team || '',
|
||||
sport: (input.sport || 'nba').toString().toLowerCase(),
|
||||
// Wave 2A — real headshot ids (optional; card self-hides to a monogram).
|
||||
playerId: input.playerId != null ? input.playerId : null,
|
||||
espnId: input.espnId != null ? input.espnId : null,
|
||||
stat: statLabel(input.stat),
|
||||
line,
|
||||
side,
|
||||
|
||||
@@ -1,82 +1,52 @@
|
||||
/**
|
||||
* Player headshot URL construction (Session 19).
|
||||
* Player headshot URL construction (Session 19; Wave 2A refactor).
|
||||
*
|
||||
* Each league hosts its own CDN; we don't proxy through ESPN as the
|
||||
* primary because (a) ESPN rate-limits image hotlinking and (b) the
|
||||
* league CDNs are the same sources PrizePicks and Sleeper use, so
|
||||
* coverage is closer to 100%.
|
||||
* The PURE URL logic lives in `playerHeadshotUrl.js` (CommonJS) so it's
|
||||
* requireable by the plain-JS Jest suite AND importable here (allowJs) — same
|
||||
* single-source-of-truth doctrine as `vyndrTokens.js` / `playerName.js`. This
|
||||
* file adds the TypeScript surface (types + the `headshotFromPlayer` helper).
|
||||
*
|
||||
* Each league hosts its own CDN; we don't proxy through ESPN as the primary
|
||||
* because (a) ESPN rate-limits image hotlinking and (b) the league CDNs are the
|
||||
* same sources PrizePicks and Sleeper use, so coverage is closer to 100%.
|
||||
*
|
||||
* Fallback chain inside the resolver:
|
||||
* 1. `cachedPhotoUrl` — if our backend has a stored URL (soccer,
|
||||
* where API-Football returns the photo in player responses), use
|
||||
* that directly. We DO NOT construct soccer URLs because no
|
||||
* central CDN exists for player headshots.
|
||||
* 1. `cachedPhotoUrl` — a backend-stored URL (soccer). We DO NOT construct
|
||||
* soccer URLs (no central CDN for player headshots).
|
||||
* 2. League CDN with `playerId` — official source.
|
||||
* 3. ESPN CDN fallback — only when `espnId` is present and no
|
||||
* league ID is available. Helpful while the roster table is
|
||||
* being backfilled with league-specific IDs.
|
||||
* 4. `/images/player-silhouette.svg` — neutral dark-theme silhouette.
|
||||
*
|
||||
* `<img onError>` in the consuming component handles 404s from the
|
||||
* CDN (e.g. a player who's in our roster but the league hasn't
|
||||
* uploaded a headshot yet) by swapping to the silhouette.
|
||||
* 3. ESPN CDN with `espnId` — NBA/WNBA (and dormant NFL/NHL) headshots.
|
||||
* 4. `/images/player-silhouette.svg` — the sentinel the consuming component
|
||||
* swaps for a team-colored MONOGRAM (never a gray blob, never a broken
|
||||
* image). `<img onError>` also degrades a 404 to the monogram.
|
||||
*/
|
||||
|
||||
import {
|
||||
getHeadshotUrl as coreGetHeadshotUrl,
|
||||
PLAYER_SILHOUETTE as CORE_SILHOUETTE,
|
||||
} from '@/lib/playerHeadshotUrl';
|
||||
|
||||
export type HeadshotSport = 'nba' | 'wnba' | 'mlb' | 'soccer' | 'soccer_wc' | string;
|
||||
|
||||
export interface HeadshotInput {
|
||||
sport: HeadshotSport;
|
||||
/** League-specific ID (NBA stats.com ID, WNBA player ID, MLB people ID). */
|
||||
playerId?: string | number | null;
|
||||
/** Optional ESPN ID as a fallback when no league ID is known. */
|
||||
/** ESPN athlete ID — the NBA/WNBA (and dormant NFL/NHL) headshot source. */
|
||||
espnId?: string | number | null;
|
||||
/** Pre-cached photo URL (used by soccer where each league has no central CDN). */
|
||||
cachedPhotoUrl?: string | null;
|
||||
}
|
||||
|
||||
export const PLAYER_SILHOUETTE = '/images/player-silhouette.svg';
|
||||
|
||||
const ESPN_SPORT_PATH: Record<string, string> = {
|
||||
nba: 'nba',
|
||||
wnba: 'wnba',
|
||||
mlb: 'mlb',
|
||||
// ESPN soccer headshots are inconsistent across leagues — explicitly
|
||||
// omit so soccer falls through to silhouette unless a cached photo
|
||||
// is provided.
|
||||
};
|
||||
export const PLAYER_SILHOUETTE: string = CORE_SILHOUETTE;
|
||||
|
||||
export function getHeadshotUrl(input: HeadshotInput): string {
|
||||
const sport = String(input.sport || '').toLowerCase();
|
||||
const playerId = input.playerId != null ? String(input.playerId) : '';
|
||||
const espnId = input.espnId != null ? String(input.espnId) : '';
|
||||
const cached = input.cachedPhotoUrl ? String(input.cachedPhotoUrl) : '';
|
||||
|
||||
if (cached) return cached;
|
||||
|
||||
if (playerId) {
|
||||
switch (sport) {
|
||||
case 'nba':
|
||||
return `https://cdn.nba.com/headshots/nba/latest/260x190/${playerId}.png`;
|
||||
case 'wnba':
|
||||
return `https://cdn.wnba.com/headshots/wnba/latest/260x190/${playerId}.png`;
|
||||
case 'mlb':
|
||||
return `https://img.mlbstatic.com/mlb-photos/image/upload/d_people:generic:headshot:67:current.png/w_213,q_auto:best/v1/people/${playerId}/headshot/67/current`;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (espnId && ESPN_SPORT_PATH[sport]) {
|
||||
return `https://a.espncdn.com/combiner/i?img=/i/headshots/${ESPN_SPORT_PATH[sport]}/players/full/${espnId}.png&w=130&h=95`;
|
||||
}
|
||||
|
||||
return PLAYER_SILHOUETTE;
|
||||
return coreGetHeadshotUrl(input);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience wrapper for the common case where the caller has a
|
||||
* player object with mixed ID fields. Pulls the first non-empty ID
|
||||
* out of the union before delegating to `getHeadshotUrl`.
|
||||
* Convenience wrapper for the common case where the caller has a player object
|
||||
* with mixed ID fields. Pulls the first non-empty ID out of the union before
|
||||
* delegating to `getHeadshotUrl`.
|
||||
*/
|
||||
export function headshotFromPlayer(player: {
|
||||
sport?: string;
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* Player headshot URL construction — PURE core (Wave 2A).
|
||||
*
|
||||
* CommonJS on purpose: importable by the `.ts` resolver (`playerHeadshot.ts`,
|
||||
* allowJs) AND requireable by the plain-JS Jest suite — same doctrine as
|
||||
* `vyndrTokens.js` / `playerName.js`. Keep the URL logic HERE so it stays
|
||||
* genuinely unit-testable (jest can't transform the `.ts`).
|
||||
*
|
||||
* Each league hosts its own CDN. Fallback chain inside the resolver:
|
||||
* 1. `cachedPhotoUrl` — a stored URL (soccer, where API-Football returns the
|
||||
* photo). We DO NOT construct soccer URLs (no central CDN).
|
||||
* 2. League CDN with `playerId` — official source.
|
||||
* 3. ESPN CDN with `espnId` — NBA/WNBA (and dormant NFL/NHL) headshots.
|
||||
* 4. `/images/player-silhouette.svg` — the sentinel the component swaps for a
|
||||
* team-colored MONOGRAM (never a gray blob, never a broken image).
|
||||
*/
|
||||
|
||||
const PLAYER_SILHOUETTE = '/images/player-silhouette.svg';
|
||||
|
||||
const ESPN_SPORT_PATH = {
|
||||
nba: 'nba',
|
||||
wnba: 'wnba',
|
||||
mlb: 'mlb',
|
||||
// Wave 2A — dormant leagues (not in ACTIVE_SPORTS + off-season). Cheap
|
||||
// correctness: an ESPN athlete id ingested later resolves for free.
|
||||
nfl: 'nfl',
|
||||
nhl: 'nhl',
|
||||
// ESPN soccer headshots are inconsistent across leagues — explicitly omit so
|
||||
// soccer falls through to the silhouette unless a cached photo is provided.
|
||||
};
|
||||
|
||||
function getHeadshotUrl(input) {
|
||||
input = input || {};
|
||||
const sport = String(input.sport || '').toLowerCase();
|
||||
const playerId = input.playerId != null ? String(input.playerId) : '';
|
||||
const espnId = input.espnId != null ? String(input.espnId) : '';
|
||||
const cached = input.cachedPhotoUrl ? String(input.cachedPhotoUrl) : '';
|
||||
|
||||
if (cached) return cached;
|
||||
|
||||
if (playerId) {
|
||||
switch (sport) {
|
||||
case 'nba':
|
||||
return `https://cdn.nba.com/headshots/nba/latest/260x190/${playerId}.png`;
|
||||
case 'wnba':
|
||||
return `https://cdn.wnba.com/headshots/wnba/latest/260x190/${playerId}.png`;
|
||||
case 'mlb':
|
||||
return `https://img.mlbstatic.com/mlb-photos/image/upload/d_people:generic:headshot:67:current.png/w_213,q_auto:best/v1/people/${playerId}/headshot/67/current`;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (espnId && ESPN_SPORT_PATH[sport]) {
|
||||
return `https://a.espncdn.com/combiner/i?img=/i/headshots/${ESPN_SPORT_PATH[sport]}/players/full/${espnId}.png&w=130&h=95`;
|
||||
}
|
||||
|
||||
return PLAYER_SILHOUETTE;
|
||||
}
|
||||
|
||||
module.exports = { PLAYER_SILHOUETTE, ESPN_SPORT_PATH, getHeadshotUrl };
|
||||
@@ -348,6 +348,10 @@ function buildPlayerStripsFromProps(gameProps, gradeIndex, deltaIndex, now = Dat
|
||||
player: displayName(p.player),
|
||||
team: knownTeam,
|
||||
archetype: rec && rec.archetype ? { primary: rec.archetype } : undefined,
|
||||
// Wave 2A — real headshot id threaded from the snapshot grade
|
||||
// (MLBAM playerId / ESPN espnId). Absent → PlayerAvatar monogram.
|
||||
playerId: (rec && rec.playerId != null ? rec.playerId : undefined),
|
||||
espnId: (rec && rec.espnId != null ? rec.espnId : undefined),
|
||||
lineup: lineupStatusFor(pk, knownTeam),
|
||||
injury: injuryFor(pk),
|
||||
stats: [],
|
||||
@@ -359,6 +363,9 @@ function buildPlayerStripsFromProps(gameProps, gradeIndex, deltaIndex, now = Dat
|
||||
const cand = displayName(p.player);
|
||||
if (cand.length > String(byPlayer[pk].player).length) byPlayer[pk].player = cand;
|
||||
if (!byPlayer[pk].archetype && rec && rec.archetype) byPlayer[pk].archetype = { primary: rec.archetype };
|
||||
// Fill an id from a later graded row if the first row lacked one.
|
||||
if (byPlayer[pk].playerId == null && rec && rec.playerId != null) byPlayer[pk].playerId = rec.playerId;
|
||||
if (byPlayer[pk].espnId == null && rec && rec.espnId != null) byPlayer[pk].espnId = rec.espnId;
|
||||
}
|
||||
if (rec) {
|
||||
const side = sideCh(rec.direction);
|
||||
|
||||
Reference in New Issue
Block a user