fpl-solver / frontend /src /components /PlayerModals.jsx
AnayShukla's picture
updates
052f3f2
// 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>
);
};