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}
|
||||
|
||||
+155
-35
@@ -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<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) {
|
||||
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) {
|
||||
}}
|
||||
>
|
||||
<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
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 10,
|
||||
fontSize: 16,
|
||||
fontWeight: 700,
|
||||
color: 'var(--text-0, #F0F0F5)',
|
||||
letterSpacing: '-0.01em',
|
||||
display: 'flex',
|
||||
alignItems: 'baseline',
|
||||
gap: 8,
|
||||
flexWrap: 'wrap',
|
||||
}}
|
||||
>
|
||||
<span aria-hidden style={{ fontSize: 14 }}>{SPORT_EMOJI[sport]}</span>
|
||||
<span style={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||
{awayTeam}
|
||||
<span style={{ color: 'var(--text-tertiary, #6B6B7B)', margin: '0 8px', fontWeight: 400 }}>
|
||||
@
|
||||
</span>
|
||||
{homeTeam}
|
||||
<span className="mono" style={{ letterSpacing: '0.04em' }}>
|
||||
{teamAbbr(awayTeam, sport)}
|
||||
</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
|
||||
className="mono"
|
||||
@@ -125,7 +241,7 @@ export default function GameCard(props: GameCardProps) {
|
||||
textTransform: 'uppercase',
|
||||
}}
|
||||
>
|
||||
{[formatTime(gameTime), venue, context].filter(Boolean).join(' · ')}
|
||||
{[formatTime(gameTime), venue, context].filter(Boolean).join(' · ') || ' '}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
@@ -155,24 +271,28 @@ export default function GameCard(props: GameCardProps) {
|
||||
Props for this game aren't published yet.
|
||||
</p>
|
||||
) : (
|
||||
<ul style={{ listStyle: 'none', padding: 0, margin: 0 }}>
|
||||
{visibleProps.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}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{hiddenCount > 0 && (
|
||||
<li
|
||||
<div>
|
||||
{visiblePlayerGroups.map((g) => (
|
||||
<PlayerCard
|
||||
key={g.player}
|
||||
player={g.player}
|
||||
sport={sport}
|
||||
// Per-player team would require a roster lookup that
|
||||
// the current odds endpoints don't expose. Leave team
|
||||
// empty — the game card header already shows both
|
||||
// teams, so the user has the context.
|
||||
team={undefined}
|
||||
props={g.props}
|
||||
gradedProps={gradedProps}
|
||||
loadingKey={loadingKey}
|
||||
errorByKey={errorByKey}
|
||||
tier={tier}
|
||||
onGrade={onGrade}
|
||||
onUpgrade={onUpgrade}
|
||||
/>
|
||||
))}
|
||||
{hiddenPlayerCount > 0 && (
|
||||
<div
|
||||
style={{
|
||||
borderTop: '1px solid var(--border, #1A1A24)',
|
||||
padding: '10px 16px',
|
||||
@@ -191,11 +311,11 @@ export default function GameCard(props: GameCardProps) {
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
+ {hiddenCount} more prop{hiddenCount === 1 ? '' : 's'}
|
||||
+ {hiddenPlayerCount} more player{hiddenPlayerCount === 1 ? '' : 's'}
|
||||
</button>
|
||||
</li>
|
||||
</div>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
);
|
||||
|
||||
@@ -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 }));
|
||||
}
|
||||
@@ -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