@@ -382,7 +500,29 @@ export default function ScanPage() {
}}
style={suggestionStyle}
>
-
{p.full_name}
+ {/* 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 */}
+

{ (e.currentTarget as HTMLImageElement).src = PLAYER_SILHOUETTE; }}
+ style={{
+ width: 28,
+ height: 28,
+ borderRadius: '50%',
+ background: 'var(--bg-elevated)',
+ flexShrink: 0,
+ marginRight: 10,
+ objectFit: 'cover',
+ }}
+ />
+
{p.full_name}
{p.team && (
{p.team}
diff --git a/web/src/components/GameCard.tsx b/web/src/components/GameCard.tsx
index 8b869af..a73ad26 100644
--- a/web/src/components/GameCard.tsx
+++ b/web/src/components/GameCard.tsx
@@ -1,7 +1,11 @@
'use client';
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 +
@@ -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 = {
+ '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: " " — 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 = {
+ nba: 'NBA',
+ wnba: 'WNBA',
+ mlb: 'MLB',
+ soccer: 'SOCCER',
+};
+
export default function GameCard(props: GameCardProps) {
const {
sport, homeTeam, awayTeam, gameTime, venue, context,
@@ -69,8 +145,16 @@ export default function GameCard(props: GameCardProps) {
} = props;
const [expanded, setExpanded] = useState(false);
- const visibleProps = expanded ? propList : propList.slice(0, defaultVisible);
- const hiddenCount = propList.length - visibleProps.length;
+ // Session 19 — visibility budget now applies to PLAYERS, not raw
+ // 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];
return (
@@ -94,26 +178,58 @@ export default function GameCard(props: GameCardProps) {
}}
>
+ {/* 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. */}
{SPORT_EMOJI[sport]}
-
- {awayTeam}
-
- @
-
- {homeTeam}
+
+ {teamAbbr(awayTeam, sport)}
+
+ vs
+
+
+ {teamAbbr(homeTeam, sport)}
+
+
+ {SPORT_LABEL[sport]}
+
+
+
+ {awayTeam} @ {homeTeam}
- {[formatTime(gameTime), venue, context].filter(Boolean).join(' · ')}
+ {[formatTime(gameTime), venue, context].filter(Boolean).join(' · ') || ' '}
)}
);
diff --git a/web/src/components/PlayerCard.tsx b/web/src/components/PlayerCard.tsx
new file mode 100644
index 0000000..9d82cb5
--- /dev/null
+++ b/web/src/components/PlayerCard.tsx
@@ -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 `
`
+ * 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;
+ loadingKey?: string | null;
+ errorByKey?: Record;
+ 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 (
+
+
+ {/* eslint-disable-next-line @next/next/no-img-element */}
+

{
+ 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,
+ }}
+ />
+
+
+ {player}
+
+ {subtitle && (
+
+ {subtitle}
+
+ )}
+
+
+ {propList.length} prop{propList.length === 1 ? '' : 's'}
+
+
+
+ {propList.map((p) => {
+ const key = propRowKey(p);
+ return (
+
+ );
+ })}
+
+
+ );
+}
+
+/**
+ * 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();
+ 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 }));
+}
diff --git a/web/src/lib/playerHeadshot.ts b/web/src/lib/playerHeadshot.ts
new file mode 100644
index 0000000..f0b3e7e
--- /dev/null
+++ b/web/src/lib/playerHeadshot.ts
@@ -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.
+ *
+ * `
` 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 = {
+ 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,
+ });
+}
diff --git a/web/src/lib/tierGate.ts b/web/src/lib/tierGate.ts
new file mode 100644
index 0000000..e0ccfdc
--- /dev/null
+++ b/web/src/lib/tierGate.ts
@@ -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 = new Set(['analyst', 'desk']);
+const FULL_LIST_TIERS: ReadonlySet = 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);
+}