Files
vyndr/web/src/components/HotListPanel.tsx
T
builtbykev 47ada9013c 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>
2026-07-13 13:18:59 -04:00

123 lines
4.2 KiB
TypeScript

'use client';
import { useEffect, useState } from 'react';
import PlayerAvatar from '@/components/vyndr/PlayerAvatar';
import type { HeadshotSport } from '@/lib/playerHeadshot';
import { getVisibleCount, getHiddenCount, type Tier } from '@/lib/tierGate';
/**
* HotListPanel (Session 23).
*
* Rolling recent-window leaders — ranked by who's TRENDING, not who has
* the biggest raw number. A 20-PPG player erupting for 28/31/25 is hot;
* a 30-PPG star who dropped 28 is not. Free users see the top 3.
*
* Self-hides when empty so the landing page never renders a dead box.
*/
interface HotPlayer {
rank: number;
name: string;
playerId: string | number | null;
team: string | null;
stat: string;
recentAvg: number;
statLine: string;
trendDescription: string;
}
export interface HotListPanelProps {
sport: string;
tier?: Tier;
stat?: string;
limit?: number;
}
export default function HotListPanel({ sport, tier = 'free', stat = 'all', limit }: HotListPanelProps) {
const [players, setPlayers] = useState<HotPlayer[] | null>(null);
useEffect(() => {
let cancelled = false;
async function load() {
try {
const res = await fetch(`/api/hotlist/${sport}?stat=${encodeURIComponent(stat)}`);
if (!res.ok) { if (!cancelled) setPlayers([]); return; }
const data = await res.json();
if (!cancelled) setPlayers(Array.isArray(data?.players) ? data.players : []);
} catch {
if (!cancelled) setPlayers([]);
}
}
load();
return () => { cancelled = true; };
}, [sport, stat]);
if (!players || players.length === 0) return null;
const tierCount = getVisibleCount(tier, players.length);
const cap = limit && limit > 0 ? Math.min(limit, tierCount) : tierCount;
const visible = players.slice(0, cap);
const hidden = limit ? players.length - visible.length : getHiddenCount(tier, players.length);
return (
<section className="hot-list-panel" style={{ margin: '16px 0' }}>
<h3 style={panelHeading}>📈 HOT RIGHT NOW</h3>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{visible.map((p) => (
<div key={`${p.name}-${p.stat}`} style={rowStyle}>
<span style={rankStyle}>#{p.rank}</span>
{/* 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>
<div style={statLineStyle}>{p.statLine}</div>
</div>
<span style={trendStyle}>{p.trendDescription}</span>
</div>
))}
</div>
{hidden > 0 && (
<a href="/pricing" style={upsellStyle}>
{hidden} more upgrade to see the full board
</a>
)}
</section>
);
}
const panelHeading: React.CSSProperties = {
fontSize: 12, letterSpacing: '0.12em', textTransform: 'uppercase',
color: 'var(--text-tertiary, #6A6A78)', margin: '0 0 10px',
};
const rowStyle: React.CSSProperties = {
display: 'flex', alignItems: 'center', gap: 10,
padding: '8px 10px', borderRadius: 10,
background: 'var(--surface, #12121A)', border: '1px solid var(--border, #2A2A36)',
};
const rankStyle: React.CSSProperties = {
flex: '0 0 auto', fontSize: 13, fontWeight: 800, width: 28,
color: 'var(--text-tertiary, #6A6A78)',
};
const playerName: React.CSSProperties = {
fontSize: 14, fontWeight: 700, color: 'var(--text-primary, #F0F0F4)',
whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis',
};
const statLineStyle: React.CSSProperties = {
fontSize: 12, color: 'var(--text-secondary, #9A9AA8)',
};
const trendStyle: React.CSSProperties = {
flex: '0 0 auto', fontSize: 11, fontWeight: 700, padding: '3px 8px',
borderRadius: 6, background: 'rgba(46,160,67,0.15)', color: '#3FB950',
};
const upsellStyle: React.CSSProperties = {
display: 'inline-block', marginTop: 10, fontSize: 12, fontWeight: 600,
color: 'var(--accent, #E94B3C)', textDecoration: 'none',
};