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:
Kev
2026-06-16 01:04:37 -04:00
parent 1d83682cdb
commit 612f5e0b72
12 changed files with 599 additions and 96 deletions
+40 -6
View File
@@ -1,13 +1,47 @@
import RouteStub from '@/components/vyndr/RouteStub';
import SectionHead from '@/components/vyndr/SectionHead';
import { Wordmark } from '@/components/vyndr';
export const metadata = { title: 'About' };
// Brand page in system voice (§3). Server component.
export default function AboutPage() {
return (
<RouteStub
title="About"
arriving="SESSION 36"
blurb="The books have every advantage. We built this to give it back. Built by Kevon Butler · Detroit."
/>
<section style={{ maxWidth: 680, margin: '0 auto', padding: '40px 16px 96px' }}>
<div style={{ marginBottom: 28 }}>
<Wordmark size="lg" cursor beta />
</div>
<h1 style={{ fontSize: 'clamp(28px, 6vw, 40px)', fontWeight: 800, letterSpacing: '-0.03em', lineHeight: 1.1, marginBottom: 20, color: 'var(--text-0)' }}>
The books have every advantage. We built this to give it back.
</h1>
<div style={{ display: 'flex', flexDirection: 'column', gap: 22 }}>
<p className="mono" style={{ fontSize: 14.5, color: 'var(--text-1)', lineHeight: 1.7 }}>
VYNDR is prop intelligence a proprietary terminal that reads the context the books hide.
Beyond the box score: form, matchup, usage, rest, the cascade when a star sits. Then a single
grade, earned across 40+ factors, that tells you whether the number is worth taking.
</p>
<p className="mono" style={{ fontSize: 14.5, color: 'var(--text-1)', lineHeight: 1.7 }}>
We are an analytics tool, not a sportsbook. We don&apos;t take wagers. We hand you the read and
the kill conditions, and let you decide.
</p>
<div>
<SectionHead style={{ marginBottom: 12 }}>BUILT BY</SectionHead>
<div style={{ background: 'var(--bg-1)', border: '1px solid var(--border)', borderRadius: 10, padding: 18 }}>
<div style={{ fontSize: 17, fontWeight: 800 }}>Kevon Butler</div>
<div className="mono" style={{ fontSize: 12.5, color: 'var(--text-1)', marginTop: 4 }}>Founder · Detroit</div>
<p className="mono" style={{ fontSize: 13.5, color: 'var(--text-1)', lineHeight: 1.65, marginTop: 12 }}>
A glitch between a tech-genius mind and a love for sports. Built in Detroit, for everyone the
books count on staying in the dark.
</p>
</div>
</div>
<div className="mono" style={{ fontSize: 11.5, color: 'var(--text-2)', letterSpacing: '0.08em' }}>
BUILT BY KEVON BUTLER · DETROIT · © 2026 VYNDR
</div>
</div>
</section>
);
}
+62 -7
View File
@@ -1,13 +1,68 @@
import RouteStub from '@/components/vyndr/RouteStub';
'use client';
export const metadata = { title: 'Compare' };
import { useState } from 'react';
import SectionHead from '@/components/vyndr/SectionHead';
import GradeBadge from '@/components/vyndr/GradeBadge';
import TerminalInput from '@/components/vyndr/TerminalInput';
// Head-to-head player comparison (§6). Sample data for now — real player
// resolution wires to /api/players/search + game logs in a later pass.
const SAMPLE = {
a: { name: 'Nikola Jokić', team: 'DEN', rows: { 'L10 PTS': '28.4', 'L10 REB': '12.1', 'L10 AST': '9.8', 'Usage%': '29.1', 'Grade': 'A+' } },
b: { name: 'Victor Wembanyama', team: 'SA', rows: { 'L10 PTS': '26.9', 'L10 REB': '10.4', 'L10 AST': '4.2', 'Usage%': '31.0', 'Grade': 'A' } },
};
const ROW_KEYS = ['L10 PTS', 'L10 REB', 'L10 AST', 'Usage%', 'Grade'];
const num = (v: string) => parseFloat(v.replace(/[^\d.]/g, ''));
export default function ComparePage() {
const [a, setA] = useState('Nikola Jokić');
const [b, setB] = useState('Victor Wembanyama');
return (
<RouteStub
title="Compare"
arriving="SESSION 36"
blurb="Two players head-to-head — markets, form, and the verdict on who has the edge tonight."
/>
<section style={{ maxWidth: 860, margin: '0 auto', padding: '28px 16px 96px' }}>
<SectionHead accent="var(--g-a)"> HEAD TO HEAD</SectionHead>
<h1 className="mono" style={{ fontSize: 28, fontWeight: 800, letterSpacing: '-0.02em', margin: '8px 0 18px' }}>COMPARE</h1>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12, marginBottom: 20 }}>
<TerminalInput prompt="" value={a} onChange={setA} placeholder="Player A" />
<TerminalInput prompt="" value={b} onChange={setB} placeholder="Player B" amber />
</div>
<div style={{ background: 'var(--bg-1)', border: '1px solid var(--border)', borderRadius: 10, overflow: 'hidden' }}>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 140px 1fr', padding: '14px 16px', background: 'var(--bg-2)', borderBottom: '1px solid var(--border)', alignItems: 'center' }}>
<div style={{ fontSize: 16, fontWeight: 800, textAlign: 'right' }}>{SAMPLE.a.name} <span className="mono" style={{ fontSize: 11, color: 'var(--text-2)' }}>{SAMPLE.a.team}</span></div>
<div className="label" style={{ textAlign: 'center' }}>VS</div>
<div style={{ fontSize: 16, fontWeight: 800 }}>{SAMPLE.b.name} <span className="mono" style={{ fontSize: 11, color: 'var(--text-2)' }}>{SAMPLE.b.team}</span></div>
</div>
{ROW_KEYS.map((k) => {
const av = SAMPLE.a.rows[k as keyof typeof SAMPLE.a.rows];
const bv = SAMPLE.b.rows[k as keyof typeof SAMPLE.b.rows];
const isGrade = k === 'Grade';
const aWins = !isGrade && num(av) >= num(bv);
const bWins = !isGrade && num(bv) >= num(av);
return (
<div key={k} style={{ display: 'grid', gridTemplateColumns: '1fr 140px 1fr', padding: '11px 16px', borderBottom: '1px solid var(--border)', alignItems: 'center' }}>
<div className="mono" style={{ textAlign: 'right', fontSize: 15, fontWeight: 700, color: aWins ? 'var(--g-a)' : 'var(--text-0)' }}>
{isGrade ? <GradeBadge grade={av} size="sm" glow /> : av}
</div>
<div className="label" style={{ textAlign: 'center' }}>{k}</div>
<div className="mono" style={{ fontSize: 15, fontWeight: 700, color: bWins ? 'var(--g-a)' : 'var(--text-0)' }}>
{isGrade ? <GradeBadge grade={bv} size="sm" glow /> : bv}
</div>
</div>
);
})}
</div>
<div className="intel-surface scanlines" style={{ marginTop: 16, borderRadius: 10, padding: '16px 18px', position: 'relative', overflow: 'hidden' }}>
<div style={{ position: 'relative', zIndex: 2 }}>
<div className="label" style={{ color: 'rgba(232,255,244,.6)', marginBottom: 6 }}>VYNDR VERDICT</div>
<div className="mono" style={{ fontSize: 14, color: '#e8fff4', lineHeight: 1.6 }}>
{SAMPLE.a.name} carries the higher floor on usage and playmaking the edge tonight tilts his way.
</div>
</div>
</div>
</section>
);
}
+58 -7
View File
@@ -1,13 +1,64 @@
import RouteStub from '@/components/vyndr/RouteStub';
'use client';
export const metadata = { title: 'Help' };
import { useMemo, useState } from 'react';
import SectionHead from '@/components/vyndr/SectionHead';
import TerminalInput from '@/components/vyndr/TerminalInput';
const FAQ: { cat: string; q: string; a: string }[] = [
{ cat: 'GRADES', q: 'What does a VYNDR grade mean?', a: 'A grade is our confidence that a prop hits, weighed across 40+ factors. A+ is the rarest and strongest; D means stay away. The letter is earned, not opinion.' },
{ cat: 'GRADES', q: 'What is a kill condition?', a: 'A red flag we detect that can sink a prop regardless of the stats — a blowout risk, a minutes cap, a brutal matchup. Analyst and Desk see them in full.' },
{ cat: 'READS', q: 'How many free reads do I get?', a: 'Five reads every calendar month on the Free tier. The counter resets at the start of each month.' },
{ cat: 'READS', q: 'What counts as a read?', a: 'Grading one prop. Viewing the same prop twice in a session only counts once.' },
{ cat: 'PLANS', q: 'What do Analyst and Desk unlock?', a: 'Analyst unlocks unlimited reads, every signal, and kill conditions. Desk adds the alt-line ladder and the full intelligence layer.' },
{ cat: 'DATA', q: 'Where does the data come from?', a: 'Live odds, schedules, injuries, and box scores from multiple providers — refreshed continuously so the signal stays current.' },
];
export default function HelpPage() {
const [query, setQuery] = useState('');
const [open, setOpen] = useState<number | null>(null);
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) return FAQ;
return FAQ.filter((f) => f.q.toLowerCase().includes(q) || f.a.toLowerCase().includes(q) || f.cat.toLowerCase().includes(q));
}, [query]);
return (
<RouteStub
title="Help & FAQ"
arriving="SESSION 36"
blurb="Searchable answers on grades, reads, plans, and how to read the signal."
/>
<section style={{ maxWidth: 720, margin: '0 auto', padding: '28px 16px 96px' }}>
<SectionHead accent="var(--g-a)"> SUPPORT</SectionHead>
<h1 className="mono" style={{ fontSize: 28, fontWeight: 800, letterSpacing: '-0.02em', margin: '8px 0 18px' }}>HELP &amp; FAQ</h1>
<div style={{ marginBottom: 20 }}>
<TerminalInput prompt="" value={query} onChange={setQuery} placeholder="Search the manual…" />
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{filtered.map((f, i) => {
const isOpen = open === i;
return (
<div key={i} className="scanlines" style={{ background: 'var(--bg-1)', border: '1px solid var(--border)', borderRadius: 8, overflow: 'hidden' }}>
<button
onClick={() => setOpen(isOpen ? null : i)}
aria-expanded={isOpen}
style={{ width: '100%', display: 'flex', alignItems: 'center', gap: 12, padding: '14px 16px', background: 'transparent', border: 'none', cursor: 'pointer', textAlign: 'left' }}
>
<span className="label" style={{ color: 'var(--g-a)', fontSize: 9.5 }}>{f.cat}</span>
<span style={{ flex: 1, fontSize: 14, fontWeight: 600, color: 'var(--text-0)' }}>{f.q}</span>
<span className="mono" style={{ color: 'var(--text-1)' }}>{isOpen ? '' : '+'}</span>
</button>
{isOpen && (
<div className="mono" style={{ padding: '0 16px 16px', fontSize: 13, color: 'var(--text-1)', lineHeight: 1.65 }}>{f.a}</div>
)}
</div>
);
})}
{filtered.length === 0 && (
<div className="mono" style={{ padding: 16, fontSize: 13, color: 'var(--text-1)' }}>No answers matched &ldquo;{query}&rdquo;.</div>
)}
</div>
<div className="mono" style={{ marginTop: 24, fontSize: 13, color: 'var(--text-1)' }}>
Still stuck? <a href="mailto:support@vyndr.app" style={{ color: 'var(--g-a)', textDecoration: 'none' }}>support@vyndr.app</a>
</div>
</section>
);
}
+57 -7
View File
@@ -1,13 +1,63 @@
import RouteStub from '@/components/vyndr/RouteStub';
'use client';
export const metadata = { title: 'Invite' };
import { useState } from 'react';
import SectionHead from '@/components/vyndr/SectionHead';
import VBtn from '@/components/vyndr/VBtn';
import { useAuth } from '@/contexts/AuthContext';
// Referral system (§12) — bring 3 friends → free Analyst. Progress is read
// from the session when available; the live invite ledger wires in later.
export default function InvitePage() {
const { user } = useAuth();
const [copied, setCopied] = useState(false);
const code = (user?.email?.split('@')[0] || 'signal').replace(/[^a-z0-9]/gi, '').toUpperCase().slice(0, 10) || 'SIGNAL';
const link = `https://vyndr.app/?ref=${code}`;
const invited = 0;
const goal = 3;
const copy = () => {
if (typeof navigator !== 'undefined' && navigator.clipboard) {
void navigator.clipboard.writeText(link);
setCopied(true);
setTimeout(() => setCopied(false), 1800);
}
};
return (
<RouteStub
title="Invite"
arriving="SESSION 36"
blurb="Bring three friends onto the signal and your Analyst plan is on the house."
/>
<section style={{ maxWidth: 640, margin: '0 auto', padding: '28px 16px 96px' }}>
<SectionHead accent="var(--amber)"> REFERRAL</SectionHead>
<h1 className="mono" style={{ fontSize: 28, fontWeight: 800, letterSpacing: '-0.02em', margin: '8px 0 8px' }}>INVITE</h1>
<p className="mono" style={{ fontSize: 14, color: 'var(--text-1)', lineHeight: 1.6, marginBottom: 24 }}>
Bring <span style={{ color: 'var(--g-a)', fontWeight: 700 }}>3 friends</span> onto the signal and your Analyst plan is on the house locked for as long as they stay.
</p>
{/* Progress */}
<div style={{ marginBottom: 24 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 8 }}>
<span className="label">FRIENDS JOINED</span>
<span className="mono" style={{ fontSize: 13, fontWeight: 700, color: 'var(--g-a)' }}>{invited} / {goal}</span>
</div>
<div style={{ display: 'flex', gap: 6 }}>
{Array.from({ length: goal }).map((_, i) => (
<div key={i} style={{ flex: 1, height: 6, borderRadius: 4, background: i < invited ? 'var(--g-a)' : 'var(--bg-2)', border: '1px solid var(--border)', boxShadow: i < invited ? '0 0 10px rgba(0,212,160,.5)' : 'none' }} />
))}
</div>
</div>
{/* Referral link */}
<div style={{ marginBottom: 16 }}>
<span className="label">YOUR LINK</span>
<div style={{ display: 'flex', gap: 8, marginTop: 8 }}>
<div className="mono" style={{ flex: 1, padding: '11px 14px', background: 'var(--bg-2)', border: '1px solid var(--border)', borderRadius: 6, fontSize: 13, color: 'var(--g-a)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{link}
</div>
<VBtn variant={copied ? 'primary' : 'outline'} onClick={copy}>{copied ? '✓ Copied' : 'Copy'}</VBtn>
</div>
</div>
<div style={{ display: 'flex', gap: 10 }}>
<VBtn variant="primary" style={{ flex: 1 }} onClick={copy}> Share your link</VBtn>
</div>
</section>
);
}
+6 -6
View File
@@ -4,7 +4,7 @@ import { useState, Suspense } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { useAuth } from '@/contexts/AuthContext';
import { trackLogin } from '@/lib/analytics';
import Wordmark from '@/components/Wordmark';
import { Wordmark } from '@/components/vyndr';
import { GoogleIcon, AppleIcon, XIcon } from '@/components/OAuthIcons';
// Session 14 — small helper so each OAuth button has the same icon+label
@@ -80,18 +80,18 @@ function LoginInner() {
};
return (
<section style={{ minHeight: '80vh', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '32px 16px' }}>
<div className="surface diagonal-cut animate-fade-up" style={{ width: '100%', maxWidth: 420, padding: 32 }}>
<section className="scanlines" style={{ minHeight: '80vh', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '32px 16px', position: 'relative' }}>
<div className="fade-in" style={{ width: '100%', maxWidth: 420, padding: 32, background: 'var(--bg-1)', border: '1px solid var(--border-hi)', borderRadius: 12, position: 'relative' }}>
<a
href="/"
style={{ display: 'flex', justifyContent: 'center', color: 'var(--text-0)', textDecoration: 'none', marginBottom: 24 }}
aria-label="VYNDR — home"
>
<Wordmark size={26} />
<Wordmark size="md" cursor beta />
</a>
<h1 style={{ fontSize: 24, fontWeight: 700, textAlign: 'center', marginBottom: 6 }}>Log in</h1>
<p style={{ textAlign: 'center', color: 'var(--text-secondary)', fontSize: 13, marginBottom: 24 }}>
<h1 className="mono" style={{ fontSize: 18, fontWeight: 800, textAlign: 'center', marginBottom: 6, letterSpacing: '0.04em' }}>ACCESS THE SIGNAL</h1>
<p className="mono" style={{ textAlign: 'center', color: 'var(--text-1)', fontSize: 12.5, marginBottom: 24 }}>
Welcome back. Let&apos;s read something.
</p>
+6 -1
View File
@@ -1,5 +1,6 @@
import type { Metadata } from 'next';
import Pricing from '@/components/Pricing';
import { ClaimMeter } from '@/components/vyndr';
export const metadata: Metadata = {
title: 'Pricing — VYNDR',
@@ -33,8 +34,12 @@ export const metadata: Metadata = {
*/
export default function PricingPage() {
return (
<main style={{ minHeight: '100vh', paddingTop: 64 }}>
<main style={{ minHeight: '100vh' }}>
<Pricing />
{/* Founder-seat scarcity under the grid (§12) */}
<div style={{ padding: '8px 16px 48px' }}>
<ClaimMeter />
</div>
</main>
);
}