'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; 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 = { 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(null); const [mode, setMode] = useState<'image' | 'text'>('image'); const [sport, setSport] = useState('MLB'); const [preview, setPreview] = useState(null); const [slipText, setSlipText] = useState(''); const [parsing, setParsing] = useState(false); const [error, setError] = useState(''); const [book, setBook] = useState(null); const [legs, setLegs] = useState(null); const [grades, setGrades] = useState([]); 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 (
▚ SLIP READER

READ MY SLIP

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.

{/* Input mode */}
{(['image', 'text'] as const).map((m) => ( ))}
{mode === 'image' ? (
fileRef.current?.click()} style={{ border: '1px dashed var(--border)', borderRadius: 8, padding: 28, textAlign: 'center', cursor: 'pointer', background: 'var(--bg-1)', marginBottom: 16, }} > onFile(e.target.files?.[0] ?? null)} /> {preview ? ( // eslint-disable-next-line @next/next/no-img-element Slip preview ) : (
CLICK TO UPLOAD · OR PASTE A SCREENSHOT (CTRL+V)
)}
) : (