4e49ee0990
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>
526 lines
20 KiB
TypeScript
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'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's numbers. Grades are model output. The Ledger keeps the receipts.
|
|
</div>
|
|
</div>
|
|
)}
|
|
</section>
|
|
);
|
|
}
|