From 850fe60e8f9767f4aec7aef3c165078062583d0c Mon Sep 17 00:00:00 2001 From: Kev Date: Sun, 22 Mar 2026 10:11:48 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20Features=203.2=20+=203.3=20=E2=80=94=20?= =?UTF-8?q?Scan=20UI=20+=20Bet=20Tracker?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- web/src/app/scan/page.tsx | 292 +++++++++++++++++++++++++++++++++++ web/src/app/tracker/page.tsx | 288 ++++++++++++++++++++++++++++++++++ web/src/lib/api.ts | 57 +++++++ 3 files changed, 637 insertions(+) create mode 100644 web/src/app/scan/page.tsx create mode 100644 web/src/app/tracker/page.tsx create mode 100644 web/src/lib/api.ts diff --git a/web/src/app/scan/page.tsx b/web/src/app/scan/page.tsx new file mode 100644 index 0000000..8f6c8e4 --- /dev/null +++ b/web/src/app/scan/page.tsx @@ -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([{ ...emptyLeg }]); + const [results, setResults] = useState(null); + const [scanning, setScanning] = useState(false); + const [error, setError] = useState(''); + const [playerSuggestions, setPlayerSuggestions] = useState([]); + 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 ( +
+
+

Scan Parlay

+ {results && results.scans_remaining != null && ( +
+ {results.scan_count} of 5 scans used +
+ )} +
+ + {/* Leg Builder */} +
+ {legs.map((leg, i) => ( +
+
+ Leg {i + 1} + {legs.length > 1 && ( + + )} +
+
+
+ { + 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 && ( +
+ {playerSuggestions.map((name) => ( + + ))} +
+ )} +
+ + 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)]" + /> + + +
+
+ ))} +
+ +
+ {legs.length < 12 && ( + + )} + +
+ + {error &&
{error}
} + + {/* Results */} + {results && ( +
+ {/* Overall Grade */} +
+

Parlay Grade

+
+ +
+ {results.correlation_flags.length > 0 && ( +

+ {results.correlation_flags.length} correlation flag{results.correlation_flags.length > 1 ? 's' : ''} detected +

+ )} +
+ + {/* Individual Legs */} + {results.legs.map((leg) => ( +
+
+
+

{leg.player}

+

+ {leg.direction.charAt(0).toUpperCase() + leg.direction.slice(1)} {leg.line} {leg.stat_type} +

+
+ +
+

{leg.reasoning_summary}

+ {leg.edge_pct !== 0 && ( + 0 ? 'text-[var(--grade-a)]' : 'text-[var(--grade-d)]'}`}> + Edge: {leg.edge_pct > 0 ? '+' : ''}{leg.edge_pct}% + + )} + {leg.kill_conditions.length > 0 && ( +
+ {leg.kill_conditions.map((k) => ( + + {k.code} + + ))} +
+ )} +
+ ))} + + {/* Correlations */} + {results.correlation_flags.length > 0 && ( +
+

Correlations

+ {results.correlation_flags.map((flag, i) => ( +
+ + {flag.impact === 'major_negative' ? '!!' : flag.impact === 'positive' ? '+' : '!'} + + {flag.detail} +
+ ))} +
+ )} + + {/* Upgrade Pitch */} + {results.upgrade_pitch && ( +
+

{results.upgrade_pitch.hook}

+

{results.upgrade_pitch.insight}

+ + {results.upgrade_pitch.cta} + +
+ )} + + {/* Actions */} +
+ +
+
+ )} +
+ ); +} diff --git a/web/src/app/tracker/page.tsx b/web/src/app/tracker/page.tsx new file mode 100644 index 0000000..430cef7 --- /dev/null +++ b/web/src/app/tracker/page.tsx @@ -0,0 +1,288 @@ +'use client'; + +import { useState, useEffect, useCallback } from 'react'; + +type Period = 'weekly' | 'monthly' | 'all_time'; + +interface PerfStats { + roi: number; + win_rate: number; + sample_size: number; + total_wagered: number; + total_profit: number; +} + +interface Bet { + id: string; + amount: number; + potential_payout: number; + book: string; + bet_type: string; + status: string; + slip_data: { legs: any[]; total_odds?: number; scan_session_id?: string }; + submission_method: string; + placed_at: string; + settled_at: string | null; +} + +const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000'; + +function getAuthHeaders(): Record { + const token = typeof window !== 'undefined' ? localStorage.getItem('sb-token') : null; + return token ? { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' } : { 'Content-Type': 'application/json' }; +} + +export default function TrackerPage() { + const [period, setPeriod] = useState('monthly'); + const [performance, setPerformance] = useState>({ weekly: null, monthly: null, all_time: null }); + const [bets, setBets] = useState([]); + const [total, setTotal] = useState(0); + const [statusFilter, setStatusFilter] = useState(''); + const [loading, setLoading] = useState(true); + const [settlingId, setSettlingId] = useState(null); + const [settleStatus, setSettleStatus] = useState('won'); + + // Quick slip state + const [showQuickSlip, setShowQuickSlip] = useState(false); + const [slipPlayer, setSlipPlayer] = useState(''); + const [slipStat, setSlipStat] = useState('points'); + const [slipLine, setSlipLine] = useState(''); + const [slipDirection, setSlipDirection] = useState('over'); + const [slipOdds, setSlipOdds] = useState('-110'); + const [slipAmount, setSlipAmount] = useState(''); + const [slipBook, setSlipBook] = useState('draftkings'); + + const fetchData = useCallback(async () => { + setLoading(true); + try { + const params = new URLSearchParams({ limit: '20', offset: '0' }); + if (statusFilter) params.set('status', statusFilter); + + const [perfRes, betsRes] = await Promise.all([ + fetch(`${API_BASE}/api/bets/performance`, { headers: getAuthHeaders() }), + fetch(`${API_BASE}/api/bets?${params}`, { headers: getAuthHeaders() }), + ]); + + if (perfRes.ok) { + const perfData = await perfRes.json(); + setPerformance(perfData); + } + if (betsRes.ok) { + const betsData = await betsRes.json(); + setBets(betsData.bets || []); + setTotal(betsData.total || 0); + } + } catch (e) { + console.error('Failed to fetch tracker data'); + } finally { + setLoading(false); + } + }, [statusFilter]); + + useEffect(() => { fetchData(); }, [fetchData]); + + const handleSettle = async (betId: string) => { + try { + const res = await fetch(`${API_BASE}/api/bets/${betId}/settle`, { + method: 'PATCH', + headers: getAuthHeaders(), + body: JSON.stringify({ status: settleStatus }), + }); + if (res.ok) { + setSettlingId(null); + fetchData(); + } + } catch (e) { console.error('Settle failed'); } + }; + + const handleQuickSlip = async (e: React.FormEvent) => { + e.preventDefault(); + try { + const res = await fetch(`${API_BASE}/api/bets/quickslip`, { + method: 'POST', + headers: getAuthHeaders(), + body: JSON.stringify({ + legs: [{ player: slipPlayer, stat_type: slipStat, line: Number(slipLine), direction: slipDirection, odds: Number(slipOdds) }], + amount: Number(slipAmount), + book: slipBook, + bet_type: 'straight', + }), + }); + if (res.ok) { + setShowQuickSlip(false); + setSlipPlayer(''); setSlipLine(''); setSlipAmount(''); + fetchData(); + } + } catch (e) { console.error('Quick slip failed'); } + }; + + const currentPerf = performance[period]; + + return ( +
+

Bet Tracker

+ + {/* Performance Cards */} +
+
+ {(['weekly', 'monthly', 'all_time'] as Period[]).map((p) => ( + + ))} +
+
+
+
= 0 ? 'text-[var(--grade-a)]' : 'text-[var(--grade-d)]'}`}> + {currentPerf ? `${currentPerf.roi > 0 ? '+' : ''}${currentPerf.roi}%` : '--'} +
+
ROI
+
+
+
+ {currentPerf ? `${currentPerf.win_rate}%` : '--'} +
+
Win Rate
+
+
+
+ {currentPerf?.sample_size ?? '--'} +
+
Bets
+
+
+
+ + {/* Quick Submit */} +
+ +
+ + {showQuickSlip && ( +
+
+ setSlipPlayer(e.target.value)} required + className="px-3 py-2 rounded-lg bg-[var(--bg)] border border-[var(--border)] text-sm text-white placeholder:text-[var(--text-muted)]" /> + + setSlipLine(e.target.value)} required + className="px-3 py-2 rounded-lg bg-[var(--bg)] border border-[var(--border)] text-sm text-white placeholder:text-[var(--text-muted)]" /> + + setSlipOdds(e.target.value)} required + className="px-3 py-2 rounded-lg bg-[var(--bg)] border border-[var(--border)] text-sm text-white placeholder:text-[var(--text-muted)]" /> + setSlipAmount(e.target.value)} required + className="px-3 py-2 rounded-lg bg-[var(--bg)] border border-[var(--border)] text-sm text-white placeholder:text-[var(--text-muted)]" /> +
+
+ + +
+
+ )} + + {/* Bet History */} +
+ {['', 'pending', 'won', 'lost', 'push'].map((s) => ( + + ))} +
+ +
+ {loading ? ( +

Loading...

+ ) : bets.length === 0 ? ( +

No bets yet. Submit your first bet above.

+ ) : ( + bets.map((bet) => ( +
+
+
+
+ {(bet.slip_data?.legs || []).map((l: any) => + `${l.player} ${l.direction?.[0]?.toUpperCase() || ''}${l.line} ${l.stat_type}` + ).join(' / ') || `${bet.bet_type} bet`} +
+
+ {bet.book} | ${bet.amount} | {new Date(bet.placed_at).toLocaleDateString()} +
+
+
+ + {bet.status.toUpperCase()} + + {bet.status === 'won' && ( +
+ +${(bet.potential_payout - bet.amount).toFixed(2)} +
+ )} +
+
+ {bet.status === 'pending' && ( +
+ {settlingId === bet.id ? ( + <> + + + + + ) : ( + + )} +
+ )} +
+ )) + )} +
+ + {bets.length > 0 && bets.length < total && ( + + )} +
+ ); +} diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts new file mode 100644 index 0000000..1d5640f --- /dev/null +++ b/web/src/lib/api.ts @@ -0,0 +1,57 @@ +const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000'; +const NBA_SERVICE = process.env.NEXT_PUBLIC_NBA_SERVICE_URL || 'http://localhost:8000'; + +async function fetchAPI(path: string, options: RequestInit = {}) { + const token = typeof window !== 'undefined' ? localStorage.getItem('sb-token') : null; + const headers: Record = { + 'Content-Type': 'application/json', + ...(options.headers as Record || {}), + }; + if (token) headers['Authorization'] = `Bearer ${token}`; + + const res = await fetch(`${API_BASE}${path}`, { ...options, headers }); + if (!res.ok) { + const body = await res.json().catch(() => ({ error: res.statusText })); + const err = new Error(body.error || 'Request failed'); + (err as any).status = res.status; + (err as any).body = body; + throw err; + } + return res.json(); +} + +export async function scanParlay(legs: any[]) { + return fetchAPI('/api/scan/parlay', { + method: 'POST', + body: JSON.stringify({ legs }), + }); +} + +export async function searchPlayers(name: string) { + const res = await fetch(`${NBA_SERVICE}/players/search?name=${encodeURIComponent(name)}`); + if (!res.ok) return { results: [] }; + return res.json(); +} + +export async function submitQuickslip(data: any) { + return fetchAPI('/api/bets/quickslip', { + method: 'POST', + body: JSON.stringify(data), + }); +} + +export async function getBets(params: Record = {}) { + const qs = new URLSearchParams(params).toString(); + return fetchAPI(`/api/bets?${qs}`); +} + +export async function settleBet(betId: string, status: string) { + return fetchAPI(`/api/bets/${betId}/settle`, { + method: 'PATCH', + body: JSON.stringify({ status }), + }); +} + +export async function getPerformance() { + return fetchAPI('/api/bets/performance'); +}