Files
vyndr/web/src/components/SimplifiedSelector.tsx
T

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>
);
}