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:
+141
-1
@@ -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'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}
|
||||
|
||||
Reference in New Issue
Block a user