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:
Kev
2026-06-12 00:30:13 -04:00
parent 0e3839a90a
commit 56392ec8f4
12 changed files with 825 additions and 41 deletions
+141 -1
View File
@@ -10,6 +10,7 @@ import {
trackScanLimitHit,
trackUpgradeClicked,
} from '@/lib/analytics';
import { getHeadshotUrl, PLAYER_SILHOUETTE, type HeadshotSport } from '@/lib/playerHeadshot';
type Sport = 'NBA' | 'MLB' | 'WNBA';
@@ -99,6 +100,11 @@ export default function ScanPage() {
const [scanning, setScanning] = useState(false);
const [result, setResult] = useState<ScanResponse | null>(null);
const [error, setError] = useState('');
// Session 19 — tonight's players grid. Pulled from the odds proxy
// (props array) so the chip set is real, not hard-coded. Each entry
// unique by name + the set of stats that player has props for, so
// clicking a chip can prefill the stat dropdown intelligently.
const [tonightsPlayers, setTonightsPlayers] = useState<Array<{ name: string; stats: string[] }> | null>(null);
// Auth gate — push anonymous users to signup
useEffect(() => {
@@ -127,6 +133,41 @@ export default function ScanPage() {
};
}, [sport]);
// Session 19 — fetch tonight's players from the odds proxy. The
// odds endpoint returns the canonical list of players who have
// props posted, which is exactly what the scan UI should surface
// as quick-fill chips. Empty array on failure → the section
// hides itself (we don't want a sad "couldn't load" stripe when
// odds-api is rate-limited).
useEffect(() => {
let cancelled = false;
setTonightsPlayers(null);
const sportPath = sport.toLowerCase();
fetch(`/api/odds/${sportPath}`)
.then(async (r) => {
if (!r.ok) throw new Error(`HTTP ${r.status}`);
return r.json();
})
.then((data: { props?: Array<{ player?: string; stat_type?: string }> }) => {
if (cancelled) return;
const byPlayer = new Map<string, Set<string>>();
for (const p of data.props || []) {
if (!p.player || !p.stat_type) continue;
const set = byPlayer.get(p.player) || new Set<string>();
set.add(p.stat_type);
byPlayer.set(p.player, set);
}
const list = Array.from(byPlayer.entries())
.map(([name, stats]) => ({ name, stats: Array.from(stats) }))
.sort((a, b) => a.name.localeCompare(b.name));
setTonightsPlayers(list);
})
.catch(() => {
if (!cancelled) setTonightsPlayers([]);
});
return () => { cancelled = true; };
}, [sport]);
// Debounced player search — narrow to selected game when set
const searchPlayers = useCallback(
async (query: string) => {
@@ -327,6 +368,83 @@ export default function ScanPage() {
)}
</div>
{/* Session 19 — tonight's players chip grid. Above the search
input so the user sees who's actually playing before having
to think about what to type. Tapping a chip prefills the
player and, when only one stat is available, the stat too. */}
{tonightsPlayers && tonightsPlayers.length > 0 && (
<div style={{ marginBottom: 20 }}>
<label className="mono" style={labelStyle}>Tonight&apos;s Players</label>
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(140px, 1fr))',
gap: 8,
maxHeight: 220,
overflowY: 'auto',
padding: 2,
}}
>
{tonightsPlayers.map((p) => {
const headshot = getHeadshotUrl({ sport: sport.toLowerCase() as HeadshotSport });
const selected = selectedPlayer === p.name;
return (
<button
key={p.name}
type="button"
onClick={() => {
setSelectedPlayer(p.name);
setPlayerQuery(p.name);
setPlayerSuggestions([]);
// If the player has exactly one stat type with
// props, prefill it — saves a tap for single-stat
// pitcher props (ERs/Ks) etc.
if (p.stats.length === 1 && SPORT_STATS[sport].some((s) => s.id === p.stats[0])) {
setStat(p.stats[0]);
}
}}
style={{
display: 'flex',
alignItems: 'center',
gap: 8,
padding: '6px 10px',
background: selected ? 'rgba(0,212,160,0.08)' : 'var(--bg-surface)',
border: `1px solid ${selected ? 'var(--grade-a)' : 'var(--border)'}`,
borderRadius: 999,
color: 'var(--text-primary)',
cursor: 'pointer',
fontSize: 12,
fontFamily: 'inherit',
textAlign: 'left',
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',
}}
/>
<span style={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', flex: 1 }}>
{p.name}
</span>
</button>
);
})}
</div>
</div>
)}
{/* Player search */}
<div style={{ marginBottom: 16, position: 'relative' }}>
<label className="mono" style={labelStyle}>Player</label>
@@ -382,7 +500,29 @@ export default function ScanPage() {
}}
style={suggestionStyle}
>
<span>{p.full_name}</span>
{/* 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',
}}
/>
<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)' }}>
{p.team}