a18a3f33fc
Part 6 #8 — Desk $44.99 is the hero tier. New DeskShowcase leads the pricing page with the "$1M terminal · $44.99" story + real feature visuals (alt-line ladder, quarter-Kelly, parlay φ, real-time feed) in a balanced two-column layout (kills the dead right-half). Desk is the sole highlighted tier / single primary CTA (color contract #9); Analyst is secondary. Real prices: Free 5 scans, Analyst $14.99/$19.99, Desk $34.99/$44.99. ClaimMeter + Stripe checkout wiring untouched. Part 4 #7 — Ticker → punctuated stillness. The continuous marquee is retired; the ticker now RESTS ≥4s on each ranked item and pulses only on change. The EKG heartbeat is a static readout — the header's ONE idle proof-of-life is the single SIGNAL-LIVE live-dot (the ticker dropped its competing pulse). All durations tokenized (--motion-*, --ticker-hold); prefers-reduced-motion kills the motion entirely. Part 8 #20 — new EmptyState component modeled on the north-star 404 (scanlines + glitch wordmark + amber system voice + CTA hierarchy), reused at the bare-red "Team not found", "Game not found", and the ledger empties — one unified voice. Part 5 — archetype glyph+chip propagated to STREAKS rows + ledger rows (optional + self-hiding; absent beats fabricated); grade reveal already carries ArchetypeBlend. Tests: new tests/unit/ds5PricingStates.test.js (22) locks all four workstreams; updated teamHubUI + vyndrDesignSystem for the new surfaces. 237 suites / 2864 tests green; next build exit 0. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
197 lines
8.2 KiB
TypeScript
197 lines
8.2 KiB
TypeScript
'use client';
|
|
|
|
import { useEffect, useState } from 'react';
|
|
import PlayerAvatar from '@/components/vyndr/PlayerAvatar';
|
|
import GradeBadge from '@/components/vyndr/GradeBadge';
|
|
import ArchetypeBadge from '@/components/vyndr/ArchetypeBadge';
|
|
import { type Tier } from '@/lib/tierGate';
|
|
|
|
/**
|
|
* StreaksPanel (Session 23 · rebuilt DS4 — the STREAKS BILLBOARD).
|
|
*
|
|
* 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
|
|
* slate when no logs are warm yet.
|
|
*/
|
|
|
|
interface Streak {
|
|
player: string;
|
|
playerId: string | number | null;
|
|
team: string | null;
|
|
type: string;
|
|
category: string;
|
|
currentStreak: number;
|
|
description: string;
|
|
// Session 60 (night2/C) — THE LENS + optional snapshot grade letter.
|
|
lens?: { builtVs?: string[] | null; matchup?: string | null; difficulty?: string | null; read?: string | null };
|
|
grade?: string | null;
|
|
// DS5 (Part 5) — the player's locked archetype from the snapshot join, when
|
|
// present. Optional + self-hiding: absent beats fabricated.
|
|
archetype?: string | null;
|
|
}
|
|
|
|
export interface StreaksPanelProps {
|
|
sport: string;
|
|
tier?: Tier;
|
|
stat?: string;
|
|
/** Optional hard cap (teaser usage on the landing page). */
|
|
limit?: number;
|
|
}
|
|
|
|
export default function StreaksPanel({ sport, tier = 'free', stat = 'all', limit }: StreaksPanelProps) {
|
|
const [streaks, setStreaks] = useState<Streak[] | null>(null);
|
|
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
async function load() {
|
|
try {
|
|
const res = await fetch(`/api/streaks/${sport}?stat=${encodeURIComponent(stat)}`);
|
|
if (!res.ok) { if (!cancelled) setStreaks([]); return; }
|
|
const data = await res.json();
|
|
if (!cancelled) setStreaks(Array.isArray(data?.streaks) ? data.streaks : []);
|
|
} catch {
|
|
if (!cancelled) setStreaks([]);
|
|
}
|
|
}
|
|
load();
|
|
return () => { cancelled = true; };
|
|
}, [sport, stat]);
|
|
|
|
if (!streaks || streaks.length === 0) return null;
|
|
|
|
// Session 60 (product def) — the PICTURE is free: every streak shows for
|
|
// every tier. Only an explicit `limit` (landing teaser) caps the list.
|
|
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: 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>
|
|
|
|
{/* 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>
|
|
{/* Part 5 — the archetype glyph+chip (the Rosetta stone),
|
|
propagated here; self-hides when the join has no archetype. */}
|
|
{s.archetype && (
|
|
<div style={{ margin: '3px 0' }}>
|
|
<ArchetypeBadge archetype={s.archetype} sport={sport} size="sm" showDesc />
|
|
</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>
|
|
);
|
|
})}
|
|
</div>
|
|
{hidden > 0 && (
|
|
<a href="/explore" style={upsellStyle}>
|
|
{hidden} more streak{hidden === 1 ? '' : 's'} on the board →
|
|
</a>
|
|
)}
|
|
</section>
|
|
);
|
|
}
|
|
|
|
const panelHeading: React.CSSProperties = {
|
|
fontSize: 12, letterSpacing: '0.12em', textTransform: 'uppercase',
|
|
color: 'var(--text-tertiary, #6A6A78)', margin: '0 0 10px',
|
|
};
|
|
const rowStyle: React.CSSProperties = {
|
|
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 teamTag: React.CSSProperties = {
|
|
fontSize: 12, fontWeight: 500, color: 'var(--text-tertiary, #6A6A78)',
|
|
};
|
|
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,
|
|
color: 'var(--accent, #E94B3C)', textDecoration: 'none',
|
|
};
|