feat: Features 3.2 + 3.3 — Scan UI + Bet Tracker

Scan UI (/scan):
- Leg builder with player autocomplete, stat/line/direction/book
- 2-12 legs, add/remove
- Calls POST /api/scan/parlay, displays grade results
- Color-coded grades (A/B/C/D), correlation flags, kill conditions
- Scan counter, upgrade pitch modal at limit
- New Scan / Save actions

Bet Tracker (/tracker):
- Performance cards: ROI, Win Rate, Bets with period toggle
- Quick Slip form for fast bet entry
- Bet history with status/book filters
- Inline settle modal (won/lost/push/void)
- Profit display on settled bets

Shared API client library (lib/api.ts).
Build clean: 9 static pages generated.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Kev
2026-03-22 10:11:48 -04:00
parent bfa8345ebf
commit 850fe60e8f
3 changed files with 637 additions and 0 deletions
+292
View File
@@ -0,0 +1,292 @@
'use client';
import { useState, useCallback } from 'react';
import GradeCard from '@/components/GradeCard';
interface Leg {
player: string;
stat_type: string;
line: number | string;
direction: string;
book: string;
}
interface LegResult {
index: number;
player: string;
stat_type: string;
line: number;
direction: string;
grade: string;
confidence: number;
edge_pct: number;
kill_conditions: { code: string; reason: string }[];
reasoning_summary: string;
}
interface ScanResult {
scan_id: string;
parlay_grade: string;
parlay_confidence: number;
correlation_flags: { type: string; legs: number[]; detail: string; impact: string }[];
legs: LegResult[];
scan_count: number;
scans_remaining: number | null;
upgrade_pitch: {
hook: string;
insight: string;
cta: string;
tier_recommended: string;
founder_price: string;
} | null;
}
const STAT_TYPES = ['points', 'rebounds', 'assists', 'threes', 'blocks', 'steals', 'pra', 'turnovers'];
const BOOKS = ['draftkings', 'fanduel', 'betmgm'];
const emptyLeg: Leg = { player: '', stat_type: 'points', line: '', direction: 'over', book: 'draftkings' };
export default function ScanPage() {
const [legs, setLegs] = useState<Leg[]>([{ ...emptyLeg }]);
const [results, setResults] = useState<ScanResult | null>(null);
const [scanning, setScanning] = useState(false);
const [error, setError] = useState('');
const [playerSuggestions, setPlayerSuggestions] = useState<string[]>([]);
const [activeInput, setActiveInput] = useState(-1);
const updateLeg = (index: number, field: keyof Leg, value: string | number) => {
setLegs((prev) => prev.map((l, i) => (i === index ? { ...l, [field]: value } : l)));
};
const addLeg = () => {
if (legs.length < 12) setLegs([...legs, { ...emptyLeg }]);
};
const removeLeg = (index: number) => {
if (legs.length > 1) setLegs(legs.filter((_, i) => i !== index));
};
const searchPlayer = useCallback(async (name: string, index: number) => {
if (name.length < 2) { setPlayerSuggestions([]); return; }
setActiveInput(index);
try {
const res = await fetch(`${process.env.NEXT_PUBLIC_NBA_SERVICE_URL || 'http://localhost:8000'}/players/search?name=${encodeURIComponent(name)}`);
const data = await res.json();
setPlayerSuggestions((data.results || []).map((r: any) => r.full_name).slice(0, 5));
} catch { setPlayerSuggestions([]); }
}, []);
const scan = async () => {
const validLegs = legs.filter((l) => l.player && l.line);
if (validLegs.length < 2) { setError('Add at least 2 legs'); return; }
setScanning(true);
setError('');
setResults(null);
try {
const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000'}/api/scan/parlay`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(typeof window !== 'undefined' && localStorage.getItem('sb-token')
? { Authorization: `Bearer ${localStorage.getItem('sb-token')}` }
: {}),
},
body: JSON.stringify({ legs: validLegs.map((l) => ({ ...l, line: Number(l.line) })) }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Scan failed');
setResults(data);
} catch (e: any) {
setError(e.message);
} finally {
setScanning(false);
}
};
return (
<section className="py-8 px-4 max-w-3xl mx-auto">
<div className="flex justify-between items-center mb-8">
<h1 className="text-3xl font-bold">Scan Parlay</h1>
{results && results.scans_remaining != null && (
<div className="text-sm text-[var(--text-muted)]">
<span className="font-mono">{results.scan_count}</span> of 5 scans used
</div>
)}
</div>
{/* Leg Builder */}
<div className="space-y-4 mb-6">
{legs.map((leg, i) => (
<div key={i} className="p-4 rounded-xl bg-[var(--card)] border border-[var(--border)]">
<div className="flex justify-between items-center mb-3">
<span className="text-xs text-[var(--text-muted)] font-mono">Leg {i + 1}</span>
{legs.length > 1 && (
<button onClick={() => removeLeg(i)} className="text-xs text-[var(--grade-d)] hover:text-white">Remove</button>
)}
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<div className="col-span-2 relative">
<input
placeholder="Player name"
value={leg.player}
onChange={(e) => {
updateLeg(i, 'player', e.target.value);
searchPlayer(e.target.value, i);
}}
onBlur={() => setTimeout(() => setPlayerSuggestions([]), 200)}
className="w-full px-3 py-2 rounded-lg bg-[var(--bg)] border border-[var(--border)] text-sm text-white placeholder:text-[var(--text-muted)] focus:outline-none focus:border-[var(--accent)]"
/>
{activeInput === i && playerSuggestions.length > 0 && (
<div className="absolute z-10 top-full mt-1 w-full bg-[var(--card)] border border-[var(--border)] rounded-lg overflow-hidden">
{playerSuggestions.map((name) => (
<button
key={name}
onMouseDown={() => { updateLeg(i, 'player', name); setPlayerSuggestions([]); }}
className="block w-full text-left px-3 py-2 text-sm hover:bg-[var(--border)] transition"
>
{name}
</button>
))}
</div>
)}
</div>
<select
value={leg.stat_type}
onChange={(e) => updateLeg(i, 'stat_type', e.target.value)}
className="px-3 py-2 rounded-lg bg-[var(--bg)] border border-[var(--border)] text-sm text-white"
>
{STAT_TYPES.map((s) => <option key={s} value={s}>{s}</option>)}
</select>
<input
type="number"
step="0.5"
placeholder="Line"
value={leg.line}
onChange={(e) => updateLeg(i, 'line', e.target.value)}
className="px-3 py-2 rounded-lg bg-[var(--bg)] border border-[var(--border)] text-sm text-white placeholder:text-[var(--text-muted)]"
/>
<select
value={leg.direction}
onChange={(e) => updateLeg(i, 'direction', e.target.value)}
className="px-3 py-2 rounded-lg bg-[var(--bg)] border border-[var(--border)] text-sm text-white"
>
<option value="over">Over</option>
<option value="under">Under</option>
</select>
<select
value={leg.book}
onChange={(e) => updateLeg(i, 'book', e.target.value)}
className="px-3 py-2 rounded-lg bg-[var(--bg)] border border-[var(--border)] text-sm text-white"
>
{BOOKS.map((b) => <option key={b} value={b}>{b}</option>)}
</select>
</div>
</div>
))}
</div>
<div className="flex gap-3 mb-8">
{legs.length < 12 && (
<button onClick={addLeg} className="px-4 py-2 text-sm border border-[var(--border)] rounded-lg hover:border-[var(--accent)] transition">
+ Add Leg
</button>
)}
<button
onClick={scan}
disabled={scanning || legs.filter((l) => l.player && l.line).length < 2}
className="flex-1 py-3 bg-[var(--accent)] text-white rounded-xl font-medium hover:opacity-90 transition disabled:opacity-40"
>
{scanning ? 'Scanning...' : 'Scan Parlay'}
</button>
</div>
{error && <div className="p-4 mb-6 rounded-xl bg-[var(--grade-d)]/10 border border-[var(--grade-d)] text-[var(--grade-d)] text-sm">{error}</div>}
{/* Results */}
{results && (
<div className="space-y-6">
{/* Overall Grade */}
<div className="p-6 rounded-2xl bg-[var(--card)] border border-[var(--border)] text-center">
<p className="text-sm text-[var(--text-muted)] mb-3">Parlay Grade</p>
<div className="flex justify-center mb-3">
<GradeCard grade={results.parlay_grade} confidence={results.parlay_confidence} />
</div>
{results.correlation_flags.length > 0 && (
<p className="text-xs text-[var(--text-muted)]">
{results.correlation_flags.length} correlation flag{results.correlation_flags.length > 1 ? 's' : ''} detected
</p>
)}
</div>
{/* Individual Legs */}
{results.legs.map((leg) => (
<div key={leg.index} className="p-5 rounded-xl bg-[var(--card)] border border-[var(--border)]">
<div className="flex justify-between items-start mb-3">
<div>
<h3 className="font-semibold">{leg.player}</h3>
<p className="text-sm text-[var(--text-muted)]">
{leg.direction.charAt(0).toUpperCase() + leg.direction.slice(1)} {leg.line} {leg.stat_type}
</p>
</div>
<GradeCard grade={leg.grade} confidence={leg.confidence} />
</div>
<p className="text-sm text-[var(--text-muted)] leading-relaxed mb-2">{leg.reasoning_summary}</p>
{leg.edge_pct !== 0 && (
<span className={`text-xs font-mono ${leg.edge_pct > 0 ? 'text-[var(--grade-a)]' : 'text-[var(--grade-d)]'}`}>
Edge: {leg.edge_pct > 0 ? '+' : ''}{leg.edge_pct}%
</span>
)}
{leg.kill_conditions.length > 0 && (
<div className="mt-2 flex flex-wrap gap-1">
{leg.kill_conditions.map((k) => (
<span key={k.code} className="px-2 py-0.5 text-xs bg-[var(--grade-d)]/10 text-[var(--grade-d)] rounded">
{k.code}
</span>
))}
</div>
)}
</div>
))}
{/* Correlations */}
{results.correlation_flags.length > 0 && (
<div className="p-5 rounded-xl bg-[var(--card)] border border-[var(--border)]">
<h3 className="font-semibold mb-3">Correlations</h3>
{results.correlation_flags.map((flag, i) => (
<div key={i} className="flex items-start gap-2 text-sm mb-2">
<span className={flag.impact === 'major_negative' ? 'text-[var(--grade-d)]' : flag.impact === 'positive' ? 'text-[var(--grade-a)]' : 'text-[var(--grade-c)]'}>
{flag.impact === 'major_negative' ? '!!' : flag.impact === 'positive' ? '+' : '!'}
</span>
<span className="text-[var(--text-muted)]">{flag.detail}</span>
</div>
))}
</div>
)}
{/* Upgrade Pitch */}
{results.upgrade_pitch && (
<div className="p-6 rounded-2xl bg-[var(--accent)]/10 border border-[var(--accent)]">
<p className="font-semibold mb-2">{results.upgrade_pitch.hook}</p>
<p className="text-sm text-[var(--text-muted)] mb-4">{results.upgrade_pitch.insight}</p>
<a href="#pricing" className="inline-block px-6 py-2 bg-[var(--accent)] text-white rounded-lg text-sm font-medium hover:opacity-90 transition">
{results.upgrade_pitch.cta}
</a>
</div>
)}
{/* Actions */}
<div className="flex gap-3">
<button
onClick={() => { setResults(null); setLegs([{ ...emptyLeg }]); }}
className="flex-1 py-3 border border-[var(--border)] rounded-xl text-sm hover:border-[var(--accent)] transition"
>
New Scan
</button>
</div>
</div>
)}
</section>
);
}