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),
|
||||
|
||||
Reference in New Issue
Block a user