Session 36: Design system Phase E — remaining screens + dashboard Bloomberg lines (1853 tests)
VYNDR 2.0 conversion, Phase E. Frontend-only; zero backend changes. - lib/slateAdapter.js: parseAmericanOdds, detectBestLines, mapScheduleToGameCards (best/worst line detection — the Bloomberg pattern). - Reskinned the LEGACY GameCard's game-lines grid with best/worst highlighting + SportBadge, keeping inline grading intact (a wholesale swap to the display-only vyndr/GameCard would have deleted the slate's grading interaction). - compare/invite/help/about: RouteStubs -> real design-system pages. - login reskinned (scanlines, system voice, new Wordmark); pricing + ClaimMeter. Honest scope: the full GameCard swap needs inline grading ported into the new component first; profile/settings/blog/game-detail reskins are light/deferred. 18 new tests. Backend 1839 -> 1853, 144 suites, zero regressions. Web build clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -2,6 +2,9 @@
|
||||
|
||||
import { useState } from 'react';
|
||||
import type { PropRowProp, PropRowResult, Tier } from '@/components/PropRow';
|
||||
import SportBadge from '@/components/vyndr/SportBadge';
|
||||
import SectionHead from '@/components/vyndr/SectionHead';
|
||||
import { detectBestLines } from '@/lib/slateAdapter';
|
||||
// Session 19 — PlayerCard groups props by player so a single player
|
||||
// with 4 props renders as ONE card with their headshot + 4 stat
|
||||
// lines, instead of 4 independent stripes that repeat the name.
|
||||
@@ -20,13 +23,6 @@ import PlayerCard, { groupPropsByPlayer } from '@/components/PlayerCard';
|
||||
|
||||
export type SlateSport = 'nba' | 'wnba' | 'mlb' | 'soccer';
|
||||
|
||||
const SPORT_EMOJI: Record<SlateSport, string> = {
|
||||
nba: '🏀',
|
||||
wnba: '🏀',
|
||||
mlb: '⚾',
|
||||
soccer: '⚽',
|
||||
};
|
||||
|
||||
const SPORT_ACCENT: Record<SlateSport, string> = {
|
||||
nba: '#E94B3C',
|
||||
wnba: '#FFB347',
|
||||
@@ -172,12 +168,27 @@ export function teamAbbr(fullName: string, sport: SlateSport): string {
|
||||
return fullName.slice(0, 4).toUpperCase();
|
||||
}
|
||||
|
||||
const SPORT_LABEL: Record<SlateSport, string> = {
|
||||
nba: 'NBA',
|
||||
wnba: 'WNBA',
|
||||
mlb: 'MLB',
|
||||
soccer: 'SOCCER',
|
||||
};
|
||||
// Bloomberg line cell (Session 36): best = green tint + green left border,
|
||||
// worst = subtle red. The #1 visual upgrade (§13).
|
||||
function LineCell({ value, best, worst }: { value: string; best?: boolean; worst?: boolean }) {
|
||||
return (
|
||||
<div
|
||||
className="mono"
|
||||
style={{
|
||||
padding: '7px 6px',
|
||||
textAlign: 'center',
|
||||
fontSize: 12.5,
|
||||
fontWeight: best ? 700 : 500,
|
||||
color: best ? 'var(--g-a)' : worst ? '#ff8a8a' : 'var(--text-0)',
|
||||
background: best ? 'rgba(0,212,160,.13)' : worst ? 'rgba(255,82,82,.07)' : 'transparent',
|
||||
borderLeft: best ? '2px solid var(--g-a)' : worst ? '2px solid rgba(255,82,82,.4)' : '2px solid transparent',
|
||||
borderRadius: 3,
|
||||
}}
|
||||
>
|
||||
{value}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function GameCard(props: GameCardProps) {
|
||||
const {
|
||||
@@ -189,7 +200,8 @@ export default function GameCard(props: GameCardProps) {
|
||||
} = props;
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const badge = statusBadge(status, score);
|
||||
const bookRows = gameLines?.books ? Object.entries(gameLines.books) : [];
|
||||
// Session 36 — best/worst line detection (Bloomberg pattern) via slateAdapter.
|
||||
const lineRows = gameLines?.books ? detectBestLines(gameLines.books) : [];
|
||||
const streakRows = (streaks || []).filter((s) => s && s.player && s.description);
|
||||
|
||||
// Session 19 — visibility budget now applies to PLAYERS, not raw
|
||||
@@ -240,7 +252,7 @@ export default function GameCard(props: GameCardProps) {
|
||||
letterSpacing: '-0.02em',
|
||||
}}
|
||||
>
|
||||
<span aria-hidden style={{ fontSize: 15 }}>{SPORT_EMOJI[sport]}</span>
|
||||
<SportBadge sport={sport} size="sm" />
|
||||
<span className="mono" style={{ letterSpacing: '0.04em' }}>
|
||||
{teamAbbr(awayTeam, sport)}
|
||||
</span>
|
||||
@@ -250,22 +262,6 @@ export default function GameCard(props: GameCardProps) {
|
||||
<span className="mono" style={{ letterSpacing: '0.04em' }}>
|
||||
{teamAbbr(homeTeam, sport)}
|
||||
</span>
|
||||
<span
|
||||
className="mono"
|
||||
style={{
|
||||
marginLeft: 'auto',
|
||||
fontSize: 10,
|
||||
fontWeight: 700,
|
||||
color: '#0A0A0F',
|
||||
background: accent,
|
||||
padding: '3px 8px',
|
||||
borderRadius: 4,
|
||||
letterSpacing: '0.08em',
|
||||
}}
|
||||
aria-label={`Sport: ${SPORT_LABEL[sport]}`}
|
||||
>
|
||||
{SPORT_LABEL[sport]}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
@@ -331,41 +327,31 @@ export default function GameCard(props: GameCardProps) {
|
||||
spread, total. Renders only when lines exist; never blocks the
|
||||
card. The brand edge is props, but lines give immediate action
|
||||
even when props haven't been published. */}
|
||||
{bookRows.length > 0 && (
|
||||
{lineRows.length > 0 && (
|
||||
<div
|
||||
style={{
|
||||
padding: '12px 20px 14px',
|
||||
borderTop: '1px solid var(--border, #1A1A24)',
|
||||
display: 'grid',
|
||||
gap: 8,
|
||||
background: 'rgba(255,255,255,0.015)',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="mono"
|
||||
style={{ fontSize: 10, fontWeight: 700, letterSpacing: '0.1em', color: 'var(--text-tertiary, #6B6B7B)', textTransform: 'uppercase' }}
|
||||
>
|
||||
Game Lines
|
||||
<SectionHead style={{ marginBottom: 10 }}>
|
||||
GAME LINES <span style={{ color: 'var(--text-2)' }}>· {lineRows.length} BOOK{lineRows.length === 1 ? '' : 'S'}</span>
|
||||
</SectionHead>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '72px 1fr 1fr 1fr', gap: '2px 6px', alignItems: 'center' }}>
|
||||
<div className="label" style={{ fontSize: 10 }}>BOOK</div>
|
||||
<div className="label" style={{ fontSize: 10, textAlign: 'center' }}>{teamAbbr(awayTeam, sport)} ML</div>
|
||||
<div className="label" style={{ fontSize: 10, textAlign: 'center' }}>{teamAbbr(homeTeam, sport)} ML</div>
|
||||
<div className="label" style={{ fontSize: 10, textAlign: 'center' }}>O/U</div>
|
||||
{lineRows.map((r) => (
|
||||
<span key={r.book} style={{ display: 'contents' }}>
|
||||
<div className="mono" style={{ fontSize: 12, fontWeight: 700, color: 'var(--text-1)', textTransform: 'capitalize', paddingLeft: 2, alignSelf: 'center' }}>{r.book}</div>
|
||||
<LineCell value={r.awayML} best={r.bestAway} worst={r.worstAway} />
|
||||
<LineCell value={r.homeML} best={r.bestHome} worst={r.worstHome} />
|
||||
<LineCell value={r.ou} />
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
{bookRows.map(([book, line]) => (
|
||||
<div
|
||||
key={book}
|
||||
className="mono"
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '72px 1fr 1fr 1fr',
|
||||
gap: 8,
|
||||
fontSize: 11.5,
|
||||
alignItems: 'baseline',
|
||||
color: 'var(--text-secondary, #8A8A9A)',
|
||||
}}
|
||||
>
|
||||
<span style={{ color: 'var(--text-0, #F0F0F5)', fontWeight: 700, textTransform: 'capitalize' }}>{book}</span>
|
||||
<span>{line.awayML ? `${teamAbbr(awayTeam, sport)} ${line.awayML}` : '—'}</span>
|
||||
<span>{line.homeML ? `${teamAbbr(homeTeam, sport)} ${line.homeML}` : '—'}</span>
|
||||
<span style={{ textAlign: 'right' }}>{line.total ? `O/U ${line.total}` : '—'}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user