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
+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);
}