/* AcquiCup — shared components + helpers. Exports to window. */
const { useState, useEffect, useRef, useMemo } = React;

/* ----------------------------- helpers ----------------------------- */
const T = (code) => (window.ACQ.TEAMS[code] || { code, name: code, color: "#888" });
const V = (key) => window.ACQ.VENUES[key] || { city: "", stadium: "" };
// Knockout fixtures whose participants are still placeholders ("Winner Match 73",
// "2nd Group A" — imported with an empty iso) have no real teams yet, so they
// can't be predicted, priced, or counted as a missing prediction.
const isTbd = (match) => !T(match.home).iso || !T(match.away).iso;

function fmtDate(d) {
  const dt = new Date(d + "T12:00:00");
  return dt.toLocaleDateString("en-US", { weekday: "short", day: "numeric", month: "short" });
}
function fmtDay(d) {
  const dt = new Date(d + "T12:00:00");
  return dt.toLocaleDateString("en-US", { weekday: "long", day: "numeric", month: "long" });
}

/* --------------------------- timezone (display) --------------------------- */
// Match kickoffs are stored as a wall-clock date ("2026-06-13") + time ("13:00") in a
// single SOURCE zone (window.ACQ.CONFIG.matchTimezone, default "UTC"; see server
// matchLock.js). We display them in the player's chosen zone. The ACTIVE display zone
// resolves: per-device choice (localStorage) → account default (me.timezone, applied via
// initTimezone) → browser-detected. This is DISPLAY ONLY — it never changes stored data
// or the server-authoritative kickoff lock.
const TZ_LS_KEY = "acq.tz";

function detectTimezone() {
  try {
    return Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
  } catch {
    return "UTC";
  }
}

const tzStore = {
  // active = the resolved display zone (always a string after module load). devicePinned
  // = the user explicitly chose it on THIS device (localStorage), so initTimezone must
  // not override it with the account default.
  active: detectTimezone(),
  devicePinned: false,
  listeners: new Set(),
  notify() { this.listeners.forEach((l) => l()); },
  subscribe(l) { this.listeners.add(l); return () => this.listeners.delete(l); },
};
try {
  const stored = window.localStorage.getItem(TZ_LS_KEY);
  if (stored) { tzStore.active = stored; tzStore.devicePinned = true; }
} catch { /* private mode / disabled storage — fall back to detected */ }

// Set the active display zone for THIS device (header dropdown + Settings save) and persist
// it. Notifies subscribers so every match date/time re-renders immediately.
function setTimezone(tz) {
  const next = tz || detectTimezone();
  tzStore.devicePinned = true;
  try { window.localStorage.setItem(TZ_LS_KEY, next); } catch { /* best effort */ }
  if (next === tzStore.active) return;
  tzStore.active = next;
  tzStore.notify();
}

// Apply the account default once `me` is known after bootstrap — but ONLY when the user
// hasn't pinned a zone on this device. Idempotent; safe to call on every refresh.
function initTimezone(accountDefault) {
  if (tzStore.devicePinned) return;
  const next = accountDefault || detectTimezone();
  if (next === tzStore.active) return;
  tzStore.active = next;
  tzStore.notify();
}

// Clear the per-device pin and fall back to the browser-detected zone (Settings "reset
// to auto-detected"). A following initTimezone(accountDefault) may then re-adopt the
// account default if one is still set.
function resetTimezone() {
  try { window.localStorage.removeItem(TZ_LS_KEY); } catch { /* best effort */ }
  tzStore.devicePinned = false;
  const next = detectTimezone();
  if (next === tzStore.active) return;
  tzStore.active = next;
  tzStore.notify();
}

const getActiveTimezone = () => tzStore.active;

// Subscribe a component to the active display zone (re-renders on change).
function useTimezone() {
  return React.useSyncExternalStore(
    (cb) => tzStore.subscribe(cb),
    () => tzStore.active
  );
}

// Source zone the stored match wall-clock values are in (server CONFIG.matchTimezone).
function matchSourceTz() {
  return (window.ACQ && window.ACQ.CONFIG && window.ACQ.CONFIG.matchTimezone) || "UTC";
}

// Offset (ms) of `tz` at a given UTC instant (DST-correct). Mirrors server matchLock.js.
function tzOffsetMs(utcMs, tz) {
  try {
    const dtf = new Intl.DateTimeFormat("en-US", {
      timeZone: tz, hour12: false,
      year: "numeric", month: "2-digit", day: "2-digit",
      hour: "2-digit", minute: "2-digit", second: "2-digit",
    });
    const p = {};
    for (const part of dtf.formatToParts(new Date(utcMs))) p[part.type] = part.value;
    const hour = p.hour === "24" ? 0 : Number(p.hour);
    const asIfUtc = Date.UTC(+p.year, +p.month - 1, +p.day, hour, +p.minute, +p.second);
    return asIfUtc - utcMs;
  } catch {
    return 0;
  }
}

// Stored (date,time) wall-clock in the SOURCE zone → epoch ms, or null if unparseable.
// Mirrors server matchLock.kickoffTimestamp so the displayed instant matches the lock.
function matchInstant(date, time, sourceTz) {
  if (!date) return null;
  const dm = /^(\d{4})-(\d{2})-(\d{2})$/.exec(String(date));
  if (!dm) return null;
  const [, y, mo, d] = dm.map(Number);
  let hh = 0, mi = 0;
  const tm = typeof time === "string" ? /^(\d{2}):(\d{2})/.exec(time) : null;
  if (tm) { hh = Number(tm[1]); mi = Number(tm[2]); }
  if (hh > 23 || mi > 59) return null;
  const guess = Date.UTC(y, mo - 1, d, hh, mi);
  if (Number.isNaN(guess)) return null;
  if (sourceTz === "UTC") return guess;
  return guess - tzOffsetMs(guess, sourceTz);
}

// Short zone label at an instant, e.g. "EDT" / "GMT+4". Falls back to the id.
function tzAbbrev(tz, atMs) {
  try {
    const parts = new Intl.DateTimeFormat("en-US", { timeZone: tz, timeZoneName: "short" })
      .formatToParts(new Date(atMs));
    const p = parts.find((x) => x.type === "timeZoneName");
    return p ? p.value : tz;
  } catch {
    return tz;
  }
}

// Match date/time rendered in the player's chosen zone. `tz` should come from useTimezone()
// so the component re-renders when the zone changes. They take the whole match (need BOTH
// date and time to place the instant — a zone shift can cross midnight). On unparseable
// data they fall back to the stored-string formatters.
function fmtMatchDate(match, tz) {
  const ms = matchInstant(match && match.date, match && match.time, matchSourceTz());
  if (ms == null) return match && match.date ? fmtDate(match.date) : "";
  return new Date(ms).toLocaleDateString("en-US", { weekday: "short", day: "numeric", month: "short", timeZone: tz });
}
function fmtMatchDay(match, tz) {
  const ms = matchInstant(match && match.date, match && match.time, matchSourceTz());
  if (ms == null) return match && match.date ? fmtDay(match.date) : "";
  return new Date(ms).toLocaleDateString("en-US", { weekday: "long", day: "numeric", month: "long", timeZone: tz });
}
// Time + short zone label, e.g. "09:00 EDT" — replaces the old hardcoded "13:00 UTC".
function fmtMatchTime(match, tz) {
  const ms = matchInstant(match && match.date, match && match.time, matchSourceTz());
  if (ms == null) return (match && match.time) || "";
  const time = new Date(ms).toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit", hour12: false, timeZone: tz });
  return time + " " + tzAbbrev(tz, ms);
}

// IANA zone list for the picker — the runtime's own list when available, else a curated
// fallback covering the common business zones.
const COMMON_TZS = [
  "UTC", "Europe/London", "Europe/Paris", "Europe/Berlin", "Europe/Madrid", "Europe/Rome",
  "Europe/Moscow", "Africa/Cairo", "Africa/Johannesburg", "Asia/Dubai", "Asia/Karachi",
  "Asia/Kolkata", "Asia/Dhaka", "Asia/Bangkok", "Asia/Singapore", "Asia/Shanghai",
  "Asia/Tokyo", "Asia/Seoul", "Australia/Sydney", "Pacific/Auckland",
  "America/Sao_Paulo", "America/New_York", "America/Chicago", "America/Denver",
  "America/Los_Angeles", "America/Mexico_City", "America/Bogota", "America/Argentina/Buenos_Aires",
];
function tzList() {
  try {
    if (typeof Intl.supportedValuesOf === "function") {
      const list = Intl.supportedValuesOf("timeZone");
      if (Array.isArray(list) && list.length) return list;
    }
  } catch { /* fall through */ }
  return COMMON_TZS;
}

// UTC offset of `tz` as a compact signed label at an instant, e.g. "+3", "-5", "+5:30",
// "+0". Derived from the same DST-correct offset used to place the kickoff instant.
function tzOffsetShort(tz, atMs) {
  const min = Math.round(tzOffsetMs(atMs, tz) / 60000);
  const h = Math.floor(Math.abs(min) / 60);
  const m = Math.abs(min) % 60;
  return (min < 0 ? "-" : "+") + h + (m ? ":" + String(m).padStart(2, "0") : "");
}

// Curated abbreviations for common NO-DST business zones where Intl (en-US) only yields a
// bare "GMT+x". Restricted to zones that don't observe DST, so a fixed code is always
// correct, and to unambiguous codes. Anything not listed keeps its GMT offset.
const TZ_ABBREV = {
  "Asia/Dubai": "GST", "Asia/Muscat": "GST",
  "Asia/Riyadh": "AST", "Asia/Qatar": "AST", "Asia/Kuwait": "AST", "Asia/Bahrain": "AST",
  "Asia/Kolkata": "IST", "Asia/Karachi": "PKT",
  "Asia/Bangkok": "ICT", "Asia/Jakarta": "WIB",
  "Asia/Singapore": "SGT", "Asia/Hong_Kong": "HKT",
  "Asia/Tokyo": "JST", "Asia/Seoul": "KST",
};

// Compact zone label combining abbreviation + offset, e.g. "CEST +2", "EDT -4", "GST +4",
// "UTC", or "GMT+9" when no abbreviation is known (Intl returns the offset itself there, so
// we don't duplicate it). Used on the picker trigger and each option's right column.
function tzMetaLabel(tz, atMs) {
  if (tz === "UTC") return "UTC";
  const ab = tzAbbrev(tz, atMs);             // "EDT" / "GMT+4" / "UTC"
  const off = tzOffsetShort(tz, atMs);       // "+3" / "-4" / "+5:30"
  if (/^(GMT|UTC)/i.test(ab)) {              // Intl gave only an offset…
    const fixed = TZ_ABBREV[tz];            // …use a curated abbrev if we have one
    return (fixed ? fixed + " " : "GMT") + off; // "GST +4" or "GMT+9"
  }
  return ab + " " + off;                      // real abbreviation → "EDT -4"
}

// Searchable timezone combobox. A native <select> can't show live offsets/abbreviations or
// be searched, so this is a custom popover: a compact trigger (abbrev + offset) opening a
// filterable list. Generic — `value`/`onChange` let it drive both the live display zone
// (header) and the saved-default picker (Settings). `compact` = the tight header trigger;
// otherwise it's a full-width field. `dropUp` opens the list upward (mobile drawer footer).
function TimezoneCombo({ value, onChange, compact = false, dropUp = false, ariaLabel = "Timezone" }) {
  const [open, setOpen] = useState(false);
  const [q, setQ] = useState("");
  const [hi, setHi] = useState(0); // highlighted option index (keyboard nav)
  const boxRef = useRef(null);
  const inputRef = useRef(null);
  // A single reference instant for all labels in one open session (offsets are ~constant
  // over the contest window; this keeps every row consistent and avoids per-row clock reads).
  const nowRef = useRef(Date.now());

  const zones = useMemo(() => {
    const list = tzList();
    return list.includes(value) ? list : [value, ...list];
  }, [value]);

  const filtered = useMemo(() => {
    const needle = q.trim().toLowerCase();
    if (!needle) return zones;
    return zones.filter((z) => {
      const hay = (z.replace(/_/g, " ") + " " + tzMetaLabel(z, nowRef.current)).toLowerCase();
      return hay.includes(needle);
    });
  }, [zones, q]);

  useEffect(() => { setHi(0); }, [q]);
  useEffect(() => {
    if (!open) return;
    const onDoc = (e) => { if (boxRef.current && !boxRef.current.contains(e.target)) setOpen(false); };
    const onKey = (e) => { if (e.key === "Escape") { setOpen(false); } };
    document.addEventListener("mousedown", onDoc);
    document.addEventListener("keydown", onKey);
    return () => { document.removeEventListener("mousedown", onDoc); document.removeEventListener("keydown", onKey); };
  }, [open]);
  useEffect(() => { if (open && inputRef.current) inputRef.current.focus(); }, [open]);

  function pick(z) { onChange(z); setOpen(false); setQ(""); }
  function onInputKey(e) {
    if (e.key === "ArrowDown") { e.preventDefault(); setHi((i) => Math.min(i + 1, filtered.length - 1)); }
    else if (e.key === "ArrowUp") { e.preventDefault(); setHi((i) => Math.max(i - 1, 0)); }
    else if (e.key === "Enter") { e.preventDefault(); if (filtered[hi]) pick(filtered[hi]); }
  }

  const meta = tzMetaLabel(value, nowRef.current);

  return (
    <div ref={boxRef} style={{ position: "relative", width: compact ? "auto" : "100%" }}>
      <button type="button" aria-label={ariaLabel} aria-expanded={open} title={"Display timezone — " + value}
        onClick={() => { nowRef.current = Date.now(); setOpen((o) => !o); }}
        className="row gap-6 pointer"
        style={{ alignItems: "center", background: "transparent",
          border: compact ? "1px solid var(--line)" : "1px solid var(--line)", borderRadius: 9,
          color: "var(--ink-2)", fontFamily: "var(--ff)", fontSize: 12.5, fontWeight: 700,
          padding: compact ? "5px 8px" : "10px 12px", width: compact ? "auto" : "100%",
          justifyContent: compact ? "flex-start" : "space-between", minWidth: 0 }}>
        <span className="row gap-6" style={{ alignItems: "center", minWidth: 0 }}>
          <Icon name="globe" size={15} stroke={1.8} />
          {!compact && <span className="nowrap" style={{ fontWeight: 600, overflow: "hidden", textOverflow: "ellipsis" }}>{value.replace(/_/g, " ")}</span>}
          <span className="mono nowrap" style={{ color: compact ? "var(--ink-2)" : "var(--ink-soft)" }}>{meta}</span>
        </span>
        <Icon name="chevronDown" size={14} stroke={2} style={{ flex: "none", opacity: .7 }} />
      </button>

      {open && (
        <div role="listbox" style={{ position: "absolute", right: compact ? 0 : "auto", left: compact ? "auto" : 0,
          [dropUp ? "bottom" : "top"]: "calc(100% + 6px)", zIndex: 90, width: compact ? 280 : "100%", minWidth: 240,
          maxWidth: "92vw", background: "var(--surface)", border: "1px solid var(--line)", borderRadius: 12,
          boxShadow: "var(--shadow-pop)", overflow: "hidden", display: "flex", flexDirection: "column" }}>
          <div className="row gap-6" style={{ alignItems: "center", padding: "8px 10px", borderBottom: "1px solid var(--line)" }}>
            <Icon name="search" size={15} stroke={1.8} style={{ color: "var(--ink-soft)", flex: "none" }} />
            <input ref={inputRef} value={q} onChange={(e) => setQ(e.target.value)} onKeyDown={onInputKey}
              placeholder="Search city or zone…" aria-label="Search timezone"
              style={{ flex: "1 1 auto", minWidth: 0, background: "transparent", border: "none", outline: "none",
                color: "var(--ink)", fontFamily: "var(--ff)", fontSize: 13.5 }} />
          </div>
          <div style={{ maxHeight: 300, overflowY: "auto", padding: 4 }}>
            {filtered.length === 0 && (
              <div className="muted" style={{ padding: "14px 12px", fontSize: 13 }}>No matching timezone</div>
            )}
            {filtered.map((z, i) => {
              const sel = z === value;
              const active = i === hi;
              return (
                <button key={z} type="button" role="option" aria-selected={sel}
                  onMouseEnter={() => setHi(i)} onClick={() => pick(z)}
                  className="row between pointer"
                  style={{ width: "100%", textAlign: "left", gap: 10, alignItems: "center",
                    padding: "8px 10px", borderRadius: 8, border: "none", cursor: "pointer",
                    background: active ? "var(--accent-soft)" : "transparent",
                    color: sel ? "var(--accent)" : "var(--ink-2)", fontFamily: "var(--ff)" }}>
                  <span className="nowrap" style={{ fontSize: 13.5, fontWeight: sel ? 700 : 600,
                    overflow: "hidden", textOverflow: "ellipsis" }}>{z.replace(/_/g, " ")}</span>
                  <span className="row gap-6" style={{ alignItems: "center", flex: "none" }}>
                    <span className="mono muted" style={{ fontSize: 11.5 }}>{tzMetaLabel(z, nowRef.current)}</span>
                    {sel && <Icon name="check" size={14} stroke={2.4} />}
                  </span>
                </button>
              );
            })}
          </div>
        </div>
      )}
    </div>
  );
}

// On-page picker for the LIVE display zone (header + mobile drawer). Wraps TimezoneCombo
// over the active zone; selecting pins the zone for this device. `compact` = header trigger.
function TimezonePicker({ compact = false }) {
  const tz = useTimezone();
  return (
    <TimezoneCombo value={tz} onChange={setTimezone} compact={compact} dropUp={!compact}
      ariaLabel="Display timezone" />
  );
}
/* ----------------------------- countries ----------------------------- */
// Full ISO-3166-1 alpha-2 list (lowercased — the form flagcdn.com expects). We keep only
// the codes and derive human names at runtime via Intl.DisplayNames (already relied on
// across the app for Intl.DateTimeFormat), so there's no ~200-line name table to maintain
// and names are properly localised English. flagcdn serves a flag SVG per code (CSP allows
// it — same CDN as TeamBadge). Stored profile values are the NAME (matches the existing
// free-text `country` convention), so the flag is looked up by name on display.
const COUNTRY_ISO2 = [
  "af","al","dz","ad","ao","ag","ar","am","au","at","az","bs","bh","bd","bb","by","be","bz",
  "bj","bt","bo","ba","bw","br","bn","bg","bf","bi","cv","kh","cm","ca","cf","td","cl","cn",
  "co","km","cg","cd","cr","ci","hr","cu","cy","cz","dk","dj","dm","do","ec","eg","sv","gq",
  "er","ee","sz","et","fj","fi","fr","ga","gm","ge","de","gh","gr","gd","gt","gn","gw","gy",
  "ht","hn","hu","is","in","id","ir","iq","ie","il","it","jm","jp","jo","kz","ke","ki","kp",
  "kr","kw","kg","la","lv","lb","ls","lr","ly","li","lt","lu","mg","mw","my","mv","ml","mt",
  "mh","mr","mu","mx","fm","md","mc","mn","me","ma","mz","mm","na","nr","np","nl","nz","ni",
  "ne","ng","mk","no","om","pk","pw","ps","pa","pg","py","pe","ph","pl","pt","qa","ro","ru",
  "rw","kn","lc","vc","ws","sm","st","sa","sn","rs","sc","sl","sg","sk","si","sb","so","za",
  "ss","es","lk","sd","sr","se","ch","sy","tw","tj","tz","th","tl","tg","to","tt","tn","tr",
  "tm","tv","ug","ua","ae","gb","us","uy","uz","vu","va","ve","vn","ye","zm","zw",
  // common dependencies / regions people may pick
  "hk","mo","pr","xk",
];
// [{ iso, name }] sorted by display name. Built once. Falls back to the upper-cased code
// when Intl can't name a region (e.g. "XK" Kosovo on some engines).
const COUNTRIES = (() => {
  let dn = null;
  try { dn = new Intl.DisplayNames(["en"], { type: "region" }); } catch (e) { /* older engine */ }
  const KOSOVO = "xk";
  return COUNTRY_ISO2
    .map((iso) => {
      let name = iso.toUpperCase();
      try { if (dn) name = dn.of(iso.toUpperCase()) || name; } catch (e) { /* keep code */ }
      if (iso === KOSOVO && (!name || name === "XK")) name = "Kosovo";
      return { iso, name };
    })
    .sort((a, b) => a.name.localeCompare(b.name));
})();
const isoForCountry = (name) => {
  if (!name) return null;
  const n = String(name).trim().toLowerCase();
  const hit = COUNTRIES.find((c) => c.name.toLowerCase() === n);
  return hit ? hit.iso : null;
};
// Small flag <img> (flagcdn SVG) with a neutral fallback plate when the iso is missing or
// the image 404s. Self-contained so it doesn't depend on screens-acquiboard's CountryFlag.
function FlagImg({ iso, name, w = 22 }) {
  const [err, setErr] = useState(false);
  const h = Math.round(w * 0.72);
  const r = Math.max(2, Math.round(w * 0.16));
  if (err || !iso) {
    return <span className="team-flag fallback" title={name || ""} style={{ width: w, height: h, borderRadius: r,
      background: "var(--surface-2)", border: "1px solid var(--line)", color: "var(--ink-soft)",
      fontSize: Math.round(w * 0.5), display: "inline-flex", alignItems: "center", justifyContent: "center", flex: "none" }}>🌐</span>;
  }
  return <img src={"https://flagcdn.com/" + iso + ".svg"} alt="" title={name || ""} onError={() => setErr(true)}
    style={{ width: w, height: h, borderRadius: r, objectFit: "cover", flex: "none", boxShadow: "inset 0 0 0 1px rgba(15,45,82,.12)" }} />;
}

// CountryCombo — a searchable country dropdown (name + flag). `value` is the country NAME
// (or "" for none); onChange(name) fires on pick, onChange("") on clear. Mirrors the
// TimezoneCombo interaction model (click to open, type to filter, ↑/↓/Enter/Esc, click-out).
function CountryCombo({ value, onChange, ariaLabel = "Country", placeholder = "Select a country…" }) {
  const [open, setOpen] = useState(false);
  const [q, setQ] = useState("");
  const [hi, setHi] = useState(0);
  const boxRef = useRef(null);
  const inputRef = useRef(null);

  const filtered = useMemo(() => {
    const needle = q.trim().toLowerCase();
    if (!needle) return COUNTRIES;
    return COUNTRIES.filter((c) => c.name.toLowerCase().includes(needle) || c.iso === needle);
  }, [q]);

  useEffect(() => { setHi(0); }, [q]);
  useEffect(() => {
    if (!open) return;
    const onDoc = (e) => { if (boxRef.current && !boxRef.current.contains(e.target)) setOpen(false); };
    const onKey = (e) => { if (e.key === "Escape") setOpen(false); };
    document.addEventListener("mousedown", onDoc);
    document.addEventListener("keydown", onKey);
    return () => { document.removeEventListener("mousedown", onDoc); document.removeEventListener("keydown", onKey); };
  }, [open]);
  useEffect(() => { if (open && inputRef.current) inputRef.current.focus(); }, [open]);

  function pick(name) { onChange(name); setOpen(false); setQ(""); }
  function onInputKey(e) {
    if (e.key === "ArrowDown") { e.preventDefault(); setHi((i) => Math.min(i + 1, filtered.length - 1)); }
    else if (e.key === "ArrowUp") { e.preventDefault(); setHi((i) => Math.max(i - 1, 0)); }
    else if (e.key === "Enter") { e.preventDefault(); if (filtered[hi]) pick(filtered[hi].name); }
  }

  const selIso = isoForCountry(value);
  const hasValue = !!(value && String(value).trim());

  return (
    <div ref={boxRef} style={{ position: "relative", width: "100%" }}>
      <button type="button" aria-label={ariaLabel} aria-expanded={open}
        onClick={() => setOpen((o) => !o)} className="row between pointer"
        style={{ alignItems: "center", gap: 8, background: "var(--surface)", border: "1px solid var(--line)",
          borderRadius: "var(--r-md)", color: hasValue ? "var(--ink)" : "var(--ink-soft)",
          fontFamily: "var(--ff)", fontSize: 14, fontWeight: 600, padding: "9px 12px", width: "100%", minWidth: 0 }}>
        <span className="row gap-8" style={{ alignItems: "center", minWidth: 0 }}>
          {hasValue ? <FlagImg iso={selIso} name={value} w={22} /> : null}
          <span className="nowrap" style={{ overflow: "hidden", textOverflow: "ellipsis" }}>{hasValue ? value : placeholder}</span>
        </span>
        <Icon name="chevronDown" size={14} stroke={2} style={{ flex: "none", opacity: .7 }} />
      </button>

      {open && (
        <div role="listbox" style={{ position: "absolute", left: 0, top: "calc(100% + 6px)", zIndex: 90,
          width: "100%", minWidth: 240, maxWidth: "92vw", background: "var(--surface)", border: "1px solid var(--line)",
          borderRadius: 12, boxShadow: "var(--shadow-pop)", overflow: "hidden", display: "flex", flexDirection: "column" }}>
          <div className="row gap-6" style={{ alignItems: "center", padding: "8px 10px", borderBottom: "1px solid var(--line)" }}>
            <Icon name="search" size={15} stroke={1.8} style={{ color: "var(--ink-soft)", flex: "none" }} />
            <input ref={inputRef} value={q} onChange={(e) => setQ(e.target.value)} onKeyDown={onInputKey}
              placeholder="Search country…" aria-label="Search country"
              style={{ flex: "1 1 auto", minWidth: 0, background: "transparent", border: "none", outline: "none",
                color: "var(--ink)", fontFamily: "var(--ff)", fontSize: 13.5 }} />
          </div>
          <div style={{ maxHeight: 300, overflowY: "auto", padding: 4 }}>
            {hasValue && (
              <button type="button" onClick={() => pick("")} className="row gap-8 pointer"
                style={{ width: "100%", textAlign: "left", alignItems: "center", padding: "8px 10px", borderRadius: 8,
                  border: "none", cursor: "pointer", background: "transparent", color: "var(--ink-soft)", fontFamily: "var(--ff)", fontSize: 13.5 }}>
                <Icon name="x" size={15} stroke={2} /> Clear selection
              </button>
            )}
            {filtered.length === 0 && <div className="muted" style={{ padding: "14px 12px", fontSize: 13 }}>No matching country</div>}
            {filtered.map((c, i) => {
              const sel = c.name === value;
              const active = i === hi;
              return (
                <button key={c.iso} type="button" role="option" aria-selected={sel}
                  onMouseEnter={() => setHi(i)} onClick={() => pick(c.name)} className="row between pointer"
                  style={{ width: "100%", textAlign: "left", gap: 10, alignItems: "center", padding: "7px 10px",
                    borderRadius: 8, border: "none", cursor: "pointer", background: active ? "var(--accent-soft)" : "transparent",
                    color: sel ? "var(--accent)" : "var(--ink-2)", fontFamily: "var(--ff)" }}>
                  <span className="row gap-8" style={{ alignItems: "center", minWidth: 0 }}>
                    <FlagImg iso={c.iso} name={c.name} w={22} />
                    <span className="nowrap" style={{ fontSize: 13.5, fontWeight: sel ? 700 : 600, overflow: "hidden", textOverflow: "ellipsis" }}>{c.name}</span>
                  </span>
                  {sel && <Icon name="check" size={14} stroke={2.4} style={{ flex: "none" }} />}
                </button>
              );
            })}
          </div>
        </div>
      )}
    </div>
  );
}

function outcomeOf(h, a) {
  if (h == null || a == null || h === "" || a === "") return null;
  h = +h; a = +a;
  return h > a ? "home" : h < a ? "away" : "draw";
}
function useNarrow(bp = 720) {
  const [n, setN] = useState(typeof window !== "undefined" && window.innerWidth < bp);
  useEffect(() => {
    const f = () => setN(window.innerWidth < bp);
    window.addEventListener("resize", f);
    return () => window.removeEventListener("resize", f);
  }, [bp]);
  return n;
}
// Extra points a CORRECT outcome earns for also nailing the exact scoreline, given
// its outcome key. Mode-aware: 'multiply' (default) → outcome×multiplier (so the bonus
// is outcome*(mult-1)); 'add' → the flat exactScoreBonus. Returns 0 if no outcome.
function exactBonus(match, outcome) {
  if (!outcome) return 0;
  if (match.exactScoreMode === "add") return match.exactScoreBonus || 0;
  const base = match.points[outcome] || 0;
  const mult = match.exactScoreMultiplier || 2;
  return base * (mult - 1);
}
// potential points if a given prediction comes EXACTLY true (upper bound, no x2):
// the outcome points plus the exact-score reward.
function potential(match, pred) {
  const o = outcomeOf(pred.home, pred.away);
  if (!o) return 0;
  const base = match.points[o] || 0;
  return base + exactBonus(match, o);
}

/* ----------------------------- icons ----------------------------- */
const ICON = {
  home: "M3 10.5 12 3l9 7.5M5 9.5V20h5v-6h4v6h5V9.5",
  calendar: "M7 3v3M17 3v3M4 8h16M5 6h14a1 1 0 0 1 1 1v12a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V7a1 1 0 0 1 1-1Z",
  trophy: "M7 4h10v3a5 5 0 0 1-10 0V4ZM7 6H4v1a3 3 0 0 0 3 3M17 6h3v1a3 3 0 0 1-3 3M9 16h6M10 16l-1 4h6l-1-4",
  building: "M4 21V5a1 1 0 0 1 1-1h9a1 1 0 0 1 1 1v16M15 21V9h4a1 1 0 0 1 1 1v11M3 21h18M8 8h3M8 12h3M8 16h3",
  target: "M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18ZM12 8a4 4 0 1 0 0 8 4 4 0 0 0 0-8ZM12 12h.01",
  book: "M4 5a2 2 0 0 1 2-2h13v15H6a2 2 0 0 0-2 2V5ZM4 19a2 2 0 0 0 2 2h13",
  gear: "M12 9a3 3 0 1 0 0 6 3 3 0 0 0 0-6ZM19 12a7 7 0 0 0-.1-1l2-1.6-2-3.4-2.3 1a7 7 0 0 0-1.7-1l-.4-2.5H10.5l-.4 2.5a7 7 0 0 0-1.7 1l-2.3-1-2 3.4 2 1.6a7 7 0 0 0 0 2l-2 1.6 2 3.4 2.3-1a7 7 0 0 0 1.7 1l.4 2.5h3.9l.4-2.5a7 7 0 0 0 1.7-1l2.3 1 2-3.4-2-1.6c.07-.33.1-.66.1-1Z",
  bolt: "M13 2 4 14h6l-1 8 9-12h-6l1-8Z",
  chevron: "M9 6l6 6-6 6",
  chevronDown: "M6 9l6 6 6-6",
  check: "M5 13l4 4L19 7",
  clock: "M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18ZM12 7v5l3 2",
  lock: "M7 11V8a5 5 0 0 1 10 0v3M5 11h14v9a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1v-9Z",
  user: "M12 12a4 4 0 1 0 0-8 4 4 0 0 0 0 8ZM5 21a7 7 0 0 1 14 0",
  flame: "M12 3c0 4-4 5-4 9a4 4 0 0 0 8 0c0-2-1-3-1-3 0 2-1 2.5-1.5 2.5C13 13.5 14 9 12 3Z",
  arrowUp: "M12 19V5M6 11l6-6 6 6",
  whistle: "M3 12a5 5 0 1 0 10 0 5 5 0 0 0-10 0ZM13 10l8-3v3l-6 1",
  plus: "M12 5v14M5 12h14",
  filter: "M3 5h18l-7 8v6l-4-2v-4L3 5Z",
  download: "M12 3v12M7 11l5 4 5-4M5 21h14",
  refresh: "M20 11a8 8 0 0 0-14-4M4 5v3h3M4 13a8 8 0 0 0 14 4M20 19v-3h-3",
  bell: "M6 9a6 6 0 0 1 12 0c0 5 2 6 2 6H4s2-1 2-6ZM10 21a2 2 0 0 0 4 0",
  star: "M12 3l2.6 5.6 6 .7-4.5 4.1 1.2 6L12 16.8 6.7 19.4l1.2-6L3.4 9.3l6-.7L12 3Z",
  table: "M4 5h16v14H4z M4 9.5h16 M4 14h16 M9 5v14",
  bracket: "M4 6h5v12H4 M9 12h5 M14 6v12 M14 12h6",
  menu: "M4 7h16M4 12h16M4 17h16",
  x: "M6 6l12 12M18 6 6 18",
  shield: "M12 3l7 3v5c0 4.4-3 7.6-7 9-4-1.4-7-4.6-7-9V6l7-3Z",
  globe: "M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18ZM3.5 9h17M3.5 15h17M12 3c2.3 2.4 3.5 5.7 3.5 9s-1.2 6.6-3.5 9c-2.3-2.4-3.5-5.7-3.5-9S9.7 5.4 12 3Z",
  search: "M11 4a7 7 0 1 0 0 14 7 7 0 0 0 0-14ZM20 20l-4-4",
  trash: "M4 7h16M9 7V5a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2M6 7l1 13a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1l1-13M10 11v6M14 11v6",
  pencil: "M4 20h4L18.5 9.5a2.1 2.1 0 0 0-3-3L5 17v3ZM13.5 6.5l3 3",
  instagram: "M7 3h10a4 4 0 0 1 4 4v10a4 4 0 0 1-4 4H7a4 4 0 0 1-4-4V7a4 4 0 0 1 4-4ZM12 8.5a3.5 3.5 0 1 0 0 7 3.5 3.5 0 0 0 0-7ZM17 7h.01",
  linkedin: "M5 3h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2ZM7.5 8h.01M7.5 11v6M11 17v-6M11 13.5a2.5 2.5 0 0 1 5 0V17",
};
function Icon({ name, size = 18, stroke = 1.8, fill = false, style }) {
  return (
    <svg width={size} height={size} viewBox="0 0 24 24" fill={fill ? "currentColor" : "none"}
      stroke="currentColor" strokeWidth={stroke} strokeLinecap="round" strokeLinejoin="round" style={style}>
      <path d={ICON[name]} />
    </svg>
  );
}

/* ----------------------------- logo ----------------------------- */
function Logo({ h = 22 }) {
  return <img src="logos/AcquiCup_logo.png" alt="AcquiCup" style={{ height: h, width: "auto", filter: "var(--logo-filter)" }} />;
}
function CupMark({ size = 26 }) {
  // square brand mark: the AcquiCup "A" (trophy cut-out), used standalone
  return <img src="logos/A_AcquiCup-logo.png" alt="AcquiCup" style={{ width: size, height: size, objectFit: "contain", flex: "none", filter: "var(--logo-filter)" }} />;
}

/* ----------------------------- team badge ----------------------------- */
function TeamBadge({ code, size = "" }) {
  const t = T(code);
  const dim = size === "sm" ? { w: 23, h: 16, r: 3 } : size === "lg" ? { w: 48, h: 34, r: 6 } : { w: 33, h: 23, r: 4 };
  const [err, setErr] = useState(false);
  if (err || !t.iso) {
    return <span className="team-flag fallback" style={{ width: dim.w, height: dim.h, borderRadius: dim.r, background: t.color, fontSize: Math.round(dim.h * 0.46) }}>{t.code}</span>;
  }
  return <img className="team-flag" src={"https://flagcdn.com/" + t.iso + ".svg"} alt={t.name} title={t.name} onError={() => setErr(true)} style={{ width: dim.w, height: dim.h, borderRadius: dim.r }} />;
}
function TeamRow({ code, sub, right, size = "" }) {
  const t = T(code);
  return (
    <div className="row gap-12">
      <TeamBadge code={code} size={size} />
      <div className="col" style={{ lineHeight: 1.15 }}>
        <span style={{ fontWeight: 700, fontSize: size === "sm" ? 13 : 15 }}>{t.name}</span>
        {sub && <span className="muted" style={{ fontSize: 11.5 }}>{sub}</span>}
      </div>
      {right}
    </div>
  );
}

/* ----------------------------- company logo ----------------------------- */
const COMPANY_MARK = {
  acquisit:    { bg: "#0F2D52", shape: "triangle" },
  northbridge: { bg: "#2A6FDB", shape: "diamond" },
  lumen:       { bg: "#C99A2E", shape: "circle" },
  vortex:      { bg: "#1C8A8A", shape: "ring" },
  meridian:    { bg: "#2D7A4F", shape: "square" },
  solstice:    { bg: "#6D54C9", shape: "bars" },
  freelance:   { bg: "#7B7B7B", shape: "dot" },
  // AI players' one-member companies — brand colours so their bump-chart lines read as
  // Claude/ChatGPT/Gemini. The actual mark renders from the doc `logo` (see CompanyLogo).
  claude:      { bg: "#D97757", shape: "circle", ai: true },
  chatgpt:     { bg: "#0F0F0F", shape: "circle", ai: true },
  gemini:      { bg: "#1C69FF", shape: "circle", ai: true },
};
const companyColor = (id) => (COMPANY_MARK[id] || {}).bg || "#7B7B7B";
// Resolve a company's logo asset from the bootstrap payload (AI companies carry one).
function companyLogoSrc(id) {
  const cos = (typeof window !== "undefined" && window.ACQ && window.ACQ.COMPANIES) || [];
  const co = cos.find((c) => c.id === id);
  return co && co.logo ? co.logo : null;
}
function CompanyLogo({ id, size = 34 }) {
  // AI companies render their brand logo on a light plate so dark/transparent marks
  // (the OpenAI knot, Gemini star, Claude burst) stay legible at 24–44px.
  const logo = companyLogoSrc(id);
  if (logo) {
    return (
      <div style={{ width: size, height: size, borderRadius: Math.round(size * 0.28), background: "#fff",
        border: "1px solid var(--line)", display: "flex", alignItems: "center", justifyContent: "center",
        overflow: "hidden", flex: "none", boxShadow: "0 1px 2px rgba(15,45,82,.16)" }}>
        <img src={logo} alt="" style={{ width: "76%", height: "76%", objectFit: "contain", display: "block" }} />
      </div>
    );
  }
  const m = COMPANY_MARK[id] || { bg: "#7B7B7B", shape: "circle" };
  const gw = Math.round(size * 0.56);
  const shapes = {
    triangle: <polygon points="50,14 88,84 12,84" fill="#fff" />,
    diamond: <polygon points="50,8 92,50 50,92 8,50" fill="#fff" />,
    circle: <circle cx="50" cy="50" r="33" fill="#fff" />,
    ring: <circle cx="50" cy="50" r="30" fill="none" stroke="#fff" strokeWidth="13" />,
    square: <rect x="20" y="20" width="60" height="60" rx="11" fill="#fff" />,
    bars: <g fill="#fff"><rect x="16" y="30" width="68" height="14" rx="7" /><rect x="16" y="56" width="44" height="14" rx="7" /></g>,
    dot: <circle cx="50" cy="50" r="19" fill="#fff" />,
  };
  return (
    <div style={{ width: size, height: size, borderRadius: Math.round(size * 0.28), background: m.bg,
      display: "flex", alignItems: "center", justifyContent: "center", flex: "none",
      boxShadow: "inset 0 0 0 1px rgba(255,255,255,.14), 0 1px 2px rgba(15,45,82,.18)" }}>
      <svg width={gw} height={gw} viewBox="0 0 100 100">{shapes[m.shape]}</svg>
    </div>
  );
}

/* ----------------------------- name display ----------------------------- */
// People are shown as "Firstname L." (first name + last-name initial), e.g.
// "Laurent Rabot" → "Laurent R.". The full first/last stay in the data (used by
// the Avatar initials and possessive copy like "Laurent's matches"); this is a
// display-only shortener. Company names are NOT people — never pass them here.
// Names are always normalised to a capitalized first letter regardless of how
// they were stored ("jOhn" → "John", "travoltA" → "T."), per-word so compound
// first names ("mary-jane" → "Mary-Jane") and accents are handled.
function capName(s) {
  return (s || "").replace(/[^\s\-']+/g, (w) => w[0].toUpperCase() + w.slice(1).toLowerCase());
}
function userName(u) {
  if (!u) return "";
  // AI bot players carry a brand name as `first` ("ChatGPT") — show it verbatim,
  // never through capName (which would flatten the interior caps to "Chatgpt").
  if (u.isAi) return (u.first || "").trim();
  const first = capName((u.first || "").trim());
  const last = u.last && u.last.trim() ? `${u.last.trim()[0].toUpperCase()}.` : "";
  return last ? `${first} ${last}`.trim() : first;
}

// Display name for a company. On narrow (mobile) screens the long shared "Friends of
// Acquisit" team name is abbreviated to "Friends" so it fits the squeezed leaderboard column.
function companyDisplayName(name, narrow) {
  if (narrow && name === "Friends of Acquisit") return "Friends";
  return name || "";
}

/* ----------------------------- avatar ----------------------------- */
function Avatar({ user, size = 34 }) {
  // AI bot players carry a logo asset (user.avatar): render it on a light disc so the
  // brand mark reads at small sizes. Everyone else gets coloured initials.
  if (user && user.avatar) {
    return (
      <div title={user.first} style={{ width: size, height: size, borderRadius: "50%", background: "#fff",
        border: "1px solid var(--line)", display: "flex", alignItems: "center", justifyContent: "center",
        overflow: "hidden", flex: "none", boxShadow: "0 1px 2px rgba(15,45,82,.14)" }}>
        <img src={user.avatar} alt={user.first} style={{ width: "76%", height: "76%", objectFit: "contain", display: "block" }} />
      </div>
    );
  }
  const initials = ((user.first || "?")[0] + (user.last || "")[0]).toUpperCase();
  const hue = (user.id || "x").split("").reduce((a, c) => a + c.charCodeAt(0), 0) % 360;
  const bg = user.you ? "var(--brand-navy)" : `oklch(0.62 0.12 ${hue})`;
  return (
    <div style={{ width: size, height: size, borderRadius: "50%", background: bg, color: "#fff",
      display: "flex", alignItems: "center", justifyContent: "center", fontSize: size * 0.4,
      fontWeight: 700, flex: "none" }}>{initials}</div>
  );
}

/* ----------------------------- AI badge ----------------------------- */
// Small pill marking an AI bot player (Claude/ChatGPT/Gemini) inline in the rankings.
function AiBadge({ size = 9.5, style }) {
  return (
    <span className="badge mono" title="AI player" style={{ fontSize: size, fontWeight: 800, letterSpacing: ".5px",
      padding: "1px 6px", border: "none", color: "#fff", flex: "none",
      background: "linear-gradient(90deg,#D97757 0%,#7A5AE0 52%,#1C69FF 100%)", ...style }}>AI</span>
  );
}

/* ----------------------------- status pill ----------------------------- */
function StatusPill({ status, minute }) {
  if (status === "live") return <span className="badge live"><span className="live-dot"></span>LIVE {minute}'</span>;
  if (status === "finished") return <span className="badge done"><Icon name="check" size={12} stroke={2.4} />Finished</span>;
  if (status === "locked") return <span className="badge amber"><Icon name="lock" size={11} stroke={2} />Locked</span>;
  if (status === "tbd") return <span className="badge"><Icon name="clock" size={11} stroke={2} />TBD</span>;
  return <span className="badge blue"><span className="dot"></span>Upcoming</span>;
}

/* ----------------------------- movement ----------------------------- */
function Movement({ value }) {
  if (!value) return <span className="mv flat">–</span>;
  if (value > 0) return <span className="mv up"><Icon name="arrowUp" size={11} stroke={2.4} />{value}</span>;
  return <span className="mv dn"><Icon name="arrowUp" size={11} stroke={2.4} style={{ transform: "rotate(180deg)" }} />{Math.abs(value)}</span>;
}

/* ----------------------------- points pill ----------------------------- */
function Pts({ value, big, prefix = "+" }) {
  return <span className="mono" style={{ fontWeight: 600, fontSize: big ? 20 : 14 }}>{prefix}{value}<span className="muted" style={{ fontSize: big ? 12 : 10, fontWeight: 500 }}> pts</span></span>;
}

/* ----------------------------- app shell / nav ----------------------------- */
const NAV = [
  { id: "dashboard", label: "Dashboard", icon: "home" },
  { id: "matches", label: "Matches", icon: "calendar" },
  { id: "standings", label: "Standings", icon: "table" },
  { id: "leaderboard", label: "Leaderboard", icon: "trophy" },
];

// Bottom (footer) menu. Rules + Admin route client-side; the rest are external
// links that open in a new tab. `admin` items show only to admins, `social` items
// render on the right of the desktop footer (everything else on the left). On mobile
// these are folded into the slide-out main menu.
const FOOTER_LINKS = [
  { id: "rules", label: "Rules", icon: "book" },
  { href: "https://acquisit.io/", label: "Acquisit.io", icon: "globe" },
  { href: "https://acquisit.io/about-us/", label: "About Us", icon: "building" },
  { id: "admin", label: "Admin", icon: "gear", admin: true },
  { href: "https://www.instagram.com/acquisit.io/", label: "Instagram", icon: "instagram", social: true },
  { href: "https://www.linkedin.com/company/acquisit-advertising", label: "LinkedIn", icon: "linkedin", social: true },
];

// Nav links are real <a href> so they can be bookmarked, shared, and opened in
// a new tab. For a plain left-click we intercept and route client-side; modified
// clicks (cmd/ctrl/shift/alt or middle-click) fall through to the browser so
// "open in new tab" keeps working.
function navClick(e, go, id) {
  if (e.defaultPrevented || e.button === 1 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
  e.preventDefault();
  go(id);
}

function AppShell({ route, go, hrefFor = (r) => "/" + r, me, children, isAdmin, isAcquisit, onLogout }) {
  const narrow = useNarrow(820);
  const main = NAV;
  // Mobile slide-out drawer ("top menu"). Hooks must run unconditionally, so
  // they live above the narrow/desktop branch even though the drawer is mobile-only.
  const [menuOpen, setMenuOpen] = useState(false);
  // Close on any route change — covers picking an item, back/forward, deep links.
  useEffect(() => { setMenuOpen(false); }, [route]);
  // Escape closes the drawer while it's open.
  useEffect(() => {
    if (!menuOpen) return;
    const onKey = (e) => { if (e.key === "Escape") setMenuOpen(false); };
    window.addEventListener("keydown", onKey);
    return () => window.removeEventListener("keydown", onKey);
  }, [menuOpen]);
  // Full nav list for the drawer. Staff (@acquisit.io) also get the AcquiBoard entry,
  // and admins additionally get the Admin entry — both appended conditionally, never in NAV.
  let drawerItems = NAV;
  if (isAcquisit) drawerItems = [...drawerItems, { id: "acquiboard", label: "AcquiBoard", icon: "shield" }];
  drawerItems = [...drawerItems, { id: "settings", label: "Settings", icon: "gear" }];
  if (narrow) {
    const tabIds = ["dashboard", "matches", "standings", "leaderboard"];
    // 100svh (smallest viewport), NOT 100dvh: with a position:fixed bottom nav,
    // dvh tracks the toolbar-collapsed (largest) viewport, so while the mobile
    // toolbar is still visible the root is taller than the visible area — that
    // surplus is a blank scrollable strip and the fixed bar appears to ride up
    // off the bottom edge. svh never exceeds the visible viewport, killing both.
    return (
      <div className="acq-root" style={{ minHeight: "100svh", paddingBottom: "calc(70px + env(safe-area-inset-bottom))" }}>
        <div className="row between" style={{ position: "sticky", top: 0, zIndex: 30, background: "var(--surface)",
          borderBottom: "1px solid var(--line)", padding: "17px 16px" }}>
          <a href={hrefFor("dashboard")} onClick={(e) => navClick(e, go, "dashboard")} className="row gap-8 pointer" style={{ textDecoration: "none", color: "inherit" }}>
            <Logo h={22} />
          </a>
          <div className="row gap-10">
            {me.rank != null && <div className="badge gold mono" title="Your individual ranking"><Icon name="trophy" size={14} fill />#{me.rank}</div>}
            {(() => {
              const myCo = ((window.ACQ && window.ACQ.COMPANY_RANK) || []).find((c) => c.id === me.company);
              return myCo && myCo.rank != null
                ? <div className="badge mono" title="Your company ranking"><Icon name="building" size={14} />#{myCo.rank}</div>
                : null;
            })()}
            <button onClick={() => setMenuOpen(true)} aria-label="Open menu" aria-expanded={menuOpen}
              className="row pointer" style={{ background: "none", border: "none", padding: 4, margin: -4, color: "var(--ink)" }}>
              <Icon name="menu" size={26} stroke={2} />
            </button>
          </div>
        </div>
        <div style={{ padding: "16px 14px 24px" }}>{children}</div>
        <div className="row" style={{ position: "fixed", bottom: 0, left: 0, right: 0, zIndex: 40,
          background: "var(--surface)", borderTop: "1px solid var(--line)",
          padding: "8px 6px calc(10px + env(safe-area-inset-bottom))",
          justifyContent: "space-around" }}>
          {tabIds.map((id) => {
            const it = NAV.find((n) => n.id === id);
            const on = route === id;
            return (
              <a key={id} href={hrefFor(id)} onClick={(e) => navClick(e, go, id)} className="bottom-tab" style={{ background: "none", border: "none", cursor: "pointer",
                display: "flex", flexDirection: "column", alignItems: "center", gap: 3, textDecoration: "none",
                color: on ? "var(--accent)" : "var(--ink-soft)", fontFamily: "var(--ff)", fontSize: 10.5, fontWeight: 600 }}>
                <Icon name={it.icon} size={21} stroke={on ? 2.1 : 1.8} fill={false} />
                {it.label.split(" ")[0]}
              </a>
            );
          })}
        </div>
        {/* Slide-out "top menu": tap the header hamburger to open. Sits above the
            header (z30) and bottom bar (z40); the backdrop closes it on tap. */}
        {menuOpen && (
          <React.Fragment>
            <div className="mobile-drawer-backdrop" onClick={() => setMenuOpen(false)}
              style={{ position: "fixed", inset: 0, zIndex: 60, background: "rgba(8,20,38,.5)", backdropFilter: "blur(2px)" }} />
            <div className="mobile-drawer" role="dialog" aria-modal="true" aria-label="Menu"
              style={{ position: "fixed", top: 0, bottom: 0, right: 0, zIndex: 61, width: 272, maxWidth: "82%",
                background: "var(--surface)", borderLeft: "1px solid var(--line)", boxShadow: "var(--shadow-pop)",
                display: "flex", flexDirection: "column" }}>
              <div className="row between" style={{ padding: "13px 16px", borderBottom: "1px solid var(--line)" }}>
                <div className="row gap-8"><Logo h={18} /></div>
                <button onClick={() => setMenuOpen(false)} aria-label="Close menu"
                  className="row pointer" style={{ background: "none", border: "none", padding: 4, margin: -4, color: "var(--ink-soft)" }}>
                  <Icon name="x" size={20} stroke={2} />
                </button>
              </div>
              <div className="col" style={{ padding: "14px 16px", borderBottom: "1px solid var(--line)", lineHeight: 1.2, minWidth: 0 }}>
                <span style={{ fontWeight: 700, fontSize: 14 }}>{userName(me)}</span>
                <span className="muted nowrap" style={{ fontSize: 12 }}>Rank #{me.rank} · {me.points} pts</span>
              </div>
              {/* Main menu + footer menu share one vertical scroll area so every
                  item stays reachable on short screens (e.g. landscape phones). */}
              <div className="col" style={{ flex: "1 1 auto", overflowY: "auto", WebkitOverflowScrolling: "touch" }}>
                <nav className="col" style={{ padding: 10, gap: 2 }}>
                  {drawerItems.map((it) => {
                    const on = route === it.id;
                    return (
                      <a key={it.id} href={hrefFor(it.id)} onClick={(e) => navClick(e, go, it.id)}
                        className={"topnav-item" + (on ? " on" : "")} style={{ display: "flex", alignItems: "center", gap: 12,
                          padding: "11px 12px", borderRadius: 10, fontFamily: "var(--ff)", fontSize: 15, fontWeight: 600,
                          textDecoration: "none", background: on ? "var(--accent-soft)" : "transparent",
                          color: on ? "var(--accent)" : "var(--ink-2)" }}>
                        <Icon name={it.icon} size={20} stroke={on ? 2 : 1.8} />{it.label}
                      </a>
                    );
                  })}
                </nav>
                <div className="col" style={{ padding: "8px 12px 12px", borderTop: "1px solid var(--line)", gap: 1 }}>
                  {FOOTER_LINKS.filter((l) => !l.admin || isAdmin).map((l) => {
                    const base = { display: "flex", alignItems: "center", gap: 12, padding: "9px 8px",
                      borderRadius: 8, fontFamily: "var(--ff)", fontSize: 14, fontWeight: 600, textDecoration: "none" };
                    const icon = <Icon name={l.icon} size={18} stroke={1.8} />;
                    if (l.href) {
                      return (
                        <a key={l.label} href={l.href} target="_blank" rel="noopener noreferrer"
                          style={{ ...base, color: "var(--ink-2)" }}>{icon}{l.label}</a>
                      );
                    }
                    const on = route === l.id;
                    return (
                      <a key={l.id} href={hrefFor(l.id)} onClick={(e) => navClick(e, go, l.id)}
                        style={{ ...base, background: on ? "var(--accent-soft)" : "transparent",
                          color: on ? "var(--accent)" : "var(--ink-2)" }}>{icon}{l.label}</a>
                    );
                  })}
                </div>
              </div>
              <div style={{ padding: "10px 14px", borderTop: "1px solid var(--line)" }}>
                <span className="muted" style={{ fontSize: 11, fontWeight: 700, letterSpacing: ".04em",
                  textTransform: "uppercase", display: "block", marginBottom: 6 }}>Timezone</span>
                <TimezonePicker />
              </div>
              {onLogout && (
                <div style={{ padding: 12, borderTop: "1px solid var(--line)" }}>
                  <button className="btn btn-ghost" style={{ width: "100%", justifyContent: "center" }}
                    onClick={() => { setMenuOpen(false); onLogout(); }}>Sign out</button>
                </div>
              )}
            </div>
          </React.Fragment>
        )}
      </div>
    );
  }
  // desktop — sticky top navigation bar (replaces the old left sidebar).
  // navLink is a render helper (called, not mounted as <navLink/>) so the items
  // are plain inline <a> elements — no per-render component remount.
  const navLink = ({ id, icon, label }) => {
    const on = route === id;
    return (
      <a key={id} href={hrefFor(id)} onClick={(e) => navClick(e, go, id)} title={label}
        className={"topnav-item" + (on ? " on" : "")} style={{
          display: "flex", alignItems: "center", gap: 8, padding: "8px 12px", borderRadius: 10,
          fontFamily: "var(--ff)", fontSize: 14, fontWeight: 600, textDecoration: "none", whiteSpace: "nowrap",
          background: on ? "var(--accent-soft)" : "transparent", color: on ? "var(--accent)" : "var(--ink-2)" }}>
        <Icon name={icon} size={18} stroke={on ? 2 : 1.8} /><span className="topnav-label">{label}</span>
      </a>
    );
  };
  const footerLink = (l) => {
    const base = { display: "flex", alignItems: "center", gap: 7, fontSize: 13, fontWeight: 600,
      textDecoration: "none", whiteSpace: "nowrap" };
    const icon = <Icon name={l.icon} size={16} stroke={1.8} />;
    if (l.href) {
      return (
        <a key={l.label} href={l.href} target="_blank" rel="noopener noreferrer" title={l.label}
          className="topfooter-link pointer" style={{ ...base, color: "var(--ink-soft)" }}>{icon}{l.label}</a>
      );
    }
    const on = route === l.id;
    return (
      <a key={l.id} href={hrefFor(l.id)} onClick={(e) => navClick(e, go, l.id)} title={l.label}
        className="topfooter-link pointer" style={{ ...base, color: on ? "var(--accent)" : "var(--ink-soft)" }}>{icon}{l.label}</a>
    );
  };
  return (
    <div className="acq-root" style={{ minHeight: "100vh", display: "flex", flexDirection: "column" }}>
      <header className="topnav" style={{ position: "sticky", top: 0, zIndex: 50,
        background: "var(--surface)", borderBottom: "1px solid var(--line)" }}>
        <div className="topnav-inner" style={{ maxWidth: 1180, margin: "0 auto", padding: "0 28px",
          height: 60, display: "flex", alignItems: "center", gap: 18 }}>
          <a href={hrefFor("dashboard")} onClick={(e) => navClick(e, go, "dashboard")} className="topnav-brand row gap-12 pointer"
            style={{ textDecoration: "none", color: "inherit", flex: "0 0 auto" }}>
            <Logo h={20} />
          </a>
          <nav className="topnav-nav" style={{ display: "flex", alignItems: "center", justifyContent: "center", gap: 2,
            flex: "1 1 auto", minWidth: 0, overflowX: "auto" }}>
            {main.map((it) => navLink(it))}
            {isAcquisit && navLink({ id: "acquiboard", icon: "shield", label: "AcquiBoard" })}
          </nav>
          <div className="topnav-right" style={{ display: "flex", alignItems: "center", gap: 12, flex: "0 0 auto" }}>
            <TimezonePicker compact />
            <a href={hrefFor("settings")} onClick={(e) => navClick(e, go, "settings")} title="Settings"
              aria-label="Settings" className="row pointer"
              style={{ color: route === "settings" ? "var(--accent)" : "var(--ink-soft)", textDecoration: "none", padding: 2 }}>
              <Icon name="gear" size={19} stroke={route === "settings" ? 2 : 1.8} />
            </a>
            <a href={hrefFor("settings")} onClick={(e) => navClick(e, go, "settings")} className="row gap-8 pointer"
              style={{ minWidth: 0, textDecoration: "none", color: "inherit" }}>
              <div className="col topnav-name" style={{ lineHeight: 1.15, minWidth: 0 }}>
                <span style={{ fontWeight: 700, fontSize: 13 }}>{userName(me)}</span>
                <span className="muted nowrap" style={{ fontSize: 11 }}>Rank #{me.rank} · {me.points} pts</span>
              </div>
            </a>
            {onLogout && <button className="btn btn-ghost btn-sm" onClick={onLogout}>Sign out</button>}
          </div>
        </div>
      </header>
      <main className="grow noscroll" style={{ minWidth: 0, maxWidth: "100%" }}>
        <div style={{ maxWidth: 1080, margin: "0 auto", padding: "30px 36px 60px" }}>{children}</div>
      </main>
      <footer className="topfooter" style={{ position: "sticky", bottom: 0, zIndex: 40,
        borderTop: "1px solid var(--line)", background: "var(--surface)", flex: "0 0 auto" }}>
        <div style={{ maxWidth: 1080, margin: "0 auto", padding: "18px 36px", display: "flex",
          flexWrap: "wrap", alignItems: "center", justifyContent: "space-between", gap: 18 }}>
          <div style={{ display: "flex", flexWrap: "wrap", alignItems: "center", gap: 22 }}>
            {FOOTER_LINKS.filter((l) => !l.social && (!l.admin || isAdmin)).map((l) => footerLink(l))}
          </div>
          <div style={{ display: "flex", alignItems: "center", gap: 20 }}>
            {FOOTER_LINKS.filter((l) => l.social).map((l) => footerLink(l))}
          </div>
        </div>
      </footer>
    </div>
  );
}

/* ----------------------------- modal ----------------------------- */
// Centered overlay dialog used by the admin CRUD forms (add match, edit user, merge
// company, add/edit team/venue…). Named AdminModal (not Modal) because app.jsx already
// declares a simpler global `Modal` for the X2-confirm dialog, and these .jsx files share
// one global scope (load order would otherwise clobber this one). Backdrop click and Escape
// both close it. `footer` is an optional right-aligned action row (submit/cancel buttons).
function AdminModal({ title, onClose, children, footer, width = 460 }) {
  useEffect(() => {
    const onKey = (e) => { if (e.key === "Escape") onClose && onClose(); };
    window.addEventListener("keydown", onKey);
    return () => window.removeEventListener("keydown", onKey);
  }, [onClose]);
  return (
    <React.Fragment>
      <div onClick={onClose} style={{ position: "fixed", inset: 0, zIndex: 80,
        background: "rgba(8,20,38,.5)", backdropFilter: "blur(2px)" }} />
      <div role="dialog" aria-modal="true" aria-label={title}
        style={{ position: "fixed", zIndex: 81, top: "50%", left: "50%", transform: "translate(-50%,-50%)",
          width, maxWidth: "94vw", maxHeight: "88vh", display: "flex", flexDirection: "column",
          background: "var(--surface)", border: "1px solid var(--line)", borderRadius: 16, boxShadow: "var(--shadow-pop)" }}>
        <div className="row between" style={{ padding: "14px 18px", borderBottom: "1px solid var(--line)", flex: "0 0 auto" }}>
          <span style={{ fontWeight: 700, fontSize: 15 }}>{title}</span>
          <button onClick={onClose} aria-label="Close" className="row pointer"
            style={{ background: "none", border: "none", padding: 4, margin: -4, color: "var(--ink-soft)", cursor: "pointer" }}>
            <Icon name="x" size={18} stroke={2} />
          </button>
        </div>
        <div style={{ padding: 18, overflowY: "auto" }}>{children}</div>
        {footer && (
          <div className="row gap-8" style={{ padding: "12px 18px", borderTop: "1px solid var(--line)",
            justifyContent: "flex-end", flex: "0 0 auto" }}>{footer}</div>
        )}
      </div>
    </React.Fragment>
  );
}

/* page header */
function PageHead({ eyebrow, title, sub, right, subWidth = "none" }) {
  return (
    <div className="col gap-6" style={{ marginBottom: 12 }}>
      <div className="row between wrap gap-12">
        <div className="col gap-6" style={{ minWidth: 0 }}>
          {eyebrow && <span className="eyebrow">{eyebrow}</span>}
          <h1 className="h-xl">{title}</h1>
        </div>
        {right}
      </div>
      {sub && <span className="soft" style={{ fontSize: 14.5, maxWidth: subWidth }}>{sub}</span>}
      <hr className="pagehead-sep" />
    </div>
  );
}

/* ----------------------------- stat bar ----------------------------- */
// Shared proportional stat bar + segment-aligned legend, used by the Dashboard's
// "My stats" card (Accuracy + Risk) and the public profile's stats card. Each row is
// { label, short?, value, pts, color }; segments and legend columns are sized by
// `value / total`, and every column shows the share %, the number of predictions
// (`value of total`), and the points those picks earned. Hovering a segment (or its
// legend column) reveals a designed tooltip: a centered popover whose caret tracks the
// hovered segment, showing the full breakdown — every bucket's share %, count and
// points, with the hovered one emphasised.
function StatBar({ rows, total, height = 16 }) {
  const [hover, setHover] = useState(null); // index of the hovered VISIBLE row, or null
  const pct = (v) => (total ? Math.round((v / total) * 100) : 0);
  const totalPts = rows.reduce((s, r) => s + (Number(r.pts) || 0), 0);
  const visible = rows.filter((r) => r.value > 0);

  // Caret x-position: centre of the hovered segment as a % of the full bar width.
  let caretPct = 50;
  if (hover != null) {
    let acc = 0;
    for (let i = 0; i < visible.length; i++) {
      const w = (visible[i].value / total) * 100;
      if (i === hover) { caretPct = acc + w / 2; break; }
      acc += w;
    }
  }

  return (
    <div className="col gap-16" style={{ position: "relative" }} onMouseLeave={() => setHover(null)}>
      {/* designed hover tooltip — the legend itself, lifted into a popover with the
          hovered bucket highlighted; caret points down at the hovered segment */}
      {hover != null && (
        <div className="statbar-tip" style={{ left: caretPct + "%" }}>
          <div className="statbar-tip-head">
            <span className="statbar-tip-dot" style={{ background: visible[hover].color }}></span>
            <span className="statbar-tip-title">{visible[hover].label}</span>
            <span className="statbar-tip-pct mono" style={{ color: "color-mix(in srgb, " + visible[hover].color + " 70%, var(--ink))" }}>{pct(visible[hover].value)}%</span>
          </div>
          <div className="statbar-tip-rows">
            {visible.map((r, i) => (
              <div key={r.label} className={"statbar-tip-row" + (i === hover ? " on" : "")}>
                <span className="statbar-tip-dot sm" style={{ background: r.color }}></span>
                <span className="statbar-tip-lbl">{r.label}</span>
                <span className="statbar-tip-val mono">{r.value}<span className="statbar-tip-of">/{total}</span></span>
                <span className="statbar-tip-pts mono">{r.pts} pts</span>
                <span className="statbar-tip-share mono">{pct(r.value)}%</span>
              </div>
            ))}
            <div className="statbar-tip-row total">
              <span className="statbar-tip-dot sm" style={{ background: "transparent" }}></span>
              <span className="statbar-tip-lbl">Total</span>
              <span className="statbar-tip-val mono">{total}</span>
              <span className="statbar-tip-pts mono">{totalPts} pts</span>
              <span className="statbar-tip-share mono">100%</span>
            </div>
          </div>
          <span className="statbar-tip-caret"></span>
        </div>
      )}
      {/* proportional bar — 2px gaps separate adjacent segments; inset hairline keeps
          it reading as a track even when one segment fills it */}
      <div style={{ display: "flex", height, borderRadius: 999, overflow: "hidden", background: "var(--surface-3)", gap: 2, boxShadow: "inset 0 0 0 1px color-mix(in srgb, var(--ink) 6%, transparent)" }}>
        {visible.map((r, i) => (
          <div key={r.label} onMouseEnter={() => setHover(i)}
            style={{ width: "calc(" + (r.value / total * 100) + "% - 2px)", background: r.color, cursor: "default",
              transition: "filter .12s, opacity .12s",
              filter: hover === i ? "brightness(1.08) saturate(1.08)" : "none",
              opacity: hover != null && hover !== i ? 0.55 : 1 }}></div>
        ))}
      </div>
      {/* horizontal legend — each column is the SAME width (+2px gap) as its bar segment
          so the share % lines up over its segment; count + points sit below it. */}
      <div style={{ display: "flex", gap: 2 }}>
        {visible.map((r, i) => (
          <div key={r.label} onMouseEnter={() => setHover(i)} className="col gap-4"
            style={{ width: "calc(" + (r.value / total * 100) + "% - 2px)", minWidth: 0, cursor: "default",
              transition: "opacity .12s", opacity: hover != null && hover !== i ? 0.55 : 1 }}>
            <span className="mono" style={{ fontWeight: 800, fontSize: 22, lineHeight: 1, letterSpacing: "-.01em", color: "color-mix(in srgb, " + r.color + " 70%, var(--ink))" }}>{pct(r.value)}<span style={{ fontSize: 13, fontWeight: 700, color: "var(--ink-soft)" }}>%</span></span>
            <span className="nowrap" style={{ fontWeight: 600, fontSize: 12.5, color: "var(--ink-soft)", overflow: "hidden", textOverflow: "ellipsis" }}><span className="dash-lbl-full">{r.label}</span>{r.short && <span className="dash-lbl-short">{r.short}</span>}</span>
            <span className="muted mono of-cnt" style={{ fontSize: 11.5 }}>{r.value} of {total}</span>
            <span className="mono" style={{ fontSize: 12, fontWeight: 700, color: "color-mix(in srgb, " + r.color + " 70%, var(--ink))" }}>{r.pts} pts</span>
          </div>
        ))}
      </div>
    </div>
  );
}

/* ------------------------------ donut ------------------------------ */
// Shared pie/donut chart + legend, used by the Dashboard's "My stats" card to break
// down POINTS by prediction type (exact / good) and by risk rate (low / medium / high).
// Each row is { label, short?, value, color }; the ring is a conic-gradient sized by
// `value / total`, the hole shows the total, and the legend lists every slice's share %
// and value. Hovering a slice (or its legend row) lifts that slice and emphasises it.
// `unit` labels the values (e.g. "pts"); `centerSub` is the small label under the total.
function Donut({ rows, unit = "pts", size = 132, centerSub, emptyLabel = "No points yet" }) {
  const [hover, setHover] = useState(null); // index of the hovered VISIBLE slice, or null
  // On phones shrink the ring so the legend can stay BESIDE it (see .donut-chart in
  // styles.css) instead of wrapping below.
  const sz = useNarrow(640) ? 100 : size;
  const visible = rows.filter((r) => Number(r.value) > 0);
  const total = visible.reduce((s, r) => s + Number(r.value), 0);
  const pct = (v) => (total ? Math.round((v / total) * 100) : 0);
  if (!total) return <span className="muted" style={{ fontSize: 12.5 }}>{emptyLabel}</span>;

  // conic-gradient stops — hard edges at each slice boundary, a hovered slice keeps
  // its colour while the rest dim, so the highlight reads on the ring too.
  let acc = 0;
  const stops = visible.map((r, i) => {
    const start = (acc / total) * 100;
    acc += Number(r.value);
    const end = (acc / total) * 100;
    const dim = hover != null && hover !== i;
    const col = dim ? "color-mix(in srgb, " + r.color + " 38%, var(--surface-3))" : r.color;
    return col + " " + start + "% " + end + "%";
  }).join(", ");
  const ring = 0.18 * sz; // donut thickness

  return (
    <div className="row wrap donut-chart" style={{ alignItems: "center", gap: 32 }} onMouseLeave={() => setHover(null)}>
      <div style={{ position: "relative", width: sz, height: sz, flex: "none" }}>
        <div style={{ width: sz, height: sz, borderRadius: "50%", background: "conic-gradient(" + stops + ")", transition: "filter .12s" }}></div>
        {/* hole — masks the ring centre and shows the total */}
        <div className="col center" style={{ position: "absolute", inset: ring, borderRadius: "50%", background: "var(--surface)",
          boxShadow: "inset 0 0 0 1px color-mix(in srgb, var(--ink) 6%, transparent)" }}>
          <span className="mono" style={{ fontWeight: 800, fontSize: 24, lineHeight: 1, letterSpacing: "-.01em" }}>{total}</span>
          <span className="muted" style={{ fontSize: 10.5, marginTop: 2 }}>{centerSub || unit}</span>
        </div>
      </div>
      {/* legend — one row per slice: colour dot, label, value + share %. The whole
          block sizes to its content (no flex-grow) and the columns are fixed-width so
          the value/% sit just past the label rather than stranded at the card edge. */}
      <div className="col gap-8 donut-legend" style={{ flex: "none" }}>
        {visible.map((r, i) => (
          <div key={r.label} onMouseEnter={() => setHover(i)}
            style={{ display: "flex", alignItems: "center", gap: 10, cursor: "default", transition: "opacity .12s", opacity: hover != null && hover !== i ? 0.5 : 1 }}>
            <span style={{ width: 10, height: 10, borderRadius: 3, flex: "none", background: r.color }}></span>
            <span className="nowrap donut-legend-label" style={{ width: 104, fontWeight: 600, fontSize: 12.5, color: "var(--ink-soft)", overflow: "hidden", textOverflow: "ellipsis", flex: "none" }}>{r.label}</span>
            <span className="mono" style={{ fontWeight: 700, fontSize: 12.5, flex: "none", width: 54 }}>{r.value} <span className="muted" style={{ fontWeight: 600 }}>{unit}</span></span>
            <span className="mono" style={{ fontWeight: 800, fontSize: 12.5, flex: "none", width: 36, textAlign: "right", color: "color-mix(in srgb, " + r.color + " 70%, var(--ink))" }}>{pct(r.value)}%</span>
          </div>
        ))}
      </div>
    </div>
  );
}

Object.assign(window, {
  T, V, isTbd, fmtDate, fmtDay, outcomeOf, useNarrow, potential,
  detectTimezone, setTimezone, initTimezone, resetTimezone, getActiveTimezone, useTimezone,
  fmtMatchDate, fmtMatchDay, fmtMatchTime, tzList, tzMetaLabel, TimezonePicker, TimezoneCombo,
  COUNTRIES, isoForCountry, FlagImg, CountryCombo,
  Icon, Logo, CupMark, TeamBadge, TeamRow, Avatar, AiBadge, CompanyLogo, companyLogoSrc, COMPANY_MARK, companyColor, StatusPill, Movement, Pts, StatBar, Donut,
  AppShell, PageHead, AdminModal, NAV,
});
