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
+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>
);
}