Session 42: Player Intelligence System — archetypes, stat strips, player profile, enhanced cards (2011 tests)
Built from the Claude Design "VYNDR Player Intelligence" bundle (10 sections). - Archetypes: src/services/archetypeService.js — 41 archetypes (15 NBA / 5 WNBA-unique / 15 MLB / 6 soccer), classify -> primary+secondary+blend. Frontend visual map web/src/lib/archetypes.js (colors verified == backend). ArchetypeBadge (full/ghost/tint + glyphs) + ArchetypeBlend (DNA bar). - StatStrip (compact/expanded): player name once, horizontal mono stats, inline GradeBadge props, onPlayerClick -> profile. - Stats API: extended src/routes/stats.js with /player/:name, /leaders, /game/:id (rate-limited). Aggregation in playerIntelService.js (sanitizes name param; grades cache; graceful on cold cache). Next proxies added. - Player Profile /player/[name]: all 9 design sections, graceful empty states. - Enhanced GameCard (MLB pitchers + player-grouped StatStrips) + GradeResultCard (archetype strip + stat context + VYNDR intelligence, optional/self-hiding via gradeAdapter.buildIntelFields). Player-name links wired everywhere. - Settings page replaces the S41 redirect (account/subscription/notifications/ display/responsible-play/danger-zone with DELETE-gated delete). LINKS to the real /settings/security MFA page — does not replace it. + BookChip. - Bonus: Stats Explorer /explore (real /api/stats/leaders leaderboard); added Explore + Settings to Nav MORE. Deferred (need data pipelines, Session 43): Team Hub, Offseason Intel, Slate redesign, Stats Explorer sub-panels. Backend 1940 -> 2011 tests (+71), 157 suites. Web build clean (exit 0). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -20,12 +20,13 @@ const PRIMARY = [
|
||||
{ id: 'ledger', label: 'Ledger', href: '/ledger' },
|
||||
];
|
||||
const MORE = [
|
||||
{ label: 'Explore', href: '/explore' },
|
||||
{ label: 'Compare', href: '/compare' },
|
||||
{ label: 'Tracker', href: '/tracker' },
|
||||
{ label: 'The Report', href: '/blog' },
|
||||
{ label: 'Invite', href: '/invite' },
|
||||
{ label: 'Pricing', href: '/pricing' },
|
||||
{ label: 'Settings', href: '/settings/security' },
|
||||
{ label: 'Settings', href: '/settings' },
|
||||
];
|
||||
|
||||
const TICKER_ITEMS = [
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { badgeStyle, glyphSvg } from '@/lib/archetypes';
|
||||
|
||||
interface ArchetypeBadgeProps {
|
||||
archetype: string; // archetype name, e.g. "POWER PULL" (case-insensitive)
|
||||
sport?: string; // nba/mlb/wnba/soccer — informational; styling is per-archetype
|
||||
variant?: 'full' | 'ghost' | 'tint';
|
||||
size?: 'sm' | 'md';
|
||||
showDesc?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* ArchetypeBadge (Session 42) — ported pixel-for-pixel from the design's
|
||||
* ArchetypeBadge.dc.html. JetBrains Mono label + a per-archetype glyph.
|
||||
* full = solid fill (PRIMARY)
|
||||
* ghost = transparent + colored border (SECONDARY)
|
||||
* tint = subtle tinted bg (default)
|
||||
* Data never glitches — this is a chrome label, no animation.
|
||||
*/
|
||||
export default function ArchetypeBadge({
|
||||
archetype,
|
||||
variant = 'tint',
|
||||
size = 'sm',
|
||||
showDesc = false,
|
||||
}: ArchetypeBadgeProps) {
|
||||
const s = badgeStyle(archetype, variant, size);
|
||||
return (
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 8, verticalAlign: 'middle' }}>
|
||||
<span
|
||||
className="mono"
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 5,
|
||||
fontWeight: 700,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.07em',
|
||||
lineHeight: 1,
|
||||
boxSizing: 'border-box',
|
||||
whiteSpace: 'nowrap',
|
||||
fontSize: s.fontSize,
|
||||
padding: s.padding,
|
||||
borderRadius: s.radius,
|
||||
color: s.textColor,
|
||||
background: s.bg,
|
||||
border: `1px solid ${s.borderColor}`,
|
||||
textShadow: s.textShadow,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{ display: 'inline-flex', flex: 'none', width: s.glyphSize, height: s.glyphSize, color: s.glyphColor }}
|
||||
dangerouslySetInnerHTML={{ __html: glyphSvg(s.glyph) }}
|
||||
/>
|
||||
{s.name}
|
||||
</span>
|
||||
{showDesc && s.desc && (
|
||||
<span style={{ fontFamily: 'var(--sans)', fontSize: s.descSize, color: '#7A7E8C', whiteSpace: 'nowrap' }}>
|
||||
{s.desc}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { archetypeColor } from '@/lib/archetypes';
|
||||
import ArchetypeBadge from '@/components/vyndr/ArchetypeBadge';
|
||||
|
||||
export interface BlendSegment {
|
||||
archetype: string;
|
||||
weight: number;
|
||||
}
|
||||
|
||||
interface ArchetypeBlendProps {
|
||||
blend: BlendSegment[];
|
||||
size?: 'sm' | 'md';
|
||||
showLegend?: boolean;
|
||||
caption?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* ArchetypeBlend (Session 42) — the production-DNA bar. Ported from the
|
||||
* design's ArchetypeBlend.dc.html. Each segment's width is that archetype's
|
||||
* share of the player's statistical value; the widest is PRIMARY.
|
||||
*/
|
||||
export default function ArchetypeBlend({
|
||||
blend,
|
||||
size = 'md',
|
||||
showLegend = true,
|
||||
caption = '',
|
||||
}: ArchetypeBlendProps) {
|
||||
const sm = size === 'sm';
|
||||
const raw = (Array.isArray(blend) ? blend : []).slice(0, 4);
|
||||
const total = raw.reduce((s, b) => s + (Number(b.weight) || 0), 0) || 1;
|
||||
const pct = (w: number) => Math.round(((Number(w) || 0) / total) * 100);
|
||||
|
||||
const barH = sm ? 6 : 10;
|
||||
const gap = sm ? 7 : 13;
|
||||
const pctSize = sm ? 10 : 13;
|
||||
const capSize = sm ? 11 : 12.5;
|
||||
const roleW = sm ? 54 : 66;
|
||||
const ROLECOL = ['#C6CBD6', '#6B6F7E', '#6B6F7E', '#5A5E6B'];
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap, width: '100%' }}>
|
||||
<div style={{ position: 'relative', display: 'flex', height: barH, borderRadius: 99, overflow: 'hidden', gap: 1.5, background: '#07070D', border: '1px solid #15151F' }}>
|
||||
{raw.map((b, i) => (
|
||||
<span
|
||||
key={i}
|
||||
title={`${b.archetype} — ${pct(b.weight)}% of production profile`}
|
||||
style={{
|
||||
width: `${(Math.max(0, Number(b.weight) || 0) / total) * 100}%`,
|
||||
background: archetypeColor(b.archetype),
|
||||
boxShadow: `inset 0 0 0 100px ${i === 0 ? 'transparent' : 'rgba(0,0,0,0.18)'}`,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{showLegend && (
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '9px 16px', alignItems: 'center' }}>
|
||||
{raw.map((b, i) => (
|
||||
<span key={i} style={{ display: 'inline-flex', alignItems: 'center', gap: 8 }}>
|
||||
<span className="mono" style={{ fontSize: 8, fontWeight: 700, letterSpacing: '0.08em', color: ROLECOL[i] || '#5A5E6B', width: roleW, textAlign: 'right' }}>
|
||||
{i === 0 ? 'PRIMARY' : i === raw.length - 1 && raw.length > 2 ? 'TERTIARY' : 'SUPPORTING'}
|
||||
</span>
|
||||
<ArchetypeBadge archetype={b.archetype} size={sm ? 'sm' : 'md'} variant={i === 0 ? 'full' : 'ghost'} />
|
||||
<span className="mono" style={{ fontSize: pctSize, fontWeight: 700, color: i === 0 ? '#FFFFFF' : '#9499A8' }}>
|
||||
{pct(b.weight)}%
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{caption && (
|
||||
<div style={{ fontFamily: 'var(--sans)', fontSize: capSize, lineHeight: 1.5, color: '#7E8390' }}>{caption}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { bookInfo } from '@/lib/books';
|
||||
|
||||
interface BookChipProps {
|
||||
book: string; // DK / FD / MGM / CZR / ESPN / BR / PB ... (case-insensitive)
|
||||
size?: 'sm' | 'md';
|
||||
showName?: boolean;
|
||||
nameColor?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* BookChip (Session 42) — a sportsbook tile with the book's brand color, ported
|
||||
* from the design's BookChip.dc.html. JetBrains Mono mono-code in a tinted tile;
|
||||
* optional full name. Data chrome — never glitches.
|
||||
*/
|
||||
export default function BookChip({ book, size = 'sm', showName = false, nameColor = '#E6E8EE' }: BookChipProps) {
|
||||
const b = bookInfo(book);
|
||||
const sm = size === 'sm';
|
||||
const tile = sm ? 26 : 34;
|
||||
const monoSize = sm ? (b.mono.length > 2 ? 8 : 10) : (b.mono.length > 2 ? 10 : 13);
|
||||
return (
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: sm ? 8 : 10, verticalAlign: 'middle' }}>
|
||||
<span
|
||||
className="mono"
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flex: 'none',
|
||||
fontWeight: 800,
|
||||
lineHeight: 1,
|
||||
width: tile,
|
||||
height: tile,
|
||||
borderRadius: sm ? 6 : 8,
|
||||
fontSize: monoSize,
|
||||
color: b.fg,
|
||||
background: b.bg,
|
||||
border: `1px solid ${b.bd}`,
|
||||
letterSpacing: '-0.02em',
|
||||
}}
|
||||
>
|
||||
{b.mono}
|
||||
</span>
|
||||
{showName && (
|
||||
<span style={{ fontFamily: 'var(--sans)', fontSize: sm ? 12 : 14, fontWeight: 600, color: nameColor, whiteSpace: 'nowrap' }}>
|
||||
{b.name}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,9 @@
|
||||
import SportBadge from '@/components/vyndr/SportBadge';
|
||||
import SectionHead from '@/components/vyndr/SectionHead';
|
||||
import GradeBadge from '@/components/vyndr/GradeBadge';
|
||||
import ArchetypeBadge from '@/components/vyndr/ArchetypeBadge';
|
||||
import StatStrip, { type StatCell, type StripProp, type StripArchetype } from '@/components/vyndr/StatStrip';
|
||||
import { playerHref } from '@/lib/playerHref';
|
||||
|
||||
export interface GameLine {
|
||||
book: string;
|
||||
@@ -23,6 +26,19 @@ export interface GameProp {
|
||||
side: string;
|
||||
delta?: string;
|
||||
}
|
||||
/** Session 42 — design's enhanced card: one strip per player (name once). */
|
||||
export interface PlayerStrip {
|
||||
player: string;
|
||||
team: string;
|
||||
archetype?: StripArchetype;
|
||||
stats: StatCell[];
|
||||
props: StripProp[];
|
||||
}
|
||||
export interface StartingPitcher {
|
||||
name: string;
|
||||
era: string;
|
||||
archetype?: string;
|
||||
}
|
||||
export interface GameCardData {
|
||||
id: string;
|
||||
sport: string;
|
||||
@@ -37,6 +53,9 @@ export interface GameCardData {
|
||||
lines: GameLine[];
|
||||
props?: GameProp[];
|
||||
streaks?: Array<{ player: string; text: string }>;
|
||||
// Session 42 — Player Intelligence enhancements (optional, self-hiding).
|
||||
pitchers?: { away: StartingPitcher; home: StartingPitcher };
|
||||
playerStrips?: PlayerStrip[];
|
||||
}
|
||||
|
||||
interface GameCardProps {
|
||||
@@ -130,6 +149,22 @@ export default function GameCard({ game: g, onAddParlay, onOpen }: GameCardProps
|
||||
{g.venue && (<><span style={{ color: 'var(--text-2)' }}> · </span>{g.venue}</>)}
|
||||
</div>
|
||||
|
||||
{/* MLB STARTING PITCHERS (Session 42) */}
|
||||
{g.pitchers && (
|
||||
<div className="mono" style={{ padding: '0 16px 11px', fontSize: 11, color: 'var(--text-1)', display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
||||
<span style={{ color: 'var(--text-2)', letterSpacing: '0.04em' }}>STARTING</span>
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6, color: '#C8CCD6' }}>
|
||||
{g.pitchers.away.name} <span style={{ color: 'var(--text-1)' }}>{g.pitchers.away.era} ERA</span>
|
||||
{g.pitchers.away.archetype && <ArchetypeBadge archetype={g.pitchers.away.archetype} size="sm" />}
|
||||
</span>
|
||||
<span style={{ color: 'var(--text-2)' }}>vs</span>
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6, color: '#C8CCD6' }}>
|
||||
{g.pitchers.home.name} <span style={{ color: 'var(--text-1)' }}>{g.pitchers.home.era} ERA</span>
|
||||
{g.pitchers.home.archetype && <ArchetypeBadge archetype={g.pitchers.home.archetype} size="sm" />}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ height: 1, background: 'var(--border)' }} />
|
||||
|
||||
{/* GAME LINES */}
|
||||
@@ -155,10 +190,28 @@ export default function GameCard({ game: g, onAddParlay, onOpen }: GameCardProps
|
||||
|
||||
<div style={{ height: 1, background: 'var(--border)' }} />
|
||||
|
||||
{/* PROPS */}
|
||||
{/* PROPS — Session 42: prefer the player-grouped StatStrip (name once,
|
||||
archetype + horizontal stats + all graded props on one line); fall
|
||||
back to the legacy per-prop rows for callers that don't supply strips. */}
|
||||
<div style={{ padding: '13px 16px' }}>
|
||||
<SectionHead style={{ marginBottom: 11 }}>GRADED PROPS</SectionHead>
|
||||
{g.props && g.props.length > 0 ? (
|
||||
{g.playerStrips && g.playerStrips.length > 0 ? (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{g.playerStrips.map((ps, i) => (
|
||||
<StatStrip
|
||||
key={i}
|
||||
player={ps.player}
|
||||
team={ps.team}
|
||||
sport={g.sport}
|
||||
archetype={ps.archetype}
|
||||
stats={ps.stats}
|
||||
props={ps.props}
|
||||
variant="compact"
|
||||
onPlayerClick={() => { window.location.href = playerHref(ps.player, g.sport); }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : g.props && g.props.length > 0 ? (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 7 }}>
|
||||
{g.props.map((p, i) => (
|
||||
<PropRow key={i} prop={p} onAddParlay={onAddParlay} />
|
||||
|
||||
@@ -4,7 +4,10 @@ import { useEffect, useState } from 'react';
|
||||
import SportBadge from '@/components/vyndr/SportBadge';
|
||||
import SectionHead from '@/components/vyndr/SectionHead';
|
||||
import VBtn from '@/components/vyndr/VBtn';
|
||||
import ArchetypeBlend from '@/components/vyndr/ArchetypeBlend';
|
||||
import GradeBadge from '@/components/vyndr/GradeBadge';
|
||||
import { gradeColor, gradeHex } from '@/lib/vyndrTokens';
|
||||
import { playerHref } from '@/lib/playerHref';
|
||||
|
||||
export interface GradeResultData {
|
||||
player: string;
|
||||
@@ -22,6 +25,11 @@ export interface GradeResultData {
|
||||
killConditions?: string[];
|
||||
books: Array<{ name: string; line: number; odds: string; best?: boolean }>;
|
||||
altLadder?: Array<{ line: number; grade: string }>;
|
||||
// Session 42 — Player Intelligence additions (all optional; self-hide).
|
||||
archetypeBlend?: Array<{ archetype: string; weight: number }>;
|
||||
propDNA?: { reliable: string[]; volatile: string[] };
|
||||
statContext?: { season?: string; last10?: string; vsOpp?: string };
|
||||
vyndrIntel?: { form?: number | string; usage?: string; matchup?: string; rest?: string };
|
||||
}
|
||||
|
||||
interface GradeResultCardProps {
|
||||
@@ -80,10 +88,10 @@ export default function GradeResultCard({
|
||||
>
|
||||
{sweep && <div className="crt-sweep-local" />}
|
||||
|
||||
{/* 1. HEADER */}
|
||||
{/* 1. HEADER — player name links to the full profile (Session 42) */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '16px 20px', background: 'var(--bg-2)', borderBottom: '1px solid var(--border)' }}>
|
||||
<div>
|
||||
<div style={{ fontSize: 22, fontWeight: 800, letterSpacing: '-0.01em', lineHeight: 1.1 }}>{d.player}</div>
|
||||
<a href={playerHref(d.player, d.sport)} style={{ fontSize: 22, fontWeight: 800, letterSpacing: '-0.01em', lineHeight: 1.1, color: 'inherit', textDecoration: 'none' }}>{d.player}</a>
|
||||
<div className="mono" style={{ fontSize: 12, color: 'var(--text-1)', marginTop: 3 }}>
|
||||
<span style={{ color: sideColor, fontWeight: 700 }}>{d.side.toUpperCase()} {d.line}</span>
|
||||
<span style={{ color: 'var(--text-2)', margin: '0 7px' }}>·</span>{d.stat}
|
||||
@@ -95,6 +103,24 @@ export default function GradeResultCard({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 1b. ARCHETYPE STRIP (Session 42) — between header and grade hero */}
|
||||
{Array.isArray(d.archetypeBlend) && d.archetypeBlend.length > 0 && (
|
||||
<div style={{ padding: '13px 20px', background: '#0C0E0D', borderBottom: '1px solid var(--border)', display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
<ArchetypeBlend blend={d.archetypeBlend} size="sm" showLegend caption="Why this grade: the prop sits in this player's PRIMARY lane, so the model weights it heavily." />
|
||||
{d.propDNA && (
|
||||
<div className="mono" style={{ fontSize: 11, color: '#7E8A86' }}>
|
||||
<span style={{ color: 'var(--text-2)' }}>PROP DNA</span>
|
||||
{(d.propDNA.reliable || []).map((s) => (
|
||||
<span key={s}> · {s.replace(/_/g, ' ')} <span style={{ color: 'var(--g-a)' }}>● reliable</span></span>
|
||||
))}
|
||||
{(d.propDNA.volatile || []).map((s) => (
|
||||
<span key={s}> · {s.replace(/_/g, ' ')} <span style={{ color: 'var(--amber)' }}>● volatile</span></span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 2. GRADE HERO — intel surface */}
|
||||
<div className="intel-surface" style={{ padding: '26px 20px 22px', textAlign: 'center' }}>
|
||||
<div className="label" style={{ position: 'relative', zIndex: 2, color: 'rgba(232,255,244,.5)', marginBottom: 2 }}>VYNDR GRADE</div>
|
||||
@@ -162,6 +188,38 @@ export default function GradeResultCard({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 5b. STAT CONTEXT (Session 42) */}
|
||||
{d.statContext && (d.statContext.season || d.statContext.last10 || d.statContext.vsOpp) && (
|
||||
<div style={{ padding: '16px 20px', borderTop: '1px solid var(--border)' }}>
|
||||
<SectionHead style={{ marginBottom: 12 }}>STAT CONTEXT</SectionHead>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3,1fr)', gap: 1, background: 'var(--border)', border: '1px solid var(--border)', borderRadius: 9, overflow: 'hidden' }}>
|
||||
{[
|
||||
{ l: 'SEASON', v: d.statContext.season, col: 'var(--text-0)' },
|
||||
{ l: 'LAST 10', v: d.statContext.last10, col: 'var(--g-a)' },
|
||||
{ l: 'vs OPP', v: d.statContext.vsOpp, col: 'var(--g-a)' },
|
||||
].map((x, i) => (
|
||||
<div key={i} style={{ background: 'var(--bg-1)', padding: '11px 12px' }}>
|
||||
<div className="mono" style={{ fontSize: 9, color: 'var(--text-2)', letterSpacing: '0.06em', marginBottom: 5 }}>{x.l}</div>
|
||||
<div className="mono" style={{ fontSize: 16, fontWeight: 600, color: x.v ? x.col : 'var(--text-2)' }}>{x.v || '—'}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 5c. VYNDR INTELLIGENCE (Session 42) */}
|
||||
{d.vyndrIntel && (
|
||||
<div className="intel-surface" style={{ margin: '0 20px 16px', padding: '15px 16px', borderRadius: 12, border: '1px solid rgba(0,212,160,0.24)' }}>
|
||||
<div className="mono" style={{ fontSize: 10, fontWeight: 600, letterSpacing: '0.1em', color: 'var(--g-a)', marginBottom: 12 }}>VYNDR INTELLIGENCE</div>
|
||||
<div className="mono" style={{ display: 'flex', gap: 10, flexWrap: 'wrap', alignItems: 'center', fontSize: 12 }}>
|
||||
{d.vyndrIntel.form != null && (<><span style={{ color: 'var(--text-2)' }}>Form</span><span style={{ color: 'var(--g-ap)', fontWeight: 700 }}>{d.vyndrIntel.form}</span><span style={{ color: '#2A3531' }}>·</span></>)}
|
||||
{d.vyndrIntel.usage && (<><span style={{ color: 'var(--text-2)' }}>Usage</span><span style={{ color: 'var(--text-0)', fontWeight: 600 }}>{d.vyndrIntel.usage}</span><span style={{ color: '#2A3531' }}>·</span></>)}
|
||||
{d.vyndrIntel.matchup && (<><span style={{ color: 'var(--text-2)' }}>Matchup</span><GradeBadge grade={d.vyndrIntel.matchup} size="sm" /><span style={{ color: '#2A3531' }}>·</span></>)}
|
||||
{d.vyndrIntel.rest && (<><span style={{ color: 'var(--text-2)' }}>Rest</span><span style={{ color: 'var(--g-a)', fontWeight: 600 }}>{d.vyndrIntel.rest}</span></>)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 6. KILL CONDITIONS */}
|
||||
{hasKill && (
|
||||
<div style={{ margin: '0 20px 16px', border: '1px solid rgba(255,179,71,.4)', borderRadius: 8, background: 'rgba(255,179,71,.06)', padding: '13px 15px' }}>
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
import ArchetypeBadge from '@/components/vyndr/ArchetypeBadge';
|
||||
import GradeBadge from '@/components/vyndr/GradeBadge';
|
||||
|
||||
export interface StatCell {
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
export interface StripProp {
|
||||
stat: string;
|
||||
line: number | string;
|
||||
side: string; // O / U / Over / Under
|
||||
grade: string;
|
||||
}
|
||||
export interface StripArchetype {
|
||||
primary: string;
|
||||
secondary?: string | null;
|
||||
}
|
||||
|
||||
interface StatStripProps {
|
||||
player: string;
|
||||
team: string;
|
||||
sport?: string;
|
||||
archetype?: StripArchetype;
|
||||
stats: StatCell[];
|
||||
last10?: StatCell[] | string;
|
||||
props?: StripProp[];
|
||||
meta?: string; // expanded: "ATL · 3B · #27"
|
||||
variant?: 'compact' | 'expanded';
|
||||
onPlayerClick?: () => void;
|
||||
}
|
||||
|
||||
const Sep = ({ ch = '|' }: { ch?: string }) => (
|
||||
<span style={{ color: '#3A3A48', margin: '0 8px' }}>{ch}</span>
|
||||
);
|
||||
|
||||
/**
|
||||
* StatStrip (Session 42) — the horizontal player line. Ported from the design's
|
||||
* Stat Strip section. The player name appears ONCE; stats flow horizontally as
|
||||
* a JetBrains Mono run (never stacked with the name repeated per stat).
|
||||
* compact = game cards / inline
|
||||
* expanded = profile hero / grade result
|
||||
*/
|
||||
export default function StatStrip({
|
||||
player,
|
||||
team,
|
||||
archetype,
|
||||
stats,
|
||||
last10,
|
||||
props,
|
||||
meta,
|
||||
variant = 'compact',
|
||||
onPlayerClick,
|
||||
}: StatStripProps) {
|
||||
const last10Str = typeof last10 === 'string'
|
||||
? last10
|
||||
: Array.isArray(last10)
|
||||
? last10.map((c) => `${c.value} ${c.label}`).join(' · ')
|
||||
: '';
|
||||
|
||||
const nameStyle: React.CSSProperties = onPlayerClick
|
||||
? { cursor: 'pointer', textDecoration: 'none', color: 'inherit' }
|
||||
: {};
|
||||
|
||||
const PlayerName = ({ children, ...rest }: { children: React.ReactNode } & React.HTMLAttributes<HTMLSpanElement>) =>
|
||||
onPlayerClick ? (
|
||||
<span role="link" tabIndex={0} onClick={onPlayerClick} onKeyDown={(e) => { if (e.key === 'Enter') onPlayerClick(); }} style={{ ...nameStyle, ...(rest.style || {}) }}>
|
||||
{children}
|
||||
</span>
|
||||
) : (
|
||||
<span {...rest}>{children}</span>
|
||||
);
|
||||
|
||||
if (variant === 'expanded') {
|
||||
return (
|
||||
<div style={{ background: 'var(--bg-1)', border: '1px solid var(--border)', borderRadius: 12, padding: 20 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', gap: 12, flexWrap: 'wrap' }}>
|
||||
<PlayerName style={{ fontWeight: 800, fontSize: 22, letterSpacing: '-0.01em', textTransform: 'uppercase', ...nameStyle }}>
|
||||
{player}
|
||||
</PlayerName>
|
||||
<span className="mono" style={{ fontSize: 12, color: 'var(--text-1)' }}>{meta || team}</span>
|
||||
</div>
|
||||
{archetype && (
|
||||
<div style={{ marginTop: 10, display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' }}>
|
||||
<ArchetypeBadge archetype={archetype.primary} size="md" showDesc />
|
||||
{archetype.secondary && <ArchetypeBadge archetype={archetype.secondary} size="md" variant="ghost" />}
|
||||
</div>
|
||||
)}
|
||||
<div style={{ height: 1, background: 'var(--border)', margin: '16px 0' }} />
|
||||
<div className="mono" style={{ display: 'flex', gap: 26, flexWrap: 'wrap', marginBottom: 10 }}>
|
||||
{stats.map((s, i) => (
|
||||
<div key={i}>
|
||||
<span style={{ fontSize: 18, color: '#fff', fontWeight: 600 }}>{s.value}</span>{' '}
|
||||
<span style={{ fontSize: 11, color: 'var(--text-1)' }}>{s.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{last10Str && (
|
||||
<div className="mono" style={{ fontSize: 12, color: 'var(--text-1)' }}>
|
||||
<span style={{ color: 'var(--g-a)', letterSpacing: '0.04em' }}>LAST 10</span>
|
||||
<Sep ch="·" />
|
||||
{last10Str}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// compact
|
||||
return (
|
||||
<div style={{ background: 'var(--bg-1)', border: '1px solid var(--border)', borderRadius: 12, padding: '14px 16px', display: 'flex', flexDirection: 'column', gap: 9 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 9, flexWrap: 'wrap' }}>
|
||||
<PlayerName style={{ fontWeight: 700, fontSize: 14, color: '#fff', ...nameStyle }}>{player}</PlayerName>
|
||||
<span className="mono" style={{ fontSize: 11, color: 'var(--text-1)' }}>{team}</span>
|
||||
{archetype && <ArchetypeBadge archetype={archetype.primary} size="sm" variant="full" />}
|
||||
{archetype?.secondary && (
|
||||
<>
|
||||
<span className="mono" style={{ color: '#3A3A48', fontSize: 11 }}>/</span>
|
||||
<ArchetypeBadge archetype={archetype.secondary} size="sm" variant="ghost" />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="mono game-lines-grid" style={{ fontSize: 12, color: 'var(--text-0)', letterSpacing: '0.02em' }}>
|
||||
{stats.map((s, i) => (
|
||||
<span key={i}>
|
||||
{i > 0 && <Sep />}
|
||||
{s.value} {s.label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
{props && props.length > 0 && (
|
||||
<div className="game-lines-grid" style={{ display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap', overflowX: 'auto' }}>
|
||||
<span className="mono" style={{ fontSize: 11, color: 'var(--text-1)', letterSpacing: '0.06em' }}>PROPS</span>
|
||||
{props.map((p, i) => (
|
||||
<span key={i} style={{ display: 'inline-flex', alignItems: 'center', gap: 10 }}>
|
||||
{i > 0 && <span style={{ color: '#3A3A48' }}>·</span>}
|
||||
<span className="mono" style={{ display: 'inline-flex', alignItems: 'center', gap: 6, fontSize: 11, color: '#B8BCC8' }}>
|
||||
{p.stat} {p.side}{p.line} <GradeBadge grade={p.grade} size="sm" />
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -12,9 +12,17 @@ export { default as GradeResultCard } from './GradeResultCard';
|
||||
export type { GradeResultData } from './GradeResultCard';
|
||||
export { default as ProcessingGrade } from './ProcessingGrade';
|
||||
export { default as GameCard } from './GameCard';
|
||||
export type { GameCardData, GameLine, GameProp } from './GameCard';
|
||||
export type { GameCardData, GameLine, GameProp, PlayerStrip, StartingPitcher } from './GameCard';
|
||||
export { default as ClaimMeter } from './ClaimMeter';
|
||||
|
||||
/* Player Intelligence (Session 42) */
|
||||
export { default as ArchetypeBadge } from './ArchetypeBadge';
|
||||
export { default as ArchetypeBlend } from './ArchetypeBlend';
|
||||
export type { BlendSegment } from './ArchetypeBlend';
|
||||
export { default as StatStrip } from './StatStrip';
|
||||
export type { StatCell, StripProp, StripArchetype } from './StatStrip';
|
||||
export { default as BookChip } from './BookChip';
|
||||
|
||||
export {
|
||||
GRADE_COLORS,
|
||||
GRADE_HEX,
|
||||
|
||||
Reference in New Issue
Block a user