Files
vyndr/web/src/app/team/[abbr]/TeamHub.tsx
T
builtbykev 24af247b29 DS0 (Design v2): the Entity Layer — teams/players/books render as themselves
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>
2026-07-12 19:02:18 -04:00

185 lines
9.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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="AZ" />
{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>
);
}