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:
+67
-2
@@ -4,8 +4,73 @@
|
||||
2026-06-16
|
||||
|
||||
## Current Phase
|
||||
SHIP BUILD v35.0 — VYNDR 2.0 design system, Phase D: core screens — Grade Result
|
||||
Card, Slate GameCard, Scan, Terminal, Landing claim meter (Session 35)
|
||||
SHIP BUILD v36.0 — VYNDR 2.0 design system, Phase E: remaining screens —
|
||||
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
|
||||
|
||||
|
||||
@@ -221,6 +221,21 @@ The frame every page sits in. Frontend-only.
|
||||
- **ClaimMeter** = `@/components/vyndr/ClaimMeter` (founder-seat scarcity), on the
|
||||
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
|
||||
- vyndr-voice (all user-facing output)
|
||||
- prop-analysis (grading methodology)
|
||||
|
||||
@@ -121,8 +121,9 @@ describe('Phase C — layout wiring', () => {
|
||||
});
|
||||
|
||||
describe('Phase C — route stubs for not-yet-built screens', () => {
|
||||
// /terminal became a real page in Session 35 (Phase D) — no longer a stub.
|
||||
const stubs = ['compare', 'invite', 'help', 'about', 'notifications'];
|
||||
// /terminal became real in Session 35; /compare /invite /help /about became
|
||||
// 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) => {
|
||||
expect(exists(`app/${name}/page.tsx`)).toBe(true);
|
||||
expect(read(`app/${name}/page.tsx`)).toContain('RouteStub');
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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'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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 & 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 “{query}”.</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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's read something.
|
||||
</p>
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
|
||||
import { useState } from 'react';
|
||||
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
|
||||
// with 4 props renders as ONE card with their headshot + 4 stat
|
||||
// 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';
|
||||
|
||||
const SPORT_EMOJI: Record<SlateSport, string> = {
|
||||
nba: '🏀',
|
||||
wnba: '🏀',
|
||||
mlb: '⚾',
|
||||
soccer: '⚽',
|
||||
};
|
||||
|
||||
const SPORT_ACCENT: Record<SlateSport, string> = {
|
||||
nba: '#E94B3C',
|
||||
wnba: '#FFB347',
|
||||
@@ -172,12 +168,27 @@ export function teamAbbr(fullName: string, sport: SlateSport): string {
|
||||
return fullName.slice(0, 4).toUpperCase();
|
||||
}
|
||||
|
||||
const SPORT_LABEL: Record<SlateSport, string> = {
|
||||
nba: 'NBA',
|
||||
wnba: 'WNBA',
|
||||
mlb: 'MLB',
|
||||
soccer: 'SOCCER',
|
||||
};
|
||||
// Bloomberg line cell (Session 36): best = green tint + green left border,
|
||||
// worst = subtle red. The #1 visual upgrade (§13).
|
||||
function LineCell({ value, best, worst }: { value: string; best?: boolean; worst?: boolean }) {
|
||||
return (
|
||||
<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) {
|
||||
const {
|
||||
@@ -189,7 +200,8 @@ export default function GameCard(props: GameCardProps) {
|
||||
} = props;
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
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);
|
||||
|
||||
// Session 19 — visibility budget now applies to PLAYERS, not raw
|
||||
@@ -240,7 +252,7 @@ export default function GameCard(props: GameCardProps) {
|
||||
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' }}>
|
||||
{teamAbbr(awayTeam, sport)}
|
||||
</span>
|
||||
@@ -250,22 +262,6 @@ export default function GameCard(props: GameCardProps) {
|
||||
<span className="mono" style={{ letterSpacing: '0.04em' }}>
|
||||
{teamAbbr(homeTeam, sport)}
|
||||
</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
|
||||
style={{
|
||||
@@ -331,42 +327,32 @@ export default function GameCard(props: GameCardProps) {
|
||||
spread, total. Renders only when lines exist; never blocks the
|
||||
card. The brand edge is props, but lines give immediate action
|
||||
even when props haven't been published. */}
|
||||
{bookRows.length > 0 && (
|
||||
{lineRows.length > 0 && (
|
||||
<div
|
||||
style={{
|
||||
padding: '12px 20px 14px',
|
||||
borderTop: '1px solid var(--border, #1A1A24)',
|
||||
display: 'grid',
|
||||
gap: 8,
|
||||
background: 'rgba(255,255,255,0.015)',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="mono"
|
||||
style={{ fontSize: 10, fontWeight: 700, letterSpacing: '0.1em', color: 'var(--text-tertiary, #6B6B7B)', textTransform: 'uppercase' }}
|
||||
>
|
||||
Game Lines
|
||||
</div>
|
||||
{bookRows.map(([book, line]) => (
|
||||
<div
|
||||
key={book}
|
||||
className="mono"
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '72px 1fr 1fr 1fr',
|
||||
gap: 8,
|
||||
fontSize: 11.5,
|
||||
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>
|
||||
<SectionHead style={{ marginBottom: 10 }}>
|
||||
GAME LINES <span style={{ color: 'var(--text-2)' }}>· {lineRows.length} BOOK{lineRows.length === 1 ? '' : 'S'}</span>
|
||||
</SectionHead>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '72px 1fr 1fr 1fr', gap: '2px 6px', alignItems: 'center' }}>
|
||||
<div className="label" style={{ fontSize: 10 }}>BOOK</div>
|
||||
<div className="label" style={{ fontSize: 10, textAlign: 'center' }}>{teamAbbr(awayTeam, sport)} ML</div>
|
||||
<div className="label" style={{ fontSize: 10, textAlign: 'center' }}>{teamAbbr(homeTeam, sport)} ML</div>
|
||||
<div className="label" style={{ fontSize: 10, textAlign: 'center' }}>O/U</div>
|
||||
{lineRows.map((r) => (
|
||||
<span key={r.book} style={{ display: 'contents' }}>
|
||||
<div className="mono" style={{ fontSize: 12, fontWeight: 700, color: 'var(--text-1)', textTransform: 'capitalize', paddingLeft: 2, alignSelf: 'center' }}>{r.book}</div>
|
||||
<LineCell value={r.awayML} best={r.bestAway} worst={r.worstAway} />
|
||||
<LineCell value={r.homeML} best={r.bestHome} worst={r.worstHome} />
|
||||
<LineCell value={r.ou} />
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{propList.length === 0 ? (
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
Reference in New Issue
Block a user