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:
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user