242 lines
7.9 KiB
TypeScript
242 lines
7.9 KiB
TypeScript
'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>
|
|
);
|
|
}
|