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
+67 -2
View File
@@ -4,8 +4,73 @@
2026-06-16 2026-06-16
## Current Phase ## Current Phase
SHIP BUILD v35.0 — VYNDR 2.0 design system, Phase D: core screens — Grade Result SHIP BUILD v36.0 — VYNDR 2.0 design system, Phase E: remaining screens —
Card, Slate GameCard, Scan, Terminal, Landing claim meter (Session 35) dashboard Bloomberg lines, compare/invite/help/about, login/pricing (Session 36)
## Session 36 (2026-06-16) — SHIPPED
Phase E of the VYNDR 2.0 conversion + Session 35's #1 deferred item (the
dashboard line upgrade). Frontend-only; ZERO backend changes. Backend 1839 →
**1853 tests** (+14), 144 suites, zero regressions. Web build clean (exit 0).
### E.1 — Dashboard GameCard (Session 35's #1 deferred) — DELIVERED via reskin
- KEY DECISION: the live Slate's legacy `GameCard` carries **inline per-prop
grading** (PlayerCard/PropRow → onGrade/gradedProps). The Session-35
display-only `vyndr/GameCard` has no grading, so a wholesale swap would
DELETE the slate's core interaction. Instead, **reskinned the legacy GameCard's
game-lines grid** with the Bloomberg best/worst pattern + SportBadge +
SectionHead, keeping grading 100% intact — the #1 visual win without the
regression.
- `web/src/lib/slateAdapter.js` (CommonJS, unit-tested): `parseAmericanOdds`
(American → decimal payout), `detectBestLines(books)` (marks best/worst ML per
side, only when ≥2 books disagree), `mapScheduleToGameCards(schedule,
gamelines, streaks, grades)` (the §7 contract mapper, for the future full
swap). GameCard now renders the lines grid via `detectBestLines`: best =
green tint + green left border, worst = subtle red. Dropped the emoji marker
for SportBadge.
### E.3 — Four stubs → real pages (zero regression risk)
- `compare` — head-to-head: two TerminalInputs, side-by-side stat table
(winner-highlighted), intel-surface VYNDR VERDICT. Sample data.
- `invite` — referral: progress 0/3, mono referral link + copy button, share
CTA. Code derived from the session email.
- `help` — searchable FAQ: TerminalInput filter + expandable Card Q&A by
category + support mailto.
- `about` — brand page in system voice (glitch Wordmark, "give it back",
Detroit signature). Server component.
### E.2 — Reskins (logic preserved)
- `login` — scanlines background, new `.wm` Wordmark (beta), system voice
("ACCESS THE SIGNAL"), Level-1 card. ALL auth logic (form, OAuth, `next`
redirect) untouched.
- `pricing` — mounted `ClaimMeter` under the grid; removed the now-doubled
`paddingTop` (layout already offsets 96 since Session 34).
- `account` is a redirect to `/profile` — left as-is (canonical surface).
### Files created
- `web/src/lib/slateAdapter.js`
- `tests/unit/vyndrPhaseE.test.js` (18 tests: adapter odds/best-line/mapping
logic, GameCard reskin, the 4 real pages, login/pricing reskins)
### Files modified
- `web/src/components/GameCard.tsx` (Bloomberg lines + SportBadge/SectionHead)
- `web/src/app/{compare,invite,help,about,login,pricing}/page.tsx`
- `tests/unit/vyndrAppShell.test.js` (only /notifications remains a stub)
### Deferred (Sessions 37+) — honest scope
- The FULL GameCard swap (replacing legacy GameCard with vyndr/GameCard) needs
inline grading ported into the new component first — bigger than a reskin;
the slateAdapter + new GameCard are both ready for it. Reskinned instead this
session to avoid deleting grading.
- Profile/settings/blog/game-detail/responsible reskins + scan-input
TerminalInput polish: light or deferred (tokens already mostly resolve there;
prioritized dashboard + paid-user-visible + new pages per the impact order).
Settings destructive-action confirmations (type-DELETE) are Phase G utility
flows.
---
## Session 35 (2026-06-16) — SHIPPED
## Session 35 (2026-06-16) — SHIPPED ## Session 35 (2026-06-16) — SHIPPED
+15
View File
@@ -221,6 +221,21 @@ The frame every page sits in. Frontend-only.
- **ClaimMeter** = `@/components/vyndr/ClaimMeter` (founder-seat scarcity), on the - **ClaimMeter** = `@/components/vyndr/ClaimMeter` (founder-seat scarcity), on the
landing under the Hero. landing under the Hero.
## VYNDR 2.0 Remaining Screens (Session 36 — Phase E)
- **Dashboard lines** — `lib/slateAdapter.js` (`parseAmericanOdds`,
`detectBestLines`, `mapScheduleToGameCards`) is the testable best/worst-line
engine. The LEGACY `components/GameCard.tsx` (used by the live Slate, with
inline grading) was RESKINNED to render its game-lines grid via
`detectBestLines` (best = green tint + green left border, worst = subtle red)
+ SportBadge. IMPORTANT: the live Slate still uses the legacy GameCard, NOT
`vyndr/GameCard` — a full swap needs inline grading ported into the new
component first (slateAdapter + vyndr/GameCard are ready for it).
- **Real pages** (were RouteStubs): `compare`, `invite`, `help`, `about`. Only
`/notifications` is still a RouteStub (keep the Session-34 stub test in sync if
you convert it).
- **Reskinned** (logic preserved): `login` (scanlines + "ACCESS THE SIGNAL"),
`pricing` (+ ClaimMeter). `account` redirects to `/profile`.
## Active Skills ## Active Skills
- vyndr-voice (all user-facing output) - vyndr-voice (all user-facing output)
- prop-analysis (grading methodology) - prop-analysis (grading methodology)
+3 -2
View File
@@ -121,8 +121,9 @@ describe('Phase C — layout wiring', () => {
}); });
describe('Phase C — route stubs for not-yet-built screens', () => { describe('Phase C — route stubs for not-yet-built screens', () => {
// /terminal became a real page in Session 35 (Phase D) — no longer a stub. // /terminal became real in Session 35; /compare /invite /help /about became
const stubs = ['compare', 'invite', 'help', 'about', 'notifications']; // real in Session 36 (Phase E). Only /notifications remains a stub.
const stubs = ['notifications'];
it.each(stubs)('/%s has a page that uses the design-system RouteStub', (name) => { it.each(stubs)('/%s has a page that uses the design-system RouteStub', (name) => {
expect(exists(`app/${name}/page.tsx`)).toBe(true); expect(exists(`app/${name}/page.tsx`)).toBe(true);
expect(read(`app/${name}/page.tsx`)).toContain('RouteStub'); expect(read(`app/${name}/page.tsx`)).toContain('RouteStub');
+115
View File
@@ -0,0 +1,115 @@
// VYNDR 2.0 — Phase E remaining screens (Session 36): slate adapter + GameCard
// Bloomberg reskin, the four stubs-turned-real pages, and login/pricing reskins.
// Adapter LOGIC runs directly via the CommonJS module; .tsx is asserted as text.
const fs = require('fs');
const path = require('path');
const WEB = path.join(__dirname, '..', '..', 'web', 'src');
const read = (rel) => fs.readFileSync(path.join(WEB, rel), 'utf8');
const slate = require('../../web/src/lib/slateAdapter');
describe('Phase E.1 — slate adapter (best/worst line detection)', () => {
it('parses American odds to a decimal payout (higher = better)', () => {
expect(slate.parseAmericanOdds('+150')).toBeCloseTo(2.5, 3);
expect(slate.parseAmericanOdds('-110')).toBeCloseTo(1.909, 2);
expect(slate.parseAmericanOdds('garbage')).toBeNull();
expect(slate.parseAmericanOdds(null)).toBeNull();
});
it('marks the best/worst moneyline across books', () => {
const rows = slate.detectBestLines({
dk: { awayML: '+150', homeML: '-170', total: '228.5' },
fd: { awayML: '+140', homeML: '-160', total: '229' },
});
const dk = rows.find((r) => r.book === 'dk');
const fd = rows.find((r) => r.book === 'fd');
expect(dk.bestAway).toBe(true); // +150 pays more than +140
expect(fd.worstAway).toBe(true);
expect(fd.bestHome).toBe(true); // -160 pays more than -170
expect(dk.worstHome).toBe(true);
expect(dk.ou).toBe('O/U 228.5');
});
it('does not mark best/worst for a lone book', () => {
const rows = slate.detectBestLines({ dk: { awayML: '+150', homeML: '-170' } });
expect(rows[0].bestAway).toBe(false);
expect(rows[0].worstAway).toBe(false);
});
it('maps schedule → GameCard contract with abbrs, time, lines', () => {
const cards = slate.mapScheduleToGameCards(
[{ id: 'g1', sport: 'NBA', status: 'in', score: { away: 50, home: 48 }, awayTeam: { abbreviation: 'LAL', name: 'Lakers' }, homeTeam: { abbreviation: 'SA', name: 'Spurs' }, gameTime: null }],
{ g1: { books: { dk: { awayML: '+120', homeML: '-140' }, fd: { awayML: '+110', homeML: '-130' } } } },
[{ player: 'LeBron', team: 'LAL', text: '5-game 25+ streak' }],
[{ player: 'LeBron', team: 'LAL', stat: 'Points', line: 26.5, grade: 'A', side: 'Over' }],
);
expect(cards).toHaveLength(1);
expect(cards[0].id).toBe('g1');
expect(cards[0].live).toBe(true);
expect(cards[0].away.abbr).toBe('LAL');
expect(cards[0].lines.length).toBe(2);
expect(cards[0].props.length).toBe(1);
expect(cards[0].streaks.length).toBe(1);
});
it('degrades gracefully when a game has no lines', () => {
const cards = slate.mapScheduleToGameCards(
[{ id: 'g2', awayTeam: { abbreviation: 'NYK' }, homeTeam: { abbreviation: 'BOS' } }],
{}, [], [],
);
expect(cards[0].lines).toEqual([]);
expect(cards[0].live).toBe(false);
});
});
describe('Phase E.1 — GameCard reskin (Bloomberg best/worst)', () => {
const src = read('components/GameCard.tsx');
it('uses the slate adapter best-line detection + new components', () => {
expect(src).toContain('detectBestLines');
expect(src).toContain('SportBadge');
expect(src).toContain('SectionHead');
});
it('renders best = green tint + green border, worst = subtle red', () => {
expect(src).toContain('rgba(0,212,160,.13)');
expect(src).toContain('rgba(255,82,82,.07)');
});
it('dropped the emoji sport marker', () => {
expect(src).not.toContain('SPORT_EMOJI');
});
});
describe('Phase E.3 — stubs are now real pages', () => {
const pages = ['compare', 'invite', 'help', 'about'];
it.each(pages)('/%s is no longer a RouteStub', (p) => {
expect(read(`app/${p}/page.tsx`)).not.toContain('RouteStub');
});
it('compare renders two player inputs + a verdict', () => {
const src = read('app/compare/page.tsx');
expect((src.match(/TerminalInput/g) || []).length).toBeGreaterThanOrEqual(2);
expect(src).toContain('VYNDR VERDICT');
});
it('invite renders the 3-friends referral messaging', () => {
expect(read('app/invite/page.tsx')).toContain('3 friends');
});
it('help renders a searchable FAQ', () => {
const src = read('app/help/page.tsx');
expect(src).toContain('TerminalInput');
expect(src).toContain('FAQ');
});
it('about uses the system-voice brand line', () => {
expect(read('app/about/page.tsx')).toContain('give it back');
});
});
describe('Phase E.2 — login + pricing reskins', () => {
it('login uses scanlines, system voice, and the new Wordmark', () => {
const src = read('app/login/page.tsx');
expect(src).toContain('scanlines');
expect(src).toContain('ACCESS THE SIGNAL');
expect(src).toContain("from '@/components/vyndr'");
});
it('pricing mounts the ClaimMeter', () => {
expect(read('app/pricing/page.tsx')).toContain('ClaimMeter');
});
});
+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' }; export const metadata = { title: 'About' };
// Brand page in system voice (§3). Server component.
export default function AboutPage() { export default function AboutPage() {
return ( return (
<RouteStub <section style={{ maxWidth: 680, margin: '0 auto', padding: '40px 16px 96px' }}>
title="About" <div style={{ marginBottom: 28 }}>
arriving="SESSION 36" <Wordmark size="lg" cursor beta />
blurb="The books have every advantage. We built this to give it back. Built by Kevon Butler · Detroit." </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() { export default function ComparePage() {
const [a, setA] = useState('Nikola Jokić');
const [b, setB] = useState('Victor Wembanyama');
return ( return (
<RouteStub <section style={{ maxWidth: 860, margin: '0 auto', padding: '28px 16px 96px' }}>
title="Compare" <SectionHead accent="var(--g-a)"> HEAD TO HEAD</SectionHead>
arriving="SESSION 36" <h1 className="mono" style={{ fontSize: 28, fontWeight: 800, letterSpacing: '-0.02em', margin: '8px 0 18px' }}>COMPARE</h1>
blurb="Two players head-to-head — markets, form, and the verdict on who has the edge tonight."
/> <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() { 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 ( return (
<RouteStub <section style={{ maxWidth: 720, margin: '0 auto', padding: '28px 16px 96px' }}>
title="Help & FAQ" <SectionHead accent="var(--g-a)"> SUPPORT</SectionHead>
arriving="SESSION 36" <h1 className="mono" style={{ fontSize: 28, fontWeight: 800, letterSpacing: '-0.02em', margin: '8px 0 18px' }}>HELP &amp; FAQ</h1>
blurb="Searchable answers on grades, reads, plans, and how to read the signal."
/> <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() { 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 ( return (
<RouteStub <section style={{ maxWidth: 640, margin: '0 auto', padding: '28px 16px 96px' }}>
title="Invite" <SectionHead accent="var(--amber)"> REFERRAL</SectionHead>
arriving="SESSION 36" <h1 className="mono" style={{ fontSize: 28, fontWeight: 800, letterSpacing: '-0.02em', margin: '8px 0 8px' }}>INVITE</h1>
blurb="Bring three friends onto the signal and your Analyst plan is on the house." <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 { useRouter, useSearchParams } from 'next/navigation';
import { useAuth } from '@/contexts/AuthContext'; import { useAuth } from '@/contexts/AuthContext';
import { trackLogin } from '@/lib/analytics'; import { trackLogin } from '@/lib/analytics';
import Wordmark from '@/components/Wordmark'; import { Wordmark } from '@/components/vyndr';
import { GoogleIcon, AppleIcon, XIcon } from '@/components/OAuthIcons'; import { GoogleIcon, AppleIcon, XIcon } from '@/components/OAuthIcons';
// Session 14 — small helper so each OAuth button has the same icon+label // Session 14 — small helper so each OAuth button has the same icon+label
@@ -80,18 +80,18 @@ function LoginInner() {
}; };
return ( return (
<section style={{ minHeight: '80vh', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '32px 16px' }}> <section className="scanlines" style={{ minHeight: '80vh', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '32px 16px', position: 'relative' }}>
<div className="surface diagonal-cut animate-fade-up" style={{ width: '100%', maxWidth: 420, padding: 32 }}> <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 <a
href="/" href="/"
style={{ display: 'flex', justifyContent: 'center', color: 'var(--text-0)', textDecoration: 'none', marginBottom: 24 }} style={{ display: 'flex', justifyContent: 'center', color: 'var(--text-0)', textDecoration: 'none', marginBottom: 24 }}
aria-label="VYNDR — home" aria-label="VYNDR — home"
> >
<Wordmark size={26} /> <Wordmark size="md" cursor beta />
</a> </a>
<h1 style={{ fontSize: 24, fontWeight: 700, textAlign: 'center', marginBottom: 6 }}>Log in</h1> <h1 className="mono" style={{ fontSize: 18, fontWeight: 800, textAlign: 'center', marginBottom: 6, letterSpacing: '0.04em' }}>ACCESS THE SIGNAL</h1>
<p style={{ textAlign: 'center', color: 'var(--text-secondary)', fontSize: 13, marginBottom: 24 }}> <p className="mono" style={{ textAlign: 'center', color: 'var(--text-1)', fontSize: 12.5, marginBottom: 24 }}>
Welcome back. Let&apos;s read something. Welcome back. Let&apos;s read something.
</p> </p>
+6 -1
View File
@@ -1,5 +1,6 @@
import type { Metadata } from 'next'; import type { Metadata } from 'next';
import Pricing from '@/components/Pricing'; import Pricing from '@/components/Pricing';
import { ClaimMeter } from '@/components/vyndr';
export const metadata: Metadata = { export const metadata: Metadata = {
title: 'Pricing — VYNDR', title: 'Pricing — VYNDR',
@@ -33,8 +34,12 @@ export const metadata: Metadata = {
*/ */
export default function PricingPage() { export default function PricingPage() {
return ( return (
<main style={{ minHeight: '100vh', paddingTop: 64 }}> <main style={{ minHeight: '100vh' }}>
<Pricing /> <Pricing />
{/* Founder-seat scarcity under the grid (§12) */}
<div style={{ padding: '8px 16px 48px' }}>
<ClaimMeter />
</div>
</main> </main>
); );
} }
+44 -58
View File
@@ -2,6 +2,9 @@
import { useState } from 'react'; import { useState } from 'react';
import type { PropRowProp, PropRowResult, Tier } from '@/components/PropRow'; import type { PropRowProp, PropRowResult, Tier } from '@/components/PropRow';
import SportBadge from '@/components/vyndr/SportBadge';
import SectionHead from '@/components/vyndr/SectionHead';
import { detectBestLines } from '@/lib/slateAdapter';
// Session 19 — PlayerCard groups props by player so a single player // Session 19 — PlayerCard groups props by player so a single player
// with 4 props renders as ONE card with their headshot + 4 stat // with 4 props renders as ONE card with their headshot + 4 stat
// lines, instead of 4 independent stripes that repeat the name. // lines, instead of 4 independent stripes that repeat the name.
@@ -20,13 +23,6 @@ import PlayerCard, { groupPropsByPlayer } from '@/components/PlayerCard';
export type SlateSport = 'nba' | 'wnba' | 'mlb' | 'soccer'; export type SlateSport = 'nba' | 'wnba' | 'mlb' | 'soccer';
const SPORT_EMOJI: Record<SlateSport, string> = {
nba: '🏀',
wnba: '🏀',
mlb: '⚾',
soccer: '⚽',
};
const SPORT_ACCENT: Record<SlateSport, string> = { const SPORT_ACCENT: Record<SlateSport, string> = {
nba: '#E94B3C', nba: '#E94B3C',
wnba: '#FFB347', wnba: '#FFB347',
@@ -172,12 +168,27 @@ export function teamAbbr(fullName: string, sport: SlateSport): string {
return fullName.slice(0, 4).toUpperCase(); return fullName.slice(0, 4).toUpperCase();
} }
const SPORT_LABEL: Record<SlateSport, string> = { // Bloomberg line cell (Session 36): best = green tint + green left border,
nba: 'NBA', // worst = subtle red. The #1 visual upgrade (§13).
wnba: 'WNBA', function LineCell({ value, best, worst }: { value: string; best?: boolean; worst?: boolean }) {
mlb: 'MLB', return (
soccer: 'SOCCER', <div
}; className="mono"
style={{
padding: '7px 6px',
textAlign: 'center',
fontSize: 12.5,
fontWeight: best ? 700 : 500,
color: best ? 'var(--g-a)' : worst ? '#ff8a8a' : 'var(--text-0)',
background: best ? 'rgba(0,212,160,.13)' : worst ? 'rgba(255,82,82,.07)' : 'transparent',
borderLeft: best ? '2px solid var(--g-a)' : worst ? '2px solid rgba(255,82,82,.4)' : '2px solid transparent',
borderRadius: 3,
}}
>
{value}
</div>
);
}
export default function GameCard(props: GameCardProps) { export default function GameCard(props: GameCardProps) {
const { const {
@@ -189,7 +200,8 @@ export default function GameCard(props: GameCardProps) {
} = props; } = props;
const [expanded, setExpanded] = useState(false); const [expanded, setExpanded] = useState(false);
const badge = statusBadge(status, score); const badge = statusBadge(status, score);
const bookRows = gameLines?.books ? Object.entries(gameLines.books) : []; // Session 36 — best/worst line detection (Bloomberg pattern) via slateAdapter.
const lineRows = gameLines?.books ? detectBestLines(gameLines.books) : [];
const streakRows = (streaks || []).filter((s) => s && s.player && s.description); const streakRows = (streaks || []).filter((s) => s && s.player && s.description);
// Session 19 — visibility budget now applies to PLAYERS, not raw // Session 19 — visibility budget now applies to PLAYERS, not raw
@@ -240,7 +252,7 @@ export default function GameCard(props: GameCardProps) {
letterSpacing: '-0.02em', letterSpacing: '-0.02em',
}} }}
> >
<span aria-hidden style={{ fontSize: 15 }}>{SPORT_EMOJI[sport]}</span> <SportBadge sport={sport} size="sm" />
<span className="mono" style={{ letterSpacing: '0.04em' }}> <span className="mono" style={{ letterSpacing: '0.04em' }}>
{teamAbbr(awayTeam, sport)} {teamAbbr(awayTeam, sport)}
</span> </span>
@@ -250,22 +262,6 @@ export default function GameCard(props: GameCardProps) {
<span className="mono" style={{ letterSpacing: '0.04em' }}> <span className="mono" style={{ letterSpacing: '0.04em' }}>
{teamAbbr(homeTeam, sport)} {teamAbbr(homeTeam, sport)}
</span> </span>
<span
className="mono"
style={{
marginLeft: 'auto',
fontSize: 10,
fontWeight: 700,
color: '#0A0A0F',
background: accent,
padding: '3px 8px',
borderRadius: 4,
letterSpacing: '0.08em',
}}
aria-label={`Sport: ${SPORT_LABEL[sport]}`}
>
{SPORT_LABEL[sport]}
</span>
</div> </div>
<div <div
style={{ style={{
@@ -331,42 +327,32 @@ export default function GameCard(props: GameCardProps) {
spread, total. Renders only when lines exist; never blocks the spread, total. Renders only when lines exist; never blocks the
card. The brand edge is props, but lines give immediate action card. The brand edge is props, but lines give immediate action
even when props haven't been published. */} even when props haven't been published. */}
{bookRows.length > 0 && ( {lineRows.length > 0 && (
<div <div
style={{ style={{
padding: '12px 20px 14px', padding: '12px 20px 14px',
borderTop: '1px solid var(--border, #1A1A24)', borderTop: '1px solid var(--border, #1A1A24)',
display: 'grid',
gap: 8,
background: 'rgba(255,255,255,0.015)', background: 'rgba(255,255,255,0.015)',
}} }}
> >
<div <SectionHead style={{ marginBottom: 10 }}>
className="mono" GAME LINES <span style={{ color: 'var(--text-2)' }}>· {lineRows.length} BOOK{lineRows.length === 1 ? '' : 'S'}</span>
style={{ fontSize: 10, fontWeight: 700, letterSpacing: '0.1em', color: 'var(--text-tertiary, #6B6B7B)', textTransform: 'uppercase' }} </SectionHead>
> <div style={{ display: 'grid', gridTemplateColumns: '72px 1fr 1fr 1fr', gap: '2px 6px', alignItems: 'center' }}>
Game Lines <div className="label" style={{ fontSize: 10 }}>BOOK</div>
</div> <div className="label" style={{ fontSize: 10, textAlign: 'center' }}>{teamAbbr(awayTeam, sport)} ML</div>
{bookRows.map(([book, line]) => ( <div className="label" style={{ fontSize: 10, textAlign: 'center' }}>{teamAbbr(homeTeam, sport)} ML</div>
<div <div className="label" style={{ fontSize: 10, textAlign: 'center' }}>O/U</div>
key={book} {lineRows.map((r) => (
className="mono" <span key={r.book} style={{ display: 'contents' }}>
style={{ <div className="mono" style={{ fontSize: 12, fontWeight: 700, color: 'var(--text-1)', textTransform: 'capitalize', paddingLeft: 2, alignSelf: 'center' }}>{r.book}</div>
display: 'grid', <LineCell value={r.awayML} best={r.bestAway} worst={r.worstAway} />
gridTemplateColumns: '72px 1fr 1fr 1fr', <LineCell value={r.homeML} best={r.bestHome} worst={r.worstHome} />
gap: 8, <LineCell value={r.ou} />
fontSize: 11.5, </span>
alignItems: 'baseline',
color: 'var(--text-secondary, #8A8A9A)',
}}
>
<span style={{ color: 'var(--text-0, #F0F0F5)', fontWeight: 700, textTransform: 'capitalize' }}>{book}</span>
<span>{line.awayML ? `${teamAbbr(awayTeam, sport)} ${line.awayML}` : '—'}</span>
<span>{line.homeML ? `${teamAbbr(homeTeam, sport)} ${line.homeML}` : '—'}</span>
<span style={{ textAlign: 'right' }}>{line.total ? `O/U ${line.total}` : '—'}</span>
</div>
))} ))}
</div> </div>
</div>
)} )}
{propList.length === 0 ? ( {propList.length === 0 ? (
+126
View File
@@ -0,0 +1,126 @@
/* ============================================================
VYNDR 2.0 slate adapter (§7, §E.1).
Merges schedule + gamelines + streaks + grades into the GameCard
contract, and detects the best/worst book line per game (the
Bloomberg pattern the #1 visual upgrade). Plain CommonJS so the
.tsx cards import it (allowJs) AND Jest exercises the logic directly.
============================================================ */
/** American odds decimal payout multiplier (higher = better for the bettor).
* "+150" 2.5, "-110" ~1.909. Returns null when unparseable. */
function parseAmericanOdds(odds) {
if (odds == null) return null;
const n = typeof odds === 'number' ? odds : parseInt(String(odds).replace(/[^\d+-]/g, ''), 10);
if (!Number.isFinite(n) || n === 0) return null;
return n > 0 ? 1 + n / 100 : 1 + 100 / Math.abs(n);
}
/** Mark the most/least favorable moneyline per side across a game's books.
* Input: { book1: { awayML, homeML, total }, ... } rows with best/worst flags.
* best/worst only set when 2 books disagree (a lone price isn't "best"). */
function detectBestLines(books) {
const entries = Object.entries(books || {});
const rows = entries.map(([book, ln]) => ({
book,
awayML: ln.awayML || '—',
homeML: ln.homeML || '—',
ou: ln.total != null ? `O/U ${ln.total}` : '—',
_away: parseAmericanOdds(ln.awayML),
_home: parseAmericanOdds(ln.homeML),
}));
const mark = (side) => {
const vals = rows.map((r) => r[side]).filter((v) => v != null);
if (vals.length < 2) return [null, null];
const max = Math.max(...vals);
const min = Math.min(...vals);
return max === min ? [null, null] : [max, min];
};
const [bestAway, worstAway] = mark('_away');
const [bestHome, worstHome] = mark('_home');
return rows.map((r) => ({
book: r.book,
awayML: r.awayML,
homeML: r.homeML,
ou: r.ou,
bestAway: r._away != null && r._away === bestAway,
worstAway: r._away != null && r._away === worstAway,
bestHome: r._home != null && r._home === bestHome,
worstHome: r._home != null && r._home === worstHome,
}));
}
function formatGameTime(iso) {
if (!iso) return '';
try {
return new Date(iso).toLocaleString(undefined, { weekday: 'short', hour: 'numeric', minute: '2-digit' });
} catch {
return String(iso);
}
}
/** Map one schedule game's lines entry → the GameCard `lines[]` contract. */
function mapGameLines(linesEntry) {
if (!linesEntry || !linesEntry.books) return [];
return detectBestLines(linesEntry.books);
}
/**
* Map schedule + gamelines + streaks (+ optional grades) GameCardData[]
* (§7). Pure no API calls, no side effects.
*/
function mapScheduleToGameCards(schedule, gamelines, streaks, grades) {
const sched = Array.isArray(schedule) ? schedule : [];
return sched.map((g) => {
const id = g.id || `${g.awayTeam?.abbreviation || '?'}-${g.homeTeam?.abbreviation || '?'}`;
const live = g.live === true || g.status === 'in';
const linesEntry = gamelines && gamelines[id];
return {
id,
sport: (g.sport || 'nba').toLowerCase(),
live,
score: g.score ? { away: g.score.away, home: g.score.home } : undefined,
clock: g.clock || undefined,
away: { abbr: g.awayTeam?.abbreviation || '', name: g.awayTeam?.name || '' },
home: { abbr: g.homeTeam?.abbreviation || '', name: g.homeTeam?.name || '' },
time: formatGameTime(g.gameTime),
venue: g.venue || undefined,
lines: mapGameLines(linesEntry),
props: mapGradedProps(grades, g),
streaks: mapStreaks(streaks, g),
};
});
}
function mapGradedProps(grades, game) {
if (!Array.isArray(grades)) return [];
const h = (game.homeTeam?.abbreviation || '').toUpperCase();
const a = (game.awayTeam?.abbreviation || '').toUpperCase();
return grades
.filter((p) => {
const t = (p.team || '').toUpperCase();
return !t || t === h || t === a;
})
.map((p) => ({ player: p.player, stat: p.stat, line: p.line, grade: p.grade, side: p.side || 'Over', delta: p.delta }));
}
function mapStreaks(streaks, game) {
if (!Array.isArray(streaks)) return [];
const h = (game.homeTeam?.abbreviation || '').toUpperCase();
const a = (game.awayTeam?.abbreviation || '').toUpperCase();
return streaks
.filter((s) => {
const t = (s.team || '').toUpperCase();
return t && (t === h || t === a);
})
.map((s) => ({ player: s.player, text: s.text || s.description || '' }));
}
module.exports = {
parseAmericanOdds,
detectBestLines,
mapGameLines,
mapScheduleToGameCards,
formatGameTime,
};