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
+288
View File
@@ -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<string, string> {
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<Period>('monthly');
const [performance, setPerformance] = useState<Record<Period, PerfStats | null>>({ weekly: null, monthly: null, all_time: null });
const [bets, setBets] = useState<Bet[]>([]);
const [total, setTotal] = useState(0);
const [statusFilter, setStatusFilter] = useState('');
const [loading, setLoading] = useState(true);
const [settlingId, setSettlingId] = useState<string | null>(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 (
<section className="py-8 px-4 max-w-4xl mx-auto">
<h1 className="text-3xl font-bold mb-8">Bet Tracker</h1>
{/* Performance Cards */}
<div className="mb-6">
<div className="flex gap-2 mb-4">
{(['weekly', 'monthly', 'all_time'] as Period[]).map((p) => (
<button
key={p}
onClick={() => setPeriod(p)}
className={`px-3 py-1 text-sm rounded-lg transition ${period === p ? 'bg-[var(--accent)] text-white' : 'bg-[var(--card)] text-[var(--text-muted)] hover:text-white'}`}
>
{p === 'all_time' ? 'All Time' : p.charAt(0).toUpperCase() + p.slice(1)}
</button>
))}
</div>
<div className="grid grid-cols-3 gap-4">
<div className="p-4 rounded-xl bg-[var(--card)] border border-[var(--border)] text-center">
<div className={`text-2xl font-mono font-bold ${(currentPerf?.roi ?? 0) >= 0 ? 'text-[var(--grade-a)]' : 'text-[var(--grade-d)]'}`}>
{currentPerf ? `${currentPerf.roi > 0 ? '+' : ''}${currentPerf.roi}%` : '--'}
</div>
<div className="text-xs text-[var(--text-muted)] mt-1">ROI</div>
</div>
<div className="p-4 rounded-xl bg-[var(--card)] border border-[var(--border)] text-center">
<div className="text-2xl font-mono font-bold">
{currentPerf ? `${currentPerf.win_rate}%` : '--'}
</div>
<div className="text-xs text-[var(--text-muted)] mt-1">Win Rate</div>
</div>
<div className="p-4 rounded-xl bg-[var(--card)] border border-[var(--border)] text-center">
<div className="text-2xl font-mono font-bold">
{currentPerf?.sample_size ?? '--'}
</div>
<div className="text-xs text-[var(--text-muted)] mt-1">Bets</div>
</div>
</div>
</div>
{/* Quick Submit */}
<div className="flex gap-3 mb-8">
<button
onClick={() => setShowQuickSlip(!showQuickSlip)}
className="px-4 py-2 text-sm border border-[var(--border)] rounded-lg hover:border-[var(--accent)] transition"
>
Quick Slip
</button>
</div>
{showQuickSlip && (
<form onSubmit={handleQuickSlip} className="p-4 rounded-xl bg-[var(--card)] border border-[var(--border)] mb-8 space-y-3">
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
<input placeholder="Player" value={slipPlayer} onChange={(e) => 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)]" />
<select value={slipStat} onChange={(e) => setSlipStat(e.target.value)}
className="px-3 py-2 rounded-lg bg-[var(--bg)] border border-[var(--border)] text-sm text-white">
{['points', 'rebounds', 'assists', 'threes', 'blocks', 'steals', 'pra', 'turnovers'].map((s) => (
<option key={s} value={s}>{s}</option>
))}
</select>
<input type="number" step="0.5" placeholder="Line" value={slipLine} onChange={(e) => 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)]" />
<select value={slipDirection} onChange={(e) => setSlipDirection(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>
<input type="number" placeholder="Odds (e.g. -110)" value={slipOdds} onChange={(e) => 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)]" />
<input type="number" step="0.01" placeholder="Amount ($)" value={slipAmount} onChange={(e) => 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)]" />
</div>
<div className="flex gap-3">
<select value={slipBook} onChange={(e) => setSlipBook(e.target.value)}
className="px-3 py-2 rounded-lg bg-[var(--bg)] border border-[var(--border)] text-sm text-white">
{['draftkings', 'fanduel', 'betmgm'].map((b) => <option key={b} value={b}>{b}</option>)}
</select>
<button type="submit" className="px-6 py-2 bg-[var(--accent)] text-white rounded-lg text-sm font-medium hover:opacity-90 transition">
Submit Bet
</button>
</div>
</form>
)}
{/* Bet History */}
<div className="mb-4 flex gap-2">
{['', 'pending', 'won', 'lost', 'push'].map((s) => (
<button
key={s}
onClick={() => setStatusFilter(s)}
className={`px-3 py-1 text-xs rounded-lg transition ${statusFilter === s ? 'bg-[var(--accent)] text-white' : 'bg-[var(--card)] text-[var(--text-muted)]'}`}
>
{s || 'All'}
</button>
))}
</div>
<div className="space-y-3">
{loading ? (
<p className="text-[var(--text-muted)] text-sm">Loading...</p>
) : bets.length === 0 ? (
<p className="text-[var(--text-muted)] text-sm">No bets yet. Submit your first bet above.</p>
) : (
bets.map((bet) => (
<div key={bet.id} className="p-4 rounded-xl bg-[var(--card)] border border-[var(--border)]">
<div className="flex justify-between items-start">
<div>
<div className="text-sm font-medium">
{(bet.slip_data?.legs || []).map((l: any) =>
`${l.player} ${l.direction?.[0]?.toUpperCase() || ''}${l.line} ${l.stat_type}`
).join(' / ') || `${bet.bet_type} bet`}
</div>
<div className="text-xs text-[var(--text-muted)] mt-1">
{bet.book} | ${bet.amount} | {new Date(bet.placed_at).toLocaleDateString()}
</div>
</div>
<div className="text-right">
<span className={`text-xs font-mono px-2 py-0.5 rounded ${
bet.status === 'won' ? 'bg-[var(--grade-a)]/10 text-[var(--grade-a)]' :
bet.status === 'lost' ? 'bg-[var(--grade-d)]/10 text-[var(--grade-d)]' :
'bg-[var(--border)] text-[var(--text-muted)]'
}`}>
{bet.status.toUpperCase()}
</span>
{bet.status === 'won' && (
<div className="text-xs text-[var(--grade-a)] mt-1 font-mono">
+${(bet.potential_payout - bet.amount).toFixed(2)}
</div>
)}
</div>
</div>
{bet.status === 'pending' && (
<div className="mt-3 flex items-center gap-2">
{settlingId === bet.id ? (
<>
<select value={settleStatus} onChange={(e) => setSettleStatus(e.target.value)}
className="px-2 py-1 text-xs rounded bg-[var(--bg)] border border-[var(--border)] text-white">
<option value="won">Won</option>
<option value="lost">Lost</option>
<option value="push">Push</option>
<option value="void">Void</option>
</select>
<button onClick={() => handleSettle(bet.id)} className="px-3 py-1 text-xs bg-[var(--accent)] text-white rounded hover:opacity-90">
Confirm
</button>
<button onClick={() => setSettlingId(null)} className="px-3 py-1 text-xs text-[var(--text-muted)]">
Cancel
</button>
</>
) : (
<button onClick={() => setSettlingId(bet.id)} className="text-xs text-[var(--accent)] hover:underline">
Settle
</button>
)}
</div>
)}
</div>
))
)}
</div>
{bets.length > 0 && bets.length < total && (
<button className="w-full mt-4 py-2 text-sm text-[var(--text-muted)] hover:text-white transition">
Load More
</button>
)}
</section>
);
}