Merge DS5 (design): pricing Desk-as-hero, ticker stillness, empty/error unify, archetype glyphs
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -5,6 +5,7 @@ import { useRouter } from 'next/navigation';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import { useParlay } from '@/contexts/ParlayContext';
|
||||
import GradeCard, { GradePill } from '@/components/GradeCard';
|
||||
import EmptyState from '@/components/vyndr/EmptyState';
|
||||
|
||||
type Sport = 'NBA' | 'MLB' | 'WNBA';
|
||||
|
||||
@@ -100,14 +101,14 @@ export default function GamePage({ params }: { params: Promise<{ id: string }> }
|
||||
}
|
||||
|
||||
if (error) {
|
||||
// DS5 (#20) — unified empty/error surface (was a bare heading + link).
|
||||
return (
|
||||
<section style={{ maxWidth: 600, margin: '0 auto', padding: '64px 16px', textAlign: 'center' }}>
|
||||
<h1 style={{ fontSize: 24, fontWeight: 700, marginBottom: 12 }}>Game not found.</h1>
|
||||
<p style={{ color: 'var(--text-secondary)', marginBottom: 24 }}>
|
||||
That matchup isn't on tonight's slate. Maybe the line moved off the board.
|
||||
</p>
|
||||
<a href="/dashboard" className="btn-primary">Back to slate</a>
|
||||
</section>
|
||||
<EmptyState
|
||||
code="GAME NOT FOUND"
|
||||
title="That matchup isn't on the board."
|
||||
message="It isn't on tonight's slate — maybe the line moved off the board. The Slate has what's live now."
|
||||
actions={[{ label: '← Back to the Slate', href: '/dashboard', primary: true }]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+45
-2
@@ -94,6 +94,16 @@
|
||||
--scan-op: 0.04;
|
||||
--grade-hero: var(--g-a);
|
||||
|
||||
/* Motion tokens (DS5 · DESIGN-SPEC Part 4 — ALIVE IS PUNCTUATED STILLNESS).
|
||||
Every duration in the living layer resolves from one of these — no one-off
|
||||
ms literals. IDLE = the single proof-of-life pulse; TRANSITION is sub-200ms
|
||||
(perceived instantaneity); DATA is the change-only green pulse; TICKER-HOLD
|
||||
is how long the ticker RESTS on each item so it can be read (≥4s). */
|
||||
--motion-idle: 1.4s;
|
||||
--motion-transition: 180ms;
|
||||
--motion-data: 300ms;
|
||||
--ticker-hold: 4200ms;
|
||||
|
||||
/* Type — Inter for chrome/UI, JetBrains Mono for ALL data.
|
||||
Session 41: --font-* come from next/font (self-hosted, set on <html>).
|
||||
Literal family names kept as fallbacks for any non-next/font context. */
|
||||
@@ -1078,7 +1088,11 @@ body.tex-grain::before {
|
||||
}
|
||||
.grade-reveal { animation: grade-reveal 0.42s cubic-bezier(.2,1.3,.4,1) both; }
|
||||
|
||||
/* ===================== TICKER ===================== */
|
||||
/* ===================== TICKER (DS5 — punctuated stillness) =====================
|
||||
The ticker RESTS. It no longer marquee-scrolls (constant motion reads cheap,
|
||||
Part 0 law #1). It holds each item for --ticker-hold (≥4s) so it can be READ,
|
||||
and the ONLY motion is a change-only cross-fade + a brief data-update pulse
|
||||
when the item advances. The legacy .ticker-track marquee is retired. */
|
||||
@keyframes ticker-scroll {
|
||||
0% { transform: translateX(0); }
|
||||
100% { transform: translateX(-50%); }
|
||||
@@ -1088,6 +1102,23 @@ body.tex-grain::before {
|
||||
white-space: nowrap;
|
||||
animation: ticker-scroll 38s linear infinite;
|
||||
}
|
||||
/* The resting strip — a static ranked row, no idle animation. */
|
||||
.ticker-rest {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
}
|
||||
/* Change-only cross-fade as the held item advances (sub-200ms, weighted). */
|
||||
@keyframes ticker-swap { from { opacity: 0.55; } to { opacity: 1; } }
|
||||
.ticker-item-enter { animation: ticker-swap var(--motion-transition) ease-out both; }
|
||||
/* Change-only green pulse — the terminal reacting to a fresh head event. */
|
||||
@keyframes ticker-pulse {
|
||||
0% { background: color-mix(in srgb, var(--g-a) 22%, transparent); }
|
||||
100% { background: transparent; }
|
||||
}
|
||||
.ticker-pulse { animation: ticker-pulse var(--motion-data) ease-out 1; }
|
||||
|
||||
/* ===================== LIVE PULSE ===================== */
|
||||
.live-dot {
|
||||
@@ -1104,6 +1135,14 @@ body.tex-grain::before {
|
||||
100% { box-shadow: 0 0 0 0 rgba(255,59,59,0); }
|
||||
}
|
||||
|
||||
/* ===================== DESK SHOWCASE (DS5 pricing hero) ===================== */
|
||||
/* Balanced two-column at desktop (pitch | real feature visuals), single column
|
||||
on mobile — kills the dead right-half of the old pricing page (#8). */
|
||||
.desk-showcase { grid-template-columns: 1fr; }
|
||||
@media (min-width: 900px) {
|
||||
.desk-showcase { grid-template-columns: 1.05fr 1fr; }
|
||||
}
|
||||
|
||||
/* ===================== LINE FLASH (odds cell changes) ===================== */
|
||||
@keyframes flash-up { 0% { background: rgba(0,212,160,0.45); } 100% { background: transparent; } }
|
||||
@keyframes flash-down { 0% { background: rgba(255,82,82,0.45); } 100% { background: transparent; } }
|
||||
@@ -1124,7 +1163,10 @@ body.tex-grain::before {
|
||||
/* ===================== LIVING LAYER (the brain) ===================== */
|
||||
/* EKG heartbeat strip */
|
||||
@keyframes ekg-scroll { 0% { transform: translateX(0); } 100% { transform: translateX(-50%); } }
|
||||
.ekg-track { display: inline-flex; animation: ekg-scroll 6s linear infinite; }
|
||||
/* DS5 — the EKG is now a STATIC readout trace (no idle scroll). The header's
|
||||
ONE idle proof-of-life is the single SIGNAL-LIVE live-dot; the heartbeat
|
||||
waveform is a frozen instrument face, not constant motion (Part 4). */
|
||||
.ekg-track { display: inline-flex; }
|
||||
|
||||
/* Neural node pulse */
|
||||
@keyframes node-pulse {
|
||||
@@ -1189,6 +1231,7 @@ body.tex-grain::before {
|
||||
/* Reduced-motion: kill the living layer + glitch chrome outright */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.wm, .wm::before, .wm::after, .ticker-track, .live-dot,
|
||||
.ticker-item-enter, .ticker-pulse,
|
||||
.intel-surface::after, .phosphor-cursor,
|
||||
.ekg-track, .brain-node, .brain-link, .proc-scan, .data-blink { animation: none !important; }
|
||||
}
|
||||
|
||||
+28
-23
@@ -3,7 +3,7 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { GradePill } from '@/components/GradeCard';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import { Skeleton } from '@/components/vyndr';
|
||||
import { Skeleton, EmptyState, ArchetypeBadge } from '@/components/vyndr';
|
||||
import { clvMode, CLV_FLAT_LINE } from '@/lib/clvDisplay';
|
||||
|
||||
/**
|
||||
@@ -44,6 +44,9 @@ interface LedgerRow {
|
||||
outcome?: 'hit' | 'miss' | 'push' | null;
|
||||
actual_value?: number | null;
|
||||
revised_from_grade?: string | null;
|
||||
// DS5 (Part 5) — the player's locked archetype, when the pipeline supplies it.
|
||||
// Optional + self-hiding: absent beats fabricated.
|
||||
archetype?: string | null;
|
||||
}
|
||||
|
||||
interface TierRecord { settled: number; hits: number; misses: number; hit_pct: number | null }
|
||||
@@ -352,6 +355,12 @@ function LedgerCard({ row, index }: { row: LedgerRow; index: number }) {
|
||||
</span>
|
||||
</div>
|
||||
<h3 style={{ fontSize: 15, fontWeight: 600, marginBottom: 2 }}>{row.player_name}</h3>
|
||||
{/* Part 5 — the archetype glyph+chip, propagated (self-hides when absent). */}
|
||||
{row.archetype && (
|
||||
<div style={{ marginBottom: 6 }}>
|
||||
<ArchetypeBadge archetype={row.archetype} size="sm" showDesc />
|
||||
</div>
|
||||
)}
|
||||
<p className="mono" style={{ fontSize: 12, color: 'var(--text-secondary)', textTransform: 'capitalize', marginBottom: 4 }}>
|
||||
{row.side} {row.line} {row.stat.replace(/_/g, ' ')}
|
||||
</p>
|
||||
@@ -369,28 +378,24 @@ function LedgerCard({ row, index }: { row: LedgerRow; index: number }) {
|
||||
}
|
||||
|
||||
function EmptyLedger({ tab }: { tab: Tab }) {
|
||||
return (
|
||||
<div className="surface diagonal-cut tex-scan" style={{ padding: 48, textAlign: 'center', display: 'grid', gap: 10, justifyItems: 'center' }}>
|
||||
<p className="lbl" style={{ color: 'var(--grade-c)' }}>LEDGER EMPTY</p>
|
||||
{tab === 'mine' ? (
|
||||
<>
|
||||
<h3 style={{ fontSize: 18, fontWeight: 700 }}>No reads yet.</h3>
|
||||
<p style={{ color: 'var(--text-1)', fontSize: 14, maxWidth: 440 }}>
|
||||
Read your first prop to start building your Ledger. Every grade you run lands here the moment it completes — and settles against the real result.
|
||||
</p>
|
||||
<a href="/scan" className="btn-primary" style={{ marginTop: 8, padding: '10px 18px' }}>
|
||||
Read a Prop →
|
||||
</a>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<h3 style={{ fontSize: 18, fontWeight: 700 }}>The model record starts with the next pipeline run.</h3>
|
||||
<p style={{ color: 'var(--text-1)', fontSize: 14, maxWidth: 440 }}>
|
||||
Every pre-graded prop lands here — hits, misses, pushes, and closing-line value. Nothing is deleted.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
// DS5 (#20) — the ONE unified empty surface, inline variant.
|
||||
return tab === 'mine' ? (
|
||||
<EmptyState
|
||||
inline
|
||||
wordmark={false}
|
||||
code="LEDGER EMPTY"
|
||||
title="No reads yet."
|
||||
message="Read your first prop to start building your Ledger. Every grade lands here the moment it completes — and settles against the real result."
|
||||
actions={[{ label: 'Read a Prop →', href: '/scan', primary: true }]}
|
||||
/>
|
||||
) : (
|
||||
<EmptyState
|
||||
inline
|
||||
wordmark={false}
|
||||
code="MODEL RECORD BUILDING"
|
||||
title="The record starts with the next pipeline run."
|
||||
message="Every pre-graded prop lands here — hits, misses, pushes, and closing-line value. Nothing is deleted."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import SectionHead from '@/components/vyndr/SectionHead';
|
||||
|
||||
/**
|
||||
* DeskShowcase (DS5 · DESIGN-SPEC Part 6, #8) — the Desk-as-hero story that
|
||||
* sits ABOVE the pricing grid. Its job is the founder's one line: make $44.99
|
||||
* feel impossibly low for a professional terminal ("how is this only $44.99"
|
||||
* IS the conversion event). Balanced two-column layout — the pitch on the left,
|
||||
* REAL feature visuals on the right (kills the dead right-half, audit #8). The
|
||||
* single primary CTA scrolls to the grid where the real Stripe checkout lives
|
||||
* (checkout wiring untouched).
|
||||
*
|
||||
* Server component — no interactivity here; the CTA is an in-page anchor and the
|
||||
* feature visuals are static, tokenized, mono-for-data mock readouts.
|
||||
*/
|
||||
|
||||
// A real alt-line-ladder rung (the Desk exclusive) — line + locked grade + edge.
|
||||
function Rung({ line, grade, edge, base }: { line: string; grade: string; edge: string; base?: boolean }) {
|
||||
const gradeCol = grade.startsWith('A') ? 'var(--g-a)' : grade.startsWith('B') ? 'var(--text-0)' : 'var(--amber)';
|
||||
const edgeCol = edge.startsWith('+') ? 'var(--g-a)' : edge.startsWith('-') ? 'var(--miss)' : 'var(--text-2)';
|
||||
return (
|
||||
<div style={{ flex: '1 1 0', minWidth: 58, textAlign: 'center', padding: '9px 6px', background: 'var(--bg-2)', border: `1px solid ${base ? 'var(--border-hi)' : 'var(--border)'}`, borderRadius: 6 }}>
|
||||
<div className="mono" style={{ fontSize: 11, color: base ? 'var(--text-0)' : 'var(--text-1)', marginBottom: 5 }}>{line}{base ? ' •' : ''}</div>
|
||||
<div className="mono" style={{ fontSize: 17, fontWeight: 800, color: gradeCol }}>{grade}</div>
|
||||
<div className="mono" style={{ fontSize: 10, color: edgeCol, marginTop: 3 }}>{edge}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function DeskShowcase() {
|
||||
return (
|
||||
<section style={{ padding: '72px 24px 8px', borderTop: '1px solid var(--border)' }}>
|
||||
<div className="desk-showcase" style={{ maxWidth: 1100, margin: '0 auto', display: 'grid', gap: 40, alignItems: 'center' }}>
|
||||
{/* LEFT — the pitch */}
|
||||
<div>
|
||||
<div className="mono" style={{ fontSize: 11, letterSpacing: '0.16em', color: 'var(--g-a)', marginBottom: 14 }}>
|
||||
THE DESK · FLAGSHIP
|
||||
</div>
|
||||
<h2 className="text-balance" style={{ fontSize: 'clamp(30px, 4.4vw, 50px)', fontWeight: 800, letterSpacing: '-0.03em', lineHeight: 1.02, margin: 0 }}>
|
||||
A $1M terminal.{' '}
|
||||
<span className="mono" style={{ color: 'var(--g-a)' }}>$44.99</span>
|
||||
<span style={{ color: 'var(--text-tertiary)' }}>/mo.</span>
|
||||
</h2>
|
||||
<p style={{ fontSize: 17, color: 'var(--text-secondary)', margin: '18px 0 0', maxWidth: 460, lineHeight: 1.55 }}>
|
||||
Every alt line graded, quarter-Kelly sizing on your bankroll, parlay
|
||||
correlation, and the model’s real-time feed. The desk a trading
|
||||
floor pays five figures for — priced for one bettor.
|
||||
</p>
|
||||
|
||||
<div style={{ display: 'flex', gap: 12, alignItems: 'center', marginTop: 28, flexWrap: 'wrap' }}>
|
||||
<a href="#pricing" className="btn-primary" style={{ padding: '13px 24px', fontSize: 15, textDecoration: 'none' }}>
|
||||
Claim a Founder Desk →
|
||||
</a>
|
||||
<span className="mono" style={{ fontSize: 12.5, color: 'var(--text-tertiary)' }}>
|
||||
<span style={{ color: 'var(--g-a)', fontWeight: 700 }}>$34.99</span> locked for the first 100 · then $44.99
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* RIGHT — real feature visuals (no dead half) */}
|
||||
<div style={{ display: 'grid', gap: 14 }}>
|
||||
<div style={{ background: 'var(--bg-1)', border: '1px solid var(--border-hi)', borderRadius: 12, padding: 18 }}>
|
||||
<SectionHead style={{ marginBottom: 12 }}>
|
||||
ALT LINE LADDER <span style={{ color: 'var(--amber)', fontSize: 9, border: '1px solid rgba(255,179,71,.4)', borderRadius: 3, padding: '1px 5px', marginLeft: 4 }}>DESK</span>
|
||||
</SectionHead>
|
||||
<div style={{ display: 'flex', gap: 7 }}>
|
||||
<Rung line="1.5" grade="A" edge="+7.1%" />
|
||||
<Rung line="2.5" grade="A+" edge="+11.4%" base />
|
||||
<Rung line="3.5" grade="B" edge="+2.0%" />
|
||||
<Rung line="4.5" grade="C" edge="-3.8%" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gap: 14, gridTemplateColumns: '1fr 1fr' }}>
|
||||
<div style={{ background: 'var(--bg-1)', border: '1px solid var(--border)', borderRadius: 12, padding: 16 }}>
|
||||
<SectionHead style={{ marginBottom: 10 }}>QUARTER-KELLY</SectionHead>
|
||||
<div className="mono" style={{ fontSize: 26, fontWeight: 800, color: 'var(--g-a)', fontVariantNumeric: 'tabular-nums' }}>2.4%</div>
|
||||
<div className="mono" style={{ fontSize: 11, color: 'var(--text-tertiary)', marginTop: 4 }}>of bankroll · at -110</div>
|
||||
</div>
|
||||
<div style={{ background: 'var(--bg-1)', border: '1px solid var(--border)', borderRadius: 12, padding: 16 }}>
|
||||
<SectionHead style={{ marginBottom: 10 }}>PARLAY φ</SectionHead>
|
||||
<div className="mono" style={{ fontSize: 26, fontWeight: 800, color: 'var(--text-0)', fontVariantNumeric: 'tabular-nums' }}>0.34</div>
|
||||
<div className="mono" style={{ fontSize: 11, color: 'var(--text-tertiary)', marginTop: 4 }}>correlation · same-team legs</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ background: 'var(--bg-1)', border: '1px solid var(--border)', borderRadius: 12, padding: '14px 16px', display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<span className="live-dot" style={{ background: 'var(--g-a)' }} aria-hidden />
|
||||
<span className="mono" style={{ fontSize: 11.5, color: 'var(--text-1)', letterSpacing: '0.04em' }}>
|
||||
REAL-TIME FEED · <span style={{ color: 'var(--g-a)', fontWeight: 700 }}>consensus vs model</span>, live line moves
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Metadata } from 'next';
|
||||
import Pricing from '@/components/Pricing';
|
||||
import { ClaimMeter } from '@/components/vyndr';
|
||||
import DeskShowcase from './DeskShowcase';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Pricing — VYNDR',
|
||||
@@ -35,6 +36,9 @@ export const metadata: Metadata = {
|
||||
export default function PricingPage() {
|
||||
return (
|
||||
<main style={{ minHeight: '100vh' }}>
|
||||
{/* DS5 (#8) — Desk is the hero. The "$1M terminal" story leads, above the
|
||||
grid, so the premium tier stops being an afterthought. */}
|
||||
<DeskShowcase />
|
||||
<Pricing />
|
||||
{/* Founder-seat scarcity under the grid (§12) */}
|
||||
<div style={{ padding: '8px 16px 48px' }}>
|
||||
|
||||
@@ -7,6 +7,7 @@ import GradeBadge from '@/components/vyndr/GradeBadge';
|
||||
import ArchetypeBadge from '@/components/vyndr/ArchetypeBadge';
|
||||
import TeamLogo from '@/components/vyndr/TeamLogo';
|
||||
import ModelRecord from '@/components/vyndr/ModelRecord';
|
||||
import EmptyState from '@/components/vyndr/EmptyState';
|
||||
import { playerHref } from '@/lib/playerHref';
|
||||
import { useParlay, legKey } from '@/contexts/ParlayContext';
|
||||
|
||||
@@ -69,11 +70,14 @@ export default function TeamHub({ abbr, sport }: { abbr: string; sport: string }
|
||||
return <section style={{ minHeight: '50vh', display: 'flex', alignItems: 'center', justifyContent: 'center' }}><p className="mono" style={{ color: 'var(--text-2)' }}>Loading team intelligence…</p></section>;
|
||||
}
|
||||
if (state === 'error' || !data) {
|
||||
// DS5 (#20) — the unified empty/error surface, not a bare-red line.
|
||||
return (
|
||||
<section style={{ maxWidth: 600, margin: '0 auto', padding: '40px 16px' }}>
|
||||
<p className="mono" style={{ color: 'var(--miss)' }}>Team not found.</p>
|
||||
<a href="/dashboard" className="mono" style={{ color: 'var(--g-a)', fontSize: 13 }}>← Back to Slate</a>
|
||||
</section>
|
||||
<EmptyState
|
||||
code="TEAM NOT FOUND"
|
||||
title="No hub for that team."
|
||||
message="That abbreviation isn't on the board right now. Check tonight's slate for the teams in play."
|
||||
actions={[{ label: '← Back to the Slate', href: '/dashboard', primary: true }]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user