Sessions 5-7a: 955 tests, deployment ready
This commit is contained in:
@@ -0,0 +1,241 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
|
||||
export type Sport = 'NBA' | 'MLB';
|
||||
|
||||
export interface OddsLine {
|
||||
player: string;
|
||||
stat_type: string;
|
||||
line: number;
|
||||
direction: string;
|
||||
book: string;
|
||||
}
|
||||
|
||||
interface SimplifiedSelectorProps {
|
||||
onScan: (leg: { player: string; stat_type: string; line: number; direction: string; sport: Sport }) => void;
|
||||
scanning?: boolean;
|
||||
oddsApiUrl?: string;
|
||||
nbaServiceUrl?: string;
|
||||
}
|
||||
|
||||
const NBA_STATS = ['points', 'rebounds', 'assists', 'threes', 'blocks', 'steals', 'pra', 'turnovers'];
|
||||
const MLB_STATS = [
|
||||
'strikeouts', 'hits_allowed', 'earned_runs', 'innings_pitched', 'walks_allowed',
|
||||
'hits', 'total_bases', 'rbi', 'runs', 'stolen_bases', 'home_runs', 'walks', 'singles', 'doubles',
|
||||
];
|
||||
|
||||
export default function SimplifiedSelector({
|
||||
onScan,
|
||||
scanning = false,
|
||||
oddsApiUrl,
|
||||
nbaServiceUrl,
|
||||
}: SimplifiedSelectorProps) {
|
||||
const [sport, setSport] = useState<Sport>('NBA');
|
||||
const [playerQuery, setPlayerQuery] = useState('');
|
||||
const [selectedPlayer, setSelectedPlayer] = useState('');
|
||||
const [statType, setStatType] = useState('');
|
||||
const [line, setLine] = useState<number | ''>('');
|
||||
const [direction, setDirection] = useState<'over' | 'under'>('over');
|
||||
const [suggestions, setSuggestions] = useState<string[]>([]);
|
||||
const [playerOdds, setPlayerOdds] = useState<OddsLine[]>([]);
|
||||
const [showSuggestions, setShowSuggestions] = useState(false);
|
||||
|
||||
const stats = sport === 'NBA' ? NBA_STATS : MLB_STATS;
|
||||
const apiBase = oddsApiUrl || process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000';
|
||||
const nbaBase = nbaServiceUrl || process.env.NEXT_PUBLIC_NBA_SERVICE_URL || 'http://localhost:8000';
|
||||
|
||||
// Reset when sport changes
|
||||
useEffect(() => {
|
||||
setPlayerQuery('');
|
||||
setSelectedPlayer('');
|
||||
setStatType('');
|
||||
setLine('');
|
||||
setPlayerOdds([]);
|
||||
setSuggestions([]);
|
||||
}, [sport]);
|
||||
|
||||
// Fetch player suggestions
|
||||
const searchPlayer = useCallback(
|
||||
async (name: string) => {
|
||||
if (name.length < 2) {
|
||||
setSuggestions([]);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await fetch(`${nbaBase}/players/search?name=${encodeURIComponent(name)}`);
|
||||
const data = await res.json();
|
||||
setSuggestions((data.results || []).map((r: any) => r.full_name).slice(0, 5));
|
||||
setShowSuggestions(true);
|
||||
} catch {
|
||||
setSuggestions([]);
|
||||
}
|
||||
},
|
||||
[nbaBase],
|
||||
);
|
||||
|
||||
// Fetch odds for selected player to pre-fill lines
|
||||
const fetchPlayerOdds = useCallback(
|
||||
async (playerName: string, selectedSport: Sport) => {
|
||||
try {
|
||||
const sportKey = selectedSport.toLowerCase();
|
||||
const res = await fetch(`${apiBase}/api/odds/${sportKey}`);
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
const props: OddsLine[] = (data.props || []).filter(
|
||||
(p: OddsLine) => p.player?.toLowerCase() === playerName.toLowerCase(),
|
||||
);
|
||||
setPlayerOdds(props);
|
||||
} catch {
|
||||
setPlayerOdds([]);
|
||||
}
|
||||
},
|
||||
[apiBase],
|
||||
);
|
||||
|
||||
// When player is selected, fetch their odds
|
||||
const selectPlayer = (name: string) => {
|
||||
setSelectedPlayer(name);
|
||||
setPlayerQuery(name);
|
||||
setShowSuggestions(false);
|
||||
setSuggestions([]);
|
||||
setStatType('');
|
||||
setLine('');
|
||||
fetchPlayerOdds(name, sport);
|
||||
};
|
||||
|
||||
// When stat changes, pre-fill line from odds
|
||||
useEffect(() => {
|
||||
if (!selectedPlayer || !statType) return;
|
||||
const match = playerOdds.find((o) => o.stat_type === statType);
|
||||
if (match) {
|
||||
setLine(match.line);
|
||||
setDirection((match.direction as 'over' | 'under') || 'over');
|
||||
} else {
|
||||
setLine('');
|
||||
}
|
||||
}, [statType, selectedPlayer, playerOdds]);
|
||||
|
||||
// Available stats for this player based on odds data
|
||||
const availableStats = playerOdds.length > 0
|
||||
? stats.filter((s) => playerOdds.some((o) => o.stat_type === s))
|
||||
: stats;
|
||||
|
||||
const canScan = selectedPlayer && statType && line !== '';
|
||||
|
||||
const handleScan = () => {
|
||||
if (!canScan || scanning) return;
|
||||
onScan({
|
||||
player: selectedPlayer,
|
||||
stat_type: statType,
|
||||
line: Number(line),
|
||||
direction,
|
||||
sport,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-5" data-testid="simplified-selector">
|
||||
{/* Sport Toggle */}
|
||||
<div className="flex gap-2" data-testid="sport-toggle">
|
||||
{(['NBA', 'MLB'] as Sport[]).map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
onClick={() => setSport(s)}
|
||||
className={`flex-1 py-3 rounded-xl font-mono font-bold text-sm transition ${
|
||||
sport === s
|
||||
? 'bg-[var(--cyan)] text-black'
|
||||
: 'bg-[var(--card)] border border-[var(--border)] text-[var(--text-muted)] hover:border-[var(--cyan)]'
|
||||
}`}
|
||||
data-testid={`sport-${s.toLowerCase()}`}
|
||||
aria-pressed={sport === s}
|
||||
>
|
||||
{s}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Player Search */}
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
placeholder={`Search ${sport} player...`}
|
||||
value={playerQuery}
|
||||
onChange={(e) => {
|
||||
setPlayerQuery(e.target.value);
|
||||
setSelectedPlayer('');
|
||||
searchPlayer(e.target.value);
|
||||
}}
|
||||
onBlur={() => setTimeout(() => setShowSuggestions(false), 200)}
|
||||
onFocus={() => suggestions.length > 0 && setShowSuggestions(true)}
|
||||
className="w-full px-4 py-3 rounded-xl bg-[var(--bg)] border border-[var(--border)] text-white placeholder:text-[var(--text-muted)] focus:outline-none focus:border-[var(--cyan)] text-sm"
|
||||
data-testid="player-search"
|
||||
/>
|
||||
{showSuggestions && suggestions.length > 0 && (
|
||||
<div className="absolute z-10 top-full mt-1 w-full bg-[var(--card)] border border-[var(--border)] rounded-xl overflow-hidden" data-testid="player-suggestions">
|
||||
{suggestions.map((name) => (
|
||||
<button
|
||||
key={name}
|
||||
onMouseDown={() => selectPlayer(name)}
|
||||
className="block w-full text-left px-4 py-2 text-sm hover:bg-[var(--border)] transition"
|
||||
>
|
||||
{name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Stat Dropdown */}
|
||||
{selectedPlayer && (
|
||||
<select
|
||||
value={statType}
|
||||
onChange={(e) => setStatType(e.target.value)}
|
||||
className="w-full px-4 py-3 rounded-xl bg-[var(--bg)] border border-[var(--border)] text-white text-sm"
|
||||
data-testid="stat-dropdown"
|
||||
>
|
||||
<option value="">Select stat...</option>
|
||||
{availableStats.map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{s}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
|
||||
{/* Line + Direction */}
|
||||
{selectedPlayer && statType && (
|
||||
<div className="flex gap-3">
|
||||
<select
|
||||
value={direction}
|
||||
onChange={(e) => setDirection(e.target.value as 'over' | 'under')}
|
||||
className="px-4 py-3 rounded-xl bg-[var(--bg)] border border-[var(--border)] text-white text-sm"
|
||||
data-testid="direction-select"
|
||||
>
|
||||
<option value="over">Over</option>
|
||||
<option value="under">Under</option>
|
||||
</select>
|
||||
<input
|
||||
type="number"
|
||||
step="0.5"
|
||||
placeholder="Line"
|
||||
value={line}
|
||||
onChange={(e) => setLine(e.target.value ? Number(e.target.value) : '')}
|
||||
className="flex-1 px-4 py-3 rounded-xl bg-[var(--bg)] border border-[var(--border)] text-white placeholder:text-[var(--text-muted)] text-sm"
|
||||
data-testid="line-input"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Scan Button */}
|
||||
<button
|
||||
onClick={handleScan}
|
||||
disabled={!canScan || scanning}
|
||||
className="w-full py-3 bg-[var(--cyan)] text-black rounded-xl font-medium hover:bg-[var(--cyan-hover)] transition disabled:opacity-40"
|
||||
data-testid="scan-button"
|
||||
>
|
||||
{scanning ? 'Reading...' : 'Read Prop'}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user