Spaces:
Running
Running
| // src/components/PlayerModals.jsx | |
| import React,{ useState, useEffect, useContext } from "react"; | |
| import { Search, Plus } from "lucide-react"; | |
| import { getPlayerPrice } from "../utils/fplLogic"; | |
| import { getShortName } from "../utils/teams"; | |
| import { PlayerContext } from "../PlayerContext"; | |
| const SafeMinsInput = ({ initialValue, onSave, isChild = false, disabled = false }) => { | |
| const [val, setVal] = useState(initialValue); | |
| useEffect(() => setVal(initialValue), [initialValue]); | |
| return ( | |
| <input | |
| type="number" | |
| disabled={disabled} | |
| value={val} | |
| onChange={(e) => { | |
| setVal(e.target.value); | |
| onSave(e.target.value); | |
| }} | |
| className={isChild | |
| ? "w-12 bg-slate-950 text-center font-mono text-xs font-bold text-indigo-400 rounded py-1 outline-none focus:ring-1 ring-indigo-500 border border-slate-800" | |
| : `w-16 text-center font-mono text-sm font-bold rounded py-1 outline-none ${disabled ? 'bg-transparent text-slate-500' : 'bg-slate-900 text-emerald-400 focus:bg-slate-800 focus:ring-1 ring-emerald-500'}` | |
| } | |
| /> | |
| ); | |
| }; | |
| export const PlayerEditModal = ({ | |
| selectedPlayer, | |
| setSelectedPlayer, | |
| activeGW, | |
| horizonGWs, | |
| updatePlayerStat, | |
| handleTransferOut, | |
| fixtures, | |
| fixtureOverrides, | |
| sessionEdits, | |
| globalPlayers | |
| }) => { | |
| const { effectiveFixtures, globalXmins } = useContext(PlayerContext); | |
| // THE FIX: Grab the live updating player, not the frozen snapshot! | |
| const livePlayer = globalPlayers?.find(p => p.ID === selectedPlayer.ID) || selectedPlayer; | |
| const TEAM_SHORTS = { | |
| 1: "ARS", 2: "AVL", 3: "BUR", 4: "BOU", 5: "BRE", | |
| 6: "BHA", 7: "CHE", 8: "CRY", 9: "EVE", 10: "FUL", | |
| 11: "LEE", 12: "LIV", 13: "MCI", 14: "MUN", 15: "NEW", | |
| 16: "NFO", 17: "SUN", 18: "TOT", 19: "WHU", 20: "WOL" | |
| }; | |
| return ( | |
| <div className="fixed inset-0 z-modal flex items-center justify-center bg-black/80 backdrop-blur-sm p-2 sm:p-4"> | |
| <div className="bg-slate-950 border border-slate-800 w-full max-w-2xl max-h-[90vh] sm:max-h-none overflow-y-auto sm:overflow-visible rounded-xl sm:rounded-2xl shadow-2xl animate-in zoom-in-95 duration-200 flex flex-col"> | |
| <div className="bg-slate-900 p-4 sm:p-5 flex justify-between items-center border-b border-slate-800 sticky top-0 z-sticky"> | |
| <div className="flex flex-col"> | |
| <h3 className="font-black text-2xl text-slate-100 uppercase tracking-tight">{livePlayer.Name}</h3> | |
| <div className="flex gap-3 text-sm font-bold text-slate-500"> | |
| <span>{livePlayer.Team}</span> | |
| <span className="text-slate-700">|</span> | |
| <span className="text-emerald-500">£{getPlayerPrice(livePlayer).toFixed(1)}m</span> | |
| </div> | |
| </div> | |
| <button onClick={() => setSelectedPlayer(null)} className="text-slate-500 hover:text-white transition-colors bg-slate-900 p-2 rounded-full border border-slate-800">✕</button> | |
| </div> | |
| <div className="p-3 sm:p-6 flex flex-col gap-4 sm:gap-6"> | |
| <div className="flex gap-4"> | |
| {[ | |
| { label: `GW${activeGW} xG`, val: livePlayer[`${activeGW}_xG`] ?? livePlayer.xG ?? "-" }, | |
| { label: `GW${activeGW} xA`, val: livePlayer[`${activeGW}_xA`] ?? livePlayer.xA ?? "-" }, | |
| { label: `GW${activeGW} CS%`, val: livePlayer[`${activeGW}_CS_Pct`] ?? livePlayer.CS_Pct ?? "-" }, | |
| ] | |
| .filter(stat => !(stat.label.includes('CS%') && livePlayer.Pos === 'F')) | |
| .map((stat) => ( | |
| <div key={stat.label} className="flex-1 bg-slate-900 p-3 rounded-xl border border-slate-800 flex flex-col items-center"> | |
| <span className="text-[10px] text-slate-500 font-bold uppercase tracking-widest text-center">{stat.label}</span> | |
| <span className="text-lg font-mono font-bold text-slate-200">{typeof stat.val === "number" ? stat.val.toFixed(2) : stat.val}</span> | |
| </div> | |
| ))} | |
| </div> | |
| <div className="border border-slate-800 rounded-xl overflow-hidden"> | |
| <table className="w-full text-left text-sm"> | |
| <thead className="bg-slate-900 text-xs text-slate-500 uppercase font-bold"> | |
| <tr> | |
| <th className="p-3">GW</th> | |
| <th className="p-3 text-center">Fixture</th> | |
| <th className="p-3 text-center">xMins</th> | |
| <th className="p-3 text-right">Proj. EV</th> | |
| </tr> | |
| </thead> | |
| <tbody className="divide-y divide-slate-800/50"> | |
| {horizonGWs.map((gw) => { | |
| const matches = []; | |
| if (livePlayer.match_projections) { | |
| Object.entries(livePlayer.match_projections).forEach(([mId, mData]) => { | |
| // THE FIX 2: Look at the merged globals instead of the empty prop! | |
| const override = effectiveFixtures?.[mId]; | |
| // THE FIX 3: Force Number() to prevent API float bugs | |
| if (override && Number(override[gw]) > 0) matches.push({ ...mData, id: mId, prob: Number(override[gw]) }); | |
| else if (!override && String(mData.default_gw) === String(gw)) matches.push({ ...mData, id: mId, prob: 1.0 }); | |
| }); | |
| } | |
| const hasMultiple = matches.length > 1; | |
| const isBlank = matches.length === 0; | |
| return ( | |
| <React.Fragment key={gw}> | |
| <tr className={`transition-colors ${hasMultiple ? 'bg-indigo-950/20' : 'bg-slate-950/50 hover:bg-slate-900'}`}> | |
| <td className="p-3 font-bold text-slate-400">GW{gw}</td> | |
| <td className="p-3 text-center text-xs font-bold text-slate-300"> | |
| {isBlank ? ( | |
| "BLANK" | |
| ) : hasMultiple ? ( | |
| "MULTIPLE" | |
| ) : ( | |
| <div className="flex items-center justify-center gap-1.5"> | |
| <span> | |
| {matches[0]?.is_home ? `${TEAM_SHORTS[matches[0].opponent_team_id]} (H)` : `${TEAM_SHORTS[matches[0]?.opponent_team_id]} (A)`} | |
| </span> | |
| {matches[0]?.prob < 1.0 && ( | |
| <span className="text-[9px] text-indigo-400 bg-indigo-500/20 px-1.5 py-0.5 rounded border border-indigo-500/30"> | |
| {Math.round(matches[0].prob * 100)}% | |
| </span> | |
| )} | |
| </div> | |
| )} | |
| </td> | |
| <td className="p-3"> | |
| <div className="flex justify-center"> | |
| <SafeMinsInput | |
| disabled={isBlank} | |
| initialValue={hasMultiple ? Math.round(livePlayer[`${gw}_xMins`] || 0) : (sessionEdits?.[livePlayer.ID]?.[`${gw}_xMins`] ?? Math.round(livePlayer[`${gw}_xMins`] || 0))} | |
| onSave={(newVal) => { | |
| if (hasMultiple) { | |
| matches.forEach(m => updatePlayerStat(livePlayer.ID, m.id, "xMins", newVal)); | |
| } else { | |
| updatePlayerStat(livePlayer.ID, gw, "xMins", newVal); | |
| } | |
| }} | |
| /> | |
| </div> | |
| </td> | |
| <td className="p-3 text-right font-mono font-bold text-cyan-400 drop-shadow-md"> | |
| {Number(livePlayer[`${gw}_Pts`] || 0).toFixed(2)} | |
| </td> | |
| </tr> | |
| {hasMultiple && matches.map(m => { | |
| const oppName = TEAM_SHORTS[m.opponent_team_id] || m.opponent_team_id; | |
| const fixLabel = m.is_home ? `${oppName} (H)` : `${oppName} (A)`; | |
| const globalMatchMins = globalXmins?.[livePlayer.ID]?.[m.id]; | |
| const sessionVal = sessionEdits?.[livePlayer.ID]?.[`${m.id}_xMins`]; | |
| const currentMins = Math.round(sessionVal !== undefined ? Number(sessionVal) : (globalMatchMins !== undefined ? Number(globalMatchMins) : m.xMins)); | |
| const scaledEV = (currentMins > 0 && m.xMins > 0) ? (m.Pts / m.xMins) * currentMins : 0; | |
| return ( | |
| <tr key={m.id} className="bg-slate-900/40 border-t border-slate-800/30"> | |
| <td className="p-2 text-right text-slate-600 font-black">↳</td> | |
| <td className="p-2 text-center text-[10px] font-bold text-indigo-300"> | |
| {fixLabel} <span className="opacity-60">({Math.round(m.prob * 100)}%)</span> | |
| </td> | |
| <td className="p-2 flex justify-center"> | |
| <SafeMinsInput | |
| isChild={true} | |
| initialValue={currentMins} | |
| onSave={(newVal) => updatePlayerStat(livePlayer.ID, m.id, "xMins", newVal)} | |
| /> | |
| </td> | |
| <td className="p-2 text-right text-[11px] font-mono font-bold text-indigo-400/80"> | |
| {(scaledEV * m.prob).toFixed(2)} | |
| </td> | |
| </tr> | |
| ); | |
| })} | |
| </React.Fragment> | |
| ); | |
| })} | |
| </tbody> | |
| </table> | |
| </div> | |
| <div className="flex gap-4 mt-2"> | |
| <button onClick={() => handleTransferOut(livePlayer)} className="flex-1 bg-red-950/40 border border-red-900/50 text-red-500 py-3 rounded-xl font-bold text-sm hover:bg-red-900/60 transition-colors">Transfer Out</button> | |
| <button onClick={() => setSelectedPlayer(null)} className="flex-1 bg-luigi-500 hover:bg-luigi-400 text-slate-950 py-3 rounded-xl font-bold text-sm transition-colors shadow-lg">Apply Edits</button> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| ); | |
| }; | |
| export const PlayerSearchModal = ({ | |
| selectedPlayer, | |
| setSelectedPlayer, | |
| searchQuery, | |
| setSearchQuery, | |
| sortConfig, | |
| setSortConfig, | |
| globalPlayers, | |
| ownedPlayerIds, | |
| activeGW, | |
| itb, | |
| handleAddPlayer, | |
| }) => { | |
| const squadTeamCounts = {}; | |
| const currentSquad = globalPlayers.filter(p => ownedPlayerIds.has(p.ID) || ownedPlayerIds.has(String(p.ID)) || ownedPlayerIds.has(Number(p.ID))); | |
| currentSquad.forEach(p => { | |
| squadTeamCounts[p.Team] = (squadTeamCounts[p.Team] || 0) + 1; | |
| }); | |
| // Free up a slot if we are actively transferring out a player from a team! | |
| if (selectedPlayer && selectedPlayer.Team) { | |
| squadTeamCounts[selectedPlayer.Team] = Math.max(0, (squadTeamCounts[selectedPlayer.Team] || 0) - 1); | |
| } | |
| const cleanString = (str) => str ? str.normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase() : ""; | |
| const cleanSearch = cleanString(searchQuery); | |
| return ( | |
| <div className="fixed inset-0 z-modal flex items-center justify-center bg-black/80 backdrop-blur-sm p-4"> | |
| <div className="bg-slate-950 border border-slate-800 w-full max-w-lg rounded-2xl shadow-2xl overflow-hidden flex flex-col animate-in zoom-in-95 duration-200"> | |
| <div className="p-4 border-b border-slate-800 flex items-center gap-3"> | |
| <Search className="text-slate-500" size={20} /> | |
| <input | |
| type="text" | |
| placeholder="Search Database..." | |
| value={searchQuery} | |
| onChange={(e) => setSearchQuery(e.target.value)} | |
| className="flex-1 bg-transparent border-none outline-none text-slate-200 font-bold" | |
| autoFocus | |
| /> | |
| <button onClick={() => setSelectedPlayer(null)} className="text-slate-500 hover:text-white font-bold text-sm">Cancel</button> | |
| </div> | |
| <div className="flex gap-2 p-2 px-4 border-b border-slate-800 bg-slate-900/50 items-center"> | |
| <span className="text-[10px] font-black text-slate-500 tracking-widest mr-2">SORT BY:</span> | |
| <button | |
| onClick={() => setSortConfig({ key: "ev", direction: sortConfig.key === "ev" && sortConfig.direction === "desc" ? "asc" : "desc" })} | |
| className={`px-3 py-1 rounded text-xs font-bold transition-colors ${sortConfig.key === "ev" ? "bg-emerald-900/50 text-emerald-400" : "bg-slate-800 text-slate-400 hover:bg-slate-700"}`} | |
| > | |
| Proj. EV {sortConfig.key === "ev" ? (sortConfig.direction === "desc" ? "↓" : "↑") : ""} | |
| </button> | |
| <button | |
| onClick={() => setSortConfig({ key: "price", direction: sortConfig.key === "price" && sortConfig.direction === "desc" ? "asc" : "desc" })} | |
| className={`px-3 py-1 rounded text-xs font-bold transition-colors ${sortConfig.key === "price" ? "bg-emerald-900/50 text-emerald-400" : "bg-slate-800 text-slate-400 hover:bg-slate-700"}`} | |
| > | |
| Price {sortConfig.key === "price" ? (sortConfig.direction === "desc" ? "↓" : "↑") : ""} | |
| </button> | |
| </div> | |
| {/* THE FIX: Changed max-h to 50vh on mobile so it doesn't span the whole screen */} | |
| <div className="max-h-[50vh] sm:max-h-[400px] overflow-y-auto p-1 sm:p-2 custom-scrollbar"> | |
| {globalPlayers | |
| // THE FIX: Apply cleanString to both the player name and the search query | |
| .filter((p) => !ownedPlayerIds.has(p.ID) && !ownedPlayerIds.has(String(p.ID)) && !ownedPlayerIds.has(Number(p.ID)) && String(p.ID) !== String(selectedPlayer.replacedPlayer?.ID) && p.Pos === selectedPlayer.Pos && cleanString(p.Name).includes(cleanSearch)) | |
| .sort((a, b) => { | |
| let valA = sortConfig.key === "ev" ? Number(a[`${activeGW}_Pts`] || 0) : getPlayerPrice(a); | |
| let valB = sortConfig.key === "ev" ? Number(b[`${activeGW}_Pts`] || 0) : getPlayerPrice(b); | |
| if (valA < valB) return sortConfig.direction === "desc" ? 1 : -1; | |
| if (valA > valB) return sortConfig.direction === "desc" ? -1 : 1; | |
| return 0; | |
| }) | |
| .slice(0, 50) | |
| .map((p) => { | |
| const sellingPrice = getPlayerPrice(selectedPlayer) || 0; | |
| const maxBudget = itb + sellingPrice; | |
| const cost = getPlayerPrice(p); | |
| const isAffordable = cost <= maxBudget; | |
| const isAtTeamLimit = (squadTeamCounts[p.Team] || 0) >= 3; | |
| const isSelectable = isAffordable && !isAtTeamLimit; | |
| return ( | |
| <button | |
| key={p.ID} | |
| disabled={!isSelectable} | |
| onClick={() => handleAddPlayer(p)} | |
| className={`w-full flex items-center justify-between p-2 sm:p-3 border-b border-slate-800/30 transition-colors group ${isSelectable ? "hover:bg-slate-900 cursor-pointer" : "opacity-40 cursor-not-allowed"}`} | |
| > | |
| <div className="flex flex-col items-start text-left"> | |
| <span className="font-bold text-slate-200 text-xs sm:text-sm">{p.Name}</span> | |
| <span className="text-[9px] sm:text-[10px] text-slate-500 font-bold uppercase tracking-wider">{p.Team} • {p.Pos}</span> | |
| </div> | |
| <div className="flex items-center gap-2 sm:gap-4 text-right"> | |
| <div className="flex flex-col items-end"> | |
| <span className="text-[10px] sm:text-xs font-mono text-emerald-400 font-bold">EV: {Number(p[`${activeGW}_Pts`] || 0).toFixed(2)}</span> | |
| <span className="text-[8px] sm:text-[10px] font-mono text-slate-400">{p[`${activeGW}_xMins`] || 0} xMins</span> | |
| </div> | |
| <div className="flex flex-col items-end justify-center"> | |
| <span className={`text-xs sm:text-sm font-mono font-bold ${isAffordable ? "text-slate-300" : "text-red-400"}`}>£{cost.toFixed(1)}m</span> | |
| {/* THE BADGE FIX: Show a clear warning if they hit the limit */} | |
| {isAtTeamLimit && <span className="text-[8px] font-black text-red-500 uppercase leading-none mt-0.5">Max 3</span>} | |
| </div> | |
| <Plus className={`transition-colors ${isSelectable ? "text-slate-600 group-hover:text-luigi-400" : "text-slate-800"}`} size={16} /> | |
| </div> | |
| </button> | |
| ); | |
| })} | |
| </div> | |
| </div> | |
| </div> | |
| ); | |
| }; |