Files
vyndr/web/src/app/slip/page.tsx
T
builtbykev 4e49ee0990 S9 (a1): slip reader — zero-API OCR
tesseract.js (self-hosted WASM, Apache-2.0) + pure per-book layout
parsers (DK/FD/MGM/Caesars) with per-field confidence and needs_review
honesty — the reader never guesses. POST /api/slips/parse (auth, free
1/day paid 10/day, 4MB cap) + Next proxy. Gated /slip page: upload or
paste, manual-correct UI, per-leg grades through the normal engine
(refusals render honestly), add-all to Parlay Lab, share card. Vision
model upgrade logged post-revenue. 2574 -> 2608 tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 19:32:17 -04:00

526 lines
20 KiB
TypeScript

'use client';
/**
* A1 S9 — Slip Reader. Upload/paste a bet-slip screenshot; tesseract OCR
* (self-hosted, backend) extracts the legs; every field is pre-filled AND
* editable (needs_review fields amber); confirm grades each leg through
* the normal engine via POST /api/scan — refusals render honestly; graded
* legs hand off to the Parlay Lab.
*
* DATA SEMANTICS: extracted numbers are USER-SLIP values (what the user's
* book printed), labeled as such. VYNDR never invents a line or odds — an
* unreadable field arrives null and stays empty until the user fills it.
*/
import { useCallback, useRef, useState } from 'react';
import SectionHead from '@/components/vyndr/SectionHead';
import VBtn from '@/components/vyndr/VBtn';
import GradeBadge from '@/components/vyndr/GradeBadge';
import { useParlay } from '@/contexts/ParlayContext';
type Sport = 'MLB' | 'NBA' | 'WNBA';
interface ParsedLeg {
player: string | null;
stat: string | null;
line: number | null;
side: 'over' | 'under' | null;
odds: number | null;
confidence?: Record<string, number>;
needs_review: boolean;
}
interface EditLeg {
player: string;
stat: string;
line: string;
side: 'over' | 'under';
odds: string;
needs_review: boolean;
flagged: { player: boolean; stat: boolean; line: boolean; side: boolean; odds: boolean };
}
interface LegGrade {
status: 'pending' | 'graded' | 'refused' | 'error';
grade?: string;
confidence?: number;
message?: string;
}
const STAT_OPTIONS: Record<Sport, string[]> = {
MLB: ['hits', 'total_bases', 'home_runs', 'rbi', 'runs', 'stolen_bases', 'doubles', 'walks', 'strikeouts', 'hits_allowed', 'earned_runs', 'innings_pitched'],
NBA: ['points', 'rebounds', 'assists', 'threes', 'blocks', 'steals', 'pra', 'turnovers'],
WNBA: ['points', 'rebounds', 'assists', 'threes', 'blocks', 'steals', 'pra', 'turnovers'],
};
const label = (s: string) => s.replace(/_/g, ' ').toUpperCase();
function toEditLeg(l: ParsedLeg): EditLeg {
return {
player: l.player ?? '',
stat: l.stat ?? '',
line: l.line != null ? String(l.line) : '',
side: l.side === 'under' ? 'under' : 'over',
odds: l.odds != null ? (l.odds > 0 ? `+${l.odds}` : String(l.odds)) : '',
needs_review: l.needs_review,
flagged: {
player: l.player == null,
stat: l.stat == null,
line: l.line == null,
side: l.side == null,
odds: l.odds == null,
},
};
}
const inputStyle = (flag: boolean): React.CSSProperties => ({
width: '100%',
padding: '8px 10px',
background: 'var(--bg-2)',
border: `1px solid ${flag ? 'var(--amber)' : 'var(--border)'}`,
borderRadius: 6,
color: flag ? 'var(--amber)' : 'var(--text-0)',
fontSize: 13,
});
export default function SlipPage() {
const { addLeg, open } = useParlay();
const fileRef = useRef<HTMLInputElement>(null);
const [mode, setMode] = useState<'image' | 'text'>('image');
const [sport, setSport] = useState<Sport>('MLB');
const [preview, setPreview] = useState<string | null>(null);
const [slipText, setSlipText] = useState('');
const [parsing, setParsing] = useState(false);
const [error, setError] = useState('');
const [book, setBook] = useState<string | null>(null);
const [legs, setLegs] = useState<EditLeg[] | null>(null);
const [grades, setGrades] = useState<LegGrade[]>([]);
const [grading, setGrading] = useState(false);
const [graded, setGraded] = useState(false);
const [copied, setCopied] = useState(false);
const [added, setAdded] = useState(false);
const parse = useCallback(async (payload: { image?: string; text?: string }) => {
setParsing(true);
setError('');
setLegs(null);
setGrades([]);
setGraded(false);
setAdded(false);
try {
const token = typeof window !== 'undefined' ? localStorage.getItem('sb-token') : null;
const res = await fetch('/api/slips/parse', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: JSON.stringify(payload),
});
const data = await res.json();
if (!res.ok) {
setError(data.error || 'The reader could not process that slip.');
return;
}
setBook(data.book ?? null);
const parsed: ParsedLeg[] = Array.isArray(data.legs) ? data.legs : [];
if (parsed.length === 0) {
setError('No legs found on that slip. Try a sharper screenshot or paste the slip text.');
return;
}
setLegs(parsed.map(toEditLeg));
} catch {
setError('The reader is offline. Try again shortly.');
} finally {
setParsing(false);
}
}, []);
const onFile = useCallback((file: File | null) => {
if (!file) return;
if (file.size > 4 * 1024 * 1024) {
setError('Image too large. 4MB max.');
return;
}
const reader = new FileReader();
reader.onload = () => {
const url = String(reader.result || '');
setPreview(url);
void parse({ image: url });
};
reader.readAsDataURL(file);
}, [parse]);
const onPaste = useCallback((e: React.ClipboardEvent) => {
const item = Array.from(e.clipboardData?.items || []).find((i) => i.type.startsWith('image/'));
if (item) {
const f = item.getAsFile();
if (f) onFile(f);
}
}, [onFile]);
const setField = (i: number, field: keyof EditLeg['flagged'], value: string) => {
setLegs((prev) => {
if (!prev) return prev;
const next = [...prev];
const leg = { ...next[i], [field]: value } as EditLeg;
// A user-corrected field is no longer flagged.
leg.flagged = { ...leg.flagged, [field]: value.trim() === '' };
next[i] = leg;
return next;
});
setGraded(false);
};
const removeLeg = (i: number) => {
setLegs((prev) => (prev ? prev.filter((_, idx) => idx !== i) : prev));
setGraded(false);
};
const legReady = (l: EditLeg) =>
l.player.trim() !== '' && l.stat !== '' && l.line.trim() !== '' && Number.isFinite(Number(l.line));
const gradeAll = async () => {
if (!legs || grading) return;
setGrading(true);
setGrades(legs.map(() => ({ status: 'pending' })));
const token = typeof window !== 'undefined' ? localStorage.getItem('sb-token') : null;
const results: LegGrade[] = [];
for (const leg of legs) {
if (!legReady(leg)) {
results.push({ status: 'error', message: 'Fill player, stat and line first.' });
continue;
}
try {
const res = await fetch('/api/scan', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: JSON.stringify({
sport,
player: leg.player.trim(),
stat: leg.stat,
line: Number(leg.line),
direction: leg.side,
book: 'draftkings',
}),
});
const data = await res.json();
if (!res.ok) {
results.push({ status: 'error', message: data.error || 'The engine hit a wall on this leg.' });
} else if (data.insufficient_data || !data.grade) {
// Honest refusal — the engine has no model reference for this prop.
results.push({ status: 'refused', message: 'NO GRADE — insufficient data. The engine does not guess.' });
} else {
results.push({ status: 'graded', grade: data.grade, confidence: data.confidence });
}
} catch {
results.push({ status: 'error', message: 'The engine hit a wall on this leg.' });
}
setGrades([...results, ...legs.slice(results.length).map(() => ({ status: 'pending' as const }))]);
}
setGrades(results);
setGrading(false);
setGraded(true);
};
const gradedLegs = legs
? legs.map((l, i) => ({ leg: l, grade: grades[i] })).filter((x) => x.grade?.status === 'graded')
: [];
const addAllToParlay = () => {
for (const { leg, grade } of gradedLegs) {
addLeg({
sport,
player: leg.player.trim(),
stat: leg.stat,
line: Number(leg.line),
direction: leg.side,
grade: grade!.grade || '',
confidence: grade!.confidence ?? 0,
});
}
setAdded(true);
open();
};
const shareText = () => {
const rows = (legs || []).map((l, i) => {
const g = grades[i];
const mark = g?.status === 'graded' ? g.grade : 'NO GRADE';
return `${l.player} ${l.stat ? label(l.stat) : '?'} ${l.side === 'under' ? 'u' : 'o'}${l.line} · ${mark}`;
});
return ['VYNDR read my slip.', ...rows, 'vyndr.app/slip'].join('\n');
};
const copyShare = () => {
if (typeof navigator !== 'undefined' && navigator.clipboard) {
void navigator.clipboard.writeText(shareText());
setCopied(true);
setTimeout(() => setCopied(false), 1800);
}
};
return (
<section style={{ maxWidth: 720, margin: '0 auto', padding: '28px 16px 96px' }} onPaste={onPaste}>
<SectionHead accent="var(--g-a)"> SLIP READER</SectionHead>
<h1 className="mono" style={{ fontSize: 28, fontWeight: 800, letterSpacing: '-0.02em', margin: '8px 0 8px' }}>READ MY SLIP</h1>
<p style={{ fontSize: 14, color: 'var(--text-1)', lineHeight: 1.6, marginBottom: 20 }}>
Drop a bet-slip screenshot. The reader extracts your legs, you correct anything it was unsure
about, and the engine grades each one. Free tier reads one slip a day.
</p>
{/* Input mode */}
<div style={{ display: 'flex', gap: 8, marginBottom: 14 }}>
{(['image', 'text'] as const).map((m) => (
<button
key={m}
className="mono"
onClick={() => setMode(m)}
style={{
padding: '7px 14px', borderRadius: 6, fontSize: 12, fontWeight: 700, cursor: 'pointer',
background: mode === m ? 'var(--bg-2)' : 'transparent',
border: `1px solid ${mode === m ? 'var(--g-a)' : 'var(--border)'}`,
color: mode === m ? 'var(--g-a)' : 'var(--text-1)',
}}
>
{m === 'image' ? 'SCREENSHOT' : 'PASTE TEXT'}
</button>
))}
<div style={{ marginLeft: 'auto' }}>
<select
className="mono"
value={sport}
onChange={(e) => setSport(e.target.value as Sport)}
style={{ ...inputStyle(false), width: 'auto', fontSize: 12 }}
aria-label="Sport"
>
<option value="MLB">MLB</option>
<option value="NBA">NBA</option>
<option value="WNBA">WNBA</option>
</select>
</div>
</div>
{mode === 'image' ? (
<div
onClick={() => fileRef.current?.click()}
style={{
border: '1px dashed var(--border)', borderRadius: 8, padding: 28, textAlign: 'center',
cursor: 'pointer', background: 'var(--bg-1)', marginBottom: 16,
}}
>
<input
ref={fileRef}
type="file"
accept="image/*"
style={{ display: 'none' }}
onChange={(e) => onFile(e.target.files?.[0] ?? null)}
/>
{preview ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={preview} alt="Slip preview" style={{ maxWidth: '100%', maxHeight: 260, borderRadius: 6 }} />
) : (
<div className="mono" style={{ fontSize: 13, color: 'var(--text-1)' }}>
CLICK TO UPLOAD · OR PASTE A SCREENSHOT (CTRL+V)
</div>
)}
</div>
) : (
<div style={{ marginBottom: 16 }}>
<textarea
className="mono"
value={slipText}
onChange={(e) => setSlipText(e.target.value)}
placeholder={'Aaron Judge Over 1.5\nTotal Bases\n-115'}
rows={7}
style={{ ...inputStyle(false), resize: 'vertical', fontSize: 13 }}
/>
<div style={{ marginTop: 10 }}>
<VBtn variant="primary" disabled={parsing || !slipText.trim()} onClick={() => void parse({ text: slipText })}>
{parsing ? 'READING…' : 'READ SLIP TEXT'}
</VBtn>
</div>
</div>
)}
{parsing && mode === 'image' && (
<div className="mono" style={{ fontSize: 13, color: 'var(--g-a)', marginBottom: 16 }}>READING THE SLIP</div>
)}
{error && (
<div className="mono" style={{ fontSize: 13, color: 'var(--miss, #ff6b6b)', marginBottom: 16 }}>{error}</div>
)}
{/* Parsed legs — manual-correct UI */}
{legs && (
<div style={{ marginBottom: 20 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', marginBottom: 6 }}>
<span className="label">
EXTRACTED LEGS{book ? ` · ${book.toUpperCase()}` : ''}
</span>
<span className="mono" style={{ fontSize: 11, color: 'var(--text-1)' }}>
USER-SLIP VALUES your book&apos;s numbers, not ours
</span>
</div>
{legs.some((l) => l.needs_review) && (
<div className="mono" style={{ fontSize: 12, color: 'var(--amber)', marginBottom: 10 }}>
Amber fields were unreadable or uncertain. The reader does not guess check them before grading.
</div>
)}
{legs.map((leg, i) => {
const g = grades[i];
return (
<div
key={i}
style={{
border: `1px solid ${leg.needs_review && !legReady(leg) ? 'var(--amber)' : 'var(--border)'}`,
borderRadius: 8, padding: 12, marginBottom: 10, background: 'var(--bg-1)',
}}
>
<div style={{ display: 'grid', gridTemplateColumns: '2fr 1.6fr 0.9fr 1fr 1fr auto', gap: 8, alignItems: 'center' }}>
<input
className="mono"
value={leg.player}
placeholder="Player"
onChange={(e) => setField(i, 'player', e.target.value)}
style={inputStyle(leg.flagged.player)}
aria-label={`Leg ${i + 1} player`}
/>
<select
className="mono"
value={leg.stat}
onChange={(e) => setField(i, 'stat', e.target.value)}
style={inputStyle(leg.flagged.stat)}
aria-label={`Leg ${i + 1} stat`}
>
<option value="">STAT</option>
{STAT_OPTIONS[sport].map((s) => (
<option key={s} value={s}>{label(s)}</option>
))}
</select>
<input
className="mono"
value={leg.line}
placeholder="Line"
inputMode="decimal"
onChange={(e) => setField(i, 'line', e.target.value)}
style={inputStyle(leg.flagged.line)}
aria-label={`Leg ${i + 1} line`}
/>
<select
className="mono"
value={leg.side}
onChange={(e) => setField(i, 'side', e.target.value)}
style={inputStyle(leg.flagged.side)}
aria-label={`Leg ${i + 1} side`}
>
<option value="over">OVER</option>
<option value="under">UNDER</option>
</select>
<input
className="mono"
value={leg.odds}
placeholder="Odds"
onChange={(e) => setField(i, 'odds', e.target.value)}
style={inputStyle(leg.flagged.odds)}
aria-label={`Leg ${i + 1} odds`}
/>
<button
className="mono"
onClick={() => removeLeg(i)}
style={{ background: 'transparent', border: 'none', color: 'var(--text-1)', cursor: 'pointer', fontSize: 15 }}
aria-label={`Remove leg ${i + 1}`}
>
</button>
</div>
{g && g.status !== 'pending' && (
<div style={{ marginTop: 10, display: 'flex', alignItems: 'center', gap: 10 }}>
{g.status === 'graded' ? (
<>
<GradeBadge grade={g.grade || ''} />
{g.confidence != null && (
<span className="mono" style={{ fontSize: 12, color: 'var(--text-1)' }}>
{Math.round(g.confidence)}% confidence
</span>
)}
</>
) : (
<span className="mono" style={{ fontSize: 12, color: g.status === 'refused' ? 'var(--amber)' : 'var(--miss, #ff6b6b)' }}>
{g.message}
</span>
)}
</div>
)}
{g && g.status === 'pending' && grading && (
<div className="mono" style={{ marginTop: 10, fontSize: 12, color: 'var(--text-1)' }}>GRADING</div>
)}
</div>
);
})}
<div style={{ display: 'flex', gap: 10, marginTop: 14, flexWrap: 'wrap' }}>
<VBtn variant="primary" disabled={grading || legs.length === 0 || !legs.every(legReady)} onClick={() => void gradeAll()}>
{grading ? 'GRADING…' : 'CONFIRM + GRADE'}
</VBtn>
{graded && gradedLegs.length >= 1 && !added && (
<VBtn variant="outline" onClick={addAllToParlay}>
+ ADD {gradedLegs.length} TO PARLAY LAB
</VBtn>
)}
{added && (
<span className="mono" style={{ fontSize: 12, color: 'var(--g-a)', alignSelf: 'center' }}>
In the Lab
</span>
)}
</div>
{!legs.every(legReady) && (
<div className="mono" style={{ fontSize: 11, color: 'var(--text-1)', marginTop: 8 }}>
Every leg needs a player, stat and line before the engine will grade it.
</div>
)}
</div>
)}
{/* Share card — VYNDR read my slip */}
{graded && legs && (
<div
style={{
border: '1px solid var(--border)', borderRadius: 10, padding: 18,
background: 'var(--bg-1)', marginTop: 8,
}}
>
<div className="label" style={{ marginBottom: 10 }}>VYNDR READ MY SLIP</div>
{legs.map((l, i) => {
const g = grades[i];
return (
<div key={i} className="mono" style={{ display: 'flex', justifyContent: 'space-between', fontSize: 13, padding: '6px 0', borderBottom: i < legs.length - 1 ? '1px solid var(--border)' : 'none' }}>
<span>
{l.player || '—'} · {l.stat ? label(l.stat) : '?'} {l.side === 'under' ? 'u' : 'o'}{l.line || '?'}
{l.odds ? ` · ${l.odds}` : ''}
</span>
<span style={{ fontWeight: 800, color: g?.status === 'graded' ? 'var(--g-a)' : 'var(--amber)' }}>
{g?.status === 'graded' ? g.grade : 'NO GRADE'}
</span>
</div>
);
})}
<div style={{ display: 'flex', gap: 10, marginTop: 14 }}>
<VBtn variant={copied ? 'primary' : 'outline'} onClick={copyShare}>
{copied ? '✓ Copied' : 'Copy share text'}
</VBtn>
</div>
<div className="mono" style={{ fontSize: 10, color: 'var(--text-1)', marginTop: 10 }}>
Slip values are your book&apos;s numbers. Grades are model output. The Ledger keeps the receipts.
</div>
</div>
)}
</section>
);
}