24af247b29
Founder's #1 priority. A single cached asset+rendering system, swapped into every surface, so entities stop being flat gray strings (DESIGN-SPEC Part 2). - web/src/lib/teamMeta.js: static registry for ALL 4 sports — 30 MLB, 30 NBA, 13 WNBA teams + 48 World Cup national teams, each with real colors + the ESPN logo/flag CDN abbr. resolveTeam (abbr/full-name/nickname/alias), teamLogoUrl (statsapi->ESPN mapping: AZ->ari, CWS->chw; soccer via the countries/ flag CDN), accentColor (picks the VISIBLE color of the pair so a #000000 primary never renders an invisible accent on #06060B). Colors + abbrs sourced once from ESPN's team API — stable public facts, zero-latency static data, no paid dependency. - TeamLogo: real ESPN-CDN logo with a team-colored MONOGRAM fallback (never a gray box / bare abbr). PlayerAvatar: real headshot with a team-colored monogram fallback (kills the gray silhouette). BookWordmark: brand-color wordmark, proper casing (DraftKings, not 'draftkings'). - Swapped into the class-level shared components so it propagates to ALL surfaces: GameCard (team logos + team-colored accent edge), StatStrip (player identity avatar), StreaksPanel (P0 billboard avatars), TeamHub header (the team's real crest leads its hub). Barrel-exported. ZERO OUT-OF-POCKET: ESPN logo/flag CDN + league headshot CDNs, all verified 200 image/png across MLB/NBA/WNBA/soccer. ACCEPTANCE (SSR render proof): /entity-demo harness server-rendered the exact real asset URLs across all 4 sports — mlb/500/nyy.png, mlb/500/chw.png (White Sox, correct ESPN abbr), nba/500/lal.png, wnba/500/ny.png, countries/500/ usa.png + bra/eng/arg/jpn flags, real mlbstatic/nba headshots (Judge 592450, LeBron 2544), DraftKings/FanDuel wordmarks. Each URL curl-verified 200 image/png. Harness removed post-proof (not a product surface). Pixel screenshot blocked by WSL2<->Windows-Chrome localhost networking, not code. 2757 -> 2776 tests (+13 entityLayer, +6 boot resilience), web build exit 0. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
185 lines
9.7 KiB
TypeScript
185 lines
9.7 KiB
TypeScript
'use client';
|
||
|
||
import { useEffect, useMemo, useState } from 'react';
|
||
import { useRouter } from 'next/navigation';
|
||
import SportBadge from '@/components/vyndr/SportBadge';
|
||
import GradeBadge from '@/components/vyndr/GradeBadge';
|
||
import ArchetypeBadge from '@/components/vyndr/ArchetypeBadge';
|
||
import TeamLogo from '@/components/vyndr/TeamLogo';
|
||
import ModelRecord from '@/components/vyndr/ModelRecord';
|
||
import { playerHref } from '@/lib/playerHref';
|
||
import { useParlay, legKey } from '@/contexts/ParlayContext';
|
||
|
||
interface RosterProp { stat: string; line: number | string; side: string; grade: string }
|
||
interface RosterPlayer {
|
||
player: string; position: string | null;
|
||
archetype: { primary: string } | null;
|
||
stats: { k: string; v: string }[];
|
||
props: RosterProp[];
|
||
propCount: number;
|
||
}
|
||
interface TeamHubData {
|
||
team: { name: string; abbr: string; sport: string };
|
||
roster: RosterPlayer[];
|
||
record?: { wins: number; losses: number };
|
||
note?: string | null;
|
||
}
|
||
|
||
type SortKey = 'archetype' | 'props' | 'name';
|
||
|
||
export default function TeamHub({ abbr, sport }: { abbr: string; sport: string }) {
|
||
const router = useRouter();
|
||
const { addLeg, removeLeg, legs, hasLeg } = useParlay();
|
||
const [data, setData] = useState<TeamHubData | null>(null);
|
||
const [state, setState] = useState<'loading' | 'ready' | 'error'>('loading');
|
||
const [sortBy, setSortBy] = useState<SortKey>('props');
|
||
const [archetypeFilter, setArchetypeFilter] = useState<string | null>(null);
|
||
|
||
useEffect(() => {
|
||
let active = true;
|
||
setState('loading');
|
||
fetch(`/api/team/${encodeURIComponent(abbr)}?sport=${encodeURIComponent(sport)}`)
|
||
.then((r) => (r.ok ? r.json() : Promise.reject()))
|
||
.then((d) => { if (active) { setData(d); setState('ready'); } })
|
||
.catch(() => { if (active) setState('error'); });
|
||
return () => { active = false; };
|
||
}, [abbr, sport]);
|
||
|
||
const archetypesPresent = useMemo(() => {
|
||
const set = new Set<string>();
|
||
(data?.roster || []).forEach((p) => { if (p.archetype?.primary) set.add(p.archetype.primary); });
|
||
return [...set].sort();
|
||
}, [data]);
|
||
|
||
const roster = useMemo(() => {
|
||
let list = [...(data?.roster || [])];
|
||
if (archetypeFilter) list = list.filter((p) => p.archetype?.primary === archetypeFilter);
|
||
list.sort((a, b) => {
|
||
if (sortBy === 'name') return a.player.localeCompare(b.player);
|
||
if (sortBy === 'props') return b.propCount - a.propCount || a.player.localeCompare(b.player);
|
||
// archetype: named first (A-Z), unclassified last
|
||
const aa = a.archetype?.primary || 'zzz';
|
||
const bb = b.archetype?.primary || 'zzz';
|
||
return aa.localeCompare(bb) || b.propCount - a.propCount;
|
||
});
|
||
return list;
|
||
}, [data, sortBy, archetypeFilter]);
|
||
|
||
if (state === 'loading') {
|
||
return <section style={{ minHeight: '50vh', display: 'flex', alignItems: 'center', justifyContent: 'center' }}><p className="mono" style={{ color: 'var(--text-2)' }}>Loading team intelligence…</p></section>;
|
||
}
|
||
if (state === 'error' || !data) {
|
||
return (
|
||
<section style={{ maxWidth: 600, margin: '0 auto', padding: '40px 16px' }}>
|
||
<p className="mono" style={{ color: 'var(--miss)' }}>Team not found.</p>
|
||
<a href="/dashboard" className="mono" style={{ color: 'var(--g-a)', fontSize: 13 }}>← Back to Slate</a>
|
||
</section>
|
||
);
|
||
}
|
||
|
||
const SortBtn = ({ k, label }: { k: SortKey; label: string }) => (
|
||
<button type="button" onClick={() => setSortBy(k)} className="mono"
|
||
style={{ cursor: 'pointer', padding: '6px 11px', borderRadius: 7, fontSize: 11, fontWeight: 700, letterSpacing: '0.04em',
|
||
background: sortBy === k ? 'color-mix(in srgb, var(--g-a) 14%, transparent)' : 'var(--bg-2)',
|
||
border: `1px solid ${sortBy === k ? 'var(--g-a)' : 'var(--border-hi)'}`, color: sortBy === k ? 'var(--g-a)' : 'var(--text-1)' }}>
|
||
{label}
|
||
</button>
|
||
);
|
||
|
||
const onPropClick = (p: RosterPlayer, pr: RosterProp) => {
|
||
const sp = (data.team.sport || 'mlb').toUpperCase();
|
||
const leg = {
|
||
sport: (sp === 'MLB' || sp === 'WNBA' ? sp : 'NBA') as 'NBA' | 'MLB' | 'WNBA',
|
||
player: p.player, team: data.team.abbr, game: '', archetype: p.archetype?.primary,
|
||
stat: String(pr.stat), line: Number(pr.line) || 0,
|
||
direction: (String(pr.side).toUpperCase() === 'U' ? 'under' : 'over') as 'over' | 'under',
|
||
grade: String(pr.grade || 'C'), confidence: 60,
|
||
};
|
||
const k = legKey(leg);
|
||
const existing = legs.find((l) => legKey(l) === k);
|
||
if (existing) removeLeg(existing.id); else addLeg(leg);
|
||
};
|
||
const propActive = (p: RosterPlayer, pr: RosterProp) =>
|
||
hasLeg(legKey({ player: p.player, stat: String(pr.stat), line: Number(pr.line) || 0, direction: String(pr.side).toUpperCase() === 'U' ? 'under' : 'over' }));
|
||
|
||
return (
|
||
<section style={{ maxWidth: 920, margin: '0 auto', padding: '20px 16px 120px' }}>
|
||
<a href="/dashboard" className="mono" style={{ fontSize: 12, color: 'var(--text-1)', textDecoration: 'none' }}>← Back to Slate</a>
|
||
|
||
{/* Header — DS0: the team's real crest leads the hub. */}
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 14, margin: '14px 0 22px', flexWrap: 'wrap' }}>
|
||
<TeamLogo team={data.team.abbr} sport={data.team.sport} size={44} title={data.team.name} />
|
||
<SportBadge sport={data.team.sport} />
|
||
<h1 style={{ margin: 0, fontSize: 28, fontWeight: 800, letterSpacing: '-0.015em' }}>{data.team.name}</h1>
|
||
<span className="mono" style={{ fontSize: 13, color: 'var(--text-1)', letterSpacing: '0.06em' }}>{data.team.abbr}</span>
|
||
{data.record && <span className="mono" style={{ fontSize: 13, color: 'var(--text-1)' }}>{data.record.wins}-{data.record.losses}</span>}
|
||
</div>
|
||
|
||
{/* Session 60 (5.2, migration 020) — VYNDR-on-team: the model's settled
|
||
record on THIS team's players. Deferred-render until rows exist. */}
|
||
<div style={{ margin: '0 0 16px' }}>
|
||
<ModelRecord team={data.team.name} sport={data.team.sport} align="left" />
|
||
</div>
|
||
|
||
{data.note && <p className="mono" style={{ fontSize: 12, color: 'var(--text-2)', marginBottom: 16 }}>{data.note}</p>}
|
||
|
||
{/* Controls */}
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap', marginBottom: 16 }}>
|
||
<span className="mono" style={{ fontSize: 10, color: 'var(--text-2)', letterSpacing: '0.06em' }}>SORT</span>
|
||
<SortBtn k="archetype" label="Archetype" /><SortBtn k="props" label="Graded" /><SortBtn k="name" label="A–Z" />
|
||
{archetypesPresent.length > 0 && <span style={{ width: 1, height: 18, background: 'var(--border-hi)', margin: '0 4px' }} />}
|
||
{archetypesPresent.map((a) => (
|
||
<button key={a} type="button" onClick={() => setArchetypeFilter((f) => (f === a ? null : a))}>
|
||
<ArchetypeBadge archetype={a} size="sm" variant={archetypeFilter === a ? 'full' : 'tint'} />
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
{/* Roster */}
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||
{roster.map((p, i) => {
|
||
const noProps = p.propCount === 0;
|
||
return (
|
||
<div key={i} style={{ background: 'var(--bg-1)', border: '1px solid var(--border)', borderRadius: 12, padding: '14px 16px', opacity: noProps ? 0.6 : 1 }}>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 9, flexWrap: 'wrap', marginBottom: 8 }}>
|
||
{p.archetype && <ArchetypeBadge archetype={p.archetype.primary} size="sm" variant="full" />}
|
||
<a href={playerHref(p.player, data.team.sport)} style={{ fontWeight: 700, fontSize: 15, color: '#fff', textDecoration: 'none' }}>{p.player}</a>
|
||
{p.position && <span className="mono" style={{ fontSize: 11, color: 'var(--text-1)' }}>{p.position}</span>}
|
||
</div>
|
||
{p.stats.length > 0 && (
|
||
<div className="mono game-lines-grid" style={{ fontSize: 12, color: 'var(--text-0)', marginBottom: noProps ? 0 : 8 }}>
|
||
{p.stats.map((s, j) => (
|
||
<span key={j}>{j > 0 && <span style={{ color: '#3A3A48', margin: '0 8px' }}>·</span>}{s.v} {s.k}</span>
|
||
))}
|
||
</div>
|
||
)}
|
||
{noProps ? (
|
||
<div className="mono" style={{ fontSize: 11, color: 'var(--text-2)', fontStyle: 'italic' }}>No active props</div>
|
||
) : (
|
||
<div className="game-lines-grid" style={{ display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' }}>
|
||
{p.props.map((pr, j) => {
|
||
const active = propActive(p, pr);
|
||
return (
|
||
<span key={j} className="mono" style={{ display: 'inline-flex', alignItems: 'center', gap: 6, fontSize: 11, color: '#B8BCC8' }}>
|
||
{pr.stat} {pr.side}{pr.line} <GradeBadge grade={pr.grade} size="sm" />
|
||
<button type="button" onClick={() => onPropClick(p, pr)} title={active ? 'Remove from Parlay' : 'Add to Parlay'}
|
||
className="mono" style={{ cursor: 'pointer', width: 20, height: 20, borderRadius: 5, lineHeight: 1,
|
||
background: active ? 'color-mix(in srgb, var(--g-a) 18%, transparent)' : 'var(--bg-2)',
|
||
border: `1px solid ${active ? 'var(--g-a)' : 'var(--border-hi)'}`, color: 'var(--g-a)', fontSize: 13, fontWeight: 700,
|
||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center' }}>
|
||
{active ? '✓' : '+'}
|
||
</button>
|
||
</span>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
})}
|
||
{roster.length === 0 && <p className="mono" style={{ color: 'var(--text-2)' }}>No players match this filter.</p>}
|
||
</div>
|
||
</section>
|
||
);
|
||
}
|