Spaces:
Running
Running
| import React, { useContext, memo } from "react"; | |
| import { Plus, RotateCcw } from "lucide-react"; | |
| import { getShortName } from "../utils/teams"; | |
| import { getPlayerPrice } from "../utils/fplLogic"; | |
| import { PlayerContext, FixturesContext } from "../PlayerContext"; | |
| // Fixed card dimensions β these are design-space pixels (before PitchView scaling). | |
| // Sized to be prominent and legible on desktop, scaling gracefully on mobile via | |
| // the PitchView scale transform. Keeps proportions consistent across devices. | |
| const CARD_W = 108; | |
| const CARD_H = 108; // photo card height only (label strip is separate, in flow below) | |
| // Badge sizing β using percentage of card width ensures badges scale proportionally | |
| // with the card rather than being fixed pixels that iOS may try to inflate. | |
| // 15% of 88px = ~13px at design scale β large enough to read and tap comfortably. | |
| const BADGE_PCT = 0.25; | |
| export const PlayerCardVisual = ({ | |
| player, | |
| isBench, | |
| captainId, | |
| viceId, | |
| handleCapChange, | |
| playerCardGWs, | |
| fixtures, | |
| activeGW, | |
| onPlayerClick, | |
| onUndo, | |
| onSolverUndo, | |
| activeChipType, | |
| }) => { | |
| if (player.isBlank) { | |
| return ( | |
| <div className="flex flex-col items-center" style={{ width: CARD_W }}> | |
| <div | |
| onClick={() => onPlayerClick(player)} | |
| style={{ width: CARD_W, height: CARD_H }} | |
| className="relative flex flex-col items-center justify-center cursor-pointer border-2 border-dashed border-slate-500 bg-slate-900/60 rounded-xl hover:bg-slate-800 hover:border-emerald-400 transition-all z-20 shadow-inner group" | |
| > | |
| {player.replacedPlayer && ( | |
| <div | |
| className="absolute left-[2px] top-[2px] flex flex-col gap-[1px] z-40 pointer-events-auto" | |
| style={{ | |
| WebkitTextSizeAdjust: "none", | |
| textSizeAdjust: "none", | |
| fontSize: 0, | |
| }} | |
| > | |
| <div | |
| role="button" | |
| tabIndex={0} | |
| onPointerDown={(e) => e.stopPropagation()} | |
| onClick={(e) => onUndo(e, player.ID, player.replacedPlayer)} | |
| style={{ | |
| width: CARD_W * BADGE_PCT, | |
| height: CARD_W * BADGE_PCT, | |
| minWidth: 0, | |
| minHeight: 0, | |
| touchAction: "manipulation", | |
| }} | |
| className="flex items-center justify-center rounded-full bg-red-600 hover:bg-red-500 text-white transition-colors border border-red-400 shadow-lg cursor-pointer select-none" | |
| title="Undo transfer" | |
| > | |
| <RotateCcw size={7} strokeWidth={2.5} /> | |
| </div> | |
| </div> | |
| )} | |
| <Plus className="text-slate-500 group-hover:text-emerald-400 transition-colors mb-1" size={24} /> | |
| <span className="text-[10px] font-black text-slate-500 group-hover:text-emerald-400 uppercase tracking-widest"> | |
| {player.Pos} | |
| </span> | |
| </div> | |
| {/* Placeholder label area so blank slots have identical height to real cards */} | |
| <div style={{ height: 55 }} /> | |
| </div> | |
| ); | |
| } | |
| const isCap = player.ID === captainId; | |
| const isVice = player.ID === viceId; | |
| const photoUrl = player.photo | |
| ? `https://wsrv.nl/?url=resources.premierleague.com/premierleague25/photos/players/110x140/${player.photo.replace(".jpg", ".png")}&output=webp&w=110&q=75` | |
| : ""; | |
| const effectiveFixtures = useContext(FixturesContext); | |
| const TEAM_MAP = { | |
| "Arsenal": 1, "Aston Villa": 2, "Burnley": 3, "Bournemouth": 4, "AFC Bournemouth": 4, "Brentford": 5, | |
| "Brighton": 6, "Brighton and Hove Albion": 6, "Chelsea": 7, "Crystal Palace": 8, "Everton": 9, "Fulham": 10, | |
| "Leeds": 11, "Leeds United": 11, "Liverpool": 12, "Man City": 13, "Manchester City": 13, | |
| "Man Utd": 14, "Manchester United": 14, "Newcastle": 15, "Newcastle United": 15, | |
| "Nott'm Forest": 16, "Nottingham Forest": 16, "Sunderland": 17, | |
| "Spurs": 18, "Tottenham": 18, "Tottenham Hotspur": 18, | |
| "West Ham": 19, "West Ham United": 19, "Wolves": 20, "Wolverhampton Wanderers": 20 | |
| }; | |
| const getActiveMatches = (teamName, gw) => { | |
| if (!fixtures || !fixtures.length || !gw) return []; | |
| const activeMatches = []; | |
| fixtures.forEach(m => { | |
| if (m.home_team !== teamName && m.away_team !== teamName) return; | |
| const hId = m.home_team_id || TEAM_MAP[m.home_team] || m.home_team; | |
| const aId = m.away_team_id || TEAM_MAP[m.away_team] || m.away_team; | |
| const matchId = `${hId}_vs_${aId}`; | |
| const override = effectiveFixtures?.[matchId]; | |
| if (override) { | |
| if (Number(override[gw]) >= 0.01) activeMatches.push({ ...m, prob: Number(override[gw]) }); | |
| } else if (String(m.GW) === String(gw)) { | |
| activeMatches.push({ ...m, prob: 1.0 }); | |
| } | |
| }); | |
| return activeMatches; | |
| }; | |
| const currentGwMatches = getActiveMatches(player.Team, activeGW); | |
| const isBlankThisGw = currentGwMatches.length === 0; | |
| const renderFixtures = (teamName, gw) => { | |
| const activeMatches = gw === activeGW ? currentGwMatches : getActiveMatches(teamName, gw); | |
| if (activeMatches.length === 0) { | |
| return <span className="text-[9px] font-bold text-slate-600">BLANK</span>; | |
| } | |
| return activeMatches.map((m, idx) => { | |
| const isHome = m.home_team === teamName; | |
| const oppName = getShortName(isHome ? m.away_team : m.home_team); | |
| const loc = isHome ? "H" : "A"; | |
| const isGhost = m.prob < 1; | |
| return ( | |
| <React.Fragment key={idx}> | |
| <span | |
| title={isGhost ? `${Math.round(m.prob * 100)}% chance of playing in GW${gw}` : undefined} | |
| className={`inline-flex items-center whitespace-nowrap ${isGhost ? "text-indigo-200 cursor-help" : "text-slate-200"}`} | |
| > | |
| <span className="text-[8px] font-black tracking-tighter leading-none">{oppName}</span> | |
| <span className={`text-[8px] font-bold ml-[1px] leading-none ${isGhost ? "text-indigo-400" : "text-slate-400"}`}> | |
| ({loc}) | |
| </span> | |
| {isGhost && ( | |
| <span | |
| className="text-[6px] font-black text-indigo-100 ml-[2px] bg-indigo-500/50 px-[2px] py-[1px] rounded-[2px] border border-indigo-400/40 tracking-tighter shadow-sm flex items-center justify-center" | |
| style={{ lineHeight: 1 }} | |
| > | |
| {Math.round(m.prob * 100)}% | |
| </span> | |
| )} | |
| </span> | |
| {idx < activeMatches.length - 1 && ( | |
| <span className="text-[6px] text-slate-500 mx-[2px] flex items-center leading-none">β’</span> | |
| )} | |
| </React.Fragment> | |
| ); | |
| }); | |
| }; | |
| // EV text sizes β primary GW gets the strongest visual weight, subsequent | |
| // GWs taper down. Sized for comfortable reading at the larger card scale. | |
| const evStyles = [ | |
| "text-emerald-400 text-[13px] font-extrabold", | |
| "text-emerald-500 text-[11px] font-bold", | |
| "text-emerald-600 text-[9px] font-semibold", | |
| ]; | |
| const isTransferIn = Boolean(player.replacedPlayer || onSolverUndo); | |
| return ( | |
| /* | |
| OUTER WRAPPER β this is what the row gap acts on. | |
| Width is exactly CARD_W so the gap math in PitchView is predictable. | |
| The wrapper is a flex-col: [photo card] then [label strip]. | |
| */ | |
| <div | |
| onClick={() => onPlayerClick(player)} | |
| className="relative flex flex-col items-center cursor-grab" | |
| style={{ width: CARD_W }} | |
| > | |
| {/* ββ PHOTO CARD ββ fixed size, photo is % of this div specifically */} | |
| <div | |
| className={`relative flex-shrink-0 rounded-[10px] transition-transform duration-200 transform-gpu overflow-visible | |
| ${isTransferIn | |
| ? "bg-gradient-to-t from-cyan-900/60 via-cyan-900/5 to-transparent -translate-y-2 shadow-[0_20px_16px_-8px_rgba(0,0,0,0.8)] z-30" | |
| : "bg-transparent z-10" | |
| } | |
| ${isBench ? (activeChipType === "bb" ? "opacity-85" : "opacity-50") : "opacity-100"} | |
| `} | |
| style={{ width: CARD_W, height: CARD_H }} | |
| > | |
| {/* | |
| C / V / Reset stack. | |
| Key fixes for mobile responsiveness: | |
| 1. Use <div role="button"> instead of <button> β buttons have browser-enforced | |
| minimum touch targets that cause the "huge badges" issue on iOS/mobile. | |
| 2. Size badges as percentage of card width so they scale proportionally. | |
| 3. Use touch-action: manipulation to prevent double-tap zoom interference. | |
| 4. -webkit-text-size-adjust: none (not 100%) fully disables text inflation. | |
| 5. appearance: none and explicit min-width/min-height: 0 to override defaults. | |
| */} | |
| <div | |
| className="absolute left-[2px] top-[2px] flex flex-col gap-[1px] z-40 pointer-events-auto" | |
| style={{ | |
| WebkitTextSizeAdjust: "none", | |
| textSizeAdjust: "none", | |
| fontSize: 0, // container has no font size β children set their own | |
| }} | |
| > | |
| {!isBench && handleCapChange && ( | |
| <> | |
| <div | |
| role="button" | |
| tabIndex={0} | |
| onPointerDown={(e) => e.stopPropagation()} | |
| onClick={(e) => { e.stopPropagation(); handleCapChange(player.ID, "C"); }} | |
| style={{ | |
| width: CARD_W * BADGE_PCT, | |
| height: CARD_W * BADGE_PCT, | |
| minWidth: 0, | |
| minHeight: 0, | |
| touchAction: "manipulation", | |
| }} | |
| className={`flex items-center justify-center rounded-full text-[7px] font-bold leading-none transition-colors shadow-md transform-gpu cursor-pointer select-none | |
| ${isCap | |
| ? activeChipType === "tc" | |
| ? "bg-purple-500 text-white border border-purple-300" | |
| : "bg-yellow-400 text-slate-900 border border-white" | |
| : "bg-slate-900/90 text-slate-400 border border-slate-700 hover:text-yellow-400" | |
| }`} | |
| > | |
| {isCap && activeChipType === "tc" ? "TC" : "C"} | |
| </div> | |
| <div | |
| role="button" | |
| tabIndex={0} | |
| onPointerDown={(e) => e.stopPropagation()} | |
| onClick={(e) => { e.stopPropagation(); handleCapChange(player.ID, "V"); }} | |
| style={{ | |
| width: CARD_W * BADGE_PCT, | |
| height: CARD_W * BADGE_PCT, | |
| minWidth: 0, | |
| minHeight: 0, | |
| touchAction: "manipulation", | |
| }} | |
| className={`flex items-center justify-center rounded-full text-[7px] font-bold leading-none transition-colors shadow-md cursor-pointer select-none | |
| ${isVice ? "bg-slate-300 text-slate-900 border border-white" : "bg-slate-900/90 text-slate-400 border border-slate-700 hover:text-white"}`} | |
| > | |
| V | |
| </div> | |
| </> | |
| )} | |
| {onSolverUndo ? ( | |
| <div | |
| role="button" | |
| tabIndex={0} | |
| onPointerDown={(e) => e.stopPropagation()} | |
| onClick={(e) => { e.stopPropagation(); onSolverUndo(player); }} | |
| style={{ | |
| width: CARD_W * BADGE_PCT, | |
| height: CARD_W * BADGE_PCT, | |
| minWidth: 0, | |
| minHeight: 0, | |
| touchAction: "manipulation", | |
| }} | |
| className="flex items-center justify-center rounded-full bg-red-600 hover:bg-red-500 text-white transform-gpu transition-colors border border-red-400 shadow-md cursor-pointer select-none" | |
| > | |
| <RotateCcw size={7} strokeWidth={2.5} /> | |
| </div> | |
| ) : player.replacedPlayer ? ( | |
| <div | |
| role="button" | |
| tabIndex={0} | |
| onPointerDown={(e) => e.stopPropagation()} | |
| onClick={(e) => onUndo(e, player.ID, player.replacedPlayer)} | |
| style={{ | |
| width: CARD_W * BADGE_PCT, | |
| height: CARD_W * BADGE_PCT, | |
| minWidth: 0, | |
| minHeight: 0, | |
| touchAction: "manipulation", | |
| }} | |
| className="flex items-center justify-center rounded-full bg-red-600 hover:bg-red-500 text-white transform-gpu transition-colors border border-red-400 shadow-md cursor-pointer select-none" | |
| > | |
| <RotateCcw size={7} strokeWidth={2.5} /> | |
| </div> | |
| ) : null} | |
| </div> | |
| {/* EV numbers β top-right corner. Primary GW most prominent, | |
| subsequent GWs taper down. Anti-inflation styles applied. */} | |
| <div | |
| className="absolute right-[3px] top-[2px] flex flex-col items-end z-30 pointer-events-none drop-shadow-[0_1px_2px_rgba(0,0,0,0.8)]" | |
| style={{ | |
| WebkitTextSizeAdjust: "none", | |
| textSizeAdjust: "none", | |
| }} | |
| > | |
| {playerCardGWs.map((gw, i) => ( | |
| <span key={gw} className={`${evStyles[i]} leading-tight tabular-nums`}> | |
| {Number(player[`${gw}_Pts`] || 0).toFixed(2)} | |
| </span> | |
| ))} | |
| </div> | |
| {/* Player photo β bottom-[-8px] lets the player's body dip into the name strip */} | |
| {photoUrl ? ( | |
| <img | |
| src={photoUrl} | |
| alt={player.Name} | |
| loading="lazy" | |
| decoding="async" | |
| draggable="false" | |
| className={`absolute bottom-[-8px] w-full object-contain pointer-events-none z-10 transform-gpu`} | |
| style={{ height: "95%" }} | |
| /> | |
| ) : ( | |
| <div | |
| className={`absolute bottom-[-8px] w-full object-contain pointer-events-none z-10 transform-gpu`} | |
| style={{ height: "70%" }} | |
| /> | |
| )} | |
| </div> | |
| {/* | |
| LABEL STRIP β in normal flow below the photo card. | |
| Width is 110% of CARD_W (β92px @ design) so the strip slightly | |
| overhangs the card for that classic FPL look, while still fitting | |
| comfortably inside the CARD_GAP (24px) so neighbouring labels never | |
| collide on any viewport. | |
| WebkitTextSizeAdjust: "100%" prevents iOS Safari from inflating the | |
| small label text β without this the name/stats wrap or overflow on | |
| mobile. | |
| */} | |
| <div | |
| draggable="false" | |
| className={`flex flex-col items-center z-30 pointer-events-none mt-[1px] flex-shrink-0 ${isBench ? "opacity-80" : "opacity-100"}`} | |
| style={{ | |
| width: "100%", | |
| WebkitTextSizeAdjust: "none", | |
| textSizeAdjust: "none", | |
| }} | |
| > | |
| <div className={`w-full text-center py-[3px] truncate px-1 font-bold text-[9px] leading-tight rounded-t shadow-md | |
| ${isTransferIn ? "bg-cyan-600 text-white" : "bg-slate-950 text-slate-100 border border-slate-700"}`}> | |
| {player.Name} | |
| </div> | |
| <div className="w-full bg-slate-200 border-x border-slate-700 flex justify-center items-center gap-1 py-[3px] shadow-inner"> | |
| <span className={`text-[9px] font-black flex items-baseline gap-[2px] leading-none ${isBlankThisGw ? "text-slate-400" : "text-slate-800"}`}> | |
| {isBlankThisGw ? "-" : (player[`${activeGW}_xMins`] ?? 90)} | |
| <span className="text-[9px] font-bold text-slate-500 uppercase tracking-tight">xMins</span> | |
| </span> | |
| <span className="text-slate-400 font-light text-[9px] leading-none">|</span> | |
| <span className="text-[9px] font-black text-emerald-700 leading-none"> | |
| Β£{getPlayerPrice(player).toFixed(1)} | |
| </span> | |
| </div> | |
| <div className="w-full bg-slate-900 border-x border-b border-slate-700 flex items-center rounded-b shadow-md px-0.5 overflow-hidden" style={{ height: 19 }}> | |
| <div className="flex items-center justify-center w-full whitespace-nowrap overflow-hidden"> | |
| {renderFixtures(player.Team, activeGW)} | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| ); | |
| }; | |
| export default memo(PlayerCardVisual); | |