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:
Kev
2026-06-18 11:12:24 -04:00
parent 32069863dc
commit 8bc79f3c38
33 changed files with 2655 additions and 22 deletions
+1 -1
View File
File diff suppressed because one or more lines are too long
+23
View File
@@ -0,0 +1,23 @@
import { NextRequest, NextResponse } from 'next/server';
export const dynamic = 'force-dynamic';
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:3000';
/**
* Stat leaders proxy (Session 42). Forwards GET /api/stats/leaders to Express,
* preserving ?sport=&stat=&limit=. Powers the Terminal / Stats Explorer.
*/
export async function GET(req: NextRequest) {
const qs = req.nextUrl.search;
try {
const upstream = await fetch(`${BACKEND_URL}/api/stats/leaders${qs}`, {
method: 'GET',
headers: { Accept: 'application/json' },
});
const data = await upstream.json().catch(() => ({}));
return NextResponse.json(data, { status: upstream.ok ? 200 : upstream.status });
} catch {
return NextResponse.json({ error: 'Stats service is unreachable. Try again in a moment.' }, { status: 502 });
}
}
@@ -0,0 +1,25 @@
import { NextRequest, NextResponse } from 'next/server';
export const dynamic = 'force-dynamic';
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:3000';
/**
* Player intelligence proxy (Session 42). Forwards GET /api/stats/player/:name
* to the Express stats route (which sanitizes the name + aggregates archetype /
* props / VYNDR intelligence). Thin pass-through; preserves ?sport=.
*/
export async function GET(req: NextRequest, ctx: { params: Promise<{ name: string }> }) {
const { name } = await ctx.params;
const qs = req.nextUrl.search;
try {
const upstream = await fetch(`${BACKEND_URL}/api/stats/player/${encodeURIComponent(name)}${qs}`, {
method: 'GET',
headers: { Accept: 'application/json' },
});
const data = await upstream.json().catch(() => ({}));
return NextResponse.json(data, { status: upstream.ok ? 200 : upstream.status });
} catch {
return NextResponse.json({ error: 'Player service is unreachable. Try again in a moment.' }, { status: 502 });
}
}
+128
View File
@@ -0,0 +1,128 @@
'use client';
import { useEffect, useMemo, useState } from 'react';
import SportBadge from '@/components/vyndr/SportBadge';
import GradeBadge from '@/components/vyndr/GradeBadge';
import { playerHref } from '@/lib/playerHref';
/**
* Stats Explorer (/explore) — Session 42, design section 07. The data hub:
* tonight's league leaderboard (top graded props by confidence) with grade +
* archetype context. Consumes the real /api/stats/leaders endpoint. Sub-panels
* (hit-rate trends, head-to-head, market-vs-VYNDR) need historical data and are
* a Session-43 follow-up — see BUILD-STATE.
*/
interface Leader {
player: string;
team: string;
stat: string;
line: number | string;
side: string;
grade: string;
confidence: number;
}
const SPORTS = [
{ key: 'nba', label: 'NBA' },
{ key: 'mlb', label: 'MLB' },
{ key: 'wnba', label: 'WNBA' },
];
export default function ExplorePage() {
const [sport, setSport] = useState('nba');
const [leaders, setLeaders] = useState<Leader[]>([]);
const [state, setState] = useState<'loading' | 'ready' | 'error'>('loading');
const [query, setQuery] = useState('');
useEffect(() => {
let active = true;
setState('loading');
fetch(`/api/stats/leaders?sport=${sport}&limit=25`)
.then((r) => r.json())
.then((d) => { if (active) { setLeaders(Array.isArray(d.leaders) ? d.leaders : []); setState('ready'); } })
.catch(() => { if (active) setState('error'); });
return () => { active = false; };
}, [sport]);
const rows = useMemo(() => {
const q = query.trim().toLowerCase();
return q ? leaders.filter((l) => (l.player || '').toLowerCase().includes(q)) : leaders;
}, [leaders, query]);
return (
<section style={{ maxWidth: 920, margin: '0 auto', padding: '24px 16px 120px' }}>
<div style={{ display: 'flex', alignItems: 'flex-end', justifyContent: 'space-between', gap: 14, marginBottom: 22, flexWrap: 'wrap' }}>
<div>
<div className="mono" style={{ fontSize: 11, color: 'var(--g-a)', letterSpacing: '0.12em', marginBottom: 8 }}>STATS · /EXPLORE</div>
<h1 style={{ margin: '0 0 8px', fontSize: 30, fontWeight: 800, letterSpacing: '-0.01em' }}>Stats Explorer</h1>
<p style={{ margin: 0, maxWidth: 560, fontSize: 14, lineHeight: 1.6, color: 'var(--text-1)' }}>
Tonight&apos;s league leaderboard every graded prop, ranked by VYNDR confidence, with the grade and archetype context only VYNDR has.
</p>
</div>
<div style={{ display: 'flex', gap: 4, background: 'var(--bg-1)', border: '1px solid var(--border)', borderRadius: 8, padding: 4 }}>
{SPORTS.map((s) => (
<button
key={s.key}
onClick={() => setSport(s.key)}
className="mono"
style={{ cursor: 'pointer', border: 'none', borderRadius: 5, padding: '7px 14px', fontSize: 11, fontWeight: 600, letterSpacing: '0.04em', color: sport === s.key ? '#06060B' : 'var(--text-1)', background: sport === s.key ? 'var(--g-a)' : 'transparent' }}
>
{s.label}
</button>
))}
</div>
</div>
{/* FILTER BAR */}
<div style={{ display: 'flex', alignItems: 'center', gap: 11, flexWrap: 'wrap', marginBottom: 14, background: 'var(--bg-1)', border: '1px solid var(--border)', borderRadius: 11, padding: '11px 14px' }}>
<span className="mono" style={{ fontSize: 14, color: 'var(--text-2)' }}></span>
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search players"
style={{ appearance: 'none', background: 'transparent', border: 'none', outline: 'none', fontFamily: 'var(--sans)', fontSize: 13, color: '#fff', flex: 1, minWidth: 140 }}
/>
<span className="mono" style={{ fontSize: 10, color: 'var(--text-2)' }}>{rows.length} graded</span>
</div>
{/* LEADERBOARD */}
<div style={{ display: 'flex', alignItems: 'center', gap: 9, marginBottom: 13 }}>
<span style={{ width: 6, height: 6, background: 'var(--g-a)', borderRadius: 1 }} />
<span className="mono" style={{ fontSize: 11, fontWeight: 600, letterSpacing: '0.1em', color: 'var(--text-1)' }}>LEAGUE LEADERBOARD</span>
<SportBadge sport={sport} />
</div>
<div style={{ background: 'var(--bg-1)', border: '1px solid var(--border)', borderRadius: 12, overflow: 'hidden' }}>
<div className="mono game-lines-grid" style={{ display: 'grid', gridTemplateColumns: '34px 1fr 90px 70px 44px', gap: 0, alignItems: 'center', padding: '10px 16px', borderBottom: '1px solid #14141E', fontSize: 9, color: 'var(--text-2)', letterSpacing: '0.08em' }}>
<div>#</div><div>PLAYER</div><div style={{ textAlign: 'right' }}>PROP</div><div style={{ textAlign: 'right' }}>CONF</div><div style={{ textAlign: 'center' }}>GRD</div>
</div>
{state === 'loading' && <div className="mono" style={{ padding: '30px 16px', textAlign: 'center', fontSize: 12, color: 'var(--text-2)' }}>Loading tonight&apos;s slate</div>}
{state === 'error' && <div className="mono" style={{ padding: '30px 16px', textAlign: 'center', fontSize: 12, color: 'var(--miss)' }}>Could not load the leaderboard. Try again.</div>}
{state === 'ready' && rows.length === 0 && (
<div className="mono" style={{ padding: '30px 16px', textAlign: 'center', fontSize: 12, color: 'var(--text-2)' }}>
No graded props for {sport.toUpperCase()} yet check back when tonight&apos;s slate posts.
</div>
)}
{state === 'ready' && rows.map((r, i) => (
<a
key={i}
href={playerHref(r.player, sport)}
className="game-lines-grid"
style={{ textDecoration: 'none', color: 'inherit', display: 'grid', gridTemplateColumns: '34px 1fr 90px 70px 44px', gap: 0, alignItems: 'center', padding: '13px 16px', borderBottom: '1px solid #14141E' }}
>
<div className="mono" style={{ fontSize: 14, fontWeight: 700, color: i < 3 ? 'var(--g-a)' : 'var(--text-1)' }}>{i + 1}</div>
<div style={{ minWidth: 0, display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
<span style={{ fontWeight: 700, fontSize: 13, color: '#fff', whiteSpace: 'nowrap' }}>{r.player}</span>
{r.team && <span className="mono" style={{ fontSize: 10, color: 'var(--text-1)' }}>{r.team}</span>}
</div>
<div className="mono" style={{ textAlign: 'right', fontSize: 12, color: '#C8CCD6' }}>{r.stat} {r.side}{r.line}</div>
<div className="mono" style={{ textAlign: 'right', fontSize: 13, fontWeight: 600, color: 'var(--text-0)' }}>{r.confidence != null ? `${r.confidence}%` : '—'}</div>
<div style={{ display: 'flex', justifyContent: 'center' }}><GradeBadge grade={r.grade} size="sm" /></div>
</a>
))}
</div>
</section>
);
}
+279
View File
@@ -0,0 +1,279 @@
'use client';
import { useEffect, useState } from 'react';
import { useParams, useSearchParams, useRouter } from 'next/navigation';
import SportBadge from '@/components/vyndr/SportBadge';
import GradeBadge from '@/components/vyndr/GradeBadge';
import ArchetypeBadge from '@/components/vyndr/ArchetypeBadge';
import ArchetypeBlend from '@/components/vyndr/ArchetypeBlend';
import { initials, sportLabel, dnaRows, blendReadout } from '@/lib/playerProfileAdapter';
interface IntelMetric { label: string; kind: string; value: string; score?: string; color: string }
interface ActiveProp { stat: string; line: number | string; side: string; grade: string; confidence?: string | null }
interface PlayerIntel {
player: string;
sport: string;
team: string;
found: boolean;
archetype: { primary: { name: string } | null; secondary: { name: string } | null; blend: { archetype: string; weight: number }[] };
propDNA: { reliable: string[]; volatile: string[] };
education: string;
season: { k: string; v: string; lg?: string }[];
last10: { d?: string; opp?: string; res?: string; stat?: string }[];
splits: { k: string; a: string; b: string }[];
gradeHistory: { grade: string; prop: string; hit?: boolean; miss?: boolean }[];
activeProps: ActiveProp[];
intel: IntelMetric[];
injury: { label: string; note: string; cascade: string } | null;
}
const SectionLabel = ({ children, color = '#7A7E8C', dot = '#00D4A0' }: { children: React.ReactNode; color?: string; dot?: string }) => (
<div style={{ display: 'flex', alignItems: 'center', gap: 9, marginBottom: 14 }}>
<span style={{ width: 6, height: 6, background: dot, borderRadius: 1 }} />
<span className="mono" style={{ fontSize: 11, fontWeight: 600, letterSpacing: '0.1em', color }}>{children}</span>
</div>
);
export default function PlayerProfilePage() {
const params = useParams();
const search = useSearchParams();
const router = useRouter();
const rawName = decodeURIComponent(String(params?.name || ''));
const sport = (search.get('sport') || 'nba').toLowerCase();
const [data, setData] = useState<PlayerIntel | null>(null);
const [state, setState] = useState<'loading' | 'ready' | 'error'>('loading');
const [dnaOpen, setDnaOpen] = useState(false);
useEffect(() => {
let active = true;
setState('loading');
fetch(`/api/stats/player/${encodeURIComponent(rawName)}?sport=${encodeURIComponent(sport)}`)
.then((r) => r.json())
.then((d) => { if (active) { setData(d); setState('ready'); } })
.catch(() => { if (active) setState('error'); });
return () => { active = false; };
}, [rawName, sport]);
if (state === 'loading') {
return (
<section style={{ minHeight: '50vh', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<p className="mono" style={{ color: 'var(--text-2)' }}>Loading player 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)' }}>Could not load this player. Try again in a moment.</p>
</section>
);
}
const p = data;
const dna = dnaRows(p.propDNA);
return (
<section style={{ maxWidth: 920, margin: '0 auto', padding: '20px 16px 120px' }}>
{/* Header */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, marginBottom: 20, flexWrap: 'wrap' }}>
<div className="mono" style={{ fontSize: 11, color: 'var(--g-a)', letterSpacing: '0.12em' }}>PLAYER · /{p.sport.toUpperCase()}</div>
<button onClick={() => router.back()} className="mono" style={{ cursor: 'pointer', background: 'transparent', border: '1px solid var(--border-hi)', borderRadius: 6, padding: '6px 12px', fontSize: 11, color: 'var(--text-1)' }}> Back</button>
</div>
{/* A. HERO */}
<div style={{ position: 'relative', overflow: 'hidden', background: 'linear-gradient(160deg,#15151f,#08080F)', border: '1px solid var(--border)', borderRadius: 16, padding: 26, marginBottom: 14 }}>
<div style={{ position: 'absolute', inset: 0, pointerEvents: 'none', background: 'repeating-linear-gradient(0deg, rgba(0,212,160,0.045) 0px, rgba(0,212,160,0.045) 1px, transparent 1px, transparent 4px)' }} />
<div style={{ position: 'relative', display: 'flex', gap: 20, alignItems: 'flex-start', flexWrap: 'wrap' }}>
<div className="mono" style={{ flex: 'none', width: 72, height: 72, borderRadius: '50%', display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'radial-gradient(circle at 30% 25%, #14241F, #0A100E)', border: '1.5px solid #00D4A066', color: 'var(--g-a)', fontWeight: 700, fontSize: 30, boxShadow: '0 0 24px #00D4A022' }}>{initials(p.player)}</div>
<div style={{ flex: 1, minWidth: 240 }}>
<h1 style={{ margin: 0, fontSize: 30, fontWeight: 800, letterSpacing: '-0.015em', lineHeight: 1.05 }}>{p.player}</h1>
<div style={{ marginTop: 8, display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' }}>
<SportBadge sport={p.sport} />
{p.team && <span className="mono" style={{ fontSize: 12, color: 'var(--text-1)' }}>{p.team}</span>}
<span className="mono" style={{ fontSize: 12, color: 'var(--text-2)' }}>{sportLabel(p.sport)}</span>
</div>
{p.archetype?.blend?.length > 0 && (
<div style={{ marginTop: 15 }}>
<div className="mono" style={{ fontSize: 10, fontWeight: 700, letterSpacing: '0.1em', color: 'var(--g-a)', marginBottom: 9 }}>ARCHETYPE DNA</div>
<ArchetypeBlend blend={p.archetype.blend} size="md" showLegend caption={blendReadout(p.archetype)} />
</div>
)}
</div>
</div>
{p.season?.length > 0 && (
<>
<div style={{ position: 'relative', height: 1, background: 'var(--border)', margin: '20px 0 16px' }} />
<div className="mono" style={{ position: 'relative', display: 'flex', gap: 28, flexWrap: 'wrap' }}>
{p.season.map((s, i) => (
<div key={i}><span style={{ fontSize: 20, color: '#fff', fontWeight: 600 }}>{s.v}</span> <span style={{ fontSize: 11, color: 'var(--text-1)' }}>{s.k}</span></div>
))}
</div>
</>
)}
</div>
{/* I. INJURY */}
{p.injury && (
<div style={{ display: 'flex', gap: 13, alignItems: 'flex-start', background: 'linear-gradient(135deg,#1A1305,#0E0B05)', border: '1px solid #FFB34740', borderRadius: 12, padding: '15px 17px', marginBottom: 14 }}>
<span style={{ flex: 'none', fontSize: 16 }}></span>
<div>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' }}>
<span className="mono" style={{ fontWeight: 700, fontSize: 12, color: 'var(--amber)', letterSpacing: '0.06em' }}>{p.injury.label}</span>
<span style={{ fontSize: 13, color: 'var(--text-0)' }}>{p.injury.note}</span>
</div>
<div className="mono" style={{ marginTop: 7, fontSize: 12, color: 'var(--text-1)' }}><span style={{ color: 'var(--amber)' }}>CASCADE</span> · {p.injury.cascade}</div>
</div>
</div>
)}
{/* B. PROP DNA */}
{dna.length > 0 && (
<div className="intel-surface" style={{ borderRadius: 14, padding: 20, marginBottom: 14, border: '1px solid rgba(0,212,160,0.22)' }}>
<SectionLabel color="var(--g-a)">PROP DNA</SectionLabel>
<p style={{ margin: '0 0 16px', fontSize: 12, color: '#7E8A86', maxWidth: 520 }}>Which props this archetype makes reliable vs. volatile unique to VYNDR.</p>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2,1fr)', gap: 10 }} className="terminal-grid">
{dna.map((d, i) => (
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: 10, background: 'rgba(0,0,0,0.25)', border: '1px solid rgba(255,255,255,0.05)', borderRadius: 9, padding: '11px 13px' }}>
<span style={{ flex: 'none', width: 9, height: 9, borderRadius: '50%', background: d.color, boxShadow: `0 0 8px ${d.color}` }} />
<span style={{ fontWeight: 600, fontSize: 13, color: 'var(--text-0)' }}>{d.prop}</span>
<span className="mono" style={{ marginLeft: 'auto', fontSize: 10, fontWeight: 600, letterSpacing: '0.06em', color: d.color }}>{d.state}</span>
</div>
))}
</div>
{p.education && (
<>
<button onClick={() => setDnaOpen((o) => !o)} className="mono" style={{ marginTop: 14, cursor: 'pointer', background: 'transparent', border: 'none', padding: 0, fontSize: 11, fontWeight: 600, color: 'var(--g-a)', letterSpacing: '0.04em' }}>What does this mean? </button>
{dnaOpen && <p style={{ margin: '12px 0 0', paddingTop: 13, borderTop: '1px solid rgba(0,212,160,0.16)', fontSize: 13, lineHeight: 1.65, color: '#A8B2AE', maxWidth: 640 }}>{p.education}</p>}
</>
)}
</div>
)}
{/* C. VYNDR INTELLIGENCE */}
{p.intel?.length > 0 && (
<div className="intel-surface" style={{ borderRadius: 14, padding: 20, marginBottom: 14, border: '1px solid rgba(0,212,160,0.22)' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 9, marginBottom: 16 }}>
<span style={{ width: 6, height: 6, background: 'var(--g-a)', borderRadius: 1 }} />
<span className="mono" style={{ fontSize: 11, fontWeight: 600, letterSpacing: '0.1em', color: 'var(--g-a)' }}>VYNDR INTELLIGENCE</span>
<span style={{ flex: 1, height: 1, background: 'rgba(0,212,160,0.14)' }} />
<span className="mono" style={{ fontSize: 10, color: 'var(--text-2)' }}>proprietary</span>
</div>
<div className="terminal-grid" style={{ display: 'grid', gridTemplateColumns: 'repeat(3,1fr)', gap: 1, background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.05)', borderRadius: 10, overflow: 'hidden' }}>
{p.intel.map((m, i) => (
<div key={i} style={{ background: '#080D0C', padding: '14px 15px', minHeight: 74, display: 'flex', flexDirection: 'column', gap: 7 }}>
<span className="mono" style={{ fontSize: 9.5, color: 'var(--text-2)', letterSpacing: '0.06em' }}>{m.label}</span>
{m.kind === 'grade' ? (
<GradeBadge grade={m.value} size="md" />
) : m.kind === 'form' ? (
<>
<div className="mono" style={{ fontSize: 22, fontWeight: 600, color: m.color }}>{m.value}</div>
<div style={{ height: 4, borderRadius: 2, background: 'rgba(255,255,255,0.08)', overflow: 'hidden' }}><div style={{ height: '100%', width: m.score, background: m.color, boxShadow: `0 0 8px ${m.color}` }} /></div>
</>
) : (
<div className="mono" style={{ fontSize: 18, fontWeight: 600, color: m.color }}>{m.value}</div>
)}
</div>
))}
</div>
</div>
)}
{/* D. ACTIVE PROPS */}
{p.activeProps?.length > 0 && (
<>
<SectionLabel>ACTIVE PROPS · TONIGHT</SectionLabel>
<div style={{ display: 'flex', flexDirection: 'column', gap: 9, marginBottom: 8 }}>
{p.activeProps.map((pr, i) => (
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: 14, flexWrap: 'wrap', background: 'var(--bg-1)', border: '1px solid var(--border)', borderRadius: 11, padding: '13px 16px' }}>
<div style={{ display: 'flex', alignItems: 'baseline', gap: 8, minWidth: 150 }}>
<span style={{ fontWeight: 700, fontSize: 14, color: '#fff' }}>{pr.stat}</span>
<span className="mono" style={{ fontSize: 12, color: 'var(--text-1)' }}>{pr.side} {pr.line}</span>
</div>
<GradeBadge grade={pr.grade} size="md" />
{pr.confidence && <span className="mono" style={{ fontSize: 12, color: 'var(--text-1)' }}>conf <span style={{ color: '#B8BCC8' }}>{pr.confidence}</span></span>}
<a href={`/scan?player=${encodeURIComponent(p.player)}&stat=${encodeURIComponent(pr.stat)}`} className="mono" style={{ marginLeft: 'auto', cursor: 'pointer', background: 'transparent', border: '1px solid var(--g-a)', borderRadius: 7, padding: '7px 13px', fontSize: 11, fontWeight: 600, letterSpacing: '0.04em', color: 'var(--g-a)', textDecoration: 'none' }}>GRADE PROP </a>
</div>
))}
</div>
</>
)}
{/* E/F. SEASON + LAST 10 */}
{(p.season?.length > 0 || p.last10?.length > 0) && (
<div className="terminal-grid" style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 14, marginTop: 24 }}>
{p.season?.length > 0 && (
<div style={{ background: 'var(--bg-1)', border: '1px solid var(--border)', borderRadius: 12, padding: 18 }}>
<SectionLabel>SEASON STATS</SectionLabel>
<div className="mono" style={{ display: 'flex', flexDirection: 'column' }}>
{p.season.map((r, i) => (
<div key={i} style={{ display: 'flex', justifyContent: 'space-between', padding: '6px 0', borderTop: i ? '1px solid #14141E' : 'none', fontSize: 12 }}>
<span style={{ color: 'var(--text-1)' }}>{r.k}</span>
<span style={{ color: '#fff', fontWeight: 600 }}>{r.v}</span>
</div>
))}
</div>
</div>
)}
{p.last10?.length > 0 && (
<div style={{ background: 'var(--bg-1)', border: '1px solid var(--border)', borderRadius: 12, padding: 18 }}>
<SectionLabel>LAST 10</SectionLabel>
<div className="mono" style={{ display: 'flex', flexDirection: 'column' }}>
{p.last10.map((g, i) => (
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '8px 0', borderTop: i ? '1px solid #14141E' : 'none', fontSize: 11 }}>
<span style={{ color: 'var(--text-1)', width: 48 }}>{g.d}</span>
<span style={{ color: 'var(--text-1)', width: 58 }}>{g.opp}</span>
<span style={{ color: '#C8CCD6', flex: 1, textAlign: 'right' }}>{g.stat}</span>
</div>
))}
</div>
</div>
)}
</div>
)}
{/* G/H. SPLITS + GRADE HISTORY */}
{(p.splits?.length > 0 || p.gradeHistory?.length > 0) && (
<div className="terminal-grid" style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 14, marginTop: 14 }}>
{p.splits?.length > 0 && (
<div style={{ background: 'var(--bg-1)', border: '1px solid var(--border)', borderRadius: 12, padding: 18 }}>
<SectionLabel>SPLITS</SectionLabel>
<div className="mono" style={{ display: 'flex', flexDirection: 'column', fontSize: 12 }}>
{p.splits.map((r, i) => (
<div key={i} style={{ display: 'grid', gridTemplateColumns: '1fr auto auto', gap: 12, padding: '7px 0', borderTop: i ? '1px solid #14141E' : 'none' }}>
<span style={{ color: 'var(--text-1)' }}>{r.k}</span>
<span style={{ color: '#fff', fontWeight: 600 }}>{r.a}</span>
<span style={{ color: '#C8CCD6' }}>{r.b}</span>
</div>
))}
</div>
</div>
)}
{p.gradeHistory?.length > 0 && (
<div style={{ background: 'var(--bg-1)', border: '1px solid var(--border)', borderRadius: 12, padding: 18 }}>
<SectionLabel>GRADE HISTORY</SectionLabel>
<div style={{ display: 'flex', flexDirection: 'column' }}>
{p.gradeHistory.map((g, i) => (
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '8px 0', borderTop: i ? '1px solid #14141E' : 'none' }}>
<GradeBadge grade={g.grade} size="sm" />
<span className="mono" style={{ fontSize: 12, color: 'var(--text-1)' }}>{g.prop}</span>
{g.hit && <span className="mono" style={{ marginLeft: 'auto', fontSize: 10, fontWeight: 700, color: 'var(--hit)' }}> HIT</span>}
{g.miss && <span className="mono" style={{ marginLeft: 'auto', fontSize: 10, fontWeight: 700, color: 'var(--miss)' }}> MISS</span>}
</div>
))}
</div>
</div>
)}
</div>
)}
{/* Empty state when there is no graded prop yet */}
{!p.found && (
<div style={{ marginTop: 24, padding: 20, border: '1px dashed var(--border-hi)', borderRadius: 12, textAlign: 'center' }}>
<p className="mono" style={{ color: 'var(--text-1)', fontSize: 13 }}>No graded props for {p.player} tonight.</p>
<p style={{ color: 'var(--text-2)', fontSize: 12, marginTop: 6 }}>The archetype + prop DNA above still apply. Check back when tonight&apos;s slate posts.</p>
</div>
)}
</section>
);
}
+207 -8
View File
@@ -1,13 +1,212 @@
import { redirect } from 'next/navigation';
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { useAuth } from '@/contexts/AuthContext';
/**
* /settings (Session 41 — P0 audit fix).
* /settings (Session 42 — Player Intelligence design).
*
* The audit found /settings 404'd. Account/preferences already live on
* /profile (the canonical surface), so forward there rather than build a
* second screen that would drift out of sync. Server-side redirect — no
* flash of an empty page.
* Replaces the Session-41 redirect with the design's full settings surface.
* NOTE: /settings/security is a REAL MFA enrollment page — this page LINKS to
* it, it does not replace it.
*/
export default function SettingsPage() {
redirect('/profile');
const Section = ({ label, color = 'var(--text-1)', dot = 'var(--g-a)', children }: { label: string; color?: string; dot?: string; children: React.ReactNode }) => (
<div style={{ background: 'var(--bg-1)', border: '1px solid var(--border)', borderRadius: 12, padding: '18px 20px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 9, marginBottom: 16 }}>
<span style={{ width: 6, height: 6, background: dot, borderRadius: 1 }} />
<span className="mono" style={{ fontSize: 11, fontWeight: 600, letterSpacing: '0.1em', color }}>{label}</span>
</div>
{children}
</div>
);
const Row = ({ children }: { children: React.ReactNode }) => (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '9px 0', gap: 12, flexWrap: 'wrap' }}>{children}</div>
);
const Divider = () => <div style={{ height: 1, background: '#14141E', margin: '4px 0' }} />;
function Toggle({ on, onClick }: { on: boolean; onClick: () => void }) {
return (
<button onClick={onClick} aria-pressed={on} style={{ cursor: 'pointer', appearance: 'none', position: 'relative', width: 40, height: 22, borderRadius: 11, border: '1px solid var(--border-hi)', background: on ? 'var(--g-a)' : '#15151f' }}>
<span style={{ position: 'absolute', top: 1, left: on ? 19 : 1, width: 18, height: 18, borderRadius: '50%', background: on ? '#06060B' : '#7a7a8e', transition: '.18s' }} />
</button>
);
}
function tierLabel(tier: string) {
if (tier === 'desk') return { label: 'DESK', color: '#00ffb8' };
if (tier === 'analyst') return { label: 'ANALYST', color: 'var(--g-b)' };
return { label: 'FREE', color: 'var(--text-1)' };
}
export default function SettingsPage() {
const router = useRouter();
const { user, tier } = useAuth();
const [emailAlerts, setEmailAlerts] = useState(true);
const [pushAlerts, setPushAlerts] = useState(false);
const [deleteText, setDeleteText] = useState('');
const [deleting, setDeleting] = useState(false);
const [delError, setDelError] = useState('');
const plan = tierLabel(tier || 'free');
const canDelete = deleteText === 'DELETE';
const handleDelete = async () => {
if (!canDelete || deleting) return;
setDeleting(true);
setDelError('');
try {
const token = typeof window !== 'undefined' ? localStorage.getItem('sb-token') : null;
const res = await fetch('/api/user/profile', {
method: 'DELETE',
headers: token ? { Authorization: `Bearer ${token}` } : {},
});
if (res.ok) {
router.replace('/?deleted=1');
return;
}
// No fake success — if the backend can't process it, say so.
setDelError('Account deletion could not be completed automatically. Email support@vyndr.app and we will remove your account within 48 hours.');
} catch {
setDelError('Network error. Try again, or email support@vyndr.app.');
} finally {
setDeleting(false);
}
};
return (
<section style={{ maxWidth: 640, margin: '0 auto', padding: '24px 16px 120px' }}>
<div style={{ marginBottom: 24 }}>
<div className="mono" style={{ fontSize: 11, color: 'var(--g-a)', letterSpacing: '0.12em', marginBottom: 10 }}>SETTINGS</div>
<h1 style={{ margin: 0, fontSize: 30, fontWeight: 800, letterSpacing: '-0.01em' }}>Settings</h1>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
{/* ACCOUNT */}
<Section label="ACCOUNT">
<Row>
<span style={{ fontSize: 13, color: 'var(--text-1)' }}>Email</span>
<span className="mono" style={{ fontSize: 13, color: 'var(--text-0)' }}>{user?.email || '—'}</span>
</Row>
<Divider />
<Row>
<span style={{ fontSize: 13, color: 'var(--text-1)' }}>Plan</span>
<span className="mono" style={{ display: 'inline-flex', alignItems: 'center', fontWeight: 700, fontSize: 11, letterSpacing: '0.06em', color: plan.color, background: 'color-mix(in srgb, currentColor 12%, transparent)', border: '1px solid color-mix(in srgb, currentColor 40%, transparent)', padding: '3px 10px', borderRadius: 5 }}>{plan.label}</span>
</Row>
<Divider />
<Row>
<div>
<div style={{ fontSize: 13, color: 'var(--text-0)' }}>Security &amp; two-factor</div>
<div style={{ fontSize: 11, color: 'var(--text-1)', marginTop: 2 }}>Authenticator-app MFA</div>
</div>
<a href="/settings/security" className="mono" style={{ cursor: 'pointer', background: 'transparent', border: '1px solid var(--border-hi)', borderRadius: 8, padding: '8px 14px', fontSize: 11, fontWeight: 600, color: 'var(--text-1)', textDecoration: 'none' }}>MANAGE </a>
</Row>
</Section>
{/* SUBSCRIPTION */}
<Section label="SUBSCRIPTION">
<Row>
<div>
<div style={{ fontSize: 14, fontWeight: 600, color: '#fff' }}>VYNDR {plan.label === 'FREE' ? 'Free' : plan.label === 'DESK' ? 'Desk' : 'Analyst'}</div>
<div className="mono" style={{ fontSize: 12, color: 'var(--text-1)', marginTop: 3 }}>{plan.label === 'FREE' ? '5 scans / month' : 'Active subscription'}</div>
</div>
<div style={{ display: 'flex', gap: 9 }}>
{plan.label !== 'FREE' && (
<button onClick={() => router.push('/profile')} className="mono" style={{ cursor: 'pointer', background: 'transparent', border: '1px solid var(--border-hi)', borderRadius: 8, padding: '9px 15px', fontSize: 11, fontWeight: 600, color: 'var(--text-1)' }}>MANAGE</button>
)}
<a href="/pricing" className="mono" style={{ cursor: 'pointer', background: 'var(--g-a)', border: '1px solid var(--g-a)', borderRadius: 8, padding: '9px 15px', fontSize: 11, fontWeight: 700, color: '#06060B', textDecoration: 'none' }}>{plan.label === 'FREE' ? 'UPGRADE' : 'CHANGE PLAN'}</a>
</div>
</Row>
</Section>
{/* NOTIFICATIONS */}
<Section label="NOTIFICATIONS">
<Row>
<div>
<div style={{ fontSize: 13, color: 'var(--text-0)' }}>Email alerts</div>
<div style={{ fontSize: 11, color: 'var(--text-1)', marginTop: 2 }}>Grade locks and line moves</div>
</div>
<Toggle on={emailAlerts} onClick={() => setEmailAlerts((v) => !v)} />
</Row>
<Divider />
<Row>
<div>
<div style={{ fontSize: 13, color: 'var(--text-0)' }}>Push alerts</div>
<div style={{ fontSize: 11, color: 'var(--text-1)', marginTop: 2 }}>Injury news for tracked players</div>
</div>
<Toggle on={pushAlerts} onClick={() => setPushAlerts((v) => !v)} />
</Row>
</Section>
{/* DISPLAY */}
<Section label="DISPLAY PREFERENCES">
<Row>
<div>
<div style={{ fontSize: 13, color: 'var(--text-0)' }}>Display, odds format &amp; accessibility</div>
<div style={{ fontSize: 11, color: 'var(--text-1)', marginTop: 2 }}>Region, odds format, text size, motion, contrast, colorblind-safe</div>
</div>
<button onClick={() => window.__prefs?.()} className="mono" style={{ cursor: 'pointer', background: 'transparent', border: '1px solid var(--border-hi)', borderRadius: 8, padding: '8px 14px', fontSize: 11, fontWeight: 600, color: 'var(--text-1)' }}>OPEN</button>
</Row>
</Section>
{/* RESPONSIBLE PLAY */}
<Section label="RESPONSIBLE PLAY" color="var(--text-1)" dot="var(--amber)">
<Row>
<span style={{ fontSize: 13, color: 'var(--text-0)' }}>Set a weekly time limit</span>
<a href="/responsible-gambling" className="mono" style={{ cursor: 'pointer', background: 'transparent', border: '1px solid var(--border-hi)', borderRadius: 8, padding: '8px 14px', fontSize: 11, fontWeight: 600, color: 'var(--text-1)', textDecoration: 'none' }}>CONFIGURE</a>
</Row>
<Divider />
<Row>
<span style={{ fontSize: 13, color: 'var(--text-0)' }}>Self-exclusion</span>
<a href="/responsible-gambling" className="mono" style={{ cursor: 'pointer', background: 'transparent', border: '1px solid #FFB34740', borderRadius: 8, padding: '8px 14px', fontSize: 11, fontWeight: 600, color: 'var(--amber)', textDecoration: 'none' }}>TAKE A BREAK</a>
</Row>
</Section>
{/* DANGER ZONE */}
<div style={{ background: '#0E0808', border: '1px solid #3A1A1A', borderRadius: 12, padding: '18px 20px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 9, marginBottom: 14 }}>
<span style={{ width: 6, height: 6, background: 'var(--miss)', borderRadius: 1 }} />
<span className="mono" style={{ fontSize: 11, fontWeight: 600, letterSpacing: '0.1em', color: '#FF7A6B' }}>DANGER ZONE</span>
</div>
<p style={{ margin: '0 0 14px', fontSize: 13, lineHeight: 1.55, color: '#9A8585' }}>
Deleting your account is permanent. All grade history and tracked players will be lost. Type{' '}
<strong className="mono" style={{ color: '#FF7A6B' }}>DELETE</strong> to confirm.
</p>
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap' }}>
<input
value={deleteText}
onChange={(e) => setDeleteText(e.target.value)}
placeholder="Type DELETE"
aria-label="Type DELETE to confirm account deletion"
className="mono"
style={{ flex: 1, minWidth: 160, appearance: 'none', background: '#0A0606', border: '1px solid #3A1A1A', borderRadius: 8, padding: '10px 13px', fontSize: 13, color: '#fff', letterSpacing: '0.08em', outline: 'none' }}
/>
<button
onClick={handleDelete}
disabled={!canDelete || deleting}
className="mono"
style={{
cursor: canDelete && !deleting ? 'pointer' : 'not-allowed',
appearance: 'none',
background: canDelete ? 'var(--miss)' : '#1A0E0E',
border: `1px solid ${canDelete ? 'var(--miss)' : '#3A1A1A'}`,
borderRadius: 8,
padding: '10px 18px',
fontSize: 11,
fontWeight: 700,
color: canDelete ? '#0A0606' : '#6A4A4A',
letterSpacing: '0.04em',
transition: '.15s',
}}
>
{deleting ? 'WORKING…' : 'DELETE ACCOUNT'}
</button>
</div>
{delError && <p className="mono" style={{ marginTop: 12, fontSize: 12, color: '#FFB0A4', lineHeight: 1.5 }}>{delError}</p>}
</div>
</div>
</section>
);
}
+2 -1
View File
@@ -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>
);
}
+50
View File
@@ -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>
);
}
+55 -2
View File
@@ -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} />
+60 -2
View File
@@ -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' }}>
+145
View File
@@ -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>
);
}
+9 -1
View File
@@ -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,
+139
View File
@@ -0,0 +1,139 @@
/* ============================================================
VYNDR 2.0 — archetype visual map (Session 42, §Player Intelligence).
Ported verbatim from the design's ArchetypeBadge.dc.html MAP +
ArchetypeBlend.dc.html. Plain CommonJS so .tsx imports it AND the
Jest suite can require it directly (same pattern as vyndrTokens.js).
Colors/glyphs MUST match src/services/archetypeService.js ARCHETYPES.
============================================================ */
/* Glyph SVG inner-markup keyed by name (drawn in a 0 0 16 16 viewBox). */
const GLYPHS = {
triangle: '<path d="M8 2 L14 14 L2 14 Z" fill="currentColor"/>',
node: '<circle cx="8" cy="8" r="2.3" fill="currentColor"/><path d="M8 5.7V1.5M5.4 9.3 2.4 13M10.6 9.3 13.6 13" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/>',
shield: '<path d="M8 1.6 L13.4 4 V8.2 C13.4 11.2 8 14.4 8 14.4 C8 14.4 2.6 11.2 2.6 8.2 V4 Z" fill="currentColor"/>',
shieldCheck: '<path d="M8 1.6 L13.4 4 V8.2 C13.4 11.2 8 14.4 8 14.4 C8 14.4 2.6 11.2 2.6 8.2 V4 Z" stroke="currentColor" stroke-width="1.5" fill="none"/><path d="M5.6 8 L7.4 9.9 L10.4 6.2" stroke="currentColor" stroke-width="1.5" fill="none" stroke-linecap="round" stroke-linejoin="round"/>',
target: '<circle cx="8" cy="8" r="6" stroke="currentColor" stroke-width="1.5" fill="none"/><circle cx="8" cy="8" r="2.2" fill="currentColor"/>',
bolt: '<path d="M9 1.6 L4 9 H7.6 L7 14.4 L12 6.6 H8.4 Z" fill="currentColor"/>',
twin: '<circle cx="6" cy="8" r="3.6" stroke="currentColor" stroke-width="1.6" fill="none"/><circle cx="10" cy="8" r="3.6" stroke="currentColor" stroke-width="1.6" fill="none"/>',
chain: '<rect x="2.4" y="5.6" width="7" height="4.8" rx="2.4" stroke="currentColor" stroke-width="1.6" fill="none"/><rect x="6.6" y="5.6" width="7" height="4.8" rx="2.4" stroke="currentColor" stroke-width="1.6" fill="none"/>',
batball: '<path d="M3 13.2 L11 5.2" stroke="currentColor" stroke-width="2.4" stroke-linecap="round"/><circle cx="12.4" cy="3.6" r="1.9" fill="currentColor"/>',
crosshair: '<circle cx="8" cy="8" r="5.6" stroke="currentColor" stroke-width="1.5" fill="none"/><path d="M8 1.4V4.4M8 11.6V14.6M1.4 8H4.4M11.6 8H14.6" stroke="currentColor" stroke-width="1.5"/><circle cx="8" cy="8" r="1.5" fill="currentColor"/>',
diamond: '<path d="M8 1.6 L14.4 8 L8 14.4 L1.6 8 Z" stroke="currentColor" stroke-width="1.6" fill="none"/><path d="M8 5.4 L10.6 8 L8 10.6 L5.4 8 Z" fill="currentColor"/>',
diamondLine: '<path d="M8 1.6 L14.4 8 L8 14.4 L1.6 8 Z" stroke="currentColor" stroke-width="1.7" fill="none"/>',
star: '<path d="M8 1.4 L9.7 6 L14.6 6.3 L10.7 9.3 L12.1 14 L8 11.2 L3.9 14 L5.3 9.3 L1.4 6.3 L6.3 6 Z" fill="currentColor"/>',
clock: '<circle cx="8" cy="9.2" r="5" stroke="currentColor" stroke-width="1.6" fill="none"/><path d="M8 9.2 V5.6 M6.2 2.2 H9.8" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/>',
chevrons: '<path d="M2.6 4 L6.6 8 L2.6 12 M7.8 4 L11.8 8 L7.8 12" stroke="currentColor" stroke-width="1.9" fill="none" stroke-linecap="round" stroke-linejoin="round"/>',
slash: '<path d="M3 13 L13 3 M13 3 H8.6 M13 3 V7.4" stroke="currentColor" stroke-width="1.9" fill="none" stroke-linecap="round" stroke-linejoin="round"/>',
arc: '<path d="M2 11.5 Q8 1.5 14 11.5" stroke="currentColor" stroke-width="1.7" fill="none" stroke-linecap="round"/><circle cx="13.4" cy="11.2" r="1.6" fill="currentColor"/>',
plus: '<path d="M8 2.4 V13.6 M2.4 8 H13.6" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/><circle cx="8" cy="8" r="1.9" fill="currentColor"/>',
lock: '<rect x="3.6" y="7" width="8.8" height="6.6" rx="1.4" fill="currentColor"/><path d="M5.4 7 V5 a2.6 2.6 0 0 1 5.2 0 V7" stroke="currentColor" stroke-width="1.5" fill="none"/>',
swap: '<path d="M3 5.6 H11.4 M9.4 3.6 L11.6 5.6 L9.4 7.6 M13 10.4 H4.6 M6.6 8.4 L4.4 10.4 L6.6 12.4" stroke="currentColor" stroke-width="1.5" fill="none" stroke-linecap="round" stroke-linejoin="round"/>',
half: '<circle cx="8" cy="8" r="6" stroke="currentColor" stroke-width="1.6" fill="none"/><path d="M8 2.2 a5.8 5.8 0 0 1 0 11.6 Z" fill="currentColor"/>',
uparrow: '<path d="M8 13.4 V3.6 M4.4 7.2 L8 3.6 L11.6 7.2" stroke="currentColor" stroke-width="1.8" fill="none" stroke-linecap="round" stroke-linejoin="round"/>',
postup: '<rect x="6.4" y="2.6" width="3.2" height="11" rx="1.6" fill="currentColor"/><circle cx="12" cy="5.2" r="1.7" fill="currentColor"/>',
};
/* name → { color, glyph, desc } — the design's MAP. */
const ARCHETYPE_MAP = {
// NBA
'VOLUME SCORER': { c: '#FF6B4A', d: 'High usage, shot-dependent scorer', g: 'triangle' },
'FLOOR GENERAL': { c: '#4A9EFF', d: 'Assist-heavy playmaker', g: 'node' },
'TWO-WAY ANCHOR': { c: '#A78BFA', d: 'Defense, rebounds, blocks', g: 'shield' },
'STRETCH BIG': { c: '#2DD4BF', d: 'Floor-spacing shooting big', g: 'target' },
'USAGE SPONGE': { c: '#FFB347', d: 'Usage spikes when stars sit', g: 'uparrow' },
'COMBO GUARD': { c: '#00D4A0', d: 'Scoring + playmaking hybrid', g: 'twin' },
'ROLE GLUE': { c: '#9499A8', d: 'Low-usage specialist', g: 'chain' },
'TRANSITION ENGINE': { c: '#22D3EE', d: 'Pace-pushing fast-break threat', g: 'chevrons' },
'POST SCORER': { c: '#FF5C5C', d: 'Back-to-basket interior scorer', g: 'postup' },
'DEFENSIVE SPECIALIST': { c: '#6366F1', d: 'Perimeter stopper, low usage', g: 'shieldCheck' },
'POINT FORWARD': { c: '#38BDF8', d: 'Oversized primary creator', g: 'half' },
SLASHER: { c: '#FB923C', d: 'Rim-attacking, foul-drawing driver', g: 'slash' },
'RIM RUNNER': { c: '#F472B6', d: 'Lob and putback finisher', g: 'arc' },
'3-AND-D': { c: '#818CF8', d: 'Catch-and-shoot plus defense', g: 'crosshair' },
'SIXTH MAN': { c: '#FACC15', d: 'Bench scoring spark', g: 'bolt' },
// WNBA-unique
'POST FACILITATOR': { c: '#C084FC', d: 'Playmaking hub from the post', g: 'node' },
'TWO-WAY WING': { c: '#A78BFA', d: 'Two-way perimeter wing', g: 'shieldCheck' },
'STRETCH FORWARD': { c: '#2DD4BF', d: 'Floor-spacing forward', g: 'target' },
'SLASHING GUARD': { c: '#FB923C', d: 'Downhill driving guard', g: 'slash' },
'INTERIOR ANCHOR': { c: '#6366F1', d: 'Paint defender and rebounder', g: 'shield' },
// MLB
'POWER PULL': { c: '#FF5C5C', d: 'HR-dependent, high strikeout power', g: 'batball' },
CONTACT: { c: '#3DDC84', d: 'High average, low strikeout', g: 'crosshair' },
'RUN PRODUCER': { c: '#4A9EFF', d: 'RBI-dependent, lineup context', g: 'diamond' },
ACE: { c: '#A78BFA', d: 'High K/9, low WHIP, deep games', g: 'star' },
'BULLPEN ARM': { c: '#FFB347', d: 'Short outings, high leverage', g: 'bolt' },
'SPEED THREAT': { c: '#2DD4BF', d: 'Stolen bases, speed score', g: 'chevrons' },
'TWO-WAY PLAYER': { c: '#F472B6', d: 'Bats and pitches at elite level', g: 'half' },
'UTILITY PLAYER': { c: '#22D3EE', d: 'Multi-position lineup flex', g: 'plus' },
'INNINGS EATER': { c: '#818CF8', d: 'Durable, deep-start workhorse', g: 'clock' },
'POWER SLUGGER': { c: '#FF6B4A', d: 'All-fields power producer', g: 'triangle' },
'TABLE SETTER': { c: '#38BDF8', d: 'On-base leadoff catalyst', g: 'diamondLine' },
'GAP HITTER': { c: '#34D399', d: 'Doubles and extra-base gaps', g: 'uparrow' },
CLOSER: { c: '#FB7185', d: 'Ninth-inning save specialist', g: 'lock' },
SWINGMAN: { c: '#FBBF24', d: 'Spot starter and long relief', g: 'swap' },
'DEFENSIVE WIZARD': { c: '#6366F1', d: 'Glove-first defensive value', g: 'shieldCheck' },
// Soccer
POACHER: { c: '#FF5C5C', d: 'Penalty-box finisher', g: 'crosshair' },
CREATOR: { c: '#4A9EFF', d: 'Chance-creating playmaker', g: 'node' },
'TARGET MAN': { c: '#FF6B4A', d: 'Hold-up aerial striker', g: 'triangle' },
'BOX-TO-BOX': { c: '#00D4A0', d: 'All-action central midfielder', g: 'chevrons' },
'WING WIZARD': { c: '#2DD4BF', d: 'Dribbling wide threat', g: 'slash' },
'SWEEPER KEEPER': { c: '#A78BFA', d: 'Distributing goalkeeper', g: 'shield' },
};
const FALLBACK = { c: '#9499A8', d: '', g: '' };
function archetypeInfo(name) {
const key = (name == null ? '' : String(name)).toUpperCase();
return ARCHETYPE_MAP[key] || FALLBACK;
}
function archetypeColor(name) {
return archetypeInfo(name).c;
}
function glyphSvg(glyphKey) {
const inner = GLYPHS[glyphKey] || '';
return `<svg viewBox="0 0 16 16" width="100%" height="100%" style="display:block">${inner}</svg>`;
}
/**
* Resolve the full style bundle for a badge — mirrors the design's renderVals.
* variant: 'full' (solid) | 'ghost' (outline) | 'tint' (default).
* size: 'sm' | 'md'.
*/
function badgeStyle(name, variant = 'tint', size = 'sm') {
const info = archetypeInfo(name);
const sm = size === 'sm';
let textColor, bg, borderColor, glyphColor, textShadow = 'none';
if (variant === 'full' || variant === 'solid') {
textColor = '#FFFFFF'; bg = info.c; borderColor = info.c; glyphColor = '#FFFFFF';
textShadow = '0 1px 2px rgba(0,0,0,0.38)';
} else if (variant === 'ghost' || variant === 'outline') {
textColor = info.c; bg = 'transparent'; borderColor = info.c + 'CC'; glyphColor = info.c;
} else {
textColor = info.c; bg = info.c + '1F'; borderColor = info.c + '52'; glyphColor = info.c;
}
return {
name: (name == null ? '' : String(name)).toUpperCase(),
desc: info.d,
glyph: info.g,
color: info.c,
textColor, bg, borderColor, glyphColor, textShadow,
fontSize: sm ? '9.5px' : '12px',
padding: sm ? '3px 7px 3px 6px' : '5px 11px 5px 9px',
radius: sm ? '4px' : '6px',
glyphSize: sm ? '11px' : '14px',
descSize: sm ? '11px' : '13px',
};
}
module.exports = {
GLYPHS,
ARCHETYPE_MAP,
archetypeInfo,
archetypeColor,
glyphSvg,
badgeStyle,
};
+29
View File
@@ -0,0 +1,29 @@
/* Sportsbook brand map (Session 42) — ported from the design's BookChip.dc.html
BOOKS table. CommonJS so it's testable + importable from the .tsx chip. */
const BOOKS = {
DK: { name: 'DraftKings', mono: 'DK', bg: '#0E1E12', fg: '#53D337', bd: '#53D33755' },
DRAFTKINGS: { name: 'DraftKings', mono: 'DK', bg: '#0E1E12', fg: '#53D337', bd: '#53D33755' },
FD: { name: 'FanDuel', mono: 'FD', bg: '#0A1830', fg: '#1493FF', bd: '#1493FF55' },
FANDUEL: { name: 'FanDuel', mono: 'FD', bg: '#0A1830', fg: '#1493FF', bd: '#1493FF55' },
MGM: { name: 'BetMGM', mono: 'MGM', bg: '#1A1405', fg: '#C8A24B', bd: '#C8A24B55' },
BETMGM: { name: 'BetMGM', mono: 'MGM', bg: '#1A1405', fg: '#C8A24B', bd: '#C8A24B55' },
CZR: { name: 'Caesars', mono: 'CZR', bg: '#0C1A14', fg: '#1A7F5A', bd: '#1A7F5A66' },
CAESARS: { name: 'Caesars', mono: 'CZR', bg: '#0C1A14', fg: '#1A7F5A', bd: '#1A7F5A66' },
ESPN: { name: 'ESPN BET', mono: 'EB', bg: '#2A0A0A', fg: '#FF4A4A', bd: '#FF4A4A55' },
'ESPN BET': { name: 'ESPN BET', mono: 'EB', bg: '#2A0A0A', fg: '#FF4A4A', bd: '#FF4A4A55' },
BR: { name: 'BetRivers', mono: 'BR', bg: '#1A0E22', fg: '#B07CFF', bd: '#B07CFF55' },
PB: { name: 'PrizePicks', mono: 'PP', bg: '#0A1A1A', fg: '#19E0C8', bd: '#19E0C855' },
PRIZEPICKS: { name: 'PrizePicks', mono: 'PP', bg: '#0A1A1A', fg: '#19E0C8', bd: '#19E0C855' },
FAN: { name: 'Fanatics', mono: 'FAN', bg: '#1A0A0A', fg: '#E84855', bd: '#E8485555' },
B365: { name: 'bet365', mono: '365', bg: '#0A1A12', fg: '#2E8B57', bd: '#2E8B5766' },
HR: { name: 'Hard Rock', mono: 'HR', bg: '#1A1206', fg: '#D4A24B', bd: '#D4A24B55' },
UD: { name: 'Underdog', mono: 'UD', bg: '#15101F', fg: '#A07CFF', bd: '#A07CFF55' },
};
function bookInfo(book) {
const key = String(book == null ? '' : book).toUpperCase();
return BOOKS[key] || { name: key, mono: key.slice(0, 3) || '?', bg: '#14141E', fg: '#B8BCC8', bd: '#23232F' };
}
module.exports = { BOOKS, bookInfo };
+38 -1
View File
@@ -86,7 +86,44 @@ function mapScanToGradeResult(input = {}) {
altLadder: includeAlt && Array.isArray(input.alt_lines)
? input.alt_lines.map((a) => ({ line: a.line, grade: a.grade }))
: [],
// Session 42 — Player Intelligence additions. All OPTIONAL + self-hiding:
// they only render once the engine supplies them (Session 43 data pipeline).
...buildIntelFields(input),
};
}
module.exports = { mapScanToGradeResult, statLabel, computeEdge, isPhosphorConfirmed, toSignals };
/**
* Build the optional archetype / stat-context / vyndr-intelligence fields for
* the grade card from whatever the engine provided. Returns {} when nothing is
* present so the card sections stay hidden (no empty boxes).
*/
function buildIntelFields(input) {
const out = {};
if (Array.isArray(input.archetype_blend) && input.archetype_blend.length) {
out.archetypeBlend = input.archetype_blend;
} else if (input.archetype) {
out.archetypeBlend = [{ archetype: String(input.archetype), weight: 1 }];
}
if (input.prop_dna && (input.prop_dna.reliable || input.prop_dna.volatile)) {
out.propDNA = {
reliable: input.prop_dna.reliable || [],
volatile: input.prop_dna.volatile || [],
};
}
const sc = {};
if (input.season_avg != null) sc.season = String(input.season_avg);
if (input.last10_avg != null) sc.last10 = String(input.last10_avg);
if (input.vs_opp_avg != null) sc.vsOpp = String(input.vs_opp_avg);
if (Object.keys(sc).length) out.statContext = sc;
const vi = {};
if (input.form != null) vi.form = input.form;
if (input.usage != null) vi.usage = String(input.usage);
if (input.matchup_grade != null) vi.matchup = String(input.matchup_grade);
if (input.rest != null) vi.rest = String(input.rest);
if (Object.keys(vi).length) out.vyndrIntel = vi;
return out;
}
module.exports = { mapScanToGradeResult, statLabel, computeEdge, isPhosphorConfirmed, toSignals, buildIntelFields };
+10
View File
@@ -0,0 +1,10 @@
/* Canonical player-profile link builder (Session 42). Used everywhere a
player name should be tappable. CommonJS so it's testable + importable. */
function playerHref(name, sport) {
const n = encodeURIComponent(String(name == null ? '' : name).trim());
const sp = String(sport || 'nba').toLowerCase();
return `/player/${n}?sport=${encodeURIComponent(sp)}`;
}
module.exports = { playerHref };
+39
View File
@@ -0,0 +1,39 @@
/* Pure display transforms for the player profile page (Session 42).
CommonJS so it's unit-testable + importable from the .tsx page. */
/** Two-letter mono initials for the hero avatar. */
function initials(name) {
const parts = String(name || '').trim().split(/\s+/).filter(Boolean);
if (parts.length === 0) return '??';
if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase();
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
}
const SPORT_LABELS = { nba: 'NBA', mlb: 'MLB', wnba: 'WNBA', soccer: 'SOC' };
function sportLabel(sport) {
return SPORT_LABELS[String(sport || '').toLowerCase()] || String(sport || '').toUpperCase();
}
/** Prop DNA → flat list of { prop, state, color } for the grid (reliable first). */
function dnaRows(propDNA) {
const dna = propDNA || { reliable: [], volatile: [] };
const pretty = (s) => String(s).replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
return [
...(dna.reliable || []).map((p) => ({ prop: pretty(p), state: 'RELIABLE', color: '#00D4A0' })),
...(dna.volatile || []).map((p) => ({ prop: pretty(p), state: 'VOLATILE', color: '#FFB347' })),
];
}
/** Short human readout under the DNA bar, e.g. "Primary lane: Power Pull". */
function blendReadout(archetype) {
if (!archetype || !archetype.primary) return '';
const prim = archetype.primary.name;
const sec = archetype.secondary ? `, supported by ${title(archetype.secondary.name)}` : '';
return `Primary lane: ${title(prim)}${sec}.`;
}
function title(s) {
return String(s || '').toLowerCase().replace(/\b\w/g, (c) => c.toUpperCase());
}
module.exports = { initials, sportLabel, dnaRows, blendReadout, title };