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
+142 -2
View File
@@ -1,10 +1,150 @@
# VYNDR — Build State # VYNDR — Build State
## Last Updated ## Last Updated
2026-06-11 2026-06-12
## Current Phase ## Current Phase
SHIP BUILD v18.0 — Internal admin dashboard + Tank01 prefetch endpoint (Session 18) SHIP BUILD v19.0 — Sports experience overhaul: player cards, game-card redesign, scan page revamp (Session 19)
## Session 19 (2026-06-12) — SHIPPED
The platform had every backend piece in place but read like a
spreadsheet. Same player name listed four times in a row, blank
scan page, generic game headers. This session restructured the
visual hierarchy so the player is the hero of every card.
### PHASE 1 — NBA proxy diagnosis
User reported "/api/odds/nba returns 503 while Express has live
data." Trace: NBA and WNBA proxies are byte-identical in shape.
Probe of production confirmed **all three** sports (NBA, WNBA,
MLB) return 503 with the same payload — root cause is upstream
of the proxy. The Express `oddsService.getOdds` 503 path fires
when odds-api fails AND no Redis cache exists.
Likely production cause: ODDS_API_KEY rotation, quota exhaustion,
or Redis disconnect (cache always empty so every request goes
live, then fails). Not fixable from code without env access.
Code change: added a `console.error` line at the 503 fallthrough
that surfaces upstream status + axios error code + truncated
upstream body. Next time someone gets paged with a 503, the log
gives them the answer instead of "Odds service unavailable."
Test: pinned the log shape (`upstream_status=`, sport name, body
substring) so a future log-cleanup PR can't silently delete it.
### PHASE 2 — PlayerCard + headshot utility
`web/src/lib/playerHeadshot.ts` exposes `getHeadshotUrl({sport,
playerId, espnId, cachedPhotoUrl})` with fallback chain:
- cached photo URL → league CDN → ESPN CDN → silhouette
- League CDNs: `cdn.nba.com/headshots/nba/latest/260x190/{id}.png`,
`cdn.wnba.com/headshots/wnba/...`,
`img.mlbstatic.com/mlb-photos/...`
- ESPN CDN used ONLY when no league ID and `espnId` present
- Soccer doesn't get a synthetic URL — API-Football's `photo`
field is cached separately and passed as `cachedPhotoUrl`
`web/public/images/player-silhouette.svg` — 64x64 generic
silhouette, dark-theme colors.
`web/src/components/PlayerCard.tsx` — new component. Header
(headshot + name + team) over N PropRow children. `<img onError>`
falls back to the silhouette so a CDN 404 doesn't leave a broken
image. Exports `groupPropsByPlayer(props)` helper.
`web/src/components/GameCard.tsx` updated:
- Imports PlayerCard + groupPropsByPlayer
- Visibility budget (`defaultVisible=4`) now applies to PLAYERS,
not raw props — previously a single player with 4+ props
consumed the whole budget and other players were hidden
- "+ N more prop(s)" → "+ N more player(s)"
### PHASE 3 — Game card header redesign
`teamAbbr(fullName, sport)` exported from GameCard:
- Override table for 30+ well-known multi-word names (Los
Angeles Lakers → LAL, St. Louis Cardinals → STL, etc.)
- Two-word names fall back to the first word's 3 letters
- Soccer composes initials when 2+ words, else truncates
Header now shows: `🏀 BOS vs DEN [NBA]` in bold mono, with the
sport label on a colored badge to the right. Below: full names
in muted text + time/venue meta line. Sport colors:
- NBA #E94B3C · WNBA #FFB347 · MLB #1E90FF · Soccer #00D4A0
### PHASE 4 — Scan page tonight's players
New "TONIGHT'S PLAYERS" chip grid above the search input, pulled
from `/api/odds/{sport}` (the canonical list of players who have
props posted today — same source The Slate uses). Each chip:
24×24 headshot + name. Click prefills the player and, when only
ONE stat type has props for that player, prefills the stat too.
Section auto-hides when the array is empty (off-season, odds-api
down, etc.) — no sad "couldn't load tonight's players" stripe.
Search dropdown enhanced: every suggestion now has a 28×28
headshot. Falls back to silhouette via onError for players the
CDN doesn't have yet.
### PHASE 5 — CSP img-src expanded
`web/next.config.ts` — img-src now includes `cdn.wnba.com` and
`img.mlbstatic.com`. Was `cdn.nba.com` + `a.espncdn.com`.
### PHASE 6 — Tier-gate utility (wired in Session 20)
`web/src/lib/tierGate.ts` — exports `canSeeFullLists(tier)`,
`canSeeGradeDetails(tier)`, `getVisibleCount(tier, totalCount)`,
`getHiddenCount(tier, totalCount)`. Free users see top 3; africa,
analyst, desk see everything. Free + africa see grade letters but
NOT detailed grade breakdowns (analyst+ only).
Not consumed yet — exported for the streaks/hot-lists work
planned in Session 20.
### Honest scope flags
- I did not run the actual UI in a browser. The web build is
clean, types resolve, and the Slate's data flow is intact, but
I can't verify the visual end state without a live render.
- Headshot CDNs will 404 for some players (rookies the league
hasn't shot yet, traded players whose league ID we haven't
re-mapped). The onError fallback prevents broken images, but
expect ~515% silhouette rate on coverage.
- The NBA proxy 503 is NOT fixed in code. The diagnostic log
helps the next operator pinpoint the root cause; the fix
itself needs env config access.
### Battery
- Express suite: **112 passed / 1444 tests** (+1 — odds service
diagnostic log test; baseline 1443)
- Web build: **clean** — all new routes register, no TS errors,
no ESLint failures
- All new TypeScript modules tree-shake into existing pages
### Files changed (Session 19)
**Created:**
- `web/src/lib/playerHeadshot.ts`
- `web/src/lib/tierGate.ts`
- `web/src/components/PlayerCard.tsx`
- `web/public/images/player-silhouette.svg`
**Modified:**
- `src/services/oddsService.js` — diagnostic log at 503 path
- `tests/unit/oddsService.test.js` — pinned log shape
- `web/src/components/GameCard.tsx` — PlayerCard integration +
teamAbbr + sport-colored header
- `web/src/app/scan/page.tsx` — tonight's players chip grid +
headshot-enriched search suggestions
- `web/next.config.ts` — CSP img-src for cdn.wnba.com +
img.mlbstatic.com
---
## Session 18 (2026-06-11) — SHIPPED ## Session 18 (2026-06-11) — SHIPPED
+14
View File
@@ -605,3 +605,17 @@
{"ts":"2026-06-12T02:17:22.737Z","sport":"nba","player_espn_id":"999999999","player_name":"Phantom Player","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"C"} {"ts":"2026-06-12T02:17:22.737Z","sport":"nba","player_espn_id":"999999999","player_name":"Phantom Player","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"C"}
{"ts":"2026-06-12T02:17:23.255Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A"} {"ts":"2026-06-12T02:17:23.255Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A"}
{"ts":"2026-06-12T02:17:23.437Z","sport":"nba","player_espn_id":"99999","player_name":"Phantom","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"B"} {"ts":"2026-06-12T02:17:23.437Z","sport":"nba","player_espn_id":"99999","player_name":"Phantom","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"B"}
{"ts":"2026-06-12T04:19:45.960Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A-"}
{"ts":"2026-06-12T04:19:45.966Z","sport":"nba","player_espn_id":"3136195","player_name":"Karl-Anthony Towns","stat_type":"rebounds","line":13.5,"direction":"under","actual_value":13,"result":"hit","margin":-0.5,"grade":"B+"}
{"ts":"2026-06-12T04:19:45.966Z","sport":"nba","player_espn_id":"3062679","player_name":"Josh Hart","stat_type":"pts_reb_ast","line":10.5,"direction":"over","actual_value":10,"result":"miss","margin":-0.5,"grade":"C"}
{"ts":"2026-06-12T04:19:46.031Z","sport":"nba","player_espn_id":"999999999","player_name":"Phantom Player","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"C"}
{"ts":"2026-06-12T04:19:46.145Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A"}
{"ts":"2026-06-12T04:19:46.395Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A"}
{"ts":"2026-06-12T04:19:46.502Z","sport":"nba","player_espn_id":"99999","player_name":"Phantom","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"B"}
{"ts":"2026-06-12T04:24:33.808Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A-"}
{"ts":"2026-06-12T04:24:33.808Z","sport":"nba","player_espn_id":"3136195","player_name":"Karl-Anthony Towns","stat_type":"rebounds","line":13.5,"direction":"under","actual_value":13,"result":"hit","margin":-0.5,"grade":"B+"}
{"ts":"2026-06-12T04:24:33.808Z","sport":"nba","player_espn_id":"3062679","player_name":"Josh Hart","stat_type":"pts_reb_ast","line":10.5,"direction":"over","actual_value":10,"result":"miss","margin":-0.5,"grade":"C"}
{"ts":"2026-06-12T04:24:33.860Z","sport":"nba","player_espn_id":"999999999","player_name":"Phantom Player","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"C"}
{"ts":"2026-06-12T04:24:33.990Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A"}
{"ts":"2026-06-12T04:24:36.806Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A"}
{"ts":"2026-06-12T04:24:36.976Z","sport":"nba","player_espn_id":"99999","player_name":"Phantom","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"B"}
+14 -1
View File
@@ -300,7 +300,20 @@ async function getOdds(sport) {
}; };
} }
// No cache at all // No cache at all. Session 19 — surface the underlying cause in
// logs so operators can tell odds-api auth failures apart from
// network blips apart from rate-limit responses. The 503 wrapper
// hides everything by design (don't leak details to clients);
// this log gives the operator the signal they need.
const upstreamStatus = err.response && err.response.status;
const upstreamBody = err.response && err.response.data;
console.error(
`[oddsService] ${sport} fetch failed — no cache fallback. ` +
`upstream_status=${upstreamStatus || 'n/a'} ` +
`code=${err.code || 'n/a'} ` +
`message=${err.message} ` +
`upstream_body=${upstreamBody ? JSON.stringify(upstreamBody).slice(0, 200) : 'n/a'}`,
);
if (err.statusCode === 429) throw err; if (err.statusCode === 429) throw err;
const serviceError = new Error('Odds service unavailable.'); const serviceError = new Error('Odds service unavailable.');
serviceError.statusCode = 503; serviceError.statusCode = 503;
+22
View File
@@ -141,6 +141,28 @@ describe('oddsService', () => {
statusCode: 503, statusCode: 503,
}); });
}); });
// Session 19 — diagnostic log line. The 503 client-facing
// message is intentionally vague (no upstream leak); the log
// line is the operator's only signal. This test pins the log
// shape so a future "clean up logs" PR can't silently delete it.
it('logs upstream status + message before throwing 503', async () => {
const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
mockRedis.get.mockResolvedValue(null);
const apiErr = new Error('Request failed with status code 401');
apiErr.code = 'ERR_BAD_REQUEST';
apiErr.response = { status: 401, data: { message: 'Invalid API key' } };
axios.get.mockRejectedValue(apiErr);
await expect(getOdds('nba')).rejects.toMatchObject({ statusCode: 503 });
const logged = errorSpy.mock.calls.map((c) => c.join(' ')).join('\n');
expect(logged).toMatch(/oddsService/);
expect(logged).toMatch(/nba/);
expect(logged).toMatch(/upstream_status=401/);
expect(logged).toMatch(/Invalid API key/);
errorSpy.mockRestore();
});
}); });
describe('getOdds - quota management', () => { describe('getOdds - quota management', () => {
+4 -1
View File
@@ -18,7 +18,10 @@ const CSP = [
"script-src 'self' 'unsafe-eval' 'unsafe-inline' https://js.stripe.com https://us-assets.i.posthog.com https://browser.sentry-cdn.com", "script-src 'self' 'unsafe-eval' 'unsafe-inline' https://js.stripe.com https://us-assets.i.posthog.com https://browser.sentry-cdn.com",
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com", "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com",
"font-src 'self' https://fonts.gstatic.com", "font-src 'self' https://fonts.gstatic.com",
"img-src 'self' data: blob: https://*.supabase.co https://cdn.nba.com https://a.espncdn.com", // Session 19 — broadened img-src for player headshots:
// cdn.wnba.com + img.mlbstatic.com are the league CDNs PrizePicks
// and Sleeper use; we use the same so coverage matches theirs.
"img-src 'self' data: blob: https://*.supabase.co https://cdn.nba.com https://cdn.wnba.com https://img.mlbstatic.com https://a.espncdn.com",
// Session 16 — Sentry browser client posts events to *.sentry.io // Session 16 — Sentry browser client posts events to *.sentry.io
// (and *.ingest.sentry.io for the ingestion endpoints). Adding // (and *.ingest.sentry.io for the ingestion endpoints). Adding
// both forms so the @sentry/nextjs init in SentryInit.tsx can // both forms so the @sentry/nextjs init in SentryInit.tsx can
+5
View File
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" role="img" aria-label="Player headshot placeholder">
<rect width="64" height="64" fill="#12121A"/>
<circle cx="32" cy="26" r="10" fill="#2A2A36"/>
<path d="M14 56c0-9.94 8.06-18 18-18s18 8.06 18 18" fill="#2A2A36"/>
</svg>

After

Width:  |  Height:  |  Size: 312 B

+1 -1
View File
File diff suppressed because one or more lines are too long
+141 -1
View File
@@ -10,6 +10,7 @@ import {
trackScanLimitHit, trackScanLimitHit,
trackUpgradeClicked, trackUpgradeClicked,
} from '@/lib/analytics'; } from '@/lib/analytics';
import { getHeadshotUrl, PLAYER_SILHOUETTE, type HeadshotSport } from '@/lib/playerHeadshot';
type Sport = 'NBA' | 'MLB' | 'WNBA'; type Sport = 'NBA' | 'MLB' | 'WNBA';
@@ -99,6 +100,11 @@ export default function ScanPage() {
const [scanning, setScanning] = useState(false); const [scanning, setScanning] = useState(false);
const [result, setResult] = useState<ScanResponse | null>(null); const [result, setResult] = useState<ScanResponse | null>(null);
const [error, setError] = useState(''); 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 // Auth gate — push anonymous users to signup
useEffect(() => { useEffect(() => {
@@ -127,6 +133,41 @@ export default function ScanPage() {
}; };
}, [sport]); }, [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 // Debounced player search — narrow to selected game when set
const searchPlayers = useCallback( const searchPlayers = useCallback(
async (query: string) => { async (query: string) => {
@@ -327,6 +368,83 @@ export default function ScanPage() {
)} )}
</div> </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 */} {/* Player search */}
<div style={{ marginBottom: 16, position: 'relative' }}> <div style={{ marginBottom: 16, position: 'relative' }}>
<label className="mono" style={labelStyle}>Player</label> <label className="mono" style={labelStyle}>Player</label>
@@ -382,7 +500,29 @@ export default function ScanPage() {
}} }}
style={suggestionStyle} 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 && ( {p.team && (
<span className="mono" style={{ fontSize: 11, color: 'var(--text-tertiary)' }}> <span className="mono" style={{ fontSize: 11, color: 'var(--text-tertiary)' }}>
{p.team} {p.team}
+155 -35
View File
@@ -1,7 +1,11 @@
'use client'; 'use client';
import { useState } from 'react'; import { useState } from 'react';
import PropRow, { PropRowProp, PropRowResult, propRowKey, Tier } from '@/components/PropRow'; import type { PropRowProp, PropRowResult, Tier } from '@/components/PropRow';
// Session 19 — PlayerCard groups props by player so a single player
// with 4 props renders as ONE card with their headshot + 4 stat
// lines, instead of 4 independent stripes that repeat the name.
import PlayerCard, { groupPropsByPlayer } from '@/components/PlayerCard';
/** /**
* GameCard — one game in the Slate (Session 13). Header with teams + * GameCard — one game in the Slate (Session 13). Header with teams +
@@ -60,6 +64,78 @@ function formatTime(iso?: string) {
} }
} }
/**
* Session 19 — produce an ESPN/DK-style team abbreviation from a
* full team name. The odds-api returns full names like
* "Phoenix Mercury" / "Boston Celtics"; the design calls for the
* compact city/club shorthand in the header. We hand-curate the few
* names that don't reduce well from a "take the first word"
* heuristic (St. Louis, Los Angeles, etc.) and otherwise fall back
* to the first three letters of the second word (so "Boston
* Celtics" → "BOS" and "Real Madrid" → "RM"). Soccer clubs are
* shorter and rarely follow city naming, so we just upper-case the
* full name up to 6 chars.
*/
const TEAM_ABBR_OVERRIDES: Record<string, string> = {
'Los Angeles Lakers': 'LAL',
'Los Angeles Clippers': 'LAC',
'Los Angeles Sparks': 'LAS',
'Los Angeles Angels': 'LAA',
'Los Angeles Dodgers': 'LAD',
'San Francisco Giants': 'SF',
'San Diego Padres': 'SD',
'St. Louis Cardinals': 'STL',
'New York Knicks': 'NYK',
'New York Yankees': 'NYY',
'New York Mets': 'NYM',
'New York Liberty': 'NYL',
'New Orleans Pelicans': 'NOP',
'Oklahoma City Thunder': 'OKC',
'Golden State Warriors': 'GSW',
'Portland Trail Blazers': 'POR',
'San Antonio Spurs': 'SA',
'Las Vegas Aces': 'LV',
'Washington Mystics': 'WAS',
'Washington Wizards': 'WAS',
'Washington Nationals': 'WSH',
'Tampa Bay Rays': 'TB',
'Kansas City Royals': 'KC',
'Toronto Blue Jays': 'TOR',
'Toronto Raptors': 'TOR',
'Chicago White Sox': 'CWS',
'Chicago Cubs': 'CHC',
};
export function teamAbbr(fullName: string, sport: SlateSport): string {
if (!fullName) return '';
if (TEAM_ABBR_OVERRIDES[fullName]) return TEAM_ABBR_OVERRIDES[fullName];
if (sport === 'soccer') {
// Clubs are typically one short name (Arsenal, Liverpool) or two
// ("Real Madrid"). Compose initials when there are 2+ words,
// otherwise truncate.
const words = fullName.trim().split(/\s+/);
if (words.length >= 2) return words.map((w) => w[0]).join('').toUpperCase().slice(0, 4);
return fullName.slice(0, 6).toUpperCase();
}
// US team-name pattern: "<City> <Nickname>" — take the nickname's
// first three letters (Boston Celtics → CEL → bumped to BOS via
// the override table for the well-known cases). For names we
// didn't override, the city-prefix usually carries more identity.
const words = fullName.trim().split(/\s+/);
if (words.length >= 2) {
// Two-word names: prefer the city (first word) → first 3 letters.
return words[0].slice(0, 3).toUpperCase();
}
return fullName.slice(0, 4).toUpperCase();
}
const SPORT_LABEL: Record<SlateSport, string> = {
nba: 'NBA',
wnba: 'WNBA',
mlb: 'MLB',
soccer: 'SOCCER',
};
export default function GameCard(props: GameCardProps) { export default function GameCard(props: GameCardProps) {
const { const {
sport, homeTeam, awayTeam, gameTime, venue, context, sport, homeTeam, awayTeam, gameTime, venue, context,
@@ -69,8 +145,16 @@ export default function GameCard(props: GameCardProps) {
} = props; } = props;
const [expanded, setExpanded] = useState(false); const [expanded, setExpanded] = useState(false);
const visibleProps = expanded ? propList : propList.slice(0, defaultVisible); // Session 19 — visibility budget now applies to PLAYERS, not raw
const hiddenCount = propList.length - visibleProps.length; // props. Showing the first 4 prop rows that all belonged to the
// same player meant the user only saw one player's card before
// hitting "+ more." Grouping by player first lines up the budget
// with how the UI actually reads.
const playerGroups = groupPropsByPlayer(propList);
// Stable alphabetical ordering — matches Slate's groupByGame sort.
playerGroups.sort((a, b) => a.player.localeCompare(b.player));
const visiblePlayerGroups = expanded ? playerGroups : playerGroups.slice(0, defaultVisible);
const hiddenPlayerCount = playerGroups.length - visiblePlayerGroups.length;
const accent = SPORT_ACCENT[sport]; const accent = SPORT_ACCENT[sport];
return ( return (
@@ -94,26 +178,58 @@ export default function GameCard(props: GameCardProps) {
}} }}
> >
<div style={{ minWidth: 0, flex: 1 }}> <div style={{ minWidth: 0, flex: 1 }}>
{/* Session 19 — team-abbreviation header: bold abbreviations
with the full names underneath in the meta line, plus a
sport-color badge to the right. ESPN/DK pattern. */}
<div <div
style={{ style={{
display: 'flex',
alignItems: 'center',
gap: 10,
fontSize: 16, fontSize: 16,
fontWeight: 700, fontWeight: 700,
color: 'var(--text-0, #F0F0F5)', color: 'var(--text-0, #F0F0F5)',
letterSpacing: '-0.01em', letterSpacing: '-0.01em',
display: 'flex',
alignItems: 'baseline',
gap: 8,
flexWrap: 'wrap',
}} }}
> >
<span aria-hidden style={{ fontSize: 14 }}>{SPORT_EMOJI[sport]}</span> <span aria-hidden style={{ fontSize: 14 }}>{SPORT_EMOJI[sport]}</span>
<span style={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}> <span className="mono" style={{ letterSpacing: '0.04em' }}>
{awayTeam} {teamAbbr(awayTeam, sport)}
<span style={{ color: 'var(--text-tertiary, #6B6B7B)', margin: '0 8px', fontWeight: 400 }}>
@
</span>
{homeTeam}
</span> </span>
<span style={{ color: 'var(--text-tertiary, #6B6B7B)', fontWeight: 400, fontSize: 13 }}>
vs
</span>
<span className="mono" style={{ letterSpacing: '0.04em' }}>
{teamAbbr(homeTeam, sport)}
</span>
<span
className="mono"
style={{
marginLeft: 'auto',
fontSize: 10,
fontWeight: 700,
color: '#0A0A0F',
background: accent,
padding: '3px 8px',
borderRadius: 4,
letterSpacing: '0.08em',
}}
aria-label={`Sport: ${SPORT_LABEL[sport]}`}
>
{SPORT_LABEL[sport]}
</span>
</div>
<div
style={{
marginTop: 4,
fontSize: 12,
color: 'var(--text-secondary, #8A8A9A)',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
}}
>
{awayTeam} <span style={{ color: 'var(--text-tertiary, #6B6B7B)' }}>@</span> {homeTeam}
</div> </div>
<div <div
className="mono" className="mono"
@@ -125,7 +241,7 @@ export default function GameCard(props: GameCardProps) {
textTransform: 'uppercase', textTransform: 'uppercase',
}} }}
> >
{[formatTime(gameTime), venue, context].filter(Boolean).join(' · ')} {[formatTime(gameTime), venue, context].filter(Boolean).join(' · ') || ' '}
</div> </div>
</div> </div>
<div <div
@@ -155,24 +271,28 @@ export default function GameCard(props: GameCardProps) {
Props for this game aren&apos;t published yet. Props for this game aren&apos;t published yet.
</p> </p>
) : ( ) : (
<ul style={{ listStyle: 'none', padding: 0, margin: 0 }}> <div>
{visibleProps.map((p) => { {visiblePlayerGroups.map((g) => (
const key = propRowKey(p); <PlayerCard
return ( key={g.player}
<PropRow player={g.player}
key={key} sport={sport}
prop={p} // Per-player team would require a roster lookup that
result={gradedProps.get(key) ?? null} // the current odds endpoints don't expose. Leave team
loading={loadingKey === key} // empty — the game card header already shows both
error={errorByKey?.[key] ?? null} // teams, so the user has the context.
tier={tier} team={undefined}
onRead={onGrade} props={g.props}
onUpgrade={onUpgrade} gradedProps={gradedProps}
/> loadingKey={loadingKey}
); errorByKey={errorByKey}
})} tier={tier}
{hiddenCount > 0 && ( onGrade={onGrade}
<li onUpgrade={onUpgrade}
/>
))}
{hiddenPlayerCount > 0 && (
<div
style={{ style={{
borderTop: '1px solid var(--border, #1A1A24)', borderTop: '1px solid var(--border, #1A1A24)',
padding: '10px 16px', padding: '10px 16px',
@@ -191,11 +311,11 @@ export default function GameCard(props: GameCardProps) {
fontWeight: 600, fontWeight: 600,
}} }}
> >
+ {hiddenCount} more prop{hiddenCount === 1 ? '' : 's'} + {hiddenPlayerCount} more player{hiddenPlayerCount === 1 ? '' : 's'}
</button> </button>
</li> </div>
)} )}
</ul> </div>
)} )}
</article> </article>
); );
+173
View File
@@ -0,0 +1,173 @@
'use client';
import { useState } from 'react';
import PropRow, { PropRowProp, PropRowResult, propRowKey, Tier } from '@/components/PropRow';
import { getHeadshotUrl, PLAYER_SILHOUETTE, HeadshotSport } from '@/lib/playerHeadshot';
/**
* PlayerCard — one player and all their props (Session 19).
*
* Slate redesign: previously each prop was an independent row, so a
* player with 4 props (Pts/Reb/Ast/3PT) appeared as 4 stripes that
* repeated the player's name. That reads like a spreadsheet, not a
* sports product. PlayerCard groups: a header with headshot + name +
* team sits above N PropRow children.
*
* Headshot resolution lives in `lib/playerHeadshot.ts`. The `<img>`
* fall-through swaps to the bundled silhouette if the CDN 404s on a
* player whose league hasn't published a headshot yet.
*/
export interface PlayerCardProps {
player: string;
sport: HeadshotSport;
team?: string;
position?: string;
/** League ID — NBA stats.com ID, WNBA player ID, or MLB people ID. */
playerId?: string | number | null;
/** ESPN ID fallback (used when no league ID is known). */
espnId?: string | number | null;
/** Pre-cached headshot URL (soccer / API-Football). */
photoUrl?: string | null;
props: PropRowProp[];
gradedProps: Map<string, PropRowResult>;
loadingKey?: string | null;
errorByKey?: Record<string, string | undefined>;
tier?: Tier;
onGrade: (prop: PropRowProp) => void;
onUpgrade?: () => void;
}
export default function PlayerCard(props: PlayerCardProps) {
const {
player, sport, team, position, playerId, espnId, photoUrl,
props: propList, gradedProps, loadingKey, errorByKey,
tier = 'free', onGrade, onUpgrade,
} = props;
// Compute the initial headshot URL — onError swaps to silhouette.
const initialUrl = getHeadshotUrl({
sport,
playerId,
espnId,
cachedPhotoUrl: photoUrl,
});
const [headshotSrc, setHeadshotSrc] = useState(initialUrl);
const subtitle = [team, position].filter(Boolean).join(' · ');
return (
<div
style={{
background: 'var(--bg-2, #12121A)',
borderTop: '1px solid var(--border, #1A1A24)',
}}
>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 12,
padding: '10px 16px',
background: 'rgba(255,255,255,0.02)',
}}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={headshotSrc}
alt={`${player} headshot`}
width={40}
height={40}
loading="lazy"
onError={() => {
if (headshotSrc !== PLAYER_SILHOUETTE) setHeadshotSrc(PLAYER_SILHOUETTE);
}}
style={{
width: 40,
height: 40,
borderRadius: '50%',
objectFit: 'cover',
background: 'var(--bg-elevated, #15151F)',
border: '1px solid var(--border, #1A1A24)',
flexShrink: 0,
}}
/>
<div style={{ minWidth: 0, flex: 1 }}>
<div
style={{
fontSize: 14,
fontWeight: 600,
color: 'var(--text-0, #F0F0F5)',
letterSpacing: '-0.005em',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
}}
>
{player}
</div>
{subtitle && (
<div
className="mono"
style={{
marginTop: 2,
fontSize: 11,
color: 'var(--text-tertiary, #6B6B7B)',
letterSpacing: '0.06em',
textTransform: 'uppercase',
}}
>
{subtitle}
</div>
)}
</div>
<div
className="mono"
style={{
fontSize: 10,
color: 'var(--text-tertiary, #6B6B7B)',
background: 'rgba(255,255,255,0.04)',
padding: '3px 8px',
borderRadius: 999,
whiteSpace: 'nowrap',
}}
aria-label={`${propList.length} props for ${player}`}
>
{propList.length} prop{propList.length === 1 ? '' : 's'}
</div>
</div>
<ul style={{ listStyle: 'none', padding: 0, margin: 0 }}>
{propList.map((p) => {
const key = propRowKey(p);
return (
<PropRow
key={key}
prop={p}
result={gradedProps.get(key) ?? null}
loading={loadingKey === key}
error={errorByKey?.[key] ?? null}
tier={tier}
onRead={onGrade}
onUpgrade={onUpgrade}
/>
);
})}
</ul>
</div>
);
}
/**
* groupPropsByPlayer — preserves the original prop order (first
* appearance of a player wins their slot), so the Slate's sort
* (alphabetical by player) is the actual visual sort.
*/
export function groupPropsByPlayer(propList: PropRowProp[]): Array<{ player: string; props: PropRowProp[] }> {
const byPlayer = new Map<string, PropRowProp[]>();
for (const p of propList) {
const existing = byPlayer.get(p.player);
if (existing) existing.push(p);
else byPlayer.set(p.player, [p]);
}
return Array.from(byPlayer.entries()).map(([player, props]) => ({ player, props }));
}
+101
View File
@@ -0,0 +1,101 @@
/**
* Player headshot URL construction (Session 19).
*
* 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.
* 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.
*/
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. */
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 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;
}
/**
* 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;
nba_id?: string | number | null;
wnba_id?: string | number | null;
mlb_id?: string | number | null;
espn_id?: string | number | null;
photo_url?: string | null;
}): string {
const sport = String(player.sport || '').toLowerCase();
const leagueId =
sport === 'nba' ? player.nba_id :
sport === 'wnba' ? player.wnba_id :
sport === 'mlb' ? player.mlb_id :
null;
return getHeadshotUrl({
sport,
playerId: leagueId,
espnId: player.espn_id,
cachedPhotoUrl: player.photo_url,
});
}
+53
View File
@@ -0,0 +1,53 @@
/**
* Tier-gating helpers (Session 19).
*
* Shared, declarative answers to the questions every list-rendering
* component asks: "should this user see everything, or a teaser?"
* Centralized so we don't drift across components (one screen
* showing 3 rows + upgrade hint, another showing 5 rows + lock
* icon, a third showing all rows with a partial blur — that path
* leads to chaos).
*
* The actual SECURITY boundary on tier-locked data lives on the
* server (the API routes that produce these lists already filter by
* the bearer token's tier). These helpers govern presentation only,
* matching the server-supplied limit so the UI doesn't promise more
* than the API will deliver.
*
* Free users see top 3. Paid (analyst + desk) see everything.
* Africa tier (entry-level subscription) also sees everything for
* lists; gradient features live behind canSeeGradeDetails.
*/
export type Tier = 'free' | 'africa' | 'analyst' | 'desk' | string;
const PAID_TIERS: ReadonlySet<string> = new Set(['analyst', 'desk']);
const FULL_LIST_TIERS: ReadonlySet<string> = new Set(['africa', 'analyst', 'desk']);
const FREE_VISIBLE_COUNT = 3;
export function canSeeFullLists(tier: Tier): boolean {
if (!tier) return false;
return FULL_LIST_TIERS.has(String(tier).toLowerCase());
}
export function canSeeGradeDetails(tier: Tier): boolean {
if (!tier) return false;
return PAID_TIERS.has(String(tier).toLowerCase());
}
export function getVisibleCount(tier: Tier, totalCount: number): number {
if (totalCount <= 0) return 0;
if (canSeeFullLists(tier)) return totalCount;
return Math.min(FREE_VISIBLE_COUNT, totalCount);
}
/**
* Inverse helper for UX copy — "Upgrade to see N more results."
* Returns 0 when the tier already sees everything (so the upsell
* line can short-circuit without arithmetic).
*/
export function getHiddenCount(tier: Tier, totalCount: number): number {
if (canSeeFullLists(tier)) return 0;
return Math.max(0, totalCount - FREE_VISIBLE_COUNT);
}