// ═══════════════════════════════════════════════════════════════════════════════ // DATA — Formations, Players, Rarities, Nations, Positions // ═══════════════════════════════════════════════════════════════════════════════ // Pitch slot positions (x/y are % of the pitch). Bands spread TOP->BOTTOM so the four // zones - forwards (~10-12) -> midfield (~29-33) -> defence (~64-67) -> GK (92, live // clamp 86) - always keep a big gap, enough that even 2-line wrapped long names clear // the row below on mobile. Forwards sit right at the top. The 4-band shapes are fully // clean; the 5-row DM/CDM shapes (4-2-3-1 / 4-1-3-2 / 3-1-4-2) cram an extra defensive- // mid row, so the smaller mobile pitch card keeps even those clear. (s13.10) var FORMATIONS_11V11 = { "4-3-3": { name: "4-3-3", slots: [ { id: "GK", pos: "GK", x: 50, y: 83 }, { id: "LB", pos: "LB", x: 12, y: 61 }, { id: "LCB", pos: "CB", x: 35, y: 63 }, { id: "RCB", pos: "CB", x: 65, y: 63 }, { id: "RB", pos: "RB", x: 88, y: 61 }, { id: "LCM", pos: "CM", x: 25, y: 39 }, { id: "CCM", pos: "CM", x: 50, y: 37 }, { id: "RCM", pos: "CM", x: 75, y: 39 }, { id: "LW", pos: "LW", x: 15, y: 12 }, { id: "ST", pos: "ST", x: 50, y: 10 }, { id: "RW", pos: "RW", x: 85, y: 12 }, ], }, "4-4-2": { name: "4-4-2", slots: [ { id: "GK", pos: "GK", x: 50, y: 83 }, { id: "LB", pos: "LB", x: 12, y: 61 }, { id: "LCB", pos: "CB", x: 35, y: 63 }, { id: "RCB", pos: "CB", x: 65, y: 63 }, { id: "RB", pos: "RB", x: 88, y: 61 }, { id: "LM", pos: "LM", x: 12, y: 38 }, { id: "LCM", pos: "CM", x: 38, y: 39 }, { id: "RCM", pos: "CM", x: 62, y: 39 }, { id: "RM", pos: "RM", x: 88, y: 38 }, { id: "LST", pos: "ST", x: 38, y: 11 }, { id: "RST", pos: "ST", x: 62, y: 11 }, ], }, "4-2-3-1": { name: "4-2-3-1", slots: [ { id: "GK", pos: "GK", x: 50, y: 87 }, { id: "LB", pos: "LB", x: 12, y: 65 }, { id: "LCB", pos: "CB", x: 35, y: 67 }, { id: "RCB", pos: "CB", x: 65, y: 67 }, { id: "RB", pos: "RB", x: 88, y: 65 }, { id: "LDM", pos: "CDM", x: 35, y: 45 }, { id: "RDM", pos: "CDM", x: 65, y: 45 }, { id: "LM", pos: "LM", x: 16, y: 25 }, { id: "CAM", pos: "CAM", x: 50, y: 24 }, { id: "RM", pos: "RM", x: 84, y: 25 }, { id: "ST", pos: "ST", x: 50, y: 4 }, ], }, "3-5-2": { name: "3-5-2", slots: [ { id: "GK", pos: "GK", x: 50, y: 83 }, { id: "LCB", pos: "CB", x: 22, y: 63 }, { id: "CCB", pos: "CB", x: 50, y: 63 }, { id: "RCB", pos: "CB", x: 78, y: 63 }, { id: "LWB", pos: "LWB", x: 8, y: 43 }, { id: "LCM", pos: "CM", x: 32, y: 35 }, { id: "CCM", pos: "CM", x: 50, y: 33 }, { id: "RCM", pos: "CM", x: 68, y: 35 }, { id: "RWB", pos: "RWB", x: 92, y: 43 }, { id: "LST", pos: "ST", x: 38, y: 11 }, { id: "RST", pos: "ST", x: 62, y: 11 }, ], }, "5-3-2": { name: "5-3-2", slots: [ { id: "GK", pos: "GK", x: 50, y: 83 }, { id: "LWB", pos: "LWB", x: 8, y: 50 }, { id: "LCB", pos: "CB", x: 28, y: 63 }, { id: "CCB", pos: "CB", x: 50, y: 63 }, { id: "RCB", pos: "CB", x: 72, y: 63 }, { id: "RWB", pos: "RWB", x: 92, y: 50 }, { id: "LCM", pos: "CM", x: 28, y: 37 }, { id: "CCM", pos: "CM", x: 50, y: 35 }, { id: "RCM", pos: "CM", x: 72, y: 37 }, { id: "LST", pos: "ST", x: 38, y: 11 }, { id: "RST", pos: "ST", x: 62, y: 11 }, ], }, "4-1-3-2": { name: "4-1-3-2", slots: [ { id: "GK", pos: "GK", x: 50, y: 85 }, { id: "LB", pos: "LB", x: 12, y: 63 }, { id: "LCB", pos: "CB", x: 35, y: 65 }, { id: "RCB", pos: "CB", x: 65, y: 65 }, { id: "RB", pos: "RB", x: 88, y: 63 }, { id: "CDM", pos: "CDM", x: 50, y: 43 }, { id: "LCM", pos: "CM", x: 22, y: 22 }, { id: "CCM", pos: "CM", x: 50, y: 22 }, { id: "RCM", pos: "CM", x: 78, y: 22 }, { id: "LST", pos: "ST", x: 35, y: 4 }, { id: "RST", pos: "ST", x: 65, y: 4 }, ], }, "3-4-3": { name: "3-4-3", slots: [ { id: "GK", pos: "GK", x: 50, y: 83 }, { id: "LCB", pos: "CB", x: 22, y: 63 }, { id: "CCB", pos: "CB", x: 50, y: 63 }, { id: "RCB", pos: "CB", x: 78, y: 63 }, { id: "LM", pos: "LM", x: 15, y: 37 }, { id: "LCM", pos: "CM", x: 38, y: 38 }, { id: "RCM", pos: "CM", x: 62, y: 38 }, { id: "RM", pos: "RM", x: 85, y: 37 }, { id: "LW", pos: "LW", x: 18, y: 12 }, { id: "ST", pos: "ST", x: 50, y: 10 }, { id: "RW", pos: "RW", x: 82, y: 12 }, ], }, "3-1-4-2": { name: "3-1-4-2", slots: [ { id: "GK", pos: "GK", x: 50, y: 85 }, { id: "LCB", pos: "CB", x: 22, y: 64 }, { id: "CCB", pos: "CB", x: 50, y: 64 }, { id: "RCB", pos: "CB", x: 78, y: 64 }, { id: "CDM", pos: "CDM", x: 50, y: 42 }, { id: "LM", pos: "LM", x: 14, y: 23 }, { id: "LCM", pos: "CM", x: 37, y: 23 }, { id: "RCM", pos: "CM", x: 63, y: 23 }, { id: "RM", pos: "RM", x: 86, y: 23 }, { id: "LST", pos: "ST", x: 33, y: 4 }, { id: "RST", pos: "ST", x: 67, y: 4 }, ], }, }; var FORMATIONS_9V9 = { "3-3-2": { name: "3-3-2", slots: [ { id: "GK", pos: "GK", x: 50, y: 92 }, { id: "LCB", pos: "CB", x: 22, y: 73 }, { id: "CCB", pos: "CB", x: 50, y: 73 }, { id: "RCB", pos: "CB", x: 78, y: 73 }, { id: "LCM", pos: "CM", x: 25, y: 48 }, { id: "CCM", pos: "CM", x: 50, y: 50 }, { id: "RCM", pos: "CM", x: 75, y: 48 }, { id: "LST", pos: "ST", x: 35, y: 20 }, { id: "RST", pos: "ST", x: 65, y: 20 }, ], }, "3-2-3": { name: "3-2-3", slots: [ { id: "GK", pos: "GK", x: 50, y: 92 }, { id: "LCB", pos: "CB", x: 22, y: 73 }, { id: "CCB", pos: "CB", x: 50, y: 73 }, { id: "RCB", pos: "CB", x: 78, y: 73 }, { id: "LCM", pos: "CM", x: 35, y: 50 }, { id: "RCM", pos: "CM", x: 65, y: 50 }, { id: "LW", pos: "LW", x: 18, y: 22 }, { id: "ST", pos: "ST", x: 50, y: 16 }, { id: "RW", pos: "RW", x: 82, y: 22 }, ], }, "4-2-2": { name: "4-2-2", slots: [ { id: "GK", pos: "GK", x: 50, y: 92 }, { id: "LB", pos: "LB", x: 15, y: 73 }, { id: "LCB", pos: "CB", x: 38, y: 77 }, { id: "RCB", pos: "CB", x: 62, y: 77 }, { id: "RB", pos: "RB", x: 85, y: 73 }, { id: "LCM", pos: "CM", x: 35, y: 50 }, { id: "RCM", pos: "CM", x: 65, y: 50 }, { id: "LST", pos: "ST", x: 35, y: 20 }, { id: "RST", pos: "ST", x: 65, y: 20 }, ], }, }; var FORMATIONS_7V7 = { "2-3-1": { name: "2-3-1", slots: [ { id: "GK", pos: "GK", x: 50, y: 92 }, { id: "LCB", pos: "CB", x: 32, y: 73 }, { id: "RCB", pos: "CB", x: 68, y: 73 }, { id: "LM", pos: "LM", x: 20, y: 48 }, { id: "CM", pos: "CM", x: 50, y: 50 }, { id: "RM", pos: "RM", x: 80, y: 48 }, { id: "ST", pos: "ST", x: 50, y: 18 }, ], }, "3-2-1": { name: "3-2-1", slots: [ { id: "GK", pos: "GK", x: 50, y: 92 }, { id: "LCB", pos: "CB", x: 22, y: 73 }, { id: "CCB", pos: "CB", x: 50, y: 73 }, { id: "RCB", pos: "CB", x: 78, y: 73 }, { id: "LCM", pos: "CM", x: 35, y: 48 }, { id: "RCM", pos: "CM", x: 65, y: 48 }, { id: "ST", pos: "ST", x: 50, y: 18 }, ], }, "2-2-2": { name: "2-2-2", slots: [ { id: "GK", pos: "GK", x: 50, y: 92 }, { id: "LCB", pos: "CB", x: 32, y: 73 }, { id: "RCB", pos: "CB", x: 68, y: 73 }, { id: "LCM", pos: "CM", x: 32, y: 48 }, { id: "RCM", pos: "CM", x: 68, y: 48 }, { id: "LST", pos: "ST", x: 35, y: 20 }, { id: "RST", pos: "ST", x: 65, y: 20 }, ], }, "1-3-2": { name: "1-3-2", slots: [ { id: "GK", pos: "GK", x: 50, y: 92 }, { id: "CB", pos: "CB", x: 50, y: 73 }, { id: "LM", pos: "LM", x: 20, y: 48 }, { id: "CM", pos: "CM", x: 50, y: 50 }, { id: "RM", pos: "RM", x: 80, y: 48 }, { id: "LST", pos: "ST", x: 35, y: 20 }, { id: "RST", pos: "ST", x: 65, y: 20 }, ], }, }; var FORMATIONS_5V5 = { "2-1-1": { name: "2-1-1", slots: [ { id: "GK", pos: "GK", x: 50, y: 92 }, { id: "LCB", pos: "CB", x: 32, y: 73 }, { id: "RCB", pos: "CB", x: 68, y: 73 }, { id: "CM", pos: "CM", x: 50, y: 48 }, { id: "ST", pos: "ST", x: 50, y: 18 }, ], }, "1-2-1": { name: "1-2-1", slots: [ { id: "GK", pos: "GK", x: 50, y: 92 }, { id: "CB", pos: "CB", x: 50, y: 73 }, { id: "LCM", pos: "CM", x: 32, y: 48 }, { id: "RCM", pos: "CM", x: 68, y: 48 }, { id: "ST", pos: "ST", x: 50, y: 18 }, ], }, "1-1-2": { name: "1-1-2", slots: [ { id: "GK", pos: "GK", x: 50, y: 92 }, { id: "CB", pos: "CB", x: 50, y: 73 }, { id: "CM", pos: "CM", x: 50, y: 48 }, { id: "LST", pos: "ST", x: 32, y: 18 }, { id: "RST", pos: "ST", x: 68, y: 18 }, ], }, }; var FORMATIONS_ALL = { "11v11": FORMATIONS_11V11, "9v9": FORMATIONS_9V9, "7v7": FORMATIONS_7V7, "5v5": FORMATIONS_5V5 }; var GAME_MODES = ["11v11", "9v9", "7v7", "5v5"]; var FORMATIONS = FORMATIONS_11V11; var PLAYERS = [ { id: 1, name: "M. Vasquez", first: "Mateo", rating: 91, pos: "ST", alt: ["CF","LW"], nation: "ARG", club: "RIVERA FC", pace: 92, sho: 93, pas: 78, dri: 89, def: 42, phy: 81 }, { id: 2, name: "K. Okonkwo", first: "Kelechi", rating: 89, pos: "LW", alt: ["ST","CF"], nation: "NGA", club: "PORTO AZUL", pace: 95, sho: 86, pas: 81, dri: 92, def: 38, phy: 74 }, { id: 3, name: "L. Bergstr\u00F6m", first: "Liam", rating: 88, pos: "CM", alt: ["CAM","CDM"],nation: "SWE", club: "\u00C5LBORG IF", pace: 78, sho: 79, pas: 90, dri: 87, def: 75, phy: 80 }, { id: 4, name: "T. M\u00FCller", first: "Tobias",rating: 87, pos: "CAM", alt: ["CM","RW"], nation: "GER", club: "RIVERA FC", pace: 81, sho: 84, pas: 89, dri: 88, def: 52, phy: 72 }, { id: 5, name: "R. Almeida", first: "Renato", rating: 90, pos: "CB", alt: ["CDM"], nation: "BRA", club: "PORTO AZUL", pace: 74, sho: 55, pas: 78, dri: 76, def: 92, phy: 89 }, { id: 6, name: "H. Tanaka", first: "Haruki", rating: 86, pos: "RW", alt: ["LW","ST"], nation: "JPN", club: "OSAKA UNITED",pace: 93, sho: 82, pas: 79, dri: 90, def: 35, phy: 70 }, { id: 7, name: "S. Kova\u010D", first: "Stipe", rating: 85, pos: "CDM", alt: ["CB","CM"], nation: "CRO", club: "ZAGREB SC", pace: 70, sho: 65, pas: 84, dri: 78, def: 86, phy: 88 }, { id: 8, name: "A. Diallo", first: "Amadou", rating: 87, pos: "LB", alt: ["LWB","LM"], nation: "SEN", club: "PORTO AZUL", pace: 91, sho: 62, pas: 80, dri: 83, def: 84, phy: 81 }, { id: 9, name: "D. O'Connell", first: "Declan", rating: 84, pos: "RB", alt: ["RWB","RM"], nation: "IRL", club: "RIVERA FC", pace: 87, sho: 60, pas: 78, dri: 80, def: 83, phy: 79 }, { id: 10, name: "E. Petrov", first: "Emil", rating: 89, pos: "GK", alt: [], nation: "BUL", club: "SOFIA RED", pace: 55, sho: 30, pas: 65, dri: 72, def: 30, phy: 80, gk: { div: 88, han: 85, kic: 82, ref: 91, spd: 56, pos: 89 } }, { id: 11, name: "N. Sa\u00EFdi", first: "Nassim",rating: 86, pos: "CM", alt: ["CAM"], nation: "MAR", club: "CASA NORTE", pace: 80, sho: 76, pas: 86, dri: 88, def: 70, phy: 75 }, { id: 12, name: "J. van Dijk", first: "Joost", rating: 88, pos: "CB", alt: [], nation: "NED", club: "AMSTEL FC", pace: 75, sho: 52, pas: 80, dri: 74, def: 90, phy: 90 }, { id: 13, name: "F. Rossi", first: "Federico", rating: 83, pos: "RM", alt: ["RW","CAM"], nation: "ITA", club: "MILANO BLU", pace: 88, sho: 78, pas: 80, dri: 86, def: 45, phy: 72 }, { id: 14, name: "B. Adebayo", first: "Bukayo", rating: 85, pos: "LM", alt: ["LW"], nation: "ENG", club: "LONDON CITY",pace: 90, sho: 80, pas: 78, dri: 88, def: 48, phy: 73 }, { id: 15, name: "G. Costa", first: "Gabriel", rating: 90, pos: "CF", alt: ["ST"], nation: "POR", club: "LISBOA AZ", pace: 85, sho: 91, pas: 84, dri: 91, def: 45, phy: 78 }, { id: 16, name: "Y. Park", first: "Yujin", rating: 84, pos: "CAM", alt: ["CM"], nation: "KOR", club: "SEOUL STAR", pace: 84, sho: 79, pas: 86, dri: 87, def: 50, phy: 70 }, ]; // O(1) player lookup by id — avoids repeated O(n) PLAYERS.find scans across the app var PLAYER_BY_ID = {}; PLAYERS.forEach(function(p) { PLAYER_BY_ID[p.id] = p; }); function getPlayer(id) { return id == null ? null : (PLAYER_BY_ID[id] || CUSTOM_PLAYERS_BY_ID[id] || API_PLAYERS_BY_ID[id] || null); } var RARITIES = [ { id: "gold", name: "GOLD", bg: "linear-gradient(155deg, #F4D87A 0%, #CD9602 45%, #8a5e02 100%)", ink: "#2F0745", line: "#FFE9A8" }, { id: "silver", name: "SILVER", bg: "linear-gradient(155deg, #E9ECF0 0%, #9aa3ad 50%, #4f555e 100%)", ink: "#1a1d22", line: "#FFFFFF" }, { id: "bronze", name: "BRONZE", bg: "linear-gradient(155deg, #E2A47C 0%, #9E5A2E 50%, #54260D 100%)", ink: "#2c1207", line: "#FFD9BF" }, { id: "icon", name: "ICON", bg: "linear-gradient(155deg, #F7DE94 0%, #CD9602 48%, #8a5e02 100%)", cardBg: "linear-gradient(160deg, #FFFFFF 0%, #FFFBF1 55%, #F6E8C6 100%)", ink: "#A9790B", line: "#CD9602" }, { id: "totw", name: "TEAM OF THE WEEK", bg: "linear-gradient(160deg, #1A0428 0%, #2F0745 50%, #08010f 100%)", ink: "#CD9602", line: "#CD9602" }, ]; var NATIONS = ["ARG","NGA","SWE","GER","BRA","JPN","CRO","SEN","IRL","BUL","MAR","NED","ITA","ENG","POR","KOR","FRA","ESP","USA","MEX","BEL","COL","URU","DEN"]; var POSITIONS = ["GK","RB","LB","CB","CDM","CM","CAM","RM","LM","RW","LW","CF","ST"]; var POS_GROUP = { GK:"GK", CB:"DEF", LB:"DEF", RB:"DEF", LWB:"DEF", RWB:"DEF", CM:"MID", CAM:"MID", CDM:"MID", LM:"MID", RM:"MID", ST:"ATT", CF:"ATT", LW:"ATT", RW:"ATT" }; // ═══════════════════════════════════════════════════════════════════════════════ // SHARED — Player Card Component (used by both Lineup Builder & Card Builder) // ═══════════════════════════════════════════════════════════════════════════════ var h = React.createElement; // ───────────────────────────────────────────────────────────────────────────── // SUPABASE — accounts, saved cards (= custom players) & saved lineups // ───────────────────────────────────────────────────────────────────────────── var SUPABASE_URL = "https://gayejlbxqhxkcacexhix.supabase.co"; var SUPABASE_KEY = "sb_publishable_-zoydG_5v3naxyCwlbjW-w_AvcBhG16"; var sb = (window.supabase && typeof window.supabase.createClient === "function") ? window.supabase.createClient(SUPABASE_URL, SUPABASE_KEY) : null; // Runtime registry of the signed-in user's custom players, keyed by uuid. // Lets the synchronous getPlayer() (used by exports, chemistry, pitch tokens) // resolve custom players the same way it resolves the demo PLAYERS. var CUSTOM_PLAYERS_BY_ID = {}; // Map a public.players row -> the in-app player shape the rest of the app expects. function rowToPlayer(row) { var first = row.first || ""; var last = row.last || ""; var disp = (first ? first.charAt(0) + ". " : "") + last; return { id: row.id, name: disp || last || first || "PLAYER", first: first, last: last, rating: row.rating, pos: row.pos, alt: [], nation: row.nation || "", club: row.club || "", rarity: row.rarity || "gold", pace: row.pace, sho: row.sho, pas: row.pas, dri: row.dri, def: row.def, phy: row.phy, trait: row.trait || null, gk: row.gk || undefined, portrait: row.portrait_url || null, portraitZoom: row.portrait_zoom != null ? Number(row.portrait_zoom) : undefined, portraitX: row.portrait_x != null ? row.portrait_x : undefined, portraitY: row.portrait_y != null ? row.portrait_y : undefined, _custom: true }; } // Rebuild the custom-player lookup from a list of in-app player objects. function setCustomPlayers(players) { CUSTOM_PLAYERS_BY_ID = {}; (players || []).forEach(function(p) { CUSTOM_PLAYERS_BY_ID[p.id] = p; }); } // ───────────────────────────────────────────────────────────────────────────── // SPORTMONKS v3 → in-app player mapping (real players for the Lineup Builder) // Players are fetched through the `sportmonks-proxy` edge function (the api // token stays server-side in app_secrets). API ids are namespaced "smk:" // so they never collide with demo (int) or custom (uuid) ids. getPlayer() // resolves them via this registry. // ───────────────────────────────────────────────────────────────────────────── var API_PLAYERS_BY_ID = {}; function registerApiPlayer(p) { if (p && p.id != null) API_PLAYERS_BY_ID[p.id] = p; return p; } // Call the sportmonks-proxy edge function. Returns the SportMonks envelope // ({ data, pagination, rate_limit, ... }) or { error } / { message } on failure. function footballProxy(path, params) { if (!sb) return Promise.resolve({ error: "no client" }); return sb.functions.invoke("sportmonks-proxy", { body: { path: path, params: params || {} } }) .then(function(res) { if (res.error) return { error: String(res.error.message || res.error) }; return res.data || { error: "empty" }; }, function(e) { return { error: String((e && e.message) || e) }; }); } // Inline a CDN image (player photo / flag / coach photo) via the proxy's image // mode → guaranteed base64 data URL (no CORS taint in html-to-image exports). // Memoised per URL for the session (flags repeat constantly — same country = // instant). Falls back to client-side canvas inlining, then to the raw URL. var _imageMemo = {}; function proxyImage(url) { if (!url) return Promise.resolve(null); if (url.indexOf("data:") === 0) return Promise.resolve(url); if (_imageMemo[url]) return _imageMemo[url]; var p = !sb ? imageToDataUrl(url) : sb.functions.invoke("sportmonks-proxy", { body: { image: url } }) .then(function(res) { var du = res && res.data && res.data.dataUrl; return du || imageToDataUrl(url); }, function() { return imageToDataUrl(url); }); _imageMemo[url] = p.then(function(du) { if (!du || String(du).indexOf("data:") !== 0) delete _imageMemo[url]; // don't memoise failures return du; }); return _imageMemo[url]; } function isDataUrl(u) { return !!u && String(u).indexOf("data:") === 0; } // Guarantee a player's portrait + flag are EMBEDDED data URLs before an export. // html-to-image can only rasterize data URLs and same-origin/CORS-clean images; a // portrait or flag that merely LOADS on-screen (because pick-time proxying fell back // to the raw cross-origin URL) silently blanks out in the PNG. We re-proxy any // non-data image here; flags fall back to flagcdn (from iso2), and a flag that still // won't inline is cleared so the text nation code renders instead of an empty box. // Mutates the player object in place (it's the shared registry object). function inlineExportImages(p) { if (!p) return Promise.resolve(); var jobs = []; if (p.portrait && !isDataUrl(p.portrait)) { jobs.push(proxyImage(p.portrait).then(function(du) { if (isDataUrl(du)) p.portrait = du; })); } if (p.nationFlag && !isDataUrl(p.nationFlag)) { var srcs = [p.nationFlag]; if (p._iso2) srcs.push("https://flagcdn.com/w80/" + p._iso2 + ".png"); jobs.push(srcs.reduce(function(chain, src) { return chain.then(function(done) { if (done) return true; return proxyImage(src).then(function(du) { if (isDataUrl(du)) { p.nationFlag = du; return true; } return false; }); }); }, Promise.resolve(false)).then(function(done) { if (!done) p.nationFlag = null; })); } return Promise.all(jobs); } function inlineAllExportImages(players) { return Promise.all((players || []).filter(Boolean).map(inlineExportImages)); } // Wait until every inside an export node is fully loaded AND decoded, so // html-to-image never rasterizes before the picture is paintable. On mobile (iOS // Safari especially) a freshly-set portrait data URL can still be decoding when the // 2-rAF wait elapses → the card renders but the photo is blank. .decode() guarantees // it's ready; a 4s cap means a stuck image never hangs the export. function waitForImages(node) { var imgs = node ? Array.prototype.slice.call(node.querySelectorAll("img")) : []; return Promise.all(imgs.map(function(img) { return new Promise(function(res) { var done = false; var fin = function() { if (!done) { done = true; res(); } }; var afterLoad = function() { // decode() warms the bitmap cache — but Chrome defers decode work // indefinitely for hidden/background pages, which would hang the export. // Skip it there; reliableToBlob's double render covers cold caches anyway. if (document.hidden || !img.decode) return fin(); try { img.decode().then(fin, fin); } catch (_) { fin(); } }; if (img.complete && img.naturalWidth > 0) afterLoad(); else { img.addEventListener("load", afterLoad, { once: true }); img.addEventListener("error", fin, { once: true }); } setTimeout(fin, 4000); // caps the WHOLE wait (load + decode) }); })); } // iOS Safari frequently drops embedded images on the FIRST html-to-image pass (its // SVG/ image cache isn't warm yet) — a second identical pass renders // them. On mobile we render twice and return the last blob; desktop renders once. // Wait for React's commit before rasterizing. Visible tab: 2 animation frames. // Hidden/background tab: rAF is suspended AND setTimeout is throttled (up to 1/min), // so an export kicked off there would hang — race a MessageChannel macrotask chain // (NOT throttled; same queue React's scheduler uses, so its commit task runs first) // plus a plain timeout as the final belt-and-braces. function afterPaintOrTimeout(ms) { return new Promise(function(res) { var done = false; var fin = function() { if (!done) { done = true; res(); } }; try { requestAnimationFrame(function() { requestAnimationFrame(fin); }); } catch (_) {} try { var mc = new MessageChannel(), hops = 0; mc.port1.onmessage = function() { hops++; if (hops >= 3) fin(); else mc.port2.postMessage(0); }; mc.port2.postMessage(0); } catch (_) {} setTimeout(fin, ms || 450); }); } function reliableToBlob(node, opts, twice) { return window.htmlToImage.toBlob(node, opts).then(function(first) { if (!twice) return first; return window.htmlToImage.toBlob(node, opts); }); } // Browsable leagues come straight from the subscription (`leagues` endpoint), so // upgrading the SportMonks plan automatically unlocks more leagues — no code change. // Each entry: { id, name, logo, seasons: [{id,name}, newest first] }. We carry the // recent seasons because a brand-new "current" season often has NO teams yet (e.g. // 2026/27 right after rollover) — browse falls back to the newest season with data. var _leaguesCache = null; var LEAGUE_PRIORITY = [8, 564, 384, 82, 301, 2, 5, 271, 501]; // PL, La Liga, Serie A, Bundesliga, Ligue 1, UCL, UEL, Superliga, Premiership function fetchLeagues() { if (_leaguesCache) return Promise.resolve(_leaguesCache); var all = []; var fetchPage = function(page) { return footballProxy("leagues", { include: "currentSeason;seasons", per_page: 50, page: page }).then(function(d) { if (apiEnvErr(d)) return all; all = all.concat(d.data || []); var more = d.pagination && d.pagination.has_more; return (more && page < 4) ? fetchPage(page + 1) : all; }); }; return fetchPage(1).then(function(raw) { var list = raw.map(function(lg) { var seasons = (lg.seasons || []).slice() .sort(function(a, b) { return (b.starting_at || "") < (a.starting_at || "") ? -1 : 1; }) .slice(0, 4).map(function(s) { return { id: s.id, name: s.name || "" }; }); var cs = lg.currentseason || lg.currentSeason || null; if (!seasons.length && cs) seasons = [{ id: cs.id, name: cs.name || "" }]; return { id: lg.id, name: lg.name, logo: lg.image_path || null, seasons: seasons }; }).filter(function(l) { return l.seasons.length; }); list.sort(function(a, b) { var pa = LEAGUE_PRIORITY.indexOf(a.id), pb = LEAGUE_PRIORITY.indexOf(b.id); pa = pa < 0 ? 99 : pa; pb = pb < 0 ? 99 : pb; return pa !== pb ? pa - pb : a.name.localeCompare(b.name); }); if (list.length) _leaguesCache = list; return list; }); } // Full country name → 3-letter code (FIFA-ish). Falls back to first 3 letters. var NATION_CODE = { England:"ENG", Spain:"ESP", Italy:"ITA", Germany:"GER", France:"FRA", Brazil:"BRA", Argentina:"ARG", Portugal:"POR", Netherlands:"NED", Belgium:"BEL", Croatia:"CRO", Senegal:"SEN", Nigeria:"NGA", Japan:"JPN", "South Korea":"KOR", Korea:"KOR", Morocco:"MAR", Mexico:"MEX", "United States":"USA", USA:"USA", Uruguay:"URU", Colombia:"COL", Denmark:"DEN", Sweden:"SWE", Norway:"NOR", Poland:"POL", Ireland:"IRL", Scotland:"SCO", Wales:"WAL", Austria:"AUT", Switzerland:"SUI", Serbia:"SRB", Turkey:"TUR", Ukraine:"UKR", Russia:"RUS", "Czech-Republic":"CZE", Greece:"GRE", Ghana:"GHA", Ivory_Coast:"CIV", "Côte d'Ivoire":"CIV", Cameroon:"CMR", Egypt:"EGY", Algeria:"ALG", Tunisia:"TUN", Chile:"CHI", Peru:"PER", Ecuador:"ECU", Paraguay:"PAR", Canada:"CAN", Australia:"AUS", Wales_:"WAL", Slovakia:"SVK", Slovenia:"SVN", Hungary:"HUN", Romania:"ROU", Finland:"FIN", Iceland:"ISL", Jamaica:"JAM" }; function nationCode(name) { if (!name) return ""; return NATION_CODE[name] || name.replace(/[^A-Za-z]/g, "").slice(0, 3).toUpperCase(); } // SportMonks `iso3` is ISO-3166, not the FIFA code shown on football cards // (NLD vs NED). Translate the common mismatches; identical codes pass through. // Demo players use FIFA codes, so this also keeps same-nation CHEMISTRY links working. var ISO3_TO_FIFA = { NLD:"NED", DEU:"GER", PRT:"POR", HRV:"CRO", CHE:"SUI", DNK:"DEN", GRC:"GRE", BGR:"BUL", ZAF:"RSA", CHL:"CHI", PRY:"PAR", URY:"URU", DZA:"ALG", AGO:"ANG", SAU:"KSA", ARE:"UAE", CRI:"CRC", HND:"HON", GTM:"GUA", TTO:"TRI", GNB:"GNB", COD:"COD", IRQ:"IRQ", SVN:"SVN" }; function fifaNation(iso3, name) { if (iso3 && ISO3_TO_FIFA[iso3]) return ISO3_TO_FIFA[iso3]; return iso3 || nationCode(name); } // Country name → flag image URL (flagcdn; England/Scotland/Wales use gb-* subdivisions). var NATION_ISO2 = { England:"gb-eng", Scotland:"gb-sct", Wales:"gb-wls", "Northern Ireland":"gb-nir", Spain:"es", Italy:"it", Germany:"de", France:"fr", Brazil:"br", Argentina:"ar", Portugal:"pt", Netherlands:"nl", Belgium:"be", Croatia:"hr", Senegal:"sn", Nigeria:"ng", Japan:"jp", "South Korea":"kr", Korea:"kr", Morocco:"ma", Mexico:"mx", "United States":"us", USA:"us", Uruguay:"uy", Colombia:"co", Denmark:"dk", Sweden:"se", Norway:"no", Poland:"pl", Ireland:"ie", Austria:"at", Switzerland:"ch", Serbia:"rs", Turkey:"tr", "Türkiye":"tr", Ukraine:"ua", Russia:"ru", "Czech-Republic":"cz", Czechia:"cz", Greece:"gr", Ghana:"gh", "Ivory Coast":"ci", "Côte d'Ivoire":"ci", Cameroon:"cm", Egypt:"eg", Algeria:"dz", Tunisia:"tn", Chile:"cl", Peru:"pe", Ecuador:"ec", Paraguay:"py", Canada:"ca", Australia:"au", Slovakia:"sk", Slovenia:"si", Hungary:"hu", Romania:"ro", Finland:"fi", Iceland:"is", Jamaica:"jm" }; function nationFlagUrl(name) { var iso = NATION_ISO2[name]; return iso ? ("https://flagcdn.com/w80/" + iso + ".png") : null; } // Country picker for the Card Maker: display name + FIFA code (drives the card's // nation text AND same-nation chemistry, so it must match demo/API players) + iso2 // (flagcdn flag). flagcdn is effectively our flag API — tiny CORS-clean PNGs, already // whitelisted in the image proxy so they embed in exports. England/Scotland/Wales/NI // use gb-* subdivisions (flagcdn supports them). var COUNTRIES = [ { name: "Albania", code: "ALB", iso2: "al" }, { name: "Algeria", code: "ALG", iso2: "dz" }, { name: "Argentina", code: "ARG", iso2: "ar" }, { name: "Australia", code: "AUS", iso2: "au" }, { name: "Austria", code: "AUT", iso2: "at" }, { name: "Belgium", code: "BEL", iso2: "be" }, { name: "Bosnia & Herzegovina", code: "BIH", iso2: "ba" }, { name: "Brazil", code: "BRA", iso2: "br" }, { name: "Bulgaria", code: "BUL", iso2: "bg" }, { name: "Cameroon", code: "CMR", iso2: "cm" }, { name: "Canada", code: "CAN", iso2: "ca" }, { name: "Chile", code: "CHI", iso2: "cl" }, { name: "Colombia", code: "COL", iso2: "co" }, { name: "Costa Rica", code: "CRC", iso2: "cr" }, { name: "Croatia", code: "CRO", iso2: "hr" }, { name: "Czechia", code: "CZE", iso2: "cz" }, { name: "Denmark", code: "DEN", iso2: "dk" }, { name: "DR Congo", code: "COD", iso2: "cd" }, { name: "Ecuador", code: "ECU", iso2: "ec" }, { name: "Egypt", code: "EGY", iso2: "eg" }, { name: "England", code: "ENG", iso2: "gb-eng" }, { name: "Finland", code: "FIN", iso2: "fi" }, { name: "France", code: "FRA", iso2: "fr" }, { name: "Georgia", code: "GEO", iso2: "ge" }, { name: "Germany", code: "GER", iso2: "de" }, { name: "Ghana", code: "GHA", iso2: "gh" }, { name: "Greece", code: "GRE", iso2: "gr" }, { name: "Hungary", code: "HUN", iso2: "hu" }, { name: "Iceland", code: "ISL", iso2: "is" }, { name: "Ireland", code: "IRL", iso2: "ie" }, { name: "Italy", code: "ITA", iso2: "it" }, { name: "Ivory Coast", code: "CIV", iso2: "ci" }, { name: "Jamaica", code: "JAM", iso2: "jm" }, { name: "Japan", code: "JPN", iso2: "jp" }, { name: "Mali", code: "MLI", iso2: "ml" }, { name: "Mexico", code: "MEX", iso2: "mx" }, { name: "Morocco", code: "MAR", iso2: "ma" }, { name: "Netherlands", code: "NED", iso2: "nl" }, { name: "Nigeria", code: "NGA", iso2: "ng" }, { name: "Northern Ireland", code: "NIR", iso2: "gb-nir" }, { name: "Norway", code: "NOR", iso2: "no" }, { name: "Panama", code: "PAN", iso2: "pa" }, { name: "Paraguay", code: "PAR", iso2: "py" }, { name: "Peru", code: "PER", iso2: "pe" }, { name: "Poland", code: "POL", iso2: "pl" }, { name: "Portugal", code: "POR", iso2: "pt" }, { name: "Qatar", code: "QAT", iso2: "qa" }, { name: "Romania", code: "ROU", iso2: "ro" }, { name: "Russia", code: "RUS", iso2: "ru" }, { name: "Saudi Arabia", code: "KSA", iso2: "sa" }, { name: "Scotland", code: "SCO", iso2: "gb-sct" }, { name: "Senegal", code: "SEN", iso2: "sn" }, { name: "Serbia", code: "SRB", iso2: "rs" }, { name: "Slovakia", code: "SVK", iso2: "sk" }, { name: "Slovenia", code: "SVN", iso2: "si" }, { name: "South Africa", code: "RSA", iso2: "za" }, { name: "South Korea", code: "KOR", iso2: "kr" }, { name: "Spain", code: "ESP", iso2: "es" }, { name: "Sweden", code: "SWE", iso2: "se" }, { name: "Switzerland", code: "SUI", iso2: "ch" }, { name: "Tunisia", code: "TUN", iso2: "tn" }, { name: "Turkey", code: "TUR", iso2: "tr" }, { name: "Ukraine", code: "UKR", iso2: "ua" }, { name: "United States", code: "USA", iso2: "us" }, { name: "Uruguay", code: "URU", iso2: "uy" }, { name: "Venezuela", code: "VEN", iso2: "ve" }, { name: "Wales", code: "WAL", iso2: "gb-wls" } ]; var CODE_TO_ISO2 = {}; COUNTRIES.forEach(function(c) { if (!CODE_TO_ISO2[c.code]) CODE_TO_ISO2[c.code] = c.iso2; }); function flagcdnUrl(iso2) { return iso2 ? ("https://flagcdn.com/w80/" + iso2 + ".png") : null; } // SportMonks detailed position name → our slot code (precise: "Left Back" → LB). // Falls back to the coarse position_id group (24 GK / 25 DEF / 26 MID / 27 ATT). var SMK_DETAILED_POS = { "Goalkeeper": "GK", "Centre Back": "CB", "Left Back": "LB", "Right Back": "RB", "Left Wing Back": "LWB", "Right Wing Back": "RWB", "Defensive Midfield": "CDM", "Central Midfield": "CM", "Attacking Midfield": "CAM", "Left Midfield": "LM", "Right Midfield": "RM", "Left Wing": "LW", "Right Wing": "RW", "Centre Forward": "ST", "Striker": "ST", "Second Striker": "CF", "Secondary Striker": "CF" }; var SMK_GROUP_POS = { 24: "GK", 25: "CB", 26: "CM", 27: "ST" }; function apiPosToSlot(detailedName, positionId) { if (detailedName && SMK_DETAILED_POS[detailedName]) return SMK_DETAILED_POS[detailedName]; return SMK_GROUP_POS[positionId] || "CM"; } // Plausible secondary positions for an API player (their own position excluded). // Wide mids/wings get their mirrored + adjacent roles; centre-mids stay central. function _altFor(pos) { var ALTS = { ST: ["CF"], CF: ["ST"], LW: ["RW", "LM"], RW: ["LW", "RM"], CAM: ["CM"], CM: ["CAM", "CDM"], CDM: ["CM"], LM: ["LW", "RM"], RM: ["RW", "LM"], CB: [], LB: ["LWB", "RB"], RB: ["RWB", "LB"], LWB: ["LB"], RWB: ["RB"], GK: [] }; return ALTS[pos] || []; } // Whether a player is eligible to fill a given slot — exact position, one of their // listed alts, or the same position GROUP (GK / DEF / MID / ATT). Used by the DUEL // build so an XI can't be stacked with all forwards to game the position-based scoring // (a real keeper/defence/midfield is required). The free Lineup builder is unrestricted. function canPlaySlot(player, slot) { if (!player || !slot) return false; if (player.pos === slot.pos) return true; if (player.alt && player.alt.indexOf(slot.pos) >= 0) return true; return POS_GROUP[player.pos] === POS_GROUP[slot.pos]; } // Human label for a slot's position group (for "pick a defender" style messages). function slotGroupLabel(slot) { var g = slot && POS_GROUP[slot.pos]; return g === "ATT" ? "forward" : g === "DEF" ? "defender" : g === "MID" ? "midfielder" : g === "GK" ? "goalkeeper" : "player"; } // Flatten a SportMonks statistics block (details[] with the `type` include) into // a simple { "goals": 14, "minutes-played": 2837, ... } map keyed by type code. // Values arrive as jsonb objects: { total } | { average } (rating) | { goals, penalties }. function smkStatMap(block) { var out = {}; ((block && block.details) || []).forEach(function(det) { var t = det.type || {}; // Search results carry lean details (no .type include, minutes only — see the // popularity ranking in ApiPlayerPanel); recognise type_id 119 so the // full-fetch-failed fallback path still sees minutes-played. var code = (t.code || t.developer_name || (det.type_id === 119 ? "minutes-played" : "")).toString().toLowerCase(); var v = det.value, num = null; if (typeof v === "number") num = v; else if (v && typeof v === "object") { num = (v.total != null) ? v.total : (v.average != null) ? v.average : (v.goals != null) ? v.goals : null; } if (code && num != null && isFinite(num)) out[code] = Number(num); }); return out; } // Derive the 6 FUT attributes + OVR (and gk stats for keepers) from a flattened // SportMonks stat map. Entry-tier plans only provide goals/assists/cards/minutes/ // appearances/cleansheets — richer plans add rating/shots/passes/tackles/dribbles/ // duels/saves, which feed in automatically when present. Tuning knobs: the priors // + per-90 multipliers below. function deriveAttributes(pos, S) { S = S || {}; var clamp = function(v, lo, hi) { return Math.max(lo, Math.min(hi, Math.round(v))); }; var mins = S["minutes-played"] || 0; var apps = S["appearances"] || 0; var per90 = function(x) { return (x || 0) / Math.max(0.5, (mins || 90) / 90); }; var grp = POS_GROUP[pos] || "MID"; var prior = { GK: { pace:58, sho:25, pas:55, dri:48, def:35, phy:72 }, DEF: { pace:76, sho:45, pas:68, dri:64, def:82, phy:80 }, MID: { pace:80, sho:68, pas:80, dri:80, def:66, phy:73 }, ATT: { pace:88, sho:82, pas:74, dri:86, def:40, phy:74 } }[grp]; var g90 = per90(S["goals"]), a90 = per90(S["assists"]); var csRatio = apps ? (S["cleansheets"] || 0) / apps : 0; // OVR: real average rating when the plan provides it (FUT-ish curve: // 6.5 avg → 73, 7.0 → 79, 7.5 → 86, 7.9 → 91); else estimate from goal // involvement (att/mid) or cleansheets (def/gk), with a starter-minutes boost. var ovr; if (S["rating"]) { ovr = clamp(13 * S["rating"] - 11.5, 50, 94); } else { var base; if (grp === "GK" || grp === "DEF") base = 69 + csRatio * 14 + Math.min(4, g90 * 10 + a90 * 8); else base = 70 + Math.min(12, g90 * (grp === "ATT" ? 11 : 15) + a90 * 9); ovr = clamp(base + Math.min(4, mins / 900), 58, 92); } var shotAcc = S["shots-total"] ? (S["shots-on-target"] || 0) / S["shots-total"] : 0; var passAcc = S["accurate-passes-percentage"] || ((S["accurate-passes"] && S["passes"]) ? (S["accurate-passes"] / S["passes"]) * 100 : 0); var driSucc = S["dribble-attempts"] ? (S["successful-dribbles"] || 0) / S["dribble-attempts"] : 0; var duelWin = S["total-duels"] ? (S["duels-won"] || 0) / S["total-duels"] : 0; var attrs = { pace: prior.pace + driSucc * 6 + (grp === "ATT" ? Math.min(4, g90 * 4) : 0), sho: prior.sho + g90 * 16 + shotAcc * 8, pas: prior.pas + a90 * 10 + (passAcc ? (passAcc - 75) * 0.4 : 0) + per90(S["key-passes"]) * 6, dri: prior.dri + driSucc * 12 + per90(S["dribble-attempts"]) * 2 + (grp !== "GK" ? Math.min(3, a90 * 6) : 0), def: prior.def + per90(S["tackles"]) * 3.5 + per90(S["interceptions"]) * 4 + (duelWin ? (duelWin - 0.5) * 14 : 0) + ((grp === "DEF" || grp === "GK") ? Math.min(6, csRatio * 10) : 0), phy: prior.phy + (duelWin ? (duelWin - 0.5) * 18 : 0) + per90(S["fouls"]) * 2 }; var out = { rating: ovr }; ["pace","sho","pas","dri","def","phy"].forEach(function(k) { out[k] = clamp(attrs[k], 35, 99); }); if (grp === "GK") { var sv90 = per90(S["saves"]); out.gk = { div: clamp(ovr + 1 + Math.min(3, sv90), 40, 95), han: clamp(ovr - 1, 40, 95), kic: clamp(ovr - 6, 40, 92), ref: clamp(ovr + 2 + Math.min(2, sv90), 40, 96), spd: clamp(ovr - 18, 35, 80), pos: clamp(ovr, 40, 95) }; } return out; } // Best display name for a SportMonks player/coach. common_name is USUALLY the curated // FUT-style name ("M. Salah", "V. Júnior") — but for some players it's just auto-derived // initial + full legal surname ("L. Yamal Nasraoui Ebana"). Detection: when common_name // equals the auto pattern AND display_name is a genuine SHORTENING of the legal name // (fewer words — "Lamine Yamal" vs "Lamine Yamal Nasraoui Ebana"), prefer display_name. // Word-count (not inequality) guards against spelling variants: Haaland's display_name // "Erling Haaland" vs name "Erling Håland" must NOT replace "E. Haaland". function smkBestName(p) { var common = (p.common_name || "").trim(); var dispN = (p.display_name || "").trim(); var first = (p.firstname || "").trim(); var last = (p.lastname || "").trim(); var legal = ((p.name || "").trim()) || (first && last ? first + " " + last : ""); var auto = first && last ? first.charAt(0) + ". " + last : ""; if (common && auto && common !== auto) return common; if (dispN && legal && dispN.split(/\s+/).length < legal.split(/\s+/).length) return dispN; return common || auto || dispN || legal; } // Map a SportMonks player (with nationality / detailedPosition / statistics.details.type // / teams.team includes) → our in-app player shape. opts.teamId/.clubName pin the // browsed squad's club; opts.seasonId prefers that season's stat block. For search // picks, the newest stat block wins and the club name comes from the teams include. function apiPlayerToPlayer(pl, opts) { opts = opts || {}; pl = pl || {}; var stats = (pl.statistics || []).filter(function(s) { return s && (s.details || []).length; }); // Tiny blocks (a 2-game cup run) give noisy ratings — prefer blocks with ≥450 // minutes (≈5 full games) whenever any exist. var meaty = stats.filter(function(s) { return (smkStatMap(s)["minutes-played"] || 0) >= 450; }); var pool = meaty.length ? meaty : stats; var st = null; if (opts.seasonId) st = stats.filter(function(s) { return s.season_id === opts.seasonId && (!opts.teamId || s.team_id === opts.teamId); })[0] || null; if (!st && opts.teamId) st = pool.filter(function(s) { return s.team_id === opts.teamId; }).sort(function(a, b) { return (b.season_id || 0) - (a.season_id || 0); })[0] || null; if (!st && pool.length) st = pool.slice().sort(function(a, b) { return (b.season_id || 0) - (a.season_id || 0); })[0]; var S = smkStatMap(st); var dp = pl.detailedposition || pl.detailedPosition || null; var pos = apiPosToSlot(dp && dp.name, pl.position_id); var nat = pl.nationality || null; var natName = (nat && nat.name) || ""; // Club: browse pins it; for search picks, resolve the stat block's team via the // teams include (and skip the national team, whose name equals the nationality). var club = opts.clubName || ""; if (!club) { var pivots = pl.teams || []; var byId = st ? pivots.filter(function(t) { return t.team_id === st.team_id; })[0] : null; var named = byId && byId.team && byId.team.name; if (named && named !== natName) club = named; else { var firstClub = pivots.map(function(t) { return t.team && t.team.name; }).filter(function(n) { return n && n !== natName; })[0]; club = firstClub || ""; } } // Name: smkBestName picks the curated football name (see helper). ⚠️ Everything // that renders names uses displayName(p), which REBUILDS from first/last when // `last` is set — so only keep `last` when "F. Last" reproduces the chosen name. // Otherwise legal surnames win over the football name ("M. Salah" would render // as "M. Salah Ghaly", Lamine Yamal as "L. Yamal Nasraoui Ebana"). var displayFull = (pl.display_name || pl.name || ((pl.firstname || "") + " " + (pl.lastname || ""))).trim() || "Player"; var dparts = displayFull.split(/\s+/); var first = (pl.firstname || dparts[0] || "").trim(); var last = (pl.lastname || (dparts.length > 1 ? dparts.slice(1).join(" ") : "")).trim(); var disp = smkBestName(pl) || displayFull; if (last && (first.charAt(0) + ". " + last) !== disp) last = ""; var derived = st ? deriveAttributes(pos, S) : Object.assign({ rating: 75, pace: 75, sho: 72, pas: 75, dri: 75, def: 65, phy: 75 }, POS_GROUP[pos] === "GK" ? { gk: { div: 76, han: 74, kic: 69, ref: 77, spd: 57, pos: 75 } } : {}); var p = { id: "smk:" + pl.id, name: disp, first: first || disp, last: last, pos: pos, alt: _altFor(pos), nation: fifaNation(nat && nat.iso3, natName), nationFlag: (nat && nat.image_path) || nationFlagUrl(natName), _iso2: (nat && nat.iso2) ? String(nat.iso2).toLowerCase() : null, // flagcdn fallback key for export re-inlining club: (club || "").toUpperCase(), rarity: "gold", rating: derived.rating, pace: derived.pace, sho: derived.sho, pas: derived.pas, dri: derived.dri, def: derived.def, phy: derived.phy, gk: derived.gk, trait: null, portrait: pl.image_path || null, // Season stat snapshot — powers Weekend Duel's PTS projection + FORM (§12). // Harmless extra fields everywhere else; only the chosen meaty block is kept. _smk: st ? { rating: S["rating"] || null, g: S["goals"] || 0, a: S["assists"] || 0, cs: S["cleansheets"] || 0, conceded: S["goals-conceded"] || 0, saves: S["saves"] || 0, mins: S["minutes-played"] || 0, apps: S["appearances"] || 0 } : null, _api: true }; return p; } // Downscale a portrait data URL so rows/uploads stay small (the card renders it // at a modest size). Outputs PNG to PRESERVE TRANSPARENCY — JPEG would flatten the // transparent background to black. Resolves to a PNG data URL. function prepImage(dataUrl, maxDim) { maxDim = maxDim || 512; return new Promise(function(resolve) { try { var img = new Image(); img.onload = function() { var w = img.naturalWidth || img.width, hgt = img.naturalHeight || img.height; var scale = Math.min(1, maxDim / Math.max(w, hgt)); var cw = Math.max(1, Math.round(w * scale)), ch = Math.max(1, Math.round(hgt * scale)); var canvas = document.createElement("canvas"); canvas.width = cw; canvas.height = ch; var ctx = canvas.getContext("2d"); ctx.drawImage(img, 0, 0, cw, ch); try { resolve(canvas.toDataURL("image/png")); } // PNG keeps the transparent background catch (_) { resolve(dataUrl); } // tainted/odd source → keep original }; img.onerror = function() { resolve(dataUrl); }; img.src = dataUrl; } catch (_) { resolve(dataUrl); } }); } // Load a (CORS-enabled) remote image and return it as a PNG data URL — used to // inline flags so they survive html-to-image exports without CORS taint. function imageToDataUrl(url) { return new Promise(function(resolve) { if (!url) { resolve(null); return; } var img = new Image(); img.crossOrigin = "anonymous"; img.onload = function() { try { var c = document.createElement("canvas"); c.width = img.naturalWidth || 80; c.height = img.naturalHeight || 54; var g = c.getContext("2d"); g.drawImage(img, 0, 0); resolve(c.toDataURL("image/png")); } catch (_) { resolve(url); } }; img.onerror = function() { resolve(url); }; img.src = url; }); } // Spinning soccer-ball loader (uses the .ft-spin keyframe in the HTML). Geometry is // generated: a central black pentagon + 5 surrounding black pentagons + seams, clipped // to the ball circle so the outer patches read as the classic truncated-icosahedron. function footballSpinner(size) { size = size || 30; var ink = "#1A0428", D = Math.PI / 180; var pent = function(cx, cy, r, rotDeg) { var p = []; for (var i = 0; i < 5; i++) { var a = (rotDeg + i * 72) * D; p.push((cx + r * Math.cos(a)).toFixed(1) + "," + (cy + r * Math.sin(a)).toFixed(1)); } return p.join(" "); }; var shapes = [h("polygon", { key: "c", points: pent(32, 32, 11, -90), fill: ink })]; var seams = []; for (var i = 0; i < 5; i++) { var mid = -54 + i * 72; // edge-midpoint direction var cx = 32 + 23 * Math.cos(mid * D), cy = 32 + 23 * Math.sin(mid * D); shapes.push(h("polygon", { key: "o" + i, points: pent(cx, cy, 8, mid + 180), fill: ink })); var va = (-90 + i * 72) * D; // central-pentagon vertex seams.push(h("line", { key: "s" + i, x1: (32 + 11 * Math.cos(va)).toFixed(1), y1: (32 + 11 * Math.sin(va)).toFixed(1), x2: (32 + 30 * Math.cos(va)).toFixed(1), y2: (32 + 30 * Math.sin(va)).toFixed(1), stroke: ink, strokeWidth: 1.4 })); } return h("svg", { width: size, height: size, viewBox: "0 0 64 64", className: "ft-spin", style: { display: "block" } }, h("defs", null, h("clipPath", { id: "ftball-clip" }, h("circle", { cx: 32, cy: 32, r: 30 }))), h("circle", { cx: 32, cy: 32, r: 30, fill: "#F5EDD8", stroke: ink, strokeWidth: 2.5 }), h("g", { clipPath: "url(#ftball-clip)" }, seams.concat(shapes)) ); } // Convert a base64 data URL to a Blob for Storage upload. function dataUrlToBlob(dataUrl) { var parts = dataUrl.split(","); var mime = (parts[0].match(/:(.*?);/) || [])[1] || "image/png"; var bin = atob(parts[1]); var len = bin.length; var arr = new Uint8Array(len); for (var i = 0; i < len; i++) arr[i] = bin.charCodeAt(i); return new Blob([arr], { type: mime }); } var cardStyles = { wrap: { position: "relative", width: "260px", aspectRatio: "0.62", fontFamily: "'Inter', sans-serif", color: "#2F0745", userSelect: "none", filter: "drop-shadow(0 24px 30px rgba(0,0,0,0.45)) drop-shadow(0 4px 8px rgba(0,0,0,0.5))" }, shape: { position: "absolute", inset: 0, clipPath: "polygon(8% 0, 92% 0, 100% 6%, 100% 94%, 92% 100%, 8% 100%, 0 94%, 0 6%)" }, inner: { position: "absolute", inset: "3px", clipPath: "polygon(8% 0, 92% 0, 100% 6%, 100% 94%, 92% 100%, 8% 100%, 0 94%, 0 6%)", padding: "18px 22px 16px", display: "flex", flexDirection: "column" }, topRow: { display: "flex", justifyContent: "space-between", alignItems: "flex-start" }, ratingBlock: { display: "flex", flexDirection: "column", alignItems: "flex-start", lineHeight: 1 }, rating: { fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: "54px", letterSpacing: "0.5px", lineHeight: 0.85, textShadow: "0 1px 0 rgba(255,255,255,0.45), 0 -1px 0 rgba(0,0,0,0.25)" }, posPill: { fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: "20px", letterSpacing: "1.5px", marginTop: "2px", paddingLeft: "2px" }, flagBox: { width: "44px", height: "32px", borderRadius: "3px", border: "1px solid currentColor", display: "flex", alignItems: "center", justifyContent: "center", fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: "13px", letterSpacing: "1px", background: "rgba(255,255,255,0.18)" }, portrait: { flex: 1, margin: "6px -4px 0", display: "flex", alignItems: "center", justifyContent: "center", position: "relative" }, portraitInner: { width: "100%", height: "100%", background: "repeating-linear-gradient(135deg, rgba(255,255,255,0.08) 0 6px, rgba(0,0,0,0.05) 6px 12px)", border: "1px dashed currentColor", display: "flex", alignItems: "center", justifyContent: "center", fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: "11px", letterSpacing: "2px", opacity: 0.55 }, nameRow: { textAlign: "center", fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: "26px", letterSpacing: "2px", lineHeight: 1, borderTop: "1px solid currentColor", borderBottom: "1px solid currentColor", padding: "6px 0 4px", margin: "4px -4px 0", textShadow: "0 1px 0 rgba(255,255,255,0.35)", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }, statsGrid: { display: "grid", gridTemplateColumns: "1fr 1px 1fr", columnGap: "12px", padding: "8px 4px 4px", fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: "15px", letterSpacing: "1px" }, statCol: { display: "flex", flexDirection: "column", gap: "3px" }, statRow: { display: "flex", justifyContent: "space-between", alignItems: "baseline" }, statVal: { fontSize: "17px" }, statLabel: { opacity: 0.75 }, divider: { background: "currentColor", opacity: 0.5 }, bottomMeta: { display: "flex", justifyContent: "center", alignItems: "center", gap: "10px", fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: "11px", letterSpacing: "1.5px", paddingTop: "6px", opacity: 0.9 }, dot: { width: "3px", height: "3px", borderRadius: "50%", background: "currentColor" }, engrave: { position: "absolute", pointerEvents: "none", inset: 0, clipPath: "polygon(8% 0, 92% 0, 100% 6%, 100% 94%, 92% 100%, 8% 100%, 0 94%, 0 6%)", background: "linear-gradient(180deg, rgba(255,255,255,0.18) 0%, rgba(255,255,255,0) 30%, rgba(0,0,0,0) 70%, rgba(0,0,0,0.18) 100%)" }, }; // ─── Player Trait Badges ───────────────────────────────────────────────────── // Each player gets a badge based on their single highest attribute. // Icons are inline SVGs in the Lucide style (ISC licensed, free) — no emojis. var TRAIT_LABELS = { pace: "Speed Demon", sho: "Sharp Shooter", pas: "Pass Master", dri: "Dribble Wizard", def: "Defensive Wall", phy: "Field Warrior" }; var TRAIT_KEYS = ["pace", "sho", "pas", "dri", "def", "phy"]; var SHIELD_CLIP = "polygon(0% 0%, 100% 0%, 100% 62%, 50% 100%, 0% 62%)"; var TRAIT_ICONS = { pace: [["path", { d: "M13 2 3 14h9l-1 8 10-12h-9l1-8z", fill: "currentColor", stroke: "none" }]], sho: [["circle", { cx: 12, cy: 12, r: 9 }], ["circle", { cx: 12, cy: 12, r: 5 }], ["circle", { cx: 12, cy: 12, r: 1.6, fill: "currentColor", stroke: "none" }]], pas: [["path", { d: "m6 17 5-5-5-5" }], ["path", { d: "m13 17 5-5-5-5" }]], dri: [["path", { d: "M12 2.5 10.3 8a2.5 2.5 0 0 1-1.8 1.8L3 11.5l5.5 1.7A2.5 2.5 0 0 1 10.3 15L12 20.5l1.7-5.5a2.5 2.5 0 0 1 1.8-1.8L21 11.5l-5.5-1.7A2.5 2.5 0 0 1 13.7 8z", fill: "currentColor", stroke: "none" }]], def: [["path", { d: "M12 2 4 5v6c0 5 3.4 8 8 9 4.6-1 8-4 8-9V5z" }], ["path", { d: "m9 12 2 2 4-4" }]], phy: [["rect", { x: 2, y: 9, width: 3, height: 6, rx: 1, fill: "currentColor", stroke: "none" }], ["rect", { x: 19, y: 9, width: 3, height: 6, rx: 1, fill: "currentColor", stroke: "none" }], ["rect", { x: 5, y: 10.5, width: 2, height: 3, fill: "currentColor", stroke: "none" }], ["rect", { x: 17, y: 10.5, width: 2, height: 3, fill: "currentColor", stroke: "none" }], ["rect", { x: 7, y: 11, width: 10, height: 2, fill: "currentColor", stroke: "none" }]] }; function traitIcon(key, size) { var els = (TRAIT_ICONS[key] || []).map(function(e, i) { return h(e[0], Object.assign({ key: i }, e[1])); }); return h("svg", { width: size, height: size, viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 2, strokeLinecap: "round", strokeLinejoin: "round", style: { display: "block", flexShrink: 0 } }, els); } function managerIcon(size) { return h("svg", { width: size, height: size, viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 2, strokeLinecap: "round", strokeLinejoin: "round", style: { display: "block", flexShrink: 0 } }, h("circle", { cx: 12, cy: 8, r: 4 }), h("path", { d: "M4 20c0-4.4 3.6-7 8-7s8 2.6 8 7" }) ); } // Display name = first-initial + last name (e.g. "T. Collishaw"); falls back to a plain name field. function displayName(p) { if (p && p.last) { var li = p.first && p.first.trim() ? p.first.trim().charAt(0).toUpperCase() + ". " : ""; return (li + p.last).trim(); } return (p && p.name) || "—"; } // Full "Firstname Lastname" for detail views — dedupes when first === lastname (single-name players). function displayFull(p) { if (!p) return ""; // A multi-word name with no "F. " prefix is already a curated full name (e.g. // "Lamine Yamal" via smkBestName) — prepending `first` would double it up. if (!p.last && p.name && /\s/.test(p.name) && !/^[A-Z]\.\s/.test(p.name)) return p.name; var base = (p.name || "").replace(/^[A-Z]\.\s/, ""); if (p.first && base && p.first.toLowerCase() !== base.toLowerCase()) return p.first + " " + base; return p.name || p.first || ""; } function bestTrait(p) { if (!p) return null; var pairs = [["pace", p.pace], ["sho", p.sho], ["pas", p.pas], ["dri", p.dri], ["def", p.def], ["phy", p.phy]]; var best = null; pairs.forEach(function(s) { if (s[1] != null && (best === null || s[1] > best[1])) best = s; }); return best ? best[0] : null; } // Shield-shaped trait badge that sits on the card edge (half overhanging). `pos` places it absolutely. // Icon-only — the trait NAME never fit legibly inside a small shield; the full label lives on the // big PlayerCard's pill instead. function traitShield(trait, w, pos) { if (!trait) return null; var hh = Math.round(w * 1.22); return h("div", { style: Object.assign({ position: "absolute", width: w + "px", height: hh + "px", filter: "drop-shadow(0 3px 6px rgba(0,0,0,0.55))", zIndex: 5, pointerEvents: "none" }, pos || {}) }, h("div", { style: { position: "absolute", inset: 0, clipPath: SHIELD_CLIP, background: "linear-gradient(160deg, #FFE9A8, #CD9602)" } }), h("div", { style: { position: "absolute", inset: Math.max(1.5, w * 0.045) + "px", clipPath: SHIELD_CLIP, background: "linear-gradient(160deg, #1a0420 0%, #3a0c54 100%)" } }), h("div", { style: { position: "absolute", inset: 0, display: "flex", alignItems: "center", justifyContent: "center", color: "#FFE9A8", paddingBottom: Math.round(hh * 0.06) + "px" } }, traitIcon(trait, Math.round(w * 0.5)) ) ); } // Shared portrait framing: object-fit cover + pan (X/Y) + zoom, anchored on the focal point. function portraitStyle(p) { var x = p.portraitX != null ? p.portraitX : 50; var y = p.portraitY != null ? p.portraitY : 50; var z = p.portraitZoom || 1; return { width: "100%", height: "100%", objectFit: "cover", objectPosition: x + "% " + y + "%", transform: "scale(" + z + ")", transformOrigin: x + "% " + y + "%", display: "block" }; } function PlayerCard(props) { var player = props.player; var rarity = props.rarity || "gold"; var scale = props.scale || 1; var r = RARITIES.find(function(x) { return x.id === rarity; }) || RARITIES[0]; var stats = player.gk ? [["DIV", player.gk.div], ["HAN", player.gk.han], ["KIC", player.gk.kic], ["REF", player.gk.ref], ["SPD", player.gk.spd], ["POS", player.gk.pos]] : [["PAC", player.pace], ["DRI", player.dri], ["SHO", player.sho], ["DEF", player.def], ["PAS", player.pas], ["PHY", player.phy]]; var left = stats.filter(function(_, i) { return i % 2 === 0; }); var right = stats.filter(function(_, i) { return i % 2 === 1; }); var shortName = displayName(player); var trait = (player.trait && TRAIT_LABELS[player.trait]) ? player.trait : bestTrait(player); return h("div", { style: Object.assign({}, cardStyles.wrap, { color: r.ink, transform: "scale(" + scale + ")", transformOrigin: "top left" }) }, h("div", { style: Object.assign({}, cardStyles.shape, { background: r.bg }) }), h("div", { style: cardStyles.engrave }), h("div", { style: r.cardBg ? Object.assign({}, cardStyles.inner, { background: r.cardBg }) : cardStyles.inner }, h("div", { style: cardStyles.topRow }, h("div", { style: cardStyles.ratingBlock }, h("div", { style: cardStyles.rating }, player.rating || "--"), h("div", { style: cardStyles.posPill }, player.pos || "\u2014") ), h("div", { style: Object.assign({}, cardStyles.flagBox, { color: r.ink }, player.nationFlag ? { padding: 0, overflow: "hidden", background: "transparent", border: "none" } : null) }, player.nationFlag ? h("img", { src: player.nationFlag, alt: player.nation || "", crossOrigin: "anonymous", style: { width: "100%", height: "100%", objectFit: "cover", display: "block" } }) : (player.nation || "\u2014") ) ), h("div", { style: Object.assign({}, cardStyles.portrait, { overflow: props.portraitEditing ? "visible" : "hidden", zIndex: props.portraitEditing ? 6 : undefined }) }, // In edit mode the overlay (PortraitCropEditor) draws the photo itself (clear + // ghosted overflow), so the static img is hidden to avoid a double image. !props.portraitEditing && (player.portrait ? h("img", { src: player.portrait, alt: "", crossOrigin: "anonymous", style: player._api ? { width: "100%", height: "100%", objectFit: "contain", objectPosition: "50% 100%", display: "block" } : portraitStyle(player) }) : (props.portraitOverlay ? null : h("div", { style: cardStyles.portraitInner }, "PLAYER PORTRAIT"))), props.portraitOverlay || null ), trait && h("div", { style: { display: "flex", justifyContent: "center", margin: "8px 0 0" } }, h("div", { style: { display: "inline-flex", alignItems: "center", gap: "6px", padding: "4px 12px 3px", borderRadius: "999px", border: "1.5px solid currentColor", fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: "14px", letterSpacing: "1.5px", lineHeight: 1, whiteSpace: "nowrap" } }, traitIcon(trait, 14), h("span", null, TRAIT_LABELS[trait].toUpperCase()) ) ), h("div", { style: cardStyles.nameRow }, shortName), h("div", { style: cardStyles.statsGrid }, h("div", { style: cardStyles.statCol }, left.map(function(s) { return h("div", { key: s[0], style: cardStyles.statRow }, h("span", { style: cardStyles.statVal }, s[1] != null ? s[1] : "--"), h("span", { style: cardStyles.statLabel }, s[0])); }) ), h("div", { style: cardStyles.divider }), h("div", { style: cardStyles.statCol }, right.map(function(s) { return h("div", { key: s[0], style: cardStyles.statRow }, h("span", { style: cardStyles.statVal }, s[1] != null ? s[1] : "--"), h("span", { style: cardStyles.statLabel }, s[0])); }) ) ), h("div", { style: cardStyles.bottomMeta }, h("span", null, player.club || "FREE AGENT"), h("span", { style: cardStyles.dot }), h("span", null, r.name) ) ) ); } // ═══════════════════════════════════════════════════════════════════════════════ // // ██╗ ██╗███╗ ██╗███████╗██╗ ██╗██████╗ // ██║ ██║████╗ ██║██╔════╝██║ ██║██╔══██╗ // ██║ ██║██╔██╗ ██║█████╗ ██║ ██║██████╔╝ // ██║ ██║██║╚██╗██║██╔══╝ ██║ ██║██╔═══╝ // ███████╗██║██║ ╚████║███████╗╚██████╔╝██║ // ╚══════╝╚═╝╚═╝ ╚═══╝╚══════╝ ╚═════╝ ╚═╝ // // LINEUP BUILDER — Formation selector, pitch view, search/stats modals, export // // ═══════════════════════════════════════════════════════════════════════════════ var useState = React.useState; var useMemo = React.useMemo; var useEffect = React.useEffect; var useRef = React.useRef; function useWindowSize() { var _s = useState({ w: window.innerWidth }); var size = _s[0], setSize = _s[1]; useEffect(function() { function handler() { setSize({ w: window.innerWidth }); } window.addEventListener("resize", handler); return function() { window.removeEventListener("resize", handler); }; }, []); return size; } // ─── Export Backgrounds ───────────────────────────────────────────────────── var BACKGROUNDS = [ { id: "stadium", name: "STADIUM NIGHT", swatch: "linear-gradient(160deg, #0b0117 0%, #2F0745 50%, #4a1166 100%)", render: function() { return h("div", { style: { position: "absolute", inset: 0, background: "radial-gradient(ellipse at 50% -10%, #6c2090 0%, #2F0745 40%, #0b0117 100%)", overflow: "hidden" } }, h("div", { style: { position: "absolute", top: 0, left: "10%", width: "30%", height: "60%", background: "radial-gradient(ellipse at top, rgba(245,237,216,0.15), transparent 60%)", filter: "blur(20px)" } }), h("div", { style: { position: "absolute", top: 0, right: "10%", width: "30%", height: "60%", background: "radial-gradient(ellipse at top, rgba(245,237,216,0.15), transparent 60%)", filter: "blur(20px)" } }) ); } }, { id: "gold", name: "GOLD FOIL", swatch: "linear-gradient(160deg, #2F0745 0%, #CD9602 100%)", render: function() { return h("div", { style: { position: "absolute", inset: 0, overflow: "hidden" } }, h("div", { style: { position: "absolute", inset: 0, background: "linear-gradient(135deg, #1A0428 0%, #2F0745 40%, #4a1166 70%, #CD9602 130%)" } }), h("div", { style: { position: "absolute", inset: 0, background: "repeating-linear-gradient(45deg, rgba(232,184,58,0.06) 0 2px, transparent 2px 14px)" } }), h("div", { style: { position: "absolute", top: "-20%", right: "-20%", width: "70%", height: "70%", background: "radial-gradient(circle, rgba(232,184,58,0.3) 0%, transparent 60%)", filter: "blur(40px)" } }) ); } }, { id: "blueprint", name: "TACTICS BOARD", swatch: "linear-gradient(160deg, #0a1a2b 0%, #14304f 100%)", render: function() { return h("div", { style: { position: "absolute", inset: 0, overflow: "hidden", background: "#0c1d33" } }, h("div", { style: { position: "absolute", inset: 0, backgroundImage: "linear-gradient(rgba(255,255,255,0.05) 1px, transparent 1px), linear-gradient(90deg, rgba(255,255,255,0.05) 1px, transparent 1px)", backgroundSize: "40px 40px" } }), h("div", { style: { position: "absolute", inset: 0, background: "radial-gradient(ellipse at 50% 50%, transparent 0%, rgba(0,0,0,0.5) 100%)" } }) ); } }, { id: "fire", name: "FIRE", swatch: "linear-gradient(160deg, #2b0807 0%, #c2410c 100%)", render: function() { return h("div", { style: { position: "absolute", inset: 0, overflow: "hidden" } }, h("div", { style: { position: "absolute", inset: 0, background: "linear-gradient(180deg, #1a0107 0%, #4a0a05 40%, #c2410c 100%)" } }), h("div", { style: { position: "absolute", bottom: "-30%", left: "20%", width: "60%", height: "70%", background: "radial-gradient(ellipse, rgba(255,150,30,0.5) 0%, transparent 60%)", filter: "blur(40px)" } }), h("div", { style: { position: "absolute", bottom: "-20%", left: "-10%", width: "50%", height: "50%", background: "radial-gradient(ellipse, rgba(220,38,38,0.4) 0%, transparent 60%)", filter: "blur(30px)" } }) ); } }, { id: "minimal", name: "MONO CREAM", swatch: "linear-gradient(160deg, #F5EDD8 0%, #c9b97f 100%)", render: function() { return h("div", { style: { position: "absolute", inset: 0, overflow: "hidden", background: "#F5EDD8" } }, h("div", { style: { position: "absolute", inset: 0, background: "radial-gradient(ellipse at 50% 30%, rgba(205,150,2,0.15) 0%, transparent 60%)" } }), h("div", { style: { position: "absolute", inset: 0, background: "repeating-linear-gradient(45deg, rgba(47,7,69,0.02) 0 2px, transparent 2px 18px)" } }) ); } }, ]; // Card-export-only option: no background — exports just the card on transparency. var TRANSPARENT_BG = { id: "transparent", name: "TRANSPARENT", swatch: "repeating-conic-gradient(#4a4a52 0% 25%, #2a2a30 0% 50%) 0 0 / 13px 13px", render: function() { return null; } }; var CARD_BACKGROUNDS = BACKGROUNDS.concat([TRANSPARENT_BG]); var EXPORT_W = 1080; var EXPORT_H = 1350; var LOGO_URL = "https://cdn.prod.website-files.com/69eb6394026dd7faab0bad78/69f9aeb58349a6169af82506_Logo%20with%20name.avif"; // ── MASTER SWITCH for the in-app TEST MODES (Duels 2X sample data §26.8 + Shootout playable // sample match §25.8). false = the "TEST MODE" pills are hidden and any persisted "on" flag in // localStorage is ignored, so real users only ever see real data. Flip to true to test. var TEST_MODES_ENABLED = false; var _logoDataUrlCache = null; function fetchLogoDataUrl() { if (_logoDataUrlCache) return Promise.resolve(_logoDataUrlCache); return new Promise(function(resolve) { var img = new Image(); img.crossOrigin = "anonymous"; img.onload = function() { try { var c = document.createElement("canvas"); c.width = img.naturalWidth || 1920; c.height = img.naturalHeight || 500; var ctx = c.getContext("2d"); ctx.drawImage(img, 0, 0); _logoDataUrlCache = c.toDataURL("image/png"); resolve(_logoDataUrlCache); } catch(e) { resolve(null); } }; img.onerror = function() { resolve(null); }; img.src = LOGO_URL; }); } // Warm the logo cache as soon as the script loads so it's embedded and ready before any export. fetchLogoDataUrl(); // A self-contained text wordmark used in exports if the image logo can't be embedded (no external asset, always renders). function brandMark(color, size) { return h("div", { style: { display: "inline-flex", alignItems: "center", gap: "0.32em", color: color, fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: size + "px", letterSpacing: "0.12em", lineHeight: 1 } }, h("svg", { width: Math.round(size * 1.05), height: Math.round(size * 1.05), viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 1.6, style: { display: "block" } }, h("circle", { cx: 12, cy: 12, r: 9.5 }), h("path", { d: "M12 6.5 15.8 9.3 14.3 13.8H9.7L8.2 9.3z", fill: "currentColor", stroke: "none" }), h("path", { d: "M12 6.5V3M15.8 9.3l3.1-1.4M14.3 13.8l1.9 2.9M9.7 13.8l-1.9 2.9M8.2 9.3 5.1 7.9" }) ), h("span", null, "HOME TEAM") ); } // ─── Lineup: MiniCard (pitch token) ───────────────────────────────────────── function MiniCard(props) { var player = props.player; var r = RARITIES.find(function(x) { return x.id === (props.rarity || "gold"); }) || RARITIES[0]; var trait = (player.trait && TRAIT_LABELS[player.trait]) ? player.trait : bestTrait(player); var OCT = "polygon(8% 0, 92% 0, 100% 6%, 100% 94%, 92% 100%, 8% 100%, 0 94%, 0 6%)"; // Name is rendered BELOW the card (in the pitch slot), not inside it. return h("div", { style: { width: "52px", aspectRatio: "0.71", position: "relative", filter: "drop-shadow(0 6px 10px rgba(0,0,0,0.5))", color: r.ink, fontFamily: "'Anton', 'Bebas Neue', sans-serif" } }, h("div", { style: { position: "absolute", inset: 0, background: r.bg, clipPath: OCT } }), // Portrait anchored to the BOTTOM edge of the card (FUT-style), clipped to the card shape. player.portrait && h("div", { style: { position: "absolute", inset: 0, clipPath: OCT, overflow: "hidden", pointerEvents: "none" } }, h("img", { src: player.portrait, alt: "", crossOrigin: "anonymous", style: { position: "absolute", left: "50%", bottom: 0, width: "92%", height: "66%", objectFit: "contain", objectPosition: "50% 100%", transform: "translateX(-50%) scale(" + (player.portraitZoom || 1) + ")", transformOrigin: "50% 100%" } }) ), h("div", { style: { position: "absolute", inset: 0, padding: "4px 5px", display: "flex", flexDirection: "column", alignItems: "center", textAlign: "center", lineHeight: 1 } }, h("div", { style: { display: "flex", justifyContent: "space-between", width: "100%", fontSize: "7px", letterSpacing: "0.5px" } }, h("div", null, h("div", { style: { fontSize: "14px", lineHeight: 0.9 } }, player.rating), h("div", { style: { fontSize: "6px", letterSpacing: "1px" } }, player.pos) ), player.nationFlag ? h("div", { style: { width: "13px", height: "9px", borderRadius: "1px", overflow: "hidden", flexShrink: 0, boxShadow: "0 0 0 0.5px rgba(0,0,0,0.3)" } }, h("img", { src: player.nationFlag, alt: "", crossOrigin: "anonymous", style: { width: "100%", height: "100%", objectFit: "cover", display: "block" } })) : h("div", { style: { fontSize: "6px", border: "0.5px solid currentColor", padding: "1px 2px", borderRadius: "1px", letterSpacing: "0.5px" } }, player.nation) ), !player.portrait && h("div", { style: { flex: 1, width: "100%", marginTop: "2px", background: "repeating-linear-gradient(135deg, rgba(255,255,255,0.08) 0 3px, rgba(0,0,0,0.05) 3px 6px)", opacity: 0.5 } }) ), traitShield(trait, 19, { left: "-9px", top: "44%" }) ); } // ─── Lineup: ExportMiniCard (larger for export) ───────────────────────────── function ExportMiniCard(props) { var player = props.player; var r = RARITIES[0]; var trait = (player.trait && TRAIT_LABELS[player.trait]) ? player.trait : bestTrait(player); // Name is rendered BELOW the card (see ExportCanvas slot map), not inside it. var OCT = "polygon(8% 0, 92% 0, 100% 6%, 100% 94%, 92% 100%, 8% 100%, 0 94%, 0 6%)"; return h("div", { style: { width: "104px", aspectRatio: "0.71", position: "relative", filter: "drop-shadow(0 10px 16px rgba(0,0,0,0.5))", color: r.ink, fontFamily: "'Anton', 'Bebas Neue', sans-serif" } }, h("div", { style: { position: "absolute", inset: 0, background: r.bg, clipPath: OCT } }), // Portrait anchored to the BOTTOM edge of the card (FUT-style), clipped to the card shape. player.portrait && h("div", { style: { position: "absolute", inset: 0, clipPath: OCT, overflow: "hidden", pointerEvents: "none" } }, h("img", { src: player.portrait, alt: "", crossOrigin: "anonymous", style: { position: "absolute", left: "50%", bottom: 0, width: "92%", height: "66%", objectFit: "contain", objectPosition: "50% 100%", transform: "translateX(-50%) scale(" + (player.portraitZoom || 1) + ")", transformOrigin: "50% 100%" } }) ), h("div", { style: { position: "absolute", inset: 0, clipPath: OCT, background: "linear-gradient(180deg, rgba(255,255,255,0.2) 0%, transparent 30%, rgba(0,0,0,0.15) 100%)" } }), h("div", { style: { position: "absolute", inset: 0, padding: "8px 7px", display: "flex", flexDirection: "column", alignItems: "center", textAlign: "center", lineHeight: 1 } }, h("div", { style: { display: "flex", justifyContent: "space-between", width: "100%", fontSize: "10px", letterSpacing: "0.5px" } }, h("div", null, h("div", { style: { fontSize: "28px", lineHeight: 0.9 } }, player.rating), h("div", { style: { fontSize: "13px", letterSpacing: "1px", marginTop: "2px" } }, player.pos) ), player.nationFlag ? h("div", { style: { width: "24px", height: "16px", borderRadius: "2px", overflow: "hidden", flexShrink: 0, boxShadow: "0 0 0 1px rgba(0,0,0,0.3)" } }, h("img", { src: player.nationFlag, alt: "", crossOrigin: "anonymous", style: { width: "100%", height: "100%", objectFit: "cover", display: "block" } })) : h("div", { style: { fontSize: "10px", border: "1px solid currentColor", padding: "2px 4px", borderRadius: "2px", letterSpacing: "0.5px", height: "fit-content" } }, player.nation) ), !player.portrait && h("div", { style: { flex: 1, width: "100%", marginTop: "4px", background: "repeating-linear-gradient(135deg, rgba(255,255,255,0.1) 0 6px, rgba(0,0,0,0.05) 6px 12px)", opacity: 0.5 } }) ), traitShield(trait, 38, { left: "-19px", top: "44%" }), // Captain badge — must live INSIDE the card root: html-to-image paints the card's // drop-shadow filter group over z-indexed SIBLINGS, so a badge next to the card // renders behind it in the exported PNG (browser stacking is fine; the rasterizer isn't). props.isCaptain && h("div", { style: { position: "absolute", top: "-13px", left: "-9px", width: "36px", height: "36px", borderRadius: "50%", background: "linear-gradient(135deg,#E8B83A,#CD9602)", border: "3px solid #1A0428", display: "flex", alignItems: "center", justifyContent: "center", fontSize: "20px", color: "#1A0428", zIndex: 6, boxShadow: "0 0 14px rgba(232,184,58,0.8)", lineHeight: 1 } }, "C") ); } // ─── Lineup: PitchLines SVG ───────────────────────────────────────────────── function PitchLines() { return h("svg", { style: { position: "absolute", inset: 0, pointerEvents: "none", opacity: 0.35 }, viewBox: "0 0 100 135", preserveAspectRatio: "none" }, h("g", { fill: "none", stroke: "#F5EDD8", strokeWidth: "0.3" }, h("rect", { x: 3, y: 3, width: 94, height: 129 }), h("line", { x1: 3, y1: 67.5, x2: 97, y2: 67.5 }), h("circle", { cx: 50, cy: 67.5, r: 11 }), h("circle", { cx: 50, cy: 67.5, r: 0.6, fill: "#F5EDD8" }), h("rect", { x: 22, y: 3, width: 56, height: 16 }), h("rect", { x: 36, y: 3, width: 28, height: 6 }), h("circle", { cx: 50, cy: 14, r: 0.6, fill: "#F5EDD8" }), h("rect", { x: 22, y: 116, width: 56, height: 16 }), h("rect", { x: 36, y: 126, width: 28, height: 6 }), h("circle", { cx: 50, cy: 121, r: 0.6, fill: "#F5EDD8" }), h("path", { d: "M 40 19 A 11 11 0 0 0 60 19" }), h("path", { d: "M 40 116 A 11 11 0 0 1 60 116" }) ) ); } // ─── Lineup: Export Canvas ────────────────────────────────────────────────── function ExportCanvas(props) { var bgId = props.bgId, formation = props.formation, assignments = props.assignments, teamName = props.teamName, teamRating = props.teamRating, showBranding = props.showBranding, logoSrc = props.logoSrc, manager = props.manager, captain = props.captain; var bg = BACKGROUNDS.find(function(b) { return b.id === bgId; }) || BACKGROUNDS[0]; var isLight = bgId === "minimal"; var inkPrimary = isLight ? "#2F0745" : "#F5EDD8"; var inkAccent = isLight ? "#7a4a00" : "#E8B83A"; var lineColor = isLight ? "rgba(47,7,69,0.25)" : "rgba(245,237,216,0.35)"; var logoOk = !!(logoSrc && logoSrc.indexOf("data:") === 0); // Export row spacing: the lone-pivot 5-row shapes (those with a CDM: 4-2-3-1 / 4-1-3-2 / 3-1-4-2) // pack their rows tight for the small LIVE cards (and place the ST at y4 to poke out). The export // cards are ~3x bigger, so at the raw spacing the striker + attacking-mid line OVERLAP. For just // those shapes, cluster the rows and re-spread them with a minimum 19% gap (fits 5 rows in [10,86]). // 4-band and wing-back shapes have natural gaps >19% and keep their raw, builder-matching spacing. var exClampY = function(y) { return Math.max(10, Math.min(y, 86)); }; var exRows = null; if (formation.slots.some(function(s) { return s.pos === "CDM"; })) { var _ys = formation.slots.map(function(s) { return exClampY(s.y); }).sort(function(a, b) { return a - b; }); exRows = []; var _prev = -999; _ys.forEach(function(y) { if (!exRows.length || y - exRows[exRows.length - 1].max > 6) exRows.push({ min: y, max: y, adj: 0 }); else exRows[exRows.length - 1].max = y; }); exRows.forEach(function(r) { r.adj = Math.max(r.min, _prev + 19); _prev = r.adj; }); } var exportBandY = function(slotY) { var cy = exClampY(slotY); if (!exRows) return cy; for (var i = 0; i < exRows.length; i++) { if (cy >= exRows[i].min && cy <= exRows[i].max) return exRows[i].adj + (cy - exRows[i].min); } return cy; }; // The export mirrors the LIVE builder pitch 1:1 — each slot sits at its raw // Math.min(slot.y, 86)% (same clamp as the builder), with the card centred on that point // (translate -73px ≈ half the 104/0.71 card) and the name hanging below — so the formation // band spacing matches the builder EXACTLY. (Was an even-row redistribution that flattened // the bands to equal gaps; the client wanted the export to match the builder's real spacing. // The card-centred anchor + the new formation y-values keep the top card + GK name inside // the overflow:hidden pitch — top row ST y13 → card top ≈ 57px, GK name bottom well above // the pitch floor; verified no clip.) return h("div", { id: "export-canvas", style: { width: EXPORT_W + "px", height: EXPORT_H + "px", position: "relative", overflow: "hidden", fontFamily: "'Inter', sans-serif", color: inkPrimary } }, bg.render(), h("div", { style: { position: "absolute", top: 0, left: "64px", right: manager ? "300px" : "64px", padding: "44px 0 0", display: "flex", flexDirection: "column", alignItems: "center", textAlign: "center", gap: "12px", zIndex: 2 } }, logoOk ? h("img", { src: logoSrc, alt: "Hometeam", style: { height: "50px", width: "190px", display: "block", objectFit: "contain", objectPosition: "center center" } }) : brandMark(inkPrimary, 34), teamName && h("div", { style: { fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: "44px", letterSpacing: "3px", color: inkPrimary, lineHeight: 1, textShadow: isLight ? "none" : "0 2px 20px rgba(0,0,0,0.5)", whiteSpace: "nowrap" } }, teamName), h("div", { style: { fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: "23px", letterSpacing: "3px", color: inkAccent, whiteSpace: "nowrap", lineHeight: 1 } }, formation.name + " \u00B7 " + Object.keys(assignments).length + "/" + formation.slots.length + " STARTERS", h("span", { style: { opacity: 0.45 } }, "\u00A0\u00A0\u00B7\u00A0\u00A0"), h("span", { style: { fontSize: "33px" } }, teamRating || "--"), h("span", { style: { fontSize: "16px", letterSpacing: "2px", marginLeft: "5px" } }, "OVR") ) ), h("div", { style: { position: "absolute", top: "240px", left: "64px", right: "64px", bottom: "108px", zIndex: 2 } }, h("div", { style: { position: "relative", width: "100%", height: "100%", background: isLight ? "linear-gradient(180deg, rgba(47,7,69,0.04) 0%, rgba(47,7,69,0.1) 100%)" : "linear-gradient(180deg, rgba(0,0,0,0.15) 0%, rgba(0,0,0,0.3) 100%)", borderRadius: "20px", border: "1px solid " + lineColor, overflow: "hidden" } }, h("svg", { style: { position: "absolute", inset: 0, width: "100%", height: "100%", opacity: 0.55 }, viewBox: "0 0 100 135", preserveAspectRatio: "none" }, h("g", { fill: "none", stroke: lineColor, strokeWidth: "0.25" }, h("rect", { x: 2, y: 2, width: 96, height: 131 }), h("line", { x1: 2, y1: 67.5, x2: 98, y2: 67.5 }), h("circle", { cx: 50, cy: 67.5, r: 11 }), h("rect", { x: 22, y: 2, width: 56, height: 16 }), h("rect", { x: 36, y: 2, width: 28, height: 6 }), h("rect", { x: 22, y: 117, width: 56, height: 16 }), h("rect", { x: 36, y: 127, width: 28, height: 6 }) ) ), formation.slots.map(function(slot) { var pid = assignments[slot.id]; var player = pid ? getPlayer(pid) : null; var ey = exportBandY(slot.y); // clamp to [10,86] + min-gap re-spread for CDM shapes (see above) var pnm = player ? displayName(player).toUpperCase() : ""; // Occupied slots anchor on the CARD's center (half of 104/0.71 ≈ 73px), not the // card+name column — otherwise a wrapping 2-line name pushes the card up past the // pitch's overflow:hidden top edge (rating row + captain badge got clipped). return h("div", { key: slot.id, style: { position: "absolute", left: slot.x + "%", top: ey + "%", transform: player ? "translate(-50%, -73px)" : "translate(-50%, -50%)", display: "flex", flexDirection: "column", alignItems: "center" } }, player ? [ h(ExportMiniCard, { key: "card", player: player, isCaptain: pid === captain }), h("div", { key: "name", style: { marginTop: "9px", fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: "25px", letterSpacing: "0.5px", lineHeight: 1.04, color: inkPrimary, textAlign: "center", maxWidth: "172px", textShadow: isLight ? "none" : "0 2px 7px rgba(0,0,0,0.7)" } }, pnm) ] : [ h("div", { key: "circle", style: { width: "90px", height: "90px", borderRadius: "50%", background: "rgba(0,0,0,0.3)", border: "2px dashed " + lineColor, display: "flex", alignItems: "center", justifyContent: "center", color: inkPrimary, opacity: 0.55, fontSize: "36px", fontWeight: 200 } }, "+"), h("div", { key: "label", style: { fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: "20px", letterSpacing: "2px", color: inkAccent, marginTop: "6px" } }, slot.pos) ] ); }) ) ), // Manager card — top-right, a distinct (non-player) card manager && h("div", { style: { position: "absolute", top: "44px", right: "52px", width: "210px", zIndex: 4 } }, h("div", { style: { background: isLight ? "linear-gradient(165deg, #ffffff 0%, #f3e9cf 100%)" : "linear-gradient(165deg, #3a1158 0%, #160426 100%)", border: "2px solid " + inkAccent, borderRadius: "16px", boxShadow: "0 14px 30px rgba(0,0,0,0.5)", padding: "16px 14px 15px", display: "flex", flexDirection: "column", alignItems: "center", textAlign: "center" } }, h("div", { style: { fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: "13px", letterSpacing: "4px", color: inkAccent, marginBottom: "10px" } }, "MANAGER"), h("div", { style: { width: "56px", height: "56px", borderRadius: "50%", overflow: "hidden", background: isLight ? "rgba(205,150,2,0.12)" : "rgba(232,184,58,0.14)", border: "2px solid " + inkAccent, display: "flex", alignItems: "center", justifyContent: "center", color: inkAccent, marginBottom: "12px" } }, manager.photo ? h("img", { src: manager.photo, alt: "", crossOrigin: "anonymous", style: { width: "100%", height: "100%", objectFit: "cover", display: "block" } }) : managerIcon(30)), h("div", { style: { fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: "21px", letterSpacing: "0.5px", color: inkPrimary, lineHeight: 1.05, textAlign: "center", maxWidth: "186px" } }, manager.name), h("div", { style: { width: "40px", height: "1px", background: inkAccent, opacity: 0.5, margin: "9px 0" } }), h("div", { style: { fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: "12px", letterSpacing: "1.5px", color: inkAccent, whiteSpace: "nowrap" } }, manager.nation + " · " + manager.style) ) ), showBranding && h("div", { style: { position: "absolute", bottom: 0, left: 0, right: 0, padding: "0 64px 50px", display: "flex", alignItems: "center", justifyContent: "space-between", zIndex: 2 } }, logoOk ? h("img", { src: logoSrc, alt: "Hometeam", style: { height: "36px", width: "138px", display: "block", flexShrink: 0, objectFit: "contain", objectPosition: "left center", opacity: 0.9 } }) : brandMark(inkAccent, 26), h("div", { style: { fontSize: "20px", letterSpacing: "1px", color: inkAccent, fontFamily: "'Anton', 'Bebas Neue', sans-serif", whiteSpace: "nowrap", flex: 1, textAlign: "right", marginLeft: "16px" } }, "#BUILDYOURXI") ) ); } // ─── Lineup: Export Modal ─────────────────────────────────────────────────── function ExportModal(props) { var isMobile = useWindowSize().w < 768; var formation = props.formation, assignments = props.assignments, teamRating = props.teamRating, manager = props.manager, captain = props.captain, onClose = props.onClose, onExported = props.onExported; var _bgId = useState("stadium"), bgId = _bgId[0], setBgId = _bgId[1]; var _tn = useState("MY ULTIMATE XI"), teamName = _tn[0], setTeamName = _tn[1]; var _sb = useState(true), showBranding = _sb[0], setShowBranding = _sb[1]; var _busy = useState(false), busy = _busy[0], setBusy = _busy[1]; var _toast = useState(null), toast = _toast[0], setToast = _toast[1]; var _scale = useState(0.5), scale = _scale[0], setScale = _scale[1]; var _logoSrc = useState(_logoDataUrlCache), logoSrc = _logoSrc[0], setLogoSrc = _logoSrc[1]; var _ink = useState(0), setInlineTick = _ink[1]; useEffect(function() { fetchLogoDataUrl().then(function(url) { setLogoSrc(url); }); }, []); useEffect(function() { function fit() { var wrap = document.getElementById("ex-preview-wrap"); if (!wrap) return; var availW = wrap.clientWidth - 40; var availH = wrap.clientHeight - 40; var s = Math.min(availW / EXPORT_W, availH / EXPORT_H); setScale(Math.max(0.15, Math.min(0.7, s))); } fit(); window.addEventListener("resize", fit); return function() { window.removeEventListener("resize", fit); }; }, []); var showToastMsg = function(msg) { setToast(msg); setTimeout(function() { setToast(null); }, 2200); }; var generateBlob = function() { if (!window.htmlToImage) return Promise.reject(new Error("Export library not loaded")); // Re-inline any non-data portrait/flag (incl. the manager photo) so nothing blanks // out in the PNG, then force a re-render so the canvas imgs pick up the data URLs. var players = Object.keys(assignments).map(function(sid) { return getPlayer(assignments[sid]); }); var mgrJob = (manager && manager.photo && !isDataUrl(manager.photo)) ? proxyImage(manager.photo).then(function(du) { if (isDataUrl(du)) manager.photo = du; }) : Promise.resolve(); // ⚠️ Wait for the HOME TEAM logo data URL BEFORE rendering. On mobile, tapping export // before the AVIF→PNG conversion finishes produced an image with the logo dropped (the // "save to gallery" path had no branding while a later download did). Awaiting it here // makes both paths embed the logo identically. var logoJob = fetchLogoDataUrl().then(function(url) { if (isDataUrl(url)) setLogoSrc(url); }); return Promise.all([inlineAllExportImages(players), mgrJob, logoJob]).then(function() { setInlineTick(function(t) { return t + 1; }); return afterPaintOrTimeout(450); }).then(function() { var node = document.getElementById("export-canvas"); if (!node) throw new Error("Canvas not found"); // wait for every portrait/flag/logo to decode, then rasterize (twice on mobile to // defeat iOS Safari's first-pass blank-image quirk). return waitForImages(node).then(function() { return reliableToBlob(node, { pixelRatio: 1, cacheBust: true, backgroundColor: "#1A0428" }, isMobile); }); }); }; var handleDownload = function() { setBusy(true); generateBlob().then(function(blob) { if (!blob) throw new Error("Failed"); var url = URL.createObjectURL(blob); var a = document.createElement("a"); a.href = url; a.download = teamName.toLowerCase().replace(/[^a-z0-9]+/g, "-") + "-" + formation.name + ".png"; document.body.appendChild(a); a.click(); document.body.removeChild(a); setTimeout(function() { URL.revokeObjectURL(url); }, 1000); showToastMsg("\u2713 DOWNLOADED"); if (onExported) onExported(); }).catch(function() { showToastMsg("\u26A0 EXPORT FAILED"); }).finally(function() { setBusy(false); }); }; var handleShare = function() { setBusy(true); generateBlob().then(function(blob) { if (!blob) throw new Error("Failed"); var file = new File([blob], teamName + ".png", { type: "image/png" }); if (navigator.canShare && navigator.canShare({ files: [file] })) { return navigator.share({ files: [file], title: teamName }).then(function() { showToastMsg("\u2713 IMAGE SAVED"); if (onExported) onExported(); }); } else if (navigator.clipboard && window.ClipboardItem) { return navigator.clipboard.write([new ClipboardItem({ "image/png": blob })]).then(function() { showToastMsg("\u2713 COPIED TO CLIPBOARD"); if (onExported) onExported(); }); } else { showToastMsg("SHARING NOT SUPPORTED"); if (onExported) onExported(); } }).catch(function(e) { if (e && e.name !== "AbortError") showToastMsg("\u26A0 SHARE FAILED"); }).finally(function() { setBusy(false); }); }; var modalS = isMobile ? { width: "min(1100px, 100%)", maxHeight: "95vh", background: "linear-gradient(180deg, #2F0745 0%, #1A0428 100%)", border: "1px solid rgba(205,150,2,0.45)", borderRadius: "16px", boxShadow: "0 30px 80px rgba(0,0,0,0.7)", display: "flex", flexDirection: "column", overflow: "hidden" } : { width: "min(1100px, 100%)", maxHeight: "92vh", background: "linear-gradient(180deg, #2F0745 0%, #1A0428 100%)", border: "1px solid rgba(205,150,2,0.45)", borderRadius: "16px", boxShadow: "0 30px 80px rgba(0,0,0,0.7)", display: "grid", gridTemplateRows: "auto 1fr auto", overflow: "hidden" }; var btnS = { fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: "14px", letterSpacing: "2px", padding: "10px 22px", borderRadius: "8px", cursor: "pointer", border: "1px solid transparent", display: "inline-flex", alignItems: "center", justifyContent: "center", gap: "8px" }; var shareStyle = isMobile ? Object.assign({}, btnS, { background: "linear-gradient(155deg, #E8B83A, #CD9602)", color: "#1A0428", boxShadow: "0 6px 18px rgba(205,150,2,0.35)", opacity: busy ? 0.5 : 1, flex: 1 }) : Object.assign({}, btnS, { background: "transparent", color: "#F5EDD8", border: "1px solid rgba(245,237,216,0.3)", opacity: busy ? 0.5 : 1 }); var dlStyle = isMobile ? Object.assign({}, btnS, { background: "transparent", color: "#F5EDD8", border: "1px solid rgba(245,237,216,0.3)", opacity: busy ? 0.5 : 1, flex: 1 }) : Object.assign({}, btnS, { background: "linear-gradient(155deg, #E8B83A, #CD9602)", color: "#1A0428", boxShadow: "0 6px 18px rgba(205,150,2,0.35)", opacity: busy ? 0.5 : 1 }); return h("div", { style: { position: "fixed", inset: 0, background: "rgba(8,1,15,0.82)", backdropFilter: "blur(8px)", display: "flex", alignItems: "center", justifyContent: "center", zIndex: 200, padding: isMobile ? "12px" : "24px" }, onClick: onClose }, h("div", { style: modalS, onClick: function(e) { e.stopPropagation(); } }, h("div", { style: { padding: "18px 24px", borderBottom: "1px solid rgba(205,150,2,0.2)", display: "flex", alignItems: "center", justifyContent: "space-between" } }, h("div", null, h("div", { style: { fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: "24px", letterSpacing: "2.5px", color: "#F5EDD8" } }, "SAVE LINEUP"), h("div", { style: { fontSize: "11px", color: "rgba(245,237,216,0.55)", letterSpacing: "1.5px", marginTop: "2px" } }, "1080 \u00D7 1350 \u00B7 STORY-READY") ), h("button", { style: { width: "32px", height: "32px", borderRadius: "50%", background: "rgba(255,255,255,0.06)", border: "1px solid rgba(255,255,255,0.1)", color: "#F5EDD8", cursor: "pointer", fontSize: "18px", lineHeight: 1 }, onClick: onClose }, "\u00D7") ), h("div", { style: isMobile ? { display: "flex", flexDirection: "column", gap: "14px", padding: "14px 16px", overflowY: "auto" } : { display: "grid", gridTemplateColumns: "1fr 280px", gap: "24px", padding: "20px 24px", overflow: "hidden" } }, h("div", { id: "ex-preview-wrap", style: isMobile ? { background: "rgba(0,0,0,0.4)", borderRadius: "12px", padding: "10px", display: "flex", alignItems: "center", justifyContent: "center", overflow: "hidden", height: "44vh", maxHeight: "380px", flexShrink: 0 } : { background: "rgba(0,0,0,0.4)", borderRadius: "12px", padding: "20px", display: "flex", alignItems: "center", justifyContent: "center", overflow: "hidden", minHeight: 0 } }, h("div", { style: { transformOrigin: "center center", transform: "scale(" + scale + ")" } }, h(ExportCanvas, { bgId: bgId, formation: formation, assignments: assignments, teamName: teamName, teamRating: teamRating, showBranding: showBranding, logoSrc: logoSrc, manager: manager, captain: captain }) ) ), (function() { var detailsNode = h("div", { key: "details" }, h("div", { style: { fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: "12px", letterSpacing: "2.5px", color: "#CD9602", marginBottom: "10px" } }, "DETAILS"), h("div", { style: { display: "flex", flexDirection: "column", gap: "6px", marginBottom: "10px" } }, h("label", { style: { fontSize: "10px", letterSpacing: "1.5px", color: "rgba(245,237,216,0.55)", textTransform: "uppercase" } }, "Team name"), h("input", { style: { background: "rgba(0,0,0,0.3)", border: "1px solid rgba(255,255,255,0.1)", borderRadius: "8px", padding: "9px 12px", color: "#F5EDD8", fontSize: "13px", outline: "none", fontFamily: "inherit" }, value: teamName, maxLength: 28, onChange: function(e) { setTeamName(e.target.value.toUpperCase()); } }) ), h("div", { style: { display: "flex", alignItems: "center", justifyContent: "space-between", fontSize: "12px", color: "rgba(245,237,216,0.8)", marginBottom: "8px" } }, h("span", null, "Show branding"), h("div", { style: { width: "36px", height: "20px", borderRadius: "999px", background: showBranding ? "#CD9602" : "rgba(255,255,255,0.15)", position: "relative", cursor: "pointer" }, onClick: function() { setShowBranding(!showBranding); } }, h("div", { style: { position: "absolute", top: "2px", left: showBranding ? "18px" : "2px", width: "16px", height: "16px", borderRadius: "50%", background: "#fff", transition: "left 180ms ease", boxShadow: "0 1px 3px rgba(0,0,0,0.3)" } }) ) ) ); var bgNode = h("div", { key: "bg" }, h("div", { style: { fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: "12px", letterSpacing: "2.5px", color: "#CD9602", marginBottom: "10px" } }, "BACKGROUND"), h("div", { style: { display: "grid", gridTemplateColumns: "1fr 1fr", gap: "8px" } }, BACKGROUNDS.map(function(b) { var active = bgId === b.id; return h("div", { key: b.id, style: { height: "62px", borderRadius: "10px", cursor: "pointer", background: b.swatch, border: active ? "2px solid #E8B83A" : "2px solid rgba(255,255,255,0.08)", boxShadow: active ? "0 0 0 3px rgba(232,184,58,0.25)" : "none", position: "relative", overflow: "hidden" }, onClick: function() { setBgId(b.id); } }, h("div", { style: { position: "absolute", bottom: "4px", left: "6px", fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: "10px", letterSpacing: "1px", color: "rgba(255,255,255,0.85)", textShadow: "0 1px 2px rgba(0,0,0,0.7)" } }, b.name) ); }) ) ); return h("div", { style: { display: "flex", flexDirection: "column", gap: "18px", overflowY: isMobile ? "visible" : "auto" } }, isMobile ? [detailsNode, bgNode] : [bgNode, detailsNode]); })() ), h("div", { style: { padding: isMobile ? "12px 16px" : "16px 24px", borderTop: "1px solid rgba(205,150,2,0.2)", display: "flex", gap: "10px", justifyContent: isMobile ? "flex-end" : "space-between", alignItems: "center", flexShrink: 0 } }, !isMobile && h("div", { style: { fontSize: "11px", color: "rgba(245,237,216,0.4)" } }, "Tip: Empty slots show as dashed circles."), h("div", { style: { display: "flex", flexDirection: isMobile ? "row-reverse" : "row", gap: "10px", width: isMobile ? "100%" : "auto" } }, h("button", { style: shareStyle, onClick: handleShare, disabled: busy }, isMobile ? "SAVE IMAGE" : "SHARE"), h("button", { style: dlStyle, onClick: handleDownload, disabled: busy }, busy ? "RENDERING\u2026" : (isMobile ? "DOWNLOAD" : "DOWNLOAD PNG")) ) ) ), toast && h("div", { style: { position: "fixed", bottom: "32px", left: "50%", transform: "translateX(-50%)", background: "#1A0428", border: "1px solid #CD9602", color: "#F5EDD8", padding: "12px 20px", borderRadius: "10px", fontSize: "13px", letterSpacing: "1px", zIndex: 300, boxShadow: "0 10px 30px rgba(0,0,0,0.5)" } }, toast) ); } // ─── Lineup: Search Modal ─────────────────────────────────────────────────── // Real-player finder (SportMonks via sportmonks-proxy). Two modes: BROWSE league→team→squad, or SEARCH by name. // Lists show lightweight candidates; the full player stats fetch happens on pick (cached by the proxy). function apiEnvErr(d) { if (!d) return "No response."; if (d.data !== undefined) return ""; // a data payload (even []) means success var raw = d.error || (typeof d.message === "string" ? d.message : ""); if (!raw) return ""; // Translate API account/auth/plan errors into a friendly message (don't leak provider details). if (/suspend|access|token|apikey|api key|authenticat|unauthoriz|denied|subscri|plan|rate ?limit|too many|entity/i.test(raw)) { return "Live player data is temporarily unavailable. Please try again later."; } return raw; } function ApiPlayerPanel(props) { var onPick = props.onPick; var mode = props.mode || "search"; // controlled by SearchModal's segmented tabs var _q = useState(""), q = _q[0], setQ = _q[1]; var _loading = useState(false), loading = _loading[0], setLoading = _loading[1]; var _err = useState(""), err = _err[0], setErr = _err[1]; var _cands = useState([]), cands = _cands[0], setCands = _cands[1]; var _lgs = useState(null), leagues = _lgs[0], setLeagues = _lgs[1]; var _league = useState(null), league = _league[0], setLeague = _league[1]; // { id, name, seasonId } var _teams = useState([]), teams = _teams[0], setTeams = _teams[1]; var _teamId = useState(null), teamId = _teamId[0], setTeamId = _teamId[1]; var _picking = useState(null), picking = _picking[0], setPicking = _picking[1]; var timer = useRef(null); // Browse mode: load the subscription's leagues once (cached module-wide). useEffect(function() { if (mode !== "browse" || leagues) return; setLoading(true); setErr(""); fetchLeagues().then(function(list) { setLoading(false); setLeagues(list); if (!list.length) setErr("No leagues available on this plan."); }); }, [mode]); // Debounced name search → players/search/{q} (lightweight; nationality + position only). useEffect(function() { if (mode !== "search") return; if (q.trim().length < 3) { setCands([]); setErr(q.trim() ? "Type at least 3 letters." : ""); return; } if (timer.current) clearTimeout(timer.current); timer.current = setTimeout(function() { setLoading(true); setErr(""); var term = q.trim().toLowerCase(); // statistics.details + the type filter pulls ONLY minutes-played (type 119) per // season block (~tens of bytes/player) — the popularity signal for ranking. // Stats exist only for leagues in the plan, so big-league players carry minutes // and obscure ones come back empty: exactly the fame bias we want. footballProxy("players/search/" + encodeURIComponent(q.trim()), { include: "nationality;detailedPosition;statistics.details", filters: "playerStatisticDetailTypes:119", per_page: 50 }).then(function(d) { setLoading(false); var e = apiEnvErr(d); if (e) { setErr(e); setCands([]); return; } var list = (d.data || []).map(function(pl) { var dp = pl.detailedposition || pl.detailedPosition || null; var nat = pl.nationality || null; // Popularity = best single-season minutes + a taste of career volume. var mins = (pl.statistics || []).map(function(s) { var det = ((s.details || []).filter(function(x) { return x.type_id === 119; })[0] || {}).value; return (det && (det.total != null ? det.total : det)) || 0; }); var maxMin = mins.reduce(function(a, b) { return Math.max(a, b); }, 0); var totMin = mins.reduce(function(a, b) { return a + b; }, 0); return { id: pl.id, name: smkBestName(pl) || pl.name, position: (dp && dp.name) || null, nationality: nat && nat.name, photo: pl.image_path, _pop: maxMin + 0.25 * Math.max(0, totMin - maxMin), _realPhoto: !!(pl.image_path && pl.image_path.indexOf("placeholder") === -1), _search: ((pl.common_name || "") + " " + (pl.display_name || "") + " " + (pl.firstname || "")).toLowerCase(), _raw: pl }; }); // Rank by fame, three signals: // 1. text tier — exact WORD match (−180: "messi" IS the word in "L. Messi" but // only a prefix of "Messias") > leading match (−100) > word-start (−40) // 2. minutes-played popularity (up to −325) — only exists for in-plan leagues, // so it must NOT outweigh an exact-word icon: Junior Messias (843 in-plan // min) was beating Lionel Messi (MLS → zero in-plan minutes). // 3. real (non-placeholder) photo (−45) — SportMonks gives real headshots to // notables in ANY league; covers icons the plan's stats can't see. var score = function(p, idx) { var n = (p.name || "").toLowerCase(); var words = p._search.split(/\s+/); var s = idx * 4; // API result order is itself a fame prior (CR7 comes back first for "ronaldo") if (words.some(function(w) { return w === term; })) s -= 180; // exact word = the player people mean else if (n.indexOf(term) === 0 || p._search.indexOf(term) === 0) s -= 100; // a name starts with the query else if (words.some(function(w) { return w.indexOf(term) === 0; })) s -= 40; // any word starts with it if (p._pop > 0) s -= 25 + Math.min(300, p._pop / 10); // stats-backed popularity if (p._realPhoto) s -= 45; // curated headshot ≈ notable // keep these tiny — they once carried the ranking but backfire on // single-name Brazilians ("Naldo" outranked "C. Ronaldo" on token count) s += (n.split(/\s+/).length) * 4; s += Math.min(n.length, 40) * 0.15; return s; }; list.forEach(function(p, i) { p._s = score(p, i); }); list.sort(function(a, b) { return a._s - b._s; }); setCands(list); if (!list.length) setErr("No players found."); }); }, 350); return function() { if (timer.current) clearTimeout(timer.current); }; }, [q, mode]); var loadTeams = function(lg) { setLeague(lg); setTeamId(null); setTeams([]); setCands([]); setErr(""); setLoading(true); // A just-rolled-over "current" season often has no teams yet — walk the // league's recent seasons (newest first) until one returns data, and remember // which one we used so the pick flow pulls that season's stats. var seasons = lg.seasons || []; var tryIdx = function(i) { if (i >= seasons.length) { setLoading(false); setErr("No teams found for " + lg.name + "."); return; } footballProxy("teams/seasons/" + seasons[i].id, { per_page: 50 }).then(function(d) { var e = apiEnvErr(d); if (e) { setLoading(false); setErr(e); return; } var ts = (d.data || []).map(function(t) { return { id: t.id, name: t.name, logo: t.image_path }; }); if (!ts.length) { tryIdx(i + 1); return; } setLoading(false); setLeague(Object.assign({}, lg, { activeSeason: seasons[i] })); setTeams(ts); }); }; tryIdx(0); }; var loadSquad = function(tm) { setTeamId(tm); setCands([]); setErr(""); setLoading(true); footballProxy("squads/teams/" + tm, { include: "player.nationality;player.detailedPosition", per_page: 50 }).then(function(d) { setLoading(false); var e = apiEnvErr(d); if (e) { setErr(e); return; } var sq = (d.data || []).filter(function(en) { return en && en.player; }); setCands(sq.map(function(en) { var pl = en.player; var dp = pl.detailedposition || pl.detailedPosition || null; var nat = pl.nationality || null; return { id: pl.id, name: smkBestName(pl) || pl.name, position: (dp && dp.name) || null, nationality: nat && nat.name, photo: pl.image_path, _raw: pl }; })); if (!sq.length) setErr("No squad data."); }); }; var pick = function(c) { if (picking) return; setPicking(c.id); setErr(""); // When browsing we know the season — filter the stats payload down to it // (a veteran's full history is ~10× larger). Search keeps the full history // so the newest meaty block can be chosen client-side. var pickParams = { include: "nationality;detailedPosition;statistics.details.type;teams.team" }; if (mode === "browse" && league && league.activeSeason) pickParams.filters = "playerStatisticSeasons:" + league.activeSeason.id; footballProxy("players/" + c.id, pickParams).then(function(d) { var pl = d.data || c._raw || { id: c.id, common_name: c.name }; // When browsing a team, that team IS the player's current club — pin it (+ the browsed season). var clubName = (mode === "browse" && teamId) ? ((teams.filter(function(t) { return t.id === teamId; })[0] || {}).name || null) : null; var mapped = apiPlayerToPlayer(pl, { teamId: teamId, clubName: clubName, seasonId: (mode === "browse" && league && league.activeSeason) ? league.activeSeason.id : null }); // Inline photo + flag via the proxy (export-safe). Photos are used exactly // as the API provides them — no background removal. Promise.all([proxyImage(mapped.portrait), proxyImage(mapped.nationFlag)]).then(function(out) { if (out[0]) mapped.portrait = out[0]; if (out[1]) mapped.nationFlag = out[1]; registerApiPlayer(mapped); setPicking(null); onPick(mapped); }); }); }; var chip = function(active, label, onClick, key) { return h("button", { key: key, onClick: onClick, style: { padding: "6px 12px", borderRadius: "999px", fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: "12px", letterSpacing: "1px", cursor: "pointer", border: active ? "1px solid #E8B83A" : "1px solid rgba(255,255,255,0.12)", background: active ? "linear-gradient(155deg, #E8B83A, #CD9602)" : "rgba(255,255,255,0.05)", color: active ? "#1A0428" : "rgba(245,237,216,0.85)", whiteSpace: "nowrap" } }, label); }; var inputS = { width: "100%", background: "rgba(0,0,0,0.3)", border: "1px solid rgba(205,150,2,0.3)", borderRadius: "10px", padding: "12px 16px", color: "#F5EDD8", fontSize: "16px", outline: "none", fontFamily: "inherit", boxSizing: "border-box" }; var candRow = function(c) { var isPicking = picking === c.id; return h("div", { key: c.id, onClick: function() { pick(c); }, style: { display: "grid", gridTemplateColumns: "34px 1fr auto", gap: "12px", alignItems: "center", padding: "9px 12px", borderRadius: "10px", background: "rgba(255,255,255,0.03)", border: "1px solid rgba(255,255,255,0.05)", marginBottom: "6px", cursor: picking ? "default" : "pointer", opacity: (picking && !isPicking) ? 0.5 : 1 }, onMouseEnter: function(e) { if (!picking) e.currentTarget.style.background = "rgba(205,150,2,0.12)"; }, onMouseLeave: function(e) { e.currentTarget.style.background = "rgba(255,255,255,0.03)"; } }, c.photo ? h("img", { src: c.photo, alt: "", crossOrigin: "anonymous", style: { width: "34px", height: "34px", borderRadius: "50%", objectFit: "cover", background: "rgba(255,255,255,0.06)" } }) : h("div", { style: { width: "34px", height: "34px", borderRadius: "50%", background: "rgba(255,255,255,0.06)" } }), h("div", { style: { minWidth: 0 } }, h("div", { style: { fontSize: "14px", fontWeight: 600, color: "#F5EDD8", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" } }, c.name), h("div", { style: { fontSize: "11px", color: "rgba(245,237,216,0.5)", letterSpacing: "0.5px" } }, [c.position, c.nationality].filter(Boolean).join(" · ") || "") ), isPicking ? h("div", { style: { display: "flex", alignItems: "center", justifyContent: "center", width: "20px" } }, footballSpinner(18)) : h("div", { style: { fontSize: "18px", color: "rgba(205,150,2,0.7)", lineHeight: 1 } }, "+") ); }; var backBtn = function(label, onClick) { return h("button", { onClick: onClick, style: { background: "rgba(255,255,255,0.05)", border: "1px solid rgba(255,255,255,0.12)", color: "#CD9602", cursor: "pointer", fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: "12px", letterSpacing: "1px", padding: "5px 12px", borderRadius: "999px" } }, label); }; var teamRow = function(t) { return h("div", { key: "t" + t.id, onClick: function() { loadSquad(t.id); }, style: { display: "grid", gridTemplateColumns: "28px 1fr auto", gap: "12px", alignItems: "center", padding: "9px 12px", borderRadius: "10px", background: "rgba(255,255,255,0.03)", border: "1px solid rgba(255,255,255,0.05)", marginBottom: "6px", cursor: "pointer" }, onMouseEnter: function(e) { e.currentTarget.style.background = "rgba(205,150,2,0.12)"; }, onMouseLeave: function(e) { e.currentTarget.style.background = "rgba(255,255,255,0.03)"; } }, t.logo ? h("img", { src: t.logo, alt: "", crossOrigin: "anonymous", style: { width: "26px", height: "26px", objectFit: "contain" } }) : h("div", { style: { width: "26px", height: "26px" } }), h("div", { style: { fontSize: "14px", fontWeight: 600, color: "#F5EDD8", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" } }, t.name), h("div", { style: { color: "rgba(205,150,2,0.7)", fontSize: "18px", lineHeight: 1 } }, "›") ); }; var lgName = league ? league.name : ""; var tmName = (teams.filter(function(t) { return t.id === teamId; })[0] || {}).name; var crumbS = { padding: "2px 22px 8px", display: "flex", alignItems: "center", gap: "8px", flexWrap: "wrap", fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: "13px", letterSpacing: "1px", color: "#F5EDD8" }; // header (step controls), kept above the scrolling list — mode comes from the parent tabs var header = h("div", null, mode === "search" ? h("div", { style: { padding: "2px 22px 6px" } }, h("input", { autoFocus: true, style: inputS, placeholder: "Search any player by name…", value: q, onChange: function(e) { setQ(e.target.value); } }), h("div", { style: { fontSize: "10px", color: "rgba(245,237,216,0.4)", marginTop: "6px", letterSpacing: "0.5px" } }, "Type at least 3 letters") ) // BROWSE: leagues → teams → squad, with a breadcrumb : !league ? h("div", { style: { padding: "2px 22px 8px" } }, h("div", { style: { fontSize: "10px", letterSpacing: "1.5px", color: "rgba(245,237,216,0.45)", marginBottom: "8px" } }, "CHOOSE A LEAGUE"), h("div", { style: { display: "flex", gap: "6px", flexWrap: "wrap", maxHeight: "150px", overflowY: "auto" } }, (leagues || []).map(function(lg) { return h("button", { key: "lg" + lg.id, onClick: function() { loadTeams(lg); }, style: { display: "inline-flex", alignItems: "center", gap: "7px", padding: "6px 12px", borderRadius: "999px", fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: "12px", letterSpacing: "1px", cursor: "pointer", border: "1px solid rgba(255,255,255,0.12)", background: "rgba(255,255,255,0.05)", color: "rgba(245,237,216,0.85)", whiteSpace: "nowrap" } }, lg.logo ? h("img", { src: lg.logo, alt: "", crossOrigin: "anonymous", style: { width: "16px", height: "16px", objectFit: "contain", display: "block" } }) : null, lg.name ); })) ) : h("div", { style: crumbS }, backBtn(teamId ? "‹ TEAMS" : "‹ LEAGUES", function() { if (teamId) { setTeamId(null); setCands([]); setErr(""); } else { setLeague(null); setTeams([]); setErr(""); } }), h("span", { style: { color: "rgba(245,237,216,0.5)" } }, lgName + (tmName ? " · " : "")), tmName ? h("span", { style: { color: "#CD9602" } }, tmName) : null, (league && league.activeSeason && league.activeSeason.name) ? h("span", { style: { color: "rgba(245,237,216,0.35)", fontSize: "11px" } }, league.activeSeason.name) : null ) ); // body list: teams (browse, league chosen, no team) OR candidates (search / squad) var showTeams = mode === "browse" && league && !teamId; var bodyItems = showTeams ? teams.map(teamRow) : cands.map(candRow); var emptyHint = mode === "search" && !q.trim() ? "Search for any footballer to add them to your XI." : null; return h("div", { style: { display: "flex", flexDirection: "column", minHeight: 0, flex: 1 } }, header, h("div", { style: { overflowY: "auto", padding: "6px 22px 22px", flex: 1, minHeight: 0 } }, loading ? h("div", { style: { display: "flex", flexDirection: "column", alignItems: "center", gap: "10px", padding: "34px 0" } }, footballSpinner(36), h("div", { style: { color: "#CD9602", fontFamily: "'Anton', 'Bebas Neue', sans-serif", letterSpacing: "2px", fontSize: "12px" } }, "LOADING")) : null, err ? h("div", { style: { textAlign: "center", padding: "16px 0", color: "#ff8a96", fontSize: "12px", lineHeight: 1.5 } }, err) : null, !loading && !err && !bodyItems.length && emptyHint ? h("div", { style: { textAlign: "center", padding: "40px 16px", color: "rgba(245,237,216,0.4)", fontSize: "13px", lineHeight: 1.6 } }, emptyHint) : null, !loading ? bodyItems : null ) ); } function SearchModal(props) { var slot = props.slot, onClose = props.onClose, onPick = props.onPick, assignedIds = props.assignedIds, onCreateCustom = props.onCreateCustom; var pool = props.players || PLAYERS; // demo players + the user's custom players // One segmented control: real-player SEARCH (default) · BROWSE · the local MY POOL. var _tab = useState("search"), tab = _tab[0], setTab = _tab[1]; // Pre-warm the league list the moment the modal opens so BROWSE feels instant. useEffect(function() { fetchLeagues(); }, []); var pickApi = function(p) { registerApiPlayer(p); onPick(p); }; var seg = function(id, label) { var active = tab === id; return h("button", { onClick: function() { setTab(id); }, style: { flex: 1, padding: "9px 0", borderRadius: "999px", fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: "13px", letterSpacing: "1.5px", cursor: "pointer", border: "none", background: active ? "linear-gradient(155deg, #E8B83A, #CD9602)" : "transparent", color: active ? "#1A0428" : "rgba(245,237,216,0.7)", boxShadow: active ? "0 2px 8px rgba(205,150,2,0.4)" : "none", transition: "background 120ms" } }, label); }; var _q = useState(""), q = _q[0], setQ = _q[1]; var _pf = useState("all"), posFilter = _pf[0], setPosFilter = _pf[1]; var groupMap = { ATT: ["ST","CF","LW","RW"], MID: ["CM","CAM","CDM","LM","RM"], DEF: ["CB","LB","RB","LWB","RWB"], GK: ["GK"] }; var recommended = useMemo(function() { if (!slot) return []; return pool.filter(function(p) { return p.pos === slot.pos || (p.alt && p.alt.indexOf(slot.pos) >= 0); }); }, [slot, pool]); var results = useMemo(function() { var list = pool; if (posFilter === "recommended") list = recommended; else if (groupMap[posFilter]) list = list.filter(function(p) { return groupMap[posFilter].indexOf(p.pos) >= 0; }); if (q.trim()) { var term = q.toLowerCase(); list = list.filter(function(p) { return p.name.toLowerCase().indexOf(term) >= 0 || p.first.toLowerCase().indexOf(term) >= 0 || p.club.toLowerCase().indexOf(term) >= 0 || p.nation.toLowerCase().indexOf(term) >= 0; }); } return list; }, [q, posFilter, recommended]); var filters = ["all", "recommended", "ATT", "MID", "DEF", "GK"]; var modalS = { width: "min(620px, 100%)", height: "min(86vh, 660px)", background: "linear-gradient(180deg, #2F0745 0%, #1A0428 100%)", border: "1px solid rgba(205,150,2,0.45)", borderRadius: "16px", boxShadow: "0 30px 80px rgba(0,0,0,0.7)", overflow: "hidden", display: "flex", flexDirection: "column" }; return h("div", { style: { position: "fixed", inset: 0, background: "rgba(8,1,15,0.7)", backdropFilter: "blur(6px)", display: "flex", alignItems: "center", justifyContent: "center", zIndex: 100, padding: "24px" }, onClick: onClose }, h("div", { style: modalS, onClick: function(e) { e.stopPropagation(); } }, h("div", { style: { padding: "18px 22px", borderBottom: "1px solid rgba(205,150,2,0.25)", display: "flex", alignItems: "center", justifyContent: "space-between" } }, h("div", null, h("div", { style: { fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: "22px", letterSpacing: "2px", color: "#F5EDD8" } }, "SELECT PLAYER"), h("div", { style: { fontSize: "11px", letterSpacing: "1.5px", color: "rgba(245,237,216,0.55)", marginTop: "2px" } }, "SLOT \u00B7 ", h("span", { style: { color: "#CD9602" } }, slot && slot.pos)) ), h("button", { style: { width: "30px", height: "30px", borderRadius: "50%", background: "rgba(255,255,255,0.06)", border: "1px solid rgba(255,255,255,0.1)", color: "#F5EDD8", cursor: "pointer", fontSize: "16px", lineHeight: 1 }, onClick: onClose }, "\u00D7") ), h("div", { style: { margin: "12px 22px 4px", display: "flex", gap: "8px", alignItems: "center" } }, h("div", { style: { flex: 1, display: "flex", gap: "4px", background: "rgba(0,0,0,0.28)", border: "1px solid rgba(255,255,255,0.08)", borderRadius: "999px", padding: "4px" } }, seg("search", "SEARCH"), seg("browse", "BROWSE"), seg("pool", "MY POOL")), onCreateCustom && h("button", { onClick: onCreateCustom, title: "Create a custom player", style: { width: "46px", height: "46px", flexShrink: 0, borderRadius: "50%", border: "1px solid rgba(205,150,2,0.55)", background: "rgba(205,150,2,0.12)", color: "#E8B83A", cursor: "pointer", display: "flex", alignItems: "center", justifyContent: "center", padding: 0 } }, h("svg", { width: 18, height: 18, viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 2.5, strokeLinecap: "round" }, h("path", { d: "M12 5v14M5 12h14" }))) ), tab !== "pool" ? h(ApiPlayerPanel, { slot: slot, onPick: pickApi, mode: tab }) : h("div", { key: "pool", style: { display: "flex", flexDirection: "column", minHeight: 0, flex: 1 } }, h("div", { style: { padding: "12px 22px 8px" } }, h("input", { style: { width: "100%", background: "rgba(0,0,0,0.3)", border: "1px solid rgba(205,150,2,0.3)", borderRadius: "10px", padding: "12px 16px", color: "#F5EDD8", fontSize: "16px", outline: "none", fontFamily: "inherit", boxSizing: "border-box" }, placeholder: "Search by name, club or nation\u2026", value: q, onChange: function(e) { setQ(e.target.value); } }), h("div", { style: { display: "flex", gap: "8px", flexWrap: "wrap", marginTop: "12px" } }, filters.map(function(f) { var active = posFilter === f; return h("div", { key: f, style: { padding: "4px 10px", borderRadius: "999px", fontSize: "11px", letterSpacing: "1px", fontFamily: "'Anton', 'Bebas Neue', sans-serif", cursor: "pointer", background: active ? "#CD9602" : "rgba(255,255,255,0.06)", color: active ? "#1A0428" : "#F5EDD8", border: active ? "1px solid #E8B83A" : "1px solid rgba(255,255,255,0.1)" }, onClick: function() { setPosFilter(f); } }, f.toUpperCase(), f === "recommended" ? " (" + recommended.length + ")" : "" ); }) ) ), h("div", { style: { overflowY: "auto", padding: "10px 22px 22px", flex: 1 } }, results.length === 0 && h("div", { style: { textAlign: "center", padding: "40px 0", color: "rgba(245,237,216,0.4)", fontSize: "13px" } }, "No players match."), results.map(function(p) { var isAssigned = assignedIds.indexOf(p.id) >= 0; return h("div", { key: p.id, style: { display: "grid", gridTemplateColumns: "44px 1fr auto auto", gap: "12px", alignItems: "center", padding: "10px 12px", borderRadius: "10px", background: "rgba(255,255,255,0.03)", border: "1px solid rgba(255,255,255,0.05)", marginBottom: "6px", cursor: isAssigned ? "not-allowed" : "pointer", opacity: isAssigned ? 0.45 : 1 }, onMouseEnter: function(e) { if (!isAssigned) e.currentTarget.style.background = "rgba(205,150,2,0.12)"; }, onMouseLeave: function(e) { e.currentTarget.style.background = "rgba(255,255,255,0.03)"; }, onClick: function() { if (!isAssigned) onPick(p); } }, h("div", { style: { fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: "28px", color: "#CD9602", textAlign: "center", lineHeight: 1 } }, p.rating), h("div", null, h("div", { style: { fontSize: "14px", fontWeight: 600, color: "#F5EDD8" } }, displayFull(p)), h("div", { style: { fontSize: "11px", color: "rgba(245,237,216,0.55)", letterSpacing: "1px" } }, p.nation + " \u00B7 " + p.club) ), h("div", { style: { fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: "14px", color: "#E8B83A", padding: "4px 8px", border: "1px solid rgba(205,150,2,0.4)", borderRadius: "6px", letterSpacing: "1px" } }, p.pos), h("div", { style: { width: 60, textAlign: "right", fontSize: 10, letterSpacing: 1, color: "rgba(245,237,216,0.4)" } }, isAssigned ? "IN LINEUP" : recommended.indexOf(p) >= 0 ? "FITS \u2713" : "") ); }) ) ) ) ); } // ─── Lineup: Stats Modal ──────────────────────────────────────────────────── function StatsModal(props) { var isMobile = useWindowSize().w < 768; var slot = props.slot, player = props.player, onClose = props.onClose, onReplace = props.onReplace, onRemove = props.onRemove, captain = props.captain, onToggleCaptain = props.onToggleCaptain; // Defensive: an unresolved player (e.g. a session-only API player not yet re-hydrated) would crash // the stat read below (player.gk / player.pace). Callers route empty-looking slots to the picker, but // never assume a player is present here. (Hooks above this line are unconditional — legal early return.) if (!player) return null; var chem = props.chem || 0; var chemStars = chem >= 8 ? 3 : chem >= 5 ? 2 : chem >= 1 ? 1 : 0; var chemColor = chemStars === 3 ? "#3fb96b" : chemStars === 2 ? "#E8B83A" : chemStars === 1 ? "#CD9602" : "rgba(245,237,216,0.3)"; var stats = player.gk ? [["DIVING", player.gk.div], ["HANDLING", player.gk.han], ["KICKING", player.gk.kic], ["REFLEXES", player.gk.ref], ["SPEED", player.gk.spd], ["POSITIONING", player.gk.pos]] : [["PACE", player.pace], ["SHOOTING", player.sho], ["PASSING", player.pas], ["DRIBBLING", player.dri], ["DEFENDING", player.def], ["PHYSICAL", player.phy]]; var altStr = player.alt && player.alt.length ? player.alt.join(" \u00B7 ") : "\u2014"; var btnS = { fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: "16px", letterSpacing: "2px", padding: "10px 22px", borderRadius: "8px", cursor: "pointer", border: "1px solid transparent" }; return h("div", { style: { position: "fixed", inset: 0, background: "rgba(8,1,15,0.7)", backdropFilter: "blur(6px)", display: "flex", alignItems: "center", justifyContent: "center", zIndex: 100, padding: isMobile ? "12px" : "24px" }, onClick: onClose }, h("div", { style: { width: "min(720px, 100%)", maxHeight: "92vh", background: "linear-gradient(180deg, #2F0745 0%, #1A0428 100%)", border: "1px solid rgba(205,150,2,0.45)", borderRadius: "16px", overflowY: "auto", boxShadow: "0 30px 80px rgba(0,0,0,0.7)" }, onClick: function(e) { e.stopPropagation(); } }, h("div", { style: { padding: "18px 22px", borderBottom: "1px solid rgba(205,150,2,0.25)", display: "flex", alignItems: "center", justifyContent: "space-between", position: "sticky", top: 0, background: "linear-gradient(180deg, #2F0745 0%, #2F0745 100%)", zIndex: 1 } }, h("div", null, h("div", { style: { fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: "22px", letterSpacing: "2px", color: "#F5EDD8" } }, "PLAYER DETAILS"), h("div", { style: { fontSize: "11px", letterSpacing: "1.5px", color: "rgba(245,237,216,0.55)", marginTop: "2px" } }, "SLOT \u00B7 ", h("span", { style: { color: "#CD9602" } }, slot && slot.pos)) ), h("button", { style: { width: "30px", height: "30px", borderRadius: "50%", background: "rgba(255,255,255,0.06)", border: "1px solid rgba(255,255,255,0.1)", color: "#F5EDD8", cursor: "pointer", fontSize: "16px", lineHeight: 1 }, onClick: onClose }, "\u00D7") ), h("div", { style: isMobile ? { display: "flex", flexDirection: "column", gap: "16px", padding: "20px" } : { display: "grid", gridTemplateColumns: "260px 1fr", gap: "28px", padding: "28px" } }, isMobile ? null : h("div", { style: { display: "flex", justifyContent: "center" } }, h(PlayerCard, { player: player, rarity: "gold" })), h("div", null, h("div", { style: { display: "flex", alignItems: "center", gap: "14px", marginBottom: "16px" } }, h("div", { style: { fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: "46px", color: "#CD9602", lineHeight: 1, flexShrink: 0 } }, player.rating), h("div", { style: { minWidth: 0 } }, h("div", { style: { fontSize: "22px", fontWeight: 700, color: "#F5EDD8", lineHeight: 1.15 } }, displayFull(player)), h("div", { style: { display: "flex", alignItems: "center", gap: "7px", fontSize: "11px", letterSpacing: "2px", color: "rgba(245,237,216,0.55)", marginTop: "5px" } }, player.nationFlag ? h("img", { src: player.nationFlag, alt: player.nation || "", crossOrigin: "anonymous", style: { width: "20px", height: "14px", objectFit: "cover", borderRadius: "2px", display: "block", flexShrink: 0 } }) : null, h("span", { style: { overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" } }, player.nation + " \u00B7 " + player.club) ) ) ), h("div", { style: { display: "flex", gap: "16px", marginBottom: "18px", fontSize: "11px", letterSpacing: "1.5px" } }, h("div", null, h("div", { style: { color: "rgba(245,237,216,0.45)" } }, "POSITION"), h("div", { style: { color: "#CD9602", fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: "18px" } }, player.pos)), h("div", null, h("div", { style: { color: "rgba(245,237,216,0.45)" } }, "ALT"), h("div", { style: { color: "#F5EDD8", fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: "14px", marginTop: "3px" } }, altStr)), h("div", null, h("div", { style: { color: "rgba(245,237,216,0.45)" } }, "CHEM"), h("div", { style: { color: chemColor, fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: "18px", letterSpacing: "1px" } }, "\u2605\u2605\u2605".slice(0, chemStars) + "\u2606\u2606\u2606".slice(0, 3 - chemStars))) ), h("div", { style: { display: "flex", flexDirection: "column", gap: "16px" } }, stats.map(function(s) { var v = s[1]; return h("div", { key: s[0], style: { display: "grid", gridTemplateColumns: "60px 1fr 36px", alignItems: "center", gap: "12px", fontFamily: "'Anton', 'Bebas Neue', sans-serif", letterSpacing: "1.5px", fontSize: "14px", color: "#F5EDD8" } }, h("span", null, s[0]), h("div", { style: { height: "8px", background: "rgba(255,255,255,0.08)", borderRadius: "999px", overflow: "hidden" } }, h("div", { style: { height: "100%", width: v + "%", background: v >= 85 ? "linear-gradient(90deg, #E8B83A, #CD9602)" : v >= 70 ? "linear-gradient(90deg, #CD9602, #8a5e02)" : "linear-gradient(90deg, #4a1166, #6c2090)", boxShadow: v >= 85 ? "0 0 12px rgba(205,150,2,0.5)" : "none" } }) ), h("span", { style: { textAlign: "right", color: v >= 85 ? "#E8B83A" : "#F5EDD8" } }, v) ); }) ) ) ), h("div", { style: { padding: "16px 28px 24px", display: "flex", flexWrap: "wrap", gap: "10px", justifyContent: "flex-end", borderTop: "1px solid rgba(205,150,2,0.2)" } }, h("button", { style: Object.assign({}, btnS, { background: "rgba(220,53,69,0.15)", color: "#ff8a96", border: "1px solid rgba(220,53,69,0.45)" }), onClick: onRemove }, "REMOVE"), onToggleCaptain && h("button", { style: Object.assign({}, btnS, captain === player.id ? { background: "rgba(232,184,58,0.15)", color: "#E8B83A", border: "1px solid rgba(232,184,58,0.5)" } : { background: "transparent", color: "rgba(245,237,216,0.6)", border: "1px solid rgba(245,237,216,0.2)" }), onClick: function() { onToggleCaptain(player.id); onClose(); } }, captain === player.id ? "★ CAPTAIN" : "MAKE CAPTAIN"), h("button", { style: Object.assign({}, btnS, { background: "transparent", color: "#F5EDD8", border: "1px solid rgba(245,237,216,0.3)" }), onClick: onClose }, "CLOSE"), h("button", { style: Object.assign({}, btnS, { background: "linear-gradient(155deg, #E8B83A, #CD9602)", color: "#1A0428", boxShadow: "0 6px 18px rgba(205,150,2,0.35)" }), onClick: onReplace }, "REPLACE") ) ) ); } // ─── More Formations Modal ─────────────────────────────────────────────────── function MoreFormationsModal(props) { var gameMode = props.gameMode, currentKey = props.currentKey, onSelect = props.onSelect, onClose = props.onClose; var formations = FORMATIONS_ALL[gameMode]; return h("div", { style: { position: "fixed", inset: 0, background: "rgba(8,1,15,0.85)", backdropFilter: "blur(8px)", display: "flex", alignItems: "center", justifyContent: "center", zIndex: 300, padding: "24px" }, onClick: onClose }, h("div", { style: { background: "linear-gradient(180deg, #2F0745 0%, #1A0428 100%)", border: "1px solid rgba(205,150,2,0.45)", borderRadius: "16px", padding: "24px", width: "min(460px, 100%)", maxHeight: "80vh", display: "flex", flexDirection: "column", gap: "16px" }, onClick: function(e) { e.stopPropagation(); } }, h("div", { style: { display: "flex", alignItems: "center", justifyContent: "space-between" } }, h("div", { style: { fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: "20px", letterSpacing: "3px", color: "#F5EDD8" } }, "ALL FORMATIONS — " + gameMode.toUpperCase()), h("button", { style: { width: "30px", height: "30px", borderRadius: "50%", background: "rgba(255,255,255,0.06)", border: "1px solid rgba(255,255,255,0.1)", color: "#F5EDD8", cursor: "pointer", fontSize: "16px", lineHeight: 1 }, onClick: onClose }, "×") ), h("div", { style: { display: "grid", gridTemplateColumns: "1fr 1fr", gap: "10px", overflowY: "auto" } }, Object.keys(formations).map(function(k) { var active = currentKey === k; return h("button", { key: k, style: { padding: "14px 8px", background: active ? "linear-gradient(155deg, #CD9602, #8a5e02)" : "rgba(255,255,255,0.04)", border: active ? "1px solid #E8B83A" : "1px solid rgba(255,255,255,0.08)", borderRadius: "10px", color: active ? "#1A0428" : "#F5EDD8", fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: "20px", letterSpacing: "2px", cursor: "pointer", boxShadow: active ? "0 6px 18px rgba(205,150,2,0.35)" : "none" }, onClick: function() { onSelect(k); onClose(); } }, k); }) ) ) ); } // ─── Manager Modal ─────────────────────────────────────────────────────────── function ManagerModal(props) { var manager = props.manager, onSave = props.onSave, onClose = props.onClose; var _tab = useState(manager && !manager._api ? "custom" : "real"), tab = _tab[0], setTab = _tab[1]; var _n = useState(manager ? manager.name : ""), mgrName = _n[0], setMgrName = _n[1]; var _nat = useState(manager ? manager.nation : "ENG"), nation = _nat[0], setNation = _nat[1]; var _st = useState(manager && manager.style ? manager.style : "ATTACKING"), style = _st[0], setStyle = _st[1]; var _photo = useState(manager ? (manager.photo || null) : null), photo = _photo[0], setPhoto = _photo[1]; var _isApi = useState(manager ? !!manager._api : false), isApi = _isApi[0], setIsApi = _isApi[1]; var _q = useState(""), q = _q[0], setQ = _q[1]; var _loading = useState(false), loading = _loading[0], setLoading = _loading[1]; var _err = useState(""), err = _err[0], setErr = _err[1]; var _res = useState([]), results = _res[0], setResults = _res[1]; var _saving = useState(false), saving = _saving[0], setSaving = _saving[1]; var timer = useRef(null); var inputS = { background: "rgba(0,0,0,0.3)", border: "1px solid rgba(255,255,255,0.1)", borderRadius: "8px", padding: "10px 12px", color: "#F5EDD8", fontSize: "14px", outline: "none", fontFamily: "inherit", width: "100%", boxSizing: "border-box" }; var labelS = { fontSize: "10px", letterSpacing: "1.5px", color: "rgba(245,237,216,0.55)", textTransform: "uppercase", marginBottom: "4px", display: "block" }; var selectS = Object.assign({}, inputS, { fontSize: "13px", appearance: "none", backgroundImage: "linear-gradient(45deg, transparent 50%, #CD9602 50%), linear-gradient(135deg, #CD9602 50%, transparent 50%)", backgroundPosition: "calc(100% - 18px) 50%, calc(100% - 13px) 50%", backgroundSize: "5px 5px", backgroundRepeat: "no-repeat", paddingRight: "32px" }); var MGRSTYLES = ["ATTACKING", "DEFENSIVE", "POSSESSION", "COUNTER", "PRESSING", "DIRECT"]; // Debounced coach search (real managers) useEffect(function() { if (tab !== "real") return; if (q.trim().length < 3) { setResults([]); setErr(q.trim() ? "Type at least 3 letters." : ""); return; } if (timer.current) clearTimeout(timer.current); timer.current = setTimeout(function() { setLoading(true); setErr(""); footballProxy("coaches/search/" + encodeURIComponent(q.trim()), { include: "nationality;teams.team", per_page: 25 }).then(function(d) { setLoading(false); var e = apiEnvErr(d); if (e) { setErr(e); setResults([]); return; } var rows = (d.data || []).map(function(c) { var nat = c.nationality || null; var activeTeam = (c.teams || []).filter(function(t) { return t.active && t.team && t.team.name; })[0]; return { id: c.id, name: c.display_name || c.name, common: smkBestName(c) || c.common_name || null, nationality: nat && nat.name, _iso3: nat && nat.iso3, photo: c.image_path || null, team: activeTeam ? { name: activeTeam.team.name } : null }; }); setResults(rows.slice(0, 25)); if (!rows.length) setErr("No managers found."); }); }, 550); return function() { if (timer.current) clearTimeout(timer.current); }; }, [q, tab]); var pickCoach = function(c) { var nm = (c.name || "").trim(); var parts = nm.split(/\s+/); setMgrName(c.common || (parts.length > 1 ? parts[0].charAt(0) + ". " + parts.slice(1).join(" ") : nm)); setNation(fifaNation(c._iso3, c.nationality) || "ENG"); setPhoto(c.photo || null); setIsApi(true); setResults([]); setErr(""); }; var doSave = function() { if (!mgrName.trim() || saving) return; setSaving(true); var finalize = function(ph) { onSave({ name: mgrName.trim(), nation: nation, style: style, photo: ph || null, _api: isApi }); onClose(); }; if (isApi && photo && photo.indexOf("data:") !== 0) proxyImage(photo).then(function(dp) { finalize(dp || photo); }); else finalize(photo); }; var tabBtn = function(id, label) { var active = tab === id; return h("button", { onClick: function() { setTab(id); setErr(""); }, style: { flex: 1, padding: "9px 0", borderRadius: "999px", fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: "13px", letterSpacing: "1.5px", cursor: "pointer", border: "none", background: active ? "linear-gradient(155deg, #E8B83A, #CD9602)" : "transparent", color: active ? "#1A0428" : "rgba(245,237,216,0.7)" } }, label); }; var styleField = h("div", null, h("label", { style: labelS }, "Tactical Style"), h("select", { style: selectS, value: style, onChange: function(e) { setStyle(e.target.value); } }, MGRSTYLES.map(function(s) { return h("option", { key: s, value: s }, s); }))); var coachRow = function(c) { return h("div", { key: c.id, onClick: function() { pickCoach(c); }, style: { display: "grid", gridTemplateColumns: "34px 1fr", gap: "10px", alignItems: "center", padding: "8px 10px", borderRadius: "10px", background: "rgba(255,255,255,0.03)", border: "1px solid rgba(255,255,255,0.05)", marginBottom: "6px", cursor: "pointer" }, onMouseEnter: function(e) { e.currentTarget.style.background = "rgba(205,150,2,0.12)"; }, onMouseLeave: function(e) { e.currentTarget.style.background = "rgba(255,255,255,0.03)"; } }, c.photo ? h("img", { src: c.photo, alt: "", crossOrigin: "anonymous", style: { width: "34px", height: "34px", borderRadius: "50%", objectFit: "cover", background: "rgba(255,255,255,0.06)" } }) : h("div", { style: { width: "34px", height: "34px", borderRadius: "50%", background: "rgba(255,255,255,0.06)", display: "flex", alignItems: "center", justifyContent: "center", color: "rgba(245,237,216,0.5)" } }, managerIcon(18)), h("div", { style: { minWidth: 0 } }, h("div", { style: { fontSize: "14px", fontWeight: 600, color: "#F5EDD8", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" } }, c.name), h("div", { style: { fontSize: "11px", color: "rgba(245,237,216,0.5)", letterSpacing: "0.5px" } }, [c.nationality, c.team && c.team.name].filter(Boolean).join(" · ")) ) ); }; var realBody = h("div", { style: { display: "flex", flexDirection: "column", gap: "12px" } }, h("input", { autoFocus: true, style: inputS, placeholder: "Search a real manager…", value: q, onChange: function(e) { setQ(e.target.value); } }), loading ? h("div", { style: { display: "flex", justifyContent: "center", padding: "16px 0" } }, footballSpinner(30)) : null, err ? h("div", { style: { color: "#ff8a96", fontSize: "12px", textAlign: "center" } }, err) : null, results.length ? h("div", { style: { maxHeight: "190px", overflowY: "auto" } }, results.map(coachRow)) : null, (isApi && mgrName) ? h("div", { style: { display: "flex", alignItems: "center", gap: "10px", padding: "10px", background: "rgba(205,150,2,0.1)", border: "1px solid rgba(205,150,2,0.35)", borderRadius: "10px" } }, photo ? h("img", { src: photo, alt: "", crossOrigin: "anonymous", style: { width: "40px", height: "40px", borderRadius: "50%", objectFit: "cover" } }) : null, h("div", { style: { flex: 1, minWidth: 0 } }, h("div", { style: { color: "#F5EDD8", fontSize: "14px", fontWeight: 600 } }, mgrName), h("div", { style: { fontSize: "11px", color: "rgba(245,237,216,0.5)" } }, "Selected · " + nation)) ) : null, (isApi && mgrName) ? styleField : null ); var customBody = h("div", { style: { display: "flex", flexDirection: "column", gap: "12px" } }, h("div", null, h("label", { style: labelS }, "Manager Name"), h("input", { style: inputS, value: mgrName, placeholder: "J. Mourinho", onChange: function(e) { setMgrName(e.target.value); setIsApi(false); setPhoto(null); } })), h("div", null, h("label", { style: labelS }, "Nation"), h("select", { style: selectS, value: nation, onChange: function(e) { setNation(e.target.value); } }, NATIONS.map(function(n) { return h("option", { key: n, value: n }, n); }))), styleField ); var canSave = mgrName.trim() && (tab === "custom" || isApi); return h("div", { style: { position: "fixed", inset: 0, background: "rgba(8,1,15,0.85)", backdropFilter: "blur(8px)", display: "flex", alignItems: "center", justifyContent: "center", zIndex: 300, padding: "24px" }, onClick: onClose }, h("div", { style: { background: "linear-gradient(180deg, #2F0745 0%, #1A0428 100%)", border: "1px solid rgba(205,150,2,0.45)", borderRadius: "16px", padding: "22px", width: "min(400px, 100%)", display: "flex", flexDirection: "column", gap: "16px" }, onClick: function(e) { e.stopPropagation(); } }, h("div", { style: { fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: "20px", letterSpacing: "3px", color: "#F5EDD8" } }, manager ? "EDIT MANAGER" : "ADD MANAGER"), h("div", { style: { display: "flex", gap: "4px", background: "rgba(0,0,0,0.28)", border: "1px solid rgba(255,255,255,0.08)", borderRadius: "999px", padding: "4px" } }, tabBtn("real", "REAL MANAGER"), tabBtn("custom", "CUSTOM")), tab === "real" ? realBody : customBody, h("div", { style: { display: "flex", gap: "10px" } }, manager && h("button", { style: { flex: 1, padding: "11px", background: "rgba(255,80,80,0.12)", border: "1px solid rgba(255,80,80,0.3)", borderRadius: "8px", color: "#ff6b6b", fontFamily: "'Anton', 'Bebas Neue', sans-serif", letterSpacing: "1.5px", cursor: "pointer", fontSize: "13px" }, onClick: function() { onSave(null); onClose(); } }, "REMOVE"), h("button", { disabled: !canSave || saving, style: { flex: 2, padding: "11px", background: (canSave && !saving) ? "linear-gradient(155deg, #E8B83A, #CD9602)" : "rgba(255,255,255,0.06)", border: "none", borderRadius: "8px", color: (canSave && !saving) ? "#1A0428" : "rgba(245,237,216,0.3)", fontFamily: "'Anton', 'Bebas Neue', sans-serif", letterSpacing: "1.5px", cursor: (canSave && !saving) ? "pointer" : "not-allowed", fontSize: "14px" }, onClick: doSave }, saving ? "SAVING…" : "SAVE") ) ) ); } // ─── Lineup Builder: Main Component ───────────────────────────────────────── function LineupBuilder(props) { props = props || {}; var session = props.session, requireAuth = props.requireAuth, myPlayers = props.myPlayers || [], refreshMyLineups = props.refreshMyLineups; var pendingLineup = props.pendingLineup, clearPendingLineup = props.clearPendingLineup; var onLineupExported = props.onLineupExported, goToCardMaker = props.goToCardMaker; var isMobile = useWindowSize().w < 768; var _gm = useState("11v11"), gameMode = _gm[0], setGameMode = _gm[1]; var _fk = useState("4-3-3"), formationKey = _fk[0], setFormationKey = _fk[1]; var _mf = useState(false), moreFormationsOpen = _mf[0], setMoreFormationsOpen = _mf[1]; var _mgr = useState(null), manager = _mgr[0], setManager = _mgr[1]; var _mgrModal = useState(false), mgrModalOpen = _mgrModal[0], setMgrModalOpen = _mgrModal[1]; var _cap = useState(null), captain = _cap[0], setCaptain = _cap[1]; var _hov = useState(null), hoveredSlot = _hov[0], setHoveredSlot = _hov[1]; var _drag = useState(null), drag = _drag[0], setDrag = _drag[1]; var dragRef = useRef(null); var justDraggedRef = useRef(false); var pitchRef = useRef(null); var modeFormations = FORMATIONS_ALL[gameMode]; var formation = modeFormations[formationKey] || Object.values(modeFormations)[0]; var _a = useState({}), assignments = _a[0], setAssignments = _a[1]; var _m = useState(null), modal = _m[0], setModal = _m[1]; var _ex = useState(false), exportOpen = _ex[0], setExportOpen = _ex[1]; // Account: save lineups (browsing saved lineups lives in the account → My Collection) var _saveLuOpen = useState(false), saveLuOpen = _saveLuOpen[0], setSaveLuOpen = _saveLuOpen[1]; var _luName = useState(""), luName = _luName[0], setLuName = _luName[1]; var _luBusy = useState(false), luBusy = _luBusy[0], setLuBusy = _luBusy[1]; var _luToast = useState(""), luToast = _luToast[0], setLuToast = _luToast[1]; var _curLu = useState(null), currentLineup = _curLu[0], setCurrentLineup = _curLu[1]; // { id, name } of the loaded lineup, or null var _cr = useState(false), confirmReset = _cr[0], setConfirmReset = _cr[1]; // true = the "reset your entire line-up?" confirm modal is open var _hyd = useState(0), hydrateTick = _hyd[0], setHydrateTick = _hyd[1]; // bumped once rehydrateApiPlayers re-registers a saved lineup's session-only API players → recompute squad var _hp = useState(null), hydratingPids = _hp[0], setHydratingPids = _hp[1]; // { pid: true } of saved API players still being re-fetched → their slots show a spinner (not empty) var loadRef = useRef(null); // pending lineup to apply after a game-mode switch // Demo players + the user's custom players, available to the slot picker. var searchPool = useMemo(function() { return PLAYERS.concat(myPlayers); }, [myPlayers]); // Reset formation + assignments when game mode changes — unless a saved lineup // is being loaded into this mode, in which case apply it instead of resetting. useEffect(function() { if (loadRef.current && loadRef.current.game_mode === gameMode) { var L = loadRef.current; loadRef.current = null; setFormationKey(L.formation_key); setAssignments(L.assignments || {}); setCaptain(L.captain || null); setManager(L.manager || null); return; } var keys = Object.keys(FORMATIONS_ALL[gameMode]); setFormationKey(keys[0]); setAssignments({}); }, [gameMode]); useEffect(function() { var newSlots = (modeFormations[formationKey] || Object.values(modeFormations)[0]).slots; setAssignments(function(prev) { var next = {}; var usedPids = []; // Pass 1: keep players whose exact slot ID still exists in new formation newSlots.forEach(function(slot) { if (prev[slot.id] && usedPids.indexOf(prev[slot.id]) < 0) { next[slot.id] = prev[slot.id]; usedPids.push(prev[slot.id]); } }); // Collect players from old slots that no longer exist var unplaced = []; Object.keys(prev).forEach(function(sid) { var pid = prev[sid]; if (usedPids.indexOf(pid) < 0) unplaced.push(pid); }); // Pass 2: reassign unplaced players to empty slots — EXACT position across ALL players first, // then alt, then same-line group, so a real full-back claims the full-back slot before a // center-back grabs it as a fallback (no scrambled back line on a formation switch). var emptySlots = newSlots.filter(function(s) { return !next[s.id]; }); [ function(p, s) { return s.pos === p.pos; }, function(p, s) { return p.alt && p.alt.indexOf(s.pos) >= 0; }, function(p, s) { return POS_GROUP[s.pos] === POS_GROUP[p.pos]; } ].forEach(function(match) { unplaced.forEach(function(pid, ui) { if (pid == null) return; var player = getPlayer(pid); if (!player) { unplaced[ui] = null; return; } for (var i = 0; i < emptySlots.length; i++) { if (match(player, emptySlots[i])) { next[emptySlots[i].id] = pid; emptySlots.splice(i, 1); unplaced[ui] = null; break; } } }); }); return next; }); }, [formationKey]); var slotCount = formation.slots.length; // Single-pass squad metrics — rating, chemistry, and per-slot chem, all memoised. // Country links are derived from a precomputed nation tally (O(n) instead of O(n²)). var squad = useMemo(function() { var slots = formation.slots; var nationCount = {}; slots.forEach(function(s) { var p = getPlayer(assignments[s.id]); if (p) nationCount[p.nation] = (nationCount[p.nation] || 0) + 1; }); var ratingSum = 0, chemSum = 0, placed = 0; var chemBySlot = {}; slots.forEach(function(s) { var p = getPlayer(assignments[s.id]); if (!p) return; placed++; ratingSum += p.rating; var posChem = p.pos === s.pos ? 7 : (p.alt && p.alt.indexOf(s.pos) >= 0) ? 5 : (POS_GROUP[p.pos] && POS_GROUP[p.pos] === POS_GROUP[s.pos]) ? 3 : 0; var links = Math.min(3, (nationCount[p.nation] || 1) - 1); var c = Math.min(10, posChem + links); chemBySlot[s.id] = c; chemSum += c; }); var n = slots.length; return { placed: placed, teamRating: placed === 0 ? 0 : Math.round(ratingSum / n), teamChem: placed === 0 ? 0 : Math.round(chemSum / (n * 10) * 100), chemBySlot: chemBySlot }; }, [formation, assignments, hydrateTick]); var filledCount = squad.placed; var teamRating = squad.teamRating; var teamChem = squad.teamChem; var chemColor = teamChem >= 75 ? "#3fb96b" : teamChem >= 45 ? "#E8B83A" : "#ff6b6b"; // Keep the captain valid: clear it if that player is no longer on the pitch. useEffect(function() { if (captain != null && Object.values(assignments).indexOf(captain) < 0) setCaptain(null); }, [assignments, captain]); var handleSlotClick = function(slot) { // Route on whether the assigned player actually RESOLVES — a saved lineup can reference a // session-only API player (smk:) that isn't re-hydrated yet; that slot renders empty, so // open the picker (opening the stats modal for a null player would crash). See rehydrateApiPlayers. if (getPlayer(assignments[slot.id])) setModal({ type: "stats", slot: slot }); else setModal({ type: "search", slot: slot }); }; var pickPlayer = function(player) { if (modal && assignments[modal.slot.id] === captain && player.id !== captain) setCaptain(null); setAssignments(function(prev) { var n = Object.assign({}, prev); n[modal.slot.id] = player.id; return n; }); setModal(null); }; var removeSlot = function(slotId) { setAssignments(function(prev) { var n = Object.assign({}, prev); delete n[slotId]; return n; }); }; var removeFromSlot = function() { removeSlot(modal.slot.id); setModal(null); }; var replaceFromSlot = function() { setModal({ type: "search", slot: modal.slot }); }; var toggleCaptain = function(pid) { setCaptain(function(c) { return c === pid ? null : pid; }); }; // Clear the pitch to start a fresh lineup (keeps the current formation + game mode). // Also clears any in-flight interaction (open picker, active drag, hover arc) so nothing lingers. var resetLineup = function() { setAssignments({}); setCaptain(null); setManager(null); setCurrentLineup(null); setModal(null); setExportOpen(false); setDrag(null); setHoveredSlot(null); setHydratingPids(null); dragRef.current = null; justDraggedRef.current = false; }; // ── Account: save / load lineups ────────────────────────────────────────── var openSaveLineup = function() { // signed-out: saving needs an account → show the sign-up CTA (coupon reward) rather than a bare // auth modal, so "save lineup" also triggers the CTA (the client's ask). SIGN UP & CLAIM → auth. if (!session) { if (onLineupExported) onLineupExported(); else if (requireAuth) requireAuth(); return; } setLuName(currentLineup ? (currentLineup.name || "") : ""); // prefill name when editing an existing lineup setSaveLuOpen(true); }; // asNew = true → always insert a fresh lineup; otherwise update the loaded one (if any). var confirmSaveLineup = function(asNew) { if (!sb || !session) return; setLuBusy(true); var name = luName.trim() || (currentLineup && currentLineup.name) || (formation.name + " · " + gameMode); var payload = { game_mode: gameMode, formation_key: formationKey, name: name, assignments: assignments, captain: captain || null, manager: manager || null, updated_at: new Date().toISOString() }; var updating = !asNew && currentLineup && currentLineup.id; var op = updating ? sb.from("lineups").update(payload).eq("id", currentLineup.id).select().single() : sb.from("lineups").insert(Object.assign({ user_id: session.user.id }, payload)).select().single(); op.then(function(res) { setLuBusy(false); setSaveLuOpen(false); if (res.error) { setLuToast("Couldn't save — " + res.error.message); } else { if (res.data) setCurrentLineup({ id: res.data.id, name: res.data.name }); setLuToast(updating ? "✓ CHANGES SAVED" : "✓ LINEUP SAVED"); if (refreshMyLineups) refreshMyLineups(); } setTimeout(function() { setLuToast(""); }, 2600); }, function(e) { setLuBusy(false); setSaveLuOpen(false); setLuToast("Couldn't save — " + ((e && e.message) || "error")); setTimeout(function() { setLuToast(""); }, 2600); }); }; var loadLineup = function(L) { setCurrentLineup({ id: L.id, name: L.name || "" }); if (L.game_mode && L.game_mode !== gameMode) { loadRef.current = L; // applied by the game-mode effect after the switch setGameMode(L.game_mode); } else { setFormationKey(L.formation_key); setAssignments(L.assignments || {}); setCaptain(L.captain || null); setManager(L.manager || null); } // Saved lineups store player ids only; re-fetch any session-only API players (smk:) so they // resolve instead of showing as empty slots (§11.9). Each such slot shows a spinner (hydratingPids) // until ITS player lands; onEach clears that pid + bumps hydrateTick → that one slot fills in. var lu = L.assignments || {}; var pending = {}; Object.keys(lu).forEach(function(sid) { var pid = lu[sid]; if (typeof pid === "string" && pid.indexOf("smk:") === 0 && !getPlayer(pid)) pending[pid] = true; }); setHydratingPids(Object.keys(pending).length ? pending : null); rehydrateApiPlayers(lu, function(pid) { setHydratingPids(function(prev) { if (!prev) return prev; var n = Object.assign({}, prev); delete n[pid]; return Object.keys(n).length ? n : null; }); setHydrateTick(function(t) { return t + 1; }); }).then(function() { setHydratingPids(null); }); }; // Load a lineup requested from the dashboard (then clear the request). useEffect(function() { if (pendingLineup) { loadLineup(pendingLineup); if (clearPendingLineup) clearPendingLineup(); } }, [pendingLineup]); var rosterPlayers = formation.slots.map(function(s) { return { slot: s, player: getPlayer(assignments[s.id]) }; }); var arcBtnS = function(active) { return { width: "28px", height: "28px", borderRadius: "50%", padding: 0, lineHeight: 1, cursor: "pointer", display: "flex", alignItems: "center", justifyContent: "center", fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: "13px", flexShrink: 0, border: active ? "1px solid #FFE9A8" : "1px solid rgba(232,184,58,0.55)", background: active ? "linear-gradient(135deg,#E8B83A,#CD9602)" : "rgba(8,1,15,0.92)", color: active ? "#1A0428" : "#F5EDD8", boxShadow: "0 4px 10px rgba(0,0,0,0.6)", transition: "filter 120ms ease" }; }; // ── Drag-to-swap players on the pitch (pointer-based; works for touch + mouse) ── var findNearestSlot = function(clientX, clientY) { var rect = pitchRef.current && pitchRef.current.getBoundingClientRect(); if (!rect) return null; var best = null, bestD = Infinity; formation.slots.forEach(function(s) { var cx = rect.left + (s.x / 100) * rect.width; var cy = rect.top + (Math.min(s.y, 86) / 100) * rect.height; var d = Math.hypot(clientX - cx, clientY - cy); if (d < bestD) { bestD = d; best = s; } }); return best; }; var onSlotPointerDown = function(slot, e) { if (e.button && e.button !== 0) return; if (!assignments[slot.id]) return; if (e.target && e.target.closest && e.target.closest(".arc-pop")) return; dragRef.current = { slotId: slot.id, pid: assignments[slot.id], startX: e.clientX, startY: e.clientY, pointerId: e.pointerId, active: false }; try { e.currentTarget.setPointerCapture(e.pointerId); } catch (_) {} }; var onSlotPointerMove = function(e) { var d = dragRef.current; if (!d || d.pointerId !== e.pointerId) return; if (!d.active && Math.hypot(e.clientX - d.startX, e.clientY - d.startY) > 8) { d.active = true; setHoveredSlot(null); } if (d.active) { e.preventDefault(); var t = findNearestSlot(e.clientX, e.clientY); setDrag({ pid: d.pid, slotId: d.slotId, x: e.clientX, y: e.clientY, targetId: (t && t.id !== d.slotId) ? t.id : null }); } }; var onSlotPointerUp = function(slot, e) { var d = dragRef.current; try { e.currentTarget.releasePointerCapture(e.pointerId); } catch (_) {} if (d && d.active) { var target = findNearestSlot(e.clientX, e.clientY); if (target && target.id !== d.slotId) { setAssignments(function(prev) { var n = Object.assign({}, prev); var a = prev[d.slotId], b = prev[target.id]; if (b != null) n[d.slotId] = b; else delete n[d.slotId]; n[target.id] = a; return n; }); } justDraggedRef.current = true; } dragRef.current = null; setDrag(null); }; var styleNotes = { "4-3-3": "Balanced attack with wide forwards. Press high, build through midfield triangles.", "4-4-2": "Classic two-striker shape. Direct play, wing crosses, strong central spine.", "4-2-3-1": "Double pivot with a creative ten. Control midfield, exploit half-spaces.", "3-5-2": "Wing-backs provide width. Three centre-backs cover transitions.", "5-3-2": "Defensive setup with counter-attack threat. Compact lines, quick breaks.", "4-1-3-2": "Single pivot shields the defence while three midfielders create overloads.", "3-4-3": "Attacking 3-back with wide midfielders and three forwards stretching the pitch.", "3-1-4-2": "Deep pivot anchors the midfield; four creators link play to a dual-striker partnership.", "3-3-2": "9v9 compact shape — solid defensive triangle with three-man midfield and twin strikers.", "3-2-3": "9v9 attacking formation. Three forwards stretch a compact defence.", "4-2-2": "9v9 back four with direct two-man midfield feeding two forwards.", "2-3-1": "7v7 midfield control shape. Two defenders, busy midfield trio, single target striker.", "3-2-1": "Solid 7v7 back three, two midfielders and a lone striker on the break.", "2-2-2": "Balanced 7v7 — equal emphasis on defence, midfield and attack.", "1-3-2": "7v7 attacking variant. One deep defender, busy midfield trio and dual strikers.", "2-1-1": "Simple 5v5 setup — two defenders, one midfielder, one striker.", "1-2-1": "5v5 diamond: lone CB, two midfielders and a striker.", "1-1-2": "Attacking 5v5 formation with two forwards pressing the opposition.", }; var panelS = { background: "linear-gradient(180deg, rgba(74,17,102,0.55) 0%, rgba(26,4,40,0.85) 100%)", border: "1px solid rgba(205,150,2,0.25)", borderRadius: "14px", padding: isMobile ? "14px" : "20px", minWidth: 0, overflow: "hidden" }; var panelTitleS = { fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: "13px", letterSpacing: "3px", color: "#CD9602", marginBottom: "14px", display: "flex", alignItems: "center", gap: "8px" }; return h("div", { style: isMobile ? { display: "flex", flexDirection: "column", gap: "14px", padding: "14px 12px 24px", minHeight: "calc(100vh - 96px)", color: "#F5EDD8", width: "100%", boxSizing: "border-box", overflowX: "hidden" } : { display: "grid", gridTemplateColumns: "260px 1fr 280px", gridTemplateRows: "auto 1fr", gap: "24px", padding: "20px 28px 24px", height: "calc(100vh - 72px)", color: "#F5EDD8", overflow: "hidden" } }, // ── FORMATION panel (mobile: 1st, desktop: col 1 row 1) h("div", { style: isMobile ? {} : { gridColumn: 1, gridRow: 1, overflowY: "auto", paddingRight: "2px" } }, h("div", { style: panelS }, h("div", { style: panelTitleS }, "FORMATION", h("div", { style: { flex: 1, height: "1px", background: "linear-gradient(90deg, rgba(205,150,2,0.6), transparent)" } })), // Game mode toggle h("div", { style: { display: "flex", gap: "5px", marginBottom: "12px" } }, GAME_MODES.map(function(m) { var active = gameMode === m; return h("button", { key: m, style: { flex: 1, padding: "6px 3px", background: active ? "rgba(205,150,2,0.2)" : "rgba(255,255,255,0.04)", border: active ? "1px solid #CD9602" : "1px solid rgba(255,255,255,0.08)", borderRadius: "7px", color: active ? "#E8B83A" : "rgba(245,237,216,0.45)", fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: "10px", letterSpacing: "1px", cursor: "pointer" }, onClick: function() { setGameMode(m); } }, m); }) ), isMobile ? h("div", { style: { overflowX: "auto", WebkitOverflowScrolling: "touch", scrollbarWidth: "none", msOverflowStyle: "none", marginLeft: "-14px", marginRight: "-14px", paddingLeft: "14px", paddingRight: "14px" } }, h("div", { style: { display: "flex", gap: "8px", paddingBottom: "2px", width: "max-content" } }, Object.keys(modeFormations).map(function(k) { var active = formationKey === k; return h("button", { key: k, style: { flexShrink: 0, padding: "10px 16px", background: active ? "linear-gradient(155deg, #CD9602, #8a5e02)" : "rgba(255,255,255,0.04)", border: active ? "1px solid #E8B83A" : "1px solid rgba(255,255,255,0.08)", borderRadius: "10px", color: active ? "#1A0428" : "#F5EDD8", fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: "20px", letterSpacing: "2px", cursor: "pointer", boxShadow: active ? "0 6px 18px rgba(205,150,2,0.35)" : "none" }, onClick: function() { setFormationKey(k); } }, k); }) ) ) : (function() { var allKeys = Object.keys(modeFormations); var quickKeys = allKeys.slice(0, 4); var hasMore = allKeys.length > 4; return h("div", { style: { display: "grid", gridTemplateColumns: "1fr 1fr", gap: "10px" } }, quickKeys.map(function(k) { var active = formationKey === k; return h("button", { key: k, style: { padding: "14px 8px", background: active ? "linear-gradient(155deg, #CD9602, #8a5e02)" : "rgba(255,255,255,0.04)", border: active ? "1px solid #E8B83A" : "1px solid rgba(255,255,255,0.08)", borderRadius: "10px", color: active ? "#1A0428" : "#F5EDD8", fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: "22px", letterSpacing: "2px", cursor: "pointer", boxShadow: active ? "0 6px 18px rgba(205,150,2,0.35)" : "none" }, onClick: function() { setFormationKey(k); } }, k); }).concat( hasMore ? [h("button", { key: "more", style: { padding: "12px 8px", background: "rgba(255,255,255,0.03)", border: "1px solid rgba(205,150,2,0.3)", borderRadius: "10px", color: "#CD9602", fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: "13px", letterSpacing: "1.5px", cursor: "pointer", gridColumn: "span 2" }, onClick: function() { setMoreFormationsOpen(true); } }, "ALL FORMATIONS →")] : [] ) ); })(), h("div", { style: { marginTop: "12px", padding: "12px", background: "rgba(0,0,0,0.25)", borderRadius: "8px", fontSize: "11px", lineHeight: 1.6, color: "rgba(245,237,216,0.6)" } }, h("div", { style: { color: "#CD9602", fontFamily: "'Anton', 'Bebas Neue', sans-serif", letterSpacing: "1.5px", marginBottom: "4px" } }, "STYLE NOTE"), styleNotes[formationKey] || "" ) ), ), // ── PITCH (mobile: 2nd, desktop: col 2 rows 1–2) // overflow:visible so a high striker (e.g. 4-2-3-1 ST at y4) pokes above the pitch top // instead of being clipped; the root grid's 20px top padding gives it room (root still // clips beyond that). The pitch itself is also overflow:visible (line ~2357). h("div", { style: isMobile ? {} : { gridColumn: 2, gridRow: "1 / 3", display: "flex", alignItems: "center", justifyContent: "center", overflow: "visible" } }, h("div", { ref: pitchRef, style: { position: "relative", aspectRatio: isMobile ? "0.62" : "0.74", height: isMobile ? "auto" : "100%", width: isMobile ? "100%" : "auto", maxWidth: "100%", background: "radial-gradient(ellipse at 50% 40%, #4a1166 0%, #2F0745 55%, #1A0428 100%)", borderRadius: "14px", border: "1px solid rgba(205,150,2,0.3)", overflow: "visible", boxShadow: isMobile ? "inset 0 0 40px rgba(0,0,0,0.4), 0 14px 30px rgba(0,0,0,0.4)" : "inset 0 0 80px rgba(0,0,0,0.4), 0 30px 60px rgba(0,0,0,0.4)" } }, h(PitchLines), formation.slots.map(function(slot) { var pid = assignments[slot.id]; var player = pid ? getPlayer(pid) : null; var py = Math.min(slot.y, 86); var isHovered = hoveredSlot === slot.id; var isCap = pid != null && pid === captain; var beingDragged = drag && player && drag.pid === pid; var isDropTarget = drag && drag.targetId === slot.id; var scaleV = isDropTarget ? 1.12 : (isHovered ? 1.07 : 1); var pName = player ? displayName(player).toUpperCase() : ""; return h("div", { key: slot.id, style: { position: "absolute", left: slot.x + "%", top: py + "%", transform: "translate(-50%, -50%) scale(" + scaleV + ")", cursor: player ? "grab" : "pointer", transition: "transform 120ms ease", zIndex: isDropTarget ? 35 : (isHovered ? 30 : 1), opacity: beingDragged ? 0.32 : 1, touchAction: player ? "none" : "auto" }, onClick: function() { if (justDraggedRef.current) { justDraggedRef.current = false; return; } handleSlotClick(slot); }, onPointerDown: function(e) { onSlotPointerDown(slot, e); }, onPointerMove: onSlotPointerMove, onPointerUp: function(e) { onSlotPointerUp(slot, e); }, onPointerCancel: function(e) { onSlotPointerUp(slot, e); }, onMouseEnter: isMobile ? null : function() { if (!dragRef.current) setHoveredSlot(slot.id); }, onMouseLeave: isMobile ? null : function() { setHoveredSlot(null); } }, isDropTarget && h("div", { key: "ring", className: "drop-target-ring", style: { position: "absolute", inset: "-7px", borderRadius: "12px", border: "2px solid #E8B83A", pointerEvents: "none", zIndex: 6 } }), isDropTarget && h("div", { key: "badge", style: { position: "absolute", bottom: "100%", left: "50%", transform: "translateX(-50%)", marginBottom: "7px", padding: "3px 10px", borderRadius: "999px", background: "linear-gradient(135deg,#E8B83A,#CD9602)", color: "#1A0428", fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: "11px", letterSpacing: "1.5px", whiteSpace: "nowrap", boxShadow: "0 4px 10px rgba(0,0,0,0.55)", pointerEvents: "none", zIndex: 8 } }, player ? "SWAP" : "MOVE HERE"), player ? h("div", { style: { position: "relative" } }, isCap && h("div", { style: { position: "absolute", top: "-7px", left: "-5px", width: "18px", height: "18px", borderRadius: "50%", background: "linear-gradient(135deg,#E8B83A,#CD9602)", border: "2px solid #1A0428", display: "flex", alignItems: "center", justifyContent: "center", fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: "10px", color: "#1A0428", zIndex: 4, boxShadow: "0 0 8px rgba(232,184,58,0.8)", lineHeight: 1 } }, "C"), h(MiniCard, { player: player, rarity: "gold" }), h("div", { style: { position: "absolute", top: "-4px", right: "-4px", width: "12px", height: "12px", borderRadius: "50%", background: "#3fb96b", border: "2px solid #1A0428", boxShadow: "0 0 8px rgba(63,185,107,0.6)" } }), h("div", { key: "pname", style: { position: "absolute", top: "100%", left: "50%", transform: "translateX(-50%)", marginTop: "3px", fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: "12px", letterSpacing: "0.3px", lineHeight: 1.0, color: "#F5EDD8", textAlign: "center", maxWidth: "84px", textShadow: "0 1px 3px rgba(0,0,0,0.85)", pointerEvents: "none" } }, pName), // Hover quick-actions arc (desktop): captain · replace · remove pop out ABOVE the card. // The container overlaps the card top (bottom: calc(100% - 16px)) so there is no dead gap that would drop hover. (!isMobile && isHovered) && h("div", { style: { position: "absolute", left: "50%", bottom: "calc(100% - 16px)", transform: "translateX(-50%)", width: "132px", height: "64px", display: "flex", alignItems: "flex-end", justifyContent: "center", gap: "9px", zIndex: 40 } }, h("button", { key: "cap", className: "arc-pop", title: isCap ? "Remove captain" : "Make captain", style: Object.assign({}, arcBtnS(isCap), { animationDelay: "0ms" }), onClick: function(e) { e.stopPropagation(); toggleCaptain(pid); } }, "C"), h("button", { key: "rep", className: "arc-pop", title: "Replace player", style: Object.assign({}, arcBtnS(false), { marginBottom: "18px", animationDelay: "55ms" }), onClick: function(e) { e.stopPropagation(); setHoveredSlot(null); setModal({ type: "search", slot: slot }); } }, "⇄"), h("button", { key: "rem", className: "arc-pop", title: "Remove player", style: Object.assign({}, arcBtnS(false), { animationDelay: "110ms" }), onClick: function(e) { e.stopPropagation(); removeSlot(slot.id); } }, "✕") ) ) : [h("div", { key: "empty", style: { width: isMobile ? "44px" : "52px", height: isMobile ? "44px" : "52px", borderRadius: "50%", background: "rgba(245,237,216,0.06)", border: "2px dashed rgba(245,237,216,0.45)", display: "flex", alignItems: "center", justifyContent: "center", color: "#F5EDD8", fontSize: isMobile ? "18px" : "22px", fontWeight: 300, backdropFilter: "blur(4px)" } }, "+"), h("div", { key: "pos", style: { position: "absolute", bottom: isMobile ? "-16px" : "-19px", left: "50%", transform: "translateX(-50%)", fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: isMobile ? "13px" : "14px", letterSpacing: "2px", color: "#CD9602", whiteSpace: "nowrap", textShadow: "0 1px 4px rgba(0,0,0,0.8)" } }, slot.pos)] ); }), // Loading overlay — while a saved lineup re-hydrates its session-only API players (§24), // dim the whole pitch + show ONE centred soccer-ball spinner (blocks interaction until done). (hydratingPids && Object.keys(hydratingPids).length > 0) && h("div", { key: "lu-loading", style: { position: "absolute", inset: 0, borderRadius: "14px", background: "rgba(12,1,22,0.58)", backdropFilter: "blur(2px)", WebkitBackdropFilter: "blur(2px)", display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", gap: "14px", zIndex: 50 } }, footballSpinner(isMobile ? 52 : 66), h("div", { style: { fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: isMobile ? "14px" : "16px", letterSpacing: "2.5px", color: "#E8B83A", textShadow: "0 1px 6px rgba(0,0,0,0.9)" } }, "LOADING LINEUP…") ) ) // close pitch div ), // close pitch wrapper // ── TEAM RATING panel (mobile: 3rd, desktop: col 1 row 2) h("div", { style: isMobile ? {} : { gridColumn: 1, gridRow: 2, overflowY: "auto" } }, h("div", { style: panelS }, h("div", { style: panelTitleS }, "TEAM RATING", h("div", { style: { flex: 1, height: "1px", background: "linear-gradient(90deg, rgba(205,150,2,0.6), transparent)" } })), h("div", { style: { display: "flex", alignItems: "baseline", gap: "10px" } }, h("div", { style: { fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: "56px", color: "#CD9602", lineHeight: 0.9 } }, teamRating || "--"), h("div", { style: { fontSize: "11px", letterSpacing: "1.5px", color: "rgba(245,237,216,0.5)" } }, "OVR") ), h("div", { style: { fontSize: "12px", color: "rgba(245,237,216,0.7)", marginTop: "8px" } }, filledCount + "/" + slotCount + " slots filled"), h("div", { style: { height: "6px", background: "rgba(255,255,255,0.06)", borderRadius: "999px", marginTop: "8px", overflow: "hidden" } }, h("div", { style: { height: "100%", width: (filledCount / slotCount * 100) + "%", background: "linear-gradient(90deg, #CD9602, #E8B83A)", transition: "width 250ms ease" } }) ), h("div", { style: { marginTop: "14px", padding: "10px 12px", background: "rgba(0,0,0,0.2)", borderRadius: "8px", border: "1px solid rgba(255,255,255,0.05)" } }, h("div", { style: { fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: "10px", letterSpacing: "2.5px", color: "rgba(245,237,216,0.5)", marginBottom: "8px" } }, "CHEMISTRY"), filledCount === 0 ? h("div", { style: { fontSize: "11px", color: "rgba(245,237,216,0.3)", fontStyle: "italic", textAlign: "center", padding: "6px 0 2px" } }, "Add players to see chemistry") : [h("div", { key: "bar", style: { display: "flex", alignItems: "center", gap: "10px" } }, h("div", { style: { flex: 1, height: "5px", background: "rgba(255,255,255,0.06)", borderRadius: "999px", overflow: "hidden" } }, h("div", { style: { height: "100%", width: teamChem + "%", background: "linear-gradient(90deg, " + chemColor + ", " + chemColor + "bb)", transition: "width 300ms ease", borderRadius: "999px" } }) ), h("div", { style: { fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: "26px", color: chemColor, lineHeight: 1, minWidth: "38px", textAlign: "right" } }, teamChem) ), h("div", { key: "sub", style: { fontSize: "10px", color: "rgba(245,237,216,0.4)", marginTop: "6px", lineHeight: 1.5 } }, "Position fit · Same country links")] ) ) ), // ── ROSTER panel + lineup actions (mobile: 4th, desktop: col 3 spans both rows; actions pinned below, always visible) h("div", { style: isMobile ? {} : { gridColumn: 3, gridRow: "1 / 3", display: "flex", flexDirection: "column", gap: "14px", minHeight: 0, overflow: "hidden" } }, h("div", { style: Object.assign({}, panelS, isMobile ? {} : { flex: "1 1 auto", minHeight: 0, display: "flex", flexDirection: "column" }) }, h("div", { style: panelTitleS }, "ROSTER", h("div", { style: { flex: 1, height: "1px", background: "linear-gradient(90deg, rgba(205,150,2,0.6), transparent)" } })), // Manager row h("div", { style: { display: "flex", alignItems: "center", gap: "10px", padding: "8px 10px", background: manager ? "rgba(205,150,2,0.08)" : "rgba(255,255,255,0.02)", border: manager ? "1px solid rgba(205,150,2,0.3)" : "1px dashed rgba(255,255,255,0.15)", borderRadius: "8px", marginBottom: "8px", cursor: "pointer" }, onClick: function() { setMgrModalOpen(true); } }, h("div", { style: { fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: "11px", color: "#CD9602", width: "32px", letterSpacing: "1px", flexShrink: 0 } }, "MGR"), manager ? [manager.photo ? h("img", { key: "ico", src: manager.photo, alt: "", crossOrigin: "anonymous", style: { width: "30px", height: "30px", borderRadius: "50%", objectFit: "cover", flexShrink: 0, border: "1px solid rgba(205,150,2,0.4)" } }) : h("div", { key: "ico", style: { width: "28px", display: "flex", justifyContent: "center", color: "#CD9602" } }, managerIcon(18)), h("div", { key: "info", style: { flex: 1, overflow: "hidden" } }, h("div", { style: { color: "#F5EDD8", fontSize: "12px", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" } }, manager.name), h("div", { style: { fontSize: "10px", color: "rgba(245,237,216,0.45)", letterSpacing: "0.5px" } }, manager.nation + " · " + manager.style) )] : h("div", { style: { color: "rgba(245,237,216,0.35)", fontStyle: "italic", flex: 1, fontSize: "11px" } }, "tap to add manager") ), h("div", { style: isMobile ? {} : { overflowY: "auto", flex: 1, minHeight: 0, paddingRight: "2px" } }, isMobile ? h("div", { style: { display: "grid", gridTemplateColumns: "1fr 1fr", gap: "6px" } }, rosterPlayers.map(function(rp) { return h("div", { key: rp.slot.id, style: { display: "flex", alignItems: "center", gap: "8px", padding: "7px 10px", background: "rgba(255,255,255,0.03)", border: "1px solid rgba(255,255,255,0.06)", borderRadius: "8px" } }, h("div", { style: { fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: "10px", color: "#CD9602", width: "26px", letterSpacing: "1px" } }, rp.slot.pos), rp.player ? h("div", { style: { flex: 1, overflow: "hidden" } }, h("div", { style: { color: "#F5EDD8", fontSize: "11px", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" } }, rp.player.name), h("div", { style: { fontSize: "9px", color: "rgba(245,237,216,0.45)" } }, rp.player.rating) ) : h("div", { style: { color: "rgba(245,237,216,0.35)", fontStyle: "italic", fontSize: "11px" } }, "\u2014") ); }) ) : rosterPlayers.map(function(rp) { return h("div", { key: rp.slot.id, style: { display: "flex", alignItems: "center", gap: "10px", padding: "8px 10px", background: "rgba(255,255,255,0.03)", border: "1px solid rgba(255,255,255,0.06)", borderRadius: "8px", marginBottom: "6px", fontSize: "12px" } }, h("div", { style: { fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: "12px", color: "#CD9602", width: "32px", letterSpacing: "1px" } }, rp.slot.pos), rp.player ? [h("div", { key: "r", style: { fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: "18px", color: "#CD9602", width: "28px" } }, rp.player.rating), h("div", { key: "n", style: { flex: 1, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" } }, h("div", { style: { color: "#F5EDD8" } }, rp.player.name), h("div", { style: { fontSize: "10px", color: "rgba(245,237,216,0.45)", letterSpacing: "0.5px" } }, rp.player.nation + " \u00B7 " + rp.player.club) )] : h("div", { style: { color: "rgba(245,237,216,0.4)", fontStyle: "italic", flex: 1 } }, "empty") ); }) ) ), // ── Lineup actions — pinned below the squad, always visible (no scroll needed) h("div", { style: { display: "flex", flexDirection: "column", gap: "8px", flexShrink: 0 } }, h("div", { style: { display: "flex", gap: "8px" } }, h("button", { onClick: openSaveLineup, disabled: filledCount === 0, style: { flex: 1, padding: "12px", background: filledCount === 0 ? "rgba(255,255,255,0.05)" : "linear-gradient(155deg, #E8B83A, #CD9602)", color: filledCount === 0 ? "rgba(245,237,216,0.35)" : "#1A0428", border: "none", borderRadius: "10px", cursor: filledCount === 0 ? "not-allowed" : "pointer", fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: "16px", letterSpacing: "2px", boxShadow: filledCount === 0 ? "none" : "0 6px 18px rgba(205,150,2,0.35)" } }, "SAVE LINEUP"), filledCount > 0 && h("button", { onClick: function() { setConfirmReset(true); }, title: "Reset lineup", style: { width: "48px", flexShrink: 0, borderRadius: "10px", cursor: "pointer", display: "flex", alignItems: "center", justifyContent: "center", border: "1px solid rgba(245,237,216,0.25)", background: "rgba(255,255,255,0.05)", color: "#F5EDD8" } }, h("svg", { width: 19, height: 19, viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 2, strokeLinecap: "round", strokeLinejoin: "round" }, h("path", { d: "M3 12a9 9 0 1 0 3-6.7L3 8" }), h("path", { d: "M3 4v4h4" }))) ), h("button", { onClick: function() { setExportOpen(true); }, disabled: filledCount === 0, style: { width: "100%", padding: "11px", background: "rgba(255,255,255,0.05)", color: filledCount === 0 ? "rgba(245,237,216,0.35)" : "#F5EDD8", border: "1px solid rgba(245,237,216,0.25)", borderRadius: "10px", cursor: filledCount === 0 ? "not-allowed" : "pointer", fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: "15px", letterSpacing: "2px" } }, "SAVE IMAGE"), luToast && h("div", { style: { marginTop: "2px", textAlign: "center", fontFamily: "'Anton', 'Bebas Neue', sans-serif", letterSpacing: "1.5px", fontSize: "13px", color: luToast.charAt(0) === "✓" ? "#9be8a8" : "#ff8a96" } }, luToast) ) ), // Modals modal && modal.type === "search" && h(SearchModal, { slot: modal.slot, players: searchPool, onClose: function() { setModal(null); }, onPick: pickPlayer, assignedIds: Object.keys(assignments).filter(function(sid) { return sid !== modal.slot.id; }).map(function(sid) { return assignments[sid]; }), onCreateCustom: goToCardMaker ? function() { setModal(null); goToCardMaker(); } : null }), modal && modal.type === "stats" && h(StatsModal, { slot: modal.slot, player: getPlayer(assignments[modal.slot.id]), chem: squad.chemBySlot[modal.slot.id], onClose: function() { setModal(null); }, onReplace: replaceFromSlot, onRemove: removeFromSlot, captain: captain, onToggleCaptain: toggleCaptain }), exportOpen && h(ExportModal, { formation: formation, assignments: assignments, teamRating: teamRating, manager: manager, captain: captain, onClose: function() { setExportOpen(false); }, onExported: onLineupExported }), confirmReset && h("div", { style: { position: "fixed", inset: 0, background: "rgba(8,1,15,0.72)", backdropFilter: "blur(6px)", display: "flex", alignItems: "center", justifyContent: "center", zIndex: 130, padding: "24px" }, onClick: function() { setConfirmReset(false); } }, h("div", { style: { width: "min(380px, 100%)", background: "linear-gradient(180deg, #2F0745 0%, #1A0428 100%)", border: "1px solid rgba(205,150,2,0.45)", borderRadius: "16px", boxShadow: "0 30px 80px rgba(0,0,0,0.7)", overflow: "hidden", textAlign: "center" }, onClick: function(e) { e.stopPropagation(); } }, h("div", { style: { padding: "26px 26px 8px", fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: "24px", letterSpacing: "1.5px", color: "#F5EDD8", lineHeight: 1.1 } }, "RESET LINE-UP?"), h("div", { style: { padding: "0 26px 22px", fontSize: "13px", color: "rgba(245,237,216,0.7)", lineHeight: 1.5 } }, "Are you sure you want to reset your entire line-up? This clears all your players, your captain, and the manager."), h("div", { style: { display: "flex", gap: "10px", padding: "0 22px 22px" } }, h("button", { onClick: function() { setConfirmReset(false); }, style: { flex: 1, padding: "12px", borderRadius: "10px", cursor: "pointer", border: "1px solid rgba(245,237,216,0.25)", background: "rgba(255,255,255,0.05)", color: "#F5EDD8", fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: "15px", letterSpacing: "1.5px" } }, "CANCEL"), h("button", { onClick: function() { setConfirmReset(false); resetLineup(); }, style: { flex: 1, padding: "12px", borderRadius: "10px", cursor: "pointer", border: "none", background: "linear-gradient(155deg, #ff7a7a, #e23d3d)", color: "#fff", fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: "15px", letterSpacing: "1.5px", boxShadow: "0 6px 18px rgba(226,61,61,0.35)" } }, "RESET") ) ) ), moreFormationsOpen && h(MoreFormationsModal, { gameMode: gameMode, currentKey: formationKey, onSelect: setFormationKey, onClose: function() { setMoreFormationsOpen(false); } }), mgrModalOpen && h(ManagerModal, { manager: manager, onSave: setManager, onClose: function() { setMgrModalOpen(false); } }), saveLuOpen && h("div", { style: { position: "fixed", inset: 0, background: "rgba(8,1,15,0.7)", backdropFilter: "blur(6px)", display: "flex", alignItems: "center", justifyContent: "center", zIndex: 120, padding: "24px" }, onClick: function() { setSaveLuOpen(false); } }, h("div", { style: { width: "min(420px, 100%)", background: "linear-gradient(180deg, #2F0745 0%, #1A0428 100%)", border: "1px solid rgba(205,150,2,0.45)", borderRadius: "16px", boxShadow: "0 30px 80px rgba(0,0,0,0.7)", overflow: "hidden" }, onClick: function(e) { e.stopPropagation(); } }, h("div", { style: { padding: "20px 24px", borderBottom: "1px solid rgba(205,150,2,0.25)", fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: "22px", letterSpacing: "2px", color: "#F5EDD8" } }, currentLineup ? "SAVE LINEUP" : "SAVE NEW LINEUP"), h("div", { style: { padding: "20px 24px", display: "flex", flexDirection: "column", gap: "14px" } }, currentLineup && h("div", { style: { fontSize: "12px", color: "rgba(245,237,216,0.6)", lineHeight: 1.5, marginTop: "-2px" } }, "Editing “", h("span", { style: { color: "#CD9602" } }, currentLineup.name || "Untitled"), "”. Save your changes to it, or save a copy as a new lineup."), h("label", { style: { fontSize: "10px", letterSpacing: "1.5px", color: "rgba(245,237,216,0.55)", textTransform: "uppercase" } }, "Lineup name"), h("input", { style: { width: "100%", boxSizing: "border-box", background: "rgba(0,0,0,0.3)", border: "1px solid rgba(205,150,2,0.3)", borderRadius: "10px", padding: "12px 16px", color: "#F5EDD8", fontSize: "16px", outline: "none", fontFamily: "inherit" }, placeholder: formation.name + " · " + gameMode, value: luName, onChange: function(e) { setLuName(e.target.value); }, onKeyDown: function(e) { if (e.key === "Enter") confirmSaveLineup(!currentLineup); } }), currentLineup ? h("div", { style: { display: "flex", flexDirection: "column", gap: "8px", marginTop: "4px" } }, h("button", { disabled: luBusy, style: { width: "100%", background: luBusy ? "rgba(205,150,2,0.5)" : "linear-gradient(155deg, #E8B83A, #CD9602)", color: "#1A0428", fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: "15px", letterSpacing: "2px", padding: "12px", border: "none", borderRadius: "10px", cursor: luBusy ? "default" : "pointer" }, onClick: function() { confirmSaveLineup(false); } }, luBusy ? "SAVING…" : "SAVE CHANGES"), h("div", { style: { display: "flex", gap: "10px" } }, h("button", { style: { flex: 1, background: "transparent", border: "1px solid rgba(245,237,216,0.3)", color: "#F5EDD8", fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: "15px", letterSpacing: "2px", padding: "12px", borderRadius: "10px", cursor: "pointer" }, onClick: function() { setSaveLuOpen(false); } }, "CANCEL"), h("button", { disabled: luBusy, style: { flex: 1, background: "rgba(255,255,255,0.05)", border: "1px solid rgba(205,150,2,0.4)", color: "#CD9602", fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: "15px", letterSpacing: "1.5px", padding: "12px", borderRadius: "10px", cursor: luBusy ? "default" : "pointer" }, onClick: function() { confirmSaveLineup(true); } }, "SAVE AS NEW") ) ) : h("div", { style: { display: "flex", gap: "10px", marginTop: "4px" } }, h("button", { style: { flex: 1, background: "transparent", border: "1px solid rgba(245,237,216,0.3)", color: "#F5EDD8", fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: "15px", letterSpacing: "2px", padding: "12px", borderRadius: "10px", cursor: "pointer" }, onClick: function() { setSaveLuOpen(false); } }, "CANCEL"), h("button", { disabled: luBusy, style: { flex: 2, background: luBusy ? "rgba(205,150,2,0.5)" : "linear-gradient(155deg, #E8B83A, #CD9602)", color: "#1A0428", fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: "15px", letterSpacing: "2px", padding: "12px", border: "none", borderRadius: "10px", cursor: luBusy ? "default" : "pointer" }, onClick: function() { confirmSaveLineup(true); } }, luBusy ? "SAVING…" : "SAVE") ) ) ) ), // Floating drag ghost following the pointer while swapping players drag && h("div", { style: { position: "fixed", left: drag.x + "px", top: drag.y + "px", transform: "translate(-50%, -50%) scale(1.15)", pointerEvents: "none", zIndex: 999, opacity: 0.92, filter: "drop-shadow(0 12px 20px rgba(0,0,0,0.7))" } }, h(MiniCard, { player: getPlayer(drag.pid), rarity: "gold" }) ) ); } // ─── Card: Export Canvas ──────────────────────────────────────────────────── function CardExportCanvas(props) { var bgId = props.bgId, cardData = props.cardData, rarity = props.rarity, showBranding = props.showBranding, logoSrc = props.logoSrc; var isTransparent = bgId === "transparent"; var bg = CARD_BACKGROUNDS.find(function(b) { return b.id === bgId; }) || BACKGROUNDS[0]; var isLight = bgId === "minimal"; var inkPrimary = isLight ? "#2F0745" : "#F5EDD8"; var inkAccent = isLight ? "#7a4a00" : "#E8B83A"; var rarityName = (RARITIES.find(function(x) { return x.id === rarity; }) || RARITIES[0]).name; var logoOk = !!(logoSrc && logoSrc.indexOf("data:") === 0); return h("div", { id: "card-export-canvas", style: { width: EXPORT_W + "px", height: EXPORT_H + "px", position: "relative", overflow: "hidden", fontFamily: "'Inter', sans-serif", color: inkPrimary } }, bg.render(), // Header — logo + rarity tag (hidden in transparent / card-only mode) !isTransparent && h("div", { style: { position: "absolute", top: 0, left: 0, right: 0, padding: "48px 64px 0", display: "flex", flexDirection: "column", alignItems: "center", textAlign: "center", gap: "14px", zIndex: 2 } }, logoOk ? h("img", { src: logoSrc, alt: "Hometeam", style: { height: "52px", width: "200px", display: "block", objectFit: "contain", objectPosition: "center center" } }) : brandMark(inkPrimary, 36), h("div", { style: { fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: "18px", letterSpacing: "6px", color: inkAccent, opacity: 0.9 } }, rarityName + " CARD") ), // Centered enlarged card h("div", { style: { position: "absolute", inset: 0, display: "flex", alignItems: "center", justifyContent: "center", zIndex: 2 } }, h("div", { style: { transform: "scale(2.45)", transformOrigin: "center center" } }, h(PlayerCard, { player: cardData, rarity: rarity }) ) ), // Footer — branding (hidden in transparent / card-only mode) !isTransparent && showBranding && h("div", { style: { position: "absolute", bottom: 0, left: 0, right: 0, padding: "0 64px 50px", display: "flex", alignItems: "center", justifyContent: "space-between", zIndex: 2 } }, logoOk ? h("img", { src: logoSrc, alt: "Hometeam", style: { height: "36px", width: "138px", display: "block", flexShrink: 0, objectFit: "contain", objectPosition: "left center", opacity: 0.9 } }) : brandMark(inkAccent, 26), h("div", { style: { fontSize: "20px", letterSpacing: "1px", color: inkAccent, fontFamily: "'Anton', 'Bebas Neue', sans-serif", whiteSpace: "nowrap", flex: 1, textAlign: "right", marginLeft: "16px" } }, "#BUILDYOURXI") ) ); } // ─── Card: Export Modal ───────────────────────────────────────────────────── function CardExportModal(props) { var isMobile = useWindowSize().w < 768; var cardData = props.cardData, rarity = props.rarity, onClose = props.onClose, onExported = props.onExported; var _bgId = useState("stadium"), bgId = _bgId[0], setBgId = _bgId[1]; var _sb = useState(true), showBranding = _sb[0], setShowBranding = _sb[1]; var _busy = useState(false), busy = _busy[0], setBusy = _busy[1]; var _toast = useState(null), toast = _toast[0], setToast = _toast[1]; var _scale = useState(0.4), scale = _scale[0], setScale = _scale[1]; var _logoSrc = useState(_logoDataUrlCache), logoSrc = _logoSrc[0], setLogoSrc = _logoSrc[1]; var _ink = useState(0), setInlineTick = _ink[1]; useEffect(function() { fetchLogoDataUrl().then(function(url) { setLogoSrc(url); }); }, []); useEffect(function() { function fit() { var wrap = document.getElementById("card-ex-preview-wrap"); if (!wrap) return; var s = Math.min((wrap.clientWidth - 40) / EXPORT_W, (wrap.clientHeight - 40) / EXPORT_H); setScale(Math.max(0.15, Math.min(0.7, s))); } fit(); window.addEventListener("resize", fit); return function() { window.removeEventListener("resize", fit); }; }, []); var showToastMsg = function(msg) { setToast(msg); setTimeout(function() { setToast(null); }, 2200); }; var fileName = function() { return (displayName(cardData) || "player-card").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") + "-card.png"; }; var generateBlob = function() { if (!window.htmlToImage) return Promise.reject(new Error("Export library not loaded")); // Ensure the portrait + flag are embedded data URLs (a saved card's portrait can be // a cross-origin Storage URL that blanks in the PNG; the flag may still be a raw // flagcdn URL), then re-render so the canvas picks them up before rasterizing. // Also wait for the HOME TEAM logo (same first-export-on-mobile branding race as the lineup export). var logoJob = fetchLogoDataUrl().then(function(url) { if (isDataUrl(url)) setLogoSrc(url); }); return Promise.all([inlineExportImages(cardData), logoJob]).then(function() { setInlineTick(function(t) { return t + 1; }); return afterPaintOrTimeout(450); }).then(function() { var node = document.getElementById("card-export-canvas"); if (!node) throw new Error("Canvas not found"); var opts = { pixelRatio: 1, cacheBust: true }; if (bgId !== "transparent") opts.backgroundColor = "#1A0428"; // wait for the portrait/flag/logo to fully decode, then rasterize (twice on mobile // to defeat iOS Safari's first-pass blank-image quirk — fixes the uploaded photo // dropping out of the saved card on phones). return waitForImages(node).then(function() { return reliableToBlob(node, opts, isMobile); }); }); }; var handleDownload = function() { setBusy(true); generateBlob().then(function(blob) { if (!blob) throw new Error("Failed"); var url = URL.createObjectURL(blob); var a = document.createElement("a"); a.href = url; a.download = fileName(); document.body.appendChild(a); a.click(); document.body.removeChild(a); setTimeout(function() { URL.revokeObjectURL(url); }, 1000); showToastMsg("✓ DOWNLOADED"); if (onExported) onExported(); }).catch(function() { showToastMsg("⚠ EXPORT FAILED"); }).finally(function() { setBusy(false); }); }; var handleShare = function() { setBusy(true); generateBlob().then(function(blob) { if (!blob) throw new Error("Failed"); var file = new File([blob], fileName(), { type: "image/png" }); if (navigator.canShare && navigator.canShare({ files: [file] })) { return navigator.share({ files: [file], title: cardData.name }).then(function() { showToastMsg("✓ IMAGE SAVED"); if (onExported) onExported(); }); } else if (navigator.clipboard && window.ClipboardItem) { return navigator.clipboard.write([new ClipboardItem({ "image/png": blob })]).then(function() { showToastMsg("✓ COPIED TO CLIPBOARD"); if (onExported) onExported(); }); } else { showToastMsg("SHARING NOT SUPPORTED"); if (onExported) onExported(); } }).catch(function(e) { if (e && e.name !== "AbortError") showToastMsg("⚠ SHARE FAILED"); }).finally(function() { setBusy(false); }); }; var modalS = isMobile ? { width: "min(1100px, 100%)", maxHeight: "95vh", background: "linear-gradient(180deg, #2F0745 0%, #1A0428 100%)", border: "1px solid rgba(205,150,2,0.45)", borderRadius: "16px", boxShadow: "0 30px 80px rgba(0,0,0,0.7)", display: "flex", flexDirection: "column", overflow: "hidden" } : { width: "min(1100px, 100%)", maxHeight: "92vh", background: "linear-gradient(180deg, #2F0745 0%, #1A0428 100%)", border: "1px solid rgba(205,150,2,0.45)", borderRadius: "16px", boxShadow: "0 30px 80px rgba(0,0,0,0.7)", display: "grid", gridTemplateRows: "auto 1fr auto", overflow: "hidden" }; var btnS = { fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: "14px", letterSpacing: "2px", padding: "10px 22px", borderRadius: "8px", cursor: "pointer", border: "1px solid transparent", display: "inline-flex", alignItems: "center", justifyContent: "center", gap: "8px" }; var shareStyle = isMobile ? Object.assign({}, btnS, { background: "linear-gradient(155deg, #E8B83A, #CD9602)", color: "#1A0428", boxShadow: "0 6px 18px rgba(205,150,2,0.35)", opacity: busy ? 0.5 : 1, flex: 1 }) : Object.assign({}, btnS, { background: "transparent", color: "#F5EDD8", border: "1px solid rgba(245,237,216,0.3)", opacity: busy ? 0.5 : 1 }); var dlStyle = isMobile ? Object.assign({}, btnS, { background: "transparent", color: "#F5EDD8", border: "1px solid rgba(245,237,216,0.3)", opacity: busy ? 0.5 : 1, flex: 1 }) : Object.assign({}, btnS, { background: "linear-gradient(155deg, #E8B83A, #CD9602)", color: "#1A0428", boxShadow: "0 6px 18px rgba(205,150,2,0.35)", opacity: busy ? 0.5 : 1 }); return h("div", { style: { position: "fixed", inset: 0, background: "rgba(8,1,15,0.82)", backdropFilter: "blur(8px)", display: "flex", alignItems: "center", justifyContent: "center", zIndex: 200, padding: isMobile ? "12px" : "24px" }, onClick: onClose }, h("div", { style: modalS, onClick: function(e) { e.stopPropagation(); } }, h("div", { style: { padding: "18px 24px", borderBottom: "1px solid rgba(205,150,2,0.2)", display: "flex", alignItems: "center", justifyContent: "space-between" } }, h("div", null, h("div", { style: { fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: "24px", letterSpacing: "2.5px", color: "#F5EDD8" } }, "SAVE CARD"), h("div", { style: { fontSize: "11px", color: "rgba(245,237,216,0.55)", letterSpacing: "1.5px", marginTop: "2px" } }, "1080 × 1350 · STORY-READY") ), h("button", { style: { width: "32px", height: "32px", borderRadius: "50%", background: "rgba(255,255,255,0.06)", border: "1px solid rgba(255,255,255,0.1)", color: "#F5EDD8", cursor: "pointer", fontSize: "18px", lineHeight: 1 }, onClick: onClose }, "×") ), h("div", { style: isMobile ? { display: "flex", flexDirection: "column", gap: "14px", padding: "14px 16px", overflowY: "auto" } : { display: "grid", gridTemplateColumns: "1fr 280px", gap: "24px", padding: "20px 24px", overflow: "hidden" } }, h("div", { id: "card-ex-preview-wrap", style: Object.assign({ borderRadius: "12px", padding: isMobile ? "10px" : "20px", display: "flex", alignItems: "center", justifyContent: "center", overflow: "hidden" }, isMobile ? { height: "300px", flexShrink: 0 } : { minHeight: 0 }, bgId === "transparent" ? { background: "repeating-conic-gradient(#2b2b33 0% 25%, #1d1d23 0% 50%) 0 0 / 22px 22px" } : { background: "rgba(0,0,0,0.4)" }) }, h("div", { style: { transformOrigin: "center center", transform: "scale(" + scale + ")" } }, h(CardExportCanvas, { bgId: bgId, cardData: cardData, rarity: rarity, showBranding: showBranding, logoSrc: logoSrc }) ) ), h("div", { style: { display: "flex", flexDirection: "column", gap: "18px", overflowY: isMobile ? "visible" : "auto" } }, h("div", null, h("div", { style: { fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: "12px", letterSpacing: "2.5px", color: "#CD9602", marginBottom: "10px" } }, "BACKGROUND"), h("div", { style: { display: "grid", gridTemplateColumns: "1fr 1fr", gap: "8px" } }, CARD_BACKGROUNDS.map(function(b) { var active = bgId === b.id; return h("div", { key: b.id, style: { height: "62px", borderRadius: "10px", cursor: "pointer", background: b.swatch, border: active ? "2px solid #E8B83A" : "2px solid rgba(255,255,255,0.08)", boxShadow: active ? "0 0 0 3px rgba(232,184,58,0.25)" : "none", position: "relative", overflow: "hidden" }, onClick: function() { setBgId(b.id); } }, h("div", { style: { position: "absolute", bottom: "4px", left: "6px", fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: "10px", letterSpacing: "1px", color: "rgba(255,255,255,0.85)", textShadow: "0 1px 2px rgba(0,0,0,0.7)" } }, b.name) ); }) ) ), h("div", null, h("div", { style: { fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: "12px", letterSpacing: "2.5px", color: "#CD9602", marginBottom: "10px" } }, "OPTIONS"), h("div", { style: { display: "flex", alignItems: "center", justifyContent: "space-between", fontSize: "12px", color: "rgba(245,237,216,0.8)" } }, h("span", null, "Show branding"), h("div", { style: { width: "36px", height: "20px", borderRadius: "999px", background: showBranding ? "#CD9602" : "rgba(255,255,255,0.15)", position: "relative", cursor: "pointer" }, onClick: function() { setShowBranding(!showBranding); } }, h("div", { style: { position: "absolute", top: "2px", left: showBranding ? "18px" : "2px", width: "16px", height: "16px", borderRadius: "50%", background: "#fff", transition: "left 180ms ease", boxShadow: "0 1px 3px rgba(0,0,0,0.3)" } }) ) ) ) ) ), h("div", { style: { padding: isMobile ? "12px 16px" : "16px 24px", borderTop: "1px solid rgba(205,150,2,0.2)", display: "flex", flexDirection: isMobile ? "row-reverse" : "row", gap: "10px", justifyContent: "flex-end", alignItems: "center", flexShrink: 0 } }, h("button", { style: shareStyle, onClick: handleShare, disabled: busy }, isMobile ? "SAVE IMAGE" : "SHARE"), h("button", { style: dlStyle, onClick: handleDownload, disabled: busy }, busy ? "RENDERING…" : (isMobile ? "DOWNLOAD" : "DOWNLOAD PNG")) ) ), toast && h("div", { style: { position: "fixed", bottom: "32px", left: "50%", transform: "translateX(-50%)", background: "#1A0428", border: "1px solid #CD9602", color: "#F5EDD8", padding: "12px 20px", borderRadius: "10px", fontSize: "13px", letterSpacing: "1px", zIndex: 300, boxShadow: "0 10px 30px rgba(0,0,0,0.5)" } }, toast) ); } // ─── In-place portrait crop editor ────────────────────────────────────────── // Direct manipulation INSIDE the card's portrait window: drag the photo to move, // drag the corner/edge handles to scale uniformly. The part outside the frame is // shown ghosted (low opacity) so the user sees exactly what will be clipped. The // geometry reproduces the card's object-fit cover + object-position + zoom EXACTLY // (see portraitStyle), so the live card is WYSIWYG. Renders into PlayerCard's // portrait region via the `portraitOverlay` prop (region overflow goes visible). var CROP_HANDLES = [ { id: "tl", x: 0, y: 0, cur: "nwse-resize", corner: true }, { id: "tr", x: 1, y: 0, cur: "nesw-resize", corner: true }, { id: "bl", x: 0, y: 1, cur: "nesw-resize", corner: true }, { id: "br", x: 1, y: 1, cur: "nwse-resize", corner: true }, { id: "t", x: 0.5, y: 0, cur: "ns-resize" }, { id: "b", x: 0.5, y: 1, cur: "ns-resize" }, { id: "l", x: 0, y: 0.5, cur: "ew-resize" }, { id: "r", x: 1, y: 0.5, cur: "ew-resize" } ]; function cornerBracketStyle(hd, hovered) { var c = hovered ? "#FFE9A8" : "#F4D87A"; var s = { width: "15px", height: "15px", boxSizing: "border-box", boxShadow: "0 0 6px rgba(232,184,58,0.75)" }; s[hd.y === 0 ? "borderTop" : "borderBottom"] = "3px solid " + c; s[hd.x === 0 ? "borderLeft" : "borderRight"] = "3px solid " + c; return s; } function PortraitCropEditor(props) { var src = props.src, zoom = props.zoom || 1, px = props.px != null ? props.px : 50, py = props.py != null ? props.py : 50, onChange = props.onChange; var isMobile = props.isMobile; var rootRef = useRef(null), dragRef = useRef(null), ptrs = useRef({}), gestureRef = useRef(null); var _f = useState({ w: 0, h: 0 }), frame = _f[0], setFrame = _f[1]; var _n = useState({ w: 1, h: 1 }), nat = _n[0], setNat = _n[1]; var _h = useState(null), hover = _h[0], setHover = _h[1]; useEffect(function() { var ro = null; function measure() { if (rootRef.current) { var r = rootRef.current.getBoundingClientRect(); if (r.width) setFrame({ w: r.width, h: r.height }); } } measure(); if (window.ResizeObserver && rootRef.current) { ro = new ResizeObserver(measure); ro.observe(rootRef.current); } window.addEventListener("resize", measure); return function() { window.removeEventListener("resize", measure); if (ro) ro.disconnect(); }; }, []); var FW = frame.w, FH = frame.h; var IA = (nat.w / nat.h) || 1; var cover = Math.max(FW / IA, FH) || 0; // scale so the image COVERS the frame var RW = IA * cover * zoom, RH = cover * zoom; // rendered image size (px) var offX = (FW - RW) * px / 100, offY = (FH - RH) * py / 100; // == object-position function clampN(v) { return Math.max(0, Math.min(100, Math.round(v))); } // ── gesture surface: 1 finger / mouse = PAN, 2 fingers = PINCH-to-zoom (mobile), // mouse wheel = zoom (desktop). Multi-pointer tracking via a pointer-id map so a // pinch reads the live distance between both fingers — the same feel as a phone's // photo adjuster (the user asked for pinch, not handles, on mobile). var onDown = function(e) { ptrs.current[e.pointerId] = { x: e.clientX, y: e.clientY }; try { e.currentTarget.setPointerCapture(e.pointerId); } catch (_) {} var ids = Object.keys(ptrs.current); if (ids.length >= 2) { var a = ptrs.current[ids[0]], b = ptrs.current[ids[1]]; gestureRef.current = { mode: "pinch", z0: zoom, d0: Math.hypot(a.x - b.x, a.y - b.y) || 1 }; } else { gestureRef.current = { mode: "pan", sx: e.clientX, sy: e.clientY, px: px, py: py, RW: RW, RH: RH }; } e.stopPropagation(); }; var onMove = function(e) { if (!ptrs.current[e.pointerId]) return; ptrs.current[e.pointerId] = { x: e.clientX, y: e.clientY }; e.preventDefault(); var g = gestureRef.current; if (!g) return; var ids = Object.keys(ptrs.current); if (g.mode === "pinch" && ids.length >= 2) { var a = ptrs.current[ids[0]], b = ptrs.current[ids[1]]; var dist = Math.hypot(a.x - b.x, a.y - b.y) || 1; onChange({ portraitZoom: Math.round(Math.max(1, Math.min(4, g.z0 * dist / g.d0)) * 100) / 100 }); } else if (g.mode === "pan") { var dnx = FW - g.RW, dny = FH - g.RH; // negative (image bigger than frame) onChange({ portraitX: clampN(dnx ? g.px + (e.clientX - g.sx) * 100 / dnx : g.px), portraitY: clampN(dny ? g.py + (e.clientY - g.sy) * 100 / dny : g.py) }); } }; var onUp = function(e) { delete ptrs.current[e.pointerId]; var ids = Object.keys(ptrs.current); if (ids.length === 1) { // lifted one finger of a pinch → resume pan with the other var rem = ptrs.current[ids[0]]; gestureRef.current = { mode: "pan", sx: rem.x, sy: rem.y, px: px, py: py, RW: RW, RH: RH }; } else if (ids.length === 0) { gestureRef.current = null; } }; var onWheel = function(e) { // desktop convenience zoom e.preventDefault(); var nz = Math.max(1, Math.min(4, zoom * (e.deltaY < 0 ? 1.08 : 0.927))); onChange({ portraitZoom: Math.round(nz * 100) / 100 }); }; // desktop-only corner/edge handles: uniform scale about centre (distance from centre) var startScale = function(e) { var r = rootRef.current.getBoundingClientRect(); var fcx = r.left + r.width / 2, fcy = r.top + r.height / 2; dragRef.current = { z0: zoom, d0: Math.hypot(e.clientX - fcx, e.clientY - fcy) || 1, fcx: fcx, fcy: fcy }; try { e.currentTarget.setPointerCapture(e.pointerId); } catch (_) {} e.stopPropagation(); }; var scaleMove = function(e) { var d = dragRef.current; if (!d) return; e.preventDefault(); var dist = Math.hypot(e.clientX - d.fcx, e.clientY - d.fcy) || 1; onChange({ portraitZoom: Math.round(Math.max(1, Math.min(4, d.z0 * dist / d.d0)) * 100) / 100 }); }; var scaleEnd = function() { dragRef.current = null; }; var imgStyle = { position: "absolute", left: offX + "px", top: offY + "px", width: RW + "px", height: RH + "px", maxWidth: "none", display: "block" }; return h("div", { ref: rootRef, style: { position: "absolute", inset: 0 } }, h("img", { src: src, alt: "", crossOrigin: "anonymous", draggable: false, onLoad: function(e) { setNat({ w: e.target.naturalWidth || 1, h: e.target.naturalHeight || 1 }); }, style: Object.assign({}, imgStyle, { opacity: 0.3, pointerEvents: "none" }) }), h("div", { style: { position: "absolute", inset: 0, overflow: "hidden" } }, h("img", { src: src, alt: "", crossOrigin: "anonymous", draggable: false, style: imgStyle }) ), h("div", { onPointerDown: onDown, onPointerMove: onMove, onPointerUp: onUp, onPointerCancel: onUp, onWheel: onWheel, style: { position: "absolute", inset: 0, cursor: "move", touchAction: "none", zIndex: 2 } }), h("div", { style: { position: "absolute", inset: 0, border: "2px solid rgba(255,233,168,0.95)", boxShadow: "0 0 16px rgba(232,184,58,0.55), inset 0 0 0 1px rgba(0,0,0,0.25)", pointerEvents: "none", zIndex: 3 } }), !isMobile && CROP_HANDLES.map(function(hd) { var hovered = hover === hd.id; var edgeW = hd.corner ? null : (hd.x === 0.5 ? "24px" : "6px"); var edgeH = hd.corner ? null : (hd.x === 0.5 ? "6px" : "24px"); return h("div", { key: hd.id, onPointerDown: startScale, onPointerMove: scaleMove, onPointerUp: scaleEnd, onPointerCancel: scaleEnd, onPointerEnter: function() { setHover(hd.id); }, onPointerLeave: function() { setHover(null); }, style: { position: "absolute", left: (hd.x * 100) + "%", top: (hd.y * 100) + "%", transform: "translate(-50%,-50%)" + (hovered ? " scale(1.3)" : ""), width: "28px", height: "28px", display: "flex", alignItems: "center", justifyContent: "center", cursor: hd.cur, touchAction: "none", zIndex: 4, transition: "transform 90ms ease" } }, hd.corner ? h("div", { style: cornerBracketStyle(hd, hovered) }) : h("div", { style: { width: edgeW, height: edgeH, background: hovered ? "#FFE9A8" : "#F4D87A", borderRadius: "3px", boxShadow: "0 0 0 1px rgba(26,4,40,0.85), 0 0 6px rgba(232,184,58,0.6)" } }) ); }) ); } // ═══════════════════════════════════════════════════════════════════════════════ // // ██████╗ █████╗ ██████╗ ██████╗ // ██╔════╝██╔══██╗██╔══██╗██╔══██╗ // ██║ ███████║██████╔╝██║ ██║ // ██║ ██╔══██║██╔══██╗██║ ██║ // ╚██████╗██║ ██║██║ ██║██████╔╝ // ╚═════╝╚═╝ ╚═╝╚═╝ ╚═╝╚═════╝ // // CARD BUILDER — Live card preview + customization form // // ═══════════════════════════════════════════════════════════════════════════════ function CardBuilder(props) { props = props || {}; var session = props.session, requireAuth = props.requireAuth, refreshMyPlayers = props.refreshMyPlayers, myPlayers = props.myPlayers || []; var pendingCard = props.pendingCard, clearPendingCard = props.clearPendingCard, onCardExported = props.onCardExported; var isMobile = useWindowSize().w < 768; var _r = useState("gold"), rarity = _r[0], setRarity = _r[1]; var _d = useState({ first: "Tyler", last: "Williams", pos: "ST", rating: 88, nation: "ENG", club: "LONDON CITY", pace: 88, sho: 90, pas: 78, dri: 86, def: 42, phy: 80, portrait: null, trait: null }), data = _d[0], setData = _d[1]; var _exp = useState(false), exportOpen = _exp[0], setExportOpen = _exp[1]; var _edit = useState(false), editing = _edit[0], setEditing = _edit[1]; var _editId = useState(null), editingId = _editId[0], setEditingId = _editId[1]; var _saving = useState(false), saving = _saving[0], setSaving = _saving[1]; var _saveMsg = useState(""), saveMsg = _saveMsg[0], setSaveMsg = _saveMsg[1]; var fileRef = useRef(null); var _bgB = useState(false), bgBusy = _bgB[0], setBgBusy = _bgB[1]; // background-removal in progress var _bgO = useState(null), bgOrig = _bgO[0], setBgOrig = _bgO[1]; // pre-removal portrait (for UNDO) var _bgE = useState(""), bgErr = _bgE[0], setBgErr = _bgE[1]; // inline error message // Save the current card to the user's account (a card == a custom player). var saveToAccount = function() { if (!session) { if (requireAuth) requireAuth(); return; } if (!sb) return; setSaving(true); setSaveMsg(""); var uid = session.user.id; var rowId = editingId || (window.crypto && window.crypto.randomUUID ? window.crypto.randomUUID() : null); var portrait = data.portrait; var ensurePortrait = Promise.resolve(portrait); // Freshly-picked (base64) portrait: downscale, then try Storage; if the bucket // upload is rejected, fall back to storing the compressed image inline so the // portrait still persists. Existing https URLs are kept as-is. if (portrait && portrait.indexOf("data:") === 0) { var path = uid + "/" + (rowId || "card") + ".png"; ensurePortrait = prepImage(portrait, 512).then(function(small) { return sb.storage.from("portraits").upload(path, dataUrlToBlob(small), { upsert: true, contentType: "image/png" }) .then(function(res) { if (res.error) return small; // Storage unavailable → persist inline return sb.storage.from("portraits").getPublicUrl(path).data.publicUrl + "?v=" + Date.now(); }, function() { return small; }); }); } ensurePortrait.then(function(portraitUrl) { var row = { user_id: uid, first: data.first || "", last: data.last || "", pos: data.pos, rating: data.rating || 0, nation: data.nation, club: data.club, rarity: rarity, pace: data.pace, sho: data.sho, pas: data.pas, dri: data.dri, def: data.def, phy: data.phy, trait: data.trait || null, gk: data.gk || null, portrait_url: portraitUrl || null, portrait_zoom: data.portraitZoom != null ? data.portraitZoom : null, portrait_x: data.portraitX != null ? data.portraitX : null, portrait_y: data.portraitY != null ? data.portraitY : null, updated_at: new Date().toISOString() }; if (rowId) row.id = rowId; return sb.from("players").upsert(row).select().single(); }).then(function(res) { setSaving(false); if (res.error) { setSaveMsg("Couldn't save — " + res.error.message); return; } if (res.data) setEditingId(res.data.id); if (refreshMyPlayers) refreshMyPlayers(); setSaveMsg("✓ SAVED TO MY PLAYERS"); setTimeout(function() { setSaveMsg(""); }, 2600); }, function(e) { setSaving(false); setSaveMsg("Couldn't save — " + ((e && e.message) || "error")); }); }; // Load a saved player back into the builder for editing. var loadCard = function(p) { setRarity(p.rarity || "gold"); setData({ first: p.first || "", last: p.last || "", pos: p.pos, rating: p.rating, nation: p.nation, club: p.club, pace: p.pace, sho: p.sho, pas: p.pas, dri: p.dri, def: p.def, phy: p.phy, portrait: p.portrait || null, trait: p.trait || null, portraitZoom: p.portraitZoom, portraitX: p.portraitX, portraitY: p.portraitY, gk: p.gk }); setEditingId(p.id); }; // Load a card requested from the dashboard (then clear the request). useEffect(function() { if (pendingCard) { loadCard(pendingCard); if (clearPendingCard) clearPendingCard(); } }, [pendingCard]); // Derive the country flag from the selected nation code (the picker only sets the // code). Shows the flagcdn flag instantly, then upgrades to a data URL so it embeds // in the export. Unknown codes clear the flag → the card falls back to the text code. useEffect(function() { var code = data.nation; var iso2 = CODE_TO_ISO2[code]; if (!iso2) { if (data.nationFlag || data._iso2) setData(function(p) { return Object.assign({}, p, { nationFlag: null, _iso2: null }); }); return; } var raw = flagcdnUrl(iso2); setData(function(p) { return Object.assign({}, p, { _iso2: iso2, nationFlag: (p.nationFlag && p._iso2 === iso2) ? p.nationFlag : raw }); }); proxyImage(raw).then(function(du) { if (isDataUrl(du)) setData(function(p) { return p._iso2 === iso2 ? Object.assign({}, p, { nationFlag: du }) : p; }); }); }, [data.nation]); var set = function(k, v) { setData(function(prev) { var n = Object.assign({}, prev); n[k] = v; return n; }); }; var recalc = function() { var v = Math.round((data.pace + data.sho + data.pas + data.dri + data.def + data.phy) / 6); set("rating", v); }; var onPickImage = function(e) { var file = e.target.files && e.target.files[0]; if (!file) return; var reader = new FileReader(); reader.onload = function(ev) { setBgOrig(null); setBgErr(""); // a fresh image hasn't had its background removed // Downscale to 720px before storing. A full-res phone photo is a multi-MB data // URL that silently fails to rasterize in the export on iOS Safari (canvas/memory // limits) — the card renders but the portrait drops out. 720px stays crisp at the // card's export size while keeping the data URL small + export-safe. prepImage(ev.target.result, 720).then(function(small) { setData(function(prev) { return Object.assign({}, prev, { portrait: small || ev.target.result, portraitZoom: 1, portraitX: 50, portraitY: 50 }); }); }); }; reader.readAsDataURL(file); e.target.value = ""; }; // Remove the portrait's background via the withoutbg-proxy edge function (key stays // server-side). Keeps the crop (zoom/pan); stashes the original so UNDO can restore it. var removeBg = function() { if (bgBusy || !data.portrait || !sb) { if (!sb) setBgErr("Background removal is unavailable right now."); return; } var orig = data.portrait; setBgErr(""); setBgBusy(true); sb.functions.invoke("withoutbg-proxy", { body: { image: orig } }).then(function(res) { setBgBusy(false); var du = res && res.data && res.data.dataUrl; var coded = res && res.data && res.data.error; if (du && String(du).indexOf("data:") === 0) { setBgOrig(orig); setData(function(p) { return Object.assign({}, p, { portrait: du }); }); } else { setBgErr(coded === "rate_limited" ? "Too many requests — wait a minute and try again." : coded === "out_of_credits" ? "Background-removal credits are used up." : coded === "bad_image" ? "Couldn't read that image — try a different photo." : coded === "image_too_large" ? "That image is too large — upload a smaller one." : "Couldn't remove the background — try again."); } }, function() { setBgBusy(false); setBgErr("Couldn't remove the background — try again."); }); }; var restoreBg = function() { if (!bgOrig) return; var o = bgOrig; setBgOrig(null); setBgErr(""); setData(function(p) { return Object.assign({}, p, { portrait: o }); }); }; // Edit-mode overlay rendered INSIDE the card's portrait window (see PlayerCard's // `portraitOverlay` prop): empty → centered "ADD PHOTO"; has photo & idle → edit // pencil (top-left); editing → the crop editor + DONE (top-left) + DELETE (top-right). var pencilIcon = function(size) { return h("svg", { width: size, height: size, viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 2, strokeLinecap: "round", strokeLinejoin: "round" }, h("path", { d: "M12 20h9" }), h("path", { d: "M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4 12.5-12.5z" }) ); }; var cornerBtnStyle = function(corner, variant) { var s = { position: "absolute", width: "30px", height: "30px", borderRadius: "50%", display: "inline-flex", alignItems: "center", justifyContent: "center", cursor: "pointer", zIndex: 8, border: "2px solid #1A0428", boxShadow: "0 4px 12px rgba(0,0,0,0.5)", padding: 0, background: variant === "delete" ? "linear-gradient(155deg,#ff6b76,#d3303d)" : "linear-gradient(155deg,#E8B83A,#CD9602)", color: variant === "delete" ? "#fff" : "#1A0428" }; if (corner === "tl") { s.top = "8px"; s.left = "8px"; } else { s.top = "8px"; s.right = "8px"; } return s; }; var checkIcon = function(size) { return h("svg", { width: size, height: size, viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 3, strokeLinecap: "round", strokeLinejoin: "round" }, h("polyline", { points: "20 6 9 17 4 12" })); }; var trashIcon = function(size) { return h("svg", { width: size, height: size, viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 2, strokeLinecap: "round", strokeLinejoin: "round" }, h("polyline", { points: "3 6 5 6 21 6" }), h("path", { d: "M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6" }), h("path", { d: "M10 11v6M14 11v6" }), h("path", { d: "M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2" })); }; var plusIcon = function(size) { return h("svg", { width: size, height: size, viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 2.5, strokeLinecap: "round" }, h("line", { x1: 12, y1: 5, x2: 12, y2: 19 }), h("line", { x1: 5, y1: 12, x2: 19, y2: 12 })); }; var portraitOverlay = (function() { if (!data.portrait) { return h("div", { style: { position: "absolute", inset: 0, display: "flex", alignItems: "center", justifyContent: "center", zIndex: 7 } }, h("button", { onClick: function() { if (fileRef.current) fileRef.current.click(); }, style: { display: "inline-flex", flexDirection: "column", alignItems: "center", gap: "6px", background: "rgba(12,2,20,0.55)", border: "1px dashed rgba(232,184,58,0.7)", color: "#F4D87A", borderRadius: "12px", padding: "14px 18px", cursor: "pointer", fontFamily: "'Anton', 'Bebas Neue', sans-serif", letterSpacing: "1.5px", fontSize: "12px" } }, plusIcon(22), h("span", null, "ADD PHOTO")) ); } if (!editing) { return h("button", { onClick: function() { setEditing(true); }, title: "Edit photo", style: cornerBtnStyle("tl") }, pencilIcon(15)); } return [ h(PortraitCropEditor, { key: "crop", src: data.portrait, zoom: data.portraitZoom || 1, px: data.portraitX, py: data.portraitY, isMobile: isMobile, onChange: function(partial) { setData(function(p) { return Object.assign({}, p, partial); }); } }), h("button", { key: "done", onClick: function() { setEditing(false); }, title: "Done", style: cornerBtnStyle("tl") }, checkIcon(16)), h("button", { key: "del", onClick: function() { setEditing(false); setBgOrig(null); setBgErr(""); setData(function(p) { return Object.assign({}, p, { portrait: null }); }); }, title: "Delete photo", style: cornerBtnStyle("tr", "delete") }, trashIcon(15)), h("div", { key: "hint", style: { position: "absolute", left: "50%", bottom: "-26px", transform: "translateX(-50%)", whiteSpace: "nowrap", fontSize: "10px", letterSpacing: "0.5px", color: "rgba(245,237,216,0.7)", background: "rgba(12,2,20,0.8)", padding: "3px 9px", borderRadius: "999px", zIndex: 8, pointerEvents: "none" } }, isMobile ? "Drag to move · pinch to zoom" : "Drag to move · scroll or drag corners to zoom") ]; })(); var statDefs = [["PAC", "pace"], ["SHO", "sho"], ["PAS", "pas"], ["DRI", "dri"], ["DEF", "def"], ["PHY", "phy"]]; var chipS = function(active, r) { return { padding: "8px 14px", fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: "13px", letterSpacing: "1.5px", borderRadius: "999px", cursor: "pointer", background: active ? r.bg : "rgba(255,255,255,0.05)", color: active ? r.ink : "#F5EDD8", border: active ? "1px solid rgba(255,255,255,0.4)" : "1px solid rgba(255,255,255,0.1)", boxShadow: active ? "0 6px 18px rgba(0,0,0,0.4)" : "none" }; }; var inputS = { background: "rgba(0,0,0,0.3)", border: "1px solid rgba(255,255,255,0.1)", borderRadius: "8px", padding: "10px 12px", color: "#F5EDD8", fontSize: "13px", outline: "none", fontFamily: "inherit", width: "100%", boxSizing: "border-box", minWidth: 0 }; var selectS = { background: "rgba(0,0,0,0.3)", border: "1px solid rgba(255,255,255,0.1)", borderRadius: "8px", padding: "10px 12px", color: "#F5EDD8", fontSize: "13px", outline: "none", fontFamily: "inherit", width: "100%", boxSizing: "border-box", minWidth: 0, appearance: "none", backgroundImage: "linear-gradient(45deg, transparent 50%, #CD9602 50%), linear-gradient(135deg, #CD9602 50%, transparent 50%)", backgroundPosition: "calc(100% - 18px) 50%, calc(100% - 13px) 50%", backgroundSize: "5px 5px", backgroundRepeat: "no-repeat", paddingRight: "32px" }; var labelS = { fontSize: "10px", letterSpacing: "1.5px", color: "rgba(245,237,216,0.55)", textTransform: "uppercase" }; var secTitleS = { fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: "13px", letterSpacing: "2.5px", color: "#CD9602", marginBottom: "14px" }; return h("div", { style: { display: isMobile ? "flex" : "grid", flexDirection: isMobile ? "column" : undefined, gridTemplateColumns: isMobile ? undefined : "1fr 1fr", gap: isMobile ? "20px" : "40px", padding: isMobile ? "16px 14px 28px" : "32px 48px", minHeight: isMobile ? "calc(100vh - 96px)" : "calc(100vh - 72px)", color: "#F5EDD8", alignItems: "start", width: "100%", maxWidth: "100%", boxSizing: "border-box", overflowX: "hidden" } }, // Preview h("div", { style: { display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "flex-start", padding: isMobile ? "20px 16px 28px" : "32px 24px", background: "radial-gradient(ellipse at 50% 30%, rgba(74,17,102,0.6) 0%, rgba(26,4,40,0.1) 70%)", borderRadius: "16px", border: "1px solid rgba(205,150,2,0.2)", position: isMobile ? "static" : "sticky", top: "24px" } }, h("div", { style: { display: "flex", gap: "8px", flexWrap: "wrap", justifyContent: "center", marginBottom: isMobile ? "16px" : "24px" } }, RARITIES.map(function(r) { return h("div", { key: r.id, style: chipS(rarity === r.id, r), onClick: function() { setRarity(r.id); } }, r.name); }) ), // Preview card: static (a true preview of the result). When a portrait exists the // card is tappable and carries an "EDIT PHOTO" button → opens the focused editor // popout (drag-to-position + zoom). Keeps every photo control in one intuitive // place instead of scattered sliders. h("div", { style: { display: "inline-block", marginBottom: editing ? "34px" : "8px" } }, h(PlayerCard, { player: data, rarity: rarity, portraitOverlay: portraitOverlay, portraitEditing: editing }) ) ), // Form h("div", { style: { background: "linear-gradient(180deg, rgba(74,17,102,0.4) 0%, rgba(26,4,40,0.85) 100%)", border: "1px solid rgba(205,150,2,0.25)", borderRadius: "16px", padding: isMobile ? "20px 16px" : "28px", minWidth: 0, maxWidth: "100%", boxSizing: "border-box" } }, h("div", { style: { fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: "22px", letterSpacing: "2px", color: "#F5EDD8", marginBottom: "4px" } }, "CREATE A PLAYER CARD"), h("div", { style: { fontSize: "12px", color: "rgba(245,237,216,0.5)", marginBottom: "20px" } }, "Customise identity, position and the six core attributes. The card preview updates live."), // Rating section h("div", { style: { marginBottom: "22px", paddingTop: "18px", borderTop: "1px solid rgba(205,150,2,0.15)" } }, h("div", { style: secTitleS }, "OVERALL RATING"), h("div", { style: { display: "grid", gridTemplateColumns: "1fr auto", alignItems: "end", gap: "16px", marginBottom: "8px" } }, h("div", null, h("div", { style: { display: "flex", flexDirection: "column", gap: "6px" } }, h("label", { style: labelS }, "Rating (1\u201399)"), h("input", { style: inputS, type: "number", min: 1, max: 99, value: data.rating, onChange: function(e) { var v = e.target.value; if (v === "") { set("rating", ""); return; } var n = parseInt(v, 10); if (isNaN(n)) return; set("rating", Math.min(99, Math.max(0, n))); }, onBlur: function(e) { var n = parseInt(e.target.value, 10); set("rating", isNaN(n) ? 75 : Math.max(1, Math.min(99, n))); } }) ), h("button", { style: { background: "transparent", border: "1px solid rgba(205,150,2,0.4)", color: "#CD9602", padding: "8px 14px", borderRadius: "8px", fontFamily: "'Anton', 'Bebas Neue', sans-serif", letterSpacing: "1.5px", fontSize: "12px", cursor: "pointer", marginTop: "10px" }, onClick: recalc }, "\u21BB AUTO FROM STATS") ), h("div", { style: { fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: "60px", color: "#CD9602", lineHeight: 0.9, textAlign: "center", minWidth: "90px", background: "rgba(0,0,0,0.25)", border: "1px solid rgba(205,150,2,0.4)", borderRadius: "10px", padding: "6px 12px" } }, data.rating) ) ), // Identity section h("div", { style: { marginBottom: "22px", paddingTop: "18px", borderTop: "1px solid rgba(205,150,2,0.15)" } }, h("div", { style: secTitleS }, "IDENTITY"), h("div", { style: { display: "grid", gridTemplateColumns: "1fr 1fr", gap: "12px" } }, h("div", { style: { display: "flex", flexDirection: "column", gap: "6px" } }, h("label", { style: labelS }, "First name"), h("input", { style: inputS, value: data.first, onChange: function(e) { set("first", e.target.value); } })), h("div", { style: { display: "flex", flexDirection: "column", gap: "6px" } }, h("label", { style: labelS }, "Last name"), h("input", { style: inputS, value: data.last, onChange: function(e) { set("last", e.target.value); } })), h("div", { style: { display: "flex", flexDirection: "column", gap: "6px" } }, h("label", { style: labelS }, "Position"), h("select", { style: selectS, value: data.pos, onChange: function(e) { set("pos", e.target.value); } }, POSITIONS.map(function(p) { return h("option", { key: p, value: p }, p); }))), h("div", { style: { display: "flex", flexDirection: "column", gap: "6px" } }, h("label", { style: labelS }, "Country"), h("select", { style: selectS, value: data.nation, onChange: function(e) { set("nation", e.target.value); } }, COUNTRIES.map(function(c) { return h("option", { key: c.code, value: c.code }, c.name); }))), h("div", { style: { display: "flex", flexDirection: "column", gap: "6px", gridColumn: "span 2" } }, h("label", { style: labelS }, "Club"), h("input", { style: inputS, value: data.club, onChange: function(e) { set("club", e.target.value.toUpperCase()); } })) ) ), // Portrait section h("div", { style: { marginBottom: "22px", paddingTop: "18px", borderTop: "1px solid rgba(205,150,2,0.15)" } }, h("div", { style: secTitleS }, "PORTRAIT"), h("input", { ref: fileRef, type: "file", accept: "image/*", style: { display: "none" }, onChange: onPickImage }), h("div", { style: { display: "flex", alignItems: "center", gap: "12px" } }, h("div", { style: { width: "52px", height: "66px", borderRadius: "8px", overflow: "hidden", flexShrink: 0, border: "1px solid rgba(255,255,255,0.12)", background: data.portrait ? "transparent" : "repeating-linear-gradient(135deg, rgba(255,255,255,0.06) 0 5px, rgba(0,0,0,0.06) 5px 10px)" } }, data.portrait ? h("img", { src: data.portrait, alt: "", style: portraitStyle(data) }) : null ), h("button", { style: { flex: 1, background: "rgba(255,255,255,0.05)", border: "1px solid rgba(205,150,2,0.4)", color: "#CD9602", padding: "11px 14px", borderRadius: "8px", fontFamily: "'Anton', 'Bebas Neue', sans-serif", letterSpacing: "1.5px", fontSize: "13px", cursor: "pointer" }, onClick: function() { if (fileRef.current) fileRef.current.click(); } }, data.portrait ? "⬆ CHANGE IMAGE" : "⬆ UPLOAD IMAGE"), data.portrait && h("button", { style: { flex: 1, background: "rgba(205,150,2,0.12)", border: "1px solid rgba(205,150,2,0.5)", color: "#F4D87A", padding: "11px 14px", borderRadius: "8px", fontFamily: "'Anton', 'Bebas Neue', sans-serif", letterSpacing: "1.5px", fontSize: "13px", cursor: "pointer", display: "inline-flex", alignItems: "center", justifyContent: "center", gap: "7px" }, onClick: function() { setEditing(true); } }, pencilIcon(14), h("span", null, "EDIT")) ), // AI background removal (withoutbg-proxy) — cut the player out so they sit clean on the card. data.portrait ? h("div", { style: { marginTop: "12px" } }, bgOrig ? h("button", { onClick: restoreBg, style: { width: "100%", background: "rgba(255,255,255,0.05)", border: "1px solid rgba(205,150,2,0.45)", color: "#CD9602", padding: "11px 14px", borderRadius: "8px", fontFamily: "'Anton', 'Bebas Neue', sans-serif", letterSpacing: "1.5px", fontSize: "13px", cursor: "pointer" } }, "↺ UNDO BACKGROUND REMOVAL") : h("button", { onClick: removeBg, disabled: bgBusy, style: { width: "100%", background: bgBusy ? "rgba(205,150,2,0.3)" : "linear-gradient(155deg, #E8B83A, #CD9602)", border: "none", color: "#1A0428", padding: "12px 14px", borderRadius: "8px", fontFamily: "'Anton', 'Bebas Neue', sans-serif", letterSpacing: "1.5px", fontSize: "13px", cursor: bgBusy ? "default" : "pointer", opacity: bgBusy ? 0.85 : 1 } }, bgBusy ? "REMOVING BACKGROUND…" : "REMOVE BACKGROUND"), bgErr ? h("div", { style: { color: "#ff8a8a", fontSize: "11px", marginTop: "6px", lineHeight: 1.4 } }, bgErr) : null ) : null, h("div", { style: { fontSize: "10px", color: "rgba(245,237,216,0.4)", marginTop: "10px", lineHeight: 1.5 } }, data.portrait ? (isMobile ? "Tap the ✎ on the photo, then drag to move and pinch to zoom." : "Tap the ✎ on the photo, then drag to move and scroll/corners to zoom.") : "PNG or JPG. Best with a transparent or head-and-shoulders cutout — or tap the photo area to add one.") ), // Attributes section h("div", { style: { marginBottom: "22px", paddingTop: "18px", borderTop: "1px solid rgba(205,150,2,0.15)" } }, h("div", { style: secTitleS }, "ATTRIBUTES"), statDefs.map(function(s) { return h("div", { key: s[1], style: { display: "grid", gridTemplateColumns: "70px 1fr 42px", alignItems: "center", gap: "12px", marginBottom: "10px" } }, h("div", { style: { fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: "13px", letterSpacing: "1.5px", color: "#F5EDD8" } }, s[0]), h("input", { type: "range", className: "attr-range", min: 30, max: 99, value: data[s[1]], onChange: function(e) { set(s[1], Number(e.target.value)); }, style: { width: "100%", accentColor: "#CD9602" } }), h("div", { style: { fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: "20px", color: "#CD9602", textAlign: "right", lineHeight: 1 } }, data[s[1]]) ); }) ), // Speciality badge selector (function() { var autoBest = bestTrait(data); var chipStyle = function(active) { return { display: "inline-flex", alignItems: "center", gap: "6px", padding: "7px 11px", borderRadius: "999px", cursor: "pointer", fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: "12px", letterSpacing: "1px", border: active ? "1px solid #E8B83A" : "1px solid rgba(255,255,255,0.12)", background: active ? "linear-gradient(155deg, #E8B83A, #CD9602)" : "rgba(255,255,255,0.04)", color: active ? "#1A0428" : "rgba(245,237,216,0.8)", lineHeight: 1 }; }; return h("div", { style: { marginBottom: "22px", paddingTop: "18px", borderTop: "1px solid rgba(205,150,2,0.15)" } }, h("div", { style: secTitleS }, "SPECIALITY BADGE"), h("div", { style: { fontSize: "11px", color: "rgba(245,237,216,0.5)", marginTop: "-8px", marginBottom: "12px", lineHeight: 1.5 } }, "Auto-picks the highest attribute — or choose any speciality."), h("div", { style: { display: "flex", flexWrap: "wrap", gap: "8px" } }, h("button", { key: "auto", style: chipStyle(!data.trait), onClick: function() { set("trait", null); } }, !data.trait && autoBest ? "AUTO · " + TRAIT_LABELS[autoBest].toUpperCase() : "AUTO"), TRAIT_KEYS.map(function(k) { var active = data.trait === k; return h("button", { key: k, style: chipStyle(active), onClick: function() { set("trait", k); } }, traitIcon(k, 14), h("span", null, TRAIT_LABELS[k].toUpperCase()) ); }) ) ); })(), // Account: save to My Players (view saved cards from the account → My Collection) h("button", { disabled: saving, style: { width: "100%", marginTop: "24px", background: saving ? "rgba(205,150,2,0.5)" : "linear-gradient(155deg, #E8B83A, #CD9602)", color: "#1A0428", fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: "16px", letterSpacing: "2px", padding: "13px 20px", border: "none", borderRadius: "10px", cursor: saving ? "default" : "pointer", boxShadow: "0 6px 18px rgba(205,150,2,0.35)" }, onClick: saveToAccount }, saving ? "SAVING…" : (editingId ? "UPDATE MY PLAYER" : "SAVE TO MY PLAYERS")), saveMsg && h("div", { style: { marginTop: "10px", textAlign: "center", fontFamily: "'Anton', 'Bebas Neue', sans-serif", letterSpacing: "1.5px", fontSize: "13px", color: saveMsg.charAt(0) === "✓" ? "#9be8a8" : "#ff8a96" } }, saveMsg), editingId && h("div", { style: { marginTop: "8px", textAlign: "center", fontSize: "11px", color: "rgba(245,237,216,0.5)" } }, "Editing a saved player — ", h("span", { style: { color: "#CD9602", cursor: "pointer", textDecoration: "underline" }, onClick: function() { setEditingId(null); } }, "save as new instead")), // Reset + export to PNG h("div", { style: { display: "flex", gap: "10px", marginTop: "14px" } }, h("button", { style: { flex: 1, background: "transparent", border: "1px solid rgba(245,237,216,0.3)", color: "#F5EDD8", fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: "16px", letterSpacing: "2px", padding: "12px 20px", borderRadius: "10px", cursor: "pointer" }, onClick: function() { setData({ first: "", last: "NEW PLAYER", pos: "ST", rating: 75, nation: "USA", club: "FREE AGENT", pace: 75, sho: 75, pas: 75, dri: 75, def: 60, phy: 75, portrait: null, trait: null }); setEditingId(null); setRarity("gold"); } }, "RESET"), h("button", { style: { flex: 1, background: "rgba(255,255,255,0.05)", border: "1px solid rgba(245,237,216,0.25)", color: "#F5EDD8", fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: "16px", letterSpacing: "2px", padding: "12px 20px", borderRadius: "10px", cursor: "pointer", display: "inline-flex", alignItems: "center", justifyContent: "center", gap: "8px" }, onClick: function() { setExportOpen(true); } }, "SAVE IMAGE") ) ), exportOpen && h(CardExportModal, { cardData: data, rarity: rarity, onClose: function() { setExportOpen(false); }, onExported: onCardExported }) ); } // ═══════════════════════════════════════════════════════════════════════════════ // ACCOUNT — Auth modal + saved-items list modal // ═══════════════════════════════════════════════════════════════════════════════ function AuthModal(props) { var onClose = props.onClose; var _mode = useState("login"), authMode = _mode[0], setAuthMode = _mode[1]; var _email = useState(""), email = _email[0], setEmail = _email[1]; var _pw = useState(""), pw = _pw[0], setPw = _pw[1]; var _busy = useState(false), busy = _busy[0], setBusy = _busy[1]; var _err = useState(""), err = _err[0], setErr = _err[1]; var _note = useState(""), note = _note[0], setNote = _note[1]; var submit = function() { setErr(""); setNote(""); if (!sb) { setErr("Connection unavailable. Please retry."); return; } if (!email.trim() || !pw) { setErr("Enter your email and password."); return; } if (authMode === "signup" && pw.length < 6) { setErr("Password must be at least 6 characters."); return; } setBusy(true); var call = authMode === "signup" ? sb.auth.signUp({ email: email.trim(), password: pw, options: { emailRedirectTo: "https://home-team-9e7387.webflow.io/lineup-builder" } }) : sb.auth.signInWithPassword({ email: email.trim(), password: pw }); call.then(function(res) { setBusy(false); if (res.error) { setErr(res.error.message); return; } // Confirmation OFF -> session arrives now and onAuthStateChange closes us out. // Confirmation ON -> no session yet; tell the user to check their email. if (authMode === "signup" && !(res.data && res.data.session)) { setNote("Account created. Check your email to confirm, then sign in."); setAuthMode("login"); return; } onClose(); }, function(e) { setBusy(false); setErr((e && e.message) || "Something went wrong."); }); }; var inputS = { width: "100%", boxSizing: "border-box", background: "rgba(0,0,0,0.3)", border: "1px solid rgba(205,150,2,0.3)", borderRadius: "10px", padding: "12px 16px", color: "#F5EDD8", fontSize: "16px", outline: "none", fontFamily: "inherit" }; var labelS = { fontSize: "10px", letterSpacing: "1.5px", color: "rgba(245,237,216,0.55)", textTransform: "uppercase", marginBottom: "6px", display: "block" }; var tab = function(id, label) { var active = authMode === id; return h("button", { onClick: function() { setAuthMode(id); setErr(""); setNote(""); }, style: { flex: 1, padding: "9px 0", fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: "14px", letterSpacing: "1.5px", cursor: "pointer", borderRadius: "999px", border: active ? "1px solid #E8B83A" : "1px solid rgba(255,255,255,0.1)", background: active ? "linear-gradient(155deg, #E8B83A, #CD9602)" : "rgba(255,255,255,0.04)", color: active ? "#1A0428" : "rgba(245,237,216,0.7)" } }, label); }; return h("div", { style: { position: "fixed", inset: 0, background: "rgba(8,1,15,0.7)", backdropFilter: "blur(6px)", display: "flex", alignItems: "center", justifyContent: "center", zIndex: 120, padding: "24px" }, onClick: onClose }, h("div", { style: { width: "min(420px, 100%)", background: "linear-gradient(180deg, #2F0745 0%, #1A0428 100%)", border: "1px solid rgba(205,150,2,0.45)", borderRadius: "16px", boxShadow: "0 30px 80px rgba(0,0,0,0.7)", overflow: "hidden" }, onClick: function(e) { e.stopPropagation(); } }, h("div", { style: { padding: "20px 24px", borderBottom: "1px solid rgba(205,150,2,0.25)", display: "flex", alignItems: "center", justifyContent: "space-between" } }, h("div", { style: { fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: "24px", letterSpacing: "2px", color: "#F5EDD8" } }, authMode === "signup" ? "CREATE ACCOUNT" : "SIGN IN"), h("button", { style: { width: "30px", height: "30px", borderRadius: "50%", background: "rgba(255,255,255,0.06)", border: "1px solid rgba(255,255,255,0.1)", color: "#F5EDD8", cursor: "pointer", fontSize: "16px", lineHeight: 1 }, onClick: onClose }, "×") ), h("div", { style: { padding: "20px 24px", display: "flex", flexDirection: "column", gap: "14px" } }, h("div", { style: { display: "flex", gap: "8px" } }, tab("login", "LOG IN"), tab("signup", "SIGN UP")), h("div", { style: { fontSize: "12px", color: "rgba(245,237,216,0.55)", lineHeight: 1.5 } }, "Save your player cards and lineups, and use your created players in the builder."), h("div", null, h("label", { style: labelS }, "Email"), h("input", { style: inputS, type: "email", autoComplete: "email", value: email, onChange: function(e) { setEmail(e.target.value); }, onKeyDown: function(e) { if (e.key === "Enter") submit(); } })), h("div", null, h("label", { style: labelS }, "Password"), h("input", { style: inputS, type: "password", autoComplete: authMode === "signup" ? "new-password" : "current-password", value: pw, onChange: function(e) { setPw(e.target.value); }, onKeyDown: function(e) { if (e.key === "Enter") submit(); } })), err && h("div", { style: { fontSize: "12px", color: "#ff8a96", lineHeight: 1.4 } }, err), note && h("div", { style: { fontSize: "12px", color: "#9be8a8", lineHeight: 1.4 } }, note), h("button", { disabled: busy, style: { marginTop: "4px", width: "100%", background: busy ? "rgba(205,150,2,0.5)" : "linear-gradient(155deg, #E8B83A, #CD9602)", color: "#1A0428", fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: "17px", letterSpacing: "2px", padding: "13px 20px", border: "none", borderRadius: "10px", cursor: busy ? "default" : "pointer", boxShadow: "0 6px 18px rgba(205,150,2,0.35)" }, onClick: submit }, busy ? "PLEASE WAIT…" : (authMode === "signup" ? "CREATE ACCOUNT" : "LOG IN")) ) ) ); } // ═══════════════════════════════════════════════════════════════════════════════ // DASHBOARD — "My Collection": saved cards + saved lineups in one place // ═══════════════════════════════════════════════════════════════════════════════ function Dashboard(props) { var user = props.user, myPlayers = props.myPlayers || [], myLineups = props.myLineups || []; var isMobile = useWindowSize().w < 768; var _tab = useState("cards"), tab = _tab[0], setTab = _tab[1]; var _confirm = useState(null), confirm = _confirm[0], setConfirm = _confirm[1]; // { kind, id, label } var tabChip = function(id, label, count) { var active = tab === id; return h("button", { onClick: function() { setTab(id); }, style: { padding: "9px 18px", borderRadius: "999px", cursor: "pointer", fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: "14px", letterSpacing: "1.5px", border: active ? "1px solid #E8B83A" : "1px solid rgba(245,237,216,0.15)", background: active ? "linear-gradient(155deg, #E8B83A, #CD9602)" : "rgba(255,255,255,0.04)", color: active ? "#1A0428" : "rgba(245,237,216,0.7)", whiteSpace: "nowrap" } }, label + " " + count); }; var actionBtn = function(label, onClick, primary) { return h("button", { onClick: onClick, style: { flex: 1, padding: "9px 10px", borderRadius: "9px", cursor: "pointer", fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: "13px", letterSpacing: "1.5px", border: primary ? "none" : "1px solid rgba(205,150,2,0.4)", background: primary ? "linear-gradient(155deg, #E8B83A, #CD9602)" : "rgba(255,255,255,0.05)", color: primary ? "#1A0428" : "#CD9602" } }, label); }; var tileS = { background: "linear-gradient(180deg, rgba(74,17,102,0.45) 0%, rgba(26,4,40,0.85) 100%)", border: "1px solid rgba(205,150,2,0.22)", borderRadius: "14px", padding: "14px", display: "flex", flexDirection: "column", gap: "10px" }; var emptyState = function(text, ctaLabel, onCta) { return h("div", { style: { gridColumn: "1 / -1", textAlign: "center", padding: "60px 20px", color: "rgba(245,237,216,0.5)" } }, h("div", { style: { fontSize: "13px", marginBottom: "16px" } }, text), h("button", { onClick: onCta, style: { padding: "11px 22px", borderRadius: "10px", border: "none", cursor: "pointer", background: "linear-gradient(155deg, #E8B83A, #CD9602)", color: "#1A0428", fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: "15px", letterSpacing: "2px", boxShadow: "0 6px 18px rgba(205,150,2,0.35)" } }, ctaLabel) ); }; // Card tile — a scaled live PlayerCard preview + EDIT / DELETE. var cardTile = function(p) { return h("div", { key: p.id, style: tileS }, h("div", { onClick: function() { props.onEditCard(p); }, style: { width: "130px", height: "210px", overflow: "hidden", position: "relative", margin: "0 auto", cursor: "pointer" } }, h("div", { style: { position: "absolute", top: 0, left: 0, transform: "scale(0.5)", transformOrigin: "top left" } }, h(PlayerCard, { player: p, rarity: p.rarity || "gold" }) ) ), h("div", { style: { textAlign: "center", minWidth: 0 } }, h("div", { style: { fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: "16px", letterSpacing: "1px", color: "#F5EDD8", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" } }, (p.first ? p.first + " " : "") + p.last), h("div", { style: { fontSize: "10px", letterSpacing: "1px", color: "rgba(245,237,216,0.5)" } }, p.pos + " · " + p.rating + " · " + (p.nation || "")) ), h("div", { style: { display: "flex", gap: "8px" } }, actionBtn("EDIT", function() { props.onEditCard(p); }, true), actionBtn("DELETE", function() { setConfirm({ kind: "card", id: p.id, label: (p.first ? p.first + " " : "") + p.last }); }, false) ) ); }; // Lineup tile — formation summary + OPEN / DELETE. var lineupTile = function(L) { var count = L.assignments ? Object.keys(L.assignments).length : 0; return h("div", { key: L.id, style: tileS }, h("div", { onClick: function() { props.onOpenLineup(L); }, style: { cursor: "pointer", padding: "10px 4px 4px" } }, h("div", { style: { fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: "20px", letterSpacing: "1px", color: "#F5EDD8", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" } }, L.name || "Untitled"), h("div", { style: { fontSize: "11px", letterSpacing: "1px", color: "#CD9602", fontFamily: "'Anton', 'Bebas Neue', sans-serif", marginTop: "4px" } }, (L.game_mode || "") + " · " + (L.formation_key || "")), h("div", { style: { fontSize: "11px", color: "rgba(245,237,216,0.5)", marginTop: "6px" } }, count + " players placed" + (L.manager ? " · manager set" : "")) ), h("div", { style: { display: "flex", gap: "8px", marginTop: "2px" } }, actionBtn("OPEN", function() { props.onOpenLineup(L); }, true), actionBtn("DELETE", function() { setConfirm({ kind: "lineup", id: L.id, label: L.name || "this lineup" }); }, false) ) ); }; var gridS = { display: "grid", gridTemplateColumns: isMobile ? "repeat(auto-fill, minmax(150px, 1fr))" : "repeat(auto-fill, minmax(190px, 1fr))", gap: "16px", marginTop: "22px" }; return h("div", { style: { padding: isMobile ? "20px 14px 40px" : "32px 48px 48px", color: "#F5EDD8", maxWidth: "1100px", margin: "0 auto", width: "100%", boxSizing: "border-box" } }, h("div", { style: { fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: isMobile ? "30px" : "40px", letterSpacing: "2px", color: "#F5EDD8" } }, "MY COLLECTION"), h("div", { style: { fontSize: "12px", color: "rgba(245,237,216,0.5)", marginTop: "2px" } }, user ? user.email : ""), h("div", { style: { display: "flex", gap: "10px", marginTop: "20px", flexWrap: "wrap" } }, tabChip("cards", "PLAYER CARDS", myPlayers.length), tabChip("lineups", "LINEUPS", myLineups.length) ), tab === "cards" ? h("div", { style: gridS }, myPlayers.length ? myPlayers.map(cardTile) : emptyState("You haven't saved any player cards yet.", "CREATE A CARD", props.goToCardMaker)) : h("div", { style: gridS }, myLineups.length ? myLineups.map(lineupTile) : emptyState("You haven't saved any lineups yet.", "BUILD A LINEUP", props.goToLineup)), // Delete confirmation confirm && h("div", { style: { position: "fixed", inset: 0, background: "rgba(8,1,15,0.7)", backdropFilter: "blur(6px)", display: "flex", alignItems: "center", justifyContent: "center", zIndex: 120, padding: "24px" }, onClick: function() { setConfirm(null); } }, h("div", { style: { width: "min(380px, 100%)", background: "linear-gradient(180deg, #2F0745 0%, #1A0428 100%)", border: "1px solid rgba(205,150,2,0.45)", borderRadius: "16px", boxShadow: "0 30px 80px rgba(0,0,0,0.7)", padding: "24px" }, onClick: function(e) { e.stopPropagation(); } }, h("div", { style: { fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: "20px", letterSpacing: "1.5px", color: "#F5EDD8", marginBottom: "8px" } }, "DELETE " + (confirm.kind === "card" ? "CARD" : "LINEUP") + "?"), h("div", { style: { fontSize: "13px", color: "rgba(245,237,216,0.6)", marginBottom: "20px", lineHeight: 1.5 } }, "“" + confirm.label + "” will be permanently removed."), h("div", { style: { display: "flex", gap: "10px" } }, h("button", { style: { flex: 1, background: "transparent", border: "1px solid rgba(245,237,216,0.3)", color: "#F5EDD8", fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: "15px", letterSpacing: "2px", padding: "12px", borderRadius: "10px", cursor: "pointer" }, onClick: function() { setConfirm(null); } }, "CANCEL"), h("button", { style: { flex: 1, background: "linear-gradient(155deg, #ff6b78, #c0303d)", border: "none", color: "#fff", fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: "15px", letterSpacing: "2px", padding: "12px", borderRadius: "10px", cursor: "pointer" }, onClick: function() { var c = confirm; setConfirm(null); if (c.kind === "card") props.onDeleteCard(c.id); else props.onDeleteLineup(c.id); } }, "DELETE") ) ) ) ); } // ═══════════════════════════════════════════════════════════════════════════════ // APP SHELL — Header, mode toggle, mounts root // ═══════════════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════════════ // WEEKEND DUEL — 3rd mode: head-to-head fantasy matchday game. See CLAUDE.md §12. // P1 = Dashboard + Build Your Team (real top-5-league players w/ PTS & FORM, Auto // Pick, 60s pick timer, save a matchday pick). Leaderboard + Badges are on-brand // previews (real data lands in P4). NO live scoring — points settle in ONE // post-match pass after the weekend's fixtures finish (P3). // ═══════════════════════════════════════════════════════════════════════════════ // Lucide-style inline icons (ISC, NO emojis — matches the trait-icon convention). var D_ICONS = { trophy: [["path",{d:"M6 9H4.5a2.5 2.5 0 0 1 0-5H6"}],["path",{d:"M18 9h1.5a2.5 2.5 0 0 0 0-5H18"}],["path",{d:"M4 22h16"}],["path",{d:"M10 14.7V17c0 .6-.5 1-1 1.2C7.9 18.8 7 20.2 7 22"}],["path",{d:"M14 14.7V17c0 .6.5 1 1 1.2C16.1 18.8 17 20.2 17 22"}],["path",{d:"M18 2H6v7a6 6 0 0 0 12 0V2Z"}]], flame: [["path",{d:"M8.5 14.5A2.5 2.5 0 0 0 11 12c0-1.4-.5-2-1-3-1.1-2.1-.2-4 2-6 .5 2.5 2 4.9 4 6.5 2 1.6 3 3.5 3 5.5a7 7 0 1 1-14 0c0-1.2.4-2.3 1-3a2.5 2.5 0 0 0 2.5 2.5z"}]], star: [["path",{d:"M11.5 2.6a.5.5 0 0 1 1 0l2.3 4.7 5.1.7a.5.5 0 0 1 .3.9l-3.7 3.6.9 5.1a.5.5 0 0 1-.8.6L12 16.5l-4.6 2.4a.5.5 0 0 1-.8-.6l.9-5.1L3.8 9.6a.5.5 0 0 1 .3-.9l5.1-.7z"}]], hash: [["path",{d:"M4 9h16"}],["path",{d:"M4 15h16"}],["path",{d:"M10 3 8 21"}],["path",{d:"M16 3l-2 18"}]], crown: [["path",{d:"M11.6 3.3a.5.5 0 0 1 .9 0l2.9 5.6a1 1 0 0 0 1.5.3l4.3-3.7a.5.5 0 0 1 .8.5l-2.8 10.3a1 1 0 0 1-1 .7H5.8a1 1 0 0 1-1-.7L2 6a.5.5 0 0 1 .8-.5l4.3 3.7a1 1 0 0 0 1.5-.3z"}],["path",{d:"M5 21h14"}]], clock: [["circle",{cx:12,cy:12,r:9}],["path",{d:"M12 7v5l3 2"}]], bell: [["path",{d:"M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"}],["path",{d:"M10.3 21a1.94 1.94 0 0 0 3.4 0"}]], zap: [["path",{d:"M13 2 3 14h9l-1 8 10-12h-9l1-8z",fill:"currentColor",stroke:"none"}]], shield: [["path",{d:"M12 2 4 5v6c0 5 3.4 8 8 9 4.6-1 8-4 8-9V5z"}],["path",{d:"m9 12 2 2 4-4"}]], chevrons: [["path",{d:"m7 12 5-5 5 5"}],["path",{d:"m7 18 5-5 5 5"}]], calendar: [["rect",{x:3,y:4,width:18,height:18,rx:2}],["path",{d:"M16 2v4M8 2v4M3 10h18"}]], book: [["path",{d:"M4 19.5A2.5 2.5 0 0 1 6.5 17H20"}],["path",{d:"M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"}]], userPlus: [["path",{d:"M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"}],["circle",{cx:9,cy:7,r:4}],["path",{d:"M19 8v6M22 11h-6"}]], users: [["circle",{cx:9,cy:7,r:3}],["circle",{cx:17,cy:9,r:2.4}],["path",{d:"M3 20v-1a5 5 0 0 1 9-3"}],["path",{d:"M14.5 14.2a4 4 0 0 1 5.5 3.8V20"}]], user: [["path",{d:"M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"}],["circle",{cx:9,cy:7,r:4}]], target: [["circle",{cx:12,cy:12,r:9}],["circle",{cx:12,cy:12,r:5}],["circle",{cx:12,cy:12,r:1.6,fill:"currentColor",stroke:"none"}]], chevronRight: [["path",{d:"m9 18 6-6-6-6"}]], plus: [["path",{d:"M12 5v14M5 12h14"}]], check: [["path",{d:"M20 6 9 17l-5-5"}]], message: [["path",{d:"M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"}]], pencil: [["path",{d:"M12 20h9"}],["path",{d:"M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4z"}]], swords: [["path",{d:"M14.5 17.5 3 6V3h3l11.5 11.5"}],["path",{d:"m13 19 6-6"}],["path",{d:"m16 16 4 4"}],["path",{d:"m19 21 2-2"}]], compare: [["path",{d:"M8 3 4 7l4 4"}],["path",{d:"M4 7h16"}],["path",{d:"m16 21 4-4-4-4"}],["path",{d:"M20 17H4"}]], x: [["path",{d:"M18 6 6 18M6 6l12 12"}]], ball: [["circle",{cx:12,cy:12,r:9}],["path",{d:"m12 8.1 2.6 1.9-1 3.1h-3.2l-1-3.1z"}],["path",{d:"M12 8.1V4M14.6 10l3.4-1.4M13.6 13.1l2.3 3.1M10.4 13.1l-2.3 3.1M9.4 10 6 8.6"}]], assist: [["path",{d:"M4 12h11"}],["path",{d:"m12 7 5 5-5 5"}]], glove: [["path",{d:"M9 11V6a1.5 1.5 0 0 1 3 0v5"}],["path",{d:"M12 11V4.5a1.5 1.5 0 0 1 3 0V11"}],["path",{d:"M15 11V6.5a1.5 1.5 0 0 1 3 0V14a6 6 0 0 1-6 6 6 6 0 0 1-6-6v-1a1.5 1.5 0 0 1 3 0"}]], card: [["rect",{x:8.5,y:3.5,width:7,height:16,rx:1.4}]], whistle: [["circle",{cx:8,cy:14,r:5}],["path",{d:"M13 12h8l-1 4h-7"}],["path",{d:"M8 9V6h4"}]] }; function dIcon(name, size, sw) { var parts = (D_ICONS[name] || []).map(function(e, i) { return h(e[0], Object.assign({ key: i }, e[1])); }); return h("svg", { width: size, height: size, viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: sw || 2, strokeLinecap: "round", strokeLinejoin: "round", style: { display: "block", flexShrink: 0 } }, parts); } // Friendly countdown ("2d 4h 15m") to a fixed deadline. A live clock legitimately // needs the wall time — this is the one place Date.now() is expected (§7 note). function fmtCountdown(ms) { if (ms == null || ms <= 0) return "now"; var s = Math.floor(ms / 1000), d = Math.floor(s / 86400), hr = Math.floor((s % 86400) / 3600), mn = Math.floor((s % 3600) / 60); if (d > 0) return d + "d " + hr + "h " + mn + "m"; if (hr > 0) return hr + "h " + mn + "m " + (s % 60) + "s"; return mn + "m " + (s % 60) + "s"; } function fmtDeadline(iso) { try { var dt = new Date(iso), days = ["Sun","Mon","Tue","Wed","Thu","Fri","Sat"]; var hh = dt.getHours(), ap = hh >= 12 ? "PM" : "AM", h12 = ((hh + 11) % 12) + 1; var mm = dt.getMinutes(); return days[dt.getDay()] + " " + h12 + ":" + (mm < 10 ? "0" + mm : "" + mm) + " " + ap; } catch (e) { return ""; } } function fmtDate(iso) { try { var dt = new Date(iso), mo = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"]; return mo[dt.getMonth()] + " " + dt.getDate() + ", " + dt.getFullYear(); } catch (e) { return ""; } } // FORM (0–10): recent match-rating, gently lifted to a "form index" feel (real // season ratings sit ~6.5–8; we scale so good form reads ~8–9.5 like the mockup). // Falls back to OVR for players without a season rating (demo/custom). function formScore(p) { if (!p) return 0; var f = (p._smk && p._smk.rating != null) ? p._smk.rating * 1.18 : (p.rating || 70) * 0.105; return Math.max(0, Math.min(10, Math.round(f * 10) / 10)); } // Fantasy scoring weights (§6 / CLAUDE.md §12) — the post-match rules (P3) and the // basis for the pre-match PTS projection below. var DUEL_SCORING = { play60: 2, playSub: 1, goal: { ATT: 4, MID: 5, DEF: 6, GK: 6 }, assist: 3, cleanSheet: { GK: 4, DEF: 4, MID: 1, ATT: 0 }, concededPer2: -1, savesPer3: 1, penSaved: 5, yellow: -1, red: -3, ownGoal: -2 }; // PTS (PRE-match pool projection): expected fantasy points from the player's per-90 // season stats, scaled so stars land ~12–17 and squad players ~5–9. A projection to // help you pick — NOT the post-match score (that only exists once the games finish). function fantasyProjection(p) { if (!p) return 0; var grp = POS_GROUP[p.pos] || "MID", s = p._smk, base; if (s && s.mins > 0) { // clamp per-90 so a tiny stat block (e.g. 2 mins, 1 goal → 45 g90) can't explode PTS var nineties = s.mins / 90, g90 = Math.min(2.5, s.g / nineties), a90 = Math.min(2.5, s.a / nineties), csR = s.apps ? s.cs / s.apps : 0; base = DUEL_SCORING.play60 + g90 * (DUEL_SCORING.goal[grp] || 5) + a90 * DUEL_SCORING.assist + (DUEL_SCORING.cleanSheet[grp] || 0) * csR + (grp === "GK" ? (s.saves / Math.max(1, s.apps)) / 3 : 0) + (s.rating ? Math.max(0, s.rating - 6.8) * 1.6 : 0); } else { var key = grp === "ATT" ? p.sho : grp === "MID" ? p.pas : grp === "DEF" ? p.def : (p.gk ? p.gk.ref : p.def); base = 2 + ((p.rating || 70) - 60) * 0.14 + ((key || 60) - 60) * 0.05; } var pts = base * 1.9 + (grp === "ATT" ? 2 : grp === "MID" ? 1 : 0); return Math.max(1, Math.min(30, Math.round(pts * 10) / 10)); } // Curated marquee top-5-league players seeding the Player Pool. Real SportMonks ids // (discovered live). Only top-5-league players are scoreable, so the Duel pool is // restricted to them. Fetched by id on first open, then module-cached + 12h proxy // cached. GK/DEF/MID/FWD spread so a full XI can be drafted straight from the pool. var DUEL_POOL_SEED = [ 154421, 4125, 96611, 997, 31000, 333594, 194167, // forwards 16827155, 600687, 1479337, 160258, 7346228, // wide forwards 336133, 26823, 37255840, 37429246, 129602, 28912747, // attacking mids 17544737, 1743, 162536, 96208, 187260, 186995, // defenders 129820, 129771 // keepers ]; var _duelPoolCache = null; // Preloaded pool: read the pre-mapped players from `duel_players` in ONE query (no // per-id API wait). The Duel pool/pitch is never exported to PNG, so the players' // raw SportMonks image URLs display fine as-is — no proxy round-trips needed. Falls // back to the live API seed if the table is empty (fresh project before seeding). function fetchDuelPool() { if (_duelPoolCache) return _duelPoolCache; if (!sb) return Promise.resolve([]); _duelPoolCache = sb.from("duel_players").select("data").order("pts", { ascending: false }).limit(300) .then(function(res) { var rows = (res && res.data) || []; if (rows.length) { var list = rows.map(function(r) { return registerApiPlayer(r.data); }).filter(Boolean); if (list.length) return list; } return fetchDuelPoolFromApi(); // empty table → seed live }, function() { return fetchDuelPoolFromApi(); }); return _duelPoolCache; } function fetchDuelPoolFromApi() { return Promise.all(DUEL_POOL_SEED.map(function(id) { return footballProxy("players/" + id, { include: "nationality;detailedPosition;statistics.details.type;teams.team" }) .then(function(d) { var pl = d && d.data; if (!pl) return null; var mapped = apiPlayerToPlayer(pl, {}); return Promise.all([proxyImage(mapped.portrait), proxyImage(mapped.nationFlag)]).then(function(out) { if (out[0]) mapped.portrait = out[0]; if (out[1]) mapped.nationFlag = out[1]; registerApiPlayer(mapped); return mapped; }); }, function() { return null; }); })).then(function(list) { var clean = list.filter(Boolean); if (!clean.length) { _duelPoolCache = null; return []; } // total failure → allow a retry next open clean.sort(function(a, b) { return fantasyProjection(b) - fantasyProjection(a); }); return clean; }, function() { _duelPoolCache = null; return []; }); } // Re-hydrate session-only API players (smk:) referenced by a SAVED lineup whose mapped objects // aren't in this session's registry yet (API_PLAYERS_BY_ID is in-memory, §11.9 — saved lineups store // ids only). Re-fetches each by its SportMonks id (the smk: id IS the SportMonks id) → maps → registers, // so getPlayer() resolves them and the slots fill in. Returns a promise → count re-registered. function rehydrateApiPlayers(assignments, onEach) { // onEach(pid, ok) is called as EACH player resolves/fails, so the caller can clear that slot's // loading spinner the moment its player lands (rather than all at once at the end). if (!assignments) return Promise.resolve(0); var jobs = {}; Object.keys(assignments).forEach(function(slotId) { var pid = assignments[slotId]; if (typeof pid === "string" && pid.indexOf("smk:") === 0 && !API_PLAYERS_BY_ID[pid]) jobs[pid] = pid.slice(4); }); var pids = Object.keys(jobs); if (!pids.length) return Promise.resolve(0); var done = function(pid, ok) { if (onEach) { try { onEach(pid, ok); } catch (e) {} } }; return Promise.all(pids.map(function(pid) { return footballProxy("players/" + jobs[pid], { include: "nationality;detailedPosition;statistics.details.type;teams.team" }) .then(function(d) { var pl = d && d.data; if (!pl) { done(pid, false); return null; } var mapped = apiPlayerToPlayer(pl, {}); return Promise.all([proxyImage(mapped.portrait), proxyImage(mapped.nationFlag)]).then(function(out) { if (out[0]) mapped.portrait = out[0]; if (out[1]) mapped.nationFlag = out[1]; registerApiPlayer(mapped); done(pid, true); return mapped; }); }, function() { done(pid, false); return null; }); })).then(function(list) { return list.filter(Boolean).length; }, function() { return 0; }); } // Pre-composed hero artwork for the dashboard "BUILD YOUR TEAM" card (3 GOAT forwards). Replaces // the old dynamically-rendered HERO_TRIO mini-cards (kept below, unused, for reference). var HERO_BUILD_IMG = "https://cdn.prod.website-files.com/69eb6394026dd7faab0bad78/6a33b11acb3f89a80c64ee66_BUILD%20LINEUP%20IMAGE.png"; // Influencer Takeover hero art (generated via the hometeam-image skill, QA-passed) — // the generic FALLBACK backdrop when an influencer has no influencer_hero of their own. var TAKEOVER_HERO_IMG = "https://cdn.prod.website-files.com/69eb6394026dd7faab0bad78/6a59d9f1f4fcb53014613964_influencer-takeover-hero.avif"; // Full-width DUELS hero banner (replaces the "GAME DUELS" text heading at the top of the Duel screen). var DUEL_BANNER_IMG = "https://cdn.prod.website-files.com/69eb6394026dd7faab0bad78/6a33b388792584d66e09c73f_duel-banner-main.png"; // Standalone DUELS poster shown on the Duel login gate (signed-out) ABOVE the "READY TO DUEL?" // forwards hero card — the gate shows BOTH posters stacked (client: "I need both the posters"). var DUEL_SIGNIN_BANNER = "https://cdn.prod.website-files.com/69eb6394026dd7faab0bad78/6a4aebe170325ae14adee128_16397ee1d6e15dd17719796bee051194_03B1605C-BFE3-4745-B886-73C603337B39.avif"; // Decorative hero trio shown in the dashboard "BUILD YOUR TEAM" card — three GOAT forwards // (Messi · Mbappé · Ronaldo) in an attacking stagger. Static card data renders instantly as gold // cards; real portraits are lazy-loaded from SportMonks and merged in (graceful no-photo fallback). var HERO_TRIO = [ { id: "hero:messi", name: "L. Messi", rating: 93, pos: "RW", nation: "ARG", iso2: "ar", q: "messi", pace: 80, sho: 92, pas: 91, dri: 95, def: 33, phy: 64 }, { id: "hero:mbappe", name: "K. Mbappé", rating: 91, pos: "ST", nation: "FRA", iso2: "fr", q: "mbapp", pace: 97, sho: 90, pas: 80, dri: 92, def: 36, phy: 78 }, { id: "hero:ronaldo", name: "C. Ronaldo", rating: 92, pos: "ST", nation: "POR", iso2: "pt", q: "ronaldo", pace: 85, sho: 93, pas: 78, dri: 85, def: 35, phy: 80 } ]; var _heroTrioLoaded = null; function loadHeroTrioPortraits() { if (_heroTrioLoaded) return _heroTrioLoaded; if (!sb) return Promise.resolve(HERO_TRIO); _heroTrioLoaded = Promise.all(HERO_TRIO.map(function(c) { var jobs = []; // flag (flagcdn is CORS-clean + whitelisted in the proxy) if (!c.nationFlag && c.iso2) jobs.push(proxyImage("https://flagcdn.com/w80/" + c.iso2 + ".png").then(function(u) { if (u && String(u).indexOf("data:") === 0) c.nationFlag = u; }, function() {})); // portrait (search → image_path → data URL) if (!c.portrait) jobs.push(footballProxy("players/search/" + encodeURIComponent(c.q), { include: "nationality", per_page: 8 }).then(function(d) { var list = (d && d.data) || []; var hit = list.filter(function(p) { var nm = ((p.lastname || "") + " " + (p.common_name || "") + " " + (p.display_name || "") + " " + (p.firstname || "")).toLowerCase(); return nm.indexOf(c.q) >= 0 && p.image_path && p.image_path.indexOf("placeholder") < 0; })[0]; if (!hit || !hit.image_path) return; return proxyImage(hit.image_path).then(function(url) { if (url && String(url).indexOf("data:") === 0) c.portrait = url; }, function() {}); }, function() {})); return Promise.all(jobs).then(function() { return c; }); })).then(function(list) { return list; }, function() { _heroTrioLoaded = null; return HERO_TRIO; }); return _heroTrioLoaded; } function isTestId(id) { return String(id).indexOf("test:") === 0; } // The 10 canonical badges (criteria = the brief's §10; visuals match the mockup). var BADGES = [ { key: "point_machine", icon: "star", name: "Point Machine", crit: "Score over 100 points in a single matchday." }, { key: "giant_killer", icon: "crown", name: "Giant Killer", crit: "Beat a manager ranked in the global Top 100." }, { key: "weekend_warrior", icon: "zap", name: "Weekend Warrior", crit: "Play 5 duels in one weekend." }, { key: "win_streak", icon: "flame", name: "Win Streak", crit: "Win 8 duels in a row." }, { key: "easy_peezy", icon: "chevrons", name: "Easy Peezy", crit: "Win by more than double your opponent's points." }, { key: "clutch_king", icon: "clock", name: "Clutch King", crit: "Be losing in added time, then an injury-time goal flips it to a win." }, { key: "defensive_guru", icon: "shield", name: "Defensive Guru", crit: "Your entire back five keeps a clean sheet in the same matchday." }, { key: "the_machine", icon: "calendar", name: "The Machine", crit: "Play every single matchday in a season." }, { key: "the_professor", icon: "book", name: "The Professor", crit: "Use more than 50 different players across a season." }, { key: "crazily_consistent",icon: "trophy", name: "Crazily Consistent",crit: "Spend the whole season in the top 10% of all managers." } ]; // Tap-to-send trash-talk lines (no emojis — house style). var TRASH_LINES = ["Good luck out there!", "May the best baller win.", "Easy win incoming.", "Your XI is cooked.", "Bring it on!", "Prepare to lose.", "May the best manager win.", "You're going down.", "Scared yet?", "My bench beats your XI.", "Talk is cheap — kickoff isn't.", "GG before we even start.", "Your defence won't stop my strikers.", "Your back line is getting torn apart.", "Your midfield won't see the ball.", "Your midfield's getting overrun.", "Your attack is toothless.", "Your forwards won't get a sniff.", "My strikers will bury your defence."]; // Manager profile editor (team name = how opponents find you; city/country → local // leaderboards). Own component so its inputs hold local state. function ProfileEditModal(props) { var _n = useState(props.name || ""), name = _n[0], setName = _n[1]; var _c = useState(props.country || ""), country = _c[0], setCountry = _c[1]; var _ci = useState(props.city || ""), city = _ci[0], setCity = _ci[1]; var _st = useState(props.state || ""), state = _st[0], setState = _st[1]; var inputS = { width: "100%", boxSizing: "border-box", background: "rgba(0,0,0,0.3)", border: "1px solid rgba(176,128,214,0.3)", borderRadius: "10px", padding: "12px 14px", color: "#F5EDD8", fontSize: "16px", outline: "none", fontFamily: "inherit" }; var labelS = { fontSize: "10px", letterSpacing: "1.5px", color: "rgba(245,237,216,0.55)", textTransform: "uppercase", marginBottom: "6px", display: "block" }; return h("div", { style: { position: "fixed", inset: 0, background: "rgba(8,1,15,0.72)", backdropFilter: "blur(6px)", display: "flex", alignItems: "center", justifyContent: "center", zIndex: 135, padding: "18px" }, onClick: props.onClose }, h("div", { style: { width: "min(400px, 100%)", background: "linear-gradient(180deg, #2a1140 0%, #130325 100%)", border: "1px solid rgba(199,155,242,0.30)", borderRadius: "18px", boxShadow: "0 30px 80px rgba(0,0,0,0.7)", overflow: "hidden" }, onClick: function(e) { e.stopPropagation(); } }, h("div", { style: { padding: "16px 20px", borderBottom: "1px solid rgba(199,155,242,0.18)", display: "flex", alignItems: "center", justifyContent: "space-between" } }, h("div", { style: { fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: "22px", letterSpacing: "1.5px", color: "#F5EDD8" } }, "MANAGER PROFILE"), h("button", { onClick: props.onClose, style: { width: "30px", height: "30px", borderRadius: "50%", background: "rgba(255,255,255,0.06)", border: "1px solid rgba(255,255,255,0.1)", color: "#F5EDD8", cursor: "pointer", fontSize: "16px" } }, "×") ), h("div", { style: { padding: "18px 20px", display: "flex", flexDirection: "column", gap: "14px", boxSizing: "border-box" } }, h("div", null, h("label", { style: labelS }, "Team name"), h("input", { style: inputS, value: name, maxLength: 30, onChange: function(e) { setName(e.target.value); } })), h("div", { style: { display: "flex", gap: "8px" } }, h("div", { style: { flex: 1, minWidth: 0 } }, h("label", { style: labelS }, "City"), h("input", { style: inputS, value: city, onChange: function(e) { setCity(e.target.value); } })), h("div", { style: { flex: 1, minWidth: 0 } }, h("label", { style: labelS }, "State"), h("input", { style: inputS, value: state, onChange: function(e) { setState(e.target.value); } })), h("div", { style: { flex: 1, minWidth: 0 } }, h("label", { style: labelS }, "Country"), h("input", { style: inputS, value: country, onChange: function(e) { setCountry(e.target.value); } })) ), h("div", { style: { fontSize: "11px", color: "rgba(245,237,216,0.45)", lineHeight: 1.4 } }, "Your team name is how opponents find and challenge you. City, state & country power the local leaderboards."), h("button", { disabled: props.busy, onClick: function() { props.onSave(name, country, city, state); }, style: { width: "100%", background: props.busy ? "rgba(244,174,28,0.45)" : "linear-gradient(160deg, #FFDE7A 0%, #F4AE1C 100%)", color: "#1A0428", fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: "16px", letterSpacing: "2px", padding: "13px", border: "none", borderRadius: "10px", cursor: "pointer" } }, props.busy ? "SAVING…" : "SAVE PROFILE") ) ) ); } // ─── §26.6 DUEL SHARE CARDS — two 1080×1080 social images (mockup-styled): the manager's // RECORD ("AIN'T NO ONE BEATING ME") and their WEEKEND LINEUP ("I'M BUILDING THE BEST // TEAM"). Rendered as real DOM canvases (Anton embeds in exports, §13.6) and rasterized // with the proven waitForImages + reliableToBlob pipeline; the lineup card re-inlines // XI portraits/flags to data URLs first (§7.8 — raw CDN urls blank out in PNGs). ─────── function DuelShareModal(props) { var isMobile = useWindowSize().w < 768; var onClose = props.onClose, profile = props.profile, stats = props.stats || {}, pick = props.pick; var _tab = useState(props.initialTab === "lineup" ? "lineup" : "stats"), tab = _tab[0], setTab = _tab[1]; var _busy = useState(false), busy = _busy[0], setBusy = _busy[1]; var _toast = useState(null), toast = _toast[0], setToast = _toast[1]; var _scale = useState(0.3), scale = _scale[0], setScale = _scale[1]; var _logoSrc = useState(_logoDataUrlCache), logoSrc = _logoSrc[0], setLogoSrc = _logoSrc[1]; var _ink = useState(0), setInlineTick = _ink[1]; var W = 1080; var GOLD = "#FFC836", VIOLET = "#C79BF2", CREAM = "#F5EDD8"; var AN = "'Anton', 'Bebas Neue', sans-serif"; var mgrName = (profile && profile.team_name) || "MY TEAM"; useEffect(function() { fetchLogoDataUrl().then(function(url) { setLogoSrc(url); }); }, []); useEffect(function() { function fit() { var wrap = document.getElementById("duel-share-wrap"); if (!wrap) return; var s = Math.min((wrap.clientWidth - 28) / W, (wrap.clientHeight - 28) / W); setScale(Math.max(0.14, Math.min(0.5, s))); } fit(); window.addEventListener("resize", fit); return function() { window.removeEventListener("resize", fit); }; }, []); var showToastMsg = function(msg) { setToast(msg); setTimeout(function() { setToast(null); }, 2400); }; // shared canvas bits var brandRow = h("div", { style: { display: "flex", alignItems: "center", gap: "18px" } }, logoSrc ? h("img", { src: logoSrc, alt: "", style: { height: "46px", width: "auto", display: "block" } }) : brandMark("#F5EDD8", 30), h("div", { style: { width: "2px", height: "34px", background: "rgba(245,237,216,0.3)" } }), h("div", { style: { fontFamily: AN, fontSize: "22px", letterSpacing: "2px", padding: "5px 18px", borderRadius: "999px", background: "linear-gradient(160deg,#FFDE7A,#F4AE1C)", color: "#1A0428" } }, "DUEL")); var bigAvatar = function(size) { return h("div", { style: { width: size + "px", height: size + "px", borderRadius: "50%", padding: "6px", background: "linear-gradient(160deg, #C79BF2, #7d4bb5)", boxShadow: "0 0 60px rgba(150,80,220,0.55)", position: "relative", flexShrink: 0 } }, h("div", { style: { width: "100%", height: "100%", borderRadius: "50%", background: "linear-gradient(160deg, #3a1f54, #1d0c30)", display: "flex", alignItems: "center", justifyContent: "center", fontFamily: AN, fontSize: Math.round(size * 0.42) + "px", color: CREAM } }, mgrName.charAt(0).toUpperCase()), h("div", { style: { position: "absolute", right: "-4px", bottom: Math.round(size * 0.05) + "px", width: Math.round(size * 0.3) + "px", height: Math.round(size * 0.34) + "px", clipPath: SHIELD_CLIP, background: "linear-gradient(160deg,#2a1140,#160229)", border: "0", display: "flex", alignItems: "center", justifyContent: "center", boxShadow: "0 0 0 4px rgba(199,155,242,0.7)" } }, h("div", { style: { width: Math.round(size * 0.16) + "px", height: Math.round(size * 0.16) + "px", marginBottom: Math.round(size * 0.045) + "px" } }, penBall(Math.round(size * 0.16))))); }; var statTile = function(icon, label, value) { return h("div", { style: { background: "rgba(26,9,45,0.88)", border: "2.5px solid rgba(199,155,242,0.55)", borderRadius: "24px", padding: "26px 30px", display: "flex", alignItems: "center", gap: "24px" } }, h("div", { style: { width: "72px", height: "72px", borderRadius: "50%", border: "3px solid rgba(199,155,242,0.7)", display: "flex", alignItems: "center", justifyContent: "center", color: GOLD, flexShrink: 0, background: "rgba(120,60,180,0.15)" } }, dIcon(icon, 34)), h("div", null, h("div", { style: { fontFamily: AN, fontSize: "20px", letterSpacing: "2.5px", color: "rgba(245,237,216,0.65)", textTransform: "uppercase" } }, label), h("div", { style: { fontFamily: AN, fontSize: "58px", lineHeight: 1.05, color: "#FFFFFF" } }, value))); }; var dpStrip = function(title1, title2, sub, cta) { return h("div", { style: { display: "flex", alignItems: "center", gap: "26px", padding: "26px 32px", borderRadius: "22px", background: "rgba(14,4,26,0.92)", border: "2px solid rgba(199,155,242,0.5)" } }, h("div", { style: { color: GOLD, flexShrink: 0, filter: "drop-shadow(0 0 16px rgba(247,176,28,0.7))" } }, dIcon("zap", 54)), h("div", { style: { flex: 1, minWidth: 0 } }, h("div", { style: { fontFamily: AN, fontSize: "30px", letterSpacing: "1px", lineHeight: 1.1 } }, h("span", { style: { color: "#FFFFFF" } }, title1 + " "), h("span", { style: { color: GOLD } }, title2)), h("div", { style: { fontSize: "21px", color: "rgba(245,237,216,0.75)", marginTop: "6px", lineHeight: 1.35 } }, sub)), cta); }; var twoXTile = h("div", { style: { flexShrink: 0, width: "116px", height: "116px", borderRadius: "20px", background: "linear-gradient(160deg, rgba(199,155,242,0.4), rgba(120,60,180,0.45))", border: "2.5px solid rgba(199,155,242,0.8)", display: "flex", alignItems: "center", justifyContent: "center", fontFamily: AN, fontSize: "50px", color: "#E7D4FF", boxShadow: "0 0 34px rgba(150,80,220,0.5)", transform: "rotate(-4deg)" } }, "2X"); // ── canvas 1: the RECORD card ──────────────────────────────────────────────── var statsCanvas = h("div", { id: "duel-share-canvas", style: { width: W + "px", height: W + "px", boxSizing: "border-box", position: "relative", overflow: "hidden", padding: "56px 60px", display: "flex", flexDirection: "column", fontFamily: "'Inter', sans-serif", color: CREAM, background: "radial-gradient(760px 560px at 88% 6%, rgba(132,48,176,0.4), transparent 55%), radial-gradient(700px 500px at 0% 100%, rgba(84,26,134,0.35), transparent 55%), linear-gradient(160deg, #170428 0%, #0d0118 100%)" } }, h("div", { style: { display: "flex", justifyContent: "space-between", alignItems: "flex-start" } }, brandRow), h("div", { style: { display: "flex", gap: "40px", alignItems: "flex-start", marginTop: "40px" } }, h("div", { style: { flex: 1, minWidth: 0 } }, h("div", { style: { fontFamily: AN, fontSize: "96px", lineHeight: 0.98, transform: "skewX(-5deg)", color: "#FFFFFF", textShadow: "0 4px 0 rgba(20,4,40,0.9), 0 0 34px rgba(168,92,255,0.4)" } }, "AIN'T NO ONE"), h("div", { style: { fontFamily: AN, fontSize: "96px", lineHeight: 0.98, transform: "skewX(-5deg)", color: GOLD, textShadow: "0 4px 0 rgba(20,4,40,0.9), 0 0 34px rgba(247,176,28,0.45)" } }, "BEATING ME"), h("div", { style: { fontFamily: AN, fontSize: "30px", letterSpacing: "2px", marginTop: "26px" } }, h("span", { style: { color: "#FFFFFF" } }, "BRING ON THE "), h("span", { style: { color: GOLD } }, "CHALLENGE."))), h("div", { style: { textAlign: "center", flexShrink: 0, width: "300px" } }, h("div", { style: { display: "flex", justifyContent: "center" } }, bigAvatar(240)), h("div", { style: { fontFamily: AN, fontSize: "40px", color: "#FFFFFF", marginTop: "18px", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" } }, mgrName), h("div", { style: { fontFamily: AN, fontSize: "20px", letterSpacing: "4px", color: VIOLET, marginTop: "4px" } }, "MANAGER"))), h("div", { style: { display: "grid", gridTemplateColumns: "1fr 1fr", gap: "22px", marginTop: "42px" } }, statTile("trophy", "Duels Won", String(stats.wins != null ? stats.wins : 0)), statTile("flame", "Win Streak", String(stats.streak != null ? stats.streak : 0)), statTile("star", "Total Points", String(Math.round(Number(stats.points) || 0).toLocaleString())), statTile("hash", "Global Rank", stats.rank ? Math.round(stats.rank).toLocaleString() : "—")), h("div", { style: { marginTop: "auto" } }, dpStrip("BEAT MANAGERS.", "EARN DOUBLE POINTS.", "Beat influencers in Duels to earn 2X points for any player next game week!", twoXTile))); // ── canvas 2: the WEEKEND LINEUP card ──────────────────────────────────────── var fk = (pick && pick.formation_key) || "4-3-3"; var fSlots = (FORMATIONS_11V11[fk] || FORMATIONS_11V11["4-3-3"]).slots; var miniStat = function(icon, value, label) { return h("div", { style: { textAlign: "center", flex: 1 } }, h("div", { style: { color: GOLD, display: "flex", justifyContent: "center", marginBottom: "6px" } }, dIcon(icon, 22)), h("div", { style: { fontFamily: AN, fontSize: "32px", color: "#FFFFFF", lineHeight: 1 } }, value), h("div", { style: { fontSize: "13px", letterSpacing: "1px", color: "rgba(245,237,216,0.55)", marginTop: "4px", textTransform: "uppercase" } }, label)); }; var lineupCanvas = h("div", { id: "duel-share-canvas", style: { width: W + "px", height: W + "px", boxSizing: "border-box", position: "relative", overflow: "hidden", padding: "52px 56px", display: "flex", flexDirection: "column", fontFamily: "'Inter', sans-serif", color: CREAM, background: "radial-gradient(700px 520px at 100% 0%, rgba(132,48,176,0.38), transparent 55%), radial-gradient(640px 480px at 0% 100%, rgba(84,26,134,0.32), transparent 55%), linear-gradient(160deg, #170428 0%, #0d0118 100%)" } }, h("div", { style: { display: "flex", justifyContent: "space-between", alignItems: "center" } }, brandRow, h("div", { style: { textAlign: "right" } }, h("div", { style: { fontFamily: AN, fontSize: "17px", letterSpacing: "3px", color: "rgba(245,237,216,0.6)" } }, "WEEKEND LINEUP"), h("div", { style: { fontFamily: AN, fontSize: "40px", color: GOLD, lineHeight: 1, textShadow: "0 0 18px rgba(247,176,28,0.45)" } }, fk))), h("div", { style: { display: "flex", gap: "36px", marginTop: "30px", flex: 1, minHeight: 0 } }, // left: copy + manager + stats h("div", { style: { width: "400px", flexShrink: 0, display: "flex", flexDirection: "column" } }, h("div", { style: { fontFamily: AN, fontSize: "24px", letterSpacing: "4px", color: GOLD } }, "THIS WEEKEND"), h("div", { style: { fontFamily: AN, fontSize: "62px", lineHeight: 1.02, transform: "skewX(-5deg)", color: "#FFFFFF", marginTop: "10px", textShadow: "0 3px 0 rgba(20,4,40,0.9)" } }, "I'M BUILDING"), h("div", { style: { fontFamily: AN, fontSize: "62px", lineHeight: 1.02, transform: "skewX(-5deg)", color: GOLD, textShadow: "0 3px 0 rgba(20,4,40,0.9), 0 0 30px rgba(247,176,28,0.4)" } }, "THE BEST TEAM"), h("div", { style: { fontFamily: AN, fontSize: "27px", letterSpacing: "1.5px", color: "#FFFFFF", marginTop: "26px" } }, "BRING ON THE CHALLENGE."), h("div", { style: { fontSize: "22px", color: "rgba(245,237,216,0.75)", marginTop: "10px", lineHeight: 1.45 } }, "Think you can beat my team in a Duel? ", h("span", { style: { color: GOLD, fontWeight: 700 } }, "CHALLENGE ME NOW!")), h("div", { style: { display: "flex", alignItems: "center", gap: "18px", marginTop: "auto", paddingTop: "22px" } }, bigAvatar(96), h("div", { style: { minWidth: 0 } }, h("div", { style: { fontFamily: AN, fontSize: "32px", color: "#FFFFFF", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" } }, mgrName), h("div", { style: { fontFamily: AN, fontSize: "16px", letterSpacing: "3px", color: VIOLET } }, "MANAGER"))), h("div", { style: { display: "flex", gap: "8px", marginTop: "22px", padding: "18px 10px", borderRadius: "18px", background: "rgba(14,4,26,0.85)", border: "1.5px solid rgba(199,155,242,0.4)" } }, miniStat("trophy", String(stats.wins != null ? stats.wins : 0), "Duels Won"), miniStat("flame", String(stats.streak != null ? stats.streak : 0), "Win Streak"), miniStat("star", String(Math.round(Number(stats.points) || 0).toLocaleString()), "Total Points"), miniStat("hash", stats.rank ? Math.round(stats.rank).toLocaleString() : "—", "Global Rank"))), // right: the XI on a green pitch (mockup style) h("div", { style: { flex: 1, position: "relative", borderRadius: "26px", overflow: "hidden", border: "2px solid rgba(255,255,255,0.18)", background: "linear-gradient(180deg, #1d6b34 0%, #124724 60%, #0d3a1c 100%)", boxShadow: "inset 0 0 80px rgba(0,0,0,0.4)" } }, h("div", { style: { position: "absolute", inset: 0, background: "repeating-linear-gradient(180deg, rgba(255,255,255,0.05) 0 62px, transparent 62px 124px)" } }), h(PitchLines), fSlots.map(function(slot) { var pid = pick && pick.assignments ? pick.assignments[slot.id] : null; var player = pid ? getPlayer(pid) : null; if (!player) return null; var ey = Math.min(slot.y, 86); return h("div", { key: slot.id, style: { position: "absolute", left: slot.x + "%", top: ey + "%", transform: "translate(-50%, -52px) scale(0.82)", display: "flex", flexDirection: "column", alignItems: "center" } }, h(ExportMiniCard, { player: player, isCaptain: pid === (pick && pick.captain) }), h("div", { style: { marginTop: "6px", fontFamily: AN, fontSize: "19px", letterSpacing: "0.5px", color: "#FFFFFF", textShadow: "0 2px 6px rgba(0,0,0,0.8)", textAlign: "center", maxWidth: "140px", lineHeight: 1.05, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" } }, displayName(player).toUpperCase())); }))), h("div", { style: { marginTop: "30px" } }, dpStrip("ACCEPT THE", "CHALLENGE", "Challenge me in Duels and prove you have a better team!", h("div", { style: { flexShrink: 0, fontFamily: AN, fontSize: "28px", letterSpacing: "1px", padding: "20px 34px", borderRadius: "16px", background: "linear-gradient(160deg,#FFDE7A,#F4AE1C)", color: "#1A0428", boxShadow: "0 10px 26px rgba(247,176,28,0.4)" } }, "CHALLENGE ME ›")))); var generateBlob = function() { if (!window.htmlToImage) return Promise.reject(new Error("Export library not loaded")); var jobs = [fetchLogoDataUrl().then(function(url) { if (isDataUrl(url)) setLogoSrc(url); })]; if (tab === "lineup" && pick && pick.assignments) { var players = Object.keys(pick.assignments).map(function(sid) { return getPlayer(pick.assignments[sid]); }).filter(Boolean); jobs.push(inlineAllExportImages(players)); } // resilience: never let a stuck image-proxy round-trip block the export forever — // after 20s proceed with whatever inlined (a missing flag beats a hung button) var jobsWithCap = Promise.race([Promise.all(jobs), new Promise(function(res) { setTimeout(res, 20000); })]); return jobsWithCap.then(function() { setInlineTick(function(t) { return t + 1; }); return afterPaintOrTimeout(450); }).then(function() { var node = document.getElementById("duel-share-canvas"); if (!node) throw new Error("Canvas not found"); // NO cacheBust: every image is a data: URL by now, and cacheBust forces an // uncached CORS re-fetch of the Google-Fonts CSS that can hang toBlob for minutes return waitForImages(node).then(function() { return reliableToBlob(node, { pixelRatio: 1, backgroundColor: "#0d0118" }, isMobile); }); }); }; var handleSave = function() { setBusy(true); generateBlob().then(function(blob) { if (!blob) throw new Error("Failed"); var url = URL.createObjectURL(blob); var a = document.createElement("a"); a.href = url; a.download = "hometeam-duel-" + (tab === "lineup" ? "lineup" : "record") + ".png"; document.body.appendChild(a); a.click(); document.body.removeChild(a); setTimeout(function() { URL.revokeObjectURL(url); }, 1000); showToastMsg("✓ IMAGE SAVED"); }).catch(function() { showToastMsg("⚠ EXPORT FAILED"); }).finally(function() { setBusy(false); }); }; var handleShare = function() { setBusy(true); generateBlob().then(function(blob) { if (!blob) throw new Error("Failed"); var file = new File([blob], "hometeam-duel.png", { type: "image/png" }); if (navigator.canShare && navigator.canShare({ files: [file] })) { return navigator.share({ files: [file], title: "HOME TEAM Duels" }).then(function() { showToastMsg("✓ SHARED"); }); } var url = URL.createObjectURL(blob); var a = document.createElement("a"); a.href = url; a.download = "hometeam-duel.png"; document.body.appendChild(a); a.click(); document.body.removeChild(a); setTimeout(function() { URL.revokeObjectURL(url); }, 1000); showToastMsg("✓ IMAGE SAVED"); }).catch(function(e) { if (!(e && e.name === "AbortError")) showToastMsg("⚠ SHARE FAILED"); }).finally(function() { setBusy(false); }); }; var segBtn = function(id, label) { var on = tab === id; return h("button", { key: id, onClick: function() { setTab(id); }, style: { flex: 1, padding: "10px", borderRadius: "10px", border: on ? "1px solid #FFC836" : "1px solid rgba(199,155,242,0.3)", background: on ? "linear-gradient(160deg,#FFDE7A,#F4AE1C)" : "rgba(199,155,242,0.06)", color: on ? "#1A0428" : "rgba(245,237,216,0.7)", fontFamily: AN, fontSize: "13px", letterSpacing: "1px", cursor: "pointer" } }, label); }; return h("div", { style: { position: "fixed", inset: 0, background: "rgba(8,1,15,0.8)", backdropFilter: "blur(6px)", display: "flex", alignItems: "center", justifyContent: "center", zIndex: 150, padding: "14px" }, onClick: onClose }, h("div", { style: { width: "min(560px, 100%)", maxHeight: "94vh", display: "flex", flexDirection: "column", background: "linear-gradient(180deg, #2a1140 0%, #130325 100%)", border: "1px solid rgba(199,155,242,0.35)", borderRadius: "18px", overflow: "hidden" }, onClick: function(e) { e.stopPropagation(); } }, h("div", { style: { padding: "16px 20px 12px", flexShrink: 0 } }, h("div", { style: { display: "flex", justifyContent: "space-between", alignItems: "center" } }, h("div", null, h("div", { style: { fontFamily: AN, fontSize: "22px", letterSpacing: "1.5px", color: "#F5EDD8" } }, "SHARE YOUR STATS"), h("div", { style: { fontSize: "11px", color: "rgba(245,237,216,0.55)", marginTop: "2px" } }, "Show off your Duels performance!")), h("button", { onClick: onClose, style: { width: "30px", height: "30px", borderRadius: "50%", background: "rgba(255,255,255,0.06)", border: "1px solid rgba(255,255,255,0.1)", color: "#F5EDD8", cursor: "pointer", fontSize: "16px", lineHeight: 1 } }, "×")), h("div", { style: { display: "flex", gap: "8px", marginTop: "12px" } }, segBtn("stats", "MY RECORD"), segBtn("lineup", "WEEKEND LINEUP"))), h("div", { id: "duel-share-wrap", style: { flex: 1, minHeight: "300px", overflow: "hidden", display: "flex", alignItems: "center", justifyContent: "center", background: "rgba(5,1,10,0.5)" } }, h("div", { style: { width: (W * scale) + "px", height: (W * scale) + "px", flexShrink: 0 } }, h("div", { style: { transform: "scale(" + scale + ")", transformOrigin: "top left" } }, tab === "lineup" ? lineupCanvas : statsCanvas))), h("div", { style: { padding: "14px 20px 16px", display: "flex", gap: "10px", flexShrink: 0 } }, h("button", { onClick: handleShare, disabled: busy, style: { flex: 1, padding: "13px", borderRadius: "11px", cursor: "pointer", border: "1px solid rgba(199,155,242,0.4)", background: "rgba(199,155,242,0.1)", color: "#F5EDD8", fontFamily: AN, fontSize: "14px", letterSpacing: "1px", opacity: busy ? 0.6 : 1 } }, "SHARE"), h("button", { onClick: handleSave, disabled: busy, style: { flex: 1, padding: "13px", borderRadius: "11px", cursor: "pointer", border: "1px solid rgba(199,155,242,0.4)", background: "rgba(199,155,242,0.1)", color: "#F5EDD8", fontFamily: AN, fontSize: "14px", letterSpacing: "1px", opacity: busy ? 0.6 : 1 } }, busy ? "WORKING…" : "SAVE IMAGE"), h("button", { onClick: onClose, style: { flex: 1.1, padding: "13px", borderRadius: "11px", cursor: "pointer", border: "none", background: "linear-gradient(160deg,#FFDE7A,#F4AE1C)", color: "#1A0428", fontFamily: AN, fontSize: "14px", letterSpacing: "1px", boxShadow: "0 6px 16px rgba(247,176,28,0.35)" } }, "DONE")), toast && h("div", { style: { position: "absolute", bottom: "78px", left: "50%", transform: "translateX(-50%)", background: "linear-gradient(180deg,#2a1140,#15052a)", border: "1px solid rgba(199,155,242,0.4)", color: "#F5EDD8", padding: "10px 16px", borderRadius: "12px", fontSize: "13px", zIndex: 10 } }, toast))); } function WeekendDuel(props) { props = props || {}; var session = props.session, requireAuth = props.requireAuth; var winW = useWindowSize().w; var isMobile = winW < 768; var user = session && session.user; var _tab = useState("dashboard"), tab = _tab[0], setTab = _tab[1]; var _md = useState(null), matchday = _md[0], setMatchday = _md[1]; var _fk = useState("4-3-3"), formationKey = _fk[0], setFormationKey = _fk[1]; var _a = useState({}), assignments = _a[0], setAssignments = _a[1]; var _cap = useState(null), captain = _cap[0], setCaptain = _cap[1]; var _pool = useState([]), pool = _pool[0], setPool = _pool[1]; var _pl = useState(true), poolLoading = _pl[0], setPoolLoading = _pl[1]; var _pf = useState("ALL"), poolFilter = _pf[0], setPoolFilter = _pf[1]; var _fq = useState(""), poolQuery = _fq[0], setPoolQuery = _fq[1]; var _fo = useState(false), filterOpen = _fo[0], setFilterOpen = _fo[1]; var _modal = useState(null), modal = _modal[0], setModal = _modal[1]; var _busy = useState(false), saveBusy = _busy[0], setSaveBusy = _busy[1]; var _toast = useState(""), toast = _toast[0], setToast = _toast[1]; var _pickId = useState(null), pickId = _pickId[0], setPickId = _pickId[1]; var _profile = useState(null), profile = _profile[0], setProfile = _profile[1]; var _now = useState(function() { return Date.now(); }), now = _now[0], setNow = _now[1]; var _pickStart = useState(null), pickStart = _pickStart[0], setPickStart = _pickStart[1]; var _badgeOpen = useState(null), badgeOpen = _badgeOpen[0], setBadgeOpen = _badgeOpen[1]; var _lbTab = useState("global"), lbTab = _lbTab[0], setLbTab = _lbTab[1]; var _bTab = useState("all"), bTab = _bTab[0], setBTab = _bTab[1]; // P2 — duels + social var _duels = useState([]), myDuels = _duels[0], setMyDuels = _duels[1]; var _inbox = useState([]), inboxDuels = _inbox[0], setInboxDuels = _inbox[1]; var _friends = useState([]), friends = _friends[0], setFriends = _friends[1]; var _freq = useState([]), friendReqs = _freq[0], setFriendReqs = _freq[1]; var _pmap = useState({}), profileMap = _pmap[0], setProfileMap = _pmap[1]; var _chOpen = useState(false), challengeOpen = _chOpen[0], setChallengeOpen = _chOpen[1]; var _inOpen = useState(false), inboxOpen = _inOpen[0], setInboxOpen = _inOpen[1]; var _edOpen = useState(false), editOpen = _edOpen[0], setEditOpen = _edOpen[1]; var _dExp = useState(false), duelsExpanded = _dExp[0], setDuelsExpanded = _dExp[1]; // YOUR DUELS accordion (show 3 → view more) var _chTab = useState("open"), chTab = _chTab[0], setChTab = _chTab[1]; var _openCh = useState([]), openChallenges = _openCh[0], setOpenChallenges = _openCh[1]; // open duels from OTHER managers var _hist = useState([]), historyDuels = _hist[0], setHistoryDuels = _hist[1]; // all my duels across matchdays var _histLd = useState(false), histLoading = _histLd[0], setHistLoading = _histLd[1]; var _mdNames = useState({}), mdNames = _mdNames[0], setMdNames = _mdNames[1]; // matchday id -> name (for history) var _uq = useState(""), userQuery = _uq[0], setUserQuery = _uq[1]; var _ures = useState([]), userResults = _ures[0], setUserResults = _ures[1]; var _socBusy = useState(false), socialBusy = _socBusy[0], setSocialBusy = _socBusy[1]; var _sent = useState([]), sentReqs = _sent[0], setSentReqs = _sent[1]; // my outgoing pending friend reqs var _scores = useState({}), scoresById = _scores[0], setScoresById = _scores[1]; // P3: player_id -> {points,breakdown} var _hsc = useState({}), histScores = _hsc[0], setHistScores = _hsc[1]; // §27: matchday_id -> scores map (summaries of PAST matchdays) var _summary = useState(null), summaryDuel = _summary[0], setSummaryDuel = _summary[1]; // duel shown in DUEL SUMMARY var _lb = useState([]), lbRows = _lb[0], setLbRows = _lb[1]; // P4: leaderboard (profiles by season pts) var _earned = useState({}), earned = _earned[0], setEarned = _earned[1]; // P4: my earned badge keys var _trash = useState({}), trashByDuel = _trash[0], setTrashByDuel = _trash[1]; // P5: duel_id -> [messages] var _tDuel = useState(null), trashDuel = _tDuel[0], setTrashDuel = _tDuel[1]; // open trash-talk thread var _tBody = useState(""), trashBody = _tBody[0], setTrashBody = _tBody[1]; var _seenT = useState({}), seenTrash = _seenT[0], setSeenTrash = _seenT[1]; // duel_id -> last-viewed ms var _hov = useState(null), hoveredSlot = _hov[0], setHoveredSlot = _hov[1]; // pitch hover (drag/arc) var _drag = useState(null), drag = _drag[0], setDrag = _drag[1]; // active drag-to-swap var _submitted = useState(false), submitted = _submitted[0], setSubmitted = _submitted[1]; // pick locked in var _heroTick = useState(0), heroTick = _heroTick[0], setHeroTick = _heroTick[1]; // re-render when hero portraits load var _cmpSel = useState([]), compareSel = _cmpSel[0], setCompareSel = _cmpSel[1]; // #4: up to 2 player ids selected to compare var _cmpOpen = useState(false), compareOpen = _cmpOpen[0], setCompareOpen = _cmpOpen[1]; // #4: the COMPARE modal is open var _customDuel = useState(null), customDuel = _customDuel[0], setCustomDuel = _customDuel[1]; // #8: duel whose CUSTOM XI we're building (null = the weekly team) var _customPicks = useState({}), customPicks = _customPicks[0], setCustomPicks = _customPicks[1]; // #8: duel_id -> true (I've set a custom XI for it) var _oppPick = useState({}), oppPickByDuel = _oppPick[0], setOppPickByDuel = _oppPick[1]; // MATCH PREVIEW: duel_id -> opponent's effective XI {formation_key,assignments,captain,locked,custom} var _infs = useState([]), influencers = _infs[0], setInfluencers = _infs[1]; // §26: flagged influencer profiles var _infIdx = useState(0), infIdx = _infIdx[0], setInfIdx = _infIdx[1]; // §26: takeover carousel index var _infPicks = useState({}), infPicks = _infPicks[0], setInfPicks = _infPicks[1]; // §26: influencer user_id -> weekly pick (public RLS) var _infCounts = useState({}), infCounts = _infCounts[0], setInfCounts = _infCounts[1]; // §26: influencer user_id -> challenger count var _myRewards = useState([]), myRewards = _myRewards[0], setMyRewards = _myRewards[1]; // §26.4: my pending DOUBLE-POINTS cards (earned in EARLIER matchdays) var _boost = useState([]), boostPicks = _boost[0], setBoostPicks = _boost[1]; // §26.4: chosen 2X players (1 card each, capped at cards held) var _shareOpen = useState(null), shareOpen = _shareOpen[0], setShareOpen = _shareOpen[1]; // §26.6: share modal ('stats' | 'lineup' | null) var _shareStats = useState(null), shareStats = _shareStats[0], setShareStats = _shareStats[1]; // §26.6: {wins, streak, points, rank} var _preview = useState(null), previewDuel = _preview[0], setPreviewDuel = _preview[1]; // MATCH PREVIEW: the duel being scouted (null = closed) var _prevSide = useState("them"), prevSide = _prevSide[0], setPrevSide = _prevSide[1]; // MATCH PREVIEW: which XI shows ("them" = opponent / "you") var _duelAlert = useState(null), duelAlert = _duelAlert[0], setDuelAlert = _duelAlert[1]; // blocking popup: fresh incoming duel challenges (dismiss advances the watermark) // TEST MODE (2X preview) — mixes in sample Double-Points cards + a sample resolved duel with a // boost snapshot so the whole 2X flow is previewable solo. Read-only; zero DB writes (§26.8). var _dtm = useState(function() { try { return TEST_MODES_ENABLED && localStorage.getItem("duel_testmode2x") === "1"; } catch (e) { return false; } }), duelTestMode = _dtm[0], setDuelTestMode = _dtm[1]; var _alive = useRef(true); // false after unmount → guards async setState var _searchTimer = useRef(null); // debounce for the manager search var dragRef = useRef(null); // pointer-drag bookkeeping var justDraggedRef = useRef(false); // suppress the click that ends a drag var pitchRef = useRef(null); // pitch bounds for nearest-slot var pickAnchorRef = useRef(null); // {key,start} — anchors the pick timer per matchday/duel (no tab-switch farming) var weeklyStashRef = useRef(null); // #8: the weekly build state, stashed while editing a custom per-duel XI var formation = FORMATIONS_11V11[formationKey] || FORMATIONS_11V11["4-3-3"]; var slots = formation.slots; var uid = user && user.id; // ── P5 trash-talk "seen" tracking (server-clock + persisted) ────────────────── // The unread badge compares each message's SERVER created_at against the newest message the user // has actually viewed (also a server created_at) — never the client clock — so it's immune to // clock skew. The marker is persisted to localStorage (keyed by uid) so unread survives reloads. var maxCreatedAt = function(msgs) { var mx = 0; (msgs || []).forEach(function(m) { var t = new Date(m.created_at).getTime(); if (t > mx) mx = t; }); return mx; }; var markTrashSeen = function(duelId, msgs) { var mx = maxCreatedAt(msgs); setSeenTrash(function(prev) { if (mx <= (prev[duelId] || 0)) return prev; var n = Object.assign({}, prev); n[duelId] = mx; // don't persist sample (test:) duel keys to localStorage — keep it in-memory only if (!isTestId(duelId)) { try { if (uid) localStorage.setItem("tt_seen:" + uid, JSON.stringify(n)); } catch (e) {} } return n; }); }; // hydrate the seen-markers from localStorage once uid is known (max-merge so we never regress one) useEffect(function() { if (!uid) return; try { var raw = localStorage.getItem("tt_seen:" + uid); if (!raw) return; var stored = JSON.parse(raw) || {}; setSeenTrash(function(prev) { var n = Object.assign({}, prev); Object.keys(stored).forEach(function(k) { if ((stored[k] || 0) > (n[k] || 0)) n[k] = stored[k]; }); return n; }); } catch (e) {} }, [uid]); // ── live 1s clock for countdowns (the legitimate Date.now() use) ────────────── useEffect(function() { var t = setInterval(function() { setNow(Date.now()); }, 1000); return function() { clearInterval(t); }; }, []); // Pick timer: the 200-second window starts when the FIRST player is placed (not on entering the build // tab) — so staring at an empty pitch doesn't burn the clock. Anchored per matchday/duel in a ref // so removing+re-adding players or leaving+re-entering does NOT reset it (no farming); it resets // only on a full reload or a new matchday/duel. useEffect(function() { if (tab !== "build" || !matchday) return; if (!Object.keys(assignments).length) return; // no players yet → countdown hasn't started var key = customDuel ? ("duel:" + customDuel.id) : ("md:" + matchday.id); // #8: a custom XI has its own timer window var a = pickAnchorRef.current; if (!a || a.key !== key) { a = { key: key, start: Date.now() }; pickAnchorRef.current = a; } setPickStart(a.start); }, [tab, matchday && matchday.id, customDuel && customDuel.id, Object.keys(assignments).length > 0]); // track mount so async handlers don't setState after unmount/logout useEffect(function() { _alive.current = true; return function() { _alive.current = false; }; }, []); // lazy-load the decorative hero trio's portraits, then re-render to show them useEffect(function() { var alive = true; loadHeroTrioPortraits().then(function() { if (alive && _alive.current) setHeroTick(function(t) { return t + 1; }); }); return function() { alive = false; }; }, []); // ── load matchday + profile + existing pick on login ────────────────────────── useEffect(function() { if (!sb || !session) { setMatchday(null); setProfile(null); return; } var alive = true; // guard stale .then() callbacks if uid changes mid-flight sb.from("matchdays").select("*").order("lock_at", { ascending: false }).limit(1) .then(function(res) { if (!alive) return; var md = (res.data || [])[0] || null; setMatchday(md); if (md) { sb.from("matchday_picks").select("*").eq("matchday_id", md.id).eq("user_id", uid).limit(1).then(function(r2) { if (!alive) return; var pk = (r2.data || [])[0]; if (pk) { setPickId(pk.id); if (pk.formation_key) setFormationKey(pk.formation_key); setAssignments(pk.assignments || {}); setCaptain(pk.captain || null); if (pk.locked) setSubmitted(true); // a submitted XI stays locked across reloads // §26.4: restore the DOUBLE-POINTS choices (array; legacy single boost_player honored) setBoostPicks(Array.isArray(pk.boost_players) && pk.boost_players.length ? pk.boost_players : (pk.boost_player ? [pk.boost_player] : [])); } }, function() {}); } }, function() {}); // ensure a profile row exists (team name defaults from the email handle). upsert with // ignoreDuplicates is race-safe AND won't clobber a customised team_name on re-login. sb.from("profiles").select("*").eq("user_id", uid).limit(1).then(function(res) { if (!alive) return; var pr = (res.data || [])[0]; if (pr) { setProfile(pr); return; } var def = { user_id: uid, team_name: ((user.email || "manager").split("@")[0]) }; sb.from("profiles").upsert(def, { onConflict: "user_id", ignoreDuplicates: true }).select().then( function(r) { if (alive) setProfile((r.data && r.data[0]) || def); }, function() { if (alive) setProfile(def); } ); }, function() {}); return function() { alive = false; }; }, [uid]); // ── load the player pool on first open ──────────────────────────────────────── useEffect(function() { if (!session) return; var alive = true; setPoolLoading(true); fetchDuelPool().then(function(list) { if (alive) { setPool(list); setPoolLoading(false); } }); return function() { alive = false; }; }, [uid]); // ── duels + social graph: load on matchday/login, then poll for incoming invites ─ var fetchProfiles = function(ids) { var need = (ids || []).filter(function(id) { return id && !profileMap[id]; }); if (!need.length || !sb) return; sb.from("profiles").select("user_id,team_name,country,city").in("user_id", need).then(function(res) { if (!_alive.current || !res.data || !res.data.length) return; setProfileMap(function(prev) { var n = Object.assign({}, prev); res.data.forEach(function(p) { n[p.user_id] = p; }); return n; }); }, function(e) { console.warn("fetchProfiles failed", e && e.message); }); }; // §26 — INFLUENCER TAKEOVER: flagged profiles, their PUBLIC weekly XIs (additive RLS), // live challenger counts (safe aggregate RPC) and my pending 2X reward. var loadInfluencers = function() { if (!sb || !uid || !matchday) return; sb.from("profiles").select("user_id,team_name,country,influencer_tagline,influencer_photo,influencer_hero").eq("is_influencer", true).then(function(res) { if (!_alive.current) return; var list = (res.data || []).filter(function(p) { return p.user_id !== uid; }); setInfluencers(list); if (!list.length) return; var ids = list.map(function(p) { return p.user_id; }); sb.from("matchday_picks").select("user_id,formation_key,assignments,captain").eq("matchday_id", matchday.id).in("user_id", ids).then(function(r2) { if (!_alive.current) return; var m = {}; (r2.data || []).forEach(function(pk) { m[pk.user_id] = pk; }); setInfPicks(m); }, function() {}); list.forEach(function(p) { sb.rpc("influencer_challenge_count", { inf: p.user_id, md: matchday.id }).then(function(r3) { if (!_alive.current) return; setInfCounts(function(prev) { var n = Object.assign({}, prev); n[p.user_id] = (typeof r3.data === "number") ? r3.data : 0; return n; }); }, function() {}); }); }, function(e) { console.warn("loadInfluencers failed", e && e.message); }); sb.from("influencer_rewards").select("*").eq("user_id", uid).eq("status", "pending").order("created_at", { ascending: true }).then(function(res) { if (!_alive.current) return; // §26.4: ALL pending cards usable this matchday (earned in EARLIER matchdays only) setMyRewards((res.data || []).filter(function(r) { return r.earned_matchday_id !== matchday.id; })); }, function() {}); }; // §26.6 — open the share modal; stats load async (streak = consecutive wins across my most // recent resolved duels; rank = managers with more season points + 1) var openShare = function(tab) { setShareOpen(tab); setShareStats({ wins: (profile && profile.duels_won) || 0, streak: 0, points: (profile && profile.season_points) || 0, rank: null }); if (!sb || !uid) return; sb.from("duels").select("winner_id,updated_at").eq("status", "resolved").or("challenger_id.eq." + uid + ",opponent_id.eq." + uid).order("updated_at", { ascending: false }).limit(60).then(function(res) { if (!_alive.current || !res.data) return; var streak = 0; for (var i = 0; i < res.data.length; i++) { if (res.data[i].winner_id === uid) streak++; else break; } setShareStats(function(s) { return Object.assign({}, s || {}, { streak: streak }); }); }, function() {}); var myPts = (profile && Number(profile.season_points)) || 0; sb.from("profiles").select("user_id", { count: "exact", head: true }).gt("season_points", myPts).then(function(res) { if (!_alive.current) return; setShareStats(function(s) { return Object.assign({}, s || {}, { rank: (res.count != null) ? res.count + 1 : null }); }); }, function() {}); }; var loadDuelsAndSocial = function() { if (!sb || !uid || !matchday) return; loadInfluencers(); sb.from("duels").select("*").eq("matchday_id", matchday.id).or("challenger_id.eq." + uid + ",opponent_id.eq." + uid).order("created_at", { ascending: false }).then(function(res) { if (!_alive.current) return; var mine = (res.data || []).filter(function(d) { return d.challenger_id === uid || d.opponent_id === uid; }); setMyDuels(mine); var inbox = mine.filter(function(d) { return d.opponent_id === uid && d.status === "pending"; }); setInboxDuels(inbox); // blocking challenge popup: incoming duels newer than the dismissal watermark (advanced on dismiss) var seenChMs = 0; try { seenChMs = parseInt(localStorage.getItem("duel_alert_seen:" + uid) || "0", 10) || 0; } catch (e) {} var freshCh = inbox.filter(function(d) { return new Date(d.created_at).getTime() > seenChMs; }); setDuelAlert(freshCh.length ? freshCh : null); var ids = {}; mine.forEach(function(d) { var o = d.challenger_id === uid ? d.opponent_id : d.challenger_id; if (o) ids[o] = 1; }); fetchProfiles(Object.keys(ids)); // MATCH PREVIEW — load each accepted-duel opponent's effective XI for scouting (their weekly pick, // or their CUSTOM XI for this specific duel). RLS now lets a participant read the other's pick. var accepted = mine.filter(function(d) { return d.status === "accepted" && d.opponent_id && d.challenger_id && !isTestId(d.id); }); if (accepted.length) { var oppByDuel = {}, oppIds = {}; accepted.forEach(function(d) { var o = d.challenger_id === uid ? d.opponent_id : d.challenger_id; oppByDuel[d.id] = o; oppIds[o] = 1; }); var accIds = accepted.map(function(d) { return d.id; }); Promise.all([ sb.from("matchday_picks").select("user_id,formation_key,assignments,captain,locked").eq("matchday_id", matchday.id).in("user_id", Object.keys(oppIds)), sb.from("duel_picks").select("duel_id,user_id,formation_key,assignments,captain,locked").in("duel_id", accIds) ]).then(function(rr) { if (!_alive.current) return; var weekly = {}; ((rr[0] && rr[0].data) || []).forEach(function(p) { weekly[p.user_id] = p; }); var custom = {}; ((rr[1] && rr[1].data) || []).forEach(function(p) { custom[p.duel_id + "|" + p.user_id] = p; }); var out = {}; accepted.forEach(function(d) { var o = oppByDuel[d.id]; var c = custom[d.id + "|" + o]; var pick = c || weekly[o]; if (pick && pick.assignments) out[d.id] = { formation_key: pick.formation_key, assignments: pick.assignments, captain: pick.captain, locked: !!pick.locked, custom: !!c }; }); setOppPickByDuel(out); }, function(e) { console.warn("load opponent picks failed", e && e.message); }); } var dIds = mine.map(function(d) { return d.id; }); if (dIds.length) sb.from("trash_talk").select("*").in("duel_id", dIds).order("created_at", { ascending: true }).limit(300).then(function(tr) { if (!_alive.current) return; var byD = {}; (tr.data || []).forEach(function(m) { (byD[m.duel_id] = byD[m.duel_id] || []).push(m); }); setTrashByDuel(byD); // first time we ever see a duel's thread (no stored marker), treat existing history as read so // the unread badge means "new since you last had the app open", not "all-time". setSeenTrash(function(prev) { var changed = false, n = prev; Object.keys(byD).forEach(function(did) { if (prev[did] === undefined) { if (!changed) { n = Object.assign({}, prev); changed = true; } n[did] = maxCreatedAt(byD[did]); } }); if (changed) { try { if (uid) localStorage.setItem("tt_seen:" + uid, JSON.stringify(n)); } catch (e) {} } return n; }); }, function() {}); }, function(e) { console.warn("load duels failed", e && e.message); }); sb.from("player_scores").select("player_id,points,breakdown").eq("matchday_id", matchday.id).then(function(res) { if (!_alive.current) return; var m = {}; (res.data || []).forEach(function(s) { m[s.player_id] = s; }); setScoresById(m); }, function(e) { console.warn("load scores failed", e && e.message); }); sb.from("friendships").select("*").or("user_id.eq." + uid + ",friend_id.eq." + uid).then(function(res) { if (!_alive.current) return; var rows = res.data || []; setFriends(rows.filter(function(f) { return f.status === "accepted"; })); setFriendReqs(rows.filter(function(f) { return f.status === "pending" && f.friend_id === uid; })); setSentReqs(rows.filter(function(f) { return f.status === "pending" && f.user_id === uid; })); var ids = {}; rows.forEach(function(f) { var o = f.user_id === uid ? f.friend_id : f.user_id; if (o) ids[o] = 1; }); fetchProfiles(Object.keys(ids)); }, function(e) { console.warn("load friends failed", e && e.message); }); // P4 — leaderboard (profiles ranked by season points) + my earned badges sb.from("profiles").select("user_id,team_name,city,state,country,season_points,duels_won").order("season_points", { ascending: false }).limit(100).then(function(res) { if (_alive.current) setLbRows(res.data || []); }, function(e) { console.warn("load leaderboard failed", e && e.message); }); sb.from("user_badges").select("badge_key").eq("user_id", uid).then(function(res) { if (!_alive.current) return; var m = {}; (res.data || []).forEach(function(b) { m[b.badge_key] = true; }); setEarned(m); }, function() {}); // Open challenges posted by OTHER managers (browsable + accept in the OPEN tab; RLS allows reading open duels) sb.from("duels").select("*").eq("matchday_id", matchday.id).is("opponent_id", null).eq("status", "pending").neq("challenger_id", uid).order("created_at", { ascending: false }).limit(50).then(function(res) { if (!_alive.current) return; var list = res.data || []; setOpenChallenges(list); fetchProfiles(list.map(function(d) { return d.challenger_id; })); }, function() {}); // #8 — which of my duels I've set a CUSTOM XI for (owner-only RLS → only my rows) sb.from("duel_picks").select("duel_id").eq("user_id", uid).then(function(res) { if (!_alive.current) return; var m = {}; (res.data || []).forEach(function(d) { m[d.duel_id] = true; }); setCustomPicks(m); }, function() {}); }; useEffect(function() { if (!sb || !session || !matchday) return; var alive = true; var run = function() { if (alive) loadDuelsAndSocial(); }; run(); var t = setInterval(run, 25000); // light polling for challenge/friend invites (no realtime) return function() { alive = false; clearInterval(t); }; }, [uid, matchday && matchday.id]); // load full duel history when the HISTORY tab is switched to useEffect(function() { if (tab === "history") loadHistory(); }, [tab]); // §27 — a summary opened from HISTORY belongs to a PAST matchday (weekly matchdays now): // fetch that matchday's player_scores once (cached per matchday) so the per-player pills // aren't blank. `scoresById` only ever holds the CURRENT matchday. var loadHistScores = function(mid) { if (!sb || !mid || histScores[mid]) return; sb.from("player_scores").select("player_id,points,breakdown").eq("matchday_id", mid).then(function(res) { if (!_alive.current) return; var m = {}; (res.data || []).forEach(function(s) { m[s.player_id] = s; }); setHistScores(function(prev) { var n = Object.assign({}, prev); n[mid] = m; return n; }); }, function(e) { console.warn("load past scores failed", e && e.message); }); }; useEffect(function() { if (!summaryDuel || !summaryDuel.matchday_id) return; if (matchday && summaryDuel.matchday_id === matchday.id) return; loadHistScores(summaryDuel.matchday_id); }, [summaryDuel && summaryDuel.id]); // while a trash-talk thread is open, poll it faster + keep it marked read useEffect(function() { if (!trashDuel || !sb) return; var alive = true; var poll = function() { sb.from("trash_talk").select("*").eq("duel_id", trashDuel.id).order("created_at", { ascending: true }).limit(300).then(function(res) { if (!alive || !_alive.current) return; setTrashByDuel(function(prev) { var n = Object.assign({}, prev); n[trashDuel.id] = res.data || []; return n; }); markTrashSeen(trashDuel.id, res.data || []); // thread is open → mark everything loaded as read (server-clock) }, function() {}); }; poll(); var t2 = setInterval(poll, 6000); return function() { alive = false; clearInterval(t2); }; }, [trashDuel]); var teamName = (profile && profile.team_name) || (user && user.email ? user.email.split("@")[0] : "YOU"); // ── derived squad totals ────────────────────────────────────────────────────── var squad = useMemo(function() { var placed = 0, sumR = 0, sumP = 0; slots.forEach(function(s) { var p = getPlayer(assignments[s.id]); if (p) { placed++; sumR += (p.rating || 0); sumP += fantasyProjection(p) * (assignments[s.id] === captain ? 2 : 1); } }); return { placed: placed, rating: placed ? Math.round(sumR / placed) : 0, proj: Math.round(sumP * 10) / 10 }; // `pool` is a dep so this recomputes once a saved pick's players are registered by // fetchDuelPool (the pick can load before the pool, leaving getPlayer() null otherwise). }, [formationKey, assignments, captain, pool]); var chemForSlot = function(slot, p) { if (!p) return 0; var posChem = p.pos === slot.pos ? 7 : (p.alt && p.alt.indexOf(slot.pos) >= 0) ? 5 : (POS_GROUP[p.pos] === POS_GROUP[slot.pos] ? 3 : 0); var natN = {}; Object.keys(assignments).forEach(function(sid) { var q = getPlayer(assignments[sid]); if (q && q.nation) natN[q.nation] = (natN[q.nation] || 0) + 1; }); return Math.min(10, posChem + Math.min(3, (natN[p.nation] || 1) - 1)); }; // ── team handlers ───────────────────────────────────────────────────────────── var handleSlotClick = function(slot) { if (pickLocked) { flashToast(lockReason || "Locked in — you can't edit your XI."); return; } setModal({ type: getPlayer(assignments[slot.id]) ? "stats" : "search", slot: slot }); }; var pickPlayer = function(player) { if (!modal || !modal.slot) return; var sid = modal.slot.id; // DUEL rule: a player can only fill a slot they can play (stops all-forward XIs). if (!canPlaySlot(player, modal.slot)) { flashToast(displayName(player) + " can't play " + modal.slot.pos + " — pick a " + slotGroupLabel(modal.slot) + "."); return; } if (assignments[sid] === captain && player.id !== captain) setCaptain(null); setAssignments(function(prev) { var n = Object.assign({}, prev); // a player can only hold one slot — if picked from SEARCH/BROWSE while already on // the pitch, MOVE them here instead of duplicating Object.keys(n).forEach(function(k) { if (k !== sid && n[k] === player.id) delete n[k]; }); n[sid] = player.id; return n; }); setModal(null); }; var placeFromPool = function(player) { if (pickLocked) { flashToast(lockReason || "Locked in — you can't edit your XI."); return; } var onPitch = Object.keys(assignments).some(function(k) { return assignments[k] === player.id; }); if (onPitch) return; var grp = POS_GROUP[player.pos]; // quick-add into the player's EXACT slot first (RB->RB), then a listed alt, then any same-line // slot — keeps a balanced, correctly-positioned XI (no all-forward stacking, no full-back in a CB slot). var target = slots.filter(function(s) { return !assignments[s.id] && s.pos === player.pos; })[0] || slots.filter(function(s) { return !assignments[s.id] && player.alt && player.alt.indexOf(s.pos) >= 0; })[0] || slots.filter(function(s) { return !assignments[s.id] && canPlaySlot(player, s); })[0]; if (!target) { var full = slots.every(function(s) { return assignments[s.id]; }); setToast(full ? "Your XI is full — remove someone first." : "No open " + (grp === "ATT" ? "FWD" : grp) + " spot — every " + (grp === "ATT" ? "FWD" : grp) + " slot is taken."); setTimeout(function() { setToast(""); }, 2400); return; } setAssignments(function(prev) { var n = Object.assign({}, prev); n[target.id] = player.id; return n; }); }; var removeFromSlot = function(slot) { setAssignments(function(prev) { var n = Object.assign({}, prev); var was = n[slot.id]; delete n[slot.id]; if (was === captain) setCaptain(null); return n; }); setModal(null); }; var toggleCaptain = function(pid) { setCaptain(function(c) { return c === pid ? null : pid; }); setModal(null); }; var autoPick = function() { setAssignments(function(prev) { var n = Object.assign({}, prev); var used = {}; Object.keys(n).forEach(function(k) { used[n[k]] = 1; }); var empties = slots.filter(function(s) { return !n[s.id]; }); // Fill in priority passes so EXACT-position players land in their own slot (RB->RB, LW->LW, CB->CB) // before any same-line fallback — no more right-backs ending up in center-back slots. The last // pass (anyone) only triggers if a line runs out of players, so the XI always fills 11/11. [ function(p, s) { return p.pos === s.pos; }, function(p, s) { return p.alt && p.alt.indexOf(s.pos) >= 0; }, function(p, s) { return POS_GROUP[p.pos] === POS_GROUP[s.pos]; }, function(p, s) { return true; } ].forEach(function(match) { empties.forEach(function(s) { if (n[s.id]) return; var pick = pool.filter(function(p) { return !used[p.id] && match(p, s); })[0]; if (pick) { n[s.id] = pick.id; used[pick.id] = 1; } }); }); return n; }); }; // captain housekeeping: clear an orphaned captain, else auto-pick the top projected // starter once the XI is full (also covers Auto Pick). useEffect(function() { if (captain && Object.values(assignments).indexOf(captain) < 0) { setCaptain(null); return; } if (captain) return; if (squad.placed < slots.length) return; var best = null, bestP = -1; slots.forEach(function(s) { var p = getPlayer(assignments[s.id]); if (p) { var v = fantasyProjection(p); if (v > bestP) { bestP = v; best = p.id; } } }); if (best) setCaptain(best); }, [assignments]); var clearTeam = function() { setAssignments({}); setCaptain(null); }; // #4 — pick up to 2 players to compare head-to-head; auto-open the modal once a pair is chosen. var toggleCompare = function(pid) { var sel = compareSel, next; if (sel.indexOf(pid) >= 0) next = sel.filter(function(x) { return x !== pid; }); else if (sel.length >= 2) next = [sel[1], pid]; // keep the most recent two else next = sel.concat([pid]); setCompareSel(next); if (next.length === 2) setCompareOpen(true); }; var saveTeam = function() { if (!session) { if (requireAuth) requireAuth(); return; } if (!sb || !matchday) { setToast("No active matchday yet."); setTimeout(function() { setToast(""); }, 2400); return; } if (pickLocked) { flashToast(lockReason || "Picks are locked."); return; } if (squad.placed < slots.length) { setToast("Fill all " + slots.length + " spots first."); setTimeout(function() { setToast(""); }, 2400); return; } setSaveBusy(true); // #8 — a CUSTOM XI is saved to duel_picks for ONE specific duel (the weekly matchday pick stays intact). if (customDuel) { var duelId = customDuel.id; // capture — customDuel may change before the promise resolves var crow = { duel_id: duelId, user_id: uid, formation_key: formationKey, assignments: assignments, captain: captain || null, locked: true, submitted_at: new Date().toISOString(), updated_at: new Date().toISOString() }; sb.from("duel_picks").upsert(crow, { onConflict: "duel_id,user_id" }).select().single().then(function(res) { setSaveBusy(false); if (res.error) { setToast("Couldn't submit — " + res.error.message); } else { setSubmitted(true); setCustomPicks(function(m) { var n = Object.assign({}, m); n[duelId] = true; return n; }); setToast("✓ XI LOCKED IN FOR THIS DUEL"); } setTimeout(function() { setToast(""); }, 2600); }, function(e) { setSaveBusy(false); setToast("Couldn't submit — " + ((e && e.message) || "error")); setTimeout(function() { setToast(""); }, 2600); }); return; } // submitting LOCKS the XI for this matchday — no further changes (the game's "lock it in" rule). var row = { user_id: uid, matchday_id: matchday.id, game_mode: "11v11", formation_key: formationKey, assignments: assignments, captain: captain || null, locked: true, boost_players: myRewards.length ? boostPicks.slice(0, myRewards.length) : [], // §26.4: the 2X choices (server re-checks eligibility + cap) boost_player: (myRewards.length && boostPicks[0]) ? boostPicks[0] : null, // legacy mirror submitted_at: new Date().toISOString(), updated_at: new Date().toISOString() }; sb.from("matchday_picks").upsert(row, { onConflict: "matchday_id,user_id" }).select().single().then(function(res) { setSaveBusy(false); if (res.error) { setToast("Couldn't submit — " + res.error.message); } else { if (res.data) setPickId(res.data.id); setSubmitted(true); setToast("✓ TEAM LOCKED IN"); } setTimeout(function() { setToast(""); }, 2600); }, function(e) { setSaveBusy(false); setToast("Couldn't submit — " + ((e && e.message) || "error")); setTimeout(function() { setToast(""); }, 2600); }); }; // Save ONLY the 2X boost picks onto the already-submitted weekly pick. The XI lock protects // the team, not the cards — boosts stay editable until the matchday deadline (scoring runs // after the weekend, so there's nothing to game pre-deadline; the scorer re-checks the cap). var saveBoosts = function() { if (!sb || !uid || !matchday) return; if (duelTestMode && !myRewards.length) { flashToast("TEST MODE — sample cards only, nothing to save. Toggle test mode off once you hold real 2X cards."); return; } var upd = { boost_players: myRewards.length ? boostPicks.slice(0, myRewards.length) : [], boost_player: (myRewards.length && boostPicks[0]) ? boostPicks[0] : null, updated_at: new Date().toISOString() }; sb.from("matchday_picks").update(upd).eq("matchday_id", matchday.id).eq("user_id", uid).select().then(function(res) { if (!_alive.current) return; if (res.error) { flashToast("Couldn't save — " + res.error.message); return; } if (!res.data || !res.data.length) { flashToast("Submit your team first — 2X picks ride on your weekly XI."); return; } flashToast(duelTestMode && boostPicks.length > myRewards.length ? "✓ 2X PICKS SAVED (real cards only — test cards don't submit)" : "✓ 2X PICKS SAVED", 2800); }, function(e) { flashToast("Couldn't save — " + ((e && e.message) || "error")); }); }; // #8 — enter a CUSTOM-XI build for one specific duel. Stash the WEEKLY build exactly once // (ref-guard, NOT the stale closure customDuel) so switching between custom duels never // overwrites it — exit always restores the true weekly team. Then fetch this duel's saved // custom pick FIRST and apply everything together (no window where the formation select is // editable before the loaded pick overwrites it). A fresh custom XI clones the weekly team. var enterCustomXI = function(duel) { if (!duel) return; if (!weeklyStashRef.current) weeklyStashRef.current = { formationKey: formationKey, assignments: assignments, captain: captain, submitted: submitted, pickId: pickId }; var base = weeklyStashRef.current; var apply = function(dp) { if (!_alive.current) return; setCustomDuel(duel); setCompareSel([]); setCompareOpen(false); if (dp) { setFormationKey(dp.formation_key || base.formationKey); setAssignments(dp.assignments || {}); setCaptain(dp.captain || null); setSubmitted(!!dp.locked); } else { setFormationKey(base.formationKey); setAssignments({}); setCaptain(null); setSubmitted(false); } // fresh duel → EMPTY pitch (build a team for THIS duel) setTab("build"); }; if (sb && uid) sb.from("duel_picks").select("*").eq("duel_id", duel.id).eq("user_id", uid).limit(1).then(function(r) { apply((r.data || [])[0] || null); }, function() { apply(null); }); else apply(null); }; var exitCustomXI = function() { var st = weeklyStashRef.current; setCustomDuel(null); setCompareSel([]); setCompareOpen(false); if (st) { setFormationKey(st.formationKey); setAssignments(st.assignments); setCaptain(st.captain); setSubmitted(st.submitted); setPickId(st.pickId); } weeklyStashRef.current = null; }; // #8 — if the duel we're building a custom XI for becomes resolved/declined/cancelled (via the // 25s poll), drop back to the weekly team so we're not editing a dead duel. useEffect(function() { if (!customDuel) return; var d = myDuels.filter(function(x) { return x.id === customDuel.id; })[0]; if (d && d.status !== "accepted" && d.status !== "pending") exitCustomXI(); }, [myDuels, customDuel]); // ── duels + social handlers ─────────────────────────────────────────────────── var nameFor = function(userId) { if (userId === "test:rival") return "Test Rival FC"; if (userId === "test:challenger") return "Pixel United"; var p = profileMap[userId]; return (p && p.team_name) || "Manager"; }; var flashToast = function(msg, ms) { if (!_alive.current) return; setToast(msg); setTimeout(function() { if (_alive.current) setToast(""); }, ms || 2400); }; // Repeat-duel rule (client 2026-06-21): FRIENDS may duel each other as many times as they like — each // duel carries its OWN lineup (the per-duel custom XI). Everyone else gets ONE active duel per pair, so // nobody can spam-challenge the same person through the random / open / search pool. var hasActiveDuelWith = function(otherId) { return myDuels.some(function(d) { var other = d.challenger_id === uid ? d.opponent_id : d.challenger_id; return other === otherId && (d.status === "pending" || d.status === "accepted"); }); }; var canCreateDuelWith = function(otherId) { return friendIds.indexOf(otherId) >= 0 || !hasActiveDuelWith(otherId); }; var createOpenChallenge = function() { if (!sb || !matchday) { setSocialBusy(false); return; } // never post a SECOND open challenge — guards findRandom's "post my own" fallback when the // user already has one live (e.g. they quick-matched but no other open challenges exist). if (myDuels.some(function(d) { return d.challenger_id === uid && d.status === "pending" && !d.opponent_id; })) { setSocialBusy(false); flashToast("Your open challenge is already live — waiting for an opponent.", 2800); return; } sb.from("duels").insert({ matchday_id: matchday.id, challenger_id: uid, opponent_id: null, status: "pending" }).select().then(function(res) { setSocialBusy(false); setChallengeOpen(false); loadDuelsAndSocial(); var nd = res && res.data && res.data[0]; flashToast("✓ OPEN CHALLENGE POSTED — build your XI while you wait", 2600); if (nd) enterCustomXI(nd); // build a team for the open challenge now; it's used when someone accepts }, function(e) { setSocialBusy(false); flashToast("Couldn't post — " + ((e && e.message) || "error")); }); }; // Accept a SPECIFIC open challenge from the OPEN list (race-safe: only succeeds if still unclaimed). var acceptOpenChallenge = function(duel) { if (!sb || !uid || !duel) return; if (!canCreateDuelWith(duel.challenger_id)) { flashToast("You already have a duel with them — only friends can duel again.", 2800); return; } setSocialBusy(true); sb.from("duels").update({ opponent_id: uid, status: "accepted", updated_at: new Date().toISOString() }).eq("id", duel.id).is("opponent_id", null).select().then(function(r) { setSocialBusy(false); if (r.data && r.data.length) { setChallengeOpen(false); loadDuelsAndSocial(); flashToast("✓ ACCEPTED — now build your XI", 2400); enterCustomXI(r.data[0]); } else { flashToast("That challenge was just taken — try another."); loadDuelsAndSocial(); } }, function(e) { setSocialBusy(false); flashToast("Couldn't accept — " + ((e && e.message) || "error")); }); }; // Full duel history across ALL matchdays (the dashboard only loads the current one). var loadHistory = function() { if (!sb || !uid) return; setHistLoading(true); sb.from("duels").select("*").eq("status", "resolved").or("challenger_id.eq." + uid + ",opponent_id.eq." + uid).order("created_at", { ascending: false }).limit(100).then(function(res) { if (!_alive.current) return; var list = (res.data || []).filter(function(d) { return d.status === "resolved" && (d.challenger_id === uid || d.opponent_id === uid); }); setHistoryDuels(list); setHistLoading(false); fetchProfiles(list.map(function(d) { return d.challenger_id === uid ? d.opponent_id : d.challenger_id; }).filter(Boolean)); var mdIds = {}; list.forEach(function(d) { if (d.matchday_id) mdIds[d.matchday_id] = 1; }); if (Object.keys(mdIds).length) sb.from("matchdays").select("id,name").in("id", Object.keys(mdIds)).then(function(mr) { if (!_alive.current) return; var n = {}; (mr.data || []).forEach(function(m) { n[m.id] = m.name; }); setMdNames(n); }, function() {}); }, function(e) { setHistLoading(false); console.warn("history load failed", e && e.message); }); }; var findRandom = function() { if (!sb || !uid || !matchday) return; setSocialBusy(true); // claim an existing open challenge (not mine); race-safe via the opponent_id IS NULL guard sb.from("duels").select("*").eq("matchday_id", matchday.id).is("opponent_id", null).eq("status", "pending").neq("challenger_id", uid).limit(30).then(function(res) { // skip opens from people we already have an active duel with (unless they're a friend) — random must never repeat-match the same person var open = (res.data || []).filter(function(o) { return canCreateDuelWith(o.challenger_id); })[0]; if (!open) { createOpenChallenge(); return; } sb.from("duels").update({ opponent_id: uid, status: "accepted", updated_at: new Date().toISOString() }).eq("id", open.id).is("opponent_id", null).select().then(function(r) { if (r.data && r.data.length) { setSocialBusy(false); setChallengeOpen(false); loadDuelsAndSocial(); flashToast("✓ MATCHED — now build your XI", 2400); enterCustomXI(r.data[0]); } else { createOpenChallenge(); } // someone grabbed it first → post our own }, function() { createOpenChallenge(); }); }, function() { setSocialBusy(false); }); }; var challengeUser = function(targetId) { if (!sb || !uid || !matchday || targetId === uid) return; if (!canCreateDuelWith(targetId)) { flashToast("You already have a duel with them — add them as a friend to duel again.", 2800); return; } // §26 — challenging an INFLUENCER locks in instantly (status accepted): they post one XI // for thousands of challengers and can't hand-accept each duel. var isInf = influencers.some(function(p) { return p.user_id === targetId; }); setSocialBusy(true); sb.from("duels").insert({ matchday_id: matchday.id, challenger_id: uid, opponent_id: targetId, status: isInf ? "accepted" : "pending" }).select().then(function(res) { setSocialBusy(false); setChallengeOpen(false); loadDuelsAndSocial(); var nd = res && res.data && res.data[0]; flashToast(isInf ? ("✓ YOU'RE IN — CHALLENGE VS " + nameFor(targetId).toUpperCase() + " IS LOCKED. Build your XI!") : ("✓ CHALLENGED " + nameFor(targetId).toUpperCase() + " — now build your XI"), 2600); if (nd) enterCustomXI(nd); // straight into a fresh (empty) build for THIS duel }, function(e) { setSocialBusy(false); flashToast("Couldn't send — " + ((e && e.message) || "error")); }); }; var acceptDuel = function(d) { if (isTestId(d.id)) { flashToast("Test mode — this is sample data."); return; } if (d.opponent_id !== uid || d.status !== "pending") return; sb.from("duels").update({ status: "accepted", updated_at: new Date().toISOString() }).eq("id", d.id).eq("opponent_id", uid).then(function() { flashToast("✓ DUEL ACCEPTED", 2200); loadDuelsAndSocial(); }, function(e) { flashToast("Couldn't accept — " + ((e && e.message) || "error")); }); }; var declineDuel = function(d) { if (isTestId(d.id)) { flashToast("Test mode — this is sample data."); return; } if (d.opponent_id !== uid) return; sb.from("duels").update({ status: "declined", updated_at: new Date().toISOString() }).eq("id", d.id).eq("opponent_id", uid).then(function() { loadDuelsAndSocial(); }, function(e) { console.warn("decline failed", e && e.message); }); }; var cancelDuel = function(d) { if (isTestId(d.id)) { flashToast("Test mode — this is sample data."); return; } if (d.challenger_id !== uid) return; sb.from("duels").delete().eq("id", d.id).eq("challenger_id", uid).then(function() { loadDuelsAndSocial(); }, function(e) { console.warn("cancel failed", e && e.message); }); }; var searchUsers = function(q) { setUserQuery(q); if (_searchTimer.current) clearTimeout(_searchTimer.current); if (!sb || !q || q.trim().length < 2) { setUserResults([]); return; } _searchTimer.current = setTimeout(function() { // debounce so we don't query per keystroke sb.from("profiles").select("user_id,team_name,country,city").ilike("team_name", "%" + q.trim() + "%").neq("user_id", uid).limit(12).then(function(res) { if (!_alive.current) return; setUserResults(res.data || []); setProfileMap(function(prev) { var n = Object.assign({}, prev); (res.data || []).forEach(function(p) { n[p.user_id] = p; }); return n; }); }, function(e) { console.warn("user search failed", e && e.message); }); }, 280); }; var acceptFriend = function(f) { if (isTestId(f.user_id)) { flashToast("Test mode — this is sample data."); return; } sb.from("friendships").update({ status: "accepted" }).eq("user_id", f.user_id).eq("friend_id", uid).then(function() { flashToast("✓ FRIEND ADDED", 2200); loadDuelsAndSocial(); }, function() {}); }; var addFriend = function(targetId) { if (!sb || !uid || targetId === uid) return; if (friendIds.indexOf(targetId) >= 0) { flashToast("You're already friends."); return; } var incoming = friendReqs.filter(function(f) { return f.user_id === targetId; })[0]; if (incoming) { acceptFriend(incoming); return; } // they already asked → just accept if (sentReqs.some(function(f) { return f.friend_id === targetId; })) { flashToast("Friend request already sent."); return; } sb.from("friendships").upsert({ user_id: uid, friend_id: targetId, status: "pending" }, { onConflict: "user_id,friend_id", ignoreDuplicates: true }).then(function() { flashToast("✓ FRIEND REQUEST SENT", 2200); loadDuelsAndSocial(); }, function(e) { flashToast("Couldn't send — " + ((e && e.message) || "error")); }); }; var declineFriend = function(f) { if (isTestId(f.user_id)) { flashToast("Test mode — this is sample data."); return; } sb.from("friendships").delete().eq("user_id", f.user_id).eq("friend_id", uid).then(function() { loadDuelsAndSocial(); }, function() {}); }; var saveProfile = function(name, country, city, state) { if (!sb || !uid) return; setSocialBusy(true); sb.from("profiles").upsert({ user_id: uid, team_name: (name || "").trim() || teamName, country: (country || "").trim() || null, state: (state || "").trim() || null, city: (city || "").trim() || null, updated_at: new Date().toISOString() }, { onConflict: "user_id" }).select().single().then(function(r) { setSocialBusy(false); setEditOpen(false); if (r.data) setProfile(r.data); flashToast("✓ PROFILE SAVED", 2200); }, function(e) { setSocialBusy(false); flashToast("Couldn't save — " + ((e && e.message) || "error")); }); }; // §26.8 TEST MODE (2X preview) — read-only merged views. Toggle off → exactly the real values. // Injects: 2 sample pending DP cards (the DP panel + chips + pitch badges become usable) and a // sample RESOLVED duel whose snapshot carries a 2X boost (the summary shows the "2X Boost" row + // per-player 2X tag). Built from the CURRENT weekly build so "your" XI is recognisable. var xMyRewards = duelTestMode ? [{ id: "test:card1", status: "pending" }, { id: "test:card2", status: "pending" }].concat(myRewards) : myRewards; var duelTest2x = null, duelTestScores = null; if (duelTestMode) { var tPids = Object.keys(assignments).map(function(k) { return assignments[k]; }).filter(function(pid) { return !!getPlayer(pid); }); if (tPids.length) { duelTestScores = {}; // deterministic (no Math.random, §2), and pills sum exactly to points: ~half score a goal var tGoalPts = { ATT: 4, MID: 5, DEF: 6, GK: 6 }; tPids.forEach(function(pid) { var tp = getPlayer(pid); var tg = (tp && POS_GROUP[tp.pos]) || "MID"; var s = 0; for (var ti = 0; ti < pid.length; ti++) s = (s + pid.charCodeAt(ti) * 7) % 97; var goal = s % 2; duelTestScores[pid] = { points: 2 + goal * (tGoalPts[tg] || 5), breakdown: { played: 1, min: 90, goals: goal, group: tg, name: tp ? displayName(tp) : pid } }; }); var tCap = (captain && tPids.indexOf(captain) >= 0) ? captain : tPids[0]; var tBoost = (boostPicks[0] && tPids.indexOf(boostPicks[0]) >= 0) ? boostPicks[0] : tCap; var tStarters = 0; tPids.forEach(function(pid) { tStarters += duelTestScores[pid].points; }); var tCapExtra = duelTestScores[tCap].points, tBoostVal = duelTestScores[tBoost].points, tBonus = 1.5; var tTotal = Math.round((tStarters + tCapExtra + tBonus + tBoostVal) * 10) / 10; var oTotal = Math.max(3, Math.round((tTotal - 7) * 10) / 10); duelTest2x = { id: "test:2x", matchday_id: matchday && matchday.id, challenger_id: uid, opponent_id: "test:rival", status: "resolved", winner_id: uid, challenger_pts: tTotal, opponent_pts: oTotal, created_at: "2026-07-12T10:00:00Z", resolved_at: "2026-07-12T10:00:00Z", breakdown: { challenger: { formation_key: formationKey, assignments: Object.assign({}, assignments), captain_id: tCap, starters: tStarters, captain: tCapExtra, bonus: tBonus, boost: tBoostVal, boost_players: [tBoost], total: tTotal }, opponent: { starters: Math.round((oTotal - 2.9) * 10) / 10, captain: 2, bonus: 0.9, boost: 0, total: oTotal } } }; } } var xDuels = duelTest2x ? [duelTest2x].concat(myDuels) : myDuels; var xInbox = inboxDuels; var xFriendReqs = friendReqs; var xScores = duelTestScores ? Object.assign({}, scoresById, duelTestScores) : scoresById; var xEarned = earned; var xTrash = trashByDuel; var xHistory = duelTest2x ? [duelTest2x].concat(historyDuels) : historyDuels; var activeDuels = xDuels.filter(function(d) { return d.status === "accepted"; }); var waitingDuels = xDuels.filter(function(d) { return d.status === "pending" && d.challenger_id === uid; }); var pendingCount = xInbox.length + xFriendReqs.length; var friendIds = friends.map(function(f) { return f.user_id === uid ? f.friend_id : f.user_id; }).filter(function(id, i, arr) { return arr.indexOf(id) === i; }); // P3 — resolved-duel derivations (your perspective) var resolvedDuels = xDuels.filter(function(d) { return d.status === "resolved"; }); var youPts = function(d) { return Number((d.challenger_id === uid ? d.challenger_pts : d.opponent_pts) || 0); }; var oppPts = function(d) { return Number((d.challenger_id === uid ? d.opponent_pts : d.challenger_pts) || 0); }; var duelOutcome = function(d) { return !d.winner_id ? "draw" : d.winner_id === uid ? "win" : "loss"; }; // §27: YOUR STATS are SEASON totals (the scorer writes profiles.duels_won/season_points) — // the current-matchday sums below read 0 every new week once matchdays became weekly var mdWins = resolvedDuels.filter(function(d) { return d.winner_id === uid; }).length; var mdTotalPts = resolvedDuels.reduce(function(a, d) { return a + youPts(d); }, 0); var myWins = (profile && profile.duels_won != null) ? Math.max(Number(profile.duels_won) || 0, mdWins) : mdWins; var myTotalPts = (profile && profile.season_points != null) ? Math.max(Number(profile.season_points) || 0, mdTotalPts) : mdTotalPts; // P5 — trash talk + rematch var openTrash = function(duel) { if (!duel) return; setTrashDuel(duel); markTrashSeen(duel.id, xTrash[duel.id] || []); }; var sendTrash = function(duel, body) { body = (body || "").trim(); if (!uid || !duel || !body) return; if (isTestId(duel.id)) { flashToast("Test mode — this is sample data."); return; } setTrashBody(""); if (!sb) return; sb.from("trash_talk").insert({ duel_id: duel.id, user_id: uid, body: body }).select().single().then(function(r) { if (!_alive.current) return; if (r.data) setTrashByDuel(function(prev) { var n = Object.assign({}, prev); n[duel.id] = (n[duel.id] || []).concat([r.data]); return n; }); }, function(e) { flashToast("Couldn't send — " + ((e && e.message) || "error")); }); }; var trashUnread = function(duel) { var msgs = xTrash[duel.id] || [], seen = seenTrash[duel.id] || 0; return msgs.filter(function(m) { return m.user_id !== uid && new Date(m.created_at).getTime() > seen; }).length; }; var rematch = function(duel) { if (isTestId(duel.id)) { flashToast("Test mode — this is sample data."); setSummaryDuel(null); return; } var oid = duel.challenger_id === uid ? duel.opponent_id : duel.challenger_id; if (!sb || !uid || !matchday || !oid) return; // One XI per matchday: a rematch needs a still-open matchday to play on. Until the multi-matchday // lifecycle lands, the only loaded matchday is the resolved one — block rather than create an // unplayable "zombie" pending duel both players are stuck with. (TODO: target the NEXT open matchday.) if (matchday.status === "resolved") { flashToast("Rematch opens once the next matchday is live."); setSummaryDuel(null); return; } if (myDuels.some(function(d) { var other = d.challenger_id === uid ? d.opponent_id : d.challenger_id; return other === oid && (d.status === "pending" || d.status === "accepted"); })) { flashToast("You already have a live duel with them."); setSummaryDuel(null); return; } setSocialBusy(true); sb.from("duels").insert({ matchday_id: matchday.id, challenger_id: uid, opponent_id: oid, status: "pending", rematch_of: duel.id }).select().then(function() { setSocialBusy(false); setSummaryDuel(null); flashToast("✓ REMATCH SENT to " + nameFor(oid).toUpperCase(), 2600); loadDuelsAndSocial(); }, function(e) { setSocialBusy(false); flashToast("Couldn't send — " + ((e && e.message) || "error")); }); }; // ── shared styles ───────────────────────────────────────────────────────────── var maxW = "1100px"; // ── Duel design tokens (scoped to WeekendDuel — matches the UI reference: dark glow-rich // backdrop, brighter amber gold, refined violet-bordered panels). Lineup/Card Maker untouched. var DG = { gold: "#FFC836", gold2: "#F0A91E", ink: "#1A0428", cream: "#F7EEDA", goldGrad: "linear-gradient(160deg, #FFD867 0%, #EFA518 100%)", goldGlow: "0 7px 18px rgba(247,176,28,0.28)", violet: "#C79BF2", // glow accent (VS, rings, focus) border: "1px solid rgba(176,128,214,0.20)", // soft violet panel edge panel: "linear-gradient(180deg, rgba(55,28,74,0.52) 0%, rgba(18,5,32,0.90) 100%)", panelShadow: "0 16px 40px rgba(0,0,0,0.46), inset 0 1px 0 rgba(255,255,255,0.05)", inset: "rgba(8,2,18,0.6)" // sunken row/tile fill }; var duelBgS = { minHeight: "100vh", background: "radial-gradient(1100px 640px at 12% -8%, rgba(132,48,176,0.26), transparent 58%)," + "radial-gradient(920px 580px at 104% -4%, rgba(84,26,134,0.28), transparent 55%)," + "radial-gradient(860px 640px at 50% 118%, rgba(110,38,150,0.16), transparent 60%)," + "linear-gradient(180deg, #120121 0%, #09010F 58%, #07010C 100%)" }; var pageS = { padding: isMobile ? "18px 14px 90px" : "26px 32px 48px", color: "#F5EDD8", maxWidth: maxW, margin: "0 auto", width: "100%", boxSizing: "border-box" }; var panelS = { background: DG.panel, border: DG.border, borderRadius: "18px", padding: isMobile ? "16px" : "20px", minWidth: 0, boxSizing: "border-box", boxShadow: DG.panelShadow }; var panelTitleS = { fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: "13px", letterSpacing: "3px", color: DG.gold, marginBottom: "14px", display: "flex", alignItems: "center", gap: "8px" }; var goldBtnS = { fontFamily: "'Anton', 'Bebas Neue', sans-serif", letterSpacing: "2px", border: "none", borderRadius: "11px", cursor: "pointer", background: DG.goldGrad, color: DG.ink, boxShadow: DG.goldGlow }; var ghostBtnS = { fontFamily: "'Anton', 'Bebas Neue', sans-serif", letterSpacing: "2px", borderRadius: "11px", cursor: "pointer", background: "rgba(199,155,242,0.07)", color: DG.gold, border: "1px solid rgba(199,155,242,0.30)" }; var deadlineMs = (matchday && matchday.lock_at) ? (new Date(matchday.lock_at).getTime() - now) : null; var resolvesMs = (matchday && matchday.resolves_at) ? (new Date(matchday.resolves_at).getTime() - now) : null; // MATCH PREVIEW: countdown to when results land // Season kickoff — until this date you can build/preview but real matches haven't started (#6). var seasonStartMs = (matchday && matchday.season_start_at) ? (new Date(matchday.season_start_at).getTime() - now) : null; var seasonNotStarted = seasonStartMs != null && seasonStartMs > 0; var seasonDays = seasonNotStarted ? Math.ceil(seasonStartMs / 86400000) : 0; // ── modal shell + avatar helpers (P2 social) ────────────────────────────────── var modalShell = function(title, onClose, body, maxw) { return h("div", { style: { position: "fixed", inset: 0, background: "rgba(8,1,15,0.72)", backdropFilter: "blur(6px)", display: "flex", alignItems: "center", justifyContent: "center", zIndex: 130, padding: "18px" }, onClick: onClose }, h("div", { style: { width: "min(" + (maxw || "440px") + ", 100%)", maxHeight: "86vh", display: "flex", flexDirection: "column", background: "linear-gradient(180deg, #2a1140 0%, #130325 100%)", border: "1px solid rgba(199,155,242,0.30)", borderRadius: "18px", boxShadow: "0 30px 80px rgba(0,0,0,0.7)", overflow: "hidden" }, onClick: function(e) { e.stopPropagation(); } }, h("div", { style: { padding: "16px 20px", borderBottom: "1px solid rgba(199,155,242,0.18)", display: "flex", alignItems: "center", justifyContent: "space-between", flexShrink: 0 } }, h("div", { style: { fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: "22px", letterSpacing: "1.5px", color: "#F5EDD8" } }, title), h("button", { onClick: onClose, style: { width: "30px", height: "30px", borderRadius: "50%", background: "rgba(255,255,255,0.06)", border: "1px solid rgba(255,255,255,0.1)", color: "#F5EDD8", cursor: "pointer", fontSize: "16px", lineHeight: 1 } }, "×") ), h("div", { style: { padding: "18px 20px", overflowY: "auto", minHeight: 0 } }, body) ) ); }; var avatarFor = function(name, size, gold) { // ringed circular avatar — gold gradient ring (you) or soft violet ring (opponent), per the reference var ring = gold ? "linear-gradient(160deg, #FFDE7A, #F4AE1C)" : "linear-gradient(160deg, rgba(199,155,242,0.85), rgba(120,60,180,0.5))"; return h("div", { style: { width: size + "px", height: size + "px", borderRadius: "50%", flexShrink: 0, padding: "2px", background: ring, boxShadow: gold ? "0 4px 12px rgba(247,176,28,0.32)" : "0 4px 12px rgba(150,80,200,0.26)" } }, h("div", { style: { width: "100%", height: "100%", borderRadius: "50%", background: gold ? "linear-gradient(160deg, #FFD86B, #E89C12)" : "linear-gradient(160deg, #3a1f54, #1d0c30)", color: gold ? DG.ink : DG.cream, display: "flex", alignItems: "center", justifyContent: "center", fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: Math.round(size * 0.42) + "px", lineHeight: 1 } }, (name || "?").charAt(0).toUpperCase())); }; var pillBtn = function(label, onClick, primary, small) { return h("button", { onClick: onClick, style: { fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: small ? "11px" : "13px", letterSpacing: "1px", padding: small ? "6px 12px" : "9px 16px", borderRadius: "999px", cursor: "pointer", whiteSpace: "nowrap", border: primary ? "none" : "1px solid rgba(199,155,242,0.32)", background: primary ? DG.goldGrad : "rgba(199,155,242,0.07)", color: primary ? DG.ink : DG.gold, boxShadow: primary ? "0 5px 14px rgba(247,176,28,0.32)" : "none" } }, label); }; // ── sub-nav (DASHBOARD · MY TEAM · LEADERBOARD · BADGES) ─────────────────────── var navItem = function(id, label, icon, onAction) { var active = !onAction && tab === id; // onAction items (e.g. HISTORY → modal) are never the active tab return h("button", { key: id, onClick: onAction || function() { setTab(id); }, style: { display: "flex", alignItems: "center", gap: "6px", padding: isMobile ? "10px 2px" : "12px 4px", flex: isMobile ? 1 : "0 0 auto", marginRight: isMobile ? 0 : "26px", justifyContent: "center", background: "transparent", border: "none", cursor: "pointer", borderBottom: active ? "2px solid " + DG.gold : "2px solid transparent", color: active ? DG.gold : "#F5EDD8", fontFamily: "'Anton', 'Bebas Neue', sans-serif", textShadow: active ? "0 0 14px rgba(247,176,28,0.5)" : "none", fontSize: isMobile ? "10px" : "14px", letterSpacing: isMobile ? "0.5px" : "1.5px", whiteSpace: "nowrap" } }, isMobile ? null : dIcon(icon, 15), label); }; // universal Duels actions (not tied to one match): manager profile + notifications. // They live on the sticky sub-nav so they're reachable from every Duels screen. var navIconBtn = function(icon, title, onClick, badge) { return h("button", { key: "ni-" + icon, onClick: onClick, title: title, style: { position: "relative", width: isMobile ? "28px" : "32px", height: isMobile ? "28px" : "32px", borderRadius: "9px", border: "1px solid rgba(176,128,214,0.4)", background: "rgba(255,255,255,0.04)", color: "#FFC836", cursor: "pointer", display: "flex", alignItems: "center", justifyContent: "center", flexShrink: 0, padding: 0 } }, dIcon(icon, isMobile ? 14 : 16), badge > 0 ? h("div", { style: { position: "absolute", top: "-5px", right: "-5px", minWidth: "16px", height: "16px", padding: "0 4px", boxSizing: "border-box", borderRadius: "999px", background: "#ff4d5e", color: "#fff", fontSize: "10px", fontFamily: "'Anton', 'Bebas Neue', sans-serif", display: "flex", alignItems: "center", justifyContent: "center", boxShadow: "0 0 8px rgba(255,77,94,0.7)" } }, badge) : null); }; var subNav = h("div", { style: { borderBottom: "1px solid rgba(176,128,214,0.16)", background: "rgba(8,1,15,0.55)", backdropFilter: "blur(8px)", position: "sticky", top: isMobile ? "58px" : "72px", zIndex: 30 } }, h("div", { style: { maxWidth: maxW, margin: "0 auto", padding: isMobile ? "0 10px" : "0 32px", display: "flex", justifyContent: isMobile ? "space-between" : "flex-start", alignItems: "center" } }, navItem("dashboard", "DASHBOARD", "target"), navItem("build", "MY TEAM", "users"), navItem("leaderboard", "LEADERBOARD", "trophy"), navItem("badges", "BADGES", "shield"), navItem("history", "HISTORY", "clock"), h("div", { style: { display: "flex", alignItems: "center", gap: isMobile ? "5px" : "9px", marginLeft: isMobile ? "6px" : "auto", flexShrink: 0 } }, navIconBtn("pencil", "Manager profile", function() { setEditOpen(true); }), navIconBtn("bell", "Notifications", function() { setInboxOpen(true); }, pendingCount)) ) ); // ── pick lock: a SUBMITTED XI, an expired 200-second pick timer, or a closed/past-deadline // matchday all lock editing — you can't change or update your team after any of these. var PICK_SECONDS = 200; var pickRemain = pickStart ? Math.max(0, PICK_SECONDS - Math.floor((now - pickStart) / 1000)) : PICK_SECONDS; var deadlineGone = !!(matchday && matchday.lock_at && now >= new Date(matchday.lock_at).getTime()); var mdClosed = !!(matchday && (matchday.status === "resolved" || matchday.status === "locked")); var timedOut = !!pickStart && pickRemain <= 0; var pickLocked = submitted || timedOut || deadlineGone || mdClosed; var lockReason = mdClosed ? "This matchday is closed." : submitted ? "Your XI is locked in — you can't edit it." : timedOut ? "Time's up — your XI is locked." : deadlineGone ? "Picks have locked for this matchday." : ""; // ── drag-to-swap + hover quick-actions (mirrors the Lineup Builder; disabled once locked) ── var arcBtnS = function(active) { return { width: "28px", height: "28px", borderRadius: "50%", padding: 0, lineHeight: 1, cursor: "pointer", display: "flex", alignItems: "center", justifyContent: "center", fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: "13px", flexShrink: 0, border: active ? "1px solid #FFE9A8" : "1px solid rgba(199,155,242,0.55)", background: active ? "linear-gradient(135deg,#FFDE7A,#F4AE1C)" : "rgba(8,1,15,0.92)", color: active ? "#1A0428" : "#F5EDD8", boxShadow: "0 4px 10px rgba(0,0,0,0.6)", transition: "filter 120ms ease" }; }; var findNearestSlot = function(clientX, clientY) { var rect = pitchRef.current && pitchRef.current.getBoundingClientRect(); if (!rect) return null; var best = null, bestD = Infinity; slots.forEach(function(s) { var cx = rect.left + (s.x / 100) * rect.width; var cy = rect.top + (Math.min(s.y, 86) / 100) * rect.height; var d = Math.hypot(clientX - cx, clientY - cy); if (d < bestD) { bestD = d; best = s; } }); return best; }; var onSlotPointerDown = function(slot, e) { if (pickLocked) return; if (e.button && e.button !== 0) return; if (!assignments[slot.id]) return; if (e.target && e.target.closest && e.target.closest(".arc-pop")) return; dragRef.current = { slotId: slot.id, pid: assignments[slot.id], startX: e.clientX, startY: e.clientY, pointerId: e.pointerId, active: false }; try { e.currentTarget.setPointerCapture(e.pointerId); } catch (_) {} }; var onSlotPointerMove = function(e) { var d = dragRef.current; if (!d || d.pointerId !== e.pointerId) return; if (!d.active && Math.hypot(e.clientX - d.startX, e.clientY - d.startY) > 8) { d.active = true; setHoveredSlot(null); } if (d.active) { e.preventDefault(); var t = findNearestSlot(e.clientX, e.clientY); setDrag({ pid: d.pid, slotId: d.slotId, x: e.clientX, y: e.clientY, targetId: (t && t.id !== d.slotId) ? t.id : null }); } }; var onSlotPointerUp = function(slot, e) { var d = dragRef.current; try { e.currentTarget.releasePointerCapture(e.pointerId); } catch (_) {} if (d && d.active) { var target = findNearestSlot(e.clientX, e.clientY); if (target && target.id !== d.slotId) { // DUEL rule: a swap/move must leave BOTH players in slots they can play. var pa = getPlayer(assignments[d.slotId]), pb = assignments[target.id] ? getPlayer(assignments[target.id]) : null; var srcSlot = slots.filter(function(s) { return s.id === d.slotId; })[0]; var ok = (!pa || canPlaySlot(pa, target)) && (!pb || !srcSlot || canPlaySlot(pb, srcSlot)); if (!ok) { flashToast("Can't swap — it would put a player out of position."); } else { setAssignments(function(prev) { var n = Object.assign({}, prev); var a = prev[d.slotId], b = prev[target.id]; if (b != null) n[d.slotId] = b; else delete n[d.slotId]; // swap, or move into an empty slot n[target.id] = a; return n; }); } } justDraggedRef.current = true; } dragRef.current = null; setDrag(null); }; // ── mini pitch token ────────────────────────────────────────────────────────── var renderSlotToken = function(slot) { var pid = assignments[slot.id], player = pid ? getPlayer(pid) : null; var py = Math.min(slot.y, 86), isCap = player && pid === captain; var isHovered = hoveredSlot === slot.id; var beingDragged = drag && player && drag.pid === pid; var isDropTarget = drag && drag.targetId === slot.id; var scaleV = isDropTarget ? 1.12 : (isHovered ? 1.07 : 1); return h("div", { key: slot.id, style: { position: "absolute", left: slot.x + "%", top: py + "%", transform: "translate(-50%, -50%) scale(" + scaleV + ")", cursor: pickLocked ? "default" : (player ? "grab" : "pointer"), transition: "transform 120ms ease", zIndex: isDropTarget ? 35 : (isHovered ? 30 : 2), opacity: beingDragged ? 0.32 : 1, touchAction: (!pickLocked && player) ? "none" : "auto" }, onClick: function() { if (justDraggedRef.current) { justDraggedRef.current = false; return; } handleSlotClick(slot); }, onPointerDown: function(e) { onSlotPointerDown(slot, e); }, onPointerMove: onSlotPointerMove, onPointerUp: function(e) { onSlotPointerUp(slot, e); }, onPointerCancel: function(e) { onSlotPointerUp(slot, e); }, onMouseEnter: (isMobile || pickLocked) ? null : function() { if (!dragRef.current) setHoveredSlot(slot.id); }, onMouseLeave: (isMobile || pickLocked) ? null : function() { setHoveredSlot(null); } }, isDropTarget && h("div", { key: "ring", className: "drop-target-ring", style: { position: "absolute", inset: "-7px", borderRadius: "12px", border: "2px solid #FFC836", pointerEvents: "none", zIndex: 6 } }), isDropTarget && h("div", { key: "badge", style: { position: "absolute", bottom: "100%", left: "50%", transform: "translateX(-50%)", marginBottom: "7px", padding: "3px 10px", borderRadius: "999px", background: "linear-gradient(135deg,#FFDE7A,#F4AE1C)", color: "#1A0428", fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: "11px", letterSpacing: "1.5px", whiteSpace: "nowrap", boxShadow: "0 4px 10px rgba(0,0,0,0.55)", pointerEvents: "none", zIndex: 8 } }, player ? "SWAP" : "MOVE HERE"), player ? h("div", { style: { position: "relative" } }, isCap && h("div", { style: { position: "absolute", top: "-7px", left: "-5px", width: "18px", height: "18px", borderRadius: "50%", background: "linear-gradient(160deg,#FFDE7A,#F4AE1C)", border: "2px solid #1A0428", display: "flex", alignItems: "center", justifyContent: "center", fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: "10px", color: "#1A0428", zIndex: 4, boxShadow: "0 0 8px rgba(232,184,58,0.8)", lineHeight: 1 } }, "C"), // 2X double-points badge — weekly build only (boosts never apply to custom per-duel XIs) (!customDuel && boostPicks.indexOf(pid) >= 0) && h("div", { style: { position: "absolute", top: "-7px", right: "-5px", padding: "3px 5px", borderRadius: "8px", background: "linear-gradient(160deg,#E4CCFF,#9D5CE8)", border: "2px solid #1A0428", fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: "9px", letterSpacing: "0.5px", color: "#1A0428", zIndex: 4, boxShadow: "0 0 8px rgba(199,155,242,0.85)", lineHeight: 1 } }, "2X"), h(MiniCard, { player: player, rarity: "gold" }), h("div", { key: "pname", style: { position: "absolute", top: "100%", left: "50%", transform: "translateX(-50%)", marginTop: "3px", fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: "12px", letterSpacing: "0.3px", lineHeight: 1.0, color: "#F5EDD8", textAlign: "center", maxWidth: "84px", textShadow: "0 1px 3px rgba(0,0,0,0.85)", pointerEvents: "none" } }, displayName(player).toUpperCase()), // Hover quick-actions arc (desktop, while unlocked): captain · replace · remove. (!isMobile && isHovered && !pickLocked) && h("div", { style: { position: "absolute", left: "50%", bottom: "calc(100% - 16px)", transform: "translateX(-50%)", width: "132px", height: "64px", display: "flex", alignItems: "flex-end", justifyContent: "center", gap: "9px", zIndex: 40 } }, h("button", { key: "cap", className: "arc-pop", title: isCap ? "Remove captain" : "Make captain", style: Object.assign({}, arcBtnS(isCap), { animationDelay: "0ms" }), onClick: function(e) { e.stopPropagation(); setCaptain(function(c) { return c === pid ? null : pid; }); } }, "C"), h("button", { key: "rep", className: "arc-pop", title: "Replace player", style: Object.assign({}, arcBtnS(false), { marginBottom: "18px", animationDelay: "55ms" }), onClick: function(e) { e.stopPropagation(); setHoveredSlot(null); setModal({ type: "search", slot: slot }); } }, "⇄"), h("button", { key: "rem", className: "arc-pop", title: "Remove player", style: Object.assign({}, arcBtnS(false), { animationDelay: "110ms" }), onClick: function(e) { e.stopPropagation(); setHoveredSlot(null); removeFromSlot(slot); } }, "✕") ) ) : [h("div", { key: "empty", style: { width: isMobile ? "44px" : "52px", height: isMobile ? "44px" : "52px", borderRadius: "50%", background: "rgba(245,237,216,0.06)", border: "2px dashed rgba(245,237,216,0.45)", display: "flex", alignItems: "center", justifyContent: "center", color: "#F5EDD8", fontSize: isMobile ? "18px" : "22px", fontWeight: 300, backdropFilter: "blur(4px)", opacity: pickLocked ? 0.5 : 1 } }, "+"), h("div", { key: "pos", style: { position: "absolute", bottom: isMobile ? "-16px" : "-19px", left: "50%", transform: "translateX(-50%)", fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: isMobile ? "13px" : "14px", letterSpacing: "2px", color: "#FFC836", whiteSpace: "nowrap", textShadow: "0 1px 4px rgba(0,0,0,0.8)" } }, slot.pos)] ); }; var pitch = h("div", { ref: pitchRef, style: { position: "relative", aspectRatio: isMobile ? "0.62" : "0.74", width: "100%", maxWidth: isMobile ? "100%" : "440px", margin: "0 auto", background: "radial-gradient(ellipse at 50% 40%, #4a1166 0%, #2F0745 55%, #1A0428 100%)", borderRadius: "14px", border: "1px solid rgba(176,128,214,0.3)", overflow: "visible", boxShadow: "inset 0 0 60px rgba(0,0,0,0.4), 0 18px 40px rgba(0,0,0,0.4)" } }, h(PitchLines), slots.map(renderSlotToken) ); // ── DASHBOARD ───────────────────────────────────────────────────────────────── var howStep = function(n, icon, title, body) { return h("div", { key: n, style: { flex: 1, minWidth: isMobile ? "calc(50% - 8px)" : 0, textAlign: "center" } }, h("div", { style: { width: "46px", height: "46px", margin: "0 auto 10px", borderRadius: "50%", border: "1px solid rgba(199,155,242,0.32)", background: "radial-gradient(circle at 50% 30%, rgba(199,155,242,0.18), rgba(247,176,28,0.10))", color: DG.gold, display: "flex", alignItems: "center", justifyContent: "center", boxShadow: "0 4px 14px rgba(120,60,180,0.25)" } }, dIcon(icon, 20)), h("div", { style: { fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: "14px", letterSpacing: "1px", color: DG.cream } }, n + ". " + title), h("div", { style: { fontSize: "11px", color: "rgba(245,237,216,0.55)", marginTop: "4px", lineHeight: 1.4 } }, body) ); }; // ── P2: upcoming-duel card + social modals ──────────────────────────────────── var oppOf = function(d) { return d.challenger_id === uid ? d.opponent_id : d.challenger_id; }; var statusChip = function(text, color) { return h("span", { key: "c", style: { fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: "10px", letterSpacing: "1px", padding: "3px 8px", borderRadius: "999px", background: color + "22", border: "1px solid " + color + "66", color: color, whiteSpace: "nowrap" } }, text); }; // a status chip that's tappable (e.g. an OPEN challenge → opens the marketplace to find/accept opponents) var clickChip = function(text, color, onClick, title) { return h("button", { key: "c", onClick: onClick, title: title, style: { fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: "10px", letterSpacing: "1px", padding: "3px 9px", borderRadius: "999px", background: color + "26", border: "1px solid " + color + "aa", color: color, whiteSpace: "nowrap", cursor: "pointer", display: "inline-flex", alignItems: "center", gap: "3px" } }, text, dIcon("chevronRight", 11)); }; var sideBlock = function(name, sub, gold, dashed) { return h("div", { style: { textAlign: "center", flex: 1, minWidth: 0 } }, h("div", { style: { display: "flex", justifyContent: "center", marginBottom: "8px" } }, dashed ? h("div", { style: { width: "52px", height: "52px", borderRadius: "50%", background: "rgba(255,255,255,0.06)", border: "1px dashed rgba(245,237,216,0.3)", color: "rgba(245,237,216,0.5)", display: "flex", alignItems: "center", justifyContent: "center", fontSize: "22px" } }, "?") : avatarFor(name, 52, gold)), h("div", { style: { fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: "16px", color: dashed ? "rgba(245,237,216,0.6)" : "#F5EDD8", letterSpacing: "0.5px", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" } }, name), h("div", { style: { fontSize: "11px", color: gold ? "#FFC836" : "rgba(245,237,216,0.45)" } }, sub) ); }; var renderUpcoming = function() { var hasPick = !!pickId; var primary = resolvedDuels[0] || activeDuels[0] || waitingDuels[0] || null; var lockTxt = matchday ? fmtDeadline(matchday.lock_at) : "Fri 7:00 PM"; var oid = primary ? oppOf(primary) : null; var waiting = primary && primary.status === "pending"; var resolved = primary && primary.status === "resolved"; var oc = resolved ? duelOutcome(primary) : null; var ocColor = oc === "win" ? "#3fb96b" : oc === "loss" ? "#ff6b6b" : "#FFC836"; return h("div", { style: Object.assign({}, panelS, { marginTop: "22px" }) }, h("div", { style: { display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: "16px", gap: "8px", flexWrap: "wrap" } }, // profile + notifications live on the sticky sub-nav (§26.14); + NEW DUEL is the // sticky FAB (§26.8) — the card title stands alone h("div", { style: Object.assign({}, panelTitleS, { marginBottom: 0 }) }, resolved ? ((matchday ? matchday.name : "") + " RESULT") : "UPCOMING DUEL") ), primary ? (resolved ? h("div", null, h("div", { style: { textAlign: "center", fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: "30px", letterSpacing: "1px", color: ocColor, lineHeight: 1, marginBottom: "12px" } }, oc === "win" ? "YOU WON" : oc === "loss" ? "YOU LOST" : "DRAW"), h("div", { style: { display: "flex", alignItems: "center", justifyContent: "space-around", gap: "10px" } }, sideBlock(teamName, youPts(primary) + " pts", true, false), h("div", { style: { fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: "30px", color: ocColor, textShadow: "0 0 14px " + ocColor + "88" } }, youPts(primary) + " – " + oppPts(primary)), sideBlock(oid ? nameFor(oid) : "Opponent", oppPts(primary) + " pts", false, false) ), h("button", { onClick: function() { setSummaryDuel(primary); }, style: Object.assign({}, goldBtnS, { width: "100%", marginTop: "16px", padding: "12px", fontSize: "15px" }) }, "VIEW SUMMARY") ) : h("div", { style: { display: "flex", alignItems: "center", justifyContent: "space-around", gap: "10px" } }, sideBlock(teamName, squad.proj + " proj. pts", true, false), h("div", { style: { fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: "30px", color: "#EEDFFB", flexShrink: 0, padding: "0 4px", textShadow: "0 0 16px rgba(199,155,242,0.7), 0 0 6px rgba(199,155,242,0.6)" } }, "VS"), sideBlock(oid ? nameFor(oid) : "OPEN CHALLENGE", waiting ? (oid ? "invite sent" : "anyone can accept") : "opponent locked in", false, !oid) )) : h("div", { style: { textAlign: "center", padding: "12px 0 4px" } }, h("div", { style: { fontSize: "13px", color: "rgba(245,237,216,0.6)", marginBottom: "14px" } }, "No active duel yet — challenge someone for " + (matchday ? matchday.name : "this weekend") + "."), h("button", { onClick: function() { setChTab("open"); setChallengeOpen(true); }, style: Object.assign({}, goldBtnS, { padding: "12px 26px", fontSize: "16px" }) }, "FIND OPPONENT") ), (primary && oid && primary.status === "accepted" && !isTestId(primary.id)) ? h("button", { onClick: function() { setPrevSide("them"); setPreviewDuel(primary); }, style: { width: "100%", marginTop: "14px", padding: "12px", borderRadius: "10px", border: "none", background: DG.goldGrad, color: DG.ink, fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: "15px", letterSpacing: "2px", cursor: "pointer", display: "inline-flex", alignItems: "center", justifyContent: "center", gap: "8px", boxShadow: "0 6px 18px rgba(247,176,28,0.4)" } }, dIcon("target", 16), "SCOUT OPPONENT") : null, (primary && oid && (primary.status === "accepted" || primary.status === "resolved")) ? h("button", { onClick: function() { openTrash(primary); }, style: { width: "100%", marginTop: "10px", padding: "11px", borderRadius: "10px", border: "1px solid rgba(176,128,214,0.5)", background: "rgba(255,255,255,0.04)", color: "#FFC836", fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: "15px", letterSpacing: "2px", cursor: "pointer", display: "inline-flex", alignItems: "center", justifyContent: "center", gap: "8px" } }, dIcon("message", 16), "TRASH TALK", trashUnread(primary) > 0 ? h("span", { style: { minWidth: "18px", height: "18px", padding: "0 5px", boxSizing: "border-box", borderRadius: "999px", background: "#ff4d5e", color: "#fff", fontSize: "11px", display: "inline-flex", alignItems: "center", justifyContent: "center" } }, trashUnread(primary)) : null) : null, (primary && oid && primary.status === "accepted" && !isTestId(primary.id)) ? h("button", { onClick: function() { enterCustomXI(primary); }, style: { width: "100%", marginTop: "10px", padding: "11px", borderRadius: "10px", border: "1px solid rgba(199,155,242,0.55)", background: "rgba(199,155,242,0.1)", color: "#E9D6FF", fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: "15px", letterSpacing: "2px", cursor: "pointer", display: "inline-flex", alignItems: "center", justifyContent: "center", gap: "8px" } }, dIcon("users", 16), customPicks[primary.id] ? "EDIT CUSTOM XI" : "USE A CUSTOM XI", customPicks[primary.id] ? h("span", { style: { fontSize: "10px", color: "#3fb96b", letterSpacing: "1px" } }, "· SET") : null) : null, !mdClosed ? h("button", { onClick: function() { setChTab("open"); setChallengeOpen(true); }, style: { width: "100%", marginTop: "10px", padding: "11px", borderRadius: "10px", border: "1px solid rgba(247,176,28,0.5)", background: "rgba(247,176,28,0.08)", color: "#FFC836", fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: "15px", letterSpacing: "2px", cursor: "pointer", display: "inline-flex", alignItems: "center", justifyContent: "center", gap: "8px" } }, dIcon("swords", 16), openChallenges.length ? ("ACCEPT AN OPEN DUEL (" + openChallenges.length + ")") : "FIND OR QUICK-MATCH AN OPPONENT", openChallenges.length ? h("span", { style: { minWidth: "18px", height: "18px", padding: "0 5px", boxSizing: "border-box", borderRadius: "999px", background: "#ff4d5e", color: "#fff", fontSize: "11px", display: "inline-flex", alignItems: "center", justifyContent: "center" } }, openChallenges.length) : null ) : null, resolved ? h("div", { style: { textAlign: "center", fontSize: "11px", color: "rgba(245,237,216,0.5)", marginTop: "14px" } }, (matchday ? matchday.name : "This matchday") + " is final — scored from the weekend's real fixtures.") : h("div", { style: { textAlign: "center", fontSize: "11px", color: hasPick ? "#9be8a8" : "#FFC836", marginTop: "14px" } }, (hasPick ? "✓ Your XI is locked in. " : "Build your XI in MY TEAM to lock in. ") + "Picks lock " + lockTxt + (deadlineMs != null && deadlineMs > 0 ? " · " + fmtCountdown(deadlineMs) : "")), // 2X boost note — the boost rides the weekly XI, so it applies to every duel this matchday // (gate on the MATCHDAY being open, not the primary duel — other duels may still be live) (!mdClosed && boostPicks.length) ? (function() { var nms = boostPicks.map(function(bp) { var p = getPlayer(bp); return p ? displayName(p) : null; }).filter(Boolean); return nms.length ? h("div", { style: { textAlign: "center", fontSize: "11px", color: "#C79BF2", marginTop: "8px" } }, "2X DOUBLE POINTS ACTIVE: " + nms.join(", ") + " — counts in every duel this matchday.") : null; })() : null, // 2X card WALLET — your unassigned hand is visible from the dashboard, not just the build (!mdClosed && Math.max(0, xMyRewards.length - boostPicks.length) > 0) ? (function() { var held = Math.max(0, xMyRewards.length - boostPicks.length); return h("div", { style: { textAlign: "center", marginTop: "8px" } }, h("span", { style: { display: "inline-flex", alignItems: "center", gap: "7px", padding: "6px 13px", borderRadius: "999px", background: "rgba(199,155,242,0.1)", border: "1px solid rgba(199,155,242,0.45)", fontSize: "11px", color: "#E9D6FF" } }, h("span", { style: { fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: "11px", padding: "1px 6px", borderRadius: "6px", background: "linear-gradient(160deg, #C79BF2, #7d4bb5)", color: "#1A0428" } }, "2X"), held + " Double-Points card" + (held > 1 ? "s" : "") + " in hand — assign in MY TEAM")); })() : null, // #2 — duels are scored from the weekend's REAL matches (not on pick), so make the timeline clear once a duel is locked. (primary && oid && primary.status === "accepted") ? h("div", { style: { textAlign: "center", fontSize: "11px", color: "rgba(245,237,216,0.5)", marginTop: "8px", lineHeight: 1.4 } }, "Matched! Scout the opponent above — teams can change until picks lock. Results land " + (matchday ? fmtDeadline(matchday.resolves_at) : "after the weekend's matches") + (resolvesMs != null && resolvesMs > 0 ? " (" + fmtCountdown(resolvesMs) + ")" : "") + ".") : null, xDuels.length ? h("div", { style: { marginTop: "16px", borderTop: "1px solid rgba(176,128,214,0.15)", paddingTop: "12px" } }, h("div", { style: { fontSize: "9px", letterSpacing: "1.5px", color: "rgba(245,237,216,0.4)", marginBottom: "8px" } }, "YOUR DUELS · " + (matchday ? matchday.name : "")), (duelsExpanded ? xDuels : xDuels.slice(0, 3)).map(function(d) { var did = oppOf(d); var isRes = d.status === "resolved"; var oc2 = isRes ? duelOutcome(d) : null; var isMyOpen = d.status === "pending" && d.challenger_id === uid && !did; // my live open challenge var chip = isRes ? statusChip((oc2 === "win" ? "WON " : oc2 === "loss" ? "LOST " : "DREW ") + youPts(d) + "–" + oppPts(d), oc2 === "win" ? "#3fb96b" : oc2 === "loss" ? "#ff6b6b" : "#FFC836") : (d.status === "accepted" ? statusChip("LOCKED", "#3fb96b") : isMyOpen ? clickChip("OPEN", "#FFC836", function(e) { e.stopPropagation(); setChTab("open"); setChallengeOpen(true); }, "Open challenges & quick-match") : (d.status === "pending" && d.challenger_id === uid ? statusChip("INVITE SENT", "#FFC836") : statusChip(d.status.toUpperCase(), "#9a8fb0"))); return h("div", { key: d.id, onClick: isRes ? function() { setSummaryDuel(d); } : (isMyOpen ? function() { setChTab("open"); setChallengeOpen(true); } : null), style: { display: "flex", alignItems: "center", gap: "10px", padding: "8px 0", cursor: (isRes || isMyOpen) ? "pointer" : "default" } }, avatarFor(did ? nameFor(did) : "?", 30, false), h("div", { style: { flex: 1, minWidth: 0, fontSize: "13px", color: "#F5EDD8", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" } }, did ? nameFor(did) : "Open challenge"), chip, (d.status === "accepted" && !isTestId(d.id)) ? h("button", { key: "cx", onClick: function(e) { e.stopPropagation(); enterCustomXI(d); }, title: customPicks[d.id] ? "Edit custom XI" : "Set a custom XI for this duel", style: { position: "relative", background: "transparent", border: "none", color: customPicks[d.id] ? "#C79BF2" : "rgba(199,155,242,0.7)", cursor: "pointer", display: "flex", alignItems: "center", padding: "2px" } }, dIcon("users", 16), customPicks[d.id] ? h("span", { style: { position: "absolute", top: "-3px", right: "-3px", width: "9px", height: "9px", borderRadius: "50%", background: "#3fb96b" } }) : null) : null, (did && d.status === "accepted" && !isTestId(d.id)) ? h("button", { key: "sc", onClick: function(e) { e.stopPropagation(); setPrevSide("them"); setPreviewDuel(d); }, title: "Scout opponent's XI", style: { background: "transparent", border: "none", color: "#FFC836", cursor: "pointer", display: "flex", alignItems: "center", padding: "2px" } }, dIcon("target", 16)) : null, (did && (d.status === "accepted" || d.status === "resolved")) ? h("button", { key: "tt", onClick: function(e) { e.stopPropagation(); openTrash(d); }, title: "Trash talk", style: { position: "relative", background: "transparent", border: "none", color: "#FFC836", cursor: "pointer", display: "flex", alignItems: "center", padding: "2px" } }, dIcon("message", 16), trashUnread(d) > 0 ? h("span", { style: { position: "absolute", top: "-3px", right: "-3px", width: "9px", height: "9px", borderRadius: "50%", background: "#ff4d5e" } }) : null) : null, isRes ? h("div", { key: "v", style: { fontSize: "11px", color: "#FFC836", fontFamily: "'Anton', 'Bebas Neue', sans-serif", letterSpacing: "1px" } }, "VIEW") : ((d.challenger_id === uid && d.status === "pending") ? h("button", { key: "x", onClick: function(e) { e.stopPropagation(); cancelDuel(d); }, style: { background: "transparent", border: "none", color: "rgba(255,138,150,0.85)", cursor: "pointer", fontSize: "11px", fontFamily: "'Anton', 'Bebas Neue', sans-serif", letterSpacing: "1px" } }, "CANCEL") : null) ); }), // accordion: first 3 duels, then VIEW MORE reveals the rest xDuels.length > 3 ? h("button", { onClick: function() { setDuelsExpanded(!duelsExpanded); }, style: { width: "100%", marginTop: "8px", padding: "9px", borderRadius: "10px", border: "1px solid rgba(176,128,214,0.35)", background: "rgba(199,155,242,0.06)", color: "#C79BF2", fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: "12px", letterSpacing: "1.5px", cursor: "pointer" } }, duelsExpanded ? "SHOW LESS ▲" : "VIEW MORE (" + (xDuels.length - 3) + ") ▼") : null ) : null ); }; // ── DUEL SUMMARY (P3) — final result + Starters/Captain/Bonus + per-player pills ── // Each event is an ICON pill that also shows the points it earned (point attribution). var GOAL_PTS_UI = { ATT: 4, MID: 5, DEF: 6, GK: 6 }; var evPill = function(key, icon, color, label, pts) { return h("span", { key: key, style: { display: "inline-flex", alignItems: "center", gap: "4px", fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: "10px", letterSpacing: "0.5px", padding: "2px 7px 2px 5px", borderRadius: "6px", background: color + "1f", border: "1px solid " + color + "55", color: color, whiteSpace: "nowrap" } }, icon ? h("span", { style: { display: "inline-flex" } }, dIcon(icon, 11)) : null, h("span", null, label), pts != null ? h("span", { style: { fontWeight: 700, opacity: 0.95 } }, (pts > 0 ? "+" : "") + pts) : null ); }; var playerPills = function(sc) { var b = (sc && sc.breakdown) || {}; var out = []; if (!b.played) { out.push(h("span", { key: "dnp", style: { fontSize: "10px", color: "rgba(245,237,216,0.4)" } }, "Did not play")); return out; } var grp = b.group || "MID", over60 = b.min >= 60; if (b.goals > 0) out.push(evPill("g", "ball", "#3fb96b", b.goals > 1 ? b.goals + " GOALS" : "GOAL", (GOAL_PTS_UI[grp] || 5) * b.goals)); if (b.assists > 0) out.push(evPill("a", "assist", "#3fb96b", b.assists > 1 ? b.assists + " ASSISTS" : "ASSIST", b.assists * 3)); if (b.cs && over60 && (grp === "GK" || grp === "DEF")) out.push(evPill("cs", "shield", "#FFC836", "CLEAN SHEET", 4)); else if (b.cs && over60 && grp === "MID") out.push(evPill("cs", "shield", "#FFC836", "CLEAN SHEET", 1)); if ((grp === "GK" || grp === "DEF") && over60 && b.conceded > 0) { var cp = -Math.floor(b.conceded / 2); if (cp < 0) out.push(evPill("conc", "shield", "#ff9d6b", b.conceded + " CONCEDED", cp)); } if (grp === "GK" && b.saves >= 3) out.push(evPill("sv", "glove", "#5ac8fa", b.saves + " SAVES", Math.floor(b.saves / 3))); if (b.yellow > 0) out.push(evPill("yc", "card", "#e8c020", "YELLOW", -b.yellow)); if (b.red > 0) out.push(evPill("rc", "card", "#ff6b6b", "RED CARD", -b.red * 3)); if (b.og > 0) out.push(evPill("og", "ball", "#ff6b6b", "OWN GOAL", -b.og * 2)); // base appearance points — always show so the point total is fully attributed out.push(evPill("min", "whistle", "rgba(245,237,216,0.55)", over60 ? "PLAYED 60'+" : "PLAYED " + b.min + "'", over60 ? 2 : 1)); return out; }; var teamColumn = function(pick, capId, isYou, boostIds) { // §27 no-team rule: a side that never built an XI was scored 0 by the server if (pick && pick.noTeam) { return h("div", { style: { padding: "14px 8px", textAlign: "center", fontSize: "12px", color: "rgba(245,237,216,0.55)", lineHeight: 1.5 } }, h("div", { style: { fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: "13px", letterSpacing: "1.5px", color: "#FF8A8A", marginBottom: "4px" } }, "NO TEAM SUBMITTED"), "No XI was built for this matchday — scored 0."); } var slots = pick ? FORMATIONS_11V11[pick.formation_key] || FORMATIONS_11V11["4-3-3"] : null; var order = pick ? (slots.slots || []).map(function(s) { return pick.assignments ? pick.assignments[s.id] : null; }).filter(Boolean) : []; // §27: a summary for a PAST matchday reads that matchday's scores (weekly matchdays now) var scMap = (summaryDuel && summaryDuel.matchday_id && matchday && summaryDuel.matchday_id !== matchday.id && histScores[summaryDuel.matchday_id]) || xScores; return order.map(function(pid) { var sc = scMap[pid] || { points: 0, breakdown: {} }; var p = getPlayer(pid); var nm = (sc.breakdown && sc.breakdown.name) || (p ? displayName(p) : pid); var isCap = pid === capId; // boosted player's shown total mirrors the server math: base ×2 captain, ×2 boost (×3 if both) var isBoost = boostIds && boostIds.indexOf(pid) >= 0; var mult = (isCap ? 2 : 1) * (isBoost ? 2 : 1); return h("div", { key: pid, style: { display: "flex", alignItems: "center", gap: "8px", padding: "7px 0", borderBottom: "1px solid rgba(255,255,255,0.05)" } }, h("div", { style: { width: "26px", fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: "16px", color: sc.points > 0 ? "#FFC836" : "rgba(245,237,216,0.4)", textAlign: "center" } }, mult > 1 ? (Number(sc.points) * mult) : sc.points), h("div", { style: { flex: 1, minWidth: 0 } }, h("div", { style: { fontSize: "12px", color: "#F5EDD8", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" } }, nm + (isCap ? " (C)" : ""), isBoost ? h("span", { style: { marginLeft: "6px", padding: "1px 5px", borderRadius: "6px", background: "rgba(199,155,242,0.18)", border: "1px solid rgba(199,155,242,0.45)", color: "#C79BF2", fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: "9px", letterSpacing: "0.5px" } }, "2X") : null), h("div", { style: { display: "flex", gap: "4px", marginTop: "3px", flexWrap: "wrap" } }, playerPills(sc)) ) ); }); }; // MATCH PREVIEW — a read-only mini-pitch of a pick (opponent's scouted XI or yours), no interaction. var renderPreviewPitch = function(pick) { if (!pick || !pick.assignments || !Object.keys(pick.assignments).length) { return h("div", { style: { textAlign: "center", padding: "40px 12px", color: "rgba(245,237,216,0.5)", fontSize: "13px", lineHeight: 1.5 } }, "No XI built yet."); } var fk = pick.formation_key || "4-3-3"; var slots = (FORMATIONS_11V11[fk] || FORMATIONS_11V11["4-3-3"]).slots; return h("div", { style: { position: "relative", aspectRatio: "0.62", width: "100%", background: "radial-gradient(ellipse at 50% 40%, #4a1166 0%, #2F0745 55%, #1A0428 100%)", borderRadius: "14px", border: "1px solid rgba(176,128,214,0.3)", overflow: "hidden", boxShadow: "inset 0 0 40px rgba(0,0,0,0.4)" } }, h(PitchLines), slots.map(function(slot) { var pid = pick.assignments[slot.id]; var player = pid ? getPlayer(pid) : null; var py = Math.min(slot.y, 86); var isCap = pid && pid === pick.captain; return h("div", { key: slot.id, style: { position: "absolute", left: slot.x + "%", top: py + "%", transform: "translate(-50%, -50%)" } }, player ? h("div", { style: { position: "relative" } }, isCap && h("div", { style: { position: "absolute", top: "-7px", left: "-5px", width: "16px", height: "16px", borderRadius: "50%", background: "linear-gradient(160deg,#FFDE7A,#F4AE1C)", border: "2px solid #1A0428", display: "flex", alignItems: "center", justifyContent: "center", fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: "9px", color: "#1A0428", zIndex: 4, lineHeight: 1 } }, "C"), h(MiniCard, { player: player, rarity: "gold" }), h("div", { style: { position: "absolute", top: "100%", left: "50%", transform: "translateX(-50%)", marginTop: "4px", fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: "11px", letterSpacing: "0.5px", lineHeight: 1.06, color: "#F5EDD8", textAlign: "center", maxWidth: "76px", textShadow: "0 1px 3px rgba(0,0,0,0.85)" } }, displayName(player).toUpperCase()) ) : h("div", { style: { width: "38px", height: "38px", borderRadius: "50%", background: "rgba(245,237,216,0.05)", border: "2px dashed rgba(245,237,216,0.3)", display: "flex", alignItems: "center", justifyContent: "center", color: "rgba(245,237,216,0.55)", fontSize: "11px", fontFamily: "'Anton', 'Bebas Neue', sans-serif", letterSpacing: "1px" } }, slot.pos) ); }) ); }; var renderMatchPreview = function() { var d = previewDuel; if (!d) return null; var oid = oppOf(d); var oppName = oid ? nameFor(oid) : "Opponent"; var opp = oppPickByDuel[d.id] || null; var youPick = { formation_key: formationKey, assignments: assignments, captain: captain }; var showThem = prevSide === "them"; var toggle = function(side, label, active) { return h("button", { onClick: function() { setPrevSide(side); }, style: { flex: 1, padding: "10px", borderRadius: "10px", cursor: "pointer", fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: "13px", letterSpacing: "1px", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis", border: active ? "1px solid " + DG.gold : "1px solid rgba(199,155,242,0.4)", background: active ? DG.goldGrad : "rgba(255,255,255,0.04)", color: active ? DG.ink : "#E9D6FF" } }, label); }; var body = h("div", null, h("div", { style: { display: "flex", alignItems: "center", justifyContent: "space-around", gap: "10px", marginBottom: "14px" } }, h("div", { style: { textAlign: "center", flex: 1, minWidth: 0 } }, h("div", { style: { display: "flex", justifyContent: "center", marginBottom: "6px" } }, avatarFor(teamName, 48, true)), h("div", { style: { fontSize: "12px", color: "#F5EDD8", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" } }, teamName)), h("div", { style: { fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: "22px", color: "#EEDFFB", flexShrink: 0, textShadow: "0 0 12px rgba(199,155,242,0.6)" } }, "VS"), h("div", { style: { textAlign: "center", flex: 1, minWidth: 0 } }, h("div", { style: { display: "flex", justifyContent: "center", marginBottom: "6px" } }, avatarFor(oppName, 48, false)), h("div", { style: { fontSize: "12px", color: "rgba(245,237,216,0.8)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" } }, oppName)) ), resolvesMs != null && resolvesMs > 0 ? h("div", { style: { textAlign: "center", fontSize: "12px", color: "#FFC836", marginBottom: "12px", display: "flex", alignItems: "center", justifyContent: "center", gap: "6px", fontFamily: "'Anton', 'Bebas Neue', sans-serif", letterSpacing: "1px" } }, dIcon("clock", 13), "RESULTS IN " + fmtCountdown(resolvesMs)) : null, h("div", { style: { display: "flex", gap: "8px", marginBottom: "12px" } }, toggle("them", "OPPONENT'S XI", showThem), toggle("you", "YOUR XI", !showThem)), (showThem && !opp) ? h("div", { style: { textAlign: "center", padding: "40px 12px", color: "rgba(245,237,216,0.5)", fontSize: "13px", lineHeight: 1.5 } }, oppName + " hasn't built their XI yet — check back soon.") : renderPreviewPitch(showThem ? opp : youPick), (showThem && opp && opp.custom) ? h("div", { style: { textAlign: "center", fontSize: "11px", color: "#C79BF2", marginTop: "10px" } }, oppName + " is fielding a custom XI for this duel.") : null, h("div", { style: { textAlign: "center", fontSize: "11px", color: "rgba(245,237,216,0.5)", marginTop: "12px", lineHeight: 1.4 } }, "Live scout — both teams can still change until picks lock" + (matchday ? " · " + fmtDeadline(matchday.lock_at) : "") + ".") ); return modalShell("MATCH PREVIEW", function() { setPreviewDuel(null); }, body, "560px"); }; var renderSummary = function() { var d = summaryDuel; if (!d) return null; var bd = d.breakdown || {}; var youB = d.challenger_id === uid ? bd.challenger : bd.opponent; var oppB = d.challenger_id === uid ? bd.opponent : bd.challenger; youB = youB || { starters: 0, captain: 0, bonus: 0, total: youPts(d) }; oppB = oppB || { starters: 0, captain: 0, bonus: 0, total: oppPts(d) }; var oc = duelOutcome(d); var ocColor = oc === "win" ? "#3fb96b" : oc === "loss" ? "#ff6b6b" : "#FFC836"; var oid = oppOf(d); // read each SCORED XI from the duel breakdown snapshot (so later team edits don't skew the // display); opponent's XI is revealed POST-resolution (no copy risk). Fall back to live state. var youSnap = (d.challenger_id === uid ? bd.challenger : bd.opponent) || {}; var oppSnap = (d.challenger_id === uid ? bd.opponent : bd.challenger) || {}; var youPick = youSnap.assignments ? { formation_key: youSnap.formation_key, assignments: youSnap.assignments, captain: youSnap.captain_id } : { formation_key: formationKey, assignments: assignments, captain: captain }; var oppPick = oppSnap.assignments ? { formation_key: oppSnap.formation_key, assignments: oppSnap.assignments, captain: oppSnap.captain_id } : null; // 2X double-points boosts from the scoring snapshot (v11 array, v10 single fallback) var youBoostIds = youSnap.boost_players || (youSnap.boost_player ? [youSnap.boost_player] : []); var oppBoostIds = oppSnap.boost_players || (oppSnap.boost_player ? [oppSnap.boost_player] : []); var brRow = function(label, yv, ov) { return h("div", { style: { display: "grid", gridTemplateColumns: "1fr auto 1fr", alignItems: "center", padding: "9px 0", borderBottom: "1px solid rgba(255,255,255,0.06)" } }, h("div", { style: { fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: "18px", color: "#F5EDD8", textAlign: "left" } }, yv), h("div", { style: { fontSize: "10px", letterSpacing: "1.5px", color: "rgba(245,237,216,0.5)", padding: "0 14px", textTransform: "uppercase" } }, label), h("div", { style: { fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: "18px", color: "#F5EDD8", textAlign: "right" } }, ov) ); }; var body = h("div", null, oc === "win" // arcade WINNER echo-trail (brand colors): solid gold pixel "WINNER" slams in, a mirrored fan of // gold->violet outline echoes unfurls below, converging to a smaller ghost WINNER at center-bottom ? (function () { var GOLD = [255, 200, 54], VIO = [199, 155, 242]; var lerp = function (t) { return "rgb(" + Math.round(GOLD[0] + (VIO[0] - GOLD[0]) * t) + "," + Math.round(GOLD[1] + (VIO[1] - GOLD[1]) * t) + "," + Math.round(GOLD[2] + (VIO[2] - GOLD[2]) * t) + ")"; }; var N = 18; var fs = isMobile ? 24 : 40; var kids = []; var i; for (i = N; i >= 1; i--) { var t = i / N; var sc = 1 - i * 0.028; var ty = i * (isMobile ? 4 : 5) + (i * i) * (isMobile ? 0.16 : 0.2); var op = Math.max(0.12, 1 - i * 0.05); kids.push( h("div", { key: "we" + i, style: { position: "absolute", left: "50%", top: "4px", transform: "translateX(-50%) translateY(" + ty + "px) scale(" + sc + ")", transformOrigin: "50% 0%", opacity: op, zIndex: N - i + 1, pointerEvents: "none" } }, h("div", { className: "winner-echo", style: { animationDelay: (i * 0.014) + "s", color: "transparent", WebkitTextStroke: "1px " + lerp(t), fontFamily: "'Press Start 2P', monospace", fontSize: fs + "px", letterSpacing: "0px", lineHeight: 1, whiteSpace: "nowrap" } }, "WINNER") ) ); } kids.push( h("div", { key: "wf", style: { position: "absolute", left: "50%", top: "4px", transform: "translateX(-50%)", zIndex: N + 4, pointerEvents: "none" } }, h("div", { className: "winner-front", style: { fontFamily: "'Press Start 2P', monospace", fontSize: fs + "px", letterSpacing: "0px", lineHeight: 1, color: "#FFC836", WebkitTextStroke: "2px #2A0540", whiteSpace: "nowrap", filter: "drop-shadow(0 0 9px rgba(255,200,54,0.55)) drop-shadow(0 3px 0 rgba(42,5,64,0.55))" } }, "WINNER") ) ); return h("div", { style: { position: "relative", height: (isMobile ? 168 : 214) + "px", overflow: "hidden", marginBottom: "2px" } }, kids); })() : h("div", { style: { textAlign: "center", marginBottom: "4px" } }, h("div", { style: { fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: "40px", letterSpacing: "1px", color: ocColor, lineHeight: 1 } }, oc === "loss" ? "YOU LOST" : "IT'S A DRAW"), oc === "draw" ? h("div", { style: { fontSize: "11px", color: "rgba(245,237,216,0.6)", marginTop: "6px", lineHeight: 1.4 } }, "Level on points — no winner. Both managers keep their points and neither gets a duel win.") : null ), h("div", { style: { display: "flex", alignItems: "center", justifyContent: "space-around", gap: "10px", margin: "16px 0" } }, h("div", { style: { textAlign: "center", flex: 1, minWidth: 0 } }, h("div", { style: { display: "flex", justifyContent: "center", marginBottom: "8px" } }, avatarFor(teamName, 56, true)), h("div", { style: { fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: "36px", color: ocColor, lineHeight: 1 } }, youPts(d)), h("div", { style: { fontSize: "12px", color: "#F5EDD8", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" } }, teamName)), h("div", { style: { fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: "24px", color: "#EEDFFB", flexShrink: 0, padding: "0 4px", textShadow: "0 0 14px rgba(199,155,242,0.66), 0 0 5px rgba(199,155,242,0.55)" } }, "VS"), h("div", { style: { textAlign: "center", flex: 1, minWidth: 0 } }, h("div", { style: { display: "flex", justifyContent: "center", marginBottom: "8px" } }, avatarFor(oid ? nameFor(oid) : "?", 56, false)), h("div", { style: { fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: "36px", color: "#F5EDD8", lineHeight: 1 } }, oppPts(d)), h("div", { style: { fontSize: "12px", color: "rgba(245,237,216,0.7)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" } }, oid ? nameFor(oid) : "Opponent")) ), h("div", { style: { margin: "12px 0 10px", textAlign: "center", background: "linear-gradient(90deg, rgba(247,176,28,0.02), rgba(247,176,28,0.26) 50%, rgba(247,176,28,0.02))", border: "1px solid rgba(247,176,28,0.30)", borderRadius: "8px", padding: "10px 14px", fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: "13px", letterSpacing: "2px", color: "#FFC836" } }, "POINTS BREAKDOWN"), brRow("Starters", youB.starters, oppB.starters), brRow("Captain", youB.captain, oppB.captain), brRow("Bonus", youB.bonus, oppB.bonus), (Number(youB.boost) > 0 || Number(oppB.boost) > 0) ? brRow("2X Boost", youB.boost || 0, oppB.boost || 0) : null, brRow("Total", youB.total, oppB.total), h("div", { style: { margin: "22px 0 10px", textAlign: "center", background: "linear-gradient(90deg, rgba(247,176,28,0.02), rgba(247,176,28,0.26) 50%, rgba(247,176,28,0.02))", border: "1px solid rgba(247,176,28,0.30)", borderRadius: "8px", padding: "10px 14px", fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: "13px", letterSpacing: "2px", color: "#FFC836" } }, "POINTS SCORED"), h("div", { style: { display: "grid", gridTemplateColumns: isMobile ? "1fr" : "1fr 1fr", gap: isMobile ? "22px" : "18px" } }, h("div", null, h("div", { style: { display: "flex", alignItems: "center", gap: "5px", fontSize: "12px", color: "#FFC836", fontFamily: "'Anton', 'Bebas Neue', sans-serif", letterSpacing: "1px", marginBottom: "5px" } }, dIcon("user", 12), teamName), teamColumn(youPick, youPick && youPick.captain, true, youBoostIds)), h("div", { style: isMobile ? { paddingTop: "18px", borderTop: "1px solid rgba(199,155,242,0.22)" } : null }, h("div", { style: { display: "flex", alignItems: "center", gap: "5px", fontSize: "12px", color: "rgba(245,237,216,0.7)", fontFamily: "'Anton', 'Bebas Neue', sans-serif", letterSpacing: "1px", marginBottom: "5px" } }, dIcon("user", 12), oid ? nameFor(oid) : "Opponent"), oppPick ? teamColumn(oppPick, oppPick.captain, false, oppBoostIds) : h("div", { style: { fontSize: "11px", color: "rgba(245,237,216,0.4)", padding: "8px 0" } }, "Opponent's XI is hidden.")) ), h("div", { style: { display: "flex", gap: "10px", marginTop: "20px" } }, h("button", { onClick: function() { if (oid) { setSummaryDuel(null); openTrash(d); } }, disabled: !oid, style: Object.assign({}, ghostBtnS, { flex: 1, padding: "13px", fontSize: "15px", display: "inline-flex", alignItems: "center", justifyContent: "center", gap: "8px", opacity: oid ? 1 : 0.5 }) }, dIcon("message", 16), "TRASH TALK"), h("button", { onClick: function() { rematch(d); }, disabled: !oid, style: Object.assign({}, goldBtnS, { flex: 1, padding: "13px", fontSize: "15px", display: "inline-flex", alignItems: "center", justifyContent: "center", gap: "8px", opacity: oid ? 1 : 0.5 }) }, dIcon("swords", 16), "REMATCH") ) ); return modalShell("DUEL SUMMARY", function() { setSummaryDuel(null); }, body, "640px"); }; var renderTrashModal = function() { var d = trashDuel; if (!d) return null; var oid = oppOf(d); var msgs = xTrash[d.id] || []; var body = h("div", { style: { display: "flex", flexDirection: "column", height: "min(58vh, 440px)" } }, h("div", { style: { flex: 1, overflowY: "auto", display: "flex", flexDirection: "column", gap: "8px", paddingBottom: "10px", minHeight: 0 } }, msgs.length ? msgs.map(function(m) { var mine = m.user_id === uid; return h("div", { key: m.id, style: { alignSelf: mine ? "flex-end" : "flex-start", maxWidth: "80%", background: mine ? "linear-gradient(160deg, #FFDE7A 0%, #F4AE1C 100%)" : "rgba(255,255,255,0.06)", color: mine ? "#1A0428" : "#F5EDD8", padding: "8px 12px", borderRadius: mine ? "13px 13px 3px 13px" : "13px 13px 13px 3px", fontSize: "13px", lineHeight: 1.35, wordBreak: "break-word" } }, m.body); }) : h("div", { style: { margin: "auto", textAlign: "center", color: "rgba(245,237,216,0.4)", fontSize: "12px", padding: "30px 0" } }, "No messages yet — fire the first shot.") ), h("div", { style: { paddingTop: "10px", borderTop: "1px solid rgba(176,128,214,0.15)" } }, h("div", { style: { fontSize: "9px", letterSpacing: "1.5px", color: "rgba(245,237,216,0.45)", marginBottom: "8px", textTransform: "uppercase" } }, "Tap a line to send"), h("div", { style: { display: "flex", gap: "7px", flexWrap: "wrap" } }, TRASH_LINES.map(function(line) { return h("button", { key: line, onClick: function() { sendTrash(d, line); }, style: { fontSize: "12px", padding: "8px 12px", borderRadius: "999px", border: "1px solid rgba(199,155,242,0.30)", background: "rgba(199,155,242,0.07)", color: "#FFC836", cursor: "pointer", whiteSpace: "nowrap", fontFamily: "'Anton', 'Bebas Neue', sans-serif", letterSpacing: "0.5px" } }, line); }) ) ) ); return modalShell("TRASH TALK" + (oid ? " · " + nameFor(oid) : ""), function() { setTrashDuel(null); }, body, "440px"); }; var renderChallengeModal = function() { var seg = function(id, label) { var active = chTab === id; return h("button", { key: id, onClick: function() { setChTab(id); }, style: { flex: 1, padding: "9px 0", borderRadius: "999px", fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: "13px", letterSpacing: "1px", cursor: "pointer", border: "none", background: active ? "linear-gradient(160deg, #FFDE7A 0%, #F4AE1C 100%)" : "transparent", color: active ? "#1A0428" : "rgba(245,237,216,0.7)" } }, label); }; var userRow = function(p, showFriend) { var isFriend = friendIds.indexOf(p.user_id) >= 0; return h("div", { key: p.user_id, style: { display: "flex", alignItems: "center", gap: "10px", padding: "9px 0", borderBottom: "1px solid rgba(255,255,255,0.05)" } }, avatarFor(p.team_name, 36, false), h("div", { style: { flex: 1, minWidth: 0 } }, h("div", { style: { fontSize: "14px", color: "#F5EDD8", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" } }, p.team_name || "Manager"), h("div", { style: { fontSize: "10px", color: "rgba(245,237,216,0.45)" } }, [p.city, p.country].filter(Boolean).join(", ") || "—") ), showFriend && !isFriend ? pillBtn("+ FRIEND", function() { addFriend(p.user_id); }, false, true) : null, pillBtn("CHALLENGE", function() { challengeUser(p.user_id); }, true, true) ); }; var body; if (chTab === "open") { var myOpen = myDuels.filter(function(d) { return d.challenger_id === uid && d.status === "pending" && !d.opponent_id; })[0]; body = h("div", null, myOpen ? h("div", { style: { marginBottom: "16px" } }, h("div", { style: { padding: "12px 14px", borderRadius: "12px", background: "rgba(247,176,28,0.1)", border: "1px solid rgba(247,176,28,0.35)", display: "flex", alignItems: "center", justifyContent: "space-between", gap: "10px" } }, h("div", { style: { minWidth: 0 } }, h("div", { style: { fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: "14px", letterSpacing: "1px", color: "#FFE9A8" } }, "YOUR CHALLENGE IS LIVE"), h("div", { style: { fontSize: "11px", color: "rgba(245,237,216,0.6)", marginTop: "2px", lineHeight: 1.45 } }, "Live for any manager to accept — and you can still challenge friends directly, as many as you like.")), pillBtn("CANCEL", function() { cancelDuel(myOpen); }, false, true)), h("div", { style: { display: "flex", gap: "8px", marginTop: "10px" } }, h("button", { onClick: function() { setChTab(friendIds.length ? "friends" : "search"); }, style: Object.assign({}, goldBtnS, { flex: 1, padding: "11px", fontSize: "13px", display: "inline-flex", alignItems: "center", justifyContent: "center", gap: "7px" }) }, dIcon("users", 15), "CHALLENGE A FRIEND"), h("button", { disabled: socialBusy, onClick: findRandom, style: Object.assign({}, ghostBtnS, { flex: 1, padding: "11px", fontSize: "13px", display: "inline-flex", alignItems: "center", justifyContent: "center", gap: "7px" }) }, dIcon("zap", 15), socialBusy ? "FINDING…" : "QUICK-MATCH"))) : [h("button", { key: "post", disabled: socialBusy, onClick: createOpenChallenge, style: Object.assign({}, goldBtnS, { width: "100%", padding: "13px", fontSize: "15px", marginBottom: "8px", opacity: socialBusy ? 0.6 : 1, display: "inline-flex", alignItems: "center", justifyContent: "center", gap: "8px" }) }, dIcon("swords", 16), "POST AN OPEN CHALLENGE"), h("button", { key: "quick", disabled: socialBusy, onClick: findRandom, style: Object.assign({}, ghostBtnS, { width: "100%", padding: "11px", fontSize: "13px", marginBottom: "16px", display: "inline-flex", alignItems: "center", justifyContent: "center", gap: "8px" }) }, dIcon("zap", 15), socialBusy ? "FINDING…" : "QUICK-MATCH — RANDOM OPPONENT")], h("div", { style: { fontSize: "10px", letterSpacing: "2px", color: "#FFC836", textTransform: "uppercase", margin: "4px 0 8px", fontFamily: "'Anton', 'Bebas Neue', sans-serif" } }, "OPEN CHALLENGES (" + openChallenges.length + ")"), openChallenges.length ? openChallenges.map(function(d) { var p = profileMap[d.challenger_id] || { user_id: d.challenger_id }; return h("div", { key: d.id, style: { display: "flex", alignItems: "center", gap: "10px", padding: "10px 0", borderBottom: "1px solid rgba(255,255,255,0.05)" } }, avatarFor(nameFor(d.challenger_id), 36, false), h("div", { style: { flex: 1, minWidth: 0 } }, h("div", { style: { fontSize: "14px", color: "#F5EDD8", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" } }, nameFor(d.challenger_id)), h("div", { style: { fontSize: "10px", color: "rgba(245,237,216,0.45)" } }, [p.city, p.country].filter(Boolean).join(", ") || "wants a duel this weekend")), pillBtn(socialBusy ? "…" : "ACCEPT", function() { acceptOpenChallenge(d); }, true, true)); }) : h("div", { style: { textAlign: "center", padding: "26px 0", color: "rgba(245,237,216,0.4)", fontSize: "12px" } }, "No open challenges right now — post yours above and a manager can accept it.") ); } else if (chTab === "search") { body = h("div", null, h("input", { value: userQuery, onChange: function(e) { searchUsers(e.target.value); }, placeholder: "Search managers by team name…", style: { width: "100%", boxSizing: "border-box", background: "rgba(0,0,0,0.3)", border: "1px solid rgba(176,128,214,0.3)", borderRadius: "10px", padding: "12px 14px", color: "#F5EDD8", fontSize: "16px", outline: "none", fontFamily: "inherit", marginBottom: "12px" } }), userResults.length ? userResults.map(function(p) { return userRow(p, true); }) : h("div", { style: { textAlign: "center", padding: "30px 0", color: "rgba(245,237,216,0.4)", fontSize: "12px" } }, userQuery.trim().length >= 2 ? "No managers found." : "Type a team name to find opponents.") ); } else { var fps = friendIds.map(function(id) { return profileMap[id] || { user_id: id, team_name: nameFor(id) }; }); body = fps.length ? h("div", null, fps.map(function(p) { return userRow(p, false); })) : h("div", { style: { textAlign: "center", padding: "30px 0", color: "rgba(245,237,216,0.4)", fontSize: "12px" } }, "No friends yet — find managers in Search and send a request."); } return modalShell("CHALLENGE", function() { setChallengeOpen(false); }, h("div", null, h("div", { style: { display: "flex", gap: "6px", background: "rgba(0,0,0,0.25)", borderRadius: "999px", padding: "4px", marginBottom: "16px" } }, seg("open", "OPEN"), seg("search", "SEARCH"), seg("friends", "FRIENDS")), body ) ); }; // ── DUEL HISTORY — its own tab (all my duels across matchdays) ── var renderHistory = function() { return h("div", { style: pageS }, h("div", { style: panelS }, h("div", { style: { fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: "18px", letterSpacing: "1.5px", color: "#F5EDD8", marginBottom: "14px" } }, "DUEL HISTORY"), (histLoading && !xHistory.length) ? h("div", { style: { textAlign: "center", padding: "44px 0", color: "rgba(245,237,216,0.5)", fontSize: "13px" } }, "Loading your duels…") : (xHistory.length ? xHistory.map(function(d) { var oid = oppOf(d); var isRes = d.status === "resolved"; var oc = isRes ? duelOutcome(d) : null; // Resolved → open the DUEL SUMMARY (lineups + W/L). Accepted & matched → open the // MATCH PREVIEW (both XIs chosen) instead of a dead "ACCEPTED" label. (#3) var canScout = !isRes && d.status === "accepted" && oid && !isTestId(d.id); var clickable = isRes || canScout; var open = isRes ? function() { setSummaryDuel(d); } : (canScout ? function() { setPrevSide("them"); setPreviewDuel(d); } : null); // resolved → show WON/LOST + score, action chip "SUMMARY" (full points breakdown + both XIs); // accepted & matched → "VIEW XIS" (scout); else a plain status chip. var resColor = oc === "win" ? "#3fb96b" : oc === "loss" ? "#ff6b6b" : "#FFC836"; var resWord = oc === "win" ? "WON" : oc === "loss" ? "LOST" : "DREW"; return h("div", { key: d.id, onClick: clickable ? open : undefined, style: { display: "flex", alignItems: "center", gap: "10px", padding: "11px 0", borderBottom: "1px solid rgba(255,255,255,0.05)", cursor: clickable ? "pointer" : "default" } }, avatarFor(oid ? nameFor(oid) : "?", 38, oc === "win"), h("div", { style: { flex: 1, minWidth: 0 } }, h("div", { style: { fontSize: "14px", color: "#F5EDD8", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" } }, "vs " + (oid ? nameFor(oid) : "Open challenge")), h("div", { style: { fontSize: "10px", color: "rgba(245,237,216,0.45)" } }, mdNames[d.matchday_id] || "Matchday")), isRes ? h("div", { style: { textAlign: "right", lineHeight: 1.1, flexShrink: 0 } }, h("div", { style: { fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: "14px", color: resColor, letterSpacing: "0.5px" } }, resWord), h("div", { style: { fontSize: "11px", color: "rgba(245,237,216,0.6)" } }, youPts(d) + "–" + oppPts(d)) ) : null, isRes ? clickChip("SUMMARY", "#FFC836", function(e) { e.stopPropagation(); setSummaryDuel(d); }, "Points breakdown + both line-ups") : canScout ? clickChip("VIEW XIS", "#C79BF2", function(e) { e.stopPropagation(); setPrevSide("them"); setPreviewDuel(d); }, "Scout both line-ups") : statusChip(d.status.toUpperCase(), "#9a8fb0")); }) : h("div", { style: { textAlign: "center", padding: "44px 0", color: "rgba(245,237,216,0.4)", fontSize: "13px", lineHeight: 1.6 } }, "No completed duels yet. Once a duel is scored from the weekend's matches, it shows up here with the full breakdown.")) ) ); }; // ── COMPARE PLAYERS (#4) — head-to-head stats so you can choose between two ── var cmpStat = function(label, aVal, bVal, max) { var aw = Math.max(0, Math.min(100, (aVal / max) * 100)), bw = Math.max(0, Math.min(100, (bVal / max) * 100)); var aWin = aVal > bVal, bWin = bVal > aVal, hot = "#3fb96b", dim = "rgba(245,237,216,0.65)"; var half = function(w, win, end) { return h("div", { style: { flex: 1, display: "flex", justifyContent: end ? "flex-end" : "flex-start", background: "rgba(255,255,255,0.06)", borderRadius: "3px", overflow: "hidden", height: "5px" } }, h("div", { style: { width: w + "%", height: "100%", background: win ? hot : DG.gold, opacity: win ? 1 : 0.45 } })); }; return h("div", { key: label, style: { marginBottom: "12px" } }, h("div", { style: { display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: "4px" } }, h("div", { style: { fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: "19px", width: "48px", color: aWin ? hot : dim, lineHeight: 1 } }, aVal), h("div", { style: { fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: "11px", letterSpacing: "1.5px", color: "rgba(245,237,216,0.55)" } }, label), h("div", { style: { fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: "19px", width: "48px", textAlign: "right", color: bWin ? hot : dim, lineHeight: 1 } }, bVal)), h("div", { style: { display: "flex", gap: "4px" } }, half(aw, aWin, true), half(bw, bWin, false)) ); }; var cmpHead = function(p) { return h("div", { style: { flex: 1, textAlign: "center", minWidth: 0 } }, p.portrait ? h("img", { src: p.portrait, style: { width: "58px", height: "58px", borderRadius: "50%", objectFit: "cover", objectPosition: "50% 16%", border: "2px solid " + DG.gold, background: "rgba(255,255,255,0.06)" } }) : h("div", { style: { width: "58px", height: "58px", borderRadius: "50%", margin: "0 auto", border: "2px solid " + DG.gold, background: "rgba(255,255,255,0.06)" } }), h("div", { style: { fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: "16px", letterSpacing: "0.5px", color: "#F5EDD8", marginTop: "7px", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" } }, displayName(p)), h("div", { style: { fontSize: "9px", color: "rgba(245,237,216,0.5)", letterSpacing: "0.5px", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" } }, (p.club || "") + " · " + p.pos), h("div", { style: { fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: "32px", color: DG.gold, lineHeight: 1, marginTop: "5px" } }, p.rating) ); }; var renderCompareModal = function() { var a = getPlayer(compareSel[0]), b = getPlayer(compareSel[1]); if (!a || !b) return modalShell("COMPARE PLAYERS", function() { setCompareOpen(false); }, h("div", { style: { textAlign: "center", padding: "34px 0", color: "rgba(245,237,216,0.5)", fontSize: "13px" } }, "Pick two players from the pool to compare."), "460px"); var onPitch = function(p) { return Object.keys(assignments).some(function(k) { return assignments[k] === p.id; }); }; var shortName = function(p) { var dn = displayName(p), parts = dn.split(". "); return (parts.length > 1 ? parts[1] : dn).toUpperCase(); }; var addBtn = function(p) { return onPitch(p) ? h("button", { disabled: true, style: Object.assign({}, ghostBtnS, { flex: 1, padding: "11px", fontSize: "12px", opacity: 0.5, cursor: "default", color: "#3fb96b", borderColor: "rgba(63,185,107,0.5)" }) }, "✓ ON PITCH") : h("button", { onClick: function() { placeFromPool(p); setCompareOpen(false); }, style: Object.assign({}, ghostBtnS, { flex: 1, padding: "11px", fontSize: "12px" }) }, "+ ADD " + shortName(p)); }; var body = h("div", null, h("div", { style: { display: "flex", alignItems: "flex-start", gap: "8px", marginBottom: "18px" } }, cmpHead(a), h("div", { style: { fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: "17px", color: DG.violet, alignSelf: "center", textShadow: "0 0 12px rgba(199,155,242,0.6)", flexShrink: 0 } }, "VS"), cmpHead(b)), cmpStat("RATING", a.rating, b.rating, 99), cmpStat("PROJ PTS", fantasyProjection(a), fantasyProjection(b), 22), cmpStat("FORM", +formScore(a).toFixed(1), +formScore(b).toFixed(1), 10), cmpStat("PACE", a.pace, b.pace, 99), cmpStat("SHOOTING", a.sho, b.sho, 99), cmpStat("PASSING", a.pas, b.pas, 99), cmpStat("DRIBBLING", a.dri, b.dri, 99), cmpStat("DEFENDING", a.def, b.def, 99), cmpStat("PHYSICAL", a.phy, b.phy, 99), !pickLocked && h("div", { style: { display: "flex", gap: "8px", marginTop: "16px" } }, addBtn(a), addBtn(b)) ); return modalShell("COMPARE PLAYERS", function() { setCompareOpen(false); }, body, "460px"); }; var renderInboxModal = function() { var section = function(title, items, render) { return h("div", { style: { marginBottom: "18px" } }, h("div", { style: { fontSize: "10px", letterSpacing: "2px", color: "#FFC836", marginBottom: "10px", fontFamily: "'Anton', 'Bebas Neue', sans-serif" } }, title), items.length ? items.map(render) : h("div", { style: { fontSize: "12px", color: "rgba(245,237,216,0.4)", padding: "4px 0" } }, "Nothing here.") ); }; return modalShell("NOTIFICATIONS", function() { setInboxOpen(false); }, h("div", null, section("DUEL CHALLENGES", xInbox, function(d) { return h("div", { key: d.id, style: { display: "flex", alignItems: "center", gap: "10px", padding: "9px 0", borderBottom: "1px solid rgba(255,255,255,0.05)" } }, avatarFor(nameFor(d.challenger_id), 36, false), h("div", { style: { flex: 1, minWidth: 0 } }, h("div", { style: { fontSize: "14px", color: "#F5EDD8" } }, nameFor(d.challenger_id)), h("div", { style: { fontSize: "10px", color: "rgba(245,237,216,0.45)" } }, "wants to duel you")), pillBtn("ACCEPT", function() { acceptDuel(d); }, true, true), pillBtn("DECLINE", function() { declineDuel(d); }, false, true) ); }), section("FRIEND REQUESTS", xFriendReqs, function(f) { return h("div", { key: f.user_id, style: { display: "flex", alignItems: "center", gap: "10px", padding: "9px 0", borderBottom: "1px solid rgba(255,255,255,0.05)" } }, avatarFor(nameFor(f.user_id), 36, false), h("div", { style: { flex: 1, minWidth: 0 } }, h("div", { style: { fontSize: "14px", color: "#F5EDD8" } }, nameFor(f.user_id)), h("div", { style: { fontSize: "10px", color: "rgba(245,237,216,0.45)" } }, "sent a friend request")), pillBtn("ACCEPT", function() { acceptFriend(f); }, true, true), pillBtn("IGNORE", function() { declineFriend(f); }, false, true) ); }) ) ); }; var renderDashboard = function() { return h("div", { style: pageS }, // §26.8 TEST MODE toggle — preview the 2X Double-Points flow solo (sample cards + a sample resolved duel) TEST_MODES_ENABLED ? h("div", { style: { display: "flex", justifyContent: "center", marginBottom: duelTestMode ? "10px" : "14px" } }, h("button", { onClick: function() { var v = !duelTestMode; setDuelTestMode(v); try { localStorage.setItem("duel_testmode2x", v ? "1" : "0"); } catch (e) {} }, style: { fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: "11px", letterSpacing: "1.5px", padding: "7px 16px", borderRadius: "999px", cursor: "pointer", border: duelTestMode ? "1px solid " + DG.gold : "1px solid rgba(199,155,242,0.3)", background: duelTestMode ? "rgba(247,176,28,0.14)" : "rgba(199,155,242,0.06)", color: duelTestMode ? DG.gold : "rgba(245,237,216,0.6)" } }, "TEST MODE: " + (duelTestMode ? "ON" : "OFF"))) : null, duelTestMode ? h("div", { style: { textAlign: "center", fontSize: "11px", color: "rgba(245,237,216,0.65)", lineHeight: 1.5, maxWidth: "480px", margin: "0 auto 16px", padding: "9px 14px", borderRadius: "10px", background: "rgba(247,176,28,0.08)", border: "1px solid rgba(247,176,28,0.25)" } }, Object.keys(assignments).length ? "2X PREVIEW: two sample Double-Points cards are in your hand (see the 2X wallet below). Go to MY TEAM → the DOUBLE POINTS panel (it shows even if your XI is locked) → tap players to place 2X chips and watch the pitch badges. Then open the sample \"Test Rival FC\" result on this page to see the 2X Boost row in the match summary. Nothing is saved — test cards never submit." : "2X PREVIEW: two sample Double-Points cards are in your hand. Add players in MY TEAM first — the 2X chips and the sample resolved duel build from your current XI. Nothing is saved.") : null, // SEASON-START countdown — real matches haven't kicked off yet; build/scout now, scored at kickoff (#6). seasonNotStarted ? h("div", { style: { marginBottom: "16px", padding: isMobile ? "16px" : "20px 22px", borderRadius: "16px", background: "linear-gradient(120deg, rgba(247,176,28,0.18), rgba(199,155,242,0.13))", border: "1px solid rgba(255,200,54,0.45)", boxShadow: "0 6px 22px rgba(0,0,0,0.28)", textAlign: "center" } }, h("div", { style: { display: "inline-flex", alignItems: "center", gap: "8px", color: DG.gold, fontFamily: "'Anton', 'Bebas Neue', sans-serif", letterSpacing: "2px", fontSize: "11px", marginBottom: "6px" } }, dIcon("clock", 14), "SEASON STARTS IN"), h("div", { style: { fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: isMobile ? "42px" : "54px", lineHeight: 1, color: "#FFFFFF", textShadow: "0 2px 16px rgba(247,176,28,0.4)" } }, seasonDays + (seasonDays === 1 ? " DAY" : " DAYS")), h("div", { style: { fontSize: "12px", color: "rgba(245,237,216,0.72)", marginTop: "8px", fontFamily: "'Anton', 'Bebas Neue', sans-serif", letterSpacing: "1px" } }, fmtCountdown(seasonStartMs) + (matchday && matchday.season_start_at ? " · KICKOFF " + fmtDate(matchday.season_start_at) : "")), h("div", { style: { fontSize: "11px", color: "rgba(245,237,216,0.55)", marginTop: "8px", lineHeight: 1.45 } }, "Build and scout your XI now — duels are scored once the season kicks off.") ) : null, // §26 — INFLUENCER TAKEOVER hero (only when a flagged influencer exists) (function() { if (!influencers.length) return null; var inf = influencers[Math.min(infIdx, influencers.length - 1)]; var iPick = infPicks[inf.user_id]; var n = infCounts[inf.user_id] || 0; var mine = myDuels.filter(function(d) { return (d.challenger_id === uid ? d.opponent_id : d.challenger_id) === inf.user_id && (d.status === "pending" || d.status === "accepted"); })[0]; var nm = inf.team_name || "Influencer"; return h("div", { style: Object.assign({}, panelS, { border: "1.5px solid rgba(255,200,54,0.55)", boxShadow: "0 16px 44px rgba(0,0,0,0.5), 0 0 30px rgba(247,176,28,0.12)", overflow: "hidden", position: "relative", padding: 0, marginBottom: "4px" }) }, h("div", { style: { position: "absolute", inset: 0, background: "radial-gradient(560px 300px at 12% 0%, rgba(247,176,28,0.14), transparent 60%), radial-gradient(420px 300px at 100% 100%, rgba(199,155,242,0.16), transparent 60%)", pointerEvents: "none" } }), h("div", { style: { position: "relative", padding: isMobile ? "14px 14px 12px" : "18px 20px 14px", display: "flex", gap: isMobile ? "12px" : "20px", alignItems: "stretch", flexWrap: isMobile ? "wrap" : "nowrap" } }, // left: takeover copy + avatar + CTA over the hero art. Backdrop = the influencer's // ON-BRAND hero artwork (`profiles.influencer_hero`, generated via the hometeam-image // skill) when set, else the generic brand art (`influencer_photo` is only the small // circular avatar). DESKTOP (≥1000px): full-bleed cover behind the text column. // MOBILE: a full-width HERO BANNER at the top of the card — art cover-fit with a // bottom scrim, badge + avatar + name overlaid on it, tagline + CTA below. h("div", { style: { flex: "1 1 0", minWidth: isMobile ? "100%" : 0, position: "relative" } }, isMobile ? [ h("div", { key: "mb", style: { position: "relative", height: "215px", margin: "-14px -14px 12px", borderTopLeftRadius: "17px", borderTopRightRadius: "17px", overflow: "hidden" } }, h("img", { src: inf.influencer_hero || TAKEOVER_HERO_IMG, alt: "", style: { position: "absolute", top: 0, left: 0, width: "100%", height: "100%", objectFit: "cover", objectPosition: "62% 12%", pointerEvents: "none" } }), h("div", { style: { position: "absolute", top: 0, left: 0, right: 0, bottom: 0, background: "linear-gradient(to bottom, rgba(26,4,40,0.18) 30%, rgba(26,4,40,0.55) 68%, rgba(21,3,33,0.96) 100%)", pointerEvents: "none" } }), h("div", { style: { position: "absolute", top: "10px", left: "12px", display: "inline-flex", alignItems: "center", gap: "6px", padding: "4px 10px", borderRadius: "999px", background: "rgba(26,4,40,0.55)", border: "1px solid rgba(255,200,54,0.5)", backdropFilter: "blur(3px)" } }, dIcon("shield", 11), h("span", { style: { fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: "9px", letterSpacing: "2px", color: DG.gold } }, "INFLUENCER TAKEOVER")), h("div", { style: { position: "absolute", left: "12px", right: "12px", bottom: "10px", display: "flex", alignItems: "center", gap: "11px" } }, inf.influencer_photo ? h("img", { src: inf.influencer_photo, alt: nm, style: { width: "52px", height: "52px", borderRadius: "50%", objectFit: "cover", objectPosition: "top", border: "2px solid rgba(255,200,54,0.7)", boxShadow: "0 6px 16px rgba(0,0,0,0.55)", flexShrink: 0 } }) : avatarFor(nm, 48, true), h("div", { style: { minWidth: 0 } }, h("div", { style: { fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: "12px", letterSpacing: "1.5px", color: "#FFFFFF", lineHeight: 1, textShadow: "0 1px 6px rgba(0,0,0,0.8)" } }, "CHALLENGE"), h("div", { style: { fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: "30px", letterSpacing: "1px", color: DG.gold, lineHeight: 1.04, textShadow: "0 0 18px rgba(247,176,28,0.5), 0 2px 8px rgba(0,0,0,0.8)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" } }, nm.toUpperCase())))), h("div", { key: "mt", style: { fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: "16px", letterSpacing: "0.5px", color: "#F5EDD8", lineHeight: 1.3, marginBottom: "12px" } }, inf.influencer_tagline || ("Can you beat " + nm + "'s team? Prove it.")), h("button", { key: "mc", onClick: function() { if (mine) { enterCustomXI(mine); } else { challengeUser(inf.user_id); } }, style: Object.assign({}, goldBtnS, { width: "100%", fontSize: "14px", padding: "13px 20px", marginBottom: "4px", display: "inline-flex", alignItems: "center", justifyContent: "center", gap: "8px" }) }, dIcon("swords", 16), mine ? "YOU'RE IN — BUILD YOUR XI" : ("CHALLENGE " + nm.toUpperCase())) ] : [ (winW >= 1000) ? h("img", { key: "da", src: inf.influencer_hero || TAKEOVER_HERO_IMG, alt: "", style: { position: "absolute", top: "-18px", right: "-20px", bottom: "-14px", left: "-20px", width: "calc(100% + 40px)", height: "calc(100% + 32px)", objectFit: "cover", objectPosition: "78% 16%", zIndex: 1, pointerEvents: "none", WebkitMaskImage: "linear-gradient(to right, transparent 0%, rgba(0,0,0,0.25) 30%, #000 62%, #000 96%, transparent), linear-gradient(to bottom, rgba(0,0,0,0.55) 0%, #000 34%, #000 92%, transparent)", maskImage: "linear-gradient(to right, transparent 0%, rgba(0,0,0,0.25) 30%, #000 62%, #000 96%, transparent), linear-gradient(to bottom, rgba(0,0,0,0.55) 0%, #000 34%, #000 92%, transparent)", WebkitMaskComposite: "source-in", maskComposite: "intersect" } }) : null, // z2 content: name block, then tagline + CTA ~46px below — vertically centered, LEFT-aligned h("div", { key: "dz", style: { position: "relative", zIndex: 2, display: "flex", flexDirection: "column", justifyContent: "center", height: "100%" } }, h("div", null, h("div", { style: { display: "inline-flex", alignItems: "center", gap: "7px", padding: "5px 12px", borderRadius: "999px", background: "rgba(247,176,28,0.14)", border: "1px solid rgba(255,200,54,0.5)", marginBottom: "10px" } }, dIcon("shield", 13), h("span", { style: { fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: "10px", letterSpacing: "2.5px", color: DG.gold } }, "INFLUENCER TAKEOVER")), h("div", { style: { display: "flex", alignItems: "center", gap: "16px" } }, inf.influencer_photo ? h("img", { src: inf.influencer_photo, alt: nm, style: { width: "88px", height: "88px", borderRadius: "50%", objectFit: "cover", objectPosition: "top", border: "2px solid rgba(255,200,54,0.6)", boxShadow: "0 6px 18px rgba(0,0,0,0.45)", flexShrink: 0 } }) : avatarFor(nm, 80, true), h("div", { style: { minWidth: 0 } }, h("div", { style: { fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: "16px", letterSpacing: "1px", color: "#FFFFFF", lineHeight: 1 } }, "CHALLENGE"), h("div", { style: { fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: "48px", letterSpacing: "1px", color: DG.gold, lineHeight: 1.02, textShadow: "0 0 22px rgba(247,176,28,0.45)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" } }, nm.toUpperCase())))), h("div", { style: { marginTop: "46px" } }, h("div", { style: { fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: "22px", letterSpacing: "0.5px", color: "#F5EDD8", lineHeight: 1.25, textShadow: "0 2px 14px rgba(0,0,0,0.85)", marginBottom: "12px", maxWidth: "360px" } }, inf.influencer_tagline || ("Can you beat " + nm + "'s team? Prove it.")), h("button", { onClick: function() { if (mine) { enterCustomXI(mine); } else { challengeUser(inf.user_id); } }, style: Object.assign({}, goldBtnS, { fontSize: "15px", padding: "13px 26px", display: "inline-flex", alignItems: "center", gap: "8px" }) }, dIcon("swords", 16), mine ? "YOU'RE IN — BUILD YOUR XI" : ("CHALLENGE " + nm.toUpperCase())))) ]), // right: the influencer's PUBLIC team. The mini-pitch renders at its known-good // width (375px, the verified mobile-builder layout) and is SCALED down whole — // cards, names and band spacing shrink together instead of crowding. (function() { var pw = isMobile ? 248 : 306, natural = 375, k = pw / natural; // crop the wrapper just below the GK's name (the pitch's own bottom band is dead // grass — GK sits at y86%, card+name reach ~97%) so the card doesn't trail empty space var ph = Math.round((natural / 0.62) * k * 0.97); return h("div", { style: { flexShrink: 0, width: pw + "px", margin: isMobile ? "0 auto" : 0 } }, h("div", { style: { fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: "9px", letterSpacing: "2px", color: DG.violet, textAlign: "center", marginBottom: "6px" } }, nm.toUpperCase() + "'S TEAM"), iPick ? h("div", { style: { width: pw + "px", height: ph + "px", overflow: "hidden", borderRadius: "14px" } }, h("div", { style: { width: natural + "px", transform: "scale(" + k + ")", transformOrigin: "top left" } }, renderPreviewPitch(iPick))) : h("div", { style: { textAlign: "center", padding: "30px 8px", borderRadius: "12px", border: "1px dashed rgba(199,155,242,0.35)", color: "rgba(245,237,216,0.55)", fontSize: "11px", lineHeight: 1.5 } }, nm + " hasn't posted this week's XI yet — challenge them anyway!")); })()), // stats strip: live challenger count + the 2X reward hook h("div", { style: { position: "relative", display: "flex", alignItems: "stretch", borderTop: "1px solid rgba(255,200,54,0.25)", background: "rgba(8,2,18,0.55)" } }, h("div", { style: { flex: 1, display: "flex", alignItems: "center", justifyContent: "center", gap: "9px", padding: isMobile ? "10px 8px" : "12px 10px" } }, h("span", { style: { color: DG.violet } }, dIcon("users", 17)), h("span", { style: { fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: isMobile ? "16px" : "19px", color: "#FFFFFF" } }, String(n)), h("span", { style: { fontSize: isMobile ? "8.5px" : "10px", letterSpacing: "1px", color: "rgba(245,237,216,0.6)", textTransform: "uppercase" } }, (n === 1 ? "player is" : "players are") + " challenging " + nm)), h("div", { style: { width: "1px", background: "rgba(176,128,214,0.2)" } }), h("div", { style: { flex: 1.2, display: "flex", alignItems: "center", justifyContent: "center", gap: "9px", padding: isMobile ? "10px 8px" : "12px 10px" } }, h("span", { style: { color: DG.gold } }, dIcon("trophy", 17)), h("span", { style: { fontSize: isMobile ? "8.5px" : "10px", letterSpacing: "0.5px", color: "rgba(245,237,216,0.75)", lineHeight: 1.35, maxWidth: isMobile ? "150px" : "220px" } }, "BEAT " + nm.toUpperCase() + " and get DOUBLE POINTS for 1 player next matchday!"), h("span", { style: { fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: isMobile ? "16px" : "20px", padding: "3px 9px", borderRadius: "9px", background: "linear-gradient(160deg, rgba(199,155,242,0.25), rgba(120,60,180,0.3))", border: "1px solid rgba(199,155,242,0.55)", color: DG.violet } }, "2X"))), // carousel dots influencers.length > 1 ? h("div", { style: { position: "relative", display: "flex", justifyContent: "center", gap: "7px", padding: "9px 0 11px", background: "rgba(8,2,18,0.55)" } }, influencers.map(function(_, i) { return h("button", { key: i, onClick: function() { setInfIdx(i); }, style: { width: i === infIdx ? "18px" : "8px", height: "8px", borderRadius: "999px", border: "none", padding: 0, cursor: "pointer", background: i === infIdx ? DG.gold : "rgba(245,237,216,0.25)", transition: "all 160ms ease" } }); })) : null); })(), // hero — extra top margin so the image's overflowing heads (overflow:visible) clear the // TEST MODE toggle above instead of overlapping it. h("div", { style: Object.assign({}, panelS, { background: "linear-gradient(115deg, #320b50 0%, #401059 45%, #5a1780 100%)", border: "1px solid rgba(199,155,242,0.24)", overflow: "visible", position: "relative", minHeight: isMobile ? "152px" : "186px", marginTop: isMobile ? "26px" : "88px" }) }, h("div", { style: { position: "absolute", inset: 0, borderRadius: "18px", background: "radial-gradient(420px 280px at 88% 30%, rgba(247,176,28,0.13), transparent 60%), radial-gradient(360px 260px at 100% 120%, rgba(199,155,242,0.20), transparent 60%)", pointerEvents: "none" } }), h("div", { style: { position: "relative", zIndex: 2, maxWidth: isMobile ? "54%" : "60%" } }, h("div", { style: { fontSize: "10px", letterSpacing: "3px", color: DG.gold, marginBottom: "6px", fontFamily: "'Anton', 'Bebas Neue', sans-serif" } }, "THIS WEEKEND"), h("div", { style: { fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: isMobile ? "30px" : "44px", letterSpacing: "1px", color: "#FFFFFF", lineHeight: 1, textShadow: "0 2px 18px rgba(0,0,0,0.35)" } }, "BUILD YOUR TEAM"), h("div", { style: { display: "flex", alignItems: "center", gap: "6px", color: "rgba(245,237,216,0.75)", fontSize: "12px", margin: "8px 0 14px" } }, dIcon("clock", 13), "Deadline: " + (matchday ? fmtDeadline(matchday.lock_at) : "—") + (deadlineMs != null ? " · " + fmtCountdown(deadlineMs) : "")), h("button", { onClick: function() { setTab("build"); }, style: Object.assign({}, goldBtnS, { fontSize: "16px", padding: "13px 28px" }) }, "BUILD TEAM") ), // pre-composed hero image — anchored bottom-right (touching the right + bottom corners), bigger, // with the players' heads overflowing the card top a little for depth (overflow:visible above). h("img", { src: HERO_BUILD_IMG, alt: "", style: { position: "absolute", right: 0, bottom: 0, height: isMobile ? "176px" : "256px", width: "auto", maxWidth: isMobile ? "44%" : "50%", objectFit: "contain", objectPosition: "bottom right", borderBottomRightRadius: isMobile ? "14px" : "18px", zIndex: 1, pointerEvents: "none", filter: "drop-shadow(-10px 8px 18px rgba(0,0,0,0.42))" } }) ), // your stats — one block with minimalist separators between the four stats h("div", { style: { display: "flex", alignItems: "center", justifyContent: "space-between", margin: "26px 0 12px" } }, h("div", { style: { fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: "13px", letterSpacing: "3px", color: DG.gold } }, "YOUR STATS"), h("button", { onClick: function() { openShare("stats"); }, style: Object.assign({}, goldBtnS, { padding: "8px 16px", fontSize: "12px", display: "inline-flex", alignItems: "center", gap: "6px" }) }, dIcon("target", 14), "SHARE")), h("div", { style: { display: "flex", alignItems: "stretch", background: DG.inset, border: DG.border, borderRadius: "16px", boxShadow: "inset 0 1px 0 rgba(255,255,255,0.04)", overflow: "hidden" } }, [["trophy", "" + myWins, "Duels Won"], ["flame", "" + myWins, "Win Streak"], ["star", "" + Math.round(myTotalPts), "Total Points"], ["hash", "—", "Global Rank"]].map(function(c, i) { return [ i > 0 ? h("div", { key: "sep" + i, style: { width: "1px", alignSelf: "center", height: "46px", background: "rgba(176,128,214,0.18)" } }) : null, h("div", { key: "cell" + i, style: { flex: 1, minWidth: 0, textAlign: "center", padding: isMobile ? "14px 4px" : "18px 10px" } }, h("div", { style: { color: DG.gold, display: "flex", justifyContent: "center", marginBottom: "8px", filter: "drop-shadow(0 2px 6px rgba(247,176,28,0.32))" } }, dIcon(c[0], isMobile ? 18 : 20)), h("div", { style: { fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: isMobile ? "24px" : "32px", color: DG.cream, lineHeight: 1 } }, c[1]), h("div", { style: { fontSize: "9px", letterSpacing: "1px", color: "rgba(245,237,216,0.5)", marginTop: "5px", textTransform: "uppercase", lineHeight: 1.2 } }, c[2]) ) ]; }) ), // upcoming duel (real — P2) renderUpcoming(), // how it works h("div", { style: Object.assign({}, panelS, { marginTop: "22px" }) }, h("div", { style: panelTitleS }, "HOW IT WORKS"), h("div", { style: { display: "flex", flexWrap: "wrap", gap: "16px" } }, howStep(1, "userPlus", "PICK", "Build your XI in 200 seconds"), howStep(2, "users", "CHALLENGE", "Take on a friend"), howStep(3, "zap", "SCORE", "Players earn points in real matches"), howStep(4, "trophy", "WIN", "Highest score takes the duel") ) ) ); }; // ── BUILD YOUR TEAM ─────────────────────────────────────────────────────────── var poolFilterTabs = ["ALL", "GK", "DEF", "MID", "FWD"]; var groupMatch = function(p, f) { if (f === "ALL") return true; if (f === "FWD") return POS_GROUP[p.pos] === "ATT"; return POS_GROUP[p.pos] === f; }; var poolRow = function(p) { var onPitch = Object.keys(assignments).some(function(k) { return assignments[k] === p.id; }); var inCmp = compareSel.indexOf(p.id) >= 0; return h("div", { key: p.id, style: { display: "grid", gridTemplateColumns: "34px 1fr auto auto 26px 28px", gap: "8px", alignItems: "center", padding: "9px 10px", borderRadius: "11px", background: DG.inset, border: "1px solid " + (inCmp ? "rgba(199,155,242,0.5)" : "rgba(176,128,214,0.12)"), marginBottom: "6px" } }, p.portrait ? h("img", { src: p.portrait, style: { width: "34px", height: "34px", borderRadius: "50%", objectFit: "cover", objectPosition: "50% 20%", background: "rgba(255,255,255,0.06)" } }) : h("div", { style: { width: "34px", height: "34px", borderRadius: "50%", background: "rgba(255,255,255,0.06)" } }), h("div", { style: { minWidth: 0 } }, h("div", { style: { fontSize: "14px", fontWeight: 600, color: "#F5EDD8", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" } }, displayName(p)), h("div", { style: { fontSize: "10px", color: "rgba(245,237,216,0.5)", letterSpacing: "0.5px", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" } }, (p.club || "") + " · " + p.pos) ), h("div", { style: { textAlign: "center" } }, h("div", { style: { fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: "17px", color: "#F5EDD8", lineHeight: 1 } }, fantasyProjection(p)), h("div", { style: { fontSize: "8px", letterSpacing: "1px", color: "rgba(245,237,216,0.45)" } }, "PTS")), h("div", { style: { textAlign: "center" } }, h("div", { style: { fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: "17px", color: DG.gold, lineHeight: 1 } }, formScore(p).toFixed(1)), h("div", { style: { fontSize: "8px", letterSpacing: "1px", color: "rgba(245,237,216,0.45)" } }, "FORM")), h("button", { onClick: function() { toggleCompare(p.id); }, title: "Compare", style: { width: "26px", height: "26px", borderRadius: "50%", border: "1px solid " + (inCmp ? DG.violet : "rgba(176,128,214,0.3)"), background: inCmp ? "rgba(199,155,242,0.2)" : "rgba(255,255,255,0.04)", color: inCmp ? DG.violet : "rgba(245,237,216,0.55)", cursor: "pointer", display: "flex", alignItems: "center", justifyContent: "center", padding: 0 } }, dIcon("compare", 13)), onPitch ? h("div", { style: { width: "28px", height: "28px", borderRadius: "50%", background: "rgba(63,185,107,0.18)", border: "1px solid rgba(63,185,107,0.6)", color: "#3fb96b", display: "flex", alignItems: "center", justifyContent: "center" } }, dIcon("check", 15)) : h("button", { onClick: function() { placeFromPool(p); }, style: { width: "28px", height: "28px", borderRadius: "50%", border: "none", cursor: "pointer", background: DG.goldGrad, color: DG.ink, display: "flex", alignItems: "center", justifyContent: "center", boxShadow: "0 4px 12px rgba(247,176,28,0.45)" } }, dIcon("plus", 16)) ); }; var poolList = pool.filter(function(p) { return groupMatch(p, poolFilter) && (!poolQuery || displayName(p).toLowerCase().indexOf(poolQuery.toLowerCase()) >= 0 || (p.club || "").toLowerCase().indexOf(poolQuery.toLowerCase()) >= 0); }); var buildPanel = h("div", { style: panelS }, h("div", { style: { display: "flex", alignItems: "center", justifyContent: "space-between", gap: "10px", marginBottom: "14px" } }, h("div", null, h("div", { style: { fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: "20px", letterSpacing: "1px", color: "#F5EDD8" } }, customDuel ? "BUILD YOUR XI" : "BUILD YOUR TEAM"), h("div", { style: { fontSize: "11px", color: "rgba(245,237,216,0.55)", display: "flex", alignItems: "center", gap: "5px", marginTop: "2px" } }, dIcon("clock", 12), "Deadline: " + (matchday ? fmtDeadline(matchday.lock_at) : "Fri 7:00 PM")) ), h("div", { style: { textAlign: "right" } }, h("div", { style: { fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: "22px", color: squad.placed === slots.length ? "#3fb96b" : "#FFC836", lineHeight: 1 } }, squad.placed + " / " + slots.length), h("div", { style: { fontSize: "9px", letterSpacing: "1.5px", color: "rgba(245,237,216,0.5)" } }, "PLAYERS") ) ), h("div", { style: { display: "flex", alignItems: "center", gap: "10px", marginBottom: "12px" } }, h("select", { value: formationKey, disabled: pickLocked, onChange: function(e) { if (!pickLocked) setFormationKey(e.target.value); }, style: { background: "rgba(0,0,0,0.3)", border: "1px solid rgba(176,128,214,0.3)", borderRadius: "8px", padding: "9px 12px", color: "#F5EDD8", fontSize: "14px", fontFamily: "inherit", outline: "none", cursor: pickLocked ? "not-allowed" : "pointer", opacity: pickLocked ? 0.55 : 1 } }, Object.keys(FORMATIONS_11V11).map(function(k) { return h("option", { key: k, value: k, style: { color: "#1A0428" } }, k); })), h("div", { style: { flex: 1 } }) ), // Prominent pick timer — starts on the FIRST player placed (#15); shows raw SECONDS // counting down ("YOU HAVE 200 SECONDS LEFT"), red in the final 10s. !pickLocked && (function() { var started = !!pickStart, danger = started && pickRemain <= 10; return h("div", { style: { marginBottom: "14px", padding: "12px 16px", borderRadius: "12px", display: "flex", alignItems: "center", justifyContent: "center", gap: "11px", background: danger ? "rgba(255,107,107,0.12)" : "rgba(247,176,28,0.10)", border: "1px solid " + (danger ? "rgba(255,107,107,0.5)" : "rgba(247,176,28,0.4)") } }, h("div", { style: { color: danger ? "#ff8a8a" : "#FFC836", display: "flex", flexShrink: 0 } }, dIcon("clock", 22)), !started ? h("div", { style: { fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: "14px", letterSpacing: "1px", color: "#FFE9A8", textAlign: "center" } }, "PICK YOUR FIRST PLAYER TO START THE " + PICK_SECONDS + "-SECOND TIMER") : (pickRemain <= 0 ? h("div", { style: { fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: "22px", letterSpacing: "1.5px", color: "#ff8a8a" } }, "TIME'S UP") : h("div", { style: { fontFamily: "'Anton', 'Bebas Neue', sans-serif", letterSpacing: "1px", color: danger ? "#ff8a8a" : "#FFE9A8", display: "flex", alignItems: "baseline", gap: "7px" } }, h("span", { style: { fontSize: "14px", opacity: 0.8 } }, "YOU HAVE"), h("span", { style: { fontSize: "27px", color: danger ? "#ff8a8a" : "#FFC836", lineHeight: 1 } }, "" + pickRemain), h("span", { style: { fontSize: "14px", opacity: 0.8 } }, pickRemain === 1 ? "SECOND LEFT" : "SECONDS LEFT"))) ); })(), pitch, pickLocked ? h("div", { style: { marginTop: "24px", padding: "14px", borderRadius: "11px", textAlign: "center", boxSizing: "border-box", width: "100%", background: "rgba(199,155,242,0.08)", border: "1px solid rgba(199,155,242,0.3)", color: "#F5EDD8" } }, h("div", { style: { fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: "16px", letterSpacing: "1.5px", display: "inline-flex", alignItems: "center", justifyContent: "center", gap: "8px" } }, dIcon("shield", 16), (mdClosed ? "MATCHDAY CLOSED" : timedOut ? "TIME'S UP · LOCKED IN" : "LOCKED IN")), h("div", { style: { fontSize: "12px", color: "rgba(245,237,216,0.6)", marginTop: "5px", letterSpacing: "0.3px" } }, "You cannot change your team.")) : [h("div", { key: "acts", style: { display: "flex", gap: "10px", marginTop: "26px" } }, h("button", { onClick: autoPick, style: Object.assign({}, ghostBtnS, { flex: 1, padding: "12px", fontSize: "15px", display: "inline-flex", alignItems: "center", justifyContent: "center", gap: "8px" }) }, dIcon("zap", 16), "AUTO PICK"), (!customDuel && squad.placed > 0) ? h("button", { key: "shl", onClick: function() { openShare("lineup"); }, style: Object.assign({}, ghostBtnS, { padding: "12px 14px", fontSize: "15px" }), title: "Share this weekend's lineup" }, "SHARE") : null, squad.placed > 0 && h("button", { onClick: clearTeam, style: Object.assign({}, ghostBtnS, { padding: "12px 16px", fontSize: "15px", color: "rgba(245,237,216,0.6)", border: "1px solid rgba(245,237,216,0.2)" }) }, "CLEAR") ), h("button", { key: "save", onClick: saveTeam, disabled: saveBusy, style: Object.assign({}, goldBtnS, { width: "100%", marginTop: "10px", padding: "13px", fontSize: "16px", opacity: saveBusy ? 0.6 : 1 }) }, saveBusy ? "SUBMITTING…" : "SUBMIT TEAM"), h("div", { key: "hint", style: { fontSize: "10px", color: "rgba(245,237,216,0.4)", textAlign: "center", marginTop: "8px", letterSpacing: "0.3px" } }, customDuel ? "Submitting locks your XI for this duel — your weekly team is unchanged. Start another duel anytime." : "Submitting locks your XI for this matchday — you can't change it after.")], // §26.4/§26.10 — DOUBLE POINTS panel, OUTSIDE the lock branch: the XI lock protects the team, // not the cards. Locked pick → chips still toggle + a SAVE 2X PICKS button PATCHes just the // boosts (until the matchday deadline). xMyRewards includes TEST MODE sample cards — // display/toggling only; saves always slice to the REAL cards. (!customDuel && xMyRewards.length && Object.keys(assignments).length) ? (function() { var left = Math.max(0, xMyRewards.length - boostPicks.length); var canEditBoosts = !mdClosed && !deadlineGone; return h("div", { key: "boost", style: { marginTop: "12px", padding: "12px", borderRadius: "14px", background: "linear-gradient(180deg, rgba(120,60,180,0.18), rgba(35,10,60,0.35))", border: "1px solid rgba(199,155,242,0.5)" } }, h("div", { style: { display: "flex", alignItems: "center", gap: "8px", marginBottom: "4px", flexWrap: "wrap" } }, h("span", { style: { fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: "13px", padding: "3px 9px", borderRadius: "9px", background: "linear-gradient(160deg, #C79BF2, #7d4bb5)", color: "#1A0428" } }, "DP"), h("span", { style: { fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: "13px", letterSpacing: "2px", color: "#FFFFFF" } }, "DOUBLE POINTS"), h("span", { style: { marginLeft: "auto", display: "inline-flex", alignItems: "center", gap: "6px", padding: "4px 11px", borderRadius: "999px", background: "rgba(247,176,28,0.14)", border: "1px solid rgba(255,200,54,0.5)", fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: "11px", letterSpacing: "1px", color: DG.gold } }, "AVAILABLE", h("span", { style: { fontSize: "15px", color: "#FFFFFF" } }, String(left)))), h("div", { style: { fontSize: "11px", color: "rgba(245,237,216,0.75)", marginBottom: "10px", lineHeight: 1.4 } }, !canEditBoosts ? "This matchday has locked — unused cards roll over to the next one." : pickLocked ? "Your XI is locked, but your 2X cards aren't — tap a player, then SAVE 2X PICKS. Editable until picks lock." : "Use Double Points (DP) on any player to score 2x points this game week — " + (left > 0 ? "select a player to activate." : "all cards assigned.") + " Saved when you submit."), h("div", { style: { display: "flex", gap: "8px", flexWrap: "wrap" } }, Object.keys(assignments).map(function(slotId) { var pid = assignments[slotId]; var pl = getPlayer(pid); if (!pl) return null; var on = boostPicks.indexOf(pid) >= 0; return h("button", { key: slotId, onClick: function() { if (!canEditBoosts) { flashToast("Picks have locked — unused 2X cards roll over to the next matchday."); return; } setBoostPicks(function(prev) { var i = prev.indexOf(pid); if (i >= 0) { var n = prev.slice(); n.splice(i, 1); return n; } if (prev.length >= xMyRewards.length) { flashToast("No Double Points cards left — beat more top-100 managers or influencers to earn them.", 2800); return prev; } return prev.concat([pid]); }); }, style: { width: "58px", padding: "6px 2px 5px", borderRadius: "12px", cursor: canEditBoosts ? "pointer" : "default", textAlign: "center", position: "relative", opacity: canEditBoosts ? 1 : 0.55, border: on ? "2px solid " + DG.gold : "1px solid rgba(199,155,242,0.3)", background: on ? "rgba(247,176,28,0.14)" : "rgba(8,2,18,0.5)" } }, on ? h("span", { style: { position: "absolute", top: "-7px", right: "-6px", fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: "10px", padding: "1px 6px", borderRadius: "999px", background: "linear-gradient(160deg,#FFDE7A,#F4AE1C)", color: "#1A0428", border: "1.5px solid #1A0428" } }, "2X") : null, h("div", { style: { width: "34px", height: "34px", margin: "0 auto 4px", borderRadius: "50%", overflow: "hidden", border: on ? "1.5px solid " + DG.gold : "1.5px solid rgba(199,155,242,0.45)", background: "#2a1140", display: "flex", alignItems: "center", justifyContent: "center" } }, pl.portrait ? h("img", { src: pl.portrait, alt: "", style: { width: "100%", height: "100%", objectFit: "cover" } }) : h("span", { style: { fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: "14px", color: "#F5EDD8" } }, (pl.name || "?").charAt(0))), h("div", { style: { fontSize: "8px", letterSpacing: "0.3px", color: on ? "#FFFFFF" : "rgba(245,237,216,0.7)", fontFamily: "'Anton', 'Bebas Neue', sans-serif", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" } }, displayName(pl))); })), (pickLocked && canEditBoosts) ? h("button", { onClick: saveBoosts, style: { width: "100%", marginTop: "10px", padding: "11px", borderRadius: "10px", cursor: "pointer", border: "1px solid rgba(199,155,242,0.55)", background: "rgba(199,155,242,0.14)", color: "#E9D6FF", fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: "14px", letterSpacing: "1.5px" } }, "SAVE 2X PICKS") : null, boostPicks.length ? null : h("div", { style: { fontSize: "9.5px", color: "rgba(245,237,216,0.5)", marginTop: "8px" } }, "Unused cards roll over to a later matchday.")); })() : null ); var poolPanel = h("div", { style: panelS }, h("div", { style: { display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: "12px" } }, h("div", { style: { fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: "16px", letterSpacing: "1.5px", color: "#F5EDD8" } }, "POPULAR PLAYERS"), h("button", { onClick: function() { setFilterOpen(function(v) { return !v; }); }, style: { display: "flex", alignItems: "center", gap: "6px", padding: "6px 12px", borderRadius: "999px", border: "1px solid rgba(176,128,214,0.35)", background: "rgba(255,255,255,0.04)", color: "#FFC836", fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: "12px", letterSpacing: "1px", cursor: "pointer" } }, dIcon("chevrons", 13), "FILTER") ), filterOpen && h("input", { value: poolQuery, onChange: function(e) { setPoolQuery(e.target.value); }, placeholder: "Filter by name or club…", style: { width: "100%", boxSizing: "border-box", background: "rgba(0,0,0,0.3)", border: "1px solid rgba(176,128,214,0.3)", borderRadius: "10px", padding: "10px 14px", color: "#F5EDD8", fontSize: "16px", outline: "none", fontFamily: "inherit", marginBottom: "10px" } }), h("div", { style: { display: "flex", gap: "5px", marginBottom: "12px" } }, poolFilterTabs.map(function(f) { var active = poolFilter === f; return h("button", { key: f, onClick: function() { setPoolFilter(f); }, style: { flex: 1, padding: "7px 0", borderRadius: "8px", border: active ? "1px solid #FFC836" : "1px solid rgba(255,255,255,0.08)", background: active ? "linear-gradient(160deg, #FFDE7A 0%, #F4AE1C 100%)" : "rgba(255,255,255,0.04)", color: active ? "#1A0428" : "rgba(245,237,216,0.6)", fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: "12px", letterSpacing: "1px", cursor: "pointer" } }, f); }) ), poolLoading ? h("div", { style: { textAlign: "center", padding: "40px 0", color: "rgba(245,237,216,0.5)", fontSize: "13px" } }, "Loading top players…") : h("div", { style: { maxHeight: isMobile ? "none" : "560px", overflowY: isMobile ? "visible" : "auto" } }, poolList.length ? poolList.map(poolRow) : h("div", { style: { textAlign: "center", padding: "30px 0", color: "rgba(245,237,216,0.4)", fontSize: "12px" } }, "No players match.") ), compareSel.length ? h("div", { style: { display: "flex", alignItems: "center", gap: "8px", marginTop: "10px", padding: "9px 11px", borderRadius: "11px", background: "rgba(199,155,242,0.1)", border: "1px solid rgba(199,155,242,0.35)" } }, h("div", { style: { display: "flex", flexWrap: "wrap", gap: "6px", flex: 1, minWidth: 0 } }, compareSel.map(function(id) { var pp = getPlayer(id); return h("span", { key: id, style: { fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: "12px", letterSpacing: "0.5px", color: "#F7EEDA", background: "rgba(199,155,242,0.18)", border: "1px solid rgba(199,155,242,0.4)", borderRadius: "999px", padding: "3px 9px", whiteSpace: "nowrap" } }, pp ? displayName(pp) : "…"); })), compareSel.length === 2 ? h("button", { onClick: function() { setCompareOpen(true); }, style: Object.assign({}, goldBtnS, { padding: "7px 14px", fontSize: "12px", flexShrink: 0 }) }, "COMPARE") : h("span", { style: { fontSize: "10px", color: "rgba(245,237,216,0.55)", whiteSpace: "nowrap", flexShrink: 0 } }, "Pick 1 more"), h("button", { onClick: function() { setCompareSel([]); }, title: "Clear", style: { width: "24px", height: "24px", borderRadius: "50%", border: "none", background: "rgba(255,255,255,0.06)", color: "rgba(245,237,216,0.6)", cursor: "pointer", display: "flex", alignItems: "center", justifyContent: "center", flexShrink: 0, padding: 0 } }, dIcon("x", 13)) ) : null, h("div", { style: { fontSize: "10px", color: "rgba(245,237,216,0.4)", marginTop: "10px", lineHeight: 1.4 } }, "Tap the compare icon on two players to see them side by side. Tap an empty pitch spot to search any top-5-league player. Only top-5-league players score points.") ); var renderBuild = function() { var cOpp = customDuel ? oppOf(customDuel) : null; var customBanner = customDuel ? h("div", { style: { marginBottom: "18px", padding: "13px 15px", borderRadius: "13px", background: "rgba(199,155,242,0.12)", border: "1px solid rgba(199,155,242,0.4)", display: "flex", alignItems: "center", justifyContent: "space-between", gap: "12px", flexWrap: "wrap" } }, h("div", { style: { minWidth: 0 } }, h("div", { style: { fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: "17px", letterSpacing: "1px", color: "#E9D6FF", display: "flex", alignItems: "center", gap: "8px" } }, dIcon("swords", 15), cOpp ? ("YOUR XI vs " + nameFor(cOpp)) : "YOUR XI · OPEN CHALLENGE"), h("div", { style: { fontSize: "11px", color: "rgba(245,237,216,0.6)", marginTop: "3px" } }, cOpp ? "This team plays this duel only — your weekly team stays as it is." : "Plays whoever accepts your open challenge — weekly team unchanged.")), h("div", { style: { display: "flex", gap: "8px", flexShrink: 0, flexWrap: "wrap" } }, h("button", { onClick: function() { setChTab("open"); setChallengeOpen(true); }, style: { display: "inline-flex", alignItems: "center", gap: "6px", padding: "8px 14px", borderRadius: "999px", border: "none", background: DG.goldGrad, color: DG.ink, fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: "12px", letterSpacing: "1px", cursor: "pointer" } }, dIcon("swords", 13), "+ NEW DUEL"), h("button", { onClick: exitCustomXI, style: Object.assign({}, ghostBtnS, { padding: "8px 14px", fontSize: "12px" }) }, "BACK TO WEEKLY")) ) : null; return h("div", { style: pageS }, customBanner, isMobile ? h("div", { style: { display: "flex", flexDirection: "column", gap: "18px" } }, buildPanel, poolPanel) : h("div", { style: { display: "grid", gridTemplateColumns: "1fr 380px", gap: "22px", alignItems: "start" } }, buildPanel, poolPanel) ); }; // ── LEADERBOARD (preview) ───────────────────────────────────────────────────── var renderLeaderboard = function() { var tier = lbTab; var rows = lbRows.filter(function(p) { if (tier === "friends") return p.user_id === uid || friendIds.indexOf(p.user_id) >= 0; if (tier === "country") return profile && profile.country && p.country === profile.country; if (tier === "state") return profile && profile.state && p.state === profile.state; if (tier === "city") return profile && profile.city && p.city === profile.city; return true; // global }); var lbTabs = [["global", "GLOBAL"], ["friends", "FRIENDS"], ["country", "COUNTRY"], ["state", "STATE"], ["city", "CITY"]]; return h("div", { style: pageS }, h("div", { style: panelS }, h("div", { style: { display: "flex", gap: "6px", marginBottom: "16px", flexWrap: "wrap" } }, lbTabs.map(function(t) { var active = lbTab === t[0]; return h("button", { key: t[0], onClick: function() { setLbTab(t[0]); }, style: { padding: "8px 14px", borderRadius: "999px", border: active ? "1px solid #FFC836" : "1px solid rgba(255,255,255,0.1)", background: active ? "linear-gradient(160deg, #FFDE7A 0%, #F4AE1C 100%)" : "rgba(255,255,255,0.04)", color: active ? "#1A0428" : "rgba(245,237,216,0.6)", fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: "12px", letterSpacing: "1px", cursor: "pointer" } }, t[1]); }) ), h("div", { style: { display: "grid", gridTemplateColumns: isMobile ? "26px 1fr auto auto" : "40px 1fr auto auto", gap: isMobile ? "8px" : "10px", padding: "0 12px 8px", fontSize: "9px", letterSpacing: "1.5px", color: "rgba(245,237,216,0.4)", textTransform: "uppercase" } }, h("div", null, "Rank"), h("div", null, "Manager"), h("div", null, "Points"), h("div", null, "")), rows.length ? rows.map(function(p, i) { var you = p.user_id === uid; var loc = [p.city, p.country].filter(Boolean).join(", "); return h("div", { key: p.user_id, style: { display: "grid", gridTemplateColumns: isMobile ? "26px 1fr auto auto" : "40px 1fr auto auto", gap: isMobile ? "8px" : "10px", alignItems: "center", padding: "10px 12px", borderRadius: "12px", marginBottom: "5px", background: you ? "linear-gradient(90deg, rgba(247,176,28,0.26), rgba(247,176,28,0.05))" : DG.inset, border: you ? "1px solid rgba(255,200,54,0.55)" : "1px solid rgba(176,128,214,0.12)", boxShadow: you ? "0 6px 18px rgba(247,176,28,0.18)" : "none" } }, h("div", { style: { fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: "18px", color: you ? DG.gold : "rgba(245,237,216,0.7)" } }, i + 1), h("div", { style: { display: "flex", alignItems: "center", gap: "10px", minWidth: 0 } }, avatarFor(p.team_name || "Manager", 34, you), h("div", { style: { minWidth: 0 } }, h("div", { style: { fontSize: "14px", color: you ? "#FFFFFF" : "rgba(245,237,216,0.85)", fontWeight: you ? 600 : 400, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" } }, (p.team_name || "Manager") + (you ? " (You)" : "")), loc ? h("div", { style: { fontSize: "10px", color: "rgba(245,237,216,0.4)" } }, loc) : null ) ), h("div", { style: { fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: "17px", color: you ? DG.gold : DG.gold2 } }, Math.round(Number(p.season_points) || 0).toLocaleString()), // §26.4 — challenge any top-100 manager straight from the board (beating them earns a DP card) you ? h("div", null) : h("button", { onClick: function() { challengeUser(p.user_id); }, title: "Challenge " + (p.team_name || "them") + " — beat them to earn a Double Points card", style: isMobile ? { width: "32px", height: "32px", borderRadius: "9px", padding: 0, display: "flex", alignItems: "center", justifyContent: "center", cursor: "pointer", border: "1px solid rgba(255,200,54,0.55)", background: "rgba(247,176,28,0.14)", color: DG.gold } : Object.assign({}, goldBtnS, { padding: "8px 14px", fontSize: "11px", letterSpacing: "1px", display: "inline-flex", alignItems: "center", gap: "6px" }) }, dIcon("zap", isMobile ? 15 : 13), isMobile ? null : "CHALLENGE") ); }) : h("div", { style: { textAlign: "center", padding: "36px 0", color: "rgba(245,237,216,0.45)", fontSize: "12px" } }, tier === "global" ? "No managers yet." : "No managers in your " + tier + " yet — set your location in your manager profile."), // §26.4 — the "BEAT THEM. DOUBLE THE WIN." reward banner (mockup style) h("div", { style: { display: "flex", alignItems: "center", gap: isMobile ? "12px" : "18px", marginTop: "14px", padding: isMobile ? "14px" : "16px 22px", borderRadius: "16px", background: "linear-gradient(100deg, rgba(20,5,38,0.9), rgba(50,15,85,0.75))", border: "1px solid rgba(199,155,242,0.4)", boxShadow: "0 10px 30px rgba(0,0,0,0.4)" } }, h("div", { style: { color: DG.gold, flexShrink: 0, filter: "drop-shadow(0 0 12px rgba(247,176,28,0.6))" } }, dIcon("zap", isMobile ? 30 : 40)), h("div", { style: { flex: 1, minWidth: 0 } }, h("div", { style: { fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: isMobile ? "17px" : "21px", letterSpacing: "1px", lineHeight: 1.15 } }, h("span", { style: { color: "#FFFFFF" } }, "BEAT THEM. "), h("span", { style: { color: DG.gold, textShadow: "0 0 14px rgba(247,176,28,0.5)" } }, "DOUBLE THE WIN.")), h("div", { style: { fontSize: isMobile ? "10.5px" : "12px", color: "rgba(245,237,216,0.75)", marginTop: "4px", lineHeight: 1.4 } }, "Beat any manager on this leaderboard and get the chance to ", h("span", { style: { color: DG.gold, fontWeight: 600 } }, "DOUBLE POINTS"), " for 1 player next week!")), h("div", { style: { flexShrink: 0, width: isMobile ? "52px" : "66px", height: isMobile ? "52px" : "66px", borderRadius: "14px", background: "linear-gradient(160deg, rgba(199,155,242,0.35), rgba(120,60,180,0.4))", border: "1.5px solid rgba(199,155,242,0.65)", display: "flex", alignItems: "center", justifyContent: "center", fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: isMobile ? "22px" : "28px", color: DG.violet, boxShadow: "0 0 20px rgba(150,80,220,0.35)" } }, "2X")), h("div", { style: { textAlign: "center", fontSize: "11px", color: "rgba(245,237,216,0.4)", marginTop: "12px" } }, "Season points = the sum of your matchday totals. Climb by winning weekends.") ) ); }; // ── BADGES ──────────────────────────────────────────────────────────────────── var renderBadges = function() { var bTabs = [["all", "ALL"], ["earned", "EARNED"], ["locked", "LOCKED"]]; var shown = BADGES.filter(function(b) { return bTab === "all" ? true : bTab === "earned" ? !!xEarned[b.key] : !xEarned[b.key]; }); var earnedCount = BADGES.filter(function(b) { return xEarned[b.key]; }).length; return h("div", { style: pageS }, h("div", { style: panelS }, h("div", { style: { display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: "18px", gap: "8px", flexWrap: "wrap" } }, h("div", { style: { display: "flex", gap: "6px" } }, bTabs.map(function(t) { var active = bTab === t[0]; return h("button", { key: t[0], onClick: function() { setBTab(t[0]); }, style: { padding: "8px 16px", borderRadius: "999px", border: active ? "1px solid " + DG.gold : "1px solid rgba(176,128,214,0.2)", background: active ? DG.goldGrad : "rgba(199,155,242,0.06)", color: active ? DG.ink : "rgba(245,237,216,0.6)", fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: "12px", letterSpacing: "1px", cursor: "pointer", boxShadow: active ? "0 5px 14px rgba(247,176,28,0.3)" : "none" } }, t[1]); })), h("div", { style: { fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: "13px", color: DG.gold, letterSpacing: "1px" } }, earnedCount + " / " + BADGES.length + " EARNED") ), shown.length === 0 ? h("div", { style: { textAlign: "center", padding: "50px 20px", color: "rgba(245,237,216,0.5)", fontSize: "13px" } }, bTab === "earned" ? "No badges earned yet — win duels to start collecting." : "Nothing here.") : h("div", { style: { display: "grid", gridTemplateColumns: isMobile ? "1fr 1fr" : "repeat(3, 1fr)", gap: "12px" } }, shown.map(function(b) { var got = !!xEarned[b.key]; var open = badgeOpen === b.key; return h("div", { key: b.key, onClick: function() { setBadgeOpen(open ? null : b.key); }, style: { background: got ? "radial-gradient(circle at 50% 0%, rgba(247,176,28,0.16), rgba(10,2,20,0.5) 70%)" : DG.inset, border: got ? "1px solid rgba(255,200,54,0.5)" : "1px solid rgba(176,128,214,0.14)", borderRadius: "14px", padding: "18px 12px", textAlign: "center", cursor: "pointer", opacity: got ? 1 : 0.55, boxShadow: got ? "0 8px 22px rgba(247,176,28,0.16)" : "none" } }, h("div", { style: { width: "56px", height: "62px", margin: "0 auto 10px", position: "relative", filter: got ? "drop-shadow(0 4px 10px rgba(247,176,28,0.6))" : "grayscale(0.7)" } }, h("div", { style: { position: "absolute", inset: 0, clipPath: SHIELD_CLIP, background: got ? "linear-gradient(160deg, #FFE49A, #F0A91E)" : "linear-gradient(160deg, #6b5a2a, #3a3017)" } }), h("div", { style: { position: "absolute", inset: "3px", clipPath: SHIELD_CLIP, background: "linear-gradient(160deg, #2a1140 0%, #160229 100%)" } }), h("div", { style: { position: "absolute", inset: 0, display: "flex", alignItems: "center", justifyContent: "center", color: got ? "#FFE9A8" : "rgba(232,184,58,0.55)", paddingBottom: "6px" } }, dIcon(b.icon, 24)) ), h("div", { style: { fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: "14px", letterSpacing: "1px", color: got ? "#F5EDD8" : "rgba(245,237,216,0.8)" } }, b.name), h("div", { style: { fontSize: "10px", color: got ? "#9be8a8" : "rgba(245,237,216,0.5)", marginTop: "5px", lineHeight: 1.4 } }, open ? b.crit : (got ? "✓ Earned" : "Tap to see how to earn")) ); }) ) ) ); }; // ── blocking duel-challenge popup ──────────────────────────────────────────── // Blocks the view until explicitly dismissed (accept / decline / later); backdrop NOT tappable. // Suppressed while building an XI or while any modal is open — the un-advanced watermark // re-surfaces it as soon as those close. var dismissDuelAlert = function() { var mx = 0; (duelAlert || []).forEach(function(d) { var t = new Date(d.created_at).getTime(); if (t > mx) mx = t; }); try { if (mx && uid) localStorage.setItem("duel_alert_seen:" + uid, String(mx)); } catch (e) {} setDuelAlert(null); }; var renderDuelAlert = function() { if (!duelAlert || !duelAlert.length) return null; var d = duelAlert[0]; var nm = nameFor(d.challenger_id); return h("div", { style: { position: "fixed", inset: 0, background: "rgba(8,1,15,0.82)", backdropFilter: "blur(7px)", display: "flex", alignItems: "center", justifyContent: "center", zIndex: 240, padding: "20px" } }, h("div", { style: { width: "min(400px, 100%)", background: "linear-gradient(180deg, #2a1140 0%, #130325 100%)", border: "1px solid rgba(255,200,54,0.55)", borderRadius: "18px", boxShadow: "0 30px 80px rgba(0,0,0,0.75), 0 0 40px rgba(247,176,28,0.18)", padding: "26px 22px", textAlign: "center" } }, h("div", { style: { display: "flex", justifyContent: "center", marginBottom: "12px" } }, avatarFor(nm, 62, false)), h("div", { style: { fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: "26px", letterSpacing: "1.5px", color: DG.gold, lineHeight: 1.1 } }, "YOU'VE BEEN CHALLENGED!"), h("div", { style: { fontSize: "13px", color: "rgba(245,237,216,0.85)", margin: "10px 0 16px", lineHeight: 1.5 } }, nm + " wants to duel you this matchday. Accept and build your XI."), duelAlert.length > 1 ? h("div", { style: { fontSize: "11px", color: "rgba(245,237,216,0.55)", marginBottom: "12px" } }, "+" + (duelAlert.length - 1) + " more challenge" + (duelAlert.length > 2 ? "s" : "") + " waiting in your notifications.") : null, h("div", { style: { display: "flex", gap: "9px" } }, h("button", { onClick: function() { dismissDuelAlert(); declineDuel(d); }, style: { flex: 1, padding: "12px", borderRadius: "11px", cursor: "pointer", background: "transparent", border: "1px solid rgba(255,138,138,0.45)", color: "#FF8A8A", fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: "13px", letterSpacing: "1px" } }, "DECLINE"), h("button", { onClick: dismissDuelAlert, style: Object.assign({}, ghostBtnS, { flex: 1, padding: "12px", fontSize: "13px" }) }, "LATER"), h("button", { onClick: function() { dismissDuelAlert(); acceptDuel(d); }, style: Object.assign({}, goldBtnS, { flex: 1.2, padding: "12px", fontSize: "13px" }) }, "ACCEPT")))); }; // ── login gate (Duel is login-required) — BOTH posters: the standalone DUEL_SIGNIN_BANNER // on top, then the "BUILD YOUR TEAM"-style forwards hero card (HERO_BUILD_IMG bleeding // off the bottom-right, heads overflowing the card top) with the SIGN IN CTA below. ── if (!user) { // top-aligned (was vertically centered — big empty bands above/below on tall screens) return h("div", { style: Object.assign({}, duelBgS, { display: "flex", alignItems: "flex-start", justifyContent: "center", padding: isMobile ? "12px 14px 40px" : "20px 18px 48px" }) }, h("div", { style: { width: "100%", maxWidth: "540px" } }, h("img", { src: DUEL_SIGNIN_BANNER, alt: "Duels", style: { width: "100%", height: "auto", display: "block", borderRadius: "16px", marginBottom: "14px", boxShadow: "0 14px 40px rgba(0,0,0,0.45)" } }), h("div", { style: Object.assign({}, panelS, { width: "100%", background: "linear-gradient(115deg, #320b50 0%, #401059 45%, #5a1780 100%)", border: "1px solid rgba(199,155,242,0.24)", overflow: "visible", position: "relative", minHeight: isMobile ? "210px" : "260px", padding: isMobile ? "24px 20px" : "32px 30px" }) }, h("div", { style: { position: "absolute", inset: 0, borderRadius: "18px", background: "radial-gradient(420px 280px at 88% 28%, rgba(247,176,28,0.14), transparent 60%), radial-gradient(360px 260px at 100% 120%, rgba(199,155,242,0.20), transparent 60%)", pointerEvents: "none" } }), h("div", { style: { position: "relative", zIndex: 2, maxWidth: isMobile ? "60%" : "56%" } }, h("div", { style: { fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: isMobile ? "26px" : "34px", letterSpacing: "1px", color: "#FFFFFF", lineHeight: 1.05, textShadow: "0 2px 18px rgba(0,0,0,0.35)" } }, "READY TO DUEL?"), h("div", { style: { fontSize: "13px", color: "rgba(245,237,216,0.78)", margin: "10px 0 18px", lineHeight: 1.5 } }, "Sign in to build your XI, challenge friends, and climb the leaderboard."), h("button", { onClick: function() { if (requireAuth) requireAuth(); }, style: Object.assign({}, goldBtnS, { padding: "13px 26px", fontSize: "16px" }) }, "SIGN IN TO PLAY") ), h("img", { src: HERO_BUILD_IMG, alt: "", style: { position: "absolute", right: 0, bottom: 0, height: isMobile ? "186px" : "250px", width: "auto", maxWidth: isMobile ? "44%" : "48%", objectFit: "contain", objectPosition: "bottom right", borderBottomRightRadius: isMobile ? "16px" : "18px", zIndex: 1, pointerEvents: "none", filter: "drop-shadow(-10px 8px 18px rgba(0,0,0,0.42))" } }) ) ) ); } return h("div", { style: duelBgS }, // ── DUELS hero banner (replaces the old "GAME DUELS" text heading) — flush to the top, edges // feathered on all four sides (mask gradients, intersected) so it blends into the page bg. ── h("div", { style: { maxWidth: isMobile ? maxW : "780px", margin: "0 auto", padding: isMobile ? "0 0 2px" : "0 0 4px" } }, h("img", { src: DUEL_BANNER_IMG, alt: "Duels", style: { width: "100%", height: "auto", display: "block", WebkitMaskImage: "linear-gradient(to right, transparent 0%, #000 6%, #000 94%, transparent 100%), linear-gradient(to bottom, transparent 0%, #000 7%, #000 90%, transparent 100%)", maskImage: "linear-gradient(to right, transparent 0%, #000 6%, #000 94%, transparent 100%), linear-gradient(to bottom, transparent 0%, #000 7%, #000 90%, transparent 100%)", WebkitMaskComposite: "source-in", maskComposite: "intersect" } }) ), subNav, tab === "dashboard" ? renderDashboard() : tab === "build" ? renderBuild() : tab === "leaderboard" ? renderLeaderboard() : tab === "history" ? renderHistory() : renderBadges(), modal && modal.type === "search" && h(SearchModal, { slot: modal.slot, players: modal.slot ? pool.filter(function(p) { return canPlaySlot(p, modal.slot); }) : pool, onClose: function() { setModal(null); }, onPick: pickPlayer, assignedIds: Object.keys(assignments).filter(function(sid) { return sid !== modal.slot.id; }).map(function(sid) { return assignments[sid]; }) }), modal && modal.type === "stats" && h(StatsModal, { slot: modal.slot, player: getPlayer(assignments[modal.slot.id]), chem: chemForSlot(modal.slot, getPlayer(assignments[modal.slot.id])), onClose: function() { setModal(null); }, onReplace: function() { setModal({ type: "search", slot: modal.slot }); }, onRemove: function() { removeFromSlot(modal.slot); }, captain: captain, onToggleCaptain: toggleCaptain }), challengeOpen && renderChallengeModal(), // §26.6 — the shareable RECORD / WEEKEND LINEUP cards (weekly team even mid-custom-build) shareOpen && h(DuelShareModal, { initialTab: shareOpen, onClose: function() { setShareOpen(null); }, profile: profile, stats: shareStats || {}, pick: (customDuel && weeklyStashRef.current) ? { formation_key: weeklyStashRef.current.formationKey, assignments: weeklyStashRef.current.assignments, captain: weeklyStashRef.current.captain } : { formation_key: formationKey, assignments: assignments, captain: captain } }), inboxOpen && renderInboxModal(), compareOpen && renderCompareModal(), editOpen && h(ProfileEditModal, { name: teamName, country: (profile && profile.country) || "", state: (profile && profile.state) || "", city: (profile && profile.city) || "", busy: socialBusy, onSave: saveProfile, onClose: function() { setEditOpen(false); } }), summaryDuel && renderSummary(), previewDuel && renderMatchPreview(), trashDuel && renderTrashModal(), // blocking duel-challenge popup — suppressed mid-build and while any modal is open (tab !== "build" && !challengeOpen && !inboxOpen && !summaryDuel && !previewDuel && !trashDuel && !compareOpen && !editOpen && !shareOpen && !modal) ? renderDuelAlert() : null, // sticky NEW DUEL — starting a duel is the point of the game, keep it one tap away everywhere. // z 90 sits UNDER every modal backdrop (120+), so it dims out while a modal is open. !challengeOpen && h("button", { key: "fab", onClick: function() { setChTab("open"); setChallengeOpen(true); }, style: { position: "fixed", right: isMobile ? "14px" : "26px", bottom: isMobile ? "16px" : "26px", zIndex: 90, display: "inline-flex", alignItems: "center", gap: "8px", padding: isMobile ? "13px 18px" : "14px 22px", borderRadius: "999px", border: "none", cursor: "pointer", background: DG.goldGrad, color: DG.ink, fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: isMobile ? "13px" : "14px", letterSpacing: "1.5px", boxShadow: "0 10px 26px rgba(247,176,28,0.45), 0 6px 16px rgba(0,0,0,0.5)" } }, dIcon("swords", 16), "NEW DUEL"), toast && h("div", { style: { position: "fixed", bottom: isMobile ? "24px" : "30px", left: "50%", transform: "translateX(-50%)", width: "max-content", maxWidth: "min(88vw, 380px)", boxSizing: "border-box", background: "linear-gradient(160deg, #2a1140, #130325)", border: "1px solid rgba(176,128,214,0.5)", color: "#F5EDD8", fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: "14px", letterSpacing: "0.6px", lineHeight: 1.35, textAlign: "center", padding: "12px 18px", borderRadius: "13px", boxShadow: "0 12px 34px rgba(0,0,0,0.55)", zIndex: 400 } }, toast) ); } // Sign-up nudge shown after a signed-out user exports a lineup — offers a coin-reward coupon. var MASCOT_CTA_IMG = "https://cdn.prod.website-files.com/69eb6394026dd7faab0bad78/69eb7f41cfda2742ec42d0ad_Mascot-2%202-sub-hero.avif"; function SignupCTA(props) { var isMobile = useWindowSize().w < 768; var coupons = (props.coupons && props.coupons.length) ? props.coupons : [props.coupon || "MYLINEUP"]; var title = props.title || "NICE LINEUP!"; var _copied = useState(null), copied = _copied[0], setCopied = _copied[1]; var copy = function(code) { try { if (navigator.clipboard) navigator.clipboard.writeText(code); } catch (e) {} setCopied(code); setTimeout(function() { setCopied(null); }, 1600); }; var coinSvg = function(sz) { return h("svg", { width: sz, height: sz, viewBox: "0 0 24 24", fill: "none", style: { display: "block" } }, h("circle", { cx: 12, cy: 12, r: 10, fill: "#F4AE1C", stroke: "#FFE9A8", strokeWidth: 1.2 }), h("circle", { cx: 12, cy: 12, r: 7.2, fill: "none", stroke: "#1A0428", strokeWidth: 0.9, opacity: 0.35 }), h("path", { d: "M12 7.2 13.5 10.3 16.9 10.8 14.5 13.2 15 16.6 12 15 9 16.6 9.5 13.2 7.1 10.8 10.5 10.3z", fill: "#1A0428", opacity: 0.8 }) ); }; return h("div", { style: { position: "fixed", inset: 0, background: "rgba(8,1,15,0.82)", backdropFilter: "blur(8px)", display: "flex", alignItems: "center", justifyContent: "center", zIndex: 320, padding: "20px" }, onClick: props.onClose }, h("div", { style: { width: "min(420px, 100%)", background: "linear-gradient(180deg, #3a1158 0%, #1A0428 100%)", border: "1px solid rgba(232,184,58,0.5)", borderRadius: "18px", boxShadow: "0 30px 80px rgba(0,0,0,0.7)", overflow: "hidden", position: "relative", textAlign: "center", padding: isMobile ? "30px 22px 24px" : "36px 30px 28px" }, onClick: function(e) { e.stopPropagation(); } }, h("div", { style: { position: "absolute", inset: 0, background: "radial-gradient(360px 220px at 50% -10%, rgba(247,176,28,0.22), transparent 60%)", pointerEvents: "none" } }), h("button", { onClick: props.onClose, style: { position: "absolute", top: "12px", right: "12px", width: "30px", height: "30px", borderRadius: "50%", background: "rgba(255,255,255,0.06)", border: "1px solid rgba(255,255,255,0.12)", color: "#F5EDD8", cursor: "pointer", fontSize: "16px", lineHeight: 1, zIndex: 2 } }, "×"), h("div", { style: { position: "relative", display: "flex", justifyContent: "center", marginBottom: "12px" } }, h("div", { style: { position: "relative", display: "inline-block" } }, h("img", { src: MASCOT_CTA_IMG, alt: "", style: { height: isMobile ? "118px" : "144px", width: "auto", display: "block", filter: "drop-shadow(0 12px 26px rgba(0,0,0,0.5))" } }), h("div", { style: { position: "absolute", right: "-8px", bottom: "-2px", filter: "drop-shadow(0 5px 12px rgba(247,176,28,0.6))" } }, coinSvg(40)) ) ), h("div", { style: { position: "relative", fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: isMobile ? "30px" : "34px", letterSpacing: "1px", color: "#F5EDD8", lineHeight: 1 } }, title), h("div", { style: { position: "relative", fontSize: "13px", color: "rgba(245,237,216,0.72)", lineHeight: 1.55, margin: "10px 4px 18px" } }, "Create a free account to save your XI, build custom player cards, and challenge friends in Duels."), h("div", { style: { position: "relative", background: "rgba(247,176,28,0.10)", border: "1px dashed rgba(232,184,58,0.55)", borderRadius: "12px", padding: "13px 14px", marginBottom: "18px" } }, h("div", { style: { fontSize: "10px", letterSpacing: "2px", color: "#E8B83A", textTransform: "uppercase", marginBottom: "7px" } }, coupons.length > 1 ? "Sign up & claim your coin rewards" : "Sign up & get a coin reward"), coupons.map(function(code) { return h("div", { key: code, style: { display: "flex", alignItems: "center", justifyContent: "center", gap: "10px", marginTop: "6px" } }, h("div", { style: { fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: "26px", letterSpacing: "3px", color: "#FFE9A8" } }, code), h("button", { onClick: function() { copy(code); }, style: { fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: "11px", letterSpacing: "1px", padding: "6px 11px", borderRadius: "8px", cursor: "pointer", border: "1px solid rgba(232,184,58,0.5)", background: "rgba(255,255,255,0.05)", color: "#E8B83A" } }, copied === code ? "COPIED ✓" : "COPY") ); }) ), h("button", { onClick: props.onSignup, style: { position: "relative", width: "100%", padding: "14px", borderRadius: "11px", border: "none", cursor: "pointer", background: "linear-gradient(155deg, #FFDE7A, #F4AE1C)", color: "#1A0428", fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: "17px", letterSpacing: "2px", boxShadow: "0 8px 22px rgba(247,176,28,0.4)" } }, "SIGN UP & CLAIM"), h("button", { onClick: props.onClose, style: { position: "relative", marginTop: "10px", background: "transparent", border: "none", color: "rgba(245,237,216,0.5)", fontSize: "12px", letterSpacing: "1px", cursor: "pointer", fontFamily: "'Anton', 'Bebas Neue', sans-serif" } }, "MAYBE LATER") ) ); } // ============================================================================ // PENALTY SHOOTOUT (§25) — async 1v1 penalty game (4th top-level tab). Reuses the // Duels social graph (profiles + friendships). Each player takes 5 penalties (+SD) // and KEEPS the opponent's by guessing the zone; a save happens iff keeper zone == // shooter zone. Resolved server-side by a trigger the instant the 2nd player submits // (migration penalty_shootout_core). Win=25 / draw=10 / loss=1 points. // ============================================================================ var PEN_ROUNDS = 5; // standard penalties each var PEN_SD = 2; // sudden-death buffer rounds (draw if still level) var PEN_TOTAL = PEN_ROUNDS + PEN_SD; // = 7 — MUST match the resolver's expected array length var PEN_SHOT_SECS = 10; // per-penalty aim/dive timer // 4 goal zones (corners). x/y are % within the SCENE so the ball can fly to them. var PEN_ZONES = [ { id: 0, name: "TOP LEFT", x: 25, y: 22 }, { id: 1, name: "TOP RIGHT", x: 75, y: 22 }, { id: 2, name: "LOW LEFT", x: 25, y: 52 }, { id: 3, name: "LOW RIGHT", x: 75, y: 52 } ]; function penZoneFromXY(rx, ry) { return (ry < 0.5 ? 0 : 2) + (rx < 0.5 ? 0 : 1); } function penRoundLabel(i, verb) { return i < PEN_ROUNDS ? (verb + " " + (i + 1) + " OF " + PEN_ROUNDS) : ("SUDDEN DEATH " + (i - PEN_ROUNDS + 1)); } // ── §25.9 reference-UI helpers ──────────────────────────────────────────────── // The chrome/glow headline treatment from the reference mockups: white→lavender // gradient text (background-clip) with a plum drop-shadow + purple glow. textShadow // would paint OVER clipped text, so the glow uses filter:drop-shadow instead. function penChrome(text, fontPx, glow) { return h("div", { style: { fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: fontPx + "px", letterSpacing: "1.5px", lineHeight: 1.05, display: "inline-block", transform: "skewX(-5deg)", whiteSpace: "nowrap", background: "linear-gradient(180deg, #FFFFFF 6%, #F2E8FF 42%, #CBAAF5 76%, #9E79D8 100%)", WebkitBackgroundClip: "text", backgroundClip: "text", WebkitTextFillColor: "transparent", filter: "drop-shadow(0 2px 0 rgba(22,4,42,0.95)) drop-shadow(0 0 18px " + (glow || "rgba(168,92,255,0.60)") + ")" } }, text); } // small inline icons (same no-emoji convention as dIcon) function penIcon(name, size, color) { var s = size || 16; var defs = { stopwatch: [["circle", { cx: 12, cy: 13.5, r: 8 }], ["path", { d: "M12 9.5 V13.5 L14.8 15.5" }], ["path", { d: "M9.5 2.5 h5 M12 2.5 V5.5 M18.5 6.5 20 5" }]], lock: [["rect", { x: 5.5, y: 10.5, width: 13, height: 9.5, rx: 2 }], ["path", { d: "M8.5 10.5 V7.5 a3.5 3.5 0 0 1 7 0 V10.5" }], ["circle", { cx: 12, cy: 15.2, r: 1.3, fill: "currentColor", stroke: "none" }]], unlock: [["rect", { x: 5.5, y: 10.5, width: 13, height: 9.5, rx: 2 }], ["path", { d: "M8.5 10.5 V7.5 a3.5 3.5 0 0 1 6.8 -1.1" }], ["circle", { cx: 12, cy: 15.2, r: 1.3, fill: "currentColor", stroke: "none" }]], share: [["path", { d: "M12 15 V3.5 M8 7 12 3 16 7" }], ["path", { d: "M5 12 v7 a2 2 0 0 0 2 2 h10 a2 2 0 0 0 2 -2 v-7" }]], podium: [["rect", { x: 9, y: 6, width: 6, height: 14, rx: 1 }], ["rect", { x: 2.5, y: 11, width: 6, height: 9, rx: 1 }], ["rect", { x: 15.5, y: 14, width: 6, height: 6, rx: 1 }], ["path", { d: "M12 1.8 12.6 3.4 14.3 3.4 12.9 4.5 13.4 6.1 12 5.1 10.6 6.1 11.1 4.5 9.7 3.4 11.4 3.4z", fill: "currentColor", stroke: "none" }]], question: [["circle", { cx: 12, cy: 12, r: 9.2 }], ["path", { d: "M9.4 9.4 a2.7 2.7 0 1 1 4.2 2.3 c-1 .7 -1.6 1.2 -1.6 2.3" }], ["circle", { cx: 12, cy: 17, r: 1.15, fill: "currentColor", stroke: "none" }]], star: [["path", { d: "M12 2.6 14.4 8.6 20.8 8.9 15.8 13 17.5 19.3 12 15.7 6.5 19.3 8.2 13 3.2 8.9 9.6 8.6z", fill: "currentColor", stroke: "none" }]], bolt: [["path", { d: "M13 2 4.5 13.5 h6 L10.5 22 19 10.5 h-6z", fill: "currentColor", stroke: "none" }]] }; return h("svg", { width: s, height: s, viewBox: "0 0 24 24", fill: "none", stroke: color || "currentColor", strokeWidth: 1.9, strokeLinecap: "round", strokeLinejoin: "round", style: { display: "block", flexShrink: 0, color: color || "currentColor" } }, (defs[name] || []).map(function(p, i) { return h(p[0], Object.assign({ key: i }, p[1])); })); } // the octagonal gold "VS" badge from the reference matchup banner function penVsBadge(size) { var oc = "polygon(29% 0, 71% 0, 100% 29%, 100% 71%, 71% 100%, 29% 100%, 0 71%, 0 29%)"; return h("div", { style: { width: size + "px", height: size + "px", position: "relative", flexShrink: 0, filter: "drop-shadow(0 3px 8px rgba(247,176,28,0.35))" } }, h("div", { style: { position: "absolute", inset: 0, clipPath: oc, WebkitClipPath: oc, background: "linear-gradient(160deg, #FFE9A8, #E8B83A 55%, #9c6f08)" } }), h("div", { style: { position: "absolute", inset: "2.5px", clipPath: oc, WebkitClipPath: oc, background: "linear-gradient(160deg, #35114f, #180329)" } }), h("div", { style: { position: "absolute", inset: 0, display: "flex", alignItems: "center", justifyContent: "center", fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: Math.round(size * 0.38) + "px", color: "#FFC836", letterSpacing: "0.5px" } }, "VS")); } // gold / silver / bronze rank shields for the leaderboard top 3 (reuses SHIELD_CLIP) function penRankBadge(rank, size) { var grads = { 1: ["#FFE49A", "#F0A91E"], 2: ["#E8EBF2", "#9AA3B5"], 3: ["#E8B48C", "#B06A35"] }; var g = grads[rank]; if (!g) return h("div", { style: { width: size + "px", textAlign: "center", fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: Math.round(size * 0.5) + "px", color: "rgba(245,237,216,0.7)" } }, rank); return h("div", { style: { width: size + "px", height: Math.round(size * 1.12) + "px", position: "relative", flexShrink: 0, filter: "drop-shadow(0 2px 6px rgba(0,0,0,0.45))" } }, h("div", { style: { position: "absolute", inset: 0, clipPath: SHIELD_CLIP, background: "linear-gradient(160deg, " + g[0] + ", " + g[1] + ")" } }), h("div", { style: { position: "absolute", inset: 0, display: "flex", alignItems: "center", justifyContent: "center", paddingBottom: Math.round(size * 0.16) + "px", fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: Math.round(size * 0.5) + "px", color: "#1A0428" } }, rank)); } // a soccer ball (white with a central pentagon + seams) function penBall(size) { // the classic flat football: central pentagon + 5 rim patches (clipped to the sphere) // + connecting seams + a top-left highlight gradient. The fixed def ids are duplicated // across instances — the geometry is identical in every copy, so the browser resolving // url(#…) to the first instance in the document renders all of them correctly. return h("svg", { width: size, height: size, viewBox: "0 0 64 64", style: { display: "block" } }, h("defs", null, h("radialGradient", { id: "pbGrad", cx: "35%", cy: "28%", r: "80%" }, h("stop", { offset: "0%", stopColor: "#ffffff" }), h("stop", { offset: "62%", stopColor: "#f2f4f9" }), h("stop", { offset: "100%", stopColor: "#c3cad9" })), h("clipPath", { id: "pbClip" }, h("circle", { cx: 32, cy: 32, r: 30 }))), h("circle", { cx: 32, cy: 32, r: 30, fill: "url(#pbGrad)", stroke: "#a7afc2", strokeWidth: 1.5 }), h("g", { clipPath: "url(#pbClip)", fill: "#242b3c" }, h("polygon", { points: "32,22.5 41,29.1 37.6,39.7 26.4,39.7 23,29.1" }), // centre pentagon h("polygon", { points: "32,10 24.4,4.5 27.3,-4.5 36.7,-4.5 39.6,4.5" }), // top rim patch h("polygon", { points: "52.9,25.2 55.8,16.2 65.2,16.2 68.1,25.2 60.5,30.7" }), // upper right h("polygon", { points: "44.9,49.8 54.3,49.8 57.2,58.8 49.6,64.3 42,58.8" }), // lower right h("polygon", { points: "19.1,49.8 22,58.8 14.4,64.3 6.8,58.8 9.7,49.8" }), // lower left h("polygon", { points: "11.1,25.2 3.5,30.7 -4.1,25.2 -1.2,16.2 8.2,16.2" })), // upper left h("g", { clipPath: "url(#pbClip)", stroke: "#242b3c", strokeWidth: 1.7, strokeLinecap: "round" }, h("line", { x1: 32, y1: 22.5, x2: 32, y2: 10 }), h("line", { x1: 41, y1: 29.1, x2: 52.9, y2: 25.2 }), h("line", { x1: 37.6, y1: 39.7, x2: 44.9, y2: 49.8 }), h("line", { x1: 26.4, y1: 39.7, x2: 19.1, y2: 49.8 }), h("line", { x1: 23, y1: 29.1, x2: 11.1, y2: 25.2 }))); } // a solid gold padlock (used by the locked-in banner mascot; fixed def id, same convention as pbGrad) function penGoldLock(w) { return h("svg", { width: w, height: Math.round(w * 1.08), viewBox: "0 0 24 26", style: { display: "block" } }, h("defs", null, h("linearGradient", { id: "plGold", x1: "0", y1: "0", x2: "0.6", y2: "1" }, h("stop", { offset: "0%", stopColor: "#FFE9A8" }), h("stop", { offset: "55%", stopColor: "#E8B83A" }), h("stop", { offset: "100%", stopColor: "#9c6f08" }))), h("path", { d: "M7 11 V7.5 a5 5 0 0 1 10 0 V11", stroke: "#8a6206", strokeWidth: 2.6, fill: "none", strokeLinecap: "round" }), h("rect", { x: 3.5, y: 10.5, width: 17, height: 13.5, rx: 2.6, fill: "url(#plGold)", stroke: "#7a5605", strokeWidth: 1 }), h("circle", { cx: 12, cy: 16, r: 2, fill: "#2a1d04" }), h("rect", { x: 11, y: 16.5, width: 2, height: 4.4, rx: 1, fill: "#2a1d04" })); } // "THE OPPONENT HAS TAKEN THEIR PENALTIES!" hype banner (client mockup 2026-07-18): // mascot (ball hugging a gold padlock) + white/gold headline + 5 glowing lock badges + // a "LOCKED & HIDDEN UNTIL THE RESULT" strip. Shown on the play screen when responding // to a match whose challenger has already locked in. // 3rd arg `taken` (§26.13 v4): null → the full hype variant; a number → the compact LIVE // TRACKER variant — YOU row fills with balls as you shoot, and each of THEIR padlocks // "unlocks" in sync (matched, still hidden — actual goals only exist at resolution). // 4th arg `faced` (v5): dives taken — during the DEFEND phase each of THEIR cells you've // faced flips from padlock to a football ("their shot plays out as you dive"). WHERE they // shot / whether it scored stays hidden until resolution — this is pace, not the result. function penLockedBanner(oppName, isMobile, taken, faced, res) { if (taken != null) { faced = faced || 0; res = res || {}; var sw = isMobile ? 22 : 28; // cell variants: dim (pending) · gold (taken, result unknown) · green (GOAL) · red (NO GOAL) var cellCs = { dim: { bg: "radial-gradient(circle at 50% 36%, #2a0f45 0%, #170627 72%)", bd: "1.5px solid rgba(168,92,255,0.5)", sh: "0 0 8px rgba(124,58,237,0.4)" }, gold: { bg: "rgba(247,176,28,0.14)", bd: "1.5px solid rgba(247,176,28,0.75)", sh: "0 0 9px rgba(247,176,28,0.45)" }, green: { bg: "rgba(63,185,107,0.18)", bd: "1.5px solid rgba(63,185,107,0.9)", sh: "0 0 10px rgba(63,185,107,0.55)" }, red: { bg: "rgba(255,107,107,0.15)", bd: "1.5px solid rgba(255,107,107,0.85)", sh: "0 0 10px rgba(255,107,107,0.5)" } }; var cell = function(key, inner, v) { var c = cellCs[v] || cellCs.dim; return h("div", { key: key, style: { width: sw + "px", height: sw + "px", borderRadius: "50%", flexShrink: 0, display: "flex", alignItems: "center", justifyContent: "center", background: c.bg, border: c.bd, boxShadow: c.sh, transition: "all 220ms ease" } }, inner); }; // SVG ✕ (a font glyph sits off-center in Anton — client-flagged) var xMark = function() { var xs = Math.round(sw * 0.5); return h("svg", { width: xs, height: xs, viewBox: "0 0 24 24", style: { display: "block" } }, h("path", { d: "M5.5 5.5 L18.5 18.5 M18.5 5.5 L5.5 18.5", stroke: "#FF8A8A", strokeWidth: 3.6, strokeLinecap: "round" })); }; var resultCell = function(key, goal) { return goal ? cell(key, penBall(Math.round(sw * 0.58)), "green") : cell(key, xMark(), "red"); }; var rowS = { display: "flex", alignItems: "center", gap: isMobile ? "5px" : "8px" }; var lblS = { fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: "10px", letterSpacing: "1.5px", width: isMobile ? "34px" : "42px", flexShrink: 0, textAlign: "right" }; var cntS = { fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: isMobile ? "12px" : "14px", marginLeft: "2px" }; // all 7 kicks: 5 standard + the 2 sudden-death buffer pens (every player takes them), // with a thin red divider marking where sudden death starts var sdDiv = function(key) { return h("div", { key: key, style: { width: "1.5px", height: Math.round(sw * 0.72) + "px", borderRadius: "2px", background: "rgba(255,138,138,0.45)", flexShrink: 0 } }); }; var youCells = [], themCells = [], rvY, rvT; for (var k = 0; k < PEN_TOTAL; k++) { if (k === PEN_ROUNDS) { youCells.push(sdDiv("ysd")); themCells.push(sdDiv("tsd")); } rvY = res["s" + k]; rvT = res["d" + k]; // YOU cell: revealed → GOAL/NO GOAL · taken (result pending) → gold ball · else dash youCells.push(k < taken ? (rvY != null ? resultCell("y" + k, rvY) : cell("y" + k, penBall(Math.round(sw * 0.58)), "gold")) : cell("y" + k, h("div", { style: { width: "7px", height: "2px", borderRadius: "2px", background: "rgba(245,237,216,0.3)" } }), "dim")); // THEM cell: faced+revealed → their GOAL/NO GOAL · faced → ball · matched by your shots → open lock · else locked themCells.push(k < faced ? (rvT != null ? resultCell("t" + k, rvT) : cell("t" + k, penBall(Math.round(sw * 0.58)), "gold")) : cell("t" + k, penIcon(k < taken ? "unlock" : "lock", Math.round(sw * 0.52), k < taken ? "#FFC836" : "#DCC6FF"), k < taken ? "gold" : "dim")); } var trackBg = PEN_LOCK_BG_IMG ? "linear-gradient(rgba(13,1,23,0.15), rgba(13,1,23,0.15)), url(" + PEN_LOCK_BG_IMG + ") center / cover no-repeat, linear-gradient(160deg, #1b0330 0%, #0d0117 60%, #12021f 100%)" : "linear-gradient(160deg, #1b0330 0%, #0d0117 60%, #12021f 100%)"; return h("div", { style: { position: "relative", overflow: "hidden", borderRadius: "16px", border: "1px solid rgba(168,92,255,0.35)", background: trackBg, padding: isMobile ? "10px 12px" : "12px 16px", marginBottom: "12px", display: "flex", alignItems: "center", gap: isMobile ? "8px" : "14px", boxShadow: "0 6px 22px rgba(0,0,0,0.45)" } }, h("div", { style: { position: "relative", width: (isMobile ? 44 : 58) + "px", height: (isMobile ? 44 : 58) + "px", flexShrink: 0 } }, h("div", { style: { position: "absolute", inset: "-16%", borderRadius: "50%", background: "radial-gradient(circle, rgba(124,58,237,0.42) 0%, rgba(124,58,237,0) 68%)", pointerEvents: "none" } }), PEN_LOCK_MASCOT_IMG ? h("img", { src: PEN_LOCK_MASCOT_IMG, alt: "", style: { position: "relative", width: "100%", height: "100%", objectFit: "contain", pointerEvents: "none" } }) : penBall(isMobile ? 44 : 58)), h("div", { style: { flex: "1 1 0", minWidth: 0 } }, h("div", { style: rowS }, h("div", { style: Object.assign({}, lblS, { color: "#FFC836" }) }, "YOU"), youCells, h("div", { style: Object.assign({}, cntS, { color: "#FFC836" }) }, Math.min(taken, PEN_TOTAL) + "/" + PEN_TOTAL)), h("div", { style: Object.assign({}, rowS, { marginTop: isMobile ? "6px" : "8px" }) }, h("div", { style: Object.assign({}, lblS, { color: "#DCC6FF", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" } ) }, "THEM"), themCells, h("div", { style: Object.assign({}, cntS, { color: "#DCC6FF" }) }, PEN_TOTAL + "/" + PEN_TOTAL)), h("div", { style: { display: "flex", alignItems: "center", gap: "5px", marginTop: isMobile ? "6px" : "8px", fontSize: isMobile ? "8px" : "9px", letterSpacing: "1.5px", color: "rgba(220,198,255,0.75)", fontFamily: "'Anton', 'Bebas Neue', sans-serif" } }, penIcon("lock", 9, "rgba(220,198,255,0.75)"), oppName.toUpperCase() + "'S RESULTS UNLOCK WHEN YOU FINISH"))); } var mW = isMobile ? 74 : 116, bw = isMobile ? 38 : 48, i; var badges = []; for (i = 0; i < PEN_ROUNDS; i++) { badges.push(h("div", { key: "lb" + i, style: { width: bw + "px", height: bw + "px", borderRadius: "50%", flexShrink: 0, display: "flex", alignItems: "center", justifyContent: "center", background: "radial-gradient(circle at 50% 36%, #2a0f45 0%, #170627 72%)", border: "2px solid rgba(168,92,255,0.55)", boxShadow: "0 0 12px rgba(124,58,237,0.5), inset 0 0 9px rgba(124,58,237,0.35)" } }, penIcon("lock", Math.round(bw * 0.46), "#E8EBF4"))); } // backdrop = the Higgsfield atmospheric art (sparkles/fog baked in) under a slight dark // overlay for text contrast, over the plain gradient (the fallback if the image URL dies) var bgLayers = PEN_LOCK_BG_IMG ? "linear-gradient(rgba(13,1,23,0.15), rgba(13,1,23,0.15)), url(" + PEN_LOCK_BG_IMG + ") center / cover no-repeat, linear-gradient(160deg, #1b0330 0%, #0d0117 60%, #12021f 100%)" : "linear-gradient(160deg, #1b0330 0%, #0d0117 60%, #12021f 100%)"; return h("div", { style: { position: "relative", overflow: "hidden", borderRadius: "16px", border: "1px solid rgba(168,92,255,0.35)", background: bgLayers, padding: isMobile ? "14px 12px 14px" : "18px 20px", marginBottom: "12px", display: "flex", alignItems: "center", gap: isMobile ? "10px" : "14px", boxShadow: "0 6px 22px rgba(0,0,0,0.45)" } }, // mascot: the Higgsfield 3D ball-hugging-padlock cutout over a soft violet glow; // SVG composition (ball + gold lock) is the fallback when PEN_LOCK_MASCOT_IMG is nulled h("div", { style: { position: "relative", width: mW + "px", height: mW + "px", flexShrink: 0, display: "flex", alignItems: "center", justifyContent: "center" } }, h("div", { style: { position: "absolute", inset: "-12%", borderRadius: "50%", background: "radial-gradient(circle, rgba(124,58,237,0.42) 0%, rgba(124,58,237,0) 68%)", pointerEvents: "none" } }), PEN_LOCK_MASCOT_IMG ? h("img", { src: PEN_LOCK_MASCOT_IMG, alt: "", style: { position: "relative", width: "100%", height: "100%", objectFit: "contain", filter: "drop-shadow(0 5px 14px rgba(0,0,0,0.55))", pointerEvents: "none" } }) : h("div", { style: { position: "relative", width: Math.round(mW * 0.74) + "px", height: Math.round(mW * 0.84) + "px", filter: "drop-shadow(0 4px 12px rgba(124,58,237,0.45))" } }, penBall(Math.round(mW * 0.74)), h("div", { style: { position: "absolute", left: "50%", bottom: 0, transform: "translateX(-50%)" } }, penGoldLock(Math.round(mW * 0.46))))), // headline + badges + footer strip h("div", { style: { flex: "1 1 0", minWidth: 0, textAlign: "center" } }, h("div", { style: { fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: isMobile ? "14px" : "20px", letterSpacing: "1.5px", color: "#FFFFFF", lineHeight: 1.15, textShadow: "0 2px 8px rgba(0,0,0,0.7)" } }, oppName.toUpperCase() + " HAS TAKEN"), h("div", { style: { fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: isMobile ? "23px" : "34px", letterSpacing: "1px", lineHeight: 1.05, display: "inline-block", transform: "skewX(-6deg)", background: "linear-gradient(180deg, #FFE9A8 8%, #FFC836 52%, #E8952F 100%)", WebkitBackgroundClip: "text", backgroundClip: "text", WebkitTextFillColor: "transparent", filter: "drop-shadow(0 2px 0 rgba(22,4,42,0.9)) drop-shadow(0 0 16px rgba(247,176,28,0.4))", marginTop: "2px", whiteSpace: "nowrap" } }, "THEIR PENALTIES!"), h("div", { style: { display: "flex", justifyContent: "center", gap: isMobile ? "8px" : "12px", margin: isMobile ? "10px 0 10px" : "14px 0 14px" } }, badges), h("div", { style: { display: "inline-flex", alignItems: "center", gap: "7px", padding: isMobile ? "5px 13px" : "7px 15px", borderRadius: "999px", border: "1px solid rgba(199,155,242,0.32)", background: "rgba(20,4,36,0.55)", maxWidth: "100%", boxSizing: "border-box" } }, penIcon("lock", 10, "#DCC6FF"), h("span", { style: { fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: isMobile ? "8.5px" : "10px", letterSpacing: "1.5px", color: "#DCC6FF", whiteSpace: "nowrap" } }, "LOCKED & HIDDEN UNTIL THE RESULT"), penIcon("lock", 10, "#DCC6FF"))), // desktop: a small right spacer so the text cluster sits near the banner's true center // (a full mascot-width mirror starves the column → everything wraps; 48px splits the difference) isMobile ? null : h("div", { style: { width: "48px", flexShrink: 0 } })); } // the diving goalkeeper silhouette (glowing), positioned/rotated toward a zone (null = centre). // the goalkeeper (§25.9 "look real"): an illustrated athlete in the purple kit — skin, // hair, jersey with a gold #1, shorts, socks, boots, white gloves — with TWO poses: // a READY stance facing the shooter (zone null) and a FULL-STRETCH DIVE (arms extended // toward the chosen corner). The dive figure is drawn diving right; left corners mirror // it (scale(-1,1) before rotate). The container transform transitions → the dive animates. // §25.9 HOLOGRAM keeper ART SLOTS — to use the client's real rendered hologram artwork // (the reference image), upload the PNGs to Webflow assets and paste the URLs here: // ready = front-on crouched stance; // dive = full-stretch HIGH dive TO THE RIGHT (top corners; left corners auto-mirror); // diveLow = low dive TO THE RIGHT (bottom corners; falls back to `dive` when null). // Transparent background is ideal; a near-BLACK background also works (they're composited // with mix-blend-mode:screen, so black disappears and the glow blends into the scene). // While a URL is null the hand-drawn SVG hologram below renders as the fallback. // (Display-only — the penalty scene is never exported to PNG, so raw cross-origin // Webflow-asset URLs are fine here, same as HERO_BUILD_IMG / DUEL_BANNER_IMG.) // §25.10 — the client's PENALTY SHOOTOUT banner artwork (replaces the dashboard text title) var PEN_BANNER_IMG = "https://cdn.prod.website-files.com/69eb6394026dd7faab0bad78/6a4aebe170325ae14adee128_03B1605C-BFE3-4745-B886-73C603337B39.png"; // the locked-in banner mascot: a Higgsfield 3D ball-hugging-gold-padlock cutout (transparent, // §26.13; original saved as pen-locked-mascot.png in the project root; Webflow-hosted AVIF). // Null → the composed SVG fallback renders. var PEN_LOCK_MASCOT_IMG = "https://cdn.prod.website-files.com/69eb6394026dd7faab0bad78/6a5cb90c781dae66fe4c72d3_pen-locked-mascot.avif"; // the locked-in banner backdrop: Higgsfield 21:9 atmospheric art (fog glows + sparkles + confetti, // no subject/text so cover-cropping is safe at any banner aspect; original = pen-locked-bg.png in // the project root; Webflow-hosted AVIF). Null → the plain CSS gradient base still renders underneath. var PEN_LOCK_BG_IMG = "https://cdn.prod.website-files.com/69eb6394026dd7faab0bad78/6a5cb90c23b7b3e0368ee86b_pen-locked-bg.avif"; var PEN_KEEPER_IMGS = { ready: "https://cdn.prod.website-files.com/69eb6394026dd7faab0bad78/6a4af059fac6fc4bb1046434_main-keeper.avif", dive: "https://cdn.prod.website-files.com/69eb6394026dd7faab0bad78/6a4af061f2b3031b92db4766_dive-main.avif", diveLow: "https://cdn.prod.website-files.com/69eb6394026dd7faab0bad78/6a4af05d67feb6e78c49131a_dive-low.avif" }; function penKeeper(zone, isMobile) { // §25.9 HOLOGRAM keeper (client reference: a purple edge-lit holographic figure on a // ringed pad). Monochrome violet: translucent body fills + bright rim strokes + a strong // violet bloom + a subtle .pen-holo flicker. Limbs are edge-lit by layering a translucent // CORE line over a slightly WIDER bright line (the bright edges peek out as rims). var EDGE = "#DCC6FF", MID = "rgba(190,140,255,0.95)", CORE = "rgba(120,60,220,0.42)", FILL = "rgba(124,58,237,0.28)", FILL2 = "rgba(124,58,237,0.42)", NUM = "#F1E8FF"; var dive = zone != null; var right = zone === 1 || zone === 3; var topZ = zone === 0 || zone === 1; var tx = 0, ty = 0, rot = 0; if (dive) { tx = right ? 46 : -46; ty = topZ ? -30 : 6; rot = topZ ? -26 : 10; } var flip = (dive && !right) ? " scale(-1,1)" : ""; var W = dive ? (isMobile ? 112 : 142) : (isMobile ? 62 : 84); var Hh = dive ? Math.round(W * 64 / 130) : Math.round(W * 100 / 80); var Wcss = W + "px", Hcss = Hh + "px"; // the img branch overrides these with scene-relative % // edge-lit limb = bright underlay (w) + translucent core overlay (w-3) var limb = function(d, w) { return [h("path", { key: d + "e", d: d, stroke: EDGE, strokeWidth: w, strokeLinecap: "round", fill: "none", opacity: 0.85 }), h("path", { key: d + "c", d: d, stroke: CORE, strokeWidth: Math.max(2, w - 3), strokeLinecap: "round", fill: "none" })]; }; var body; var imgSrc = dive ? ((topZ ? null : PEN_KEEPER_IMGS.diveLow) || PEN_KEEPER_IMGS.dive) : PEN_KEEPER_IMGS.ready; if (imgSrc) { // the client's real hologram artwork — screen-blended so a black backdrop vanishes // and the purple glow composites into the goal scene. The dive angle is baked into // the art, so image mode uses much milder rotation than the SVG poses. // SCENE-RELATIVE sizing so the keeper stays in proportion with the goal at every width: // ready ≈ 46% of scene height (~80% of the goal mouth, like a real keeper) with his boots // planted ON the goal line (ty lifts the centered box so art-bottom ≈ the 66% grass line); // dives span ~42% of scene width. tx/ty/rot are % of the figure's own box. if (dive) { tx = right ? 22 : -22; rot = topZ ? -6 : 4; ty = topZ ? -24 : 7; } else { ty = -6.5; } Wcss = dive ? "42%" : "30%"; Hcss = dive ? "35%" : "46%"; body = h("img", { src: imgSrc, alt: "", style: { width: "100%", height: "100%", objectFit: "contain", mixBlendMode: "screen", display: "block", pointerEvents: "none" } }); } else if (!dive) { // READY: front-on — arms out, gloves open, standing on a holographic ringed pad body = h("svg", { viewBox: "0 0 80 100", width: "100%", height: "100%" }, // holo floor rings under the feet h("ellipse", { cx: 40, cy: 94.5, rx: 27, ry: 4.6, fill: "none", stroke: MID, strokeWidth: 1.1, opacity: 0.5 }), h("ellipse", { cx: 40, cy: 94.5, rx: 19, ry: 3.2, fill: "none", stroke: MID, strokeWidth: 1, opacity: 0.32 }), h("ellipse", { cx: 40, cy: 94.5, rx: 11, ry: 1.9, fill: "none", stroke: MID, strokeWidth: 0.9, opacity: 0.2 }), // legs + boots limb("M34 68 L31 86", 7.5), limb("M46 68 L49 86", 7.5), h("ellipse", { cx: 28.5, cy: 91.5, rx: 7.5, ry: 4, fill: FILL2, stroke: EDGE, strokeWidth: 1.3 }), h("ellipse", { cx: 51.5, cy: 91.5, rx: 7.5, ry: 4, fill: FILL2, stroke: EDGE, strokeWidth: 1.3 }), // arms + gloves limb("M28 33 L11 46", 8.5), limb("M52 33 L69 46", 8.5), h("circle", { cx: 9, cy: 50, r: 6, fill: FILL, stroke: EDGE, strokeWidth: 1.5 }), h("circle", { cx: 71, cy: 50, r: 6, fill: FILL, stroke: EDGE, strokeWidth: 1.5 }), // shorts + torso with scanlines + the glowing #1 h("rect", { x: 27, y: 54, width: 26, height: 16, rx: 5.5, fill: FILL2, stroke: EDGE, strokeWidth: 1.4 }), h("rect", { x: 25, y: 25, width: 30, height: 33, rx: 8, fill: FILL, stroke: EDGE, strokeWidth: 1.6 }), h("path", { d: "M26.5 33 L53.5 33 M26.5 40 L53.5 40 M26.5 47 L53.5 47", stroke: MID, strokeWidth: 0.7, opacity: 0.4 }), h("text", { x: 40, y: 48, textAnchor: "middle", fontFamily: "'Anton', sans-serif", fontSize: 15, fill: NUM, style: { filter: "drop-shadow(0 0 3px rgba(220,190,255,0.9))" } }, "1"), // neck + head h("rect", { x: 36.5, y: 19, width: 7, height: 7, rx: 2.5, fill: FILL2 }), h("circle", { cx: 40, cy: 13.5, r: 9.5, fill: FILL, stroke: EDGE, strokeWidth: 1.5 }), h("path", { d: "M30.6 13 Q31.4 3.8 40 3.8 Q48.6 3.8 49.4 13 Q45 8.6 40 8.9 Q35 8.6 30.6 13 Z", fill: FILL2 })); } else { // DIVE: full stretch toward +X (mirrored for left corners) — legs trailing, arms extended body = h("svg", { viewBox: "0 0 130 64", width: "100%", height: "100%" }, limb("M36 33 L17 46", 7), limb("M38 29 L20 17", 7), h("ellipse", { cx: 12.5, cy: 49, rx: 6.6, ry: 3.8, fill: FILL2, stroke: EDGE, strokeWidth: 1.3 }), h("ellipse", { cx: 15.5, cy: 13.5, rx: 6.6, ry: 3.8, fill: FILL2, stroke: EDGE, strokeWidth: 1.3 }), h("rect", { x: 28, y: 22, width: 19, height: 18, rx: 6.5, fill: FILL2, stroke: EDGE, strokeWidth: 1.4 }), h("path", { d: "M42 22 L73 15.5 Q80 17.5 80.5 24 L78 36 L44 40 Q40 31 42 22 Z", fill: FILL, stroke: EDGE, strokeWidth: 1.6, strokeLinejoin: "round" }), h("path", { d: "M47 27 L76 21 M46 32 L77 26.5", stroke: MID, strokeWidth: 0.7, opacity: 0.4 }), limb("M74 20 L110 7.5", 7.5), limb("M76 28 L116 18", 7.5), h("circle", { cx: 114.5, cy: 5.5, r: 6, fill: FILL, stroke: EDGE, strokeWidth: 1.5 }), h("circle", { cx: 120.5, cy: 16.5, r: 6, fill: FILL, stroke: EDGE, strokeWidth: 1.5 }), h("circle", { cx: 88, cy: 18, r: 8.8, fill: FILL, stroke: EDGE, strokeWidth: 1.5 }), h("path", { d: "M79.6 15.6 Q80.8 8.2 88 7.9 Q95.2 8.2 96.4 15.6 Q92 11.6 88 11.9 Q84 11.6 79.6 15.6 Z", fill: FILL2 })); } return h("div", { className: "pen-holo", style: { position: "absolute", left: "50%", top: "47%", width: Wcss, height: Hcss, transform: "translate(-50%,-50%) translate(" + tx + "%," + ty + "%)" + flip + " rotate(" + rot + "deg)", transition: "transform 430ms cubic-bezier(0.3,0.7,0.25,1)", zIndex: 4, pointerEvents: "none", filter: "drop-shadow(0 0 5px rgba(190,130,255,0.85)) drop-shadow(0 0 16px rgba(140,60,255,0.55))" } }, body); } // the goal + keeper + ball scene, shared by the PLAY input + the SUMMARY replay. // §25.9 reference look: striped night pitch, gold-frame goal with a diamond net, // the SELECTED corner lights its whole quadrant gold, an in-goal swipe hint, and // an optional lock-in footer strip. opts: { sel, ballZone, keeperZone, result, // showTargets, isMobile, hint, footer }. function penGoalScene(o) { o = o || {}; var sel = (o.sel == null) ? null : o.sel, ballZone = (o.ballZone == null) ? null : o.ballZone; var keeperZone = (o.keeperZone == null) ? null : o.keeperZone, result = o.result || null, showTargets = !!o.showTargets, isMobile = o.isMobile; var bpos = ballZone != null ? PEN_ZONES[ballZone] : { x: 50, y: 81 }; // rest on the spot, clear of the lock-footer strip var ballSize = isMobile ? 32 : 40, tgt = isMobile ? 30 : 38; // quadrant rectangles inside the goal mouth (goal region = left 8% → 92%, top 9% → 66%) var QUADS = [ { z: 0, left: "8%", top: "9%", width: "42%", height: "28.5%" }, { z: 1, left: "50%", top: "9%", width: "42%", height: "28.5%" }, { z: 2, left: "8%", top: "37.5%", width: "42%", height: "28.5%" }, { z: 3, left: "50%", top: "37.5%", width: "42%", height: "28.5%" } ]; return h("div", { style: { position: "relative", width: "100%", aspectRatio: "1.5 / 1", borderRadius: "14px", overflow: "hidden", background: "radial-gradient(120% 90% at 50% 8%, #232b4d 0%, #131a30 46%, #0a0e1c 100%)", boxShadow: "inset 0 0 60px rgba(0,0,0,0.6)", border: "1px solid rgba(255,200,54,0.28)" } }, // striped night grass + penalty spot h("div", { style: { position: "absolute", left: "-4%", right: "-4%", bottom: 0, height: "36%", transform: "perspective(300px) rotateX(28deg)", transformOrigin: "50% 0%", background: "repeating-linear-gradient(90deg, #14401f 0 9%, #0e3117 9% 18%)", boxShadow: "inset 0 10px 24px rgba(0,0,0,0.55)" } }), h("div", { style: { position: "absolute", left: "50%", bottom: "13%", width: "13%", height: "2.5%", transform: "translateX(-50%)", borderRadius: "50%", background: "rgba(255,255,255,0.14)" } }), // the goal mouth: dark backdrop + diamond net + gold-lit quadrant for the selection h("div", { className: result === "goal" ? "pen-net-shake" : "", style: { position: "absolute", left: "8%", right: "8%", top: "9%", height: "57%" } }, h("div", { style: { position: "absolute", inset: 0, background: "linear-gradient(180deg, rgba(6,10,22,0.85), rgba(10,15,30,0.55)), repeating-linear-gradient(45deg, rgba(210,220,255,0.13) 0 1px, transparent 1px 13px), repeating-linear-gradient(-45deg, rgba(210,220,255,0.13) 0 1px, transparent 1px 13px)" } })), QUADS.map(function(q) { var on = showTargets && sel === q.z; return h("div", { key: "q" + q.z, style: { position: "absolute", left: q.left, top: q.top, width: q.width, height: q.height, pointerEvents: "none", transition: "opacity 140ms ease", opacity: on ? 1 : 0, background: "radial-gradient(80% 80% at 50% 50%, rgba(247,176,28,0.42), rgba(247,176,28,0.10) 75%, transparent 100%)", boxShadow: "inset 0 0 0 2px rgba(255,200,54,0.55)", backgroundBlendMode: "screen" } }); }), // goal frame (posts + bar) — drawn OVER the net so it reads as the woodwork h("div", { style: { position: "absolute", left: "8%", right: "8%", top: "9%", height: "57%", border: (isMobile ? "4px" : "5px") + " solid #F4F6FF", borderBottom: "none", borderTopLeftRadius: "5px", borderTopRightRadius: "5px", boxShadow: "0 0 18px rgba(190,205,255,0.30), inset 0 0 10px rgba(0,0,0,0.35)", pointerEvents: "none" } }), // corner targets showTargets ? PEN_ZONES.map(function(z) { var on = sel === z.id; return h("div", { key: z.id, style: { position: "absolute", left: z.x + "%", top: z.y + "%", transform: "translate(-50%,-50%)", width: tgt + "px", height: tgt + "px", borderRadius: "50%", border: on ? "3px solid #FFC836" : "2.5px solid rgba(255,255,255,0.75)", background: on ? "radial-gradient(circle, rgba(247,176,28,0.55), rgba(247,176,28,0.08))" : "rgba(10,14,28,0.45)", boxShadow: on ? "0 0 20px rgba(247,176,28,0.8)" : "0 0 8px rgba(0,0,0,0.4)", display: "flex", alignItems: "center", justifyContent: "center", pointerEvents: "none", transition: "border-color 120ms ease, box-shadow 120ms ease, background 120ms ease", zIndex: 3 } }, h("div", { style: { width: "8px", height: "8px", borderRadius: "50%", background: on ? "#FFC836" : "rgba(255,255,255,0.9)" } })); }) : null, // in-goal swipe hint (attack, nothing aimed yet) o.hint ? h("div", { style: { position: "absolute", left: "50%", top: "30%", transform: "translate(-50%,-50%)", zIndex: 6, pointerEvents: "none", textAlign: "center", fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: isMobile ? "15px" : "19px", letterSpacing: "1.5px", lineHeight: 1.35, color: "#FFFFFF", textShadow: "0 2px 10px rgba(0,0,0,0.9), 0 0 22px rgba(168,92,255,0.55)" } }, o.hint.split("\n").map(function(l, i) { return h("div", { key: i }, l); })) : null, penKeeper(keeperZone, isMobile), h("div", { style: { position: "absolute", left: bpos.x + "%", top: bpos.y + "%", width: ballSize + "px", height: ballSize + "px", transform: "translate(-50%,-50%)", transition: "left 460ms cubic-bezier(0.42,0.04,0.2,1), top 460ms cubic-bezier(0.42,0.04,0.2,1)", zIndex: 5, pointerEvents: "none", filter: "drop-shadow(0 5px 8px rgba(0,0,0,0.55)) drop-shadow(0 0 14px rgba(255,240,200,0.28))" } }, h("div", { className: ballZone != null ? "pen-ball-spin" : "", style: { width: "100%", height: "100%" } }, penBall(ballSize))), // lock-in footer strip o.footer ? h("div", { style: { position: "absolute", left: 0, right: 0, bottom: 0, padding: "6px 10px 7px", display: "flex", alignItems: "center", justifyContent: "center", gap: "6px", background: "linear-gradient(180deg, rgba(5,8,16,0), rgba(5,8,16,0.72))", color: "rgba(245,237,216,0.75)", fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: isMobile ? "9px" : "10px", letterSpacing: "2px", zIndex: 6, pointerEvents: "none" } }, penIcon("lock", isMobile ? 11 : 12, "rgba(245,237,216,0.75)"), o.footer) : null, result ? h("div", { className: "pen-pop", style: { position: "absolute", left: "50%", top: "38%", transform: "translate(-50%,-50%)", padding: isMobile ? "7px 16px" : "9px 22px", borderRadius: "12px", fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: isMobile ? "24px" : "32px", letterSpacing: "2px", color: result === "goal" ? "#0c1c10" : "#fff", background: result === "goal" ? "linear-gradient(160deg,#7CFFB0,#23C463)" : "linear-gradient(160deg,#FF8A8A,#E2353C)", boxShadow: "0 10px 28px rgba(0,0,0,0.5)", zIndex: 8 } }, result === "goal" ? "GOAL!" : "SAVED!") : null); } // ── the interactive PLAY flow: ATTACK phase (swipe to aim) then DEFEND phase (tap a corner) ── function PenaltyPlay(props) { var isMobile = props.isMobile, oppName = props.oppName || "RIVAL", DG = props.DG; // §26.13 v6: resume = per-round moves committed in an earlier session (server-binding) — // seed the play state so those rounds aren't re-playable and the tracker restores. var rz = props.resume || null; var _ph = useState(rz ? rz.phase : "attack"), phase = _ph[0], setPhase = _ph[1]; var _rd = useState(rz ? rz.round : 0), round = _rd[0], setRound = _rd[1]; var _sel = useState(null), sel = _sel[0], setSel = _sel[1]; var _lk = useState(false), locked = _lk[0], setLocked = _lk[1]; var _sc = useState(PEN_SHOT_SECS), secs = _sc[0], setSecs = _sc[1]; // §25.10 interstitials: "phase" = the big STEP 1/STEP 2 card (dismissed by its button), // "round" = the auto "PENALTY N" announcement between kicks. Timer + aiming pause while set. var _intro = useState("phase"), intro = _intro[0], setIntro = _intro[1]; // per-round revealed results ("s"/"d" → goal boolean) — drives the tracker cells var _rv = useState(rz ? rz.revealed : {}), revealed = _rv[0], setRevealed = _rv[1]; var selRef = useRef(null), shotsRef = useRef(rz ? rz.shots.slice() : []), divesRef = useRef(rz ? rz.dives.slice() : []); var padRef = useRef(null), commitRef = useRef(null), mountedRef = useRef(true), dragRef = useRef(null), lockingRef = useRef(false); useEffect(function() { mountedRef.current = true; return function() { mountedRef.current = false; if (commitRef.current) clearTimeout(commitRef.current); }; }, []); // defensive: clear any pending commit timer when the round/phase actually changes (the timer that // caused the change has already fired, so this is a no-op in the normal flow — but it prevents an // orphaned commit from a future code path advancing twice). useEffect(function() { return function() { if (commitRef.current) { clearTimeout(commitRef.current); commitRef.current = null; } }; }, [phase, round]); // the between-rounds "PENALTY N" card auto-dismisses; the STEP cards wait for their button useEffect(function() { if (intro !== "round") return; var t = setTimeout(function() { if (mountedRef.current) setIntro(null); }, 1150); return function() { clearTimeout(t); }; }, [intro, phase, round]); function startRound(ph, r) { setPhase(ph); setRound(r); setSel(null); selRef.current = null; setLocked(false); lockingRef.current = false; setSecs(PEN_SHOT_SECS); setIntro(r === 0 ? "phase" : "round"); } // §26.13 v6: the moment a kick/dive commits, commit it server-side and ask for the // opponent's corner for that round → goal/no-goal lights up in the tracker. If an // earlier session already committed a DIFFERENT corner for this round (quit-and-replay // edge), the server version is binding — adopt it so the final submit can't mismatch. function requestReveal(kind, r, z) { if (!props.onReveal) return; props.onReveal(kind, r, z).then(function(out) { if (!mountedRef.current || !out) return; if (out.committed != null && out.committed !== z) { var arr = kind === "shot" ? shotsRef.current : divesRef.current; if (arr.length > r) arr[r] = out.committed; z = out.committed; } if (out.opp == null) return; var goal = out.opp !== z; // shot: their dive missed my corner · dive: their shot beat my dive setRevealed(function(prev) { var n = Object.assign({}, prev); n[(kind === "shot" ? "s" : "d") + r] = goal; return n; }); }, function() {}); } function advance(z) { if (!mountedRef.current) return; if (phase === "attack") { shotsRef.current = shotsRef.current.concat([z]); requestReveal("shot", round, z); if (round + 1 < PEN_TOTAL) startRound("attack", round + 1); else startRound("defend", 0); } else { divesRef.current = divesRef.current.concat([z]); requestReveal("dive", round, z); if (round + 1 < PEN_TOTAL) startRound("defend", round + 1); else { setPhase("done"); props.onSubmit(shotsRef.current.slice(), divesRef.current.slice()); } } } function lockIn(zoneArg) { if (locked || lockingRef.current || intro || phase === "done") return; // ref guard: a timer-expiry + tap in the same frame can't double-commit lockingRef.current = true; var z = (zoneArg != null) ? zoneArg : (selRef.current != null ? selRef.current : (round % 4)); selRef.current = z; setSel(z); setLocked(true); commitRef.current = setTimeout(function() { advance(z); }, phase === "attack" ? 900 : 760); } // per-round countdown (pure counter — no Date.now). Paused during intros; at 0, auto-locks // the current/most-likely zone. useEffect(function() { if (locked || intro || phase === "done") return; if (secs <= 0) { lockIn(); return; } var t = setTimeout(function() { setSecs(function(s) { return s - 1; }); }, 1000); return function() { clearTimeout(t); }; }, [secs, locked, intro, phase, round]); function zoneFromEvent(e) { var r = padRef.current && padRef.current.getBoundingClientRect(); if (!r || !r.width) return null; var rx = Math.max(0, Math.min(1, (e.clientX - r.left) / r.width)); var ry = Math.max(0, Math.min(1, (e.clientY - r.top) / r.height)); return penZoneFromXY(rx, ry); } function onDown(e) { if (locked || intro) return; try { e.currentTarget.setPointerCapture(e.pointerId); } catch (_) {} dragRef.current = e.pointerId; var z = zoneFromEvent(e); if (z != null) { selRef.current = z; setSel(z); } } function onMove(e) { if (locked || intro || dragRef.current == null) return; var z = zoneFromEvent(e); if (z != null && z !== selRef.current) { selRef.current = z; setSel(z); } } function onUp(e) { if (locked || intro) return; try { e.currentTarget.releasePointerCapture(e.pointerId); } catch (_) {} var z = zoneFromEvent(e); if (z != null) { selRef.current = z; setSel(z); } dragRef.current = null; } var attacking = phase === "attack"; var verb = attacking ? "PENALTY" : "SAVE"; var ballZone = (attacking && locked) ? sel : null; var keeperZone = (!attacking && locked) ? sel : null; var low = secs <= 3 && !locked; var sideAv = props.avatarFor; var st = props.stats || { wins: 0, losses: 0, draws: 0 }; var shotsDone = shotsRef.current.length, divesDone = divesRef.current.length; var goldPanel = { background: "linear-gradient(180deg, rgba(55,28,74,0.55) 0%, rgba(18,5,32,0.92) 100%)", border: "1px solid rgba(255,200,54,0.42)", borderRadius: "16px", boxShadow: "0 14px 36px rgba(0,0,0,0.5), inset 0 1px 0 rgba(255,255,255,0.05)", boxSizing: "border-box" }; var tipRow = function(icon, txt) { return h("div", { key: icon, style: { display: "flex", alignItems: "center", gap: "5px" } }, h("div", { style: { width: "17px", height: "17px", borderRadius: "50%", border: "1px solid rgba(255,200,54,0.55)", display: "flex", alignItems: "center", justifyContent: "center", flexShrink: 0, color: DG.gold } }, penIcon(icon, 10, DG.gold)), h("div", { style: { fontSize: isMobile ? "8.5px" : "10.5px", color: "rgba(245,237,216,0.8)", lineHeight: 1.2, whiteSpace: "nowrap" } }, txt)); }; // pips for the progress card: done=gold check, current=pulsing ring, pending=dash var pip = function(state, k) { return h("div", { key: k, className: state === "cur" ? "pen-timer-pulse" : "", style: { width: "19px", height: "19px", borderRadius: "50%", display: "flex", alignItems: "center", justifyContent: "center", flexShrink: 0, fontSize: "10px", lineHeight: 1, border: state === "done" ? "1.5px solid rgba(124,255,176,0.75)" : state === "cur" ? "1.5px solid #FFC836" : "1.5px solid rgba(255,255,255,0.22)", background: state === "done" ? "rgba(35,196,99,0.2)" : state === "cur" ? "rgba(247,176,28,0.18)" : "rgba(255,255,255,0.03)", color: state === "done" ? "#7CFFB0" : state === "cur" ? "#FFC836" : "rgba(255,255,255,0.35)" } }, state === "done" ? "✓" : state === "cur" ? "●" : "–"); }; var progRow = function(label, done, active) { return h("div", { style: { display: "flex", alignItems: "center", gap: "6px", marginBottom: "6px" } }, h("div", { style: { width: isMobile ? "34px" : "40px", fontSize: "9px", letterSpacing: "1px", color: "rgba(245,237,216,0.6)", fontFamily: "'Anton', 'Bebas Neue', sans-serif", flexShrink: 0 } }, label), h("div", { style: { display: "flex", gap: "4px", flexWrap: "wrap" } }, Array.apply(null, { length: PEN_ROUNDS }).map(function(_, i) { return pip(i < done ? "done" : (active && i === done && round < PEN_ROUNDS ? "cur" : "pend"), i); }), (active && round >= PEN_ROUNDS) || done > PEN_ROUNDS ? h("div", { key: "sd", style: { fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: "10px", color: "#FF8A8A", alignSelf: "center", marginLeft: "2px" } }, "SD") : null)); }; return h("div", { style: { maxWidth: "620px", margin: "0 auto", width: "100%", boxSizing: "border-box", padding: isMobile ? "12px 14px 46px" : "18px 20px 54px" } }, // chrome title + quit h("div", { style: { position: "relative", textAlign: "center", marginBottom: "4px" } }, penChrome("PENALTY SHOOTOUT", isMobile ? 30 : 40), h("button", { onClick: props.onExit, title: "Quit — nothing is submitted until the last save", style: { position: "absolute", right: 0, top: "50%", transform: "translateY(-50%)", background: "rgba(199,155,242,0.08)", border: "1px solid rgba(199,155,242,0.3)", color: DG.gold, borderRadius: "9px", padding: "6px 10px", fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: "10px", letterSpacing: "1px", cursor: "pointer" } }, "✕ QUIT")), h("div", { style: { textAlign: "center", fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: isMobile ? "10px" : "11px", letterSpacing: "2.5px", color: DG.gold, marginBottom: "12px" } }, PEN_ROUNDS + " PENALTIES EACH · SUDDEN DEATH IF TIED"), // VS matchup banner (gold frame, octagonal VS badge) h("div", { style: Object.assign({}, goldPanel, { display: "flex", alignItems: "center", padding: isMobile ? "10px 12px" : "12px 18px", gap: "10px", marginBottom: "10px" }) }, h("div", { style: { display: "flex", alignItems: "center", gap: "9px", flex: "1 1 0", minWidth: 0 } }, sideAv(props.myName || "You", isMobile ? 38 : 44, true), h("div", { style: { minWidth: 0 } }, h("div", { style: { fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: isMobile ? "13px" : "15px", color: "#FFFFFF", letterSpacing: "1px" } }, "YOU"), h("div", { style: { fontSize: isMobile ? "8px" : "9px", letterSpacing: "1.5px", color: DG.gold, fontFamily: "'Anton', 'Bebas Neue', sans-serif", textTransform: "uppercase", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" } }, props.myName || ""))), penVsBadge(isMobile ? 38 : 44), h("div", { style: { display: "flex", alignItems: "center", gap: "9px", flex: "1 1 0", minWidth: 0, justifyContent: "flex-end" } }, h("div", { style: { minWidth: 0, textAlign: "right" } }, h("div", { style: { fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: isMobile ? "13px" : "15px", color: "#FFFFFF", letterSpacing: "1px" } }, "RIVAL"), h("div", { style: { fontSize: isMobile ? "8px" : "9px", letterSpacing: "1.5px", color: DG.violet, fontFamily: "'Anton', 'Bebas Neue', sans-serif", textTransform: "uppercase", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" } }, oppName)), sideAv(oppName, isMobile ? 38 : 44, false))), // responding-match SCOREBOARD (§26.13 v5/v6): at the TOP, persistent. Opening attack // round → the full hype reveal; afterwards the LIVE TRACKER — cells light up green // (goal) / red (no goal) per round as each result reveals via the commit protocol. (props.match && !props.match.isNew && !(attacking && round === 0 && shotsDone === 0)) ? penLockedBanner(oppName, isMobile, Math.min(PEN_TOTAL, shotsDone), Math.min(PEN_TOTAL, divesDone), revealed) : ((props.match && !props.match.isNew) ? penLockedBanner(oppName, isMobile) : null), // round indicator h("div", { style: { display: "flex", alignItems: "center", justifyContent: "center", gap: "10px", marginBottom: "12px", background: DG.inset, border: "1px solid rgba(176,128,214,0.16)", borderRadius: "12px", padding: "8px 14px" } }, h("div", { style: { fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: "11px", letterSpacing: "2px", color: "rgba(245,237,216,0.75)" } }, penRoundLabel(round, verb === "PENALTY" ? "ROUND" : "SAVE")), h("div", { style: { display: "flex", gap: "6px", alignItems: "center" } }, Array.apply(null, { length: PEN_ROUNDS }).map(function(_, i) { var done = round > i, cur = round === i && round < PEN_ROUNDS; return h("div", { key: i, style: { width: cur ? "10px" : "8px", height: cur ? "10px" : "8px", borderRadius: "50%", background: done ? DG.gold : cur ? "rgba(247,176,28,0.55)" : "rgba(255,255,255,0.18)", border: cur ? "1px solid #FFC836" : "1px solid transparent", boxShadow: cur ? "0 0 10px rgba(247,176,28,0.7)" : "none", transition: "all 150ms ease" } }); }), round >= PEN_ROUNDS ? h("div", { style: { fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: "10px", letterSpacing: "1px", color: "#FF8A8A" } }, "SUDDEN DEATH") : null)), // the gold play panel: [timer | phase title | tips] + SWIPE TO SHOOT + the goal pad h("div", { style: Object.assign({}, goldPanel, { padding: isMobile ? "12px 12px 12px" : "16px 16px 16px", marginBottom: "12px" }) }, // header = ONE flex row, split to the sides (§26.13 v6.3): timer LEFT, the // SELECT-A-CORNER divider RIGHT — no dead space between them h("div", { style: { display: "flex", alignItems: "center", justifyContent: "space-between", gap: "10px", marginBottom: "10px" } }, h("div", { style: { display: "flex", alignItems: "center", gap: "7px", flexShrink: 0 } }, h("div", { style: { color: DG.gold } }, penIcon("stopwatch", isMobile ? 20 : 26, DG.gold)), h("div", null, h("div", { className: low ? "pen-timer-pulse" : "", style: { fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: isMobile ? "26px" : "34px", lineHeight: 0.95, color: low ? "#FF8A8A" : "#FFFFFF" } }, locked ? "✓" : (secs < 10 ? "0" + secs : String(secs))), h("div", { style: { fontSize: "7.5px", letterSpacing: "1.5px", color: locked ? "#7CFFB0" : "rgba(245,237,216,0.55)", fontFamily: "'Anton', 'Bebas Neue', sans-serif" } }, locked ? "LOCKED" : "SECONDS"))), h("div", { style: { textAlign: "right", minWidth: 0, fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: isMobile ? "13.5px" : "18px", letterSpacing: isMobile ? "2px" : "4px", color: "#FFFFFF", textShadow: "0 0 12px rgba(168,92,255,0.5)" } }, attacking ? "SELECT A CORNER TO SHOOT" : "SELECT A CORNER TO SAVE")), // the goal pad (select/drag a corner) + the §25.10 interstitial overlays h("div", { ref: padRef, onPointerDown: onDown, onPointerMove: onMove, onPointerUp: onUp, onPointerCancel: onUp, style: { position: "relative", touchAction: "none", cursor: "default", userSelect: "none", WebkitUserSelect: "none" } }, penGoalScene({ sel: sel, ballZone: ballZone, keeperZone: keeperZone, result: null, showTargets: !locked, isMobile: isMobile, footer: locked ? (attacking ? "SHOT LOCKED IN — GOOD LUCK!" : "DIVE LOCKED IN — GOOD LUCK!") : (attacking ? "SHOT LOCKS IN WHEN TIME ENDS" : "DIVE LOCKS IN WHEN TIME ENDS") }), // STEP 1 / STEP 2 phase card (button-dismissed) intro === "phase" ? h("div", { style: { position: "absolute", inset: 0, zIndex: 12, borderRadius: "14px", background: "rgba(7,2,14,0.72)", backdropFilter: "blur(3px)", display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", textAlign: "center", padding: "14px" } }, h("div", { style: { fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: "11px", letterSpacing: "3px", color: attacking ? DG.gold : DG.violet, marginBottom: "8px" } }, attacking ? "STEP 1 OF 2" : "STEP 2 OF 2"), h("div", { className: "pen-pop", style: { fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: isMobile ? "24px" : "32px", letterSpacing: "1.5px", lineHeight: 1.1, color: "#FFFFFF", textShadow: "0 0 18px " + (attacking ? "rgba(247,176,28,0.55)" : "rgba(168,92,255,0.6)") } }, attacking ? "TAKE YOUR 5 PENALTIES FIRST" : "YOU'RE THE GOALKEEPER NOW"), h("div", { style: { fontSize: isMobile ? "11px" : "13px", color: "rgba(245,237,216,0.85)", margin: "10px 0 16px", lineHeight: 1.5, maxWidth: "340px" } }, attacking ? ("Select a corner for each penalty — " + oppName + " will try to save them.") : ("Now select where you'll save " + oppName + "'s 5 penalties.")), h("button", { onClick: function() { setIntro(null); }, style: Object.assign({}, props.goldBtnS, { padding: "12px 30px", fontSize: "15px" }) }, attacking ? "START SHOOTING" : "START SAVING")) : null, // between-kicks "PENALTY N" announcement (auto-dismisses) intro === "round" ? h("div", { style: { position: "absolute", inset: 0, zIndex: 12, borderRadius: "14px", background: "rgba(7,2,14,0.5)", display: "flex", alignItems: "center", justifyContent: "center", pointerEvents: "none" } }, h("div", { className: "pen-pop", style: { fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: isMobile ? "28px" : "38px", letterSpacing: "2px", color: attacking ? DG.gold : DG.violet, textShadow: "0 2px 12px rgba(0,0,0,0.8), 0 0 26px " + (attacking ? "rgba(247,176,28,0.6)" : "rgba(168,92,255,0.65)") } }, penRoundLabel(round, verb))) : null, // "LOCKED IN" confirmation badge while the kick commits (locked && phase !== "done" && !intro) ? h("div", { className: "pen-pop", style: { position: "absolute", top: "7%", left: "50%", transform: "translateX(-50%)", zIndex: 11, padding: "6px 14px", borderRadius: "999px", background: "rgba(20,60,35,0.88)", border: "1.5px solid rgba(124,255,176,0.7)", fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: isMobile ? "11px" : "13px", letterSpacing: "1.5px", color: "#7CFFB0", whiteSpace: "nowrap" } }, (round < PEN_ROUNDS ? verb + " " + (round + 1) : "SUDDEN DEATH " + (round - PEN_ROUNDS + 1)) + " LOCKED IN ✓") : null), // the three tips — one horizontal row at the BOTTOM of the play box (§26.13 v6.2) h("div", { style: { display: "flex", alignItems: "center", justifyContent: "center", flexWrap: "wrap", gap: isMobile ? "6px 9px" : "8px 22px", marginTop: "11px" } }, attacking ? [tipRow("star", "Aim for the corners"), tipRow("bolt", "Outsmart the keeper"), tipRow("lock", "Lock it in to shoot")] : [tipRow("star", "Read the shooter"), tipRow("bolt", "Select a corner to dive"), tipRow("lock", "Match it to SAVE")])), // lock-in button h("button", { onClick: function() { lockIn(); }, disabled: locked || !!intro || sel == null, style: Object.assign({}, props.goldBtnS, { width: "100%", padding: "15px", fontSize: "17px", marginBottom: "12px", opacity: (locked || intro || sel == null) ? 0.4 : 1, cursor: (locked || intro || sel == null) ? "default" : "pointer" }) }, locked ? "LOCKED IN ✓" : (attacking ? "LOCK IN SHOT" : "LOCK IN DIVE")), // progress + record cards (reference bottom row) h("div", { style: { display: "grid", gridTemplateColumns: "1.4fr 1fr", gap: "10px" } }, h("div", { style: Object.assign({}, goldPanel, { border: "1px solid rgba(176,128,214,0.22)", padding: isMobile ? "10px 12px" : "12px 14px" }) }, h("div", { style: { fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: "10px", letterSpacing: "2px", color: DG.gold, marginBottom: "8px" } }, "SHOOTOUT PROGRESS"), progRow("SHOTS", shotsDone, attacking), progRow("DIVES", divesDone, !attacking)), h("div", { style: Object.assign({}, goldPanel, { border: "1px solid rgba(176,128,214,0.22)", padding: isMobile ? "10px 12px" : "12px 14px", textAlign: "center" }) }, h("div", { style: { display: "flex", alignItems: "center", justifyContent: "center", gap: "5px", fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: "10px", letterSpacing: "2px", color: DG.gold, marginBottom: "7px" } }, dIcon("trophy", 12), "RECORD"), h("div", { style: { fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: isMobile ? "20px" : "24px", color: "#FFFFFF", letterSpacing: "1px", lineHeight: 1 } }, st.wins + " - " + st.losses + " - " + st.draws), h("div", { style: { fontSize: "7.5px", letterSpacing: "1.5px", color: "rgba(245,237,216,0.5)", marginTop: "5px" } }, "WINS - LOSSES - DRAWS")))); } // ── animated penalty-by-penalty replay (summary). Steps through every taken penalty, // ball flies to the shot zone, keeper dives to the guessed zone, GOAL!/SAVED!. ── function PenaltyReplay(props) { var isMobile = props.isMobile, DG = props.DG, mySide = props.mySide, oppName = props.oppName || "RIVAL"; var rounds = props.rounds || []; var steps = []; rounds.forEach(function(rd) { steps.push({ taker: "c", shot: rd.cShot, dive: rd.oDive, goal: rd.cGoal, sd: rd.sd }); steps.push({ taker: "o", shot: rd.oShot, dive: rd.cDive, goal: rd.oGoal, sd: rd.sd }); }); var _si = useState(0), idx = _si[0], setIdx = _si[1]; var _sp = useState("wind"), sp = _sp[0], setSp = _sp[1]; var mounted = useRef(true); useEffect(function() { mounted.current = true; return function() { mounted.current = false; }; }, []); useEffect(function() { if (idx >= steps.length) { return; } setSp("wind"); var t1 = setTimeout(function() { if (mounted.current) setSp("fly"); }, 280); var t2 = setTimeout(function() { if (mounted.current) setSp("result"); }, 840); var t3 = setTimeout(function() { if (!mounted.current) return; if (idx + 1 >= steps.length) props.onDone(); else setIdx(idx + 1); }, 1850); return function() { clearTimeout(t1); clearTimeout(t2); clearTimeout(t3); }; }, [idx]); var step = steps[Math.min(idx, steps.length - 1)] || { shot: 0, dive: 0, goal: 0 }; // running score up to & including the current step var my = 0, opp = 0; for (var i = 0; i <= idx && i < steps.length; i++) { var s = steps[i]; var mine = (s.taker === mySide); if (s.goal) { if (mine) my++; else opp++; } } var takerIsMe = step.taker === mySide; var ballZone = sp === "wind" ? null : step.shot, keeperZone = sp === "wind" ? null : step.dive, result = sp === "result" ? (step.goal ? "goal" : "save") : null; return h("div", { style: { position: "fixed", inset: 0, zIndex: 200, background: "rgba(7,2,14,0.92)", backdropFilter: "blur(6px)", display: "flex", alignItems: "center", justifyContent: "center", padding: "18px" } }, h("div", { style: { width: "100%", maxWidth: "520px" } }, h("div", { style: { display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: "10px" } }, h("div", { style: { fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: "15px", letterSpacing: "1px", color: takerIsMe ? DG.gold : DG.violet } }, (takerIsMe ? "YOUR PENALTY" : oppName.toUpperCase() + "'S PENALTY") + (step.sd ? " · SD" : "")), h("div", { style: { fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: "20px", color: "#F5EDD8" } }, "YOU " + my + " — " + opp + " " + (oppName.length > 7 ? "OPP" : oppName.toUpperCase()))), penGoalScene({ ballZone: ballZone, keeperZone: keeperZone, result: result, showTargets: false, isMobile: isMobile }), h("button", { onClick: props.onDone, style: { display: "block", margin: "16px auto 0", background: "rgba(199,155,242,0.08)", border: "1px solid rgba(199,155,242,0.3)", color: DG.gold, borderRadius: "10px", padding: "10px 22px", fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: "13px", letterSpacing: "1px", cursor: "pointer" } }, "SKIP ▸"))); } // ── PENALTY TEST MODE (§25.8) — deterministic sample data so a solo user can preview every // screen AND actually play a full shootout (resolved LOCALLY in JS, mirroring the SQL // resolver) with ZERO DB writes. Same read-only x*-view pattern as the old Duels §12.14. function isPenTestId(id) { return typeof id === "string" && id.indexOf("test:") === 0; } // The simulated opponent's fixed inputs (used for BOTH their shots you must save and the // dives that face your shots). Deterministic — the outcome depends only on YOUR play. var PEN_TEST_TURN = { shots: [0, 3, 1, 2, 0, 2, 1], dives: [2, 0, 3, 1, 2, 0, 3] }; // JS mirror of the SQL resolver penalty_resolve(): 5 standard rounds, then sudden-death // one round at a time (first round where exactly one scores decides), else draw. function penResolveLocal(cShots, cDives, oShots, oDives) { var rounds = [], cs = 0, os = 0, win = null, decided = false, i, cg, og; for (i = 0; i < PEN_ROUNDS; i++) { cg = oDives[i] !== cShots[i] ? 1 : 0; og = cDives[i] !== oShots[i] ? 1 : 0; cs += cg; os += og; rounds.push({ r: i, sd: false, cShot: cShots[i], cDive: cDives[i], oShot: oShots[i], oDive: oDives[i], cGoal: cg, oGoal: og }); } if (cs === os) { for (i = PEN_ROUNDS; i < PEN_TOTAL && !decided; i++) { cg = oDives[i] !== cShots[i] ? 1 : 0; og = cDives[i] !== oShots[i] ? 1 : 0; cs += cg; os += og; rounds.push({ r: i, sd: true, cShot: cShots[i], cDive: cDives[i], oShot: oShots[i], oDive: oDives[i], cGoal: cg, oGoal: og }); if (cg !== og) { decided = true; win = cg === 1 ? "c" : "o"; } } } else { win = cs > os ? "c" : "o"; } return { cs: cs, os: os, win: win, rounds: rounds }; } function buildPenTestData(uid) { return { names: { "test:rival": "Test Rival FC", "test:pixel": "Pixel United", "test:goalie": "Golden Gloves" }, matches: [ // a pre-resolved 4-2 WIN vs Test Rival FC → summary / replay / win animation demo { id: "test:res", challenger_id: uid, opponent_id: "test:rival", status: "resolved", winner_id: uid, challenger_score: 4, opponent_score: 2, created_at: "2026-06-27T12:00:00Z", resolved_at: "2026-06-28T09:30:00Z", breakdown: { std: 5, rounds: [ { r: 0, sd: false, cShot: 0, cDive: 3, oShot: 2, oDive: 1, cGoal: 1, oGoal: 1 }, { r: 1, sd: false, cShot: 3, cDive: 1, oShot: 1, oDive: 0, cGoal: 1, oGoal: 0 }, { r: 2, sd: false, cShot: 2, cDive: 2, oShot: 0, oDive: 2, cGoal: 0, oGoal: 1 }, { r: 3, sd: false, cShot: 1, cDive: 3, oShot: 3, oDive: 3, cGoal: 1, oGoal: 0 }, { r: 4, sd: false, cShot: 0, cDive: 2, oShot: 2, oDive: 2, cGoal: 1, oGoal: 0 } ] } }, // a PLAYABLE incoming challenge → tap PLAY, take the full shootout, resolved locally { id: "test:inbox", challenger_id: "test:pixel", opponent_id: uid, status: "pending", created_at: "2026-06-28T18:00:00Z" } ], open: [{ id: "test:open", challenger_id: "test:goalie", opponent_id: null, status: "pending", created_at: "2026-06-28T15:00:00Z" }], friendReqs: [{ user_id: "test:pixel", friend_id: uid, status: "pending" }], lb: [ { user_id: "test:rival", wins: 12, losses: 5, draws: 2, played: 19, points: 322, current_streak: 3, best_streak: 5 }, { user_id: "test:goalie", wins: 9, losses: 7, draws: 1, played: 17, points: 244, current_streak: 0, best_streak: 4 }, { user_id: "test:pixel", wins: 6, losses: 9, draws: 3, played: 18, points: 189, current_streak: 1, best_streak: 3 } ] }; } function PenaltyShootout(props) { props = props || {}; var session = props.session, requireAuth = props.requireAuth; var isMobile = useWindowSize().w < 768; var user = session && session.user; var uid = user && user.id; var _tab = useState("dashboard"), tab = _tab[0], setTab = _tab[1]; var _matches = useState([]), myMatches = _matches[0], setMyMatches = _matches[1]; var _open = useState([]), openChallenges = _open[0], setOpenChallenges = _open[1]; var _fs = useState([]), friendships = _fs[0], setFriendships = _fs[1]; var _pmap = useState({}), profileMap = _pmap[0], setProfileMap = _pmap[1]; var _prof = useState(null), profile = _prof[0], setProfile = _prof[1]; var _stats = useState(null), myStats = _stats[0], setMyStats = _stats[1]; var _lb = useState([]), lbRows = _lb[0], setLbRows = _lb[1]; var _lbTab = useState("global"), lbTab = _lbTab[0], setLbTab = _lbTab[1]; var _play = useState(null), playMatch = _play[0], setPlayMatch = _play[1]; // {isNew,opponentId,open} or an existing match (responding) var _sum = useState(null), summaryMatch = _sum[0], setSummaryMatch = _sum[1]; var _replay = useState(false), replayOpen = _replay[0], setReplayOpen = _replay[1]; var _chOpen = useState(false), challengeOpen = _chOpen[0], setChallengeOpen = _chOpen[1]; var _chTab = useState("open"), chTab = _chTab[0], setChTab = _chTab[1]; var _inOpen = useState(false), inboxOpen = _inOpen[0], setInboxOpen = _inOpen[1]; var _howOpen = useState(false), howOpen = _howOpen[0], setHowOpen = _howOpen[1]; var _uq = useState(""), userQuery = _uq[0], setUserQuery = _uq[1]; var _ures = useState([]), userResults = _ures[0], setUserResults = _ures[1]; var _busy = useState(false), busy = _busy[0], setBusy = _busy[1]; var _toast = useState(""), toast = _toast[0], setToast = _toast[1]; // §25.8 TEST MODE — persisted toggle + the shootouts played locally against the simulated // opponent this session ({origTestMatchId → resolved match}, cleared on toggle change). var _testMode = useState(function() { try { return TEST_MODES_ENABLED && localStorage.getItem("pen_testmode") === "1"; } catch (e) { return false; } }), testMode = _testMode[0], setTestMode = _testMode[1]; var _testPlayed = useState({}), testPlayed = _testPlayed[0], setTestPlayed = _testPlayed[1]; var _sentN = useState({}), sentNudges = _sentN[0], setSentNudges = _sentN[1]; // §25.10: friendId → nudged this session // blocking alerts (must be explicitly dismissed): fresh incoming challenges / a fresh nudge. // Watermarks live in localStorage and only advance when the popup is DISMISSED, so an alert // suppressed while in a match (the play screen is a separate return) re-surfaces after exit. var _chAlert = useState(null), chAlert = _chAlert[0], setChAlert = _chAlert[1]; var _nAlert = useState(null), nudgeAlert = _nAlert[0], setNudgeAlert = _nAlert[1]; var aliveRef = useRef(true), searchTimer = useRef(null); useEffect(function() { aliveRef.current = true; return function() { aliveRef.current = false; }; }, []); useEffect(function() { try { localStorage.setItem("pen_testmode", testMode ? "1" : "0"); } catch (e) {} setTestPlayed({}); }, [testMode]); // ── design tokens (scoped — mirrors the Duel reference palette) ────────────── var DG = { gold: "#FFC836", gold2: "#F0A91E", ink: "#1A0428", cream: "#F7EEDA", goldGrad: "linear-gradient(160deg, #FFD867 0%, #EFA518 100%)", goldGlow: "0 7px 18px rgba(247,176,28,0.28)", violet: "#C79BF2", border: "1px solid rgba(176,128,214,0.20)", panel: "linear-gradient(180deg, rgba(55,28,74,0.52) 0%, rgba(18,5,32,0.90) 100%)", panelShadow: "0 16px 40px rgba(0,0,0,0.46), inset 0 1px 0 rgba(255,255,255,0.05)", inset: "rgba(8,2,18,0.6)" }; var duelBgS = { minHeight: "100vh", background: "radial-gradient(1100px 640px at 12% -8%, rgba(132,48,176,0.26), transparent 58%)," + "radial-gradient(920px 580px at 104% -4%, rgba(84,26,134,0.28), transparent 55%)," + "radial-gradient(860px 640px at 50% 118%, rgba(110,38,150,0.16), transparent 60%)," + "linear-gradient(180deg, #120121 0%, #09010F 58%, #07010C 100%)" }; var maxW = "880px"; var pageS = { padding: isMobile ? "18px 14px 90px" : "26px 32px 60px", color: "#F5EDD8", maxWidth: maxW, margin: "0 auto", width: "100%", boxSizing: "border-box" }; var panelS = { background: DG.panel, border: DG.border, borderRadius: "18px", padding: isMobile ? "16px" : "20px", minWidth: 0, boxSizing: "border-box", boxShadow: DG.panelShadow }; var panelTitleS = { fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: "13px", letterSpacing: "3px", color: DG.gold, marginBottom: "14px", display: "flex", alignItems: "center", gap: "8px" }; var goldBtnS = { fontFamily: "'Anton', 'Bebas Neue', sans-serif", letterSpacing: "2px", border: "none", borderRadius: "11px", cursor: "pointer", background: DG.goldGrad, color: DG.ink, boxShadow: DG.goldGlow }; var ghostBtnS = { fontFamily: "'Anton', 'Bebas Neue', sans-serif", letterSpacing: "2px", borderRadius: "11px", cursor: "pointer", background: "rgba(199,155,242,0.07)", color: DG.gold, border: "1px solid rgba(199,155,242,0.30)" }; var flash = function(msg, ms) { setToast(msg); setTimeout(function() { if (aliveRef.current) setToast(""); }, ms || 2400); }; var avatarFor = function(name, size, gold) { var ring = gold ? "linear-gradient(160deg, #FFDE7A, #F4AE1C)" : "linear-gradient(160deg, rgba(199,155,242,0.85), rgba(120,60,180,0.5))"; return h("div", { style: { width: size + "px", height: size + "px", borderRadius: "50%", flexShrink: 0, padding: "2px", background: ring, boxShadow: gold ? "0 4px 12px rgba(247,176,28,0.32)" : "0 4px 12px rgba(150,80,200,0.26)" } }, h("div", { style: { width: "100%", height: "100%", borderRadius: "50%", background: gold ? "linear-gradient(160deg, #FFD86B, #E89C12)" : "linear-gradient(160deg, #3a1f54, #1d0c30)", color: gold ? DG.ink : DG.cream, display: "flex", alignItems: "center", justifyContent: "center", fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: Math.round(size * 0.42) + "px", lineHeight: 1 } }, (name || "?").charAt(0).toUpperCase())); }; var modalShell = function(title, onClose, body, maxw) { return h("div", { style: { position: "fixed", inset: 0, background: "rgba(8,1,15,0.72)", backdropFilter: "blur(6px)", display: "flex", alignItems: "center", justifyContent: "center", zIndex: 130, padding: "18px" }, onClick: onClose }, h("div", { style: { width: "min(" + (maxw || "440px") + ", 100%)", maxHeight: "86vh", display: "flex", flexDirection: "column", background: "linear-gradient(180deg, #2a1140 0%, #130325 100%)", border: "1px solid rgba(199,155,242,0.30)", borderRadius: "18px", boxShadow: "0 30px 80px rgba(0,0,0,0.7)", overflow: "hidden" }, onClick: function(e) { e.stopPropagation(); } }, h("div", { style: { padding: "16px 20px", borderBottom: "1px solid rgba(199,155,242,0.18)", display: "flex", alignItems: "center", justifyContent: "space-between", flexShrink: 0 } }, h("div", { style: { fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: "22px", letterSpacing: "1.5px", color: "#F5EDD8" } }, title), h("button", { onClick: onClose, style: { width: "30px", height: "30px", borderRadius: "50%", background: "rgba(255,255,255,0.06)", border: "1px solid rgba(255,255,255,0.1)", color: "#F5EDD8", cursor: "pointer", fontSize: "16px", lineHeight: 1 } }, "×")), h("div", { style: { padding: "18px 20px", overflowY: "auto", minHeight: 0 } }, body))); }; // ── derived social ─────────────────────────────────────────────────────────── var testData = (testMode && uid) ? buildPenTestData(uid) : null; // §25.8 — null when off → x* views are exactly the real data var friendIds = []; friendships.forEach(function(f) { if (f.status === "accepted") { var o = f.user_id === uid ? f.friend_id : f.user_id; if (o && friendIds.indexOf(o) < 0) friendIds.push(o); } }); var friendReqs = friendships.filter(function(f) { return f.status === "pending" && f.friend_id === uid; }); var xFriendReqs = testData ? testData.friendReqs.concat(friendReqs) : friendReqs; var nameFor = function(id) { if (!id) return "Open"; if (testData && testData.names[id]) return testData.names[id]; if (id === uid) return (profile && profile.team_name) || "You"; var p = profileMap[id]; return (p && p.team_name) || "Player"; }; var oppOf = function(m) { return m.challenger_id === uid ? m.opponent_id : m.challenger_id; }; var hasActiveMatchWith = function(other) { return myMatches.some(function(m) { return m.status === "pending" && oppOf(m) === other; }); }; var canCreateWith = function(other) { return friendIds.indexOf(other) >= 0 || !hasActiveMatchWith(other); }; var myOpen = myMatches.filter(function(m) { return m.status === "pending" && m.challenger_id === uid && !m.opponent_id; })[0]; // ── loaders ──────────────────────────────────────────────────────────────── var fetchProfiles = function(ids) { var need = (ids || []).filter(function(id) { return id && !profileMap[id]; }); if (!need.length || !sb) return; sb.from("profiles").select("user_id,team_name,country,state,city").in("user_id", need).then(function(res) { if (!aliveRef.current || !res.data || !res.data.length) return; setProfileMap(function(prev) { var n = Object.assign({}, prev); res.data.forEach(function(p) { n[p.user_id] = p; }); return n; }); }, function(e) { console.warn("pen fetchProfiles", e && e.message); }); }; var loadAll = function() { if (!sb || !uid) return; sb.from("penalty_matches").select("*").or("challenger_id.eq." + uid + ",opponent_id.eq." + uid).order("created_at", { ascending: false }).then(function(res) { if (!aliveRef.current) return; var mine = (res.data || []).filter(function(m) { return m.challenger_id === uid || m.opponent_id === uid; }); setMyMatches(mine); var ids = {}; mine.forEach(function(m) { var o = oppOf(m); if (o) ids[o] = 1; }); fetchProfiles(Object.keys(ids)); // blocking challenge popup: any incoming pending challenge newer than the dismissal watermark var seenChMs = 0; try { seenChMs = parseInt(localStorage.getItem("pen_alert_seen:" + uid) || "0", 10) || 0; } catch (e) {} var freshCh = mine.filter(function(m) { return m.status === "pending" && m.opponent_id === uid && new Date(m.created_at).getTime() > seenChMs; }); setChAlert(freshCh.length ? freshCh : null); }, function() {}); sb.from("penalty_matches").select("*").is("opponent_id", null).eq("status", "pending").neq("challenger_id", uid).order("created_at", { ascending: false }).limit(50).then(function(res) { if (!aliveRef.current) return; setOpenChallenges(res.data || []); fetchProfiles((res.data || []).map(function(m) { return m.challenger_id; })); }, function() {}); sb.from("friendships").select("*").or("user_id.eq." + uid + ",friend_id.eq." + uid).then(function(res) { if (!aliveRef.current) return; setFriendships(res.data || []); var ids = {}; (res.data || []).forEach(function(f) { ids[f.user_id] = 1; ids[f.friend_id] = 1; }); fetchProfiles(Object.keys(ids)); }, function() {}); sb.from("penalty_stats").select("*").eq("user_id", uid).limit(1).then(function(res) { if (aliveRef.current) setMyStats((res.data || [])[0] || null); }, function() {}); // §25.10 friend nudges — surface unseen incoming nudges as a BLOCKING popup (seen-marker // = the newest nudge's SERVER created_at, persisted per user in localStorage; it only // advances when the popup is dismissed, so a nudge suppressed mid-match re-surfaces) sb.from("penalty_nudges").select("*").eq("to_id", uid).order("created_at", { ascending: false }).limit(10).then(function(res) { if (!aliveRef.current || !res.data || !res.data.length) return; var seenKey = "pen_nudge_seen:" + uid, seen = 0; try { seen = parseInt(localStorage.getItem(seenKey) || "0", 10) || 0; } catch (e) {} var nowMs = Date.now(); // event plumbing, not render logic (§2) — bounds the "new" window var fresh = res.data.filter(function(n) { var t = new Date(n.created_at).getTime(); return t > seen && (nowMs - t) < 3 * 86400000; }); if (!fresh.length) return; var newest = new Date(res.data[0].created_at).getTime(); sb.from("profiles").select("team_name").eq("user_id", fresh[0].from_id).limit(1).then(function(pr) { if (!aliveRef.current) return; var nm = (pr.data && pr.data[0] && pr.data[0].team_name) || "A rival"; setNudgeAlert({ name: nm, count: fresh.length, newest: newest }); }, function() {}); }, function() {}); }; var loadLeaderboard = function() { if (!sb) return; sb.from("penalty_stats").select("*").order("wins", { ascending: false }).order("points", { ascending: false }).limit(100).then(function(res) { if (!aliveRef.current) return; setLbRows(res.data || []); fetchProfiles((res.data || []).map(function(r) { return r.user_id; })); }, function() {}); }; // bootstrap profile + first load useEffect(function() { if (!sb || !session || !uid) { setProfile(null); setMyMatches([]); setMyStats(null); return; } sb.from("profiles").select("*").eq("user_id", uid).limit(1).then(function(res) { if (!aliveRef.current) return; var pr = (res.data || [])[0]; if (pr) { setProfile(pr); return; } var def = { user_id: uid, team_name: ((user.email || "player").split("@")[0]) }; sb.from("profiles").upsert(def, { onConflict: "user_id", ignoreDuplicates: true }).select().then(function(r) { if (aliveRef.current) setProfile((r.data && r.data[0]) || def); }, function() { if (aliveRef.current) setProfile(def); }); }, function() {}); loadAll(); }, [uid]); // poll for incoming challenges/results useEffect(function() { if (!uid) return; var t = setInterval(function() { loadAll(); }, 20000); return function() { clearInterval(t); }; }, [uid]); // load the leaderboard when its tab opens useEffect(function() { if (tab === "leaderboard" && uid) loadLeaderboard(); }, [tab, uid]); // §26.13 v6 — before mounting the play screen for a REAL responding match, load any // per-round moves committed in an earlier session (quit → resume: committed rounds are // BINDING server-side) and re-reveal them so the tracker's goal/no-goal cells restore. // Fresh matches get an empty _resume — it just marks the screen ready to mount. useEffect(function() { if (!playMatch || playMatch.isNew || isPenTestId(playMatch.id) || playMatch._resume || !sb || !uid) return; var alive = true, mid = playMatch.id; sb.from("penalty_moves").select("round,kind,zone").eq("match_id", mid).eq("user_id", uid).order("round").then(function(r) { if (!alive) return; var rows = r.data || [], shots = [], dives = [], i, m; for (i = 0; i < PEN_TOTAL; i++) { m = rows.filter(function(x) { return x.kind === "shot" && x.round === i; })[0]; if (m) shots.push(m.zone); else break; } for (i = 0; i < PEN_TOTAL; i++) { m = rows.filter(function(x) { return x.kind === "dive" && x.round === i; })[0]; if (m) dives.push(m.zone); else break; } var ph = shots.length >= PEN_TOTAL ? "defend" : "attack"; if (ph === "defend" && dives.length >= PEN_TOTAL) dives = dives.slice(0, PEN_TOTAL - 1); var rd = ph === "attack" ? shots.length : dives.length; var jobs = []; shots.forEach(function(z, ri) { if (ri < PEN_TOTAL) jobs.push(sb.rpc("penalty_reveal_move", { p_match: mid, p_round: ri, p_kind: "dive" }).then(function(q) { return ["s" + ri, q.data != null ? (q.data !== z) : null]; }, function() { return [null, null]; })); }); dives.forEach(function(z, ri) { if (ri < PEN_TOTAL) jobs.push(sb.rpc("penalty_reveal_move", { p_match: mid, p_round: ri, p_kind: "shot" }).then(function(q) { return ["d" + ri, q.data != null ? (q.data !== z) : null]; }, function() { return [null, null]; })); }); Promise.all(jobs).then(function(pairs) { if (!alive) return; var rev = {}; pairs.forEach(function(p) { if (p[0] && p[1] != null) rev[p[0]] = p[1]; }); setPlayMatch(function(prev) { return (prev && prev.id === mid && !prev._resume) ? Object.assign({}, prev, { _resume: { phase: ph, round: rd, shots: shots, dives: dives, revealed: rev } }) : prev; }); }); }, function() { if (alive) setPlayMatch(function(prev) { return (prev && prev.id === mid && !prev._resume) ? Object.assign({}, prev, { _resume: { phase: "attack", round: 0, shots: [], dives: [], revealed: {} } }) : prev; }); }); return function() { alive = false; }; }, [playMatch && playMatch.id]); // §26.13 v6 — per-round commit + reveal: called by PenaltyPlay the moment a kick/dive // commits. Real responding matches insert the move (binding) then ask the server for the // opponent's already-committed corner for that round; test matches resolve locally. var revealMove = function(target, kind, r, z) { var isTest = isPenTestId(target.id) || (target.isNew && target.opponentId && isPenTestId(target.opponentId)); if (isTest) return Promise.resolve({ opp: kind === "shot" ? PEN_TEST_TURN.dives[r] : PEN_TEST_TURN.shots[r], committed: null }); if (target.isNew || !sb || !uid) return Promise.resolve(null); // challenger plays blind — nothing to reveal yet var mid = target.id; return sb.from("penalty_moves").insert({ match_id: mid, user_id: uid, round: r, kind: kind, zone: z }).then(function(ins) { var committedP = ins.error ? sb.from("penalty_moves").select("zone").eq("match_id", mid).eq("user_id", uid).eq("round", r).eq("kind", kind).limit(1).then(function(q) { return (q.data && q.data[0]) ? q.data[0].zone : null; }, function() { return null; }) : Promise.resolve(null); return committedP.then(function(cz) { return sb.rpc("penalty_reveal_move", { p_match: mid, p_round: r, p_kind: kind === "shot" ? "dive" : "shot" }).then(function(rv) { return { opp: (rv && rv.data != null) ? rv.data : null, committed: cz }; }, function() { return { opp: null, committed: cz }; }); }); }, function() { return null; }); }; // ── handlers ───────────────────────────────────────────────────────────────── var submitTurn = function(target, shots, dives) { // §25.8 TEST MODE — a test match resolves LOCALLY (JS mirror of the SQL resolver), zero DB writes. // Playing a test challenge → I'm the OPPONENT (the simulated rival challenged me); // a test REMATCH (isNew vs a test: opponent) → I'm the CHALLENGER. if (target && (isPenTestId(target.id) || (target.isNew && isPenTestId(target.opponentId)))) { var meCh = !!target.isNew; var res = meCh ? penResolveLocal(shots, dives, PEN_TEST_TURN.shots, PEN_TEST_TURN.dives) : penResolveLocal(PEN_TEST_TURN.shots, PEN_TEST_TURN.dives, shots, dives); var chId = meCh ? uid : target.challenger_id, opId = meCh ? target.opponentId : uid; var key = meCh ? "test:rematch" : target.id; var tm = { id: key + ":done", challenger_id: chId, opponent_id: opId, status: "resolved", winner_id: res.win === "c" ? chId : res.win === "o" ? opId : null, challenger_score: res.cs, opponent_score: res.os, breakdown: { std: PEN_ROUNDS, rounds: res.rounds }, created_at: "2026-06-29T10:00:00Z", resolved_at: "2026-06-29T10:00:00Z" }; setTestPlayed(function(p) { var n = Object.assign({}, p); n[key] = tm; return n; }); setPlayMatch(null); setReplayOpen(false); setSummaryMatch(tm); flash("TEST MATCH — nothing saved"); return; } if (busy) return; setBusy(true); if (target && target.isNew) { var row = { challenger_id: uid, opponent_id: target.opponentId || null, status: "pending" }; if (target.rematchOf) row.rematch_of = target.rematchOf; sb.from("penalty_matches").insert(row).select().then(function(res) { var m = res.data && res.data[0]; if (!m) { setBusy(false); flash("Couldn't create the match."); return; } sb.from("penalty_turns").insert({ match_id: m.id, user_id: uid, shots: shots, dives: dives }).select().then(function() { if (!aliveRef.current) return; setBusy(false); setPlayMatch(null); flash(target.open ? "✓ OPEN CHALLENGE POSTED" : "✓ CHALLENGE SENT to " + nameFor(target.opponentId).toUpperCase()); loadAll(); }, function(e) { setBusy(false); flash("Couldn't submit — " + ((e && e.message) || "error")); }); }, function(e) { setBusy(false); flash("Couldn't create — " + ((e && e.message) || "error")); }); } else { var mid = target.id; sb.from("penalty_turns").insert({ match_id: mid, user_id: uid, shots: shots, dives: dives }).select().then(function(ri) { // supabase resolves (not rejects) postgrest errors — surface the §26.13 v6 // committed-moves mismatch guard (should never hit honest resume-restored play) if (ri && ri.error) { setBusy(false); flash("Couldn't submit — " + (ri.error.message || "error")); return; } sb.from("penalty_matches").select("*").eq("id", mid).limit(1).then(function(r2) { if (!aliveRef.current) return; setBusy(false); setPlayMatch(null); var m = (r2.data || [])[0]; if (m && m.status === "resolved") { setSummaryMatch(m); setReplayOpen(false); } else flash("Submitted — result pending."); loadAll(); }, function() { setBusy(false); setPlayMatch(null); loadAll(); }); }, function(e) { setBusy(false); flash("Couldn't submit — " + ((e && e.message) || "error")); }); } }; var startChallenge = function(opponentId, rematchOf) { if (isPenTestId(opponentId)) { setChallengeOpen(false); setInboxOpen(false); setSummaryMatch(null); setPlayMatch({ isNew: true, opponentId: opponentId, open: false }); return; } // §25.8: test rematch → play locally if (opponentId && !canCreateWith(opponentId)) { flash("You already have a live match with " + nameFor(opponentId) + " — add them as a friend to play again."); return; } setChallengeOpen(false); setInboxOpen(false); setPlayMatch({ isNew: true, opponentId: opponentId || null, open: !opponentId, rematchOf: rematchOf || null }); }; var createOpenChallenge = function() { if (myOpen) { flash("Your open challenge is already live."); setChTab("open"); return; } setChallengeOpen(false); setPlayMatch({ isNew: true, opponentId: null, open: true }); }; var acceptOpenChallenge = function(m) { if (isPenTestId(m.id)) { setChallengeOpen(false); setPlayMatch(Object.assign({}, m, { opponent_id: uid })); return; } // §25.8: test open challenge → play locally if (!canCreateWith(m.challenger_id)) { flash("You already have a live match with " + nameFor(m.challenger_id) + "."); return; } if (busy) return; setBusy(true); sb.from("penalty_matches").update({ opponent_id: uid }).eq("id", m.id).is("opponent_id", null).select().then(function(r) { if (!aliveRef.current) return; setBusy(false); if (r.data && r.data.length) { setChallengeOpen(false); setPlayMatch(Object.assign({}, r.data[0])); } else { flash("That challenge was just taken."); loadAll(); } }, function(e) { setBusy(false); flash("Couldn't accept — " + ((e && e.message) || "error")); }); }; // §25.10 — nudge a friend (lands as an in-app toast on their next poll) + share an invite externally var sendNudge = function(friendId) { if (isPenTestId(friendId)) { flash("Test mode — sample data"); return; } if (sentNudges[friendId]) { flash("Already nudged — they'll see it next time they open the app."); return; } sb.from("penalty_nudges").insert({ from_id: uid, to_id: friendId, match_id: myOpen ? myOpen.id : null }).then(function(res) { if (!aliveRef.current) return; if (res.error) { flash("Couldn't nudge — " + res.error.message); return; } setSentNudges(function(p) { var n = Object.assign({}, p); n[friendId] = true; return n; }); flash("✓ NUDGE SENT to " + nameFor(friendId).toUpperCase() + " — they'll get a shout in the app"); }, function() {}); }; // blocking-popup dismissals — advancing the watermark here (not at fetch time) is what // makes a popup suppressed mid-match re-surface after the match exits var dismissChAlert = function() { var mx = 0; (chAlert || []).forEach(function(m) { var t = new Date(m.created_at).getTime(); if (t > mx) mx = t; }); try { if (mx) localStorage.setItem("pen_alert_seen:" + uid, String(mx)); } catch (e) {} setChAlert(null); }; var dismissNudgeAlert = function() { try { if (nudgeAlert && nudgeAlert.newest) localStorage.setItem("pen_nudge_seen:" + uid, String(nudgeAlert.newest)); } catch (e) {} setNudgeAlert(null); }; var shareInvite = function() { var txt = "I've posted an open Penalty Shootout challenge on HOME TEAM — accept it before someone else does! https://home-team-9e7387.webflow.io/lineup-builder?tab=penalty"; if (navigator.share) { navigator.share({ text: txt }).catch(function() {}); } else { try { navigator.clipboard.writeText(txt); flash("✓ INVITE COPIED — paste it to your friends"); } catch (e) { flash(txt, 5000); } } }; var declineMatch = function(m) { if (isPenTestId(m.id)) { flash("Test mode — sample data"); return; } sb.from("penalty_matches").update({ status: "declined" }).eq("id", m.id).then(function() { loadAll(); }, function() {}); }; var cancelMatch = function(m) { if (isPenTestId(m.id)) { flash("Test mode — sample data"); return; } sb.from("penalty_matches").update({ status: "cancelled" }).eq("id", m.id).then(function() { loadAll(); }, function() {}); }; var runSearch = function(q) { setUserQuery(q); if (searchTimer.current) clearTimeout(searchTimer.current); if (!q || q.trim().length < 2) { setUserResults([]); return; } searchTimer.current = setTimeout(function() { sb.from("profiles").select("user_id,team_name,country,city").ilike("team_name", "%" + q.trim() + "%").limit(20).then(function(res) { if (!aliveRef.current) return; setUserResults((res.data || []).filter(function(p) { return p.user_id !== uid; })); }, function() {}); }, 280); }; var addFriend = function(id) { if (isPenTestId(id)) { flash("Test mode — sample data"); return; } var incoming = friendships.filter(function(f) { return f.user_id === id && f.friend_id === uid && f.status !== "accepted"; })[0]; if (incoming) { acceptFriend(id); return; } sb.from("friendships").upsert({ user_id: uid, friend_id: id, status: "pending" }, { onConflict: "user_id,friend_id", ignoreDuplicates: true }).then(function() { flash("Friend request sent"); loadAll(); }, function() {}); }; var acceptFriend = function(other) { if (isPenTestId(other)) { flash("Test mode — sample data"); return; } sb.from("friendships").update({ status: "accepted" }).eq("user_id", other).eq("friend_id", uid).then(function() { loadAll(); }, function() {}); }; var declineFriend = function(other) { if (isPenTestId(other)) { flash("Test mode — sample data"); return; } sb.from("friendships").delete().eq("user_id", other).eq("friend_id", uid).then(function() { loadAll(); }, function() {}); }; // ── match perspective helpers ───────────────────────────────────────────────── var sideOf = function(m) { return m.challenger_id === uid ? "c" : "o"; }; var myScoreOf = function(m) { return sideOf(m) === "c" ? m.challenger_score : m.opponent_score; }; var oppScoreOf = function(m) { return sideOf(m) === "c" ? m.opponent_score : m.challenger_score; }; var outcomeOf = function(m) { if (m.status !== "resolved") return null; if (m.winner_id == null) return "draw"; return m.winner_id === uid ? "win" : "loss"; }; // categorise my matches for the dashboard. §25.8: with TEST MODE on, the sample matches are // merged in READ-ONLY (a locally-played test match replaces its pending original; a test // rematch is prepended). Toggle off → testData null → these are exactly the real values. var xMatches = myMatches; if (testData) { xMatches = testData.matches.map(function(m) { return testPlayed[m.id] || m; }); if (testPlayed["test:rematch"]) xMatches = [testPlayed["test:rematch"]].concat(xMatches); xMatches = xMatches.concat(myMatches); } var xOpen = testData ? testData.open.concat(openChallenges) : openChallenges; var incoming = xMatches.filter(function(m) { return m.status === "pending" && m.opponent_id === uid; }); var waiting = xMatches.filter(function(m) { return m.status === "pending" && m.challenger_id === uid; }); var results = xMatches.filter(function(m) { return m.status === "resolved"; }); var unread = incoming.length + xFriendReqs.length; // ── login gate ───────────────────────────────────────────────────────────── if (!user) { // top-aligned (was vertically centered — big empty bands above/below on tall screens), // with the same SHOOTOUT hero banner the signed-in dashboard shows (tap → sign in) return h("div", { style: Object.assign({}, duelBgS, { display: "flex", alignItems: "flex-start", justifyContent: "center", padding: isMobile ? "12px 14px 40px" : "20px 18px 48px" }) }, h("div", { style: { width: "100%", maxWidth: "520px" } }, h("div", { style: Object.assign({}, panelS, { background: "linear-gradient(115deg, #320b50 0%, #401059 45%, #5a1780 100%)", border: "1px solid rgba(199,155,242,0.24)", overflow: "hidden", position: "relative", padding: isMobile ? "22px 18px" : "30px 28px" }) }, h("div", { style: { position: "absolute", inset: 0, background: "radial-gradient(420px 280px at 86% 24%, rgba(247,176,28,0.14), transparent 60%)", pointerEvents: "none" } }), h("div", { style: { position: "relative", zIndex: 2 } }, h("div", { style: { fontSize: "12px", letterSpacing: "2px", color: DG.gold, fontFamily: "'Anton', 'Bebas Neue', sans-serif", margin: "0 0 14px" } }, PEN_ROUNDS + " PENALTIES EACH · SUDDEN DEATH IF TIED"), // the SHOOTOUT hero banner sits where the goal scene used to be (client: one image, inside the card) h("img", { src: PEN_BANNER_IMG, alt: "Penalty Shootout", onClick: function() { if (requireAuth) requireAuth(); }, style: { cursor: "pointer", width: "100%", height: "auto", display: "block", borderRadius: "14px", marginBottom: "16px", boxShadow: "0 12px 30px rgba(0,0,0,0.45)" } }), h("div", { style: { fontSize: "13px", color: "rgba(245,237,216,0.8)", marginBottom: "16px", lineHeight: 1.5 } }, "Challenge friends to a 1-v-1 shootout. Pick your corners to score, dive to save, climb the leaderboard."), h("button", { onClick: function() { if (requireAuth) requireAuth(); }, style: Object.assign({}, goldBtnS, { padding: "13px 26px", fontSize: "16px" }) }, "SIGN IN TO PLAY"))))); } // ── sub-nav ──────────────────────────────────────────────────────────────── var navItem = function(id, label, icon, onAction) { var active = !onAction && tab === id; return h("button", { key: id, onClick: onAction || function() { setTab(id); }, style: { display: "flex", alignItems: "center", gap: "6px", padding: isMobile ? "10px 2px" : "12px 4px", flex: isMobile ? 1 : "0 0 auto", marginRight: isMobile ? 0 : "26px", justifyContent: "center", background: "transparent", border: "none", cursor: "pointer", borderBottom: active ? "2px solid " + DG.gold : "2px solid transparent", color: active ? DG.gold : "#F5EDD8", fontFamily: "'Anton', 'Bebas Neue', sans-serif", textShadow: active ? "0 0 14px rgba(247,176,28,0.5)" : "none", fontSize: isMobile ? "11px" : "14px", letterSpacing: isMobile ? "0.5px" : "1.5px", whiteSpace: "nowrap", position: "relative" } }, isMobile ? null : dIcon(icon, 15), label, (id === "inbox" && unread > 0) ? h("span", { style: { background: "#E2353C", color: "#fff", fontSize: "9px", borderRadius: "999px", padding: "1px 5px", marginLeft: "2px", fontFamily: "Inter, sans-serif" } }, unread) : null); }; var subNav = h("div", { style: { borderBottom: "1px solid rgba(176,128,214,0.16)", background: "rgba(8,1,15,0.55)", backdropFilter: "blur(8px)", position: "sticky", top: isMobile ? "96px" : "72px", zIndex: 30 } }, h("div", { style: { maxWidth: maxW, margin: "0 auto", padding: isMobile ? "0 10px" : "0 32px", display: "flex", justifyContent: isMobile ? "space-between" : "flex-start" } }, navItem("dashboard", "PLAY", "target"), navItem("leaderboard", "LEADERBOARD", "trophy"), navItem("history", "HISTORY", "clock"), navItem("inbox", "INBOX", "swords", function() { setInboxOpen(true); }), navItem("how", "RULES", "shield", function() { setHowOpen(true); }))); // ── record card ────────────────────────────────────────────────────────────── var st = myStats || { wins: 0, losses: 0, draws: 0, played: 0, points: 0, current_streak: 0, best_streak: 0 }; var winPct = st.played ? Math.round(100 * st.wins / st.played) : 0; var statCell = function(label, val, gold) { return h("div", { style: { flex: 1, textAlign: "center", padding: "4px 0" } }, h("div", { style: { fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: isMobile ? "22px" : "26px", color: gold ? DG.gold : "#F5EDD8", lineHeight: 1 } }, val), h("div", { style: { fontSize: "9px", letterSpacing: "1.5px", color: "rgba(245,237,216,0.5)", marginTop: "5px", textTransform: "uppercase" } }, label)); }; var divider = h("div", { style: { width: "1px", alignSelf: "stretch", background: "rgba(176,128,214,0.18)" } }); var statusChip = function(label, color) { return h("span", { style: { fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: "10px", letterSpacing: "1px", padding: "3px 9px", borderRadius: "999px", background: "rgba(8,2,18,0.6)", border: "1px solid " + color, color: color, whiteSpace: "nowrap" } }, label); }; var matchRow = function(m, kind) { var oid = oppOf(m); var oc = outcomeOf(m); var chip = oc === "win" ? statusChip("WON " + myScoreOf(m) + "-" + oppScoreOf(m), "#7CFFB0") : oc === "loss" ? statusChip("LOST " + myScoreOf(m) + "-" + oppScoreOf(m), "#FF8A8A") : oc === "draw" ? statusChip("DREW " + myScoreOf(m) + "-" + oppScoreOf(m), DG.violet) : kind === "incoming" ? statusChip("YOUR TURN", DG.gold) : (!m.opponent_id ? statusChip("OPEN", DG.violet) : statusChip("WAITING", "rgba(245,237,216,0.6)")); var clickable = oc != null || kind === "incoming"; var go = oc != null ? function() { setSummaryMatch(m); setReplayOpen(false); } : kind === "incoming" ? function() { setPlayMatch(Object.assign({}, m)); } : null; return h("div", { key: m.id, onClick: go || undefined, style: { display: "flex", alignItems: "center", gap: "11px", padding: "11px 12px", borderRadius: "12px", background: DG.inset, border: "1px solid rgba(176,128,214,0.12)", marginBottom: "8px", cursor: clickable ? "pointer" : "default" } }, avatarFor(nameFor(oid), 36, oc === "win"), h("div", { style: { flex: 1, minWidth: 0 } }, h("div", { style: { fontSize: "14px", color: "#F5EDD8", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" } }, oid ? ("vs " + nameFor(oid)) : "Open challenge"), // a pending match ALWAYS carries the challenger's submitted turn (created together in // submitTurn), so an incoming row can unconditionally say theirs are locked in kind === "incoming" ? h("div", { style: { fontSize: "10px", color: "#C79BF2", display: "flex", alignItems: "center", gap: "4px" } }, penIcon("lock", 9, "#C79BF2"), nameFor(oid) + " has LOCKED IN their penalties — your turn") : h("div", { style: { fontSize: "10px", color: oc != null ? "rgba(255,216,103,0.8)" : "rgba(245,237,216,0.45)" } }, oc != null ? ("+" + (oc === "win" ? 25 : oc === "draw" ? 10 : 1) + " point" + (oc === "loss" ? "" : "s") + " earned — tap for the summary") : (!m.opponent_id ? "Live for anyone to accept" : "Awaiting their penalties — they'll see your shots are locked in"))), chip, kind === "incoming" ? h("button", { onClick: function(e) { e.stopPropagation(); declineMatch(m); }, style: { background: "transparent", border: "1px solid rgba(255,138,138,0.4)", color: "#FF8A8A", borderRadius: "8px", padding: "5px 9px", fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: "10px", letterSpacing: "1px", cursor: "pointer" } }, "DECLINE") : null, (kind === "waiting" && !m.opponent_id && !isPenTestId(m.id)) ? h("button", { onClick: function(e) { e.stopPropagation(); setChTab("open"); setChallengeOpen(true); }, style: { background: "rgba(247,176,28,0.12)", border: "1px solid rgba(255,200,54,0.5)", color: DG.gold, borderRadius: "8px", padding: "5px 9px", fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: "10px", letterSpacing: "1px", cursor: "pointer" } }, "NUDGE") : null, (kind === "waiting") ? h("button", { onClick: function(e) { e.stopPropagation(); cancelMatch(m); }, style: { background: "transparent", border: "1px solid rgba(199,155,242,0.4)", color: DG.violet, borderRadius: "8px", padding: "5px 9px", fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: "10px", letterSpacing: "1px", cursor: "pointer" } }, "CANCEL") : null); }; // ── DASHBOARD ────────────────────────────────────────────────────────────── var renderDashboard = function() { return h("div", { style: pageS }, // §25.10 — the client's banner artwork replaces the text title (feathered into the bg, §12.20 // treatment). The art has a baked-in START SHOOTOUT button → the whole banner opens FIND OPPONENT. h("div", { style: { display: "flex", justifyContent: "center", marginBottom: "10px" } }, h("img", { src: PEN_BANNER_IMG, alt: "Penalty Shootout", onClick: function() { setChTab(myOpen ? "open" : "friends"); setChallengeOpen(true); }, style: { cursor: "pointer", width: "100%", maxWidth: isMobile ? "420px" : "560px", height: "auto", display: "block", WebkitMaskImage: "linear-gradient(to right, transparent, #000 7%, #000 93%, transparent), linear-gradient(to bottom, transparent, #000 8%, #000 90%, transparent)", maskImage: "linear-gradient(to right, transparent, #000 7%, #000 93%, transparent), linear-gradient(to bottom, transparent, #000 8%, #000 90%, transparent)", WebkitMaskComposite: "source-in", maskComposite: "intersect" } })), // §25.8 TEST MODE toggle — solo preview with sample data + a locally-resolved playable match TEST_MODES_ENABLED ? h("div", { style: { display: "flex", justifyContent: "center", marginBottom: testMode ? "10px" : "16px" } }, h("button", { onClick: function() { setTestMode(!testMode); }, style: { fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: "11px", letterSpacing: "1.5px", padding: "7px 16px", borderRadius: "999px", cursor: "pointer", border: testMode ? "1px solid " + DG.gold : "1px solid rgba(199,155,242,0.3)", background: testMode ? "rgba(247,176,28,0.14)" : "rgba(199,155,242,0.06)", color: testMode ? DG.gold : "rgba(245,237,216,0.6)" } }, "TEST MODE: " + (testMode ? "ON" : "OFF"))) : null, testMode ? h("div", { style: { textAlign: "center", fontSize: "11px", color: "rgba(245,237,216,0.65)", lineHeight: 1.5, maxWidth: "460px", margin: "0 auto 16px", padding: "9px 14px", borderRadius: "10px", background: "rgba(247,176,28,0.08)", border: "1px solid rgba(247,176,28,0.25)" } }, "Sample data is mixed in so you can preview every screen — play the PIXEL UNITED challenge to try a full shootout against a simulated opponent. Nothing is saved.") : null, // record (gold-framed, trophy header — reference style) h("div", { style: Object.assign({}, panelS, { border: "1px solid rgba(255,200,54,0.38)", marginBottom: "14px", padding: "14px 16px" }) }, h("div", { style: { display: "flex", alignItems: "center", justifyContent: "center", gap: "6px", fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: "11px", letterSpacing: "3px", color: DG.gold, marginBottom: "10px" } }, dIcon("trophy", 13), "RECORD"), h("div", { style: { display: "flex", alignItems: "center", background: DG.inset, borderRadius: "12px", padding: "8px 4px" } }, statCell("Points", Math.round(st.points || 0), true), divider, statCell("Wins", st.wins, true), divider, statCell("Losses", st.losses), divider, statCell("Draws", st.draws), divider, statCell("Win %", winPct + "%", true), divider, statCell("Streak", st.best_streak)), h("div", { style: { textAlign: "center", fontSize: "9.5px", letterSpacing: "0.5px", color: "rgba(245,237,216,0.5)", marginTop: "8px" } }, "Win +25 · Draw +10 · Loss +1 — every result below shows the points it earned.")), // leaderboard / how-to-play shortcuts (reference tiles) h("div", { style: { display: "grid", gridTemplateColumns: "1fr 1fr", gap: "10px", marginBottom: "12px" } }, h("div", { onClick: function() { setTab("leaderboard"); }, style: Object.assign({}, panelS, { padding: isMobile ? "12px 10px" : "13px 14px", cursor: "pointer", display: "flex", alignItems: "center", gap: "10px" }) }, h("div", { style: { width: "34px", height: "34px", borderRadius: "9px", background: DG.goldGrad, display: "flex", alignItems: "center", justifyContent: "center", color: DG.ink, flexShrink: 0, boxShadow: "0 4px 10px rgba(247,176,28,0.3)" } }, penIcon("podium", 19, "#1A0428")), h("div", { style: { minWidth: 0 } }, h("div", { style: { fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: isMobile ? "12px" : "13px", letterSpacing: "1px", color: "#F5EDD8" } }, "LEADERBOARD"), h("div", { style: { fontSize: "8.5px", letterSpacing: "1px", color: "rgba(245,237,216,0.5)", textTransform: "uppercase" } }, "View top players"))), h("div", { onClick: function() { setHowOpen(true); }, style: Object.assign({}, panelS, { padding: isMobile ? "12px 10px" : "13px 14px", cursor: "pointer", display: "flex", alignItems: "center", gap: "10px" }) }, h("div", { style: { width: "34px", height: "34px", borderRadius: "9px", background: DG.goldGrad, display: "flex", alignItems: "center", justifyContent: "center", color: DG.ink, flexShrink: 0, boxShadow: "0 4px 10px rgba(247,176,28,0.3)" } }, penIcon("question", 19, "#1A0428")), h("div", { style: { minWidth: 0 } }, h("div", { style: { fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: isMobile ? "12px" : "13px", letterSpacing: "1px", color: "#F5EDD8" } }, "HOW TO PLAY"), h("div", { style: { fontSize: "8.5px", letterSpacing: "1px", color: "rgba(245,237,216,0.5)", textTransform: "uppercase" } }, "Game rules")))), // find opponent h("button", { onClick: function() { setChTab(myOpen ? "open" : "friends"); setChallengeOpen(true); }, style: Object.assign({}, goldBtnS, { width: "100%", padding: "16px", fontSize: "18px", marginBottom: "16px", display: "flex", alignItems: "center", justifyContent: "center", gap: "10px" }) }, h("div", { style: { width: "20px", height: "20px" } }, penBall(20)), "FIND OPPONENT"), // your turn incoming.length ? h("div", { style: Object.assign({}, panelS, { marginBottom: "14px" }) }, h("div", { style: panelTitleS }, dIcon("target", 15), "YOUR TURN (" + incoming.length + ")"), incoming.map(function(m) { return matchRow(m, "incoming"); })) : null, // waiting waiting.length ? h("div", { style: Object.assign({}, panelS, { marginBottom: "14px" }) }, h("div", { style: panelTitleS }, dIcon("clock", 15), "WAITING ON OPPONENT"), waiting.map(function(m) { return matchRow(m, "waiting"); })) : null, // recent results results.length ? h("div", { style: Object.assign({}, panelS, { marginBottom: "14px" }) }, h("div", { style: panelTitleS }, dIcon("trophy", 15), "RECENT RESULTS"), results.slice(0, 6).map(function(m) { return matchRow(m, "result"); })) : null, // open challenges marketplace xOpen.length ? h("div", { style: panelS }, h("div", { style: panelTitleS }, dIcon("swords", 15), "OPEN CHALLENGES (" + xOpen.length + ")"), xOpen.slice(0, 8).map(function(m) { return h("div", { key: m.id, style: { display: "flex", alignItems: "center", gap: "11px", padding: "10px 12px", borderRadius: "12px", background: DG.inset, border: "1px solid rgba(176,128,214,0.12)", marginBottom: "8px" } }, avatarFor(nameFor(m.challenger_id), 34, false), h("div", { style: { flex: 1, minWidth: 0 } }, h("div", { style: { fontSize: "14px", color: "#F5EDD8", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" } }, nameFor(m.challenger_id)), h("div", { style: { fontSize: "10px", color: "rgba(245,237,216,0.45)" } }, "wants a shootout")), h("button", { onClick: function() { acceptOpenChallenge(m); }, disabled: busy, style: Object.assign({}, goldBtnS, { padding: "8px 16px", fontSize: "12px" }) }, "ACCEPT")); })) : (!incoming.length && !waiting.length && !results.length ? h("div", { style: Object.assign({}, panelS, { textAlign: "center", color: "rgba(245,237,216,0.55)", fontSize: "13px", padding: "30px 18px" }) }, "No matches yet — tap FIND OPPONENT to challenge someone to a shootout.") : null)); }; // ── LEADERBOARD ────────────────────────────────────────────────────────────── var renderLeaderboard = function() { var profOf = function(id) { return profileMap[id] || (id === uid ? profile : null); }; // §25.8: test mode mixes in sample managers (re-sorted so ranking stays wins-desc) var base = testData ? testData.lb.concat(lbRows).sort(function(a, b) { return (b.wins - a.wins) || (b.points - a.points); }) : lbRows; var rows = base.filter(function(r) { var p = profOf(r.user_id); if (lbTab === "friends") return r.user_id === uid || friendIds.indexOf(r.user_id) >= 0; if (lbTab === "country") return profile && profile.country && p && p.country === profile.country; return true; }); var myRank = 0; for (var i = 0; i < base.length; i++) { if (base[i].user_id === uid) { myRank = i + 1; break; } } var topPct = myRank ? Math.max(1, Math.round(myRank / base.length * 100)) : 0; var lbTabs = [["global", "GLOBAL"], ["friends", "FRIENDS"], ["country", "COUNTRY"]]; var rankStat = function(icon, val, label, iconNode) { return h("div", { style: { flex: 1, textAlign: "center", padding: "2px 0" } }, h("div", { style: { display: "flex", justifyContent: "center", color: DG.gold, marginBottom: "4px" } }, iconNode || dIcon(icon, 15)), h("div", { style: { fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: isMobile ? "18px" : "21px", color: "#FFFFFF", lineHeight: 1 } }, val), h("div", { style: { fontSize: "7.5px", letterSpacing: "1.5px", color: "rgba(245,237,216,0.5)", marginTop: "4px", textTransform: "uppercase" } }, label)); }; var vDiv = h("div", { style: { width: "1px", alignSelf: "stretch", background: "rgba(176,128,214,0.2)", margin: "4px 0" } }); return h("div", { style: pageS }, // chrome headline h("div", { style: { textAlign: "center", marginBottom: "12px" } }, penChrome("LEADERBOARD", isMobile ? 40 : 54), h("div", { style: { display: "flex", alignItems: "center", justifyContent: "center", gap: "6px", marginTop: "4px", fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: "11px", letterSpacing: "3px", color: DG.gold } }, penIcon("bolt", 11, DG.gold), "PENALTY SHOOTOUT", penIcon("bolt", 11, DG.gold))), // segmented tier tabs h("div", { style: { display: "flex", gap: "4px", padding: "4px", borderRadius: "999px", border: "1px solid rgba(176,128,214,0.25)", background: DG.inset, marginBottom: "14px" } }, lbTabs.map(function(t) { var on = lbTab === t[0]; return h("button", { key: t[0], onClick: function() { setLbTab(t[0]); }, style: { flex: 1, padding: "9px 4px", borderRadius: "999px", border: on ? "1px solid rgba(255,200,54,0.7)" : "1px solid transparent", background: on ? "rgba(247,176,28,0.12)" : "transparent", color: on ? DG.gold : "rgba(245,237,216,0.55)", fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: "12px", letterSpacing: "1.5px", cursor: "pointer" } }, t[1]); })), // YOUR RANK card (violet frame, shield + rank + icon stats) h("div", { style: Object.assign({}, panelS, { border: "1px solid rgba(199,155,242,0.45)", marginBottom: "14px", display: "flex", alignItems: "center", gap: isMobile ? "8px" : "14px", padding: isMobile ? "12px 10px" : "14px 18px" }) }, h("div", { style: { display: "flex", alignItems: "center", gap: "10px", flexShrink: 0 } }, h("div", { style: { width: isMobile ? "40px" : "48px", height: isMobile ? "45px" : "54px", position: "relative", filter: "drop-shadow(0 4px 10px rgba(247,176,28,0.45))" } }, h("div", { style: { position: "absolute", inset: 0, clipPath: SHIELD_CLIP, background: "linear-gradient(160deg, #FFE49A, #F0A91E)" } }), h("div", { style: { position: "absolute", inset: 0, display: "flex", alignItems: "center", justifyContent: "center", paddingBottom: "7px" } }, h("div", { style: { width: isMobile ? "19px" : "23px", height: isMobile ? "19px" : "23px" } }, penBall(isMobile ? 19 : 23)))), h("div", null, h("div", { style: { fontSize: "8px", letterSpacing: "2px", color: DG.gold, fontFamily: "'Anton', 'Bebas Neue', sans-serif" } }, "YOUR RANK"), h("div", { style: { fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: isMobile ? "26px" : "32px", color: "#FFFFFF", lineHeight: 1 } }, myRank ? String(myRank) : "—"), myRank ? h("div", { style: { fontSize: "8px", letterSpacing: "1px", color: "rgba(245,237,216,0.55)", marginTop: "2px" } }, "TOP " + topPct + "%") : null)), vDiv, rankStat("trophy", st.wins, "Wins"), vDiv, rankStat("target", winPct + "%", "Win %"), vDiv, rankStat(null, st.best_streak, "Best Streak", h("div", { style: { width: "15px", height: "15px" } }, penBall(15)))), // table h("div", { style: panelS }, h("div", { style: { display: "grid", gridTemplateColumns: isMobile ? "26px 1fr 28px 38px 36px 66px" : "38px 1fr 52px 54px 64px 132px", gap: isMobile ? "6px" : "8px", padding: "0 10px 8px", fontSize: "8.5px", letterSpacing: "1.5px", color: "rgba(245,237,216,0.45)", textTransform: "uppercase" } }, h("div", null, "Rank"), h("div", null, "Player"), h("div", { style: { textAlign: "center" } }, "Wins"), h("div", { style: { textAlign: "center" } }, "Win %"), h("div", { style: { textAlign: "center" } }, "Streak"), h("div", null, "")), rows.length ? rows.map(function(r, i) { var you = r.user_id === uid; var p = profOf(r.user_id); var nm = (p && p.team_name) || nameFor(r.user_id); var pct = r.played ? Math.round(100 * r.wins / r.played) : 0; return h("div", { key: r.user_id, style: { display: "grid", gridTemplateColumns: isMobile ? "26px 1fr 28px 38px 36px 66px" : "38px 1fr 52px 54px 64px 132px", gap: isMobile ? "6px" : "8px", alignItems: "center", padding: isMobile ? "9px 10px" : "10px 12px", borderRadius: "13px", marginBottom: "6px", background: you ? "linear-gradient(90deg, rgba(120,60,180,0.35), rgba(60,20,100,0.18))" : (i === 0 ? "linear-gradient(90deg, rgba(247,176,28,0.16), rgba(247,176,28,0.03))" : DG.inset), border: you ? "1.5px solid rgba(199,155,242,0.75)" : (i === 0 ? "1px solid rgba(255,200,54,0.5)" : "1px solid rgba(176,128,214,0.12)"), boxShadow: you ? "0 6px 18px rgba(140,70,220,0.25)" : "none" } }, penRankBadge(i + 1, isMobile ? 22 : 26), h("div", { style: { display: "flex", alignItems: "center", gap: "9px", minWidth: 0 } }, avatarFor(nm, isMobile ? 30 : 34, you || i === 0), h("div", { style: { minWidth: 0 } }, h("div", { style: { fontFamily: "'Anton', 'Bebas Neue', sans-serif", letterSpacing: "0.5px", fontSize: isMobile ? "12px" : "13.5px", color: you ? "#FFFFFF" : "rgba(245,237,216,0.9)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" } }, (you ? "YOU" : nm)), h("div", { style: { fontSize: "8px", letterSpacing: "1px", color: you ? DG.violet : "rgba(245,237,216,0.4)", textTransform: "uppercase", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" } }, you ? nm : ((p && [p.city, p.country].filter(Boolean).join(", ")) || "Shootout player"))), ), h("div", { style: { textAlign: "center", fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: isMobile ? "15px" : "17px", color: i === 0 ? DG.gold : you ? "#FFD867" : DG.gold2 } }, r.wins), h("div", { style: { textAlign: "center", fontSize: isMobile ? "12px" : "13px", color: you ? "#FFFFFF" : "rgba(245,237,216,0.8)" } }, pct + "%"), h("div", { style: { textAlign: "center", fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: isMobile ? "13px" : "15px", color: DG.violet } }, r.best_streak), // §25.11 — challenge any ranked player straight from the board (top 100 shown), // + §26.12 add-friend right from the row (friends unlock nudges + unlimited rematches) you ? h("div", null) : (function() { var isFriend = friendIds.indexOf(r.user_id) >= 0; var reqSent = friendships.some(function(f) { return f.user_id === uid && f.friend_id === r.user_id && f.status === "pending"; }); var chBtn = h("button", { key: "ch", onClick: function() { startChallenge(r.user_id); }, title: "Challenge " + nm + " to a shootout", style: isMobile ? { width: "30px", height: "30px", borderRadius: "9px", padding: 0, display: "flex", alignItems: "center", justifyContent: "center", cursor: "pointer", border: "1px solid rgba(255,200,54,0.55)", background: "rgba(247,176,28,0.14)", color: DG.gold, flexShrink: 0 } : Object.assign({}, goldBtnS, { flex: 1, padding: "7px 0", fontSize: "11px", letterSpacing: "1px", display: "flex", alignItems: "center", justifyContent: "center", gap: "5px" }) }, dIcon("swords", isMobile ? 14 : 13), isMobile ? null : "CHALLENGE"); var frBtn = isFriend ? h("div", { key: "fr", title: "Friends", style: { width: "30px", height: "30px", borderRadius: "9px", display: "flex", alignItems: "center", justifyContent: "center", border: "1px solid rgba(199,155,242,0.4)", color: DG.violet, flexShrink: 0 } }, dIcon("users", 14)) : h("button", { key: "fr", onClick: function() { if (reqSent) { flash("Friend request already sent to " + nm + "."); return; } addFriend(r.user_id); }, title: reqSent ? "Friend request sent" : "Add " + nm + " as a friend", style: { width: "30px", height: "30px", borderRadius: "9px", padding: 0, display: "flex", alignItems: "center", justifyContent: "center", cursor: "pointer", border: "1px solid rgba(199,155,242,0.5)", background: "rgba(199,155,242,0.1)", color: reqSent ? "rgba(199,155,242,0.45)" : DG.violet, flexShrink: 0 } }, dIcon("userPlus", 14)); return h("div", { style: { display: "flex", gap: "6px", alignItems: "center", justifyContent: "flex-end" } }, chBtn, frBtn); })()); }) : h("div", { style: { textAlign: "center", padding: "36px 0", color: "rgba(245,237,216,0.45)", fontSize: "12px" } }, lbTab === "global" ? "No players ranked yet — be the first to win a shootout." : "No players in your " + lbTab + " yet."))); }; // ── HISTORY ──────────────────────────────────────────────────────────────── var renderHistory = function() { return h("div", { style: pageS }, h("div", { style: panelS }, h("div", { style: panelTitleS }, dIcon("clock", 15), "MATCH HISTORY"), results.length ? results.map(function(m) { return matchRow(m, "result"); }) : h("div", { style: { textAlign: "center", padding: "36px 0", color: "rgba(245,237,216,0.45)", fontSize: "12px" } }, "No completed shootouts yet."))); }; // ── SUMMARY (§25.9 — the reference "YOU WIN!" victory screen) ───────────────── var renderSummary = function() { var m = summaryMatch; var oc = outcomeOf(m); var oid = oppOf(m); var side = sideOf(m); var rds = (m.breakdown && m.breakdown.rounds) || []; var sd = rds.some(function(r) { return r.sd; }); var pts = oc === "win" ? 25 : oc === "draw" ? 10 : 1; var head = oc === "win" ? "YOU WIN!" : oc === "loss" ? "YOU LOSE" : "IT'S A DRAW"; var glow = oc === "win" ? "rgba(247,176,28,0.55)" : oc === "loss" ? "rgba(226,53,60,0.5)" : "rgba(168,92,255,0.6)"; var accCol = oc === "win" ? DG.gold : oc === "loss" ? "#FF8A8A" : DG.violet; var myName = (profile && profile.team_name) || "You"; var goldPanel = Object.assign({}, panelS, { border: "1px solid rgba(255,200,54,0.42)" }); var decisive = (oc !== "draw" && sd) ? rds.length - 1 : -1; // the winning SD kick (regulation wins have no single decisive kick) var winnerRowIsMine = oc === "win"; // deterministic confetti (index-based — no Math.random) var confetti = oc === "win" ? Array.apply(null, { length: 18 }).map(function(_, i) { var colors = ["#FFC836", "#C79BF2", "#F5EDD8", "#7CFFB0"]; return h("div", { key: i, className: "pen-confetti", style: { position: "absolute", top: 0, left: ((i * 53 + 11) % 100) + "%", width: (5 + (i % 3) * 2) + "px", height: (8 + (i % 4) * 2) + "px", background: colors[i % 4], opacity: 0, borderRadius: "1.5px", animationDelay: (i % 9) * 0.3 + "s", animationDuration: (2.3 + (i % 5) * 0.28) + "s", pointerEvents: "none" } }); }) : null; var iconCell = function(goal, isWinnerKick) { return h("div", { style: { width: "27px", height: "27px", borderRadius: "50%", display: "flex", alignItems: "center", justifyContent: "center", flexShrink: 0, background: goal ? "rgba(35,196,99,0.2)" : "rgba(226,53,60,0.16)", border: isWinnerKick ? "2px solid #FFC836" : "1.5px solid " + (goal ? "rgba(124,255,176,0.55)" : "rgba(255,138,138,0.45)"), boxShadow: isWinnerKick ? "0 0 12px rgba(247,176,28,0.7)" : "none" } }, goal ? h("div", { style: { width: "12px", height: "12px" } }, penBall(12)) : h("span", { style: { color: "#FF8A8A", fontSize: "13px", lineHeight: 1 } }, "✕")); }; var labelW = isMobile ? "52px" : "80px"; var gridHeader = h("div", { style: { display: "flex", alignItems: "center", gap: "8px", marginBottom: "7px" } }, h("div", { style: { width: labelW, flexShrink: 0 } }), h("div", { style: { display: "flex", gap: "6px" } }, rds.map(function(r, i) { return h("div", { key: i, style: { width: "27px", textAlign: "center", fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: "11px", color: r.sd ? "#FF8A8A" : DG.violet, flexShrink: 0 } }, r.sd ? "SD" : String(i + 1)); }))); var rowFor = function(label, gold, getGoal, mineRow) { return h("div", { style: { display: "flex", alignItems: "center", gap: "8px", marginBottom: "8px" } }, h("div", { style: { width: labelW, display: "flex", alignItems: "center", gap: "6px", flexShrink: 0, minWidth: 0 } }, avatarFor(label, 22, gold), h("div", { style: { fontSize: "10px", fontFamily: "'Anton', 'Bebas Neue', sans-serif", letterSpacing: "0.5px", color: gold ? DG.gold : "rgba(245,237,216,0.8)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" } }, mineRow ? "YOU" : label)), h("div", { style: { display: "flex", gap: "6px", alignItems: "center", flexWrap: "wrap" } }, rds.map(function(r, i) { return h("div", { key: i }, iconCell(getGoal(r), i === decisive && mineRow === winnerRowIsMine)); }), (decisive >= 0 && mineRow === winnerRowIsMine) ? h("div", { style: { fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: "9px", letterSpacing: "1px", color: DG.gold, whiteSpace: "nowrap" } }, "★ WINNER") : null)); }; var shareResult = function() { var txt = oc === "win" ? ("I just won my penalty shootout " + myScoreOf(m) + "-" + oppScoreOf(m) + " vs " + nameFor(oid) + " on HOME TEAM. Think you can save mine?") : oc === "draw" ? ("My penalty shootout vs " + nameFor(oid) + " finished all square at " + myScoreOf(m) + "-" + oppScoreOf(m) + " on HOME TEAM.") : ("Lost a heartbreaker " + myScoreOf(m) + "-" + oppScoreOf(m) + " to " + nameFor(oid) + " in the HOME TEAM penalty shootout. Rematch incoming."); if (navigator.share) { navigator.share({ text: txt }).catch(function() {}); } else { try { navigator.clipboard.writeText(txt); flash("✓ RESULT COPIED — paste it anywhere"); } catch (e) { flash(txt); } } }; var rewardCell = function(icon, big, label) { return h("div", { style: { flex: 1, display: "flex", alignItems: "center", justifyContent: "center", gap: "10px", padding: "6px 0" } }, h("div", { style: { width: "38px", height: "38px", borderRadius: "50%", flexShrink: 0, background: "radial-gradient(circle at 35% 30%, #FFE9A8, #E8B83A 60%, #9c6f08)", display: "flex", alignItems: "center", justifyContent: "center", color: "#1A0428", boxShadow: "0 4px 12px rgba(247,176,28,0.35)" } }, penIcon(icon, 19, "#1A0428")), h("div", null, h("div", { style: { fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: "21px", color: "#FFFFFF", lineHeight: 1 } }, big), h("div", { style: { fontSize: "8px", letterSpacing: "1.5px", color: "rgba(245,237,216,0.55)", marginTop: "3px", textTransform: "uppercase" } }, label))); }; return h("div", { style: pageS }, // top back button (client: the bottom "BACK TO DASHBOARD" is easy to miss) h("button", { onClick: function() { setSummaryMatch(null); setReplayOpen(false); setTab("dashboard"); }, style: { display: "inline-flex", alignItems: "center", gap: "6px", background: "rgba(199,155,242,0.08)", border: "1px solid rgba(199,155,242,0.32)", borderRadius: "999px", color: "#F5EDD8", fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: "11px", letterSpacing: "1.5px", padding: "7px 14px", cursor: "pointer", marginBottom: "2px" } }, "‹ BACK"), // headline + confetti h("div", { style: { position: "relative", textAlign: "center", padding: "8px 0 2px", overflow: "hidden" } }, confetti, h("div", { className: "pen-pop", style: { position: "relative" } }, penChrome(head, isMobile ? 52 : 72, glow)), h("div", { style: { fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: "12px", letterSpacing: "3px", color: DG.gold, margin: "4px 0 14px" } }, "PENALTY SHOOTOUT")), // score matchup panel (mobile: labels sit UNDER the avatars so the score box never collides) h("div", { style: Object.assign({}, goldPanel, { display: "flex", alignItems: "center", gap: isMobile ? "8px" : "10px", padding: isMobile ? "12px 10px" : "14px 20px", marginBottom: "12px" }) }, h("div", { style: { display: "flex", flexDirection: isMobile ? "column" : "row", alignItems: "center", gap: isMobile ? "5px" : "9px", flex: "1 1 0", minWidth: 0 } }, avatarFor(myName, isMobile ? 38 : 48, true), h("div", { style: { minWidth: 0, maxWidth: "100%", textAlign: isMobile ? "center" : "left" } }, h("div", { style: { fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: isMobile ? "12px" : "15px", color: "#FFFFFF", letterSpacing: "1px" } }, "YOU"), h("div", { style: { fontSize: "8px", letterSpacing: "1px", color: DG.gold, fontFamily: "'Anton', 'Bebas Neue', sans-serif", textTransform: "uppercase", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", maxWidth: isMobile ? "84px" : "130px" } }, myName))), h("div", { style: { textAlign: "center", flexShrink: 0 } }, // roomier box: at small render sizes tightly-packed 38px digits smear into a "white // box" illusion (client bug report) — smaller digits + wider tracking + more padding h("div", { style: { display: "inline-block", padding: isMobile ? "7px 14px" : "10px 26px", borderRadius: "12px", background: DG.inset, border: "1px solid rgba(176,128,214,0.25)", fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: isMobile ? "20px" : "30px", color: "#FFFFFF", lineHeight: 1, letterSpacing: isMobile ? "2px" : "4px" } }, myScoreOf(m) + " - " + oppScoreOf(m)), sd ? h("div", { style: { marginTop: "6px", display: "inline-flex", alignItems: "center", gap: "4px", padding: "2px 8px", borderRadius: "999px", border: "1px solid rgba(255,200,54,0.5)", background: "rgba(247,176,28,0.1)", fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: "8px", letterSpacing: "1px", color: DG.gold } }, penIcon("bolt", 8, DG.gold), "SUDDEN DEATH") : null), h("div", { style: { display: "flex", flexDirection: isMobile ? "column" : "row", alignItems: "center", gap: isMobile ? "5px" : "9px", flex: "1 1 0", minWidth: 0, justifyContent: isMobile ? "flex-start" : "flex-end" } }, isMobile ? avatarFor(nameFor(oid), 38, false) : null, h("div", { style: { minWidth: 0, maxWidth: "100%", textAlign: isMobile ? "center" : "right" } }, h("div", { style: { fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: isMobile ? "12px" : "15px", color: "#FFFFFF", letterSpacing: "1px" } }, "RIVAL"), h("div", { style: { fontSize: "8px", letterSpacing: "1px", color: DG.violet, fontFamily: "'Anton', 'Bebas Neue', sans-serif", textTransform: "uppercase", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", maxWidth: isMobile ? "84px" : "130px" } }, nameFor(oid))), isMobile ? null : avatarFor(nameFor(oid), 48, false))), // §26.12 — add the opponent as a friend right from the result (friends unlock nudges + // unlimited rematches). Hidden for existing friends / test opponents. (oid && !isPenTestId(oid) && friendIds.indexOf(oid) < 0) ? (function() { var reqSent = friendships.some(function(f) { return f.user_id === uid && f.friend_id === oid && f.status === "pending"; }); return h("button", { onClick: function() { if (reqSent) { flash("Friend request already sent to " + nameFor(oid) + "."); return; } addFriend(oid); }, style: Object.assign({}, ghostBtnS, { width: "100%", padding: "11px", fontSize: "12.5px", marginBottom: "12px", display: "flex", alignItems: "center", justifyContent: "center", gap: "8px", border: "1px solid rgba(199,155,242,0.45)", color: reqSent ? "rgba(199,155,242,0.5)" : "#DCC6FF", background: "rgba(199,155,242,0.07)" }) }, dIcon("userPlus", 15), reqSent ? "FRIEND REQUEST SENT" : ("ADD " + nameFor(oid).toUpperCase() + " AS A FRIEND — unlock nudges & rematches")); })() : null, // victory shield + tagline h("div", { style: { textAlign: "center", margin: "18px 0 16px" } }, h("div", { style: { width: isMobile ? "74px" : "88px", height: isMobile ? "84px" : "100px", margin: "0 auto 10px", position: "relative", filter: "drop-shadow(0 6px 18px " + glow + ")" } }, h("div", { style: { position: "absolute", inset: 0, clipPath: SHIELD_CLIP, background: oc === "win" ? "linear-gradient(160deg, #FFE49A, #F0A91E)" : oc === "loss" ? "linear-gradient(160deg, #8a4a52, #4a2030)" : "linear-gradient(160deg, #C79BF2, #7d4bb5)" } }), h("div", { style: { position: "absolute", inset: "4px", clipPath: SHIELD_CLIP, background: "linear-gradient(160deg, #2a1140 0%, #160229 100%)" } }), h("div", { style: { position: "absolute", inset: 0, display: "flex", alignItems: "center", justifyContent: "center", paddingBottom: "12px" } }, h("div", { style: { width: isMobile ? "34px" : "40px", height: isMobile ? "34px" : "40px" } }, penBall(isMobile ? 34 : 40)))), h("div", { style: { fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: isMobile ? "26px" : "32px", letterSpacing: "2px", color: accCol, textShadow: "0 0 20px " + glow } }, oc === "win" ? "VICTORY!" : oc === "loss" ? "DEFEAT" : "ALL SQUARE"), h("div", { style: { fontSize: "12px", color: "rgba(245,237,216,0.7)", marginTop: "5px" } }, oc === "win" ? "Great nerves! You held your nerve and came out on top." : oc === "loss" ? "So close — study the replay and get your revenge." : "Neither keeper blinked. Settle it with a rematch!")), // shootout summary grid h("div", { style: Object.assign({}, goldPanel, { marginBottom: "12px" }) }, h("div", { style: Object.assign({}, panelTitleS, { justifyContent: "center" }) }, "SHOOTOUT SUMMARY"), gridHeader, rowFor(myName, true, function(r) { return side === "c" ? r.cGoal : r.oGoal; }, true), rowFor(nameFor(oid), false, function(r) { return side === "c" ? r.oGoal : r.cGoal; }, false), h("div", { style: { display: "flex", gap: "16px", justifyContent: "center", marginTop: "10px", fontSize: "10px", color: "rgba(245,237,216,0.55)" } }, h("span", { style: { display: "flex", alignItems: "center", gap: "5px" } }, h("div", { style: { width: "11px", height: "11px" } }, penBall(11)), "GOAL"), h("span", { style: { display: "flex", alignItems: "center", gap: "5px" } }, h("span", { style: { color: "#FF8A8A" } }, "✕"), "SAVED / MISS"))), // rewards h("div", { style: Object.assign({}, goldPanel, { marginBottom: "12px", padding: "12px 10px" }) }, h("div", { style: { textAlign: "center", fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: "11px", letterSpacing: "3px", color: DG.gold, marginBottom: "8px" } }, "REWARDS"), h("div", { style: { display: "flex", alignItems: "stretch" } }, rewardCell("star", "+" + pts, "POINTS EARNED"), h("div", { style: { width: "1px", background: "rgba(176,128,214,0.2)" } }), rewardCell("podium", String(Math.round(st.points || 0)), "TOTAL POINTS"), h("div", { style: { width: "1px", background: "rgba(176,128,214,0.2)" } }), rewardCell("bolt", String(st.current_streak), "WIN STREAK"))), // record + next up h("div", { style: { display: "grid", gridTemplateColumns: "1fr 1fr", gap: "10px", marginBottom: "14px" } }, h("div", { style: Object.assign({}, panelS, { textAlign: "center", padding: "14px 10px" }) }, h("div", { style: { fontSize: "8px", letterSpacing: "2px", color: DG.gold, fontFamily: "'Anton', 'Bebas Neue', sans-serif", marginBottom: "7px" } }, "YOUR RECORD"), h("div", { style: { display: "flex", alignItems: "center", justifyContent: "center", gap: "8px" } }, h("span", { style: { color: DG.gold } }, dIcon("trophy", 18)), h("span", { style: { fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: isMobile ? "19px" : "23px", color: "#FFFFFF", letterSpacing: "1px" } }, st.wins + " - " + st.losses + " - " + st.draws)), h("div", { style: { fontSize: "7.5px", letterSpacing: "1.5px", color: "rgba(245,237,216,0.5)", marginTop: "5px" } }, "WINS - LOSSES - DRAWS")), h("div", { onClick: function() { setSummaryMatch(null); setReplayOpen(false); setChTab("open"); setChallengeOpen(true); }, style: Object.assign({}, panelS, { padding: "14px 12px", cursor: "pointer", display: "flex", alignItems: "center", gap: "10px" }) }, h("div", { style: { width: "32px", height: "32px", borderRadius: "50%", border: "1px solid rgba(199,155,242,0.4)", display: "flex", alignItems: "center", justifyContent: "center", flexShrink: 0 } }, h("div", { style: { width: "17px", height: "17px" } }, penBall(17))), h("div", { style: { minWidth: 0 } }, h("div", { style: { fontSize: "8px", letterSpacing: "2px", color: "rgba(245,237,216,0.5)", fontFamily: "'Anton', 'Bebas Neue', sans-serif", marginBottom: "3px" } }, "NEXT UP"), h("div", { style: { fontSize: "11px", color: "rgba(245,237,216,0.85)", lineHeight: 1.35 } }, "Find a new opponent or climb the leaderboard.")), h("div", { style: { marginLeft: "auto", color: "rgba(245,237,216,0.4)", flexShrink: 0 } }, "›"))), // actions rds.length ? h("button", { onClick: function() { setReplayOpen(true); }, style: Object.assign({}, ghostBtnS, { width: "100%", padding: "13px", fontSize: "14px", marginBottom: "10px", display: "flex", alignItems: "center", justifyContent: "center", gap: "8px" }) }, "▶ WATCH REPLAY") : null, h("div", { style: { display: "flex", gap: "10px", marginBottom: "12px" } }, h("button", { onClick: shareResult, style: Object.assign({}, ghostBtnS, { flex: 1, padding: "13px", fontSize: "14px", display: "flex", alignItems: "center", justifyContent: "center", gap: "8px" }) }, penIcon("share", 15, DG.gold), "SHARE RESULT"), h("button", { onClick: function() { var o = oid, rm = m.id; setSummaryMatch(null); startChallenge(o, rm); }, style: Object.assign({}, goldBtnS, { flex: 1.2, padding: "13px", fontSize: "14px", display: "flex", alignItems: "center", justifyContent: "center", gap: "8px" }) }, h("div", { style: { width: "16px", height: "16px" } }, penBall(16)), "PLAY AGAIN")), // climb-the-leaderboard banner + exit h("div", { onClick: function() { setSummaryMatch(null); setReplayOpen(false); setTab("leaderboard"); }, style: Object.assign({}, goldPanel, { display: "flex", alignItems: "center", gap: "12px", padding: "12px 14px", cursor: "pointer", marginBottom: "10px" }) }, h("div", { style: { width: "34px", height: "34px", borderRadius: "9px", background: DG.goldGrad, display: "flex", alignItems: "center", justifyContent: "center", color: DG.ink, flexShrink: 0 } }, penIcon("podium", 19, "#1A0428")), h("div", { style: { minWidth: 0 } }, h("div", { style: { fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: "13px", letterSpacing: "1.5px", color: DG.gold } }, "CLIMB THE LEADERBOARD"), h("div", { style: { fontSize: "10px", color: "rgba(245,237,216,0.6)" } }, "Compete with the best and be #1")), h("div", { style: { marginLeft: "auto", color: "rgba(245,237,216,0.4)", flexShrink: 0 } }, "›")), h("button", { onClick: function() { setSummaryMatch(null); setReplayOpen(false); setTab("dashboard"); }, style: { display: "block", margin: "0 auto", background: "transparent", border: "none", color: "rgba(245,237,216,0.5)", fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: "12px", letterSpacing: "2px", cursor: "pointer", padding: "8px 14px" } }, "BACK TO DASHBOARD"), replayOpen ? h(PenaltyReplay, { rounds: rds, mySide: side, oppName: nameFor(oid), isMobile: isMobile, DG: DG, onDone: function() { setReplayOpen(false); } }) : null); }; // ── CHALLENGE modal ────────────────────────────────────────────────────────── var renderChallengeModal = function() { var tabBtnC = function(id, label) { var on = chTab === id; return h("button", { key: id, onClick: function() { setChTab(id); }, style: { flex: 1, padding: "9px", borderRadius: "9px", border: on ? "1px solid " + DG.gold : "1px solid rgba(199,155,242,0.25)", background: on ? DG.goldGrad : "rgba(199,155,242,0.06)", color: on ? DG.ink : "rgba(245,237,216,0.7)", fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: "12px", letterSpacing: "1px", cursor: "pointer" } }, label); }; var personRow = function(id, nm, actionLabel, action, sub) { return h("div", { key: id, style: { display: "flex", alignItems: "center", gap: "10px", padding: "10px 0", borderBottom: "1px solid rgba(199,155,242,0.1)" } }, avatarFor(nm, 34, false), h("div", { style: { flex: 1, minWidth: 0 } }, h("div", { style: { fontSize: "14px", color: "#F5EDD8", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" } }, nm), sub ? h("div", { style: { fontSize: "10px", color: "rgba(245,237,216,0.45)" } }, sub) : null), h("button", { onClick: action, disabled: busy, style: Object.assign({}, goldBtnS, { padding: "8px 14px", fontSize: "12px" }) }, actionLabel)); }; var body; if (chTab === "open") { body = h("div", null, myOpen ? h("div", { style: { marginBottom: "14px", padding: "12px", borderRadius: "12px", background: "rgba(247,176,28,0.1)", border: "1px solid rgba(247,176,28,0.35)" } }, h("div", { style: { fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: "14px", color: DG.gold, marginBottom: "4px" } }, "YOUR CHALLENGE IS LIVE"), h("div", { style: { fontSize: "11px", color: "rgba(245,237,216,0.7)", marginBottom: "10px" } }, "Waiting for any manager to accept it — nudge a friend to grab it first."), friendIds.length ? h("div", { style: { marginBottom: "10px" } }, h("div", { style: { fontSize: "9px", letterSpacing: "1.5px", color: "rgba(245,237,216,0.55)", textTransform: "uppercase", marginBottom: "6px" } }, "Nudge a friend"), h("div", { style: { display: "flex", gap: "6px", flexWrap: "wrap" } }, friendIds.slice(0, 8).map(function(fid) { var done = !!sentNudges[fid]; return h("button", { key: fid, onClick: function() { sendNudge(fid); }, style: { padding: "6px 12px", borderRadius: "999px", cursor: done ? "default" : "pointer", fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: "11px", letterSpacing: "0.5px", border: done ? "1px solid rgba(124,255,176,0.5)" : "1px solid rgba(255,200,54,0.5)", background: done ? "rgba(35,196,99,0.14)" : "rgba(247,176,28,0.12)", color: done ? "#7CFFB0" : DG.gold } }, (done ? "✓ " : "") + nameFor(fid) + (done ? " · NUDGED" : "")); }))) : null, h("div", { style: { display: "flex", gap: "8px", flexWrap: "wrap" } }, h("button", { onClick: shareInvite, style: Object.assign({}, goldBtnS, { padding: "8px 14px", fontSize: "12px", display: "inline-flex", alignItems: "center", gap: "6px" }) }, penIcon("share", 13, "#1A0428"), "SHARE INVITE"), h("button", { onClick: function() { cancelMatch(myOpen); flash("Open challenge cancelled."); }, style: Object.assign({}, ghostBtnS, { padding: "8px 14px", fontSize: "12px" }) }, "CANCEL CHALLENGE"))) : h("button", { onClick: createOpenChallenge, style: Object.assign({}, goldBtnS, { width: "100%", padding: "13px", fontSize: "15px", marginBottom: "14px", display: "flex", alignItems: "center", justifyContent: "center", gap: "8px" }) }, dIcon("swords", 17), "POST AN OPEN CHALLENGE"), h("div", { style: { fontSize: "11px", letterSpacing: "1.5px", color: "rgba(245,237,216,0.5)", marginBottom: "8px", textTransform: "uppercase" } }, "OPEN CHALLENGES (" + xOpen.length + ")"), xOpen.length ? xOpen.map(function(m) { return personRow(m.id, nameFor(m.challenger_id), "ACCEPT", function() { acceptOpenChallenge(m); }, "wants a shootout"); }) : h("div", { style: { fontSize: "12px", color: "rgba(245,237,216,0.45)", padding: "16px 0", textAlign: "center" } }, "No open challenges right now.")); } else if (chTab === "search") { body = h("div", null, h("input", { value: userQuery, onChange: function(e) { runSearch(e.target.value); }, placeholder: "Search by team name…", style: { width: "100%", boxSizing: "border-box", padding: "11px 13px", borderRadius: "10px", border: "1px solid rgba(199,155,242,0.3)", background: "rgba(8,2,18,0.6)", color: "#F5EDD8", fontSize: "14px", marginBottom: "12px", minWidth: 0 } }), userResults.length ? userResults.map(function(p) { return personRow(p.user_id, p.team_name || "Player", "CHALLENGE", function() { startChallenge(p.user_id); }, [p.city, p.country].filter(Boolean).join(", ")); }) : h("div", { style: { fontSize: "12px", color: "rgba(245,237,216,0.45)", padding: "16px 0", textAlign: "center" } }, userQuery.length >= 2 ? "No managers found." : "Type at least 2 letters to search.")); } else { var fr = friendIds.map(function(id) { return { id: id, nm: nameFor(id) }; }); body = h("div", null, h("input", { value: userQuery, onChange: function(e) { runSearch(e.target.value); }, placeholder: "Find people to add…", style: { width: "100%", boxSizing: "border-box", padding: "11px 13px", borderRadius: "10px", border: "1px solid rgba(199,155,242,0.3)", background: "rgba(8,2,18,0.6)", color: "#F5EDD8", fontSize: "14px", marginBottom: "12px", minWidth: 0 } }), userQuery.length >= 2 ? (userResults.length ? userResults.map(function(p) { var isF = friendIds.indexOf(p.user_id) >= 0; return personRow(p.user_id, p.team_name || "Player", isF ? "CHALLENGE" : "+ ADD", isF ? function() { startChallenge(p.user_id); } : function() { addFriend(p.user_id); }, [p.city, p.country].filter(Boolean).join(", ")); }) : h("div", { style: { fontSize: "12px", color: "rgba(245,237,216,0.45)", padding: "12px 0", textAlign: "center" } }, "No managers found.")) : (fr.length ? fr.map(function(f) { return personRow(f.id, f.nm, "CHALLENGE", function() { startChallenge(f.id); }); }) : h("div", { style: { fontSize: "12px", color: "rgba(245,237,216,0.45)", padding: "16px 0", textAlign: "center" } }, "No friends yet — search above to add some."))); } return modalShell("FIND OPPONENT", function() { setChallengeOpen(false); }, h("div", null, h("div", { style: { display: "flex", gap: "6px", marginBottom: "16px" } }, tabBtnC("open", "OPEN"), tabBtnC("search", "SEARCH"), tabBtnC("friends", "FRIENDS")), body), "460px"); }; // ── INBOX modal (friend requests + incoming challenges) ─────────────────────── var renderInboxModal = function() { var body = h("div", null, incoming.length ? h("div", { style: { marginBottom: "16px" } }, h("div", { style: { fontSize: "11px", letterSpacing: "1.5px", color: "rgba(245,237,216,0.5)", marginBottom: "8px", textTransform: "uppercase" } }, "CHALLENGES"), incoming.map(function(m) { return h("div", { key: m.id, style: { display: "flex", alignItems: "center", gap: "10px", padding: "8px 0", borderBottom: "1px solid rgba(199,155,242,0.1)" } }, avatarFor(nameFor(m.challenger_id), 34, false), h("div", { style: { flex: 1, minWidth: 0 } }, h("div", { style: { fontSize: "14px", color: "#F5EDD8", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" } }, nameFor(m.challenger_id)), h("div", { style: { fontSize: "9.5px", color: "#C79BF2", display: "flex", alignItems: "center", gap: "4px" } }, penIcon("lock", 9, "#C79BF2"), "Their penalties are locked in")), h("button", { onClick: function() { setInboxOpen(false); setPlayMatch(Object.assign({}, m)); }, style: Object.assign({}, goldBtnS, { padding: "8px 14px", fontSize: "12px" }) }, "PLAY"), h("button", { onClick: function() { declineMatch(m); }, style: Object.assign({}, ghostBtnS, { padding: "8px 12px", fontSize: "12px" }) }, "DECLINE")); })) : null, xFriendReqs.length ? h("div", null, h("div", { style: { fontSize: "11px", letterSpacing: "1.5px", color: "rgba(245,237,216,0.5)", marginBottom: "8px", textTransform: "uppercase" } }, "FRIEND REQUESTS"), xFriendReqs.map(function(f) { return h("div", { key: f.user_id, style: { display: "flex", alignItems: "center", gap: "10px", padding: "8px 0", borderBottom: "1px solid rgba(199,155,242,0.1)" } }, avatarFor(nameFor(f.user_id), 34, false), h("div", { style: { flex: 1, minWidth: 0, fontSize: "14px", color: "#F5EDD8" } }, nameFor(f.user_id)), h("button", { onClick: function() { acceptFriend(f.user_id); }, style: Object.assign({}, goldBtnS, { padding: "8px 14px", fontSize: "12px" }) }, "ACCEPT"), h("button", { onClick: function() { declineFriend(f.user_id); }, style: Object.assign({}, ghostBtnS, { padding: "8px 12px", fontSize: "12px" }) }, "IGNORE")); })) : null, (!incoming.length && !xFriendReqs.length) ? h("div", { style: { textAlign: "center", padding: "30px 0", color: "rgba(245,237,216,0.45)", fontSize: "13px" } }, "Nothing new. Challenge someone to get started!") : null); return modalShell("INBOX", function() { setInboxOpen(false); }, body, "440px"); }; // ── HOW TO PLAY modal ────────────────────────────────────────────────────── var renderHowModal = function() { var step = function(n, t, d) { return h("div", { style: { display: "flex", gap: "12px", marginBottom: "14px" } }, h("div", { style: { width: "28px", height: "28px", borderRadius: "50%", flexShrink: 0, background: DG.goldGrad, color: DG.ink, display: "flex", alignItems: "center", justifyContent: "center", fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: "14px" } }, n), h("div", null, h("div", { style: { fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: "15px", color: "#F5EDD8", letterSpacing: "0.5px" } }, t), h("div", { style: { fontSize: "12px", color: "rgba(245,237,216,0.65)", lineHeight: 1.5, marginTop: "2px" } }, d))); }; return modalShell("HOW TO PLAY", function() { setHowOpen(false); }, h("div", null, step("1", "CHALLENGE", "Pick a friend, search a team, or post an open challenge."), step("2", "TAKE 5 PENALTIES", "Select a corner for each shot. You've got 10 seconds — lock it in or re-pick."), step("3", "MAKE 5 SAVES", "Dive to guess where your opponent will shoot. Guess the same corner and it's SAVED."), step("4", "SUDDEN DEATH", "Only if it's level after 5: two sudden-death penalties each. Still level after those? It's a DRAW — both keep 10 points."), step("5", "WIN & CLIMB", "Win = 25 pts, draw = 10, loss = 1. Your points for each match show on its result card and in the match summary's REWARDS. Climb the leaderboard by wins, win % and streaks."), h("div", { style: { fontSize: "11px", color: "rgba(245,237,216,0.5)", textAlign: "center", marginTop: "6px" } }, "Both players play whenever they're online — results land once you've both taken your turn.")), "460px"); }; // ── blocking challenge / nudge popup ───────────────────────────────────────── // Blocks the main view until explicitly dismissed (play / decline / later) — the backdrop // is NOT tappable. Never rendered on the play screen (separate return below) or over the // match summary; the un-advanced watermark re-surfaces it right after. var renderPenAlert = function() { if (chAlert && chAlert.length) { var m = chAlert[0]; var nm = nameFor(m.challenger_id); return h("div", { style: { position: "fixed", inset: 0, background: "rgba(8,1,15,0.82)", backdropFilter: "blur(7px)", display: "flex", alignItems: "center", justifyContent: "center", zIndex: 240, padding: "20px" } }, h("div", { style: { width: "min(400px, 100%)", background: "linear-gradient(180deg, #2a1140 0%, #130325 100%)", border: "1px solid rgba(255,200,54,0.55)", borderRadius: "18px", boxShadow: "0 30px 80px rgba(0,0,0,0.75), 0 0 40px rgba(247,176,28,0.18)", padding: "26px 22px", textAlign: "center" } }, h("div", { style: { display: "flex", justifyContent: "center", marginBottom: "12px" } }, avatarFor(nm, 62, false)), h("div", { style: { fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: "26px", letterSpacing: "1.5px", color: DG.gold, lineHeight: 1.1 } }, "YOU'VE BEEN CHALLENGED!"), h("div", { style: { fontSize: "13px", color: "rgba(245,237,216,0.85)", margin: "10px 0 4px", lineHeight: 1.5 } }, nm + " wants a penalty shootout."), h("div", { style: { display: "inline-flex", alignItems: "center", gap: "5px", fontSize: "10.5px", color: "#C79BF2", marginBottom: "16px" } }, penIcon("lock", 10, "#C79BF2"), "Their penalties are already locked in — your move."), chAlert.length > 1 ? h("div", { style: { fontSize: "11px", color: "rgba(245,237,216,0.55)", marginBottom: "12px" } }, "+" + (chAlert.length - 1) + " more challenge" + (chAlert.length > 2 ? "s" : "") + " waiting in your INBOX.") : null, h("div", { style: { display: "flex", gap: "9px" } }, h("button", { onClick: function() { dismissChAlert(); declineMatch(m); }, style: { flex: 1, padding: "12px", borderRadius: "11px", cursor: "pointer", background: "transparent", border: "1px solid rgba(255,138,138,0.45)", color: "#FF8A8A", fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: "13px", letterSpacing: "1px" } }, "DECLINE"), h("button", { onClick: dismissChAlert, style: Object.assign({}, ghostBtnS, { flex: 1, padding: "12px", fontSize: "13px" }) }, "LATER"), h("button", { onClick: function() { dismissChAlert(); setPlayMatch(Object.assign({}, m)); }, style: Object.assign({}, goldBtnS, { flex: 1.3, padding: "12px", fontSize: "13px" }) }, "PLAY NOW")))); } if (nudgeAlert) { return h("div", { style: { position: "fixed", inset: 0, background: "rgba(8,1,15,0.82)", backdropFilter: "blur(7px)", display: "flex", alignItems: "center", justifyContent: "center", zIndex: 240, padding: "20px" } }, h("div", { style: { width: "min(400px, 100%)", background: "linear-gradient(180deg, #2a1140 0%, #130325 100%)", border: "1px solid rgba(199,155,242,0.55)", borderRadius: "18px", boxShadow: "0 30px 80px rgba(0,0,0,0.75), 0 0 40px rgba(150,80,200,0.2)", padding: "26px 22px", textAlign: "center" } }, h("div", { style: { display: "flex", justifyContent: "center", marginBottom: "12px" } }, avatarFor(nudgeAlert.name, 62, false)), h("div", { style: { fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: "24px", letterSpacing: "1.5px", color: "#DCC6FF", lineHeight: 1.15 } }, nudgeAlert.name.toUpperCase() + " NUDGED YOU"), h("div", { style: { fontSize: "13px", color: "rgba(245,237,216,0.85)", margin: "10px 0 16px", lineHeight: 1.5 } }, "Up for a shootout?" + (nudgeAlert.count > 1 ? " (+" + (nudgeAlert.count - 1) + " more nudge" + (nudgeAlert.count > 2 ? "s" : "") + ")" : "")), h("div", { style: { display: "flex", gap: "9px" } }, h("button", { onClick: dismissNudgeAlert, style: Object.assign({}, ghostBtnS, { flex: 1, padding: "12px", fontSize: "13px" }) }, "CLOSE"), h("button", { onClick: function() { dismissNudgeAlert(); setChTab(myOpen ? "open" : "friends"); setChallengeOpen(true); }, style: Object.assign({}, goldBtnS, { flex: 1.4, padding: "12px", fontSize: "13px" }) }, "FIND OPPONENT")))); } return null; }; // ── PLAY screen ────────────────────────────────────────────────────────────── if (playMatch) { var oppId = playMatch.isNew ? playMatch.opponentId : oppOf(playMatch); // real responding matches wait for the §26.13 v6 resume fetch before mounting // (committed rounds + revealed results must seed the play state, not race it) var playReady = playMatch.isNew || isPenTestId(playMatch.id) || !!playMatch._resume; return h("div", { style: duelBgS }, playReady ? h(PenaltyPlay, { match: playMatch, resume: playMatch._resume || null, oppName: oppId ? nameFor(oppId) : "OPEN", myName: (profile && profile.team_name) || "You", stats: st, isMobile: isMobile, DG: DG, goldBtnS: goldBtnS, avatarFor: avatarFor, onReveal: function(kind, r, z) { return revealMove(playMatch, kind, r, z); }, onExit: function() { setPlayMatch(null); }, onSubmit: function(shots, dives) { submitTurn(playMatch, shots, dives); } }) : h("div", { style: { minHeight: "50vh", display: "flex", alignItems: "center", justifyContent: "center" } }, h("div", { className: "ft-spin" }, footballSpinner(48))), busy ? h("div", { style: { position: "fixed", inset: 0, background: "rgba(7,2,14,0.6)", display: "flex", alignItems: "center", justifyContent: "center", zIndex: 220 } }, h("div", { className: "ft-spin" }, footballSpinner(48))) : null, toast ? h("div", { style: { position: "fixed", bottom: "26px", left: "50%", transform: "translateX(-50%)", maxWidth: "min(88vw,380px)", width: "max-content", background: "linear-gradient(180deg,#2a1140,#15052a)", border: "1px solid rgba(199,155,242,0.4)", color: "#F5EDD8", padding: "12px 18px", borderRadius: "13px", textAlign: "center", lineHeight: 1.35, fontSize: "13px", zIndex: 400, boxShadow: "0 14px 40px rgba(0,0,0,0.5)" } }, toast) : null); } // ── main render ──────────────────────────────────────────────────────────── return h("div", { style: duelBgS }, summaryMatch ? null : subNav, summaryMatch ? renderSummary() : (tab === "leaderboard" ? renderLeaderboard() : tab === "history" ? renderHistory() : renderDashboard()), challengeOpen ? renderChallengeModal() : null, inboxOpen ? renderInboxModal() : null, howOpen ? renderHowModal() : null, // blocking challenge/nudge popup — not over the summary (let the win moment breathe) or other modals (!summaryMatch && !challengeOpen && !inboxOpen && !howOpen) ? renderPenAlert() : null, (busy && !challengeOpen && !inboxOpen && !howOpen) ? h("div", { style: { position: "fixed", inset: 0, background: "rgba(7,2,14,0.55)", display: "flex", alignItems: "center", justifyContent: "center", zIndex: 220 } }, h("div", { className: "ft-spin" }, footballSpinner(48))) : null, toast ? h("div", { style: { position: "fixed", bottom: "26px", left: "50%", transform: "translateX(-50%)", maxWidth: "min(88vw,380px)", width: "max-content", background: "linear-gradient(180deg,#2a1140,#15052a)", border: "1px solid rgba(199,155,242,0.4)", color: "#F5EDD8", padding: "12px 18px", borderRadius: "13px", textAlign: "center", lineHeight: 1.35, fontSize: "13px", zIndex: 400, boxShadow: "0 14px 40px rgba(0,0,0,0.5)" } }, toast) : null); } // FUT-style nav icons (module scope so App can use them; same inline-SVG convention as dIcon). function navIcon(name, size) { var s = size || 22; var defs = { lineup: [["rect", { x: 4, y: 3, width: 16, height: 18, rx: 2.2 }], ["path", { d: "M9.2 3 V1.7 h5.6 V3" }], ["circle", { cx: 12, cy: 7.4, r: 1.05, fill: "currentColor", stroke: "none" }], ["circle", { cx: 7.8, cy: 12.4, r: 1.05, fill: "currentColor", stroke: "none" }], ["circle", { cx: 16.2, cy: 12.4, r: 1.05, fill: "currentColor", stroke: "none" }], ["circle", { cx: 12, cy: 17, r: 1.05, fill: "currentColor", stroke: "none" }], ["path", { d: "M12 7.4 7.8 12.4 M12 7.4 16.2 12.4 M7.8 12.4 12 17 M16.2 12.4 12 17", strokeWidth: 1.25 }]], card: [["rect", { x: 5.2, y: 2.6, width: 13.6, height: 18.8, rx: 2.2 }], ["circle", { cx: 12, cy: 9.4, r: 2.5 }], ["path", { d: "M7.6 17.6 c0 -2.6 2 -3.9 4.4 -3.9 s4.4 1.3 4.4 3.9" }]], swords: [["polyline", { points: "14.5 17.5 3 6 3 3 6 3 17.5 14.5" }], ["line", { x1: 13, x2: 19, y1: 19, y2: 13 }], ["line", { x1: 16, x2: 20, y1: 16, y2: 20 }], ["line", { x1: 19, x2: 21, y1: 21, y2: 19 }], ["polyline", { points: "14.5 6.5 18 3 21 3 21 6 17.5 9.5" }], ["line", { x1: 5, x2: 9, y1: 14, y2: 18 }], ["line", { x1: 7, x2: 4, y1: 17, y2: 20 }], ["line", { x1: 3, x2: 5, y1: 19, y2: 21 }]], user: [["circle", { cx: 12, cy: 8, r: 3.6, fill: "currentColor", stroke: "none" }], ["path", { d: "M4.6 20 c0 -4.1 3.3 -6.6 7.4 -6.6 s7.4 2.5 7.4 6.6 Z", fill: "currentColor", stroke: "none" }]], chev: [["path", { d: "M6 9.5 12 15 18 9.5", strokeWidth: 2.4 }]], penalty: [["circle", { cx: 12, cy: 12, r: 8.5 }], ["path", { d: "M12 6.6 16 9.5 14.5 14.2 9.5 14.2 8 9.5z", fill: "currentColor", stroke: "none" }], ["path", { d: "M12 3.5 V6.6 M4.3 9 8 10.6 M19.7 9 16 10.6 M8.6 19.1 9.5 14.2 M15.4 19.1 14.5 14.2", strokeWidth: 1.4 }]] }; return h("svg", { width: s, height: s, viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 1.9, strokeLinecap: "round", strokeLinejoin: "round", style: { display: "block", flexShrink: 0 } }, (defs[name] || []).map(function(p, i) { return h(p[0], Object.assign({ key: i }, p[1])); })); } // Deep-link the entry tab from the URL so a Webflow home-page link can open a specific section: // ?tab=card / ?tab=duels / ?tab=lineup (a #card / #duels / #lineup hash also works). // Auth-redirect hashes (#access_token=…) are ignored. Defaults to lineup. (§21) function tabFromUrl() { try { var m = (window.location.search || "").match(/[?&]tab=([^&#]+)/i); var q = m ? decodeURIComponent(m[1]).toLowerCase().trim() : ""; var hash = (window.location.hash || "").replace(/^#/, "").toLowerCase().trim(); var raw = q || (hash && hash.indexOf("access_token") < 0 ? hash : ""); var map = { lineup: "lineup", lineups: "lineup", "lineup-builder": "lineup", builder: "lineup", card: "card", cards: "card", cardmaker: "card", "card-maker": "card", duel: "weekendDuel", duels: "weekendDuel", weekendduel: "weekendDuel", penalty: "penalty", penalties: "penalty", shootout: "penalty", "penalty-shootout": "penalty", penaltyshootout: "penalty" }; return map[raw] || "lineup"; } catch (e) { return "lineup"; } } function App() { var isMobile = useWindowSize().w < 768; var _mode = useState(tabFromUrl), mode = _mode[0], setMode = _mode[1]; var _session = useState(null), session = _session[0], setSession = _session[1]; var _myPlayers = useState([]), myPlayers = _myPlayers[0], setMyPlayers = _myPlayers[1]; var _myLineups = useState([]), myLineups = _myLineups[0], setMyLineups = _myLineups[1]; var _authOpen = useState(false), authOpen = _authOpen[0], setAuthOpen = _authOpen[1]; var _menuOpen = useState(false), menuOpen = _menuOpen[0], setMenuOpen = _menuOpen[1]; var _pendingCard = useState(null), pendingCard = _pendingCard[0], setPendingCard = _pendingCard[1]; var _pendingLineup = useState(null), pendingLineup = _pendingLineup[0], setPendingLineup = _pendingLineup[1]; var _cta = useState(false), ctaOpen = _cta[0], setCtaOpen = _cta[1]; var _ctaTrig = useState(false), ctaTriggered = _ctaTrig[0], setCtaTriggered = _ctaTrig[1]; // CTA has fired → the persistent coin shows var _earnedC = useState({}), earnedCoupons = _earnedC[0], setEarnedCoupons = _earnedC[1]; // {MYLINEUP, MYCARD} earned by a signed-out user var userRef = useRef(null), ctaTrigRef = useRef(false), earnedRef = useRef({}); var user = session && session.user; userRef.current = user; ctaTrigRef.current = ctaTriggered; earnedRef.current = earnedCoupons; // Fetch the signed-in user's custom players (= cards) and sync the global lookup. var refreshMyPlayers = function() { if (!sb || !session) { setMyPlayers([]); setCustomPlayers([]); return Promise.resolve([]); } return sb.from("players").select("*").order("created_at", { ascending: false }).then(function(res) { var list = (res.data || []).map(rowToPlayer); setCustomPlayers(list); setMyPlayers(list); return list; }); }; // Fetch the signed-in user's saved lineups. var refreshMyLineups = function() { if (!sb || !session) { setMyLineups([]); return Promise.resolve([]); } return sb.from("lineups").select("*").order("created_at", { ascending: false }).then(function(res) { setMyLineups(res.data || []); return res.data || []; }); }; // Track auth session (initial + live changes). useEffect(function() { if (!sb) return; sb.auth.getSession().then(function(res) { setSession(res.data ? res.data.session : null); }); var sub = sb.auth.onAuthStateChange(function(_evt, s) { setSession(s); }); return function() { try { sub.data.subscription.unsubscribe(); } catch (_) {} }; }, []); // When the session changes, (re)load or clear the saved collection. useEffect(function() { if (session) { refreshMyPlayers(); refreshMyLineups(); setCtaOpen(false); } // signing in dismisses the signup CTA else { setMyPlayers([]); setCustomPlayers([]); setMyLineups([]); if (mode === "account") setMode("lineup"); } }, [session && session.user && session.user.id]); var requireAuth = function() { setAuthOpen(true); }; var signOut = function() { setMenuOpen(false); if (sb) sb.auth.signOut(); }; // Nudge a signed-out user to sign up. Each export earns its coupon (MYLINEUP / MYCARD); // the modal pops on the first nudge OR whenever a NEW reward is earned (so saving a card // after a lineup re-opens it showing BOTH codes), then a persistent coin keeps it reachable. // Uses refs so a delayed/timer call always sees the CURRENT auth + earned state (no stale closure). var triggerSignupCta = function(coupon) { if (userRef.current) return; // signed in → already have an account, no nudge if (coupon && !earnedRef.current[coupon]) { var n = Object.assign({}, earnedRef.current); n[coupon] = true; earnedRef.current = n; setEarnedCoupons(n); } // open EVERY time a signed-out user saves/downloads (not just the first) — the client wants it to // trigger on all such actions until they sign up. The persistent coin remains for quick re-open. setCtaTriggered(true); setCtaOpen(true); }; // Auto-trigger after 2 minutes of using the app (signed-out only) — grants the MYLINEUP welcome code. useEffect(function() { var t = setTimeout(function() { triggerSignupCta("MYLINEUP"); }, 120000); return function() { clearTimeout(t); }; }, []); // Cross-screen navigation: open a saved item in its builder. var openCardInBuilder = function(card) { setPendingCard(card); setMode("card"); }; var openLineupInBuilder = function(L) { setPendingLineup(L); setMode("lineup"); }; var deleteCardById = function(id) { if (!sb) return Promise.resolve(); return sb.from("players").delete().eq("id", id).then(function() { return refreshMyPlayers(); }); }; var deleteLineupById = function(id) { if (!sb) return Promise.resolve(); return sb.from("lineups").delete().eq("id", id).then(function() { return refreshMyLineups(); }); }; // FUT nav button (compact — original tab size, new design): gold frame + (active/CTA → gold fill, // else dark) + icon | divider | label. The ACCOUNT button passes rounded:true (pill, no divider) + // arrow:true (dropdown chevron) so it reads as a menu, not another octagonal tab. var OCT = "polygon(7px 0, calc(100% - 7px) 0, 100% 7px, 100% calc(100% - 7px), calc(100% - 7px) 100%, 7px 100%, 0 calc(100% - 7px), 0 7px)"; var futBtn = function(opts) { // { label, icon, gold, onClick, title, rounded, arrow } var goldFill = opts.gold, hPx = isMobile ? 34 : 40; var clip = opts.rounded ? "none" : OCT, rad = opts.rounded ? (hPx / 2) + "px" : "0", radIn = opts.rounded ? (hPx / 2 - 2) + "px" : "0"; return h("button", { id: opts.id, onClick: opts.onClick, title: opts.title || opts.label, style: { position: "relative", height: hPx + "px", padding: 0, border: "none", background: "transparent", cursor: "pointer", clipPath: clip, WebkitClipPath: clip, borderRadius: rad, flexShrink: 0, filter: goldFill ? "drop-shadow(0 3px 9px rgba(247,176,28,0.36))" : "drop-shadow(0 2px 7px rgba(0,0,0,0.4))" } }, h("div", { style: { position: "absolute", inset: 0, clipPath: clip, WebkitClipPath: clip, borderRadius: rad, background: "linear-gradient(155deg, #FFEAA8, #E8B83A 50%, #9c6f08)" } }), h("div", { style: { position: "absolute", inset: "2px", clipPath: clip, WebkitClipPath: clip, borderRadius: radIn, background: goldFill ? "linear-gradient(160deg, #FFDE7A 0%, #F0A91E 100%)" : "linear-gradient(160deg, #350d54 0%, #190427 100%)" } }), h("div", { style: { position: "relative", height: "100%", display: "flex", alignItems: "center", justifyContent: "center", gap: isMobile ? "6px" : "8px", padding: isMobile ? "0 10px" : "0 14px", color: goldFill ? "#1A0428" : "#FFC836" } }, navIcon(opts.icon, isMobile ? 15 : 17), opts.rounded ? null : h("div", { style: { width: "1.5px", height: isMobile ? "16px" : "19px", background: goldFill ? "rgba(26,4,40,0.4)" : "rgba(247,176,28,0.5)", flexShrink: 0 } }), h("div", { style: { fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: isMobile ? "12px" : "14px", letterSpacing: "1px", whiteSpace: "nowrap", color: goldFill ? "#1A0428" : "#F5EDD8" } }, opts.label), opts.arrow ? h("span", { style: { display: "flex", marginLeft: "-1px", opacity: 0.8 } }, navIcon("chev", isMobile ? 14 : 16)) : null ) ); }; var tabBtn = function(id, label, icon) { return futBtn({ id: "pstab-" + id, label: label, icon: icon, gold: mode === id, onClick: function() { setMode(id); } }); }; // On mobile the 4 game tabs scroll horizontally; keep the ACTIVE tab in view (e.g. SHOOTOUT, the rightmost). useEffect(function() { if (!isMobile) return; var el = document.getElementById("pstab-" + mode); if (el && el.scrollIntoView) { try { el.scrollIntoView({ block: "nearest", inline: "nearest" }); } catch (_) {} } }, [mode, isMobile]); // Account control: avatar initial + dropdown (signed in) or SIGN IN (signed out). var initial = (user && user.email) ? user.email.charAt(0).toUpperCase() : "?"; var menuItem = function(label, onClick, danger) { return h("button", { onClick: onClick, onMouseEnter: function(e) { e.currentTarget.style.background = "rgba(205,150,2,0.14)"; }, onMouseLeave: function(e) { e.currentTarget.style.background = "transparent"; }, style: { display: "block", width: "100%", textAlign: "left", padding: "12px 16px", background: "transparent", border: "none", color: danger ? "#ff8a96" : "#F5EDD8", fontFamily: "'Anton', 'Bebas Neue', sans-serif", fontSize: "14px", letterSpacing: "1.5px", cursor: "pointer", whiteSpace: "nowrap", borderTop: danger ? "1px solid rgba(255,255,255,0.06)" : "none" } }, label); }; var accountControl = user ? h("div", { style: { position: "relative", flexShrink: 0 } }, futBtn({ label: isMobile ? "ACCOUNT" : (user.email ? user.email.split("@")[0].slice(0, 10).toUpperCase() : "ACCOUNT"), icon: "user", gold: true, rounded: true, arrow: true, title: user.email, onClick: function() { setMenuOpen(function(v) { return !v; }); } }), menuOpen && h("div", { onClick: function() { setMenuOpen(false); }, style: { position: "fixed", inset: 0, zIndex: 60 } }), menuOpen && h("div", { style: { position: "absolute", top: "calc(100% + 8px)", right: 0, minWidth: "230px", background: "linear-gradient(180deg, #2F0745 0%, #1A0428 100%)", border: "1px solid rgba(205,150,2,0.45)", borderRadius: "12px", boxShadow: "0 20px 50px rgba(0,0,0,0.6)", overflow: "hidden", zIndex: 61 } }, h("div", { style: { padding: "14px 16px", borderBottom: "1px solid rgba(205,150,2,0.2)" } }, h("div", { style: { fontSize: "9px", letterSpacing: "2px", color: "rgba(245,237,216,0.45)", textTransform: "uppercase", marginBottom: "4px" } }, "Signed in as"), h("div", { style: { fontSize: "12px", color: "#F5EDD8", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" } }, user.email) ), menuItem("MY COLLECTION", function() { setMenuOpen(false); setMode("account"); }, false), menuItem("SIGN OUT", signOut, true) ) ) : futBtn({ label: "SIGN IN", icon: "user", gold: true, rounded: true, onClick: function() { setAuthOpen(true); } }); var cardProps = { session: session, myPlayers: myPlayers, refreshMyPlayers: refreshMyPlayers, requireAuth: requireAuth, pendingCard: pendingCard, clearPendingCard: function() { setPendingCard(null); }, onCardExported: function() { triggerSignupCta("MYCARD"); } }; var lineupProps = { session: session, myPlayers: myPlayers, refreshMyLineups: refreshMyLineups, requireAuth: requireAuth, pendingLineup: pendingLineup, clearPendingLineup: function() { setPendingLineup(null); }, onLineupExported: function() { triggerSignupCta("MYLINEUP"); }, goToCardMaker: function() { setMode("card"); } }; var dashProps = { user: user, myPlayers: myPlayers, myLineups: myLineups, onEditCard: openCardInBuilder, onOpenLineup: openLineupInBuilder, onDeleteCard: deleteCardById, onDeleteLineup: deleteLineupById, goToCardMaker: function() { setMode("card"); }, goToLineup: function() { setMode("lineup"); } }; var duelProps = { session: session, requireAuth: requireAuth }; var penaltyProps = { session: session, requireAuth: requireAuth }; var screen = mode === "account" ? h(Dashboard, dashProps) : (mode === "lineup" ? h(LineupBuilder, lineupProps) : (mode === "card" ? h(CardBuilder, cardProps) : (mode === "penalty" ? h(PenaltyShootout, penaltyProps) : h(WeekendDuel, duelProps)))); var navLogoSrc = "https://cdn.prod.website-files.com/69eb6394026dd7faab0bad78/69f9aeb58349a6169af82506_Logo%20with%20name.avif"; return h("div", { style: { minHeight: "100vh", background: "radial-gradient(ellipse at 50% -10%, #4a1166 0%, #2F0745 40%, #1A0428 100%)", fontFamily: "'Inter', sans-serif", overflowX: "hidden", width: "100%" } }, // Original compact layout, new FUT-style tabs. Logo (links to the main site) left/top. isMobile // Mobile: logo + account on the top row, game tabs on their own row below. ? h("div", { style: { position: "sticky", top: 0, zIndex: 50, background: "rgba(8,1,15,0.6)", backdropFilter: "blur(10px)", borderBottom: "1px solid rgba(205,150,2,0.22)" } }, h("div", { style: { height: "52px", padding: "0 14px", display: "flex", alignItems: "center", justifyContent: "space-between", gap: "10px" } }, h("a", { href: "https://www.hometeamsoccer.com/", title: "hometeamsoccer.com", style: { display: "flex", alignItems: "center", flexShrink: 0, lineHeight: 0 } }, h("img", { src: navLogoSrc, alt: "Home Team", style: { height: "24px", width: "auto", display: "block", objectFit: "contain" } })), accountControl ), h("div", { className: "hide-scrollbar", style: { overflowX: "auto", padding: "0 10px 9px" } }, h("div", { style: { display: "inline-flex", gap: "8px", minWidth: "100%", justifyContent: "center", boxSizing: "border-box", padding: "0 2px" } }, tabBtn("lineup", "LINEUP", "lineup"), tabBtn("card", "CARD MAKER", "card"), tabBtn("weekendDuel", "DUELS", "swords"), tabBtn("penalty", "SHOOTOUT", "penalty") ) ) ) // Desktop: single row — logo left, tabs + account right. : h("div", { style: { height: "72px", padding: "0 28px", display: "flex", alignItems: "center", justifyContent: "space-between", gap: "10px", borderBottom: "1px solid rgba(205,150,2,0.22)", background: "rgba(8,1,15,0.6)", backdropFilter: "blur(10px)", position: "sticky", top: 0, zIndex: 50 } }, h("a", { href: "https://www.hometeamsoccer.com/", title: "hometeamsoccer.com", style: { display: "flex", alignItems: "center", flexShrink: 0, lineHeight: 0 } }, h("img", { src: navLogoSrc, alt: "Home Team", style: { height: "32px", width: "auto", display: "block", objectFit: "contain" } })), h("div", { style: { display: "flex", gap: "10px", flexShrink: 0, alignItems: "center" } }, tabBtn("lineup", "LINEUP", "lineup"), tabBtn("card", "CARD MAKER", "card"), tabBtn("weekendDuel", "DUELS", "swords"), tabBtn("penalty", "SHOOTOUT", "penalty"), accountControl ) ), screen, authOpen && h(AuthModal, { onClose: function() { setAuthOpen(false); } }), (!user && ctaOpen) && h(SignupCTA, { coupons: Object.keys(earnedCoupons).length ? Object.keys(earnedCoupons) : ["MYLINEUP"], title: (earnedCoupons.MYCARD && earnedCoupons.MYLINEUP) ? "NICE WORK!" : (earnedCoupons.MYCARD && !earnedCoupons.MYLINEUP) ? "NICE CARD!" : "NICE LINEUP!", onSignup: function() { setCtaOpen(false); setAuthOpen(true); }, onClose: function() { setCtaOpen(false); } }), // Persistent coin — after the CTA has fired, a signed-out user can re-open it anytime. (!user && ctaTriggered && !ctaOpen && !authOpen) && h("button", { onClick: function() { setCtaOpen(true); }, title: "Sign up & claim your coin reward", style: { position: "fixed", bottom: isMobile ? "18px" : "26px", right: isMobile ? "18px" : "26px", width: "58px", height: "58px", borderRadius: "50%", border: "none", cursor: "pointer", padding: 0, background: "linear-gradient(155deg, #FFDE7A, #F4AE1C)", boxShadow: "0 8px 24px rgba(247,176,28,0.5), 0 0 0 5px rgba(247,176,28,0.16)", display: "flex", alignItems: "center", justifyContent: "center", zIndex: 190 } }, h("svg", { width: 30, height: 30, viewBox: "0 0 24 24", fill: "#1A0428" }, h("path", { d: "M12 3 14.1 9.1 20.6 9.2 15.4 13.1 17.3 19.3 12 15.6 6.7 19.3 8.6 13.1 3.4 9.2 9.9 9.1z" }))) ); } ReactDOM.createRoot(document.getElementById("root")).render(h(App));