Merge DS4 (design): billboards — STREAKS row, grade reveal, CLV reframe, /u profile
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> # Conflicts: # web/src/app/ledger/page.tsx
This commit is contained in:
@@ -2,15 +2,18 @@
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import PlayerAvatar from '@/components/vyndr/PlayerAvatar';
|
||||
import GradeBadge from '@/components/vyndr/GradeBadge';
|
||||
import { type Tier } from '@/lib/tierGate';
|
||||
|
||||
/**
|
||||
* StreaksPanel (Session 23).
|
||||
* StreaksPanel (Session 23 · rebuilt DS4 — the STREAKS BILLBOARD).
|
||||
*
|
||||
* Surfaces computed player streaks for a sport, narrowed by the active
|
||||
* stat filter. Everything through VYNDR's lens — "4-game 28+ scoring
|
||||
* streak", not "31.2 PPG". Free users see the top 3 with an upgrade
|
||||
* nudge; paid users see the full list.
|
||||
* DESIGN-SPEC Part 7 (#2): the STREAKS row is a P0 billboard — the timeline
|
||||
* is customer #1. Not a log line: a Bloomberg alert. ONE bold figure (the
|
||||
* streak LENGTH, mono + tabular, the largest thing in the row) among muted
|
||||
* context (real player identity + "built vs" lens), with a SINGLE severity
|
||||
* accent — tonight's matchup difficulty (step-up amber / step-down green).
|
||||
* The grade badge is tier-gated (the READ is the paid layer).
|
||||
*
|
||||
* Self-hides when there are no streaks so the landing page never shows an
|
||||
* empty box — the other layers (schedule, game lines, props) carry the
|
||||
@@ -61,39 +64,60 @@ export default function StreaksPanel({ sport, tier = 'free', stat = 'all', limit
|
||||
|
||||
// Session 60 (product def) — the PICTURE is free: every streak shows for
|
||||
// every tier. Only an explicit `limit` (landing teaser) caps the list.
|
||||
// The paid layer is the GRADE, not the data. tierGate still governs the
|
||||
// hot LISTS (top-3 free) — not streaks.
|
||||
void tier;
|
||||
const visible = limit && limit > 0 ? streaks.slice(0, limit) : streaks;
|
||||
const hidden = streaks.length - visible.length;
|
||||
// DS4 — the GRADE (the READ) is the paid layer; only analyst/desk see it.
|
||||
const canSeeGrade = tier === 'analyst' || tier === 'desk';
|
||||
|
||||
return (
|
||||
<section className="streaks-panel" style={{ margin: '16px 0' }}>
|
||||
<h3 style={panelHeading}>🔥 STREAKS</h3>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
{visible.map((s) => (
|
||||
<div key={`${s.player}-${s.type}`} style={rowStyle}>
|
||||
{/* DS0 — real headshot / team-colored monogram (kills the gray
|
||||
silhouette). The STREAKS row is a P0 billboard. */}
|
||||
<PlayerAvatar name={s.player} sport={sport} playerId={s.playerId} team={s.team} size={38} />
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={playerName}>
|
||||
{s.player}{s.team ? ` · ${s.team}` : ''}
|
||||
{/* Grade letter when the snapshot graded this player (the paid
|
||||
READ layer; the letter itself is public on the slate). */}
|
||||
{s.grade && <span className="mono" style={{ marginLeft: 8, fontSize: 10.5, fontWeight: 800, color: 'var(--g-a, #00D4A0)', border: '1px solid rgba(0,212,160,.35)', borderRadius: 4, padding: '1px 5px' }}>{s.grade}</span>}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{visible.map((s) => {
|
||||
const diff = s.lens?.difficulty;
|
||||
// ONE severity accent — tonight's matchup difficulty.
|
||||
const sev = diff === 'step up'
|
||||
? { color: 'var(--amber, #FFB347)', label: '▲ STEP-UP SPOT TONIGHT' }
|
||||
: diff === 'step down'
|
||||
? { color: 'var(--g-a, #00D4A0)', label: '▼ SOFTER SPOT TONIGHT' }
|
||||
: null;
|
||||
// The LENS — muted "built vs" context; never the raw streak alone.
|
||||
const builtVs = Array.isArray(s.lens?.builtVs) && s.lens!.builtVs!.length > 0
|
||||
? `built vs ${s.lens!.builtVs!.slice(0, 3).join(', ')}`
|
||||
: (s.lens?.read || s.description);
|
||||
return (
|
||||
<div key={`${s.player}-${s.type}`} style={rowStyle}>
|
||||
{/* Real identity — headshot / team-colored monogram (DS0). */}
|
||||
<PlayerAvatar name={s.player} sport={sport} playerId={s.playerId} team={s.team} size={46} />
|
||||
|
||||
{/* THE HERO — streak length, mono + tabular, the largest figure
|
||||
in the row. One bold number; everything else demoted. */}
|
||||
<div style={heroBlock}>
|
||||
<span className="mono" style={heroNum}>{s.currentStreak}</span>
|
||||
<span className="mono" style={heroUnit}>GAME<br />STREAK</span>
|
||||
</div>
|
||||
{/* THE LENS — the interpreted read, never the raw streak alone. */}
|
||||
<div style={streakDesc}>{s.lens?.read || s.description}</div>
|
||||
{s.lens?.difficulty && (
|
||||
<div className="mono" style={{ fontSize: 10.5, marginTop: 2, color: s.lens.difficulty === 'step up' ? 'var(--amber, #FFB347)' : s.lens.difficulty === 'step down' ? 'var(--g-a, #00D4A0)' : 'var(--text-tertiary, #6A6A78)' }}>
|
||||
{s.lens.difficulty === 'step up' ? '▲ TOUGHER SPOT TONIGHT' : s.lens.difficulty === 'step down' ? '▼ SOFTER SPOT TONIGHT' : ''}
|
||||
|
||||
{/* Identity + the LENS — demoted, muted context. */}
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={playerName}>
|
||||
{s.player}{s.team ? <span style={teamTag}> · {s.team}</span> : null}
|
||||
</div>
|
||||
)}
|
||||
<div className="mono" style={categoryLine}>{s.description}</div>
|
||||
<div style={lensLine}>{builtVs}</div>
|
||||
</div>
|
||||
|
||||
{/* The single severity accent + tier-gated grade badge. */}
|
||||
<div style={rightRail}>
|
||||
{sev && <span className="mono" style={{ ...sevChip, color: sev.color, borderColor: sev.color }}>{sev.label}</span>}
|
||||
{s.grade && (
|
||||
canSeeGrade
|
||||
? <GradeBadge grade={s.grade} size="md" glow />
|
||||
: <a href="/pricing" className="mono" style={gradeLock} title="Unlock the grade">GRADE ◆</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<span style={badgeStyle}>{s.currentStreak} G</span>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{hidden > 0 && (
|
||||
<a href="/explore" style={upsellStyle}>
|
||||
@@ -109,20 +133,51 @@ const panelHeading: React.CSSProperties = {
|
||||
color: 'var(--text-tertiary, #6A6A78)', margin: '0 0 10px',
|
||||
};
|
||||
const rowStyle: React.CSSProperties = {
|
||||
display: 'flex', alignItems: 'center', gap: 10,
|
||||
padding: '8px 10px', borderRadius: 10,
|
||||
display: 'flex', alignItems: 'center', gap: 14,
|
||||
padding: '12px 14px', borderRadius: 12,
|
||||
background: 'var(--surface, #12121A)', border: '1px solid var(--border, #2A2A36)',
|
||||
};
|
||||
// THE HERO NUMBER — deliberately the largest font in the row (mono, tabular).
|
||||
const heroBlock: React.CSSProperties = {
|
||||
flex: '0 0 auto', display: 'flex', alignItems: 'baseline', gap: 6,
|
||||
minWidth: 62,
|
||||
};
|
||||
const heroNum: React.CSSProperties = {
|
||||
fontSize: 38, fontWeight: 800, lineHeight: 1,
|
||||
color: 'var(--text-primary, #F0F0F4)', fontVariantNumeric: 'tabular-nums',
|
||||
letterSpacing: '-0.03em',
|
||||
};
|
||||
const heroUnit: React.CSSProperties = {
|
||||
fontSize: 8.5, fontWeight: 700, lineHeight: 1.05, letterSpacing: '0.1em',
|
||||
color: 'var(--text-tertiary, #6A6A78)',
|
||||
};
|
||||
const playerName: React.CSSProperties = {
|
||||
fontSize: 14, fontWeight: 700, color: 'var(--text-primary, #F0F0F4)',
|
||||
whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis',
|
||||
};
|
||||
const streakDesc: React.CSSProperties = {
|
||||
fontSize: 12, color: 'var(--text-secondary, #9A9AA8)',
|
||||
const teamTag: React.CSSProperties = {
|
||||
fontSize: 12, fontWeight: 500, color: 'var(--text-tertiary, #6A6A78)',
|
||||
};
|
||||
const badgeStyle: React.CSSProperties = {
|
||||
flex: '0 0 auto', fontSize: 12, fontWeight: 800, padding: '3px 8px',
|
||||
borderRadius: 6, background: 'rgba(233,75,60,0.15)', color: 'var(--accent, #E94B3C)',
|
||||
const categoryLine: React.CSSProperties = {
|
||||
fontSize: 11, color: 'var(--text-secondary, #9A9AA8)', marginTop: 1,
|
||||
};
|
||||
const lensLine: React.CSSProperties = {
|
||||
fontSize: 11, color: 'var(--text-tertiary, #6A6A78)', marginTop: 2,
|
||||
whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis',
|
||||
};
|
||||
const rightRail: React.CSSProperties = {
|
||||
flex: '0 0 auto', display: 'flex', flexDirection: 'column',
|
||||
alignItems: 'flex-end', gap: 6,
|
||||
};
|
||||
const sevChip: React.CSSProperties = {
|
||||
fontSize: 9.5, fontWeight: 700, letterSpacing: '0.06em',
|
||||
padding: '2px 7px', borderRadius: 5, border: '1px solid transparent',
|
||||
background: 'transparent', whiteSpace: 'nowrap',
|
||||
};
|
||||
const gradeLock: React.CSSProperties = {
|
||||
fontSize: 10.5, fontWeight: 800, letterSpacing: '0.06em',
|
||||
color: 'var(--text-tertiary, #6A6A78)', textDecoration: 'none',
|
||||
border: '1px solid var(--border, #2A2A36)', borderRadius: 4, padding: '2px 6px',
|
||||
};
|
||||
const upsellStyle: React.CSSProperties = {
|
||||
display: 'inline-block', marginTop: 10, fontSize: 12, fontWeight: 600,
|
||||
|
||||
@@ -69,6 +69,9 @@ export default function GradeResultCard({
|
||||
}, [replayKey]);
|
||||
|
||||
const sideColor = d.side === 'Over' ? 'var(--g-a)' : 'var(--miss)';
|
||||
// COLOR CONTRACT (Part 1 #3): edge/CLV colored by SIGN. A negative edge must
|
||||
// NEVER render green — negative = var(--miss), positive/zero = signal-green.
|
||||
const edgeColor = d.edge != null && d.edge < 0 ? 'var(--miss)' : 'var(--g-a)';
|
||||
const hasKill = !!d.killConditions && d.killConditions.length > 0;
|
||||
const hasBooks = Array.isArray(d.books) && d.books.length > 0;
|
||||
const hasAlt = Array.isArray(d.altLadder) && d.altLadder.length > 0;
|
||||
@@ -150,7 +153,7 @@ export default function GradeResultCard({
|
||||
{d.edge != null && (
|
||||
<>
|
||||
<span style={{ color: 'rgba(232,255,244,.4)', margin: '0 9px' }}>·</span>
|
||||
<span style={{ color: 'var(--g-a)' }}>{d.edge >= 0 ? '+' : ''}{d.edge}% edge</span>
|
||||
<span style={{ color: edgeColor }}>{d.edge >= 0 ? '+' : ''}{d.edge}% edge</span>
|
||||
</>
|
||||
)}
|
||||
<span style={{ color: 'rgba(232,255,244,.4)', margin: '0 9px' }}>·</span>
|
||||
@@ -173,11 +176,11 @@ export default function GradeResultCard({
|
||||
{[
|
||||
{ l: 'MODEL', v: d.projection != null ? d.projection : '—', col: d.projection != null ? 'var(--g-a)' : 'var(--text-2)' },
|
||||
{ l: 'LINE', v: d.line, col: 'var(--text-0)' },
|
||||
{ l: 'EDGE', v: d.edge != null ? `${d.edge >= 0 ? '+' : ''}${d.edge}%` : '—', col: d.edge != null ? 'var(--g-a)' : 'var(--text-2)' },
|
||||
{ l: 'EDGE', v: d.edge != null ? `${d.edge >= 0 ? '+' : ''}${d.edge}%` : '—', col: d.edge != null ? edgeColor : 'var(--text-2)' },
|
||||
].map((x, i) => (
|
||||
<div key={i} style={{ padding: '14px 16px', textAlign: 'center', borderRight: i < 2 ? '1px solid var(--border)' : 'none' }}>
|
||||
<div className="label" style={{ fontSize: 10, marginBottom: 5 }}>{x.l}</div>
|
||||
<div className="mono" style={{ fontSize: 19, fontWeight: 700, color: x.col }}>{x.v}</div>
|
||||
<div className="mono" style={{ fontSize: 19, fontWeight: 700, color: x.col, fontVariantNumeric: 'tabular-nums' }}>{x.v}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user