Merge S9 (a1): slip reader — zero-API OCR
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,31 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:3000';
|
||||
|
||||
/**
|
||||
* A1 S9 — Slip Reader proxy. Forwards POST /api/slips/parse (base64 image
|
||||
* or slip text) to Express with the Authorization header (S25 rule: the
|
||||
* browser never reaches Express directly). Body can be ~5MB of base64 —
|
||||
* stream it through as text.
|
||||
*/
|
||||
export async function POST(req: NextRequest) {
|
||||
const body = await req.text();
|
||||
try {
|
||||
const upstream = await fetch(`${BACKEND_URL}/api/slips/parse`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(req.headers.get('authorization')
|
||||
? { Authorization: req.headers.get('authorization')! }
|
||||
: {}),
|
||||
},
|
||||
body,
|
||||
});
|
||||
const data = await upstream.json().catch(() => ({}));
|
||||
return NextResponse.json(data, { status: upstream.status });
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'The reader is offline. Try again shortly.' }, { status: 502 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,525 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
@@ -24,6 +24,7 @@ const GATED_ROUTES = [
|
||||
'/settings',
|
||||
'/notifications',
|
||||
'/invite',
|
||||
'/slip', // A1 S9 — slip reader (personal surface: reads the user's own bet slip)
|
||||
];
|
||||
|
||||
/* Open — reachable without auth (marketing + the free funnel + auth itself). */
|
||||
|
||||
Reference in New Issue
Block a user