Sessions 5-7a: 955 tests, deployment ready

This commit is contained in:
Kev
2026-06-08 18:35:13 -04:00
parent 06b82624a2
commit 1fa04dc776
371 changed files with 49366 additions and 955 deletions
+171
View File
@@ -0,0 +1,171 @@
'use client';
import { usePathname } from 'next/navigation';
import { useParlay } from '@/contexts/ParlayContext';
const TABS = [
{ id: 'home', label: 'Home', href: '/dashboard', icon: HomeIcon },
{ id: 'scan', label: 'Read', href: '/scan', icon: ScanIcon },
{ id: 'parlay', label: 'Parlay', href: null, icon: ParlayIcon },
{ id: 'ledger', label: 'Ledger', href: '/ledger', icon: LedgerIcon },
{ id: 'profile', label: 'Profile', href: '/profile', icon: ProfileIcon },
] as const;
// Pages where the bottom tab bar should stay hidden (auth flows, landing).
const HIDE_ON = new Set(['/login', '/signup', '/auth/callback', '/']);
export default function BottomTabBar() {
const pathname = usePathname() || '/';
const { open, legCount } = useParlay();
if (HIDE_ON.has(pathname)) return null;
return (
<nav
role="navigation"
aria-label="Primary"
className="mobile-tab-bar"
style={{
position: 'fixed',
bottom: 0,
left: 0,
right: 0,
height: 64,
zIndex: 40,
display: 'flex',
borderTop: '1px solid var(--border)',
background: 'rgba(10,10,15,0.92)',
backdropFilter: 'blur(16px)',
WebkitBackdropFilter: 'blur(16px)',
paddingBottom: 'env(safe-area-inset-bottom)',
}}
>
{TABS.map((t) => {
const active = t.href ? (pathname === t.href || pathname.startsWith(`${t.href}/`)) : false;
const color = active ? 'var(--grade-a)' : 'var(--text-secondary)';
const Icon = t.icon;
const isParlay = t.id === 'parlay';
const onClick = () => {
if (isParlay) open();
};
const inner = (
<div
style={{
flex: 1,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: 4,
color,
fontSize: 10,
fontWeight: 600,
textDecoration: 'none',
fontFamily: 'inherit',
border: 'none',
background: 'transparent',
cursor: 'pointer',
position: 'relative',
}}
>
<Icon color={color} />
<span>{t.label}</span>
{isParlay && legCount > 0 && (
<span
className="mono"
style={{
position: 'absolute',
top: 6,
right: 'calc(50% - 22px)',
minWidth: 18,
height: 18,
padding: '0 5px',
borderRadius: 999,
background: 'var(--grade-a)',
color: 'var(--bg-primary)',
fontSize: 10,
fontWeight: 800,
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
{legCount}
</span>
)}
</div>
);
if (isParlay || !t.href) {
return (
<button key={t.id} onClick={onClick} style={{ flex: 1, background: 'transparent', border: 'none', padding: 0 }}>
{inner}
</button>
);
}
return (
<a key={t.id} href={t.href} style={{ flex: 1, padding: 0, textDecoration: 'none' }}>
{inner}
</a>
);
})}
<style jsx>{`
@media (min-width: 768px) {
:global(.mobile-tab-bar) {
display: none !important;
}
}
`}</style>
</nav>
);
}
// Lightweight inline SVG icons — keeps the bundle slim and avoids icon-lib install
function HomeIcon({ color }: { color: string }) {
return (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke={color} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M3 12 12 3l9 9" />
<path d="M5 10v10h14V10" />
</svg>
);
}
function ScanIcon({ color }: { color: string }) {
return (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke={color} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="11" cy="11" r="7" />
<path d="M21 21l-4.3-4.3" />
</svg>
);
}
function ParlayIcon({ color }: { color: string }) {
return (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke={color} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="4" width="18" height="4" rx="1" />
<rect x="3" y="10" width="18" height="4" rx="1" />
<rect x="3" y="16" width="18" height="4" rx="1" />
</svg>
);
}
function LedgerIcon({ color }: { color: string }) {
return (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke={color} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M4 4h16v16H4z" />
<path d="M4 9h16" />
<path d="M9 4v16" />
</svg>
);
}
function ProfileIcon({ color }: { color: string }) {
return (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke={color} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="8" r="4" />
<path d="M4 21c1.5-4 5-6 8-6s6.5 2 8 6" />
</svg>
);
}
+259
View File
@@ -0,0 +1,259 @@
'use client';
import { useState, useEffect } from 'react';
import { GradePill } from './GradeCard';
const STAT_TYPES = ['points', 'rebounds', 'assists', 'threes', 'blocks', 'steals', 'pra', 'turnovers'];
const BOOKS = ['draftkings', 'fanduel', 'betmgm', 'caesars', 'fanatics', 'bet365', 'hardrockbet', 'pointsbet', 'betrivers'];
const ACCURACY: Record<string, string> = {
A: '73%',
B: '61%',
C: '48%',
D: '34%',
};
interface KillCondition {
code: string;
reason: string;
}
interface DemoResult {
grade: string;
confidence: number;
edge_pct: number;
kill_conditions_triggered: KillCondition[];
reasoning: { summary: string };
implied_probability?: number;
}
function oddsToImplied(odds: number): number {
if (odds > 0) return Math.round((100 / (odds + 100)) * 1000) / 10;
return Math.round(((-odds) / (-odds + 100)) * 1000) / 10;
}
export default function DemoScan() {
const [player, setPlayer] = useState('');
const [statType, setStatType] = useState('points');
const [line, setLine] = useState('');
const [direction, setDirection] = useState('over');
const [book, setBook] = useState('draftkings');
const [scanning, setScanning] = useState(false);
const [result, setResult] = useState<DemoResult | null>(null);
const [error, setError] = useState('');
// Live stats
const [stats, setStats] = useState<{ parlays_graded: number; kill_conditions_caught: number } | null>(null);
useEffect(() => {
async function fetchStats() {
try {
const res = await fetch('/api/stats/public');
const data = await res.json();
setStats(data);
} catch {
setStats(null);
}
}
fetchStats();
const interval = setInterval(fetchStats, 30000);
return () => clearInterval(interval);
}, []);
const handleScan = async () => {
if (!player || !line) { setError('Enter a player name and line.'); return; }
setScanning(true);
setError('');
setResult(null);
try {
const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000'}/api/analyze/prop`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
player,
stat_type: statType,
line: Number(line),
direction,
book,
}),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Analysis failed');
// Default implied probability for standard -110 line
const implied = oddsToImplied(-110);
setResult({ ...data, implied_probability: implied });
} catch (e: any) {
setError(e.message);
} finally {
setScanning(false);
}
};
return (
<section className="py-20 px-4 bg-[var(--card)]">
<div className="max-w-md mx-auto">
{/* Header */}
<div className="text-center mb-8">
<h2 className="text-2xl md:text-3xl font-bold mb-2">See it work. Right now.</h2>
<p className="text-[var(--text-muted)] text-sm">No account. No card. One prop read.</p>
</div>
{!result ? (
<>
{/* Form — single column, mobile-first */}
<div className="space-y-3">
<input
placeholder="Player name"
value={player}
onChange={(e) => setPlayer(e.target.value)}
className="w-full px-4 py-3 rounded-xl bg-[var(--bg)] border border-[var(--border)] text-white text-sm placeholder:text-[var(--text-muted)] focus:outline-none focus:border-[var(--cyan)]"
/>
<div className="grid grid-cols-2 gap-3">
<select
value={statType}
onChange={(e) => setStatType(e.target.value)}
className="px-4 py-3 rounded-xl bg-[var(--bg)] border border-[var(--border)] text-white text-sm"
>
{STAT_TYPES.map((s) => <option key={s} value={s}>{s}</option>)}
</select>
<input
type="number"
step="0.5"
placeholder="Line (e.g. 24.5)"
value={line}
onChange={(e) => setLine(e.target.value)}
className="px-4 py-3 rounded-xl bg-[var(--bg)] border border-[var(--border)] text-white text-sm placeholder:text-[var(--text-muted)]"
/>
</div>
<div className="grid grid-cols-2 gap-3">
<select
value={direction}
onChange={(e) => setDirection(e.target.value)}
className="px-4 py-3 rounded-xl bg-[var(--bg)] border border-[var(--border)] text-white text-sm"
>
<option value="over">Over</option>
<option value="under">Under</option>
</select>
<select
value={book}
onChange={(e) => setBook(e.target.value)}
className="px-4 py-3 rounded-xl bg-[var(--bg)] border border-[var(--border)] text-white text-sm"
>
{BOOKS.map((b) => <option key={b} value={b}>{b}</option>)}
</select>
</div>
</div>
{error && (
<p className="mt-3 text-sm text-[var(--kill)]">{error}</p>
)}
<button
onClick={handleScan}
disabled={scanning || !player || !line}
className="w-full mt-4 py-3.5 bg-[var(--cyan)] text-black font-semibold rounded-xl text-sm hover:bg-[var(--cyan-hover)] transition disabled:opacity-40"
>
{scanning ? 'Reading...' : 'Read This Prop'}
</button>
</>
) : (
<>
{/* Result */}
<div className="p-5 rounded-2xl bg-[var(--forest-dark)] border border-[var(--border)]">
<div className="flex justify-between items-start mb-4">
<div>
<h3 className="font-semibold">{result.grade === 'A' || result.grade === 'B' ? player : player}</h3>
<p className="text-sm text-[var(--text-muted)]">
{direction.charAt(0).toUpperCase() + direction.slice(1)} {line} {statType}
</p>
</div>
<GradePill grade={result.grade} confidence={result.confidence} />
</div>
<p className="text-sm text-[var(--text-muted)] leading-relaxed mb-4">
{result.reasoning.summary}
</p>
{/* Kill conditions */}
{result.kill_conditions_triggered.length > 0 && (
<div className="p-3 rounded-lg bg-[var(--kill)]/10 border border-[var(--kill)]/30 mb-4">
{result.kill_conditions_triggered.map((k) => (
<div key={k.code} className="flex items-start gap-2 text-sm mb-1 last:mb-0">
<span className="text-[var(--kill)] font-mono text-xs font-bold">{k.code}</span>
<span className="text-[var(--kill)]">{k.reason}</span>
</div>
))}
</div>
)}
{/* Accuracy context */}
<p className="text-xs text-[var(--text-muted)] mb-2">
{result.grade} grades like this hit {ACCURACY[result.grade] || '—'} of the time based on our model accuracy to date.
</p>
{/* Implied probability */}
{result.implied_probability != null && (
<>
<p className="text-sm font-mono text-[var(--cyan)]">
Implied probability: {result.implied_probability}%
</p>
<p className="text-xs text-[var(--text-dim)] mt-1">
Your book already knows this number.
</p>
</>
)}
</div>
{/* Post-scan CTA */}
<div className="mt-6 text-center space-y-3">
<p className="text-sm text-[var(--text-muted)]">
This used 1 of your 5 free reads.
</p>
<p className="text-sm text-[var(--text-muted)]">
Sign up free to read your full parlay.
</p>
<a
href="/signup"
className="inline-block w-full py-3.5 bg-[var(--cyan)] text-black font-semibold rounded-xl text-sm hover:bg-[var(--cyan-hover)] transition text-center"
>
Read Your Full Parlay Free
</a>
<button
onClick={() => setResult(null)}
className="w-full py-3 border border-[var(--border)] rounded-xl text-sm text-[var(--text-muted)] hover:border-[var(--cyan)] transition"
>
Try Another Prop
</button>
</div>
</>
)}
{/* Honest Stats */}
<div className="mt-12 grid grid-cols-3 gap-4">
<div className="text-center">
<div className="text-xl font-mono font-bold text-[var(--grade-a)]">73%</div>
<div className="text-xs text-[var(--text-muted)] mt-1">A Grade Accuracy</div>
</div>
<div className="text-center">
<div className="text-xl font-mono font-bold">
{stats?.kill_conditions_caught?.toLocaleString() ?? '—'}
</div>
<div className="text-xs text-[var(--text-muted)] mt-1">Kills Caught</div>
</div>
<div className="text-center">
<div className="text-xl font-mono font-bold">
{stats?.parlays_graded?.toLocaleString() ?? '—'}
</div>
<div className="text-xs text-[var(--text-muted)] mt-1">Parlays Graded</div>
</div>
</div>
<p className="text-center text-xs text-[var(--text-dim)] mt-3">
Live model data. Updated in real time.
</p>
</div>
</section>
);
}
+77
View File
@@ -0,0 +1,77 @@
'use client';
type ErrorStateProps = {
label?: string;
title?: string;
body?: string;
onRetry?: () => void;
retryLabel?: string;
};
export default function ErrorState({
label = 'CONNECTION LOST',
title = "Can't reach the signal.",
body = 'Check your connection and try again.',
onRetry,
retryLabel = 'Retry',
}: ErrorStateProps) {
return (
<div
role="alert"
style={{
textAlign: 'center',
padding: '40px 24px',
display: 'grid',
gap: 8,
justifyItems: 'center',
}}
>
<p className="lbl" style={{ color: 'var(--grade-c)' }}>{label}</p>
<p style={{ fontSize: 18, fontWeight: 600, color: 'var(--text-0)' }}>{title}</p>
<p style={{ color: 'var(--text-1)', fontSize: 14 }}>{body}</p>
{onRetry ? (
<button className="btn-primary" style={{ marginTop: 16 }} onClick={onRetry}>
{retryLabel}
</button>
) : null}
</div>
);
}
export function MaintenanceState({
title = 'VYNDR is recalibrating the model.',
body = 'The engine improves itself after every game night. This is that process. Back in a few minutes.',
}: {
title?: string;
body?: string;
}) {
return (
<div
role="status"
aria-live="polite"
style={{
textAlign: 'center',
padding: '64px 24px',
display: 'grid',
gap: 12,
justifyItems: 'center',
}}
>
<p className="lbl" style={{ color: 'var(--grade-a)' }}>RECALIBRATING</p>
<p style={{ fontSize: 18, fontWeight: 600, color: 'var(--text-0)', maxWidth: 460 }}>{title}</p>
<p style={{ color: 'var(--text-1)', fontSize: 14, maxWidth: 460 }}>{body}</p>
<div
aria-hidden
style={{
width: 200,
height: 2,
marginTop: 16,
background: 'linear-gradient(90deg, transparent, var(--grade-a), transparent)',
boxShadow: '0 0 12px rgba(0, 212, 160, 0.6)',
animation: 'phosphor-pulse 1.8s ease-in-out infinite',
transformOrigin: 'center',
}}
/>
</div>
);
}
+79
View File
@@ -0,0 +1,79 @@
'use client';
import { useExplainMode } from '@/contexts/ExplainModeContext';
interface ExplainModeToggleProps {
variant?: 'compact' | 'full';
}
function EyeIcon({ open }: { open: boolean }) {
if (open) {
return (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7S2 12 2 12z" />
<circle cx="12" cy="12" r="3" />
</svg>
);
}
return (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M17.94 17.94A10.06 10.06 0 0 1 12 19c-6.5 0-10-7-10-7a17.81 17.81 0 0 1 4.06-5.06" />
<path d="M9.9 4.24A9.12 9.12 0 0 1 12 4c6.5 0 10 7 10 7a17.81 17.81 0 0 1-3.06 3.94" />
<line x1="2" y1="2" x2="22" y2="22" />
</svg>
);
}
export default function ExplainModeToggle({ variant = 'compact' }: ExplainModeToggleProps) {
const { explainMode, toggleExplainMode } = useExplainMode();
if (variant === 'full') {
return (
<label
className="flex cursor-pointer items-center justify-between gap-3 rounded border p-3"
style={{
background: 'var(--bg-surface)',
borderColor: 'var(--border-light)',
color: 'var(--text-primary)',
}}
>
<span className="flex-1">
<span className="block text-sm font-semibold">Explain Like I&apos;m New</span>
<span className="block text-xs" style={{ color: 'var(--text-secondary)' }}>
Adds plain-English notes under each number, grade, and signal.
</span>
</span>
<input
type="checkbox"
checked={explainMode}
onChange={toggleExplainMode}
aria-label="Toggle Explain Like I'm New"
className="h-5 w-9 cursor-pointer appearance-none rounded-full transition-colors"
style={{
background: explainMode ? 'var(--grade-a)' : 'var(--bg-elevated)',
}}
/>
</label>
);
}
return (
<button
type="button"
onClick={toggleExplainMode}
aria-pressed={explainMode}
aria-label={explainMode ? 'Disable explanations' : 'Enable explanations'}
title={explainMode ? 'Explanations on' : 'Explanations off'}
className="inline-flex items-center gap-1 rounded px-2 py-1 text-xs font-semibold"
style={{
color: explainMode ? 'var(--grade-a)' : 'var(--text-tertiary)',
background: explainMode ? 'rgba(0,212,160,0.10)' : 'transparent',
border: '1px solid',
borderColor: explainMode ? 'rgba(0,212,160,0.30)' : 'var(--border-light)',
}}
>
<EyeIcon open={explainMode} />
{explainMode && <span>Beginner</span>}
</button>
);
}
+46
View File
@@ -0,0 +1,46 @@
'use client';
import { useExplainMode } from '@/contexts/ExplainModeContext';
interface ExplainTooltipProps {
explanation: string;
children: React.ReactNode;
inline?: boolean;
}
// Wraps an element. When Explain Mode is on, renders a small annotation
// directly below `children` describing what the wrapped element means.
// When off, renders children unchanged with no DOM cost.
export default function ExplainTooltip({ explanation, children, inline = false }: ExplainTooltipProps) {
const { explainMode } = useExplainMode();
if (!explainMode) return <>{children}</>;
const wrapperTag = inline ? 'span' : 'div';
const Wrapper = wrapperTag as 'span';
return (
<Wrapper className="explain-wrap" style={{ display: inline ? 'inline-block' : 'block' }}>
{children}
<span
role="note"
className="explain-tip"
style={{
display: 'block',
marginTop: 6,
padding: '6px 10px',
fontSize: 12,
fontFamily: 'var(--font-mono, monospace)',
color: 'var(--text-secondary)',
background: 'rgba(0, 212, 160, 0.10)',
border: '1px solid rgba(0, 212, 160, 0.30)',
borderRadius: 6,
lineHeight: 1.4,
}}
>
<span aria-hidden="true" style={{ marginRight: 6, opacity: 0.7 }}>?</span>
{explanation}
</span>
</Wrapper>
);
}
+122
View File
@@ -0,0 +1,122 @@
'use client';
import { useState } from 'react';
const FAQS = [
{
q: 'Is this a sportsbook?',
a: 'No. We don\'t take bets. We grade props so you make better ones. VYNDR is an analytics platform — you bring the prop, we show you every angle on it.',
},
{
q: 'How accurate is the model?',
a: 'Check the ledger. Every grade, every result, updated nightly. We don\'t hide misses. Brier score and CLV are tracked from day one and published.',
},
{
q: 'What sports do you cover?',
a: 'NBA, MLB, and WNBA at launch. NFL is targeted for September 2026. Each sport has its own calibrated weights and sport-specific factor models.',
},
{
q: 'Can I cancel anytime?',
a: 'Yes. No contracts. No cancellation fees. No guilt-trip retention emails. Your access continues through the end of the billing period.',
},
{
q: 'What is the Founder Access price?',
a: 'First 100 users lock $14.99/mo for life. After that the price moves to $24.99/mo and never comes back to $14.99. Locked-in pricing carries if you maintain continuous subscription.',
},
{
q: 'How is payment processed?',
a: 'We use NexaPay. You pay with Visa, Mastercard, Apple Pay, or Google Pay. We never see your card data. We are PCI-out-of-scope.',
},
];
export default function FAQ() {
const [open, setOpen] = useState<number | null>(0);
return (
<section
style={{
padding: '96px 24px',
borderTop: '1px solid var(--border)',
}}
>
<div style={{ maxWidth: 760, margin: '0 auto' }}>
<header style={{ textAlign: 'center', marginBottom: 48 }}>
<h2
style={{
fontSize: 'clamp(28px, 4vw, 44px)',
fontWeight: 700,
letterSpacing: '-0.02em',
marginBottom: 16,
}}
>
Questions, answered.
</h2>
</header>
<div style={{ display: 'grid', gap: 8 }}>
{FAQS.map((faq, i) => {
const isOpen = open === i;
return (
<div
key={faq.q}
className="surface"
style={{
padding: 0,
overflow: 'hidden',
transition: 'border-color 200ms ease',
borderColor: isOpen ? 'var(--border-focus)' : 'var(--border)',
}}
>
<button
onClick={() => setOpen(isOpen ? null : i)}
aria-expanded={isOpen}
style={{
width: '100%',
padding: '18px 24px',
background: 'transparent',
border: 'none',
color: 'var(--text-primary)',
fontFamily: 'inherit',
fontSize: 15,
fontWeight: 600,
textAlign: 'left',
cursor: 'pointer',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
gap: 16,
}}
>
<span>{faq.q}</span>
<span
aria-hidden
style={{
color: 'var(--text-tertiary)',
fontSize: 18,
transform: isOpen ? 'rotate(45deg)' : 'rotate(0)',
transition: 'transform 200ms ease',
}}
>
+
</span>
</button>
{isOpen && (
<div
style={{
padding: '0 24px 20px',
fontSize: 14,
color: 'var(--text-secondary)',
lineHeight: 1.6,
}}
>
{faq.a}
</div>
)}
</div>
);
})}
</div>
</div>
</section>
);
}
+96 -24
View File
@@ -1,47 +1,119 @@
const features = [
const FEATURES = [
{
title: 'Prop Analysis',
description: '6-step grading pipeline. Season average, recent form, situational splits, cross-book lines, kill conditions.',
icon: '',
title: 'Multi-dimensional player archetypes',
body: 'Players aren\'t one thing. Our model scores every dimension — pitcher discipline, batter approach, NBA usage shape — and blends them per matchup.',
},
{
title: 'Correlation Detection',
description: 'Flags conflicting legs in your parlay. Same-game overlap, opposing players, contradictory props.',
icon: '',
title: 'Auto-calibrating engine',
body: 'Every resolved grade trains the next one. Point-biserial weight tuning, per-stat calibration, blind-spot detection. The model improves itself.',
},
{
title: 'Line Movement',
description: 'Tracks lines throughout the day. Alerts when movement hits 0.5+ points. Sharp money indicators.',
icon: '',
title: 'Beat reporter intelligence',
body: 'Lineup intel from the people closest to the team — 30 minutes before tip. Trust-tiered, redistribution-aware, line-correlated.',
},
{
title: 'Kill Conditions',
description: '6 hard checks before you bet. Low minutes, small sample, back-to-back, blowout risk, split conflicts.',
icon: '',
title: 'Kill conditions',
body: 'We don\'t just grade the prop. We tell you what kills it. Six hard checks per read: minutes, sample, fatigue, blowout risk, splits, line conflict.',
},
{
title: 'Bet Tracking',
description: 'Log every bet. Screenshot upload, quick slip, or manual entry. Track ROI and win rate over time.',
icon: '',
title: 'Parlay correlation math',
body: 'Phi-coefficient analysis catches the legs that secretly fight each other. The books love correlated unders. We surface them.',
},
{
title: 'Cascade Alerts',
description: 'Star player scratched? BetonBLK re-grades your affected parlays and alerts you instantly.',
icon: '',
title: 'ABS intelligence (MLB)',
body: 'The automated strike zone changes everything. Per-pitcher, per-batter discipline scoring. Zone 14 framing loss. Challenge math.',
},
{
icon: '◯',
title: 'Three sports, one engine',
body: 'NBA. MLB. WNBA. Unified intelligence layer with sport-specific calibration. NFL coming September 2026.',
},
{
icon: '⌦',
title: 'The honest ledger',
body: 'Every grade. Every result. No hiding. No deletion. Brier score and CLV from day one. Public accuracy by tier.',
},
];
export default function Features() {
return (
<section className="py-24 px-4 bg-[var(--card)]">
<div className="max-w-5xl mx-auto">
<h2 className="text-3xl font-bold text-center mb-4">Built for Serious Bettors</h2>
<p className="text-[var(--text-muted)] text-center mb-16 max-w-lg mx-auto">
Every feature exists because we needed it ourselves. No fluff.
</p>
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
{features.map((f) => (
<div key={f.title} className="p-5 rounded-xl border border-[var(--border)] bg-[var(--bg)]">
<h3 className="font-semibold mb-2">{f.title}</h3>
<p className="text-sm text-[var(--text-muted)] leading-relaxed">{f.description}</p>
<section
style={{
padding: '96px 24px',
borderTop: '1px solid var(--border)',
}}
>
<div style={{ maxWidth: 1200, margin: '0 auto' }}>
<header style={{ textAlign: 'center', maxWidth: 720, margin: '0 auto 64px' }}>
<h2
className="text-balance"
style={{
fontSize: 'clamp(28px, 4vw, 44px)',
fontWeight: 700,
letterSpacing: '-0.02em',
marginBottom: 16,
}}
>
One platform. Everything connected.
</h2>
<p style={{ fontSize: 17, color: 'var(--text-secondary)' }}>
Built by bettors who got tired of switching between five tabs to grade one prop.
</p>
</header>
<div
style={{
display: 'grid',
gap: 16,
}}
className="features-grid"
>
{FEATURES.map((f, i) => (
<div
key={f.title}
className={`surface surface-hover diagonal-cut animate-fade-up stagger-${(i % 6) + 1}`}
style={{ padding: 24 }}
>
<div
className="mono"
style={{
fontSize: 28,
color: 'var(--grade-a)',
marginBottom: 16,
lineHeight: 1,
}}
aria-hidden
>
{f.icon}
</div>
<h3 style={{ fontSize: 16, fontWeight: 600, marginBottom: 8 }}>{f.title}</h3>
<p style={{ fontSize: 14, color: 'var(--text-secondary)', lineHeight: 1.6 }}>{f.body}</p>
</div>
))}
</div>
</div>
<style jsx>{`
:global(.features-grid) {
grid-template-columns: 1fr;
}
@media (min-width: 640px) {
:global(.features-grid) {
grid-template-columns: repeat(2, 1fr);
}
}
@media (min-width: 1024px) {
:global(.features-grid) {
grid-template-columns: repeat(4, 1fr);
}
}
`}</style>
</section>
);
}
+136 -50
View File
@@ -1,62 +1,148 @@
'use client';
const PRIMARY_LINKS = [
{ label: 'Read', href: '/scan' },
{ label: 'Tracker', href: '/tracker' },
{ label: 'Ledger', href: '/ledger' },
{ label: 'Pricing', href: '/#pricing' },
{ label: 'Blog', href: '/blog' },
];
import { useState } from 'react';
const LEGAL_LINKS = [
{ label: 'Terms', href: '/terms' },
{ label: 'Privacy', href: '/privacy' },
{ label: 'Responsible Gambling', href: '/responsible-gambling' },
];
import Wordmark from '@/components/Wordmark';
const SOCIAL = [
{ label: 'Twitter', href: 'https://twitter.com/getvyndr' },
{ label: 'Discord', href: 'https://discord.gg/getvyndr' },
];
export default function Footer() {
const [email, setEmail] = useState('');
const [submitted, setSubmitted] = useState(false);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
// TODO: Store email in Supabase
setSubmitted(true);
};
return (
<footer className="py-16 px-4 border-t border-[var(--border)]">
<div className="max-w-5xl mx-auto">
<div className="grid md:grid-cols-2 gap-12 mb-12">
<div>
<h3 className="font-mono font-bold text-lg mb-2">
Beton<span className="text-[var(--accent)]">BLK</span>
</h3>
<p className="text-sm text-[var(--text-muted)] max-w-sm">
AI-powered parlay intelligence. Built by bettors, for bettors.
<footer
style={{
borderTop: '1px solid var(--border)',
padding: '64px 24px 32px',
marginTop: 64,
}}
>
<div style={{ maxWidth: 1200, margin: '0 auto' }}>
<div
style={{
display: 'grid',
gap: 48,
marginBottom: 48,
}}
className="footer-top"
>
<div style={{ maxWidth: 400 }}>
<a
href="/"
style={{ color: 'var(--text-0)', textDecoration: 'none', display: 'inline-flex', alignItems: 'center' }}
aria-label="VYNDR — home"
>
<Wordmark size={24} />
</a>
<p
style={{
marginTop: 12,
fontSize: 14,
color: 'var(--text-secondary)',
lineHeight: 1.6,
}}
>
The books have every advantage. We built this to give it back.
</p>
<p className="mono" style={{ marginTop: 16, fontSize: 12, color: 'var(--text-tertiary)' }}>
Built in Detroit.
</p>
</div>
<div>
<h4 className="font-semibold mb-3">Get early access + founder pricing</h4>
{submitted ? (
<p className="text-[var(--grade-a)] text-sm">You're in. We'll be in touch.</p>
) : (
<form onSubmit={handleSubmit} className="flex gap-2">
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="your@email.com"
required
className="flex-1 px-4 py-2 rounded-lg bg-[var(--card)] border border-[var(--border)] text-sm text-white placeholder:text-[var(--text-muted)] focus:outline-none focus:border-[var(--accent)]"
/>
<button
type="submit"
className="px-6 py-2 bg-[var(--accent)] text-white rounded-lg text-sm font-medium hover:opacity-90 transition"
>
Join
</button>
</form>
)}
</div>
<FooterColumn title="Product" links={PRIMARY_LINKS} />
<FooterColumn title="Legal" links={LEGAL_LINKS} />
<FooterColumn title="Community" links={SOCIAL} external />
</div>
<div className="flex justify-between items-center text-xs text-[var(--text-muted)] border-t border-[var(--border)] pt-6">
<span>2026 BetonBLK. All rights reserved.</span>
<div className="flex gap-4">
<a href="#" className="hover:text-white transition">Terms</a>
<a href="#" className="hover:text-white transition">Privacy</a>
<a href="#" className="hover:text-white transition">Twitter/X</a>
</div>
<div
style={{
borderTop: '1px solid var(--border)',
paddingTop: 24,
display: 'grid',
gap: 16,
}}
>
<p style={{ fontSize: 12, color: 'var(--text-tertiary)', lineHeight: 1.6 }}>
VYNDR is an analytics tool, not a sportsbook. We don&apos;t accept wagers. Gamble responsibly.
If you or someone you know has a gambling problem, call <strong style={{ color: 'var(--text-secondary)' }}>1-800-522-4700</strong>{' '}
or visit <a href="https://www.ncpgambling.org" target="_blank" rel="noopener noreferrer" style={{ color: 'var(--grade-a)' }}>ncpgambling.org</a>.
</p>
<p className="mono" style={{ fontSize: 11, color: 'var(--text-tertiary)' }}>
© 2026 VYNDR. All rights reserved.
</p>
</div>
</div>
<style jsx>{`
:global(.footer-top) {
grid-template-columns: 1fr;
}
@media (min-width: 768px) {
:global(.footer-top) {
grid-template-columns: 2fr 1fr 1fr 1fr;
}
}
`}</style>
</footer>
);
}
function FooterColumn({
title,
links,
external,
}: {
title: string;
links: { label: string; href: string }[];
external?: boolean;
}) {
return (
<div>
<h4
className="mono"
style={{
fontSize: 11,
fontWeight: 700,
letterSpacing: '0.08em',
textTransform: 'uppercase',
color: 'var(--text-tertiary)',
marginBottom: 16,
}}
>
{title}
</h4>
<ul style={{ display: 'grid', gap: 8 }}>
{links.map((l) => (
<li key={l.label}>
<a
href={l.href}
target={external ? '_blank' : undefined}
rel={external ? 'noopener noreferrer' : undefined}
style={{
color: 'var(--text-secondary)',
textDecoration: 'none',
fontSize: 14,
transition: 'color 200ms ease',
}}
onMouseEnter={(e) => (e.currentTarget.style.color = 'var(--text-primary)')}
onMouseLeave={(e) => (e.currentTarget.style.color = 'var(--text-secondary)')}
>
{l.label}
</a>
</li>
))}
</ul>
</div>
);
}
+527 -14
View File
@@ -1,21 +1,534 @@
const gradeColors: Record<string, string> = {
A: 'bg-[var(--grade-a)]/10 border-[var(--grade-a)] text-[var(--grade-a)]',
B: 'bg-[var(--grade-b)]/10 border-[var(--grade-b)] text-[var(--grade-b)]',
C: 'bg-[var(--grade-c)]/10 border-[var(--grade-c)] text-[var(--grade-c)]',
D: 'bg-[var(--grade-d)]/10 border-[var(--grade-d)] text-[var(--grade-d)]',
};
'use client';
import { useEffect, useMemo, useState } from 'react';
import ExplainTooltip from '@/components/ExplainTooltip';
import ExplainModeToggle from '@/components/ExplainModeToggle';
import { markReadComplete } from '@/lib/reads';
// Short, plain-English explanations rendered when Explain Like I'm New is on.
// Each key maps to one piece of data we surface on this card.
const EXPLANATIONS = {
grade: "Our overall confidence. A-minus means we estimate about a 76% chance this prop hits, based on 40+ factors.",
projection: "What our model predicts the player will actually do tonight for this stat.",
line: "The number the sportsbook set. The player needs to go over or under it.",
overUnder: 'Over = the player needs MORE than the line. Under = LESS.',
confidence: "How much data we have on this player and stat. More games = more reliable.",
killConditions: "Red flags we detected that could cause this prop to miss regardless of the stats.",
factors: "The signals our engine weighs — recent form, matchup, rest, usage, etc.",
} as const;
export type Sport = 'NBA' | 'MLB' | 'WNBA';
export type Tier = 'free' | 'analyst' | 'desk';
export interface KillCondition {
code: string;
reason: string;
}
export interface AltLine {
line: number;
grade: string;
hit_rate?: number;
edge_pct?: number;
}
export interface FactorAnalysis {
matchup?: string;
trend?: string;
usage?: string;
minutes?: string;
pace?: string;
rest?: string;
weather?: string;
abs?: string;
[key: string]: string | undefined;
}
export interface GradeCardProps {
sport: Sport;
player: string;
stat: string;
line: number;
direction: 'over' | 'under';
grade: string;
projection?: number;
confidence?: number;
sample_size?: number;
factors?: FactorAnalysis;
alt_lines?: AltLine[];
kill_conditions?: KillCondition[];
reasoning?: string;
historical_hit_rate?: number;
tier: Tier;
onUpgradeClick?: (target: 'analyst' | 'desk', from: string) => void;
onAddToParlay?: () => void;
onShare?: () => void;
trending?: boolean;
}
const SPORTSBOOKS = [
{ id: 'draftkings', label: 'DK', color: '#53D337', host: 'sportsbook.draftkings.com' },
{ id: 'fanduel', label: 'FD', color: '#1493FF', host: 'sportsbook.fanduel.com' },
{ id: 'betmgm', label: 'MGM', color: '#BB9959', host: 'sports.betmgm.com' },
{ id: 'caesars', label: 'Caesars', color: '#C8A35F', host: 'sportsbook.caesars.com' },
{ id: 'pointsbet', label: 'PB', color: '#E2231A', host: 'pointsbet.com' },
];
function gradeTierClass(grade: string): { color: string; bg: string; border: string } {
const g = (grade || '').trim().toUpperCase().charAt(0);
if (g === 'A') return { color: 'var(--grade-a)', bg: 'rgba(0,200,150,0.10)', border: 'rgba(0,200,150,0.40)' };
if (g === 'B') return { color: 'var(--grade-b)', bg: 'rgba(74,158,255,0.10)', border: 'rgba(74,158,255,0.40)' };
if (g === 'C') return { color: 'var(--grade-c)', bg: 'rgba(255,179,71,0.10)', border: 'rgba(255,179,71,0.40)' };
return { color: 'var(--grade-d)', bg: 'rgba(255,107,107,0.10)', border: 'rgba(255,107,107,0.40)' };
}
function confidenceLabel(sample?: number): { label: string; tone: 'high' | 'moderate' | 'limited' } {
const n = sample ?? 0;
if (n >= 30) return { label: `High confidence (${n} games)`, tone: 'high' };
if (n >= 12) return { label: `Moderate confidence (${n} games)`, tone: 'moderate' };
return { label: `Limited data (${Math.max(0, n)} games)`, tone: 'limited' };
}
function deepLink(host: string, player: string): string {
const slug = encodeURIComponent(player);
return `https://${host}/?search=${slug}`;
}
export default function GradeCard(props: GradeCardProps) {
const tone = gradeTierClass(props.grade);
const conf = confidenceLabel(props.sample_size);
const [revealed, setRevealed] = useState(false);
// Animate the grade letter on first paint
useEffect(() => {
const t = window.setTimeout(() => setRevealed(true), 50);
return () => window.clearTimeout(t);
}, [props.grade]);
// Mark this card as ONE read for the InstallPrompt / PushPrompt gates.
// GradeCardProps doesn't carry a server-side id, so build a stable
// composite key from the canonical identifying fields. Per-session
// dedupe — viewing the same prop twice in one session counts once.
useEffect(() => {
if (!revealed || typeof window === 'undefined') return;
const readKey = `vyndr_read_${props.sport}_${props.player}_${props.stat}_${props.line}_${props.direction}`;
if (!window.sessionStorage.getItem(readKey)) {
window.sessionStorage.setItem(readKey, '1');
markReadComplete();
}
}, [revealed, props.sport, props.player, props.stat, props.line, props.direction]);
const showFactors = props.tier !== 'free';
const showAltLines = props.tier === 'desk';
const sportBadge = useMemo(() => {
const s = props.sport;
if (s === 'NBA') return { color: '#E94B3C' };
if (s === 'MLB') return { color: '#1E90FF' };
return { color: '#FFB347' };
}, [props.sport]);
export default function GradeCard({ grade, confidence, label }: { grade: string; confidence?: number; label?: string }) {
const colors = gradeColors[grade] || gradeColors.D;
return (
<div className={`inline-flex items-center gap-3 px-4 py-2 rounded-xl border ${colors}`}>
<span className="font-mono font-bold text-3xl">{grade}</span>
{confidence != null && (
<div className="text-sm">
<div className="font-mono font-medium">{confidence}%</div>
{label && <div className="text-xs opacity-70">{label}</div>}
<article
className="surface diagonal-cut animate-fade-up"
style={{ padding: 24, maxWidth: 560, width: '100%' }}
aria-label={`Grade card for ${props.player}`}
>
{/* Header */}
<header style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 16 }}>
<div>
<div style={{ display: 'flex', gap: 8, alignItems: 'center', marginBottom: 4 }}>
<span
className="mono"
style={{
fontSize: 11,
fontWeight: 700,
padding: '2px 8px',
borderRadius: 999,
background: `${sportBadge.color}1F`,
color: sportBadge.color,
letterSpacing: '0.05em',
}}
>
{props.sport}
</span>
{props.trending && (
<span
className="mono"
title="Trending in parlays tonight"
style={{
fontSize: 11,
padding: '2px 8px',
borderRadius: 999,
background: 'rgba(255,179,71,0.15)',
color: 'var(--grade-c)',
}}
>
Trending in parlays
</span>
)}
</div>
<h3 style={{ fontSize: 18, fontWeight: 600, marginBottom: 2 }}>{props.player}</h3>
<ExplainTooltip explanation={EXPLANATIONS.overUnder}>
<p className="mono" style={{ fontSize: 13, color: 'var(--text-secondary)', textTransform: 'capitalize' }}>
{props.direction} {props.line} {props.stat.replace(/_/g, ' ')}
</p>
</ExplainTooltip>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<ExplainModeToggle variant="compact" />
{props.onShare && (
<button
onClick={props.onShare}
aria-label="Share grade"
className="btn-ghost"
style={{ padding: '6px 12px', fontSize: 12 }}
>
Share
</button>
)}
</div>
</header>
{/* Grade letter — the hero */}
<div style={{ display: 'flex', justifyContent: 'center', margin: '24px 0' }}>
<ExplainTooltip explanation={EXPLANATIONS.grade}>
<div
className={revealed ? 'animate-grade mono' : 'mono'}
style={{
fontSize: 72,
fontWeight: 800,
lineHeight: 1,
color: tone.color,
textShadow: `0 0 40px ${tone.color}33`,
padding: '16px 32px',
borderRadius: 16,
background: `radial-gradient(circle at center, ${tone.bg} 0%, transparent 70%)`,
letterSpacing: '-0.04em',
}}
>
{props.grade || '—'}
</div>
</ExplainTooltip>
</div>
{/* Projection + confidence */}
{(props.projection != null || props.sample_size != null) && (
<div
style={{
display: 'grid',
gridTemplateColumns: props.projection != null && props.sample_size != null ? '1fr 1fr' : '1fr',
gap: 12,
marginBottom: 16,
}}
>
{props.projection != null && (
<ExplainTooltip explanation={EXPLANATIONS.projection}>
<div className="surface" style={{ padding: '12px 16px', textAlign: 'center', borderRadius: 12 }}>
<div style={{ fontSize: 11, color: 'var(--text-secondary)', marginBottom: 4 }}>Projection</div>
<div className="mono" style={{ fontSize: 20, fontWeight: 700, color: 'var(--text-primary)' }}>
{props.projection.toFixed(1)} {props.stat.replace(/_/g, ' ')}
</div>
</div>
</ExplainTooltip>
)}
{props.sample_size != null && (
<ExplainTooltip explanation={EXPLANATIONS.confidence}>
<div className="surface" style={{ padding: '12px 16px', textAlign: 'center', borderRadius: 12 }}>
<div style={{ fontSize: 11, color: 'var(--text-secondary)', marginBottom: 4 }}>Confidence</div>
<div
className="mono"
style={{
fontSize: 13,
fontWeight: 600,
color: conf.tone === 'limited' ? 'var(--grade-c)' : 'var(--text-primary)',
}}
>
{conf.label}
</div>
</div>
</ExplainTooltip>
)}
</div>
)}
{/* Factor analysis — gated for free tier */}
<FactorBlock
factors={props.factors}
killConditions={props.kill_conditions}
gated={!showFactors}
onUpgrade={() => props.onUpgradeClick?.('analyst', 'grade_card_factors')}
/>
{/* Alt lines — gated for free + analyst */}
<AltLineBlock
altLines={props.alt_lines}
gated={!showAltLines}
currentTier={props.tier}
onUpgrade={() => props.onUpgradeClick?.('desk', 'grade_card_alt_lines')}
/>
{/* Reasoning */}
{props.reasoning && (
<div style={{ marginTop: 16 }}>
<SectionLabel>Model reasoning</SectionLabel>
{showFactors ? (
<p style={{ fontSize: 14, color: 'var(--text-secondary)', lineHeight: 1.6 }}>{props.reasoning}</p>
) : (
<BlurredText text={props.reasoning} onUpgrade={() => props.onUpgradeClick?.('analyst', 'grade_card_reasoning')} />
)}
</div>
)}
{/* Historical accuracy */}
{props.historical_hit_rate != null && (
<p style={{ marginTop: 12, fontSize: 12, color: 'var(--text-tertiary)' }} className="mono">
{props.grade} grades hit at {Math.round(props.historical_hit_rate * 100)}% historically.
</p>
)}
{/* Sportsbook deep links */}
<div style={{ marginTop: 20, display: 'flex', flexWrap: 'wrap', gap: 8 }}>
{SPORTSBOOKS.map((book) => (
<a
key={book.id}
href={deepLink(book.host, props.player)}
target="_blank"
rel="noopener noreferrer"
className="mono"
style={{
padding: '6px 12px',
fontSize: 11,
fontWeight: 700,
borderRadius: 999,
border: `1px solid ${book.color}66`,
color: book.color,
textDecoration: 'none',
}}
>
{book.label}
</a>
))}
</div>
{/* Actions */}
{props.onAddToParlay && (
<div style={{ marginTop: 16, display: 'flex', gap: 8 }}>
<button onClick={props.onAddToParlay} className="btn-primary" style={{ flex: 1 }}>
Add to Parlay
</button>
</div>
)}
</article>
);
}
function SectionLabel({ children }: { children: React.ReactNode }) {
return (
<div
className="mono"
style={{
fontSize: 11,
fontWeight: 700,
color: 'var(--text-tertiary)',
textTransform: 'uppercase',
letterSpacing: '0.08em',
marginBottom: 8,
}}
>
{children}
</div>
);
}
function FactorBlock({
factors,
killConditions,
gated,
onUpgrade,
}: {
factors?: FactorAnalysis;
killConditions?: KillCondition[];
gated: boolean;
onUpgrade: () => void;
}) {
const hasContent = (factors && Object.values(factors).some(Boolean)) || (killConditions && killConditions.length > 0);
if (!hasContent && !gated) return null;
return (
<div style={{ position: 'relative', marginTop: 16 }}>
<SectionLabel>Factor analysis</SectionLabel>
<div className={gated ? 'tier-locked' : ''} aria-hidden={gated}>
{factors && (
<ul style={{ display: 'grid', gap: 6, marginBottom: killConditions?.length ? 12 : 0 }}>
{Object.entries(factors)
.filter(([, v]) => Boolean(v))
.map(([k, v]) => (
<li
key={k}
style={{ display: 'flex', justifyContent: 'space-between', fontSize: 13, gap: 12 }}
>
<span style={{ color: 'var(--text-tertiary)', textTransform: 'capitalize' }}>{k}</span>
<span style={{ color: 'var(--text-primary)', textAlign: 'right' }}>{v}</span>
</li>
))}
</ul>
)}
{killConditions && killConditions.length > 0 && (
<div
style={{
padding: 12,
borderRadius: 12,
border: '1px solid rgba(255,71,87,0.30)',
background: 'rgba(255,71,87,0.08)',
}}
>
<div className="mono" style={{ fontSize: 11, fontWeight: 700, color: 'var(--danger)', marginBottom: 6 }}>
KILL CONDITIONS
</div>
{killConditions.map((k) => (
<div key={k.code} style={{ display: 'flex', gap: 8, fontSize: 13, marginTop: 4 }}>
<span className="mono" style={{ color: 'var(--danger)', fontWeight: 700, fontSize: 11 }}>
{k.code}
</span>
<span style={{ color: 'var(--text-primary)' }}>{k.reason}</span>
</div>
))}
</div>
)}
{!hasContent && gated && (
<div style={{ height: 120, background: 'var(--bg-elevated)', borderRadius: 12 }} />
)}
</div>
{gated && (
<div className="tier-locked-overlay">
<p style={{ fontSize: 14, fontWeight: 600, color: 'var(--text-primary)' }}>
Full analysis. Kill conditions. Alt lines.
</p>
<button onClick={onUpgrade} className="btn-primary">
Unlock \$14.99/mo
</button>
</div>
)}
</div>
);
}
function AltLineBlock({
altLines,
gated,
currentTier,
onUpgrade,
}: {
altLines?: AltLine[];
gated: boolean;
currentTier: Tier;
onUpgrade: () => void;
}) {
if (!altLines || altLines.length === 0) {
if (currentTier === 'free') return null;
return null;
}
return (
<div style={{ position: 'relative', marginTop: 16 }}>
<SectionLabel>Alt line ladder</SectionLabel>
<div className={gated ? 'tier-locked' : ''} aria-hidden={gated}>
<div style={{ display: 'grid', gap: 6 }}>
{altLines.map((alt) => {
const altTone = gradeTierClass(alt.grade);
return (
<div
key={alt.line}
style={{
display: 'grid',
gridTemplateColumns: '1fr auto auto',
gap: 12,
alignItems: 'center',
padding: '8px 12px',
background: 'var(--bg-elevated)',
borderRadius: 8,
}}
>
<span className="mono" style={{ fontSize: 14, color: 'var(--text-primary)' }}>
{alt.line.toFixed(1)}
</span>
<span
className="mono"
style={{
fontSize: 12,
fontWeight: 700,
padding: '2px 8px',
borderRadius: 999,
color: altTone.color,
background: altTone.bg,
}}
>
{alt.grade}
</span>
<span className="mono" style={{ fontSize: 12, color: 'var(--text-secondary)' }}>
{alt.hit_rate != null ? `${Math.round(alt.hit_rate * 100)}%` : '—'}
</span>
</div>
);
})}
</div>
</div>
{gated && (
<div className="tier-locked-overlay">
<p style={{ fontSize: 14, fontWeight: 600, color: 'var(--text-primary)' }}>
Alt line ladder + Kelly sizing.
</p>
<button onClick={onUpgrade} className="btn-primary">
Go Desk $44.99/mo
</button>
</div>
)}
</div>
);
}
function BlurredText({ text, onUpgrade }: { text: string; onUpgrade: () => void }) {
return (
<div style={{ position: 'relative' }}>
<p className="tier-locked" style={{ fontSize: 14, color: 'var(--text-secondary)', lineHeight: 1.6 }}>
{text}
</p>
<button
onClick={onUpgrade}
className="btn-primary"
style={{ position: 'absolute', top: '50%', left: '50%', transform: 'translate(-50%, -50%)' }}
>
Unlock \$14.99/mo
</button>
</div>
);
}
/* ─────────────────────────────────────────────────────────
Lightweight grade pill — back-compat for callers that only
want the colored letter (used by ledger/scan summaries)
───────────────────────────────────────────────────────── */
export function GradePill({ grade, confidence }: { grade: string; confidence?: number }) {
const tone = gradeTierClass(grade);
return (
<div
className="mono"
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 8,
padding: '6px 12px',
borderRadius: 12,
border: `1px solid ${tone.border}`,
background: tone.bg,
color: tone.color,
fontWeight: 700,
}}
>
<span style={{ fontSize: 22, lineHeight: 1 }}>{grade}</span>
{confidence != null && <span style={{ fontSize: 12, opacity: 0.85 }}>{confidence}%</span>}
</div>
);
}
+261 -16
View File
@@ -1,22 +1,267 @@
'use client';
import { GradePill } from './GradeCard';
export default function Hero() {
return (
<section className="relative min-h-[85vh] flex items-center justify-center px-4">
<div className="max-w-3xl text-center">
<h1 className="text-5xl md:text-7xl font-bold tracking-tight mb-6">
Stop guessing.<br />
<span className="text-[var(--accent)]">Start grading.</span>
</h1>
<p className="text-lg md:text-xl text-[var(--text-muted)] mb-10 max-w-xl mx-auto">
BetonBLK scans your parlay in seconds. AI-powered prop analysis across DraftKings, FanDuel, and BetMGM.
</p>
<a
href="/scan"
className="inline-block px-8 py-4 bg-[var(--accent)] text-white font-semibold rounded-xl text-lg hover:opacity-90 transition"
>
Scan Your First Parlay Free
</a>
<p className="mt-4 text-sm text-[var(--text-muted)]">5 free scans. No credit card required.</p>
<section className="radial-glow diagonal-cut" style={{ position: 'relative', overflow: 'hidden' }}>
<div
style={{
maxWidth: 1200,
margin: '0 auto',
padding: '96px 24px 64px',
display: 'grid',
gap: 48,
alignItems: 'center',
}}
className="hero-grid"
>
<div className="animate-fade-up" style={{ maxWidth: 800 }}>
<span
className="mono"
style={{
display: 'inline-block',
padding: '4px 12px',
fontSize: 11,
fontWeight: 700,
letterSpacing: '0.10em',
borderRadius: 999,
background: 'var(--accent-glow)',
color: 'var(--grade-a)',
marginBottom: 24,
textTransform: 'uppercase',
}}
>
NBA · MLB · WNBA
</span>
<h1
className="text-balance"
style={{
fontSize: 'clamp(36px, 6vw, 64px)',
fontWeight: 700,
letterSpacing: '-0.03em',
lineHeight: 1.05,
marginBottom: 20,
}}
>
The books have every advantage.<br />
<span style={{ color: 'var(--grade-a)' }}>We built this to give it back.</span>
</h1>
<p
className="text-pretty"
style={{
fontSize: 18,
color: 'var(--text-secondary)',
lineHeight: 1.6,
marginBottom: 32,
maxWidth: 600,
}}
>
Grade your NBA, MLB, and WNBA props with intelligence the books don&apos;t want you to have.
Forty-plus factors. Kill conditions. Alt-line ladders. The honest ledger.
</p>
<SportBadgeStrip />
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap', marginTop: 24 }}>
<a href="/signup" className="btn-primary" style={{ padding: '14px 28px', fontSize: 15 }}>
Get Started Free
</a>
<a href="/ledger" className="btn-ghost" style={{ padding: '14px 28px', fontSize: 15 }}>
See the Ledger
</a>
</div>
<p style={{ fontSize: 13, color: 'var(--text-tertiary)', marginTop: 16 }}>
5 free reads every month. No credit card. Cancel anytime.
</p>
</div>
<FloatingDemoCard />
</div>
<p
style={{
textAlign: 'center',
fontSize: 12,
color: 'var(--text-tertiary)',
padding: '0 24px 32px',
maxWidth: 600,
margin: '0 auto',
}}
>
VYNDR is an analytics tool, not a sportsbook. Gamble responsibly. 1-800-522-4700.
</p>
<style jsx>{`
@media (min-width: 960px) {
:global(.hero-grid) {
grid-template-columns: 1.4fr 1fr;
}
}
`}</style>
</section>
);
}
const SPORTS_DISPLAY = [
{ label: 'NBA', active: true, color: '#E94B3C' },
{ label: 'MLB', active: true, color: '#1E90FF' },
{ label: 'WNBA', active: true, color: '#F7944A' },
{ label: 'NFL', active: false, color: '#013369' },
{ label: 'NHL', active: false, color: '#A0A0B0' },
{ label: 'TENNIS', active: false, color: '#C5B358' },
{ label: 'MMA', active: false, color: '#D4AF37' },
{ label: 'BOXING', active: false, color: '#8B0000' },
{ label: 'GOLF', active: false, color: '#2E7D32' },
];
function SportBadgeStrip() {
return (
<div
role="list"
aria-label="Supported sports"
style={{
display: 'flex',
gap: 8,
flexWrap: 'wrap',
marginTop: 16,
marginBottom: 8,
maxWidth: 640,
}}
>
{SPORTS_DISPLAY.map((s) => {
const base: React.CSSProperties = {
fontFamily: 'IBM Plex Mono, JetBrains Mono, monospace',
fontSize: 11,
fontWeight: 700,
letterSpacing: '0.08em',
padding: '5px 11px',
borderRadius: 999,
textTransform: 'uppercase',
whiteSpace: 'nowrap',
};
return s.active ? (
<span
key={s.label}
role="listitem"
style={{
...base,
color: s.color,
background: `${s.color}1A`,
border: `1px solid ${s.color}66`,
boxShadow: `0 0 12px ${s.color}33`,
}}
>
{s.label}
</span>
) : (
<span
key={s.label}
role="listitem"
title="COMING THIS SUMMER"
aria-label={`${s.label} — coming this summer`}
style={{
...base,
color: 'var(--text-2)',
background: 'transparent',
border: '1px solid var(--border)',
}}
>
{s.label}
</span>
);
})}
</div>
);
}
function FloatingDemoCard() {
return (
<div
className="animate-fade-up stagger-3"
style={{
position: 'relative',
transform: 'rotate(-1deg)',
padding: 24,
background: 'var(--bg-elevated)',
border: '1px solid var(--border-focus)',
borderRadius: 20,
boxShadow: '0 24px 64px rgba(0,0,0,0.6), 0 0 0 1px var(--accent-glow)',
maxWidth: 380,
marginInline: 'auto',
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
<div>
<span
className="mono"
style={{
fontSize: 11,
padding: '2px 8px',
borderRadius: 999,
background: 'rgba(233,75,60,0.15)',
color: '#E94B3C',
fontWeight: 700,
}}
>
NBA
</span>
<h3 style={{ fontSize: 16, fontWeight: 600, marginTop: 8 }}>Nikola Jokic</h3>
<p className="mono" style={{ fontSize: 12, color: 'var(--text-secondary)' }}>
Over 26.5 points
</p>
</div>
<GradePill grade="A-" confidence={73} />
</div>
<div style={{ display: 'flex', gap: 8, marginBottom: 12 }}>
<Stat label="Projection" value="29.4 pts" />
<Stat label="Edge" value="+6.2%" tone="positive" />
</div>
<ul style={{ display: 'grid', gap: 6, fontSize: 12, color: 'var(--text-secondary)' }}>
<li style={row}>
<span>Matchup</span>
<span style={{ color: 'var(--text-primary)' }}>LAL · 26th vs C</span>
</li>
<li style={row}>
<span>L10 form</span>
<span style={{ color: 'var(--text-primary)' }}>27.4 / 7 of 10</span>
</li>
<li style={row}>
<span>Usage shift</span>
<span style={{ color: 'var(--grade-a)' }}>+3.2% w/o Murray</span>
</li>
</ul>
</div>
);
}
const row: React.CSSProperties = {
display: 'flex',
justifyContent: 'space-between',
paddingBlock: 4,
borderBottom: '1px solid var(--border)',
};
function Stat({ label, value, tone }: { label: string; value: string; tone?: 'positive' }) {
return (
<div
style={{
flex: 1,
padding: '8px 12px',
background: 'var(--bg-surface)',
borderRadius: 10,
textAlign: 'center',
}}
>
<div style={{ fontSize: 10, color: 'var(--text-tertiary)' }}>{label}</div>
<div
className="mono"
style={{
fontSize: 14,
fontWeight: 700,
color: tone === 'positive' ? 'var(--grade-a)' : 'var(--text-primary)',
}}
>
{value}
</div>
</div>
);
}
+64 -19
View File
@@ -1,36 +1,81 @@
const steps = [
const STEPS = [
{
number: '01',
title: 'Build your parlay',
description: 'Add your legs — player, stat, line, book. 2 to 12 props.',
n: '01',
title: 'Read a prop',
body: 'Pick a sport. Find the player. Set the line. We grade it in seconds across forty-plus factors.',
},
{
number: '02',
title: 'Get your grade',
description: 'Each leg graded A through D. Overall parlay grade with correlation checks.',
n: '02',
title: 'Read the grade',
body: 'Letter grade. Projection. Confidence. Factor breakdown. Kill conditions. Alt line ladder. The whole picture.',
},
{
number: '03',
title: 'See the edge',
description: 'Season averages, recent form, situational splits, cross-book line comparison. Every factor explained.',
n: '03',
title: 'Make the call',
body: 'Take the prop, walk away, or shop the alt line. Either way, you decided with intelligence — not vibes.',
},
];
export default function HowItWorks() {
return (
<section className="py-24 px-4">
<div className="max-w-5xl mx-auto">
<h2 className="text-3xl font-bold text-center mb-16">How It Works</h2>
<div className="grid md:grid-cols-3 gap-8">
{steps.map((step) => (
<div key={step.number} className="p-6 rounded-2xl bg-[var(--card)] border border-[var(--border)]">
<div className="font-mono text-[var(--accent)] text-sm font-bold mb-3">{step.number}</div>
<h3 className="text-xl font-semibold mb-2">{step.title}</h3>
<p className="text-[var(--text-muted)] text-sm leading-relaxed">{step.description}</p>
<section
style={{
padding: '96px 24px',
borderTop: '1px solid var(--border)',
}}
>
<div style={{ maxWidth: 1100, margin: '0 auto' }}>
<header style={{ textAlign: 'center', maxWidth: 720, margin: '0 auto 64px' }}>
<h2
className="text-balance"
style={{ fontSize: 'clamp(28px, 4vw, 44px)', fontWeight: 700, letterSpacing: '-0.02em', marginBottom: 16 }}
>
How it works.
</h2>
<p style={{ fontSize: 17, color: 'var(--text-secondary)' }}>
Three steps. No tout picks. No black box.
</p>
</header>
<div className="hiw-grid" style={{ display: 'grid', gap: 24, position: 'relative' }}>
{STEPS.map((s, i) => (
<div
key={s.n}
className={`surface diagonal-cut animate-fade-up stagger-${i + 1}`}
style={{
padding: 32,
position: 'relative',
}}
>
<div
className="mono"
style={{
fontSize: 12,
fontWeight: 700,
color: 'var(--grade-a)',
letterSpacing: '0.10em',
marginBottom: 24,
}}
>
STEP {s.n}
</div>
<h3 style={{ fontSize: 22, fontWeight: 700, marginBottom: 12, letterSpacing: '-0.01em' }}>{s.title}</h3>
<p style={{ fontSize: 15, color: 'var(--text-secondary)', lineHeight: 1.6 }}>{s.body}</p>
</div>
))}
</div>
</div>
<style jsx>{`
:global(.hiw-grid) {
grid-template-columns: 1fr;
}
@media (min-width: 768px) {
:global(.hiw-grid) {
grid-template-columns: repeat(3, 1fr);
}
}
`}</style>
</section>
);
}
+141
View File
@@ -0,0 +1,141 @@
'use client';
import { useEffect, useState } from 'react';
// PWA install banner. Shown only after the user has completed ≥2 Reads — we
// don't want to nag visitors before they've seen the product work. Trigger
// counter is incremented elsewhere via incrementReadCount() in lib/reads.ts.
type BeforeInstallPromptEvent = Event & {
prompt: () => Promise<void>;
userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>;
};
const READS_KEY = 'vyndr_reads_completed';
const DISMISSED_KEY = 'vyndr_install_dismissed';
const REQUIRED_READS = 2;
const DISMISSAL_COOLDOWN_DAYS = 7;
function isStandalone(): boolean {
if (typeof window === 'undefined') return false;
if (window.matchMedia('(display-mode: standalone)').matches) return true;
// iOS Safari exposes navigator.standalone only for installed PWAs.
const nav = window.navigator as Navigator & { standalone?: boolean };
return nav.standalone === true;
}
function isIOS(): boolean {
if (typeof window === 'undefined') return false;
const ua = window.navigator.userAgent;
return /iPad|iPhone|iPod/.test(ua) && !(window as unknown as { MSStream?: unknown }).MSStream;
}
function readsCompleted(): number {
if (typeof window === 'undefined') return 0;
const raw = window.localStorage.getItem(READS_KEY);
return raw ? parseInt(raw, 10) || 0 : 0;
}
function dismissedRecently(): boolean {
if (typeof window === 'undefined') return false;
const raw = window.localStorage.getItem(DISMISSED_KEY);
if (!raw) return false;
const ts = parseInt(raw, 10);
if (!ts) return false;
const ageDays = (Date.now() - ts) / (1000 * 60 * 60 * 24);
return ageDays < DISMISSAL_COOLDOWN_DAYS;
}
export default function InstallPrompt() {
const [deferred, setDeferred] = useState<BeforeInstallPromptEvent | null>(null);
const [visible, setVisible] = useState(false);
const [iosHint, setIosHint] = useState(false);
useEffect(() => {
if (isStandalone()) return;
if (readsCompleted() < REQUIRED_READS) return;
if (dismissedRecently()) return;
if (isIOS()) {
// iOS doesn't fire beforeinstallprompt — show manual instructions.
setIosHint(true);
setVisible(true);
return;
}
const onBeforeInstall = (e: Event) => {
e.preventDefault();
setDeferred(e as BeforeInstallPromptEvent);
setVisible(true);
};
window.addEventListener('beforeinstallprompt', onBeforeInstall);
return () => window.removeEventListener('beforeinstallprompt', onBeforeInstall);
}, []);
const handleInstall = async () => {
if (!deferred) return;
await deferred.prompt();
const choice = await deferred.userChoice;
if (choice.outcome === 'dismissed') {
window.localStorage.setItem(DISMISSED_KEY, String(Date.now()));
}
setVisible(false);
setDeferred(null);
};
const handleDismiss = () => {
window.localStorage.setItem(DISMISSED_KEY, String(Date.now()));
setVisible(false);
};
if (!visible) return null;
return (
<div
role="dialog"
aria-label="Install VYNDR"
className="fixed bottom-4 left-4 right-4 z-50 mx-auto max-w-md rounded-lg border p-4 shadow-lg"
style={{
background: 'var(--bg-surface)',
borderColor: 'var(--border-light)',
color: 'var(--text-primary)',
}}
>
<div className="flex items-start justify-between gap-3">
<div className="flex-1">
<div className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>
Install VYNDR
</div>
<div className="mt-1 text-xs" style={{ color: 'var(--text-secondary)' }}>
{iosHint
? 'Tap the Share button, then "Add to Home Screen" for instant access.'
: 'Add VYNDR to your home screen for instant access.'}
</div>
</div>
<button
type="button"
onClick={handleDismiss}
aria-label="Dismiss install prompt"
className="rounded p-1 text-xs"
style={{ color: 'var(--text-tertiary)' }}
>
</button>
</div>
{!iosHint && (
<button
type="button"
onClick={handleInstall}
className="mt-3 w-full rounded px-3 py-2 text-sm font-semibold"
style={{
background: 'var(--grade-a)',
color: 'var(--bg-0)',
}}
>
Install
</button>
)}
</div>
);
}
+148
View File
@@ -0,0 +1,148 @@
'use client';
import { useEffect, useState } from 'react';
interface LiveProp {
player: string;
stat: string;
line: number;
direction: string;
grade: string;
sport: string;
}
const SPORT_COLOR: Record<string, string> = {
NBA: '#E94B3C',
MLB: '#1E90FF',
WNBA: '#FFB347',
};
function gradeColor(grade: string): string {
const g = (grade || '').trim().toUpperCase().charAt(0);
if (g === 'A') return 'var(--grade-a)';
if (g === 'B') return 'var(--grade-b)';
if (g === 'C') return 'var(--grade-c)';
return 'var(--grade-d)';
}
export default function LivePropsStrip() {
const [props, setProps] = useState<LiveProp[] | null>(null);
const [error, setError] = useState(false);
useEffect(() => {
let cancelled = false;
async function load() {
try {
const res = await fetch('/api/props/live');
if (!res.ok) {
if (!cancelled) setError(true);
return;
}
const data = await res.json();
if (cancelled) return;
if (Array.isArray(data) && data.length > 0) {
setProps(data.slice(0, 12));
setError(false);
} else {
setProps([]);
}
} catch {
if (!cancelled) setError(true);
}
}
load();
const id = setInterval(load, 60_000);
return () => {
cancelled = true;
clearInterval(id);
};
}, []);
// Loading / fallback state
if (props === null) return null;
if (error || props.length === 0) {
return (
<section
style={{
padding: '24px',
borderTop: '1px solid var(--border)',
borderBottom: '1px solid var(--border)',
background: 'var(--bg-surface)',
}}
>
<p
className="mono"
style={{
textAlign: 'center',
fontSize: 12,
color: 'var(--text-tertiary)',
letterSpacing: '0.08em',
}}
>
TONIGHT&apos;S GRADES LOAD AT 5 PM ET
</p>
</section>
);
}
// Duplicate for seamless ticker scroll
const ticker = [...props, ...props];
return (
<section
style={{
padding: '20px 0',
borderTop: '1px solid var(--border)',
borderBottom: '1px solid var(--border)',
background: 'var(--bg-surface)',
overflow: 'hidden',
}}
>
<div className="animate-ticker" style={{ display: 'flex', gap: 16, whiteSpace: 'nowrap', width: 'max-content' }}>
{ticker.map((p, i) => (
<div
key={`${p.player}-${i}`}
style={{
display: 'inline-flex',
gap: 12,
alignItems: 'center',
padding: '8px 16px',
background: 'var(--bg-elevated)',
border: '1px solid var(--border)',
borderRadius: 12,
}}
>
<span
className="mono"
style={{
fontSize: 10,
fontWeight: 700,
padding: '2px 6px',
borderRadius: 999,
color: SPORT_COLOR[p.sport] || 'var(--text-secondary)',
background: `${SPORT_COLOR[p.sport] || 'var(--text-secondary)'}1F`,
}}
>
{p.sport}
</span>
<span style={{ fontSize: 13, fontWeight: 600 }}>{p.player}</span>
<span className="mono" style={{ fontSize: 12, color: 'var(--text-secondary)' }}>
{p.direction} {p.line} {p.stat}
</span>
<span
className="mono"
style={{
fontSize: 16,
fontWeight: 800,
color: gradeColor(p.grade),
}}
>
{p.grade}
</span>
</div>
))}
</div>
</section>
);
}
+147
View File
@@ -0,0 +1,147 @@
'use client';
import { useCallback, useEffect, useState } from 'react';
import { useAuth } from '@/contexts/AuthContext';
import { getBrowserSupabase } from '@/lib/supabase';
// Mounts at the root layout. Checks Supabase's AAL (Authenticator Assurance
// Level) after every auth state change. If the user has MFA enrolled but
// their session is still at aal1, we block the UI with a code challenge
// until they reach aal2. Until verified, they can't see paid features.
type ChallengeState =
| { status: 'idle' }
| { status: 'needed'; factorId: string }
| { status: 'verifying'; factorId: string; challengeId: string };
export default function MFAChallenge() {
const { user, session } = useAuth();
const [state, setState] = useState<ChallengeState>({ status: 'idle' });
const [code, setCode] = useState('');
const [error, setError] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
const evaluate = useCallback(async () => {
const supabase = getBrowserSupabase();
if (!supabase || !user) {
setState({ status: 'idle' });
return;
}
const aal = await supabase.auth.mfa.getAuthenticatorAssuranceLevel();
if (aal.error) return;
const { currentLevel, nextLevel } = aal.data;
if (currentLevel === 'aal1' && nextLevel === 'aal2') {
const { data } = await supabase.auth.mfa.listFactors();
const factor = (data?.totp ?? []).find((f) => f.status === 'verified');
if (factor) {
setState({ status: 'needed', factorId: factor.id });
return;
}
}
setState({ status: 'idle' });
}, [user]);
useEffect(() => {
void evaluate();
}, [evaluate, session?.access_token]);
const issueChallenge = async () => {
if (state.status !== 'needed') return;
const supabase = getBrowserSupabase();
if (!supabase) return;
const { data, error: cErr } = await supabase.auth.mfa.challenge({ factorId: state.factorId });
if (cErr || !data) {
setError(cErr?.message ?? 'Could not start MFA challenge.');
return;
}
setState({ status: 'verifying', factorId: state.factorId, challengeId: data.id });
};
const submit = async () => {
if (state.status !== 'verifying') return;
const supabase = getBrowserSupabase();
if (!supabase) return;
setSubmitting(true);
setError(null);
try {
const res = await supabase.auth.mfa.verify({
factorId: state.factorId,
challengeId: state.challengeId,
code,
});
if (res.error) {
setError(res.error.message);
return;
}
setCode('');
setState({ status: 'idle' });
} finally {
setSubmitting(false);
}
};
if (state.status === 'idle') return null;
return (
<div
role="dialog"
aria-modal="true"
aria-label="Two-factor authentication required"
className="fixed inset-0 z-[60] flex items-center justify-center bg-black/85 p-4"
>
<div
className="w-full max-w-sm rounded-lg border p-5"
style={{ background: 'var(--bg-surface)', borderColor: 'var(--border-light)' }}
>
<h2 className="text-lg font-semibold" style={{ color: 'var(--text-primary)' }}>
Two-factor required
</h2>
<p className="mt-2 text-sm" style={{ color: 'var(--text-secondary)' }}>
Enter the 6-digit code from your authenticator app.
</p>
<input
inputMode="numeric"
pattern="[0-9]*"
maxLength={6}
autoFocus
value={code}
onChange={(e) => setCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
className="mt-4 w-full rounded border px-3 py-2 text-center text-lg tracking-widest"
style={{
background: 'var(--bg-elevated)',
borderColor: 'var(--border-light)',
color: 'var(--text-primary)',
}}
placeholder="123456"
/>
{error && (
<p className="mt-2 text-xs" style={{ color: 'var(--grade-d)' }}>
{error}
</p>
)}
<div className="mt-4 flex gap-2">
{state.status === 'needed' ? (
<button
type="button"
onClick={issueChallenge}
className="w-full rounded px-4 py-2 text-sm font-semibold"
style={{ background: 'var(--grade-a)', color: 'var(--bg-0)' }}
>
Continue
</button>
) : (
<button
type="button"
onClick={submit}
disabled={code.length !== 6 || submitting}
className="w-full rounded px-4 py-2 text-sm font-semibold disabled:opacity-50"
style={{ background: 'var(--grade-a)', color: 'var(--bg-0)' }}
>
{submitting ? 'Verifying…' : 'Verify'}
</button>
)}
</div>
</div>
</div>
);
}
+76
View File
@@ -0,0 +1,76 @@
'use client';
import { useEffect, useState } from 'react';
import Link from 'next/link';
import { useAuth } from '@/contexts/AuthContext';
// One-time nag for paid users who haven't been told about MFA yet.
// The actual enrollment happens on /settings/security — this modal just
// directs them there. We mark prompted=true regardless of action so we
// don't pester users who explicitly chose "later".
export default function MFAPrompt() {
const { user, tier, profile, markMFAPrompted } = useAuth();
const [open, setOpen] = useState(false);
useEffect(() => {
if (!user || !profile) return;
if (tier === 'free') return;
if (profile.mfa_setup_prompted) return;
setOpen(true);
}, [user, tier, profile]);
if (!open) return null;
const handleLater = async () => {
setOpen(false);
await markMFAPrompted();
};
const tierLabel = tier === 'desk' ? 'Desk' : 'Analyst';
return (
<div
role="dialog"
aria-modal="true"
aria-label="Secure your account"
className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 p-4"
>
<div
className="w-full max-w-md rounded-lg border p-5"
style={{
background: 'var(--bg-surface)',
borderColor: 'var(--border-light)',
color: 'var(--text-primary)',
}}
>
<h2 className="text-lg font-semibold">Secure your {tierLabel} account</h2>
<p className="mt-2 text-sm" style={{ color: 'var(--text-secondary)' }}>
Two-factor authentication takes 60 seconds and protects your subscription, billing details,
and Ledger history from password-only attacks.
</p>
<div className="mt-5 flex justify-end gap-2">
<button
type="button"
onClick={handleLater}
className="rounded border px-4 py-2 text-sm font-semibold"
style={{ borderColor: 'var(--border-light)', color: 'var(--text-secondary)' }}
>
Remind me later
</button>
<Link
href="/settings/security"
onClick={() => {
void markMFAPrompted();
setOpen(false);
}}
className="rounded px-4 py-2 text-sm font-semibold"
style={{ background: 'var(--grade-a)', color: 'var(--bg-0)' }}
>
Set up now
</Link>
</div>
</div>
</div>
);
}
+259
View File
@@ -0,0 +1,259 @@
'use client';
import { useState } from 'react';
import { useAuth } from '@/contexts/AuthContext';
import Wordmark from '@/components/Wordmark';
import NotificationBell from '@/components/NotificationBell';
const NAV_LINKS = [
{ label: 'Read', href: '/scan' },
{ label: 'Tracker', href: '/tracker' },
{ label: 'Ledger', href: '/ledger' },
{ label: 'Pricing', href: '/#pricing' },
{ label: 'Blog', href: '/blog' },
];
export default function Nav() {
const { user, tier, scansRemaining, signOut } = useAuth();
const [mobileOpen, setMobileOpen] = useState(false);
const [menuOpen, setMenuOpen] = useState(false);
return (
<nav
style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
zIndex: 50,
height: 64,
borderBottom: '1px solid var(--border)',
background: 'rgba(10, 10, 15, 0.85)',
backdropFilter: 'blur(12px)',
WebkitBackdropFilter: 'blur(12px)',
}}
>
<div
style={{
maxWidth: 1280,
margin: '0 auto',
padding: '0 24px',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 24,
}}
>
<a
href="/"
style={{ color: 'var(--text-0)', textDecoration: 'none', display: 'inline-flex', alignItems: 'center' }}
aria-label="VYNDR — home"
>
<Wordmark size={22} />
</a>
<div className="nav-desktop" style={{ display: 'none', gap: 28, alignItems: 'center' }}>
{NAV_LINKS.map((l) => (
<a
key={l.href}
href={l.href}
style={{
fontSize: 14,
color: 'var(--text-secondary)',
textDecoration: 'none',
transition: 'color 200ms ease',
}}
onMouseEnter={(e) => (e.currentTarget.style.color = 'var(--text-primary)')}
onMouseLeave={(e) => (e.currentTarget.style.color = 'var(--text-secondary)')}
>
{l.label}
</a>
))}
{user ? (
<div style={{ position: 'relative', display: 'inline-flex', alignItems: 'center', gap: 10 }}>
{scansRemaining != null && tier === 'free' && (
<span
className="mono"
style={{
fontSize: 12,
color: scansRemaining <= 1 ? 'var(--grade-c)' : 'var(--text-secondary)',
}}
>
{scansRemaining}/5 reads · MO
</span>
)}
<NotificationBell />
<button
onClick={() => setMenuOpen((o) => !o)}
aria-haspopup="menu"
aria-expanded={menuOpen}
style={{
width: 36,
height: 36,
borderRadius: 999,
background: 'var(--bg-elevated)',
border: '1px solid var(--border-focus)',
color: 'var(--text-primary)',
cursor: 'pointer',
fontFamily: 'inherit',
fontWeight: 600,
}}
>
{user.email?.charAt(0).toUpperCase()}
</button>
{menuOpen && (
<div
role="menu"
className="surface-elevated"
style={{
position: 'absolute',
right: 0,
top: 'calc(100% + 8px)',
minWidth: 220,
padding: 8,
}}
>
<div style={{ padding: '8px 12px', borderBottom: '1px solid var(--border)' }}>
<div style={{ fontSize: 12, color: 'var(--text-tertiary)' }}>Signed in as</div>
<div style={{ fontSize: 13, fontWeight: 600, overflow: 'hidden', textOverflow: 'ellipsis' }}>
{user.email}
</div>
<div className="mono" style={{ marginTop: 6, fontSize: 11, color: 'var(--grade-a)', textTransform: 'uppercase' }}>
{tier} tier
</div>
</div>
{tier === 'free' && (
<a
href="/#pricing"
role="menuitem"
style={{ display: 'block', padding: '10px 12px', fontSize: 13, color: 'var(--text-primary)', textDecoration: 'none' }}
>
Upgrade $14.99/mo
</a>
)}
<button
onClick={() => {
void signOut();
setMenuOpen(false);
}}
role="menuitem"
style={{
width: '100%',
textAlign: 'left',
padding: '10px 12px',
background: 'transparent',
border: 'none',
color: 'var(--text-secondary)',
fontSize: 13,
cursor: 'pointer',
fontFamily: 'inherit',
}}
>
Log out
</button>
</div>
)}
</div>
) : (
<a href="/login" className="btn-primary" style={{ padding: '8px 16px', fontSize: 13 }}>
Log In
</a>
)}
</div>
<button
className="nav-mobile-toggle"
aria-label="Toggle menu"
aria-expanded={mobileOpen}
onClick={() => setMobileOpen((o) => !o)}
style={{
display: 'flex',
background: 'transparent',
border: '1px solid var(--border)',
borderRadius: 8,
padding: 8,
color: 'var(--text-primary)',
cursor: 'pointer',
}}
>
{mobileOpen ? '×' : '≡'}
</button>
</div>
{mobileOpen && (
<div
className="nav-mobile-panel"
style={{
borderTop: '1px solid var(--border)',
background: 'var(--bg-primary)',
padding: 16,
}}
>
<div style={{ display: 'grid', gap: 4 }}>
{NAV_LINKS.map((l) => (
<a
key={l.href}
href={l.href}
onClick={() => setMobileOpen(false)}
style={{
padding: '12px 16px',
fontSize: 15,
color: 'var(--text-primary)',
textDecoration: 'none',
borderRadius: 8,
}}
>
{l.label}
</a>
))}
{user ? (
<button
onClick={() => {
void signOut();
setMobileOpen(false);
}}
style={{
textAlign: 'left',
padding: '12px 16px',
fontSize: 15,
color: 'var(--text-secondary)',
background: 'transparent',
border: 'none',
cursor: 'pointer',
fontFamily: 'inherit',
}}
>
Log out
</button>
) : (
<a
href="/login"
className="btn-primary"
style={{ marginTop: 8, padding: 12 }}
onClick={() => setMobileOpen(false)}
>
Log In
</a>
)}
</div>
</div>
)}
<style jsx>{`
@media (min-width: 768px) {
:global(.nav-desktop) {
display: flex !important;
}
:global(.nav-mobile-toggle) {
display: none !important;
}
:global(.nav-mobile-panel) {
display: none !important;
}
}
`}</style>
</nav>
);
}
+280
View File
@@ -0,0 +1,280 @@
'use client';
import { useEffect, useMemo, useRef, useState } from 'react';
import { useAuth } from '@/contexts/AuthContext';
type NotificationType =
| 'rare_grade'
| 'cascade'
| 'steam'
| 'morning_results'
| 'line_movement'
| 'injury'
| 'system';
type Notification = {
id: string;
type: NotificationType;
title: string;
body?: string | null;
link?: string | null;
read: boolean;
created_at: string;
};
const TYPE_LABEL: Record<NotificationType, string> = {
rare_grade: 'A+ ALERT',
cascade: 'CASCADE',
steam: 'STEAM',
morning_results: 'RESULTS',
line_movement: 'LINE',
injury: 'INJURY',
system: 'SYSTEM',
};
const TYPE_TINT: Record<NotificationType, string> = {
rare_grade: 'var(--grade-aplus)',
cascade: 'var(--grade-c)',
steam: 'var(--grade-c)',
morning_results: 'var(--grade-b)',
line_movement: 'var(--grade-b)',
injury: 'var(--grade-d)',
system: 'var(--text-1)',
};
// Mock items used until /api/notifications is wired. The shape matches the
// future Supabase row exactly so the dropdown won't change when real data lands.
const MOCK: Notification[] = [
{
id: 'mock-1',
type: 'rare_grade',
title: 'Jokic Points Over 25.5 graded A+',
body: 'Rare grade tonight. Phosphor confirmed.',
link: '/dashboard',
read: false,
created_at: new Date(Date.now() - 8 * 60_000).toISOString(),
},
{
id: 'mock-2',
type: 'cascade',
title: 'Murray OUT → Jokic usage +3.2%',
body: 'Cascade recalibrated 4 props across DEN.',
link: '/dashboard',
read: false,
created_at: new Date(Date.now() - 42 * 60_000).toISOString(),
},
{
id: 'mock-3',
type: 'morning_results',
title: 'Last night: 2 of 3 graded A+ hit',
body: 'Brunson, Edwards landed. Wilson missed by 1.',
link: '/ledger',
read: true,
created_at: new Date(Date.now() - 13 * 60 * 60_000).toISOString(),
},
];
function fmtTime(iso: string): string {
const d = new Date(iso);
const diff = (Date.now() - d.getTime()) / 1000;
if (diff < 60) return `${Math.floor(diff)}s`;
if (diff < 3600) return `${Math.floor(diff / 60)}m`;
if (diff < 86_400) return `${Math.floor(diff / 3600)}h`;
return d.toLocaleDateString([], { month: 'short', day: 'numeric' });
}
export default function NotificationBell() {
const { user } = useAuth();
const [open, setOpen] = useState(false);
const [items, setItems] = useState<Notification[]>([]);
const wrapRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!user) {
setItems([]);
return;
}
let alive = true;
(async () => {
try {
const res = await fetch('/api/notifications', { cache: 'no-store' });
if (!res.ok) throw new Error('not-yet');
const data = (await res.json()) as { notifications?: Notification[] };
if (alive) setItems(Array.isArray(data.notifications) ? data.notifications : MOCK);
} catch {
if (alive) setItems(MOCK);
}
})();
return () => { alive = false; };
}, [user]);
useEffect(() => {
if (!open) return;
const onClick = (e: MouseEvent) => {
if (!wrapRef.current?.contains(e.target as Node)) setOpen(false);
};
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setOpen(false); };
document.addEventListener('mousedown', onClick);
document.addEventListener('keydown', onKey);
return () => {
document.removeEventListener('mousedown', onClick);
document.removeEventListener('keydown', onKey);
};
}, [open]);
const unread = useMemo(() => items.filter((n) => !n.read).length, [items]);
const markAllRead = () => {
setItems((prev) => prev.map((n) => ({ ...n, read: true })));
void fetch('/api/notifications/read-all', { method: 'POST' }).catch(() => {});
};
if (!user) return null;
return (
<div ref={wrapRef} style={{ position: 'relative' }}>
<button
type="button"
onClick={() => setOpen((o) => !o)}
aria-label={`Notifications${unread ? `, ${unread} unread` : ''}`}
aria-haspopup="menu"
aria-expanded={open}
style={{
width: 36,
height: 36,
borderRadius: 999,
background: 'transparent',
border: '1px solid var(--border)',
color: 'var(--text-1)',
cursor: 'pointer',
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
position: 'relative',
}}
>
<BellIcon />
{unread > 0 && (
<span
aria-hidden
style={{
position: 'absolute',
top: 4,
right: 4,
minWidth: 16,
height: 16,
padding: '0 4px',
borderRadius: 999,
background: 'var(--grade-a)',
color: '#062b22',
fontSize: 10,
fontWeight: 800,
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
boxShadow: '0 0 8px rgba(0, 212, 160, 0.7)',
fontFamily: 'IBM Plex Mono, monospace',
}}
>
{unread > 9 ? '9+' : unread}
</span>
)}
</button>
{open && (
<div
role="menu"
className="surface-elevated"
style={{
position: 'absolute',
right: 0,
top: 'calc(100% + 8px)',
width: 320,
maxHeight: 400,
overflowY: 'auto',
padding: 8,
zIndex: 60,
}}
>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '6px 8px 10px' }}>
<span className="lbl" style={{ color: 'var(--text-1)' }}>ALERTS</span>
{unread > 0 && (
<button
type="button"
onClick={markAllRead}
style={{ background: 'transparent', border: 'none', color: 'var(--grade-a)', fontSize: 12, cursor: 'pointer' }}
>
Mark all as read
</button>
)}
</div>
{items.length === 0 ? (
<div style={{ padding: '24px 12px', textAlign: 'center', color: 'var(--text-1)' }}>
<p style={{ fontSize: 13 }}>No new alerts.</p>
<p style={{ fontSize: 12, color: 'var(--text-2)', marginTop: 4 }}>
We&apos;ll notify you when something moves.
</p>
</div>
) : (
<ul style={{ listStyle: 'none', margin: 0, padding: 0, display: 'grid', gap: 4 }}>
{items.map((n) => (
<li key={n.id}>
<a
href={n.link || '#'}
onClick={() => setOpen(false)}
style={{
display: 'grid',
gap: 4,
padding: '10px 10px',
borderRadius: 8,
background: n.read ? 'transparent' : 'rgba(0, 212, 160, 0.05)',
borderLeft: `2px solid ${n.read ? 'var(--border)' : TYPE_TINT[n.type]}`,
textDecoration: 'none',
}}
>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<span
style={{
fontFamily: 'IBM Plex Mono, monospace',
fontSize: 10,
letterSpacing: '0.08em',
color: TYPE_TINT[n.type],
fontWeight: 700,
}}
>
{TYPE_LABEL[n.type]}
</span>
<span className="mono" style={{ fontSize: 10, color: 'var(--text-2)' }}>{fmtTime(n.created_at)}</span>
</div>
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-0)', lineHeight: 1.35 }}>
{n.title}
</span>
{n.body ? (
<span style={{ fontSize: 12, color: 'var(--text-1)', lineHeight: 1.4 }}>{n.body}</span>
) : null}
</a>
</li>
))}
</ul>
)}
</div>
)}
</div>
);
}
function BellIcon() {
return (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" aria-hidden>
<path
d="M6 9a6 6 0 1112 0c0 4 2 5 2 5H4s2-1 2-5z"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path d="M10 18a2 2 0 004 0" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
</svg>
);
}
+231
View File
@@ -0,0 +1,231 @@
'use client';
import { useEffect, useMemo, useState } from 'react';
import { useParlay, type ParlayLeg } from '@/contexts/ParlayContext';
import { GradePill } from './GradeCard';
import { trackParlayBuilt } from '@/lib/analytics';
interface ParlayGradeResponse {
parlay_grade: string;
parlay_confidence: number;
correlation_flags: { type: string; legs: number[]; detail: string; impact: string }[];
decimal_odds?: number;
}
export default function ParlayTray() {
const { legs, isOpen, close, removeLeg, clear } = useParlay();
const [grading, setGrading] = useState(false);
const [parlayResult, setParlayResult] = useState<ParlayGradeResponse | null>(null);
// Reset the parlay grade whenever the leg set changes
useEffect(() => {
setParlayResult(null);
}, [legs]);
const sports = useMemo(() => Array.from(new Set(legs.map((l) => l.sport))), [legs]);
const gradeParlay = async () => {
if (legs.length < 2) return;
setGrading(true);
try {
const token = typeof window !== 'undefined' ? localStorage.getItem('sb-token') : null;
const res = await fetch('/api/parlay/grade', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: JSON.stringify({
legs: legs.map((l) => ({
sport: l.sport,
player: l.player,
stat_type: l.stat,
line: l.line,
direction: l.direction,
})),
}),
});
const data = (await res.json()) as ParlayGradeResponse;
if (res.ok) {
setParlayResult(data);
trackParlayBuilt({ legs: legs.length, sports, grade: data.parlay_grade });
}
} finally {
setGrading(false);
}
};
if (!isOpen) return null;
return (
<div
role="dialog"
aria-modal="true"
aria-label="Parlay tray"
style={{
position: 'fixed',
inset: 0,
zIndex: 60,
display: 'flex',
alignItems: 'flex-end',
justifyContent: 'center',
}}
>
<button
aria-label="Close parlay tray"
onClick={close}
style={{
position: 'absolute',
inset: 0,
background: 'rgba(0,0,0,0.55)',
backdropFilter: 'blur(4px)',
border: 'none',
cursor: 'pointer',
}}
/>
<section
className="surface-elevated diagonal-cut animate-fade-up"
style={{
position: 'relative',
width: '100%',
maxWidth: 560,
maxHeight: '85vh',
margin: '0 auto',
borderTopLeftRadius: 24,
borderTopRightRadius: 24,
borderBottomLeftRadius: 0,
borderBottomRightRadius: 0,
padding: 24,
display: 'flex',
flexDirection: 'column',
gap: 16,
overflowY: 'auto',
}}
>
<header style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline' }}>
<div>
<h2 style={{ fontSize: 18, fontWeight: 700 }}>Parlay tray</h2>
<p className="mono" style={{ fontSize: 11, color: 'var(--text-tertiary)', letterSpacing: '0.05em' }}>
{legs.length} LEG{legs.length === 1 ? '' : 'S'} · {sports.join(' · ') || 'ADD A LEG'}
</p>
</div>
<button onClick={close} className="btn-ghost" style={{ padding: '6px 12px', fontSize: 12 }}>
Close
</button>
</header>
{legs.length === 0 ? (
<EmptyTrayCopy />
) : (
<ul style={{ display: 'grid', gap: 8 }}>
{legs.map((l) => (
<LegRow key={l.id} leg={l} onRemove={() => removeLeg(l.id)} />
))}
</ul>
)}
{parlayResult && (
<div
className="surface diagonal-cut"
style={{
padding: 16,
textAlign: 'center',
border: '1px solid var(--border-focus)',
}}
>
<p className="mono" style={{ fontSize: 11, color: 'var(--text-tertiary)', letterSpacing: '0.08em' }}>
PARLAY GRADE
</p>
<div style={{ display: 'flex', justifyContent: 'center', marginTop: 8 }}>
<GradePill grade={parlayResult.parlay_grade} confidence={parlayResult.parlay_confidence} />
</div>
{parlayResult.correlation_flags.length > 0 && (
<div
style={{
marginTop: 12,
padding: 12,
textAlign: 'left',
borderRadius: 8,
background: 'rgba(255,179,71,0.10)',
border: '1px solid rgba(255,179,71,0.30)',
}}
>
<p className="mono" style={{ fontSize: 11, fontWeight: 700, color: 'var(--grade-c)', marginBottom: 4 }}>
CORRELATION WARNINGS
</p>
{parlayResult.correlation_flags.map((f, i) => (
<p key={i} style={{ fontSize: 12, color: 'var(--text-secondary)', marginTop: 4 }}>
{f.detail}
</p>
))}
</div>
)}
</div>
)}
{legs.length > 0 && (
<footer style={{ display: 'grid', gap: 8 }}>
<button
onClick={gradeParlay}
disabled={legs.length < 2 || grading}
className={grading ? 'shimmer-loading' : 'btn-primary'}
style={{ padding: 14, fontWeight: 600, fontSize: 14, border: 'none', borderRadius: 12, color: 'var(--text-primary)', cursor: legs.length < 2 ? 'not-allowed' : 'pointer', opacity: legs.length < 2 ? 0.4 : 1 }}
>
{grading ? 'Running correlation analysis…' : legs.length < 2 ? 'Add 2+ legs to grade' : 'Grade parlay'}
</button>
<button onClick={clear} className="btn-ghost" style={{ padding: 12, fontSize: 13 }}>
Clear tray
</button>
</footer>
)}
</section>
</div>
);
}
function EmptyTrayCopy() {
return (
<div style={{ padding: '32px 0', textAlign: 'center' }}>
<p className="mono" style={{ fontSize: 12, color: 'var(--text-tertiary)', letterSpacing: '0.08em' }}>
NO LEGS YET
</p>
<p style={{ marginTop: 12, color: 'var(--text-secondary)', fontSize: 14, lineHeight: 1.6 }}>
Read a prop, hit <strong>Add to Parlay</strong>, and we&apos;ll build the slip here.
We grade overall correlation and surface the legs that secretly fight each other.
</p>
</div>
);
}
function LegRow({ leg, onRemove }: { leg: ParlayLeg; onRemove: () => void }) {
return (
<li
className="surface"
style={{
padding: 12,
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
gap: 12,
}}
>
<div>
<div style={{ fontSize: 14, fontWeight: 600 }}>{leg.player}</div>
<div className="mono" style={{ fontSize: 12, color: 'var(--text-secondary)', textTransform: 'capitalize' }}>
{leg.sport} · {leg.direction} {leg.line} {leg.stat.replace(/_/g, ' ')}
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<GradePill grade={leg.grade} />
<button
onClick={onRemove}
aria-label={`Remove ${leg.player}`}
className="btn-ghost"
style={{ padding: '4px 10px', fontSize: 11 }}
>
</button>
</div>
</li>
);
}
+233
View File
@@ -0,0 +1,233 @@
'use client';
import { useEffect, useId, useMemo, useRef, useState } from 'react';
export type PlayerResult = {
id: string;
full_name: string;
team?: string;
position?: string;
headshot_url?: string;
};
export type Sport = 'NBA' | 'MLB' | 'WNBA';
type Props = {
sport: Sport;
gameId?: string;
placeholder?: string;
initialValue?: string;
onSelect: (player: PlayerResult) => void;
autoFocus?: boolean;
};
const SPORT_TINT: Record<Sport, string> = {
NBA: 'var(--nba)',
MLB: 'var(--mlb)',
WNBA: 'var(--wnba)',
};
export default function PlayerSearch({
sport,
gameId,
placeholder = 'Search players…',
initialValue = '',
onSelect,
autoFocus = false,
}: Props) {
const [query, setQuery] = useState(initialValue);
const [results, setResults] = useState<PlayerResult[] | null>(null);
const [loading, setLoading] = useState(false);
const [highlight, setHighlight] = useState(0);
const [open, setOpen] = useState(false);
const inputId = useId();
const listboxId = `${inputId}-listbox`;
const containerRef = useRef<HTMLDivElement>(null);
const abortRef = useRef<AbortController | null>(null);
// Debounced fetch
useEffect(() => {
const q = query.trim();
if (q.length < 2) {
setResults(null);
setLoading(false);
return;
}
setLoading(true);
abortRef.current?.abort();
const ctrl = new AbortController();
abortRef.current = ctrl;
const t = setTimeout(async () => {
try {
const params = new URLSearchParams({ sport, q });
if (gameId) params.set('game_id', gameId);
const res = await fetch(`/api/players/search?${params.toString()}`, { signal: ctrl.signal });
const data = await res.json().catch(() => ({}));
if (!ctrl.signal.aborted) {
setResults(Array.isArray(data?.players) ? data.players.slice(0, 5) : []);
setHighlight(0);
}
} catch {
if (!ctrl.signal.aborted) setResults([]);
} finally {
if (!ctrl.signal.aborted) setLoading(false);
}
}, 220);
return () => {
clearTimeout(t);
ctrl.abort();
};
}, [query, sport, gameId]);
// Close on outside click
useEffect(() => {
const onClick = (e: MouseEvent) => {
if (!containerRef.current?.contains(e.target as Node)) setOpen(false);
};
document.addEventListener('mousedown', onClick);
return () => document.removeEventListener('mousedown', onClick);
}, []);
const noResults = useMemo(
() => !loading && results !== null && results.length === 0 && query.trim().length >= 2,
[loading, results, query],
);
const choose = (p: PlayerResult) => {
onSelect(p);
setQuery(p.full_name);
setOpen(false);
};
const onKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (!results || results.length === 0) return;
if (e.key === 'ArrowDown') {
e.preventDefault();
setOpen(true);
setHighlight((h) => Math.min(results.length - 1, h + 1));
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setHighlight((h) => Math.max(0, h - 1));
} else if (e.key === 'Enter') {
e.preventDefault();
const sel = results[highlight];
if (sel) choose(sel);
} else if (e.key === 'Escape') {
setOpen(false);
}
};
return (
<div ref={containerRef} style={{ position: 'relative' }}>
<input
id={inputId}
role="combobox"
aria-controls={listboxId}
aria-expanded={open && (loading || !!results?.length || noResults)}
aria-autocomplete="list"
aria-activedescendant={results && results[highlight] ? `${inputId}-opt-${highlight}` : undefined}
autoComplete="off"
autoFocus={autoFocus}
placeholder={placeholder}
className="input-field"
value={query}
onFocus={() => setOpen(true)}
onChange={(e) => { setQuery(e.target.value); setOpen(true); }}
onKeyDown={onKeyDown}
style={{ width: '100%' }}
/>
{open && (loading || results !== null) && (
<ul
id={listboxId}
role="listbox"
className="surface-elevated"
style={{
position: 'absolute',
top: 'calc(100% + 6px)',
left: 0,
right: 0,
zIndex: 30,
margin: 0,
padding: 4,
listStyle: 'none',
maxHeight: 280,
overflowY: 'auto',
}}
>
{loading && (
<li style={{ padding: 12 }}>
<span className="lbl" style={{ color: 'var(--text-1)' }}>SEARCHING</span>
</li>
)}
{!loading && noResults && (
<li style={{ padding: 12 }}>
<p style={{ fontSize: 14, color: 'var(--text-0)', margin: 0 }}>
No players found for &quot;{query}&quot;.
</p>
<p style={{ fontSize: 12, color: 'var(--text-1)', margin: '4px 0 0' }}>Check spelling.</p>
</li>
)}
{!loading && results && results.map((p, i) => {
const active = i === highlight;
return (
<li
key={p.id}
id={`${inputId}-opt-${i}`}
role="option"
aria-selected={active}
onMouseDown={(e) => { e.preventDefault(); choose(p); }}
onMouseEnter={() => setHighlight(i)}
style={{
display: 'flex',
alignItems: 'center',
gap: 12,
padding: '10px 12px',
borderRadius: 8,
background: active ? 'var(--bg-2)' : 'transparent',
cursor: 'pointer',
}}
>
<span
aria-hidden
style={{
width: 24, height: 24, borderRadius: 999,
background: 'var(--bg-3)',
border: `1px solid ${SPORT_TINT[sport]}`,
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: 10,
fontFamily: 'var(--font-mono, "IBM Plex Mono")',
fontWeight: 700,
color: 'var(--text-1)',
}}
>
{p.full_name.split(' ').map((n) => n[0]).slice(0, 2).join('')}
</span>
<span style={{ flex: 1, color: 'var(--text-0)', fontSize: 14, fontWeight: 600 }}>
{p.full_name}
</span>
{p.team ? (
<span className="mono" style={{ fontSize: 11, color: 'var(--text-1)' }}>{p.team}</span>
) : null}
<span
className="pill"
style={{
color: SPORT_TINT[sport],
background: 'transparent',
border: `1px solid ${SPORT_TINT[sport]}`,
}}
>
{sport}
</span>
</li>
);
})}
</ul>
)}
</div>
);
}
+19
View File
@@ -0,0 +1,19 @@
'use client';
import { useEffect } from 'react';
import { usePathname } from 'next/navigation';
import { initAnalytics, trackPageView } from '@/lib/analytics';
export default function PostHogProvider({ children }: { children: React.ReactNode }) {
const pathname = usePathname();
useEffect(() => {
initAnalytics();
}, []);
useEffect(() => {
if (pathname) trackPageView(pathname);
}, [pathname]);
return <>{children}</>;
}
+151 -83
View File
@@ -1,120 +1,188 @@
const tiers = [
'use client';
const TIERS = [
{
id: 'free',
name: 'Free',
price: '$0',
founderPrice: null,
period: '',
cta: 'Get Started',
cadence: '/mo',
headline: 'Try the model. No card required.',
cta: 'Start Free',
ctaHref: '/signup',
highlight: false,
features: [
'5 scans per month',
'View line movements',
'Basic prop grades',
'5 reads per month',
'Grade letter + projection',
'Cross-book line comparison',
'Confidence indicator',
],
unavailable: ['Bet tracking', 'Cascade alerts', 'Performance analytics'],
locked: [
'Factor analysis (blurred)',
'Kill conditions (blurred)',
'Alt line ladder (locked)',
],
highlight: false,
},
{
id: 'analyst',
name: 'Analyst',
price: '$19.99',
founderPrice: '$14.99',
period: '/mo',
cta: 'Subscribe',
ctaHref: '/api/stripe/checkout?tier=analyst',
highlight: true,
price: '$14.99',
originalPrice: '$24.99',
cadence: '/mo',
badge: 'Founder Access',
headline: 'The full intelligence layer.',
cta: 'Lock Founder Price',
ctaHref: '/api/checkout?tier=analyst',
features: [
'Unlimited scans',
'Line movement alerts',
'Bet tracking',
'Cascade alerts',
'Basic performance analytics',
'Unlimited reads',
'Full factor analysis (40+ signals)',
'Kill conditions surfaced inline',
'Cascade alerts when lineups shift',
'Parlay leg history with grades',
'Sportsbook deep links',
],
unavailable: ['Priority alerts', 'Behavioral patterns'],
locked: [
'Alt line ladder (Desk only)',
'Kelly sizing (Desk only)',
],
highlight: true,
},
{
id: 'desk',
name: 'Desk',
price: '$49.99',
founderPrice: '$34.99',
period: '/mo',
cta: 'Subscribe',
ctaHref: '/api/stripe/checkout?tier=desk',
highlight: false,
price: '$44.99',
originalPrice: '$49.99',
cadence: '/mo',
headline: 'Everything. The professional setup.',
cta: 'Go Desk',
ctaHref: '/api/checkout?tier=desk',
features: [
'Unlimited scans',
'Line movement + priority alerts',
'Full bet tracking',
'Priority cascade alerts',
'Full performance analytics',
'Behavioral pattern insights',
'Everything in Analyst',
'Alt line ladder + edge ranking',
'Quarter-Kelly sizing recommendations',
'Real-time intelligence feed',
'Parlay correlation analysis (phi)',
'Consensus vs model comparison',
'API access (coming Q3)',
],
unavailable: [],
locked: [],
highlight: false,
},
];
export default function Pricing() {
return (
<section className="py-24 px-4" id="pricing">
<div className="max-w-5xl mx-auto">
<h2 className="text-3xl font-bold text-center mb-4">Simple Pricing</h2>
<p className="text-[var(--text-muted)] text-center mb-16">Start free. Upgrade when you're ready.</p>
<div className="grid md:grid-cols-3 gap-6">
{tiers.map((tier) => (
<div
key={tier.name}
className={`relative p-6 rounded-2xl border ${
tier.highlight
? 'border-[var(--accent)] bg-[var(--accent)]/5'
: 'border-[var(--border)] bg-[var(--card)]'
}`}
<section
id="pricing"
style={{
padding: '96px 24px',
borderTop: '1px solid var(--border)',
}}
>
<div style={{ maxWidth: 1200, margin: '0 auto' }}>
<header style={{ textAlign: 'center', maxWidth: 720, margin: '0 auto 64px' }}>
<h2
className="text-balance"
style={{ fontSize: 'clamp(28px, 4vw, 44px)', fontWeight: 700, letterSpacing: '-0.02em', marginBottom: 16 }}
>
Pricing built for bettors. Not for SaaS investors.
</h2>
<p style={{ fontSize: 17, color: 'var(--text-secondary)' }}>
First 100 users lock $14.99/mo for life. This price dies at user 101.
</p>
</header>
<div className="pricing-grid" style={{ display: 'grid', gap: 24 }}>
{TIERS.map((tier, i) => (
<article
key={tier.id}
className={`surface diagonal-cut${tier.highlight ? ' diagonal-cut-strong' : ''} animate-fade-up stagger-${i + 1}`}
style={{
padding: 32,
position: 'relative',
border: tier.highlight ? '1px solid var(--grade-a)' : '1px solid var(--border)',
background: tier.highlight ? 'var(--bg-elevated)' : 'var(--bg-surface)',
boxShadow: tier.highlight ? '0 16px 48px var(--accent-glow)' : 'none',
}}
>
{tier.founderPrice && (
<div className="absolute -top-3 left-4 px-3 py-0.5 bg-[var(--accent)] text-white text-xs font-mono font-bold rounded-full">
Founder Rate — Locked for Life
{tier.badge && (
<div
className="mono"
style={{
position: 'absolute',
top: -12,
left: 24,
padding: '4px 12px',
background: 'var(--grade-a)',
color: 'var(--bg-primary)',
fontSize: 10,
fontWeight: 800,
letterSpacing: '0.08em',
borderRadius: 999,
textTransform: 'uppercase',
}}
>
{tier.badge}
</div>
)}
<h3 className="text-xl font-bold mt-2 mb-1">{tier.name}</h3>
<div className="flex items-baseline gap-1 mb-6">
{tier.founderPrice ? (
<>
<span className="text-3xl font-bold font-mono">{tier.founderPrice}</span>
<span className="text-[var(--text-muted)] text-sm">{tier.period}</span>
<span className="ml-2 text-sm text-[var(--text-muted)] line-through">{tier.price}</span>
</>
) : (
<>
<span className="text-3xl font-bold font-mono">{tier.price}</span>
<span className="text-[var(--text-muted)] text-sm">{tier.period}</span>
</>
<h3 style={{ fontSize: 14, fontWeight: 600, color: 'var(--text-secondary)', marginBottom: 4, textTransform: 'uppercase', letterSpacing: '0.08em' }}>
{tier.name}
</h3>
<div style={{ display: 'flex', alignItems: 'baseline', gap: 8, marginBottom: 4 }}>
<span className="mono" style={{ fontSize: 40, fontWeight: 800, color: 'var(--text-primary)', letterSpacing: '-0.03em' }}>
{tier.price}
</span>
<span style={{ color: 'var(--text-tertiary)', fontSize: 14 }}>{tier.cadence}</span>
{tier.originalPrice && (
<span className="mono" style={{ fontSize: 13, color: 'var(--text-tertiary)', textDecoration: 'line-through' }}>
{tier.originalPrice}
</span>
)}
</div>
<ul className="space-y-2 mb-8">
{tier.features.map((f) => (
<li key={f} className="flex items-start gap-2 text-sm">
<span className="text-[var(--grade-a)] mt-0.5">+</span>
{f}
</li>
))}
{tier.unavailable.map((f) => (
<li key={f} className="flex items-start gap-2 text-sm text-[var(--text-muted)]">
<span className="mt-0.5">-</span>
{f}
</li>
))}
</ul>
<p style={{ fontSize: 14, color: 'var(--text-secondary)', marginBottom: 24, minHeight: 42 }}>
{tier.headline}
</p>
<a
href={tier.ctaHref}
className={`block text-center py-3 rounded-xl font-medium transition ${
tier.highlight
? 'bg-[var(--accent)] text-white hover:opacity-90'
: 'bg-[var(--border)] text-white hover:bg-[var(--text-muted)]/20'
}`}
className={tier.highlight ? 'btn-primary' : 'btn-ghost'}
style={{ width: '100%', padding: 14, marginBottom: 24 }}
>
{tier.cta}
</a>
</div>
<ul style={{ display: 'grid', gap: 10 }}>
{tier.features.map((f) => (
<li key={f} style={{ display: 'flex', gap: 10, fontSize: 14 }}>
<span style={{ color: 'var(--grade-a)', fontWeight: 700 }} aria-hidden>+</span>
<span style={{ color: 'var(--text-primary)' }}>{f}</span>
</li>
))}
{tier.locked.map((f) => (
<li key={f} style={{ display: 'flex', gap: 10, fontSize: 14, color: 'var(--text-tertiary)' }}>
<span aria-hidden></span>
<span>{f}</span>
</li>
))}
</ul>
</article>
))}
</div>
<p style={{ textAlign: 'center', fontSize: 13, color: 'var(--text-tertiary)', marginTop: 32 }}>
Cancel anytime. No contracts. Card or Apple Pay or Google Pay payments processed by NexaPay.
</p>
</div>
<style jsx>{`
:global(.pricing-grid) {
grid-template-columns: 1fr;
}
@media (min-width: 768px) {
:global(.pricing-grid) {
grid-template-columns: repeat(3, 1fr);
}
}
`}</style>
</section>
);
}
+134
View File
@@ -0,0 +1,134 @@
'use client';
import { useEffect, useState } from 'react';
import { useAuth } from '@/contexts/AuthContext';
// Push opt-in banner. Same gating as InstallPrompt: only after 2 Reads.
// We treat the result tri-state:
// granted → POST the PushSubscription to /api/push/subscribe
// denied → remember it; never ask again
// default → user dismissed; ask again next session
const READS_KEY = 'vyndr_reads_completed';
const ASKED_KEY = 'vyndr_push_asked';
const DENIED_KEY = 'vyndr_push_denied';
const REQUIRED_READS = 2;
function readsCompleted(): number {
if (typeof window === 'undefined') return 0;
const raw = window.localStorage.getItem(READS_KEY);
return raw ? parseInt(raw, 10) || 0 : 0;
}
function base64UrlToArrayBuffer(base64Url: string): ArrayBuffer {
const padding = '='.repeat((4 - (base64Url.length % 4)) % 4);
const base64 = (base64Url + padding).replace(/-/g, '+').replace(/_/g, '/');
const raw = window.atob(base64);
const buffer = new ArrayBuffer(raw.length);
const view = new Uint8Array(buffer);
for (let i = 0; i < raw.length; i += 1) view[i] = raw.charCodeAt(i);
return buffer;
}
export default function PushPrompt() {
const { user } = useAuth();
const [visible, setVisible] = useState(false);
const [busy, setBusy] = useState(false);
useEffect(() => {
if (!user) return;
if (typeof window === 'undefined') return;
if (!('serviceWorker' in navigator) || !('PushManager' in window)) return;
if (Notification.permission === 'granted' || Notification.permission === 'denied') return;
if (window.localStorage.getItem(DENIED_KEY)) return;
if (window.sessionStorage.getItem(ASKED_KEY)) return;
if (readsCompleted() < REQUIRED_READS) return;
setVisible(true);
}, [user]);
const handleEnable = async () => {
setBusy(true);
window.sessionStorage.setItem(ASKED_KEY, '1');
try {
const permission = await Notification.requestPermission();
if (permission === 'denied') {
window.localStorage.setItem(DENIED_KEY, '1');
setVisible(false);
return;
}
if (permission !== 'granted') {
setVisible(false);
return;
}
const registration = await navigator.serviceWorker.ready;
const vapidKey = process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY;
if (!vapidKey) {
console.warn('[push] NEXT_PUBLIC_VAPID_PUBLIC_KEY not set');
setVisible(false);
return;
}
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: base64UrlToArrayBuffer(vapidKey),
});
await fetch('/api/push/subscribe', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ subscription }),
});
setVisible(false);
} catch (err) {
console.warn('[push] subscribe failed:', err);
setVisible(false);
} finally {
setBusy(false);
}
};
const handleDismiss = () => {
window.sessionStorage.setItem(ASKED_KEY, '1');
setVisible(false);
};
if (!visible) return null;
return (
<div
role="dialog"
aria-label="Enable notifications"
className="fixed bottom-24 left-4 right-4 z-50 mx-auto max-w-md rounded-lg border p-4 shadow-lg"
style={{
background: 'var(--bg-surface)',
borderColor: 'var(--border-light)',
color: 'var(--text-primary)',
}}
>
<div className="flex items-start justify-between gap-3">
<div className="flex-1">
<div className="text-sm font-semibold">Get notified on cascades + A+ alerts</div>
<div className="mt-1 text-xs" style={{ color: 'var(--text-secondary)' }}>
We&apos;ll ping you when a prop drops to A+, a cascade triggers, or your reads resolve.
</div>
</div>
<button
type="button"
onClick={handleDismiss}
aria-label="Dismiss notification prompt"
className="rounded p-1 text-xs"
style={{ color: 'var(--text-tertiary)' }}
>
</button>
</div>
<button
type="button"
onClick={handleEnable}
disabled={busy}
className="mt-3 w-full rounded px-3 py-2 text-sm font-semibold disabled:opacity-50"
style={{ background: 'var(--grade-a)', color: 'var(--bg-0)' }}
>
{busy ? 'Enabling…' : 'Enable notifications'}
</button>
</div>
);
}
+183
View File
@@ -0,0 +1,183 @@
'use client';
import { useRef } from 'react';
import { trackShareCardGenerated } from '@/lib/analytics';
interface ShareCardProps {
sport: 'NBA' | 'MLB' | 'WNBA';
player: string;
stat: string;
line: number;
direction: 'over' | 'under';
grade: string;
projection?: number;
sampleSize?: number;
}
const SPORT_COLOR: Record<ShareCardProps['sport'], string> = {
NBA: '#E94B3C',
MLB: '#1E90FF',
WNBA: '#FFB347',
};
function gradeColor(grade: string): string {
const g = (grade || '').trim().toUpperCase().charAt(0);
if (g === 'A') return '#00C896';
if (g === 'B') return '#4A9EFF';
if (g === 'C') return '#FFB347';
return '#FF6B6B';
}
/**
* Renders a 1200x630 OG-shaped share image into a hidden canvas, then
* provides Download + Copy actions. Intentionally hides the analysis —
* shares the GRADE only, which is what drives traffic back to the site.
*/
export function useShareCard(props: ShareCardProps) {
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const ensureCanvas = (): HTMLCanvasElement => {
if (canvasRef.current) return canvasRef.current;
const c = document.createElement('canvas');
c.width = 1200;
c.height = 630;
canvasRef.current = c;
return c;
};
const renderToCanvas = async (): Promise<HTMLCanvasElement> => {
const c = ensureCanvas();
const ctx = c.getContext('2d');
if (!ctx) throw new Error('No 2D context.');
// Background — obsidian with diagonal accent
ctx.fillStyle = '#0A0A0F';
ctx.fillRect(0, 0, c.width, c.height);
// Diagonal gradient overlay
const g = ctx.createLinearGradient(0, 0, c.width, c.height);
g.addColorStop(0, 'rgba(26,74,58,0.20)');
g.addColorStop(1, 'rgba(0,200,150,0.02)');
ctx.fillStyle = g;
ctx.fillRect(0, 0, c.width, c.height);
// VYNDR wordmark top-left
ctx.fillStyle = '#F0F0F5';
ctx.font = '800 38px "JetBrains Mono", "SF Mono", ui-monospace, monospace';
ctx.fillText('VYND', 64, 96);
ctx.fillStyle = '#00D4A0';
ctx.fillText('R', 64 + ctx.measureText('VYND').width, 96);
// Sport badge
const sportColor = SPORT_COLOR[props.sport];
ctx.font = '700 16px "JetBrains Mono", monospace';
ctx.fillStyle = sportColor;
ctx.fillText(props.sport, 64, 220);
// Player name (large)
ctx.fillStyle = '#F0F0F5';
ctx.font = '700 72px "Instrument Sans", system-ui, sans-serif';
wrapText(ctx, props.player, 64, 300, 780, 80);
// Prop line (mono)
ctx.fillStyle = '#8A8A9A';
ctx.font = '500 28px "JetBrains Mono", monospace';
const cap = (s: string) => s.charAt(0).toUpperCase() + s.slice(1);
ctx.fillText(
`${cap(props.direction)} ${props.line} ${props.stat.replace(/_/g, ' ')}`,
64,
420,
);
// Grade letter (huge, colored)
const gc = gradeColor(props.grade);
ctx.fillStyle = gc;
ctx.font = '800 240px "JetBrains Mono", monospace';
ctx.textAlign = 'right';
ctx.fillText(props.grade || '—', c.width - 64, 380);
// Glow ring behind the grade
ctx.shadowColor = gc;
ctx.shadowBlur = 60;
ctx.fillText(props.grade || '—', c.width - 64, 380);
ctx.shadowBlur = 0;
// Projection (small, beneath player line)
if (props.projection != null) {
ctx.textAlign = 'left';
ctx.fillStyle = '#5A5A6A';
ctx.font = '500 22px "JetBrains Mono", monospace';
ctx.fillText(`Projection ${props.projection.toFixed(1)}`, 64, 470);
}
// Footer — watermark
ctx.textAlign = 'left';
ctx.fillStyle = '#5A5A6A';
ctx.font = '500 18px "JetBrains Mono", monospace';
ctx.fillText('vyndr.app', 64, 580);
ctx.textAlign = 'right';
ctx.fillStyle = '#5A5A6A';
ctx.fillText('Built in Detroit.', c.width - 64, 580);
return c;
};
const download = async () => {
const c = await renderToCanvas();
c.toBlob((blob) => {
if (!blob) return;
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `vyndr-${props.player.replace(/\W+/g, '-')}-${props.grade}.png`.toLowerCase();
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
trackShareCardGenerated({ sport: props.sport, grade: props.grade });
}, 'image/png');
};
const copyToClipboard = async (): Promise<boolean> => {
if (typeof ClipboardItem === 'undefined' || !navigator.clipboard?.write) return false;
const c = await renderToCanvas();
return new Promise<boolean>((resolve) => {
c.toBlob(async (blob) => {
if (!blob) return resolve(false);
try {
await navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })]);
trackShareCardGenerated({ sport: props.sport, grade: props.grade });
resolve(true);
} catch {
resolve(false);
}
}, 'image/png');
});
};
return { download, copyToClipboard };
}
function wrapText(
ctx: CanvasRenderingContext2D,
text: string,
x: number,
y: number,
maxWidth: number,
lineHeight: number,
) {
const words = text.split(' ');
let line = '';
for (const word of words) {
const test = line ? `${line} ${word}` : word;
const m = ctx.measureText(test);
if (m.width > maxWidth && line) {
ctx.fillText(line, x, y);
line = word;
y += lineHeight;
} else {
line = test;
}
}
if (line) ctx.fillText(line, x, y);
}
+241
View File
@@ -0,0 +1,241 @@
'use client';
import { useState, useCallback, useEffect } from 'react';
export type Sport = 'NBA' | 'MLB';
export interface OddsLine {
player: string;
stat_type: string;
line: number;
direction: string;
book: string;
}
interface SimplifiedSelectorProps {
onScan: (leg: { player: string; stat_type: string; line: number; direction: string; sport: Sport }) => void;
scanning?: boolean;
oddsApiUrl?: string;
nbaServiceUrl?: string;
}
const NBA_STATS = ['points', 'rebounds', 'assists', 'threes', 'blocks', 'steals', 'pra', 'turnovers'];
const MLB_STATS = [
'strikeouts', 'hits_allowed', 'earned_runs', 'innings_pitched', 'walks_allowed',
'hits', 'total_bases', 'rbi', 'runs', 'stolen_bases', 'home_runs', 'walks', 'singles', 'doubles',
];
export default function SimplifiedSelector({
onScan,
scanning = false,
oddsApiUrl,
nbaServiceUrl,
}: SimplifiedSelectorProps) {
const [sport, setSport] = useState<Sport>('NBA');
const [playerQuery, setPlayerQuery] = useState('');
const [selectedPlayer, setSelectedPlayer] = useState('');
const [statType, setStatType] = useState('');
const [line, setLine] = useState<number | ''>('');
const [direction, setDirection] = useState<'over' | 'under'>('over');
const [suggestions, setSuggestions] = useState<string[]>([]);
const [playerOdds, setPlayerOdds] = useState<OddsLine[]>([]);
const [showSuggestions, setShowSuggestions] = useState(false);
const stats = sport === 'NBA' ? NBA_STATS : MLB_STATS;
const apiBase = oddsApiUrl || process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000';
const nbaBase = nbaServiceUrl || process.env.NEXT_PUBLIC_NBA_SERVICE_URL || 'http://localhost:8000';
// Reset when sport changes
useEffect(() => {
setPlayerQuery('');
setSelectedPlayer('');
setStatType('');
setLine('');
setPlayerOdds([]);
setSuggestions([]);
}, [sport]);
// Fetch player suggestions
const searchPlayer = useCallback(
async (name: string) => {
if (name.length < 2) {
setSuggestions([]);
return;
}
try {
const res = await fetch(`${nbaBase}/players/search?name=${encodeURIComponent(name)}`);
const data = await res.json();
setSuggestions((data.results || []).map((r: any) => r.full_name).slice(0, 5));
setShowSuggestions(true);
} catch {
setSuggestions([]);
}
},
[nbaBase],
);
// Fetch odds for selected player to pre-fill lines
const fetchPlayerOdds = useCallback(
async (playerName: string, selectedSport: Sport) => {
try {
const sportKey = selectedSport.toLowerCase();
const res = await fetch(`${apiBase}/api/odds/${sportKey}`);
if (!res.ok) return;
const data = await res.json();
const props: OddsLine[] = (data.props || []).filter(
(p: OddsLine) => p.player?.toLowerCase() === playerName.toLowerCase(),
);
setPlayerOdds(props);
} catch {
setPlayerOdds([]);
}
},
[apiBase],
);
// When player is selected, fetch their odds
const selectPlayer = (name: string) => {
setSelectedPlayer(name);
setPlayerQuery(name);
setShowSuggestions(false);
setSuggestions([]);
setStatType('');
setLine('');
fetchPlayerOdds(name, sport);
};
// When stat changes, pre-fill line from odds
useEffect(() => {
if (!selectedPlayer || !statType) return;
const match = playerOdds.find((o) => o.stat_type === statType);
if (match) {
setLine(match.line);
setDirection((match.direction as 'over' | 'under') || 'over');
} else {
setLine('');
}
}, [statType, selectedPlayer, playerOdds]);
// Available stats for this player based on odds data
const availableStats = playerOdds.length > 0
? stats.filter((s) => playerOdds.some((o) => o.stat_type === s))
: stats;
const canScan = selectedPlayer && statType && line !== '';
const handleScan = () => {
if (!canScan || scanning) return;
onScan({
player: selectedPlayer,
stat_type: statType,
line: Number(line),
direction,
sport,
});
};
return (
<div className="space-y-5" data-testid="simplified-selector">
{/* Sport Toggle */}
<div className="flex gap-2" data-testid="sport-toggle">
{(['NBA', 'MLB'] as Sport[]).map((s) => (
<button
key={s}
onClick={() => setSport(s)}
className={`flex-1 py-3 rounded-xl font-mono font-bold text-sm transition ${
sport === s
? 'bg-[var(--cyan)] text-black'
: 'bg-[var(--card)] border border-[var(--border)] text-[var(--text-muted)] hover:border-[var(--cyan)]'
}`}
data-testid={`sport-${s.toLowerCase()}`}
aria-pressed={sport === s}
>
{s}
</button>
))}
</div>
{/* Player Search */}
<div className="relative">
<input
type="text"
placeholder={`Search ${sport} player...`}
value={playerQuery}
onChange={(e) => {
setPlayerQuery(e.target.value);
setSelectedPlayer('');
searchPlayer(e.target.value);
}}
onBlur={() => setTimeout(() => setShowSuggestions(false), 200)}
onFocus={() => suggestions.length > 0 && setShowSuggestions(true)}
className="w-full px-4 py-3 rounded-xl bg-[var(--bg)] border border-[var(--border)] text-white placeholder:text-[var(--text-muted)] focus:outline-none focus:border-[var(--cyan)] text-sm"
data-testid="player-search"
/>
{showSuggestions && suggestions.length > 0 && (
<div className="absolute z-10 top-full mt-1 w-full bg-[var(--card)] border border-[var(--border)] rounded-xl overflow-hidden" data-testid="player-suggestions">
{suggestions.map((name) => (
<button
key={name}
onMouseDown={() => selectPlayer(name)}
className="block w-full text-left px-4 py-2 text-sm hover:bg-[var(--border)] transition"
>
{name}
</button>
))}
</div>
)}
</div>
{/* Stat Dropdown */}
{selectedPlayer && (
<select
value={statType}
onChange={(e) => setStatType(e.target.value)}
className="w-full px-4 py-3 rounded-xl bg-[var(--bg)] border border-[var(--border)] text-white text-sm"
data-testid="stat-dropdown"
>
<option value="">Select stat...</option>
{availableStats.map((s) => (
<option key={s} value={s}>
{s}
</option>
))}
</select>
)}
{/* Line + Direction */}
{selectedPlayer && statType && (
<div className="flex gap-3">
<select
value={direction}
onChange={(e) => setDirection(e.target.value as 'over' | 'under')}
className="px-4 py-3 rounded-xl bg-[var(--bg)] border border-[var(--border)] text-white text-sm"
data-testid="direction-select"
>
<option value="over">Over</option>
<option value="under">Under</option>
</select>
<input
type="number"
step="0.5"
placeholder="Line"
value={line}
onChange={(e) => setLine(e.target.value ? Number(e.target.value) : '')}
className="flex-1 px-4 py-3 rounded-xl bg-[var(--bg)] border border-[var(--border)] text-white placeholder:text-[var(--text-muted)] text-sm"
data-testid="line-input"
/>
</div>
)}
{/* Scan Button */}
<button
onClick={handleScan}
disabled={!canScan || scanning}
className="w-full py-3 bg-[var(--cyan)] text-black rounded-xl font-medium hover:bg-[var(--cyan-hover)] transition disabled:opacity-40"
data-testid="scan-button"
>
{scanning ? 'Reading...' : 'Read Prop'}
</button>
</div>
);
}
+96
View File
@@ -0,0 +1,96 @@
import type { CSSProperties } from 'react';
type Size = number | string;
const pulseStyle = (extra: CSSProperties = {}): CSSProperties => ({
background: 'var(--bg-2)',
animation: 'skeleton-pulse 1.5s ease-in-out infinite',
...extra,
});
export function SkeletonBox({
width,
height,
borderRadius = 8,
style,
}: {
width?: Size;
height?: Size;
borderRadius?: number;
style?: CSSProperties;
}) {
return (
<div
aria-hidden
style={pulseStyle({
width,
height,
borderRadius,
...style,
})}
/>
);
}
export function SkeletonLine({ width = '80%', height = 14 }: { width?: Size; height?: Size }) {
return <SkeletonBox width={width} height={height} borderRadius={4} style={{ marginBottom: 8 }} />;
}
export function SkeletonGradeCard() {
return (
<div className="surface" style={{ padding: 16, display: 'grid', gap: 12 }} aria-label="Loading grade card" role="status">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<SkeletonBox width={60} height={20} borderRadius={999} />
<SkeletonBox width={36} height={36} borderRadius={999} />
</div>
<SkeletonBox width="70%" height={22} borderRadius={6} />
<SkeletonBox width="45%" height={14} borderRadius={4} />
<div style={{ display: 'flex', justifyContent: 'center', padding: '8px 0' }}>
<SkeletonBox width={72} height={72} borderRadius={12} />
</div>
<SkeletonLine width="90%" />
<SkeletonLine width="80%" />
<SkeletonLine width="75%" />
</div>
);
}
export function SkeletonGameCard() {
return (
<div
className="surface"
style={{ padding: 16, display: 'flex', gap: 12, alignItems: 'stretch' }}
aria-label="Loading game card"
role="status"
>
<SkeletonBox width={3} height={56} borderRadius={2} />
<div style={{ flex: 1, display: 'grid', gap: 8 }}>
<SkeletonBox width="55%" height={18} borderRadius={4} />
<SkeletonBox width="35%" height={14} borderRadius={4} />
</div>
<SkeletonBox width={60} height={20} borderRadius={999} />
</div>
);
}
export function SkeletonRow({ count = 3 }: { count?: number }) {
return (
<div style={{ display: 'grid', gap: 8 }} aria-label="Loading" role="status">
{Array.from({ length: count }, (_, i) => (
<SkeletonGameCard key={i} />
))}
</div>
);
}
export function SkeletonGradeRail({ count = 4 }: { count?: number }) {
return (
<div style={{ display: 'flex', gap: 12, overflowX: 'auto', padding: '4px 4px 12px' }} aria-label="Loading grade rail" role="status">
{Array.from({ length: count }, (_, i) => (
<div key={i} style={{ minWidth: 220, flex: '0 0 auto' }}>
<SkeletonGradeCard />
</div>
))}
</div>
);
}
+107
View File
@@ -0,0 +1,107 @@
'use client';
import { useEffect, useState } from 'react';
const PREF_KEY = 'vyndr.sportsbook_modal.suppress';
type Props = {
book: string;
url: string;
open: boolean;
onClose: () => void;
};
export default function SportsbookModal({ book, url, open, onClose }: Props) {
const [dontShowAgain, setDontShowAgain] = useState(false);
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
document.addEventListener('keydown', onKey);
return () => document.removeEventListener('keydown', onKey);
}, [open, onClose]);
if (!open) return null;
const continueOut = () => {
if (dontShowAgain) {
try { localStorage.setItem(PREF_KEY, '1'); } catch { /* private mode */ }
}
window.open(url, '_blank', 'noopener,noreferrer');
onClose();
};
return (
<div
role="dialog"
aria-modal="true"
aria-labelledby="sportsbook-modal-title"
onClick={onClose}
style={{
position: 'fixed',
inset: 0,
zIndex: 100,
background: 'rgba(6, 6, 11, 0.72)',
backdropFilter: 'blur(8px)',
WebkitBackdropFilter: 'blur(8px)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: 16,
}}
>
<div
onClick={(e) => e.stopPropagation()}
className="surface diagonal-cut"
style={{ maxWidth: 400, width: '100%', padding: 24 }}
>
<p className="lbl" style={{ color: 'var(--grade-c)' }}>LEAVING VYNDR</p>
<p id="sportsbook-modal-title" style={{ fontSize: 16, fontWeight: 600, marginTop: 8 }}>
You&apos;re being redirected to {book}.
</p>
<p style={{ color: 'var(--text-1)', fontSize: 14, marginTop: 8 }}>
VYNDR doesn&apos;t place bets, handle money, or guarantee outcomes.
</p>
<div style={{ display: 'flex', gap: 12, marginTop: 20 }}>
<button type="button" className="btn-primary" style={{ flex: 1 }} onClick={continueOut}>
Continue to {book}
</button>
<button type="button" className="btn-ghost" style={{ flex: 1 }} onClick={onClose}>
Stay on VYNDR
</button>
</div>
<label
style={{
display: 'flex',
alignItems: 'center',
gap: 8,
marginTop: 16,
cursor: 'pointer',
userSelect: 'none',
}}
>
<input
type="checkbox"
checked={dontShowAgain}
onChange={(e) => setDontShowAgain(e.target.checked)}
/>
<span style={{ color: 'var(--text-2)', fontSize: 12 }}>Don&apos;t show this again</span>
</label>
</div>
</div>
);
}
export function shouldSkipSportsbookModal(): boolean {
try {
return localStorage.getItem(PREF_KEY) === '1';
} catch {
return false;
}
}
export function openSportsbookSafely(url: string) {
window.open(url, '_blank', 'noopener,noreferrer');
}
+42
View File
@@ -0,0 +1,42 @@
import type { CSSProperties } from 'react';
type WordmarkProps = {
size?: number;
cursor?: boolean;
animated?: boolean;
ariaLabel?: string;
};
/**
* VYNDR wordmark. IBM Plex Mono 800, RGB-split letters, glowing R, blinking cursor.
* The R is the brand — always green (#00D4A0), always intercepted-looking.
* `aria-label` exposes the readable brand to screen readers; per-letter spans are aria-hidden.
*/
export default function Wordmark({
size = 22,
cursor = true,
animated = true,
ariaLabel = 'VYNDR',
}: WordmarkProps) {
const style: CSSProperties & { '--wm-size'?: string } = {
fontSize: size,
'--wm-size': `${size}px`,
};
return (
<span
className={`wordmark${animated ? ' wm-anim' : ''}`}
style={style}
role="img"
aria-label={ariaLabel}
>
<span className="vynd" aria-hidden>
<span className="wm-letter" data-text="V">V</span>
<span className="wm-letter" data-text="Y">Y</span>
<span className="wm-letter" data-text="N">N</span>
<span className="wm-letter" data-text="D">D</span>
</span>
<span className="r wm-letter" data-text="R" aria-hidden>R</span>
{cursor ? <span className="wm-cursor" aria-hidden /> : null}
</span>
);
}