// NO PROD JSON API (bucket B — feed is a DataTables render payload or
//   {"html":...}, not data) — mock until backend (see PROD_API_INVENTORY.md)
// Represents: GET /skins/{id}/{tab} · SkinsController + Skin\SkinFooterController — see docs/ISYSTEM_REFERENCE.md §Batch 6 "Skins deep-dive part 2/3" + "Skins deep-dive — part 3/3"
/* PART 2 of the §8.2 Skins consolidation — the remaining tab bodies of the consolidated skin editor.

   ── How this file plugs in ────────────────────────────────────────────────────────────────────
   src/pages/HostSkinsUnified.jsx (part 1) owns the list, the editor shell, the pill-tab strip and
   the Home / Settings / Customer.io tab bodies. It looks tab bodies up in the global
   `HskTabRegistry` AT RENDER TIME, so this later-loading file fills the rest by assigning into that
   same object (bottom of this file). Nothing in part 1 — nor in HostSkins.jsx or Skins.jsx — is
   edited. Every helper below is prefixed Hsk2 / HSK2 / hsk2; the shared tab-body kit (HskSection,
   HskField, HskInput, HskSelect, HskSwitch, HskTextarea, HskNote, HskSaveBar, HskTabHead, hskToast,
   hskRng, hskHash) is part 1's and is reused by name, since part 1 always loads first.

   ── What this file covers ─────────────────────────────────────────────────────────────────────
   Reference §Batch 6 "Skins deep-dive part 2/3" — Providers, Game management, Subcategories
   management — and "part 3/3" — Graphic, Domains, Jackpot, Limits, Homepage View, FAQ & Footer
   entries, Footer navigation, the Sport / Financial / Security catch-all tabs, and the
   Deposit / Withdraw methods tabs. 15 of the 22 real tabs; ids match HSK_TABS exactly.

   ── Deliberately NOT here ─────────────────────────────────────────────────────────────────────
   · `operational-settings` — the protected renderSettings() panel in Skins.jsx (brief §4.4). Part 1
     already registers an honest pointer to it. Not copied, not reimplemented, not re-registered.
   · `customerio` — the Customer.io / CDP panel lives in the protected src/pages/Settings.jsx
     (brief §4.1). Part 1 registers a pointer. This file must never carry a copy of it, so it does
     not touch that id at all.
   · `sidebar`, `levels`, `banner-provider-promotion`, `seo-content-blocks` — real tabs (they render
     through the catch-all showTab with no role check) that Batch 6 does not field-map. They stay on
     part 1's HskTabPending: an honest "not built yet" beats an invented form. See phaseD/skins-2.md.

   ── Known real-platform defects handled per the known-bug policy (CLAUDE.md) ──────────────────
   Each one is implemented as the *evident intent*, commented at its implementation site, and
   repeated as a SUGGESTION line here:

   <!-- SUGGESTION: add `show_banner` to SkinProvider::$fillable (app/Models/SkinProvider.php:15-22) — SkinsController::updateProvidersSkin's create branch (SC:2297-2304) silently drops the Banner flag, so enabling a provider with Banner ticked in the same save loses it until a second save. -->
   <!-- SUGGESTION: call self::flushSkinCache($id) at the end of `case "providers"` (SC:3122-3128) like every other tab does — provider lists are served from caches the provider save never busts. -->
   <!-- SUGGESTION: give skins_providers a `status` column instead of deleting the row when Active is unticked (SC:2262-2277) — the delete throws away `percentage` (not editable on this screen) and `priority` on every toggle-off. -->
   <!-- SUGGESTION: fix the game-management provider filter: value `1` is hijacked to mean "any provider" (SC:1863-1869), so a provider whose id is 1 can never be filtered on its own. -->
   <!-- SUGGESTION: fix the subcategory refresh path — public/js/pages/skins/gamemanagement.js:143 calls `/gamemanagement/getsubcatgories` (typo → 404) and the correctly-spelled route (routes/admin.php:193-195) points at SkinsController@getsubcategories, a method that does not exist (→ 500). -->
   <!-- SUGGESTION: build the "apply from another Skin" provider list from the skin being edited — the Blade's `foreach ($skins as $skin)` dropdown loop clobbers $skin, so skinProviders($skin->id,1) at gamemanagement.blade.php:234 uses the LAST skin of Skin::all(). Also permission-check `selected_skin`: a scoped Customer Care manager can copy configuration out of any skin on the platform. -->
   <!-- SUGGESTION: include `game_id` in the uploadSubcategoryImage updateOrInsert key (SC:1454-1457) — as written the tile image is written onto an arbitrary game-association row, so any flow that clears assocs (inline edits, removeSettings, applySettings, applyFromSkin) also erases the image. -->
   <!-- SUGGESTION: correct the delete path in removeSubcategoryImage (SC:1488) — it deletes `public/subcategories/images/…` while uploads land in `public/subcategories/img` (SC:1451), so no file is ever removed and re-uploads orphan the old ones. -->
   <!-- SUGGESTION: persist `colore11`–`colore14` (the save block at SC:3474-3483 is commented out) or remove the four inputs from graphic.blade.php:296-345 — today they are editable and silently discarded. -->
   <!-- SUGGESTION: validate the black-logo upload against `logo_black`, not `logo_logo` (SC:3356) — as keyed, the image rule never sees the uploaded file and any file type is accepted. -->
   <!-- SUGGESTION: read the four `*_remove` hidden inputs (graphic.blade.php:34,58,82,106) server-side, or drop the Remove buttons — an image can be replaced but never cleared. -->
   <!-- SUGGESTION: validate the Domains tab (updateDomainsSkin, SC:945-986) — no host format check, no uniqueness, and nothing enforces exactly one `main` row; and move flushSkinCache($id) to AFTER the write (SC:3526), since it currently clears keys derived from the OLD main domain and nothing flushes the new one. -->
   <!-- SUGGESTION: hide the Limits sport-balance calculator (or its +/- buttons) when `enable_sportsbook_balance_limits` is off — with the setting absent the rate and percentage are 0, so the buttons compute 0; and render the configured `sportsbook_balance_limit_perc` instead of the hardcoded "(12% of)" label (limits.blade.php:207). -->
   <!-- SUGGESTION: move getExchangeRate() out of limits.blade.php:11 — a bare top-level function in a Blade view is a redeclare hazard the moment the view is included twice. -->
   <!-- SUGGESTION: flush the skin cache after saveHomepageView / resetHomepageView (SC:4493-4545) and after every FAQ/footer entry write (SC:4649-4746) — all of them are read by the player-facing API through cached skin data. -->
   <!-- SUGGESTION: replace the hardcoded `if ($id == 70 || $id == 71 || $id == 72) $skinId = 69;` aliasing (SC:4460-4462, SC:4555-4557) with a real `homepage_source_skin_id` column — today three skins silently edit a fourth skin's homepage catalog. -->
   <!-- SUGGESTION: add the missing role checks: showFAQFooter / saveFAQFooter / updateFAQFooter / removeFAQFooter and SkinFooterController::json() are reachable by any authenticated back-office session; saveHomepageView / resetHomepageView / getGamesBySubcategory likewise. -->
   <!-- SUGGESTION: reconcile FaqFooter::getLangKeys() (12 hardcoded languages) with the `languages` table the Languages screen edits — `ar` and `ro` are offered for entries but are not rows in `languages`, while `nl`, `pl` and the legacy `br` row exist in `languages` and can never receive an entry. -->
   <!-- SUGGESTION: delete the descendants' translations in SkinFooterController::delete() (SFC:162-167) — it removes the item, its direct children and its own translations, orphaning every child translation row. -->
   <!-- SUGGESTION: point the Financial form at its own endpoint — financial.blade.php:5-6 posts to /skins/saveSkin/{id}/sport, so (a) a skin admin allowed to VIEW the tab is refused on save by the `sport` policy, and (b) every financial save runs the sport case's else-branch and deletes the skin's `print_always_copy` flag. -->
   <!-- SUGGESTION: render sport_bet_taxes / sport_win_taxes from their own stored values — sport.blade.php:82-83,95-96 print `sport_taxes` into both selects, and the JS at :198-202 force-zeroes them whenever the master select changes. -->
   <!-- SUGGESTION: fix the Security tab's hidden inputs (security.blade.php:59) — the initial value tests the settings ROW array instead of its ['value'], so a role stored as 0 renders unchecked but posts 1, and an untouched save silently re-enables it. -->
   <!-- SUGGESTION: stop delete-and-recreating per-skin payment methods (updateSkinDeposits SC:2319-2341 / updateSkinWithdraws SC:2344-2365, both flagged "DA SISTEMARE") — every legacy save wipes fee_pct, currency, agents, limit_year and auto_approve_under, which only the modern Payments admin writes. -->

   Everything here is a prototype: no fetch, no persistence. Saves emit a toast describing the real
   write path so the mechanics stay legible. */

const { useState: hsk2UseState, useMemo: hsk2UseMemo, useEffect: hsk2UseEffect } = React;

/* ══════════════════════════════════════════════════════════════════════════════════════════════
   1. Option sources — each list mirrors a real helper or table named in the reference.
   ══════════════════════════════════════════════════════════════════════════════════════════ */

/* integrations() — app/Helpers/utils.php:2037. The Providers matrix is ordered integration_id, name
   (ProvidersController::getProvidersForSkinManagement, PC:42-58) and shows the integration name only
   to a super admin (providers.blade.php:82-86). */
const HSK2_INTEGRATIONS = { 15: "Novusbet", 40: "TimelessTech", 50: "Slotomatica", 64: "SoftBetexchange", 80: "IGPixel", 90: "CmsWager", 95: "MondoGaming" };

/* `providers` — the FULL global catalog, not the skin's own. Ids/names are the ones the rest of the
   prototype uses (HostCmsProviderPromos HPP_PROVIDERS_BY_CAT, HostSetVendorsGroups) so a provider
   means the same thing on every screen. */
const HSK2_PROVIDERS = [
  { id: 69, name: "Sportsbook", integration: 15 },
  { id: 195, name: "Betxchange", integration: 64 },
  { id: 196, name: "Altenar", integration: 15 },
  { id: 101, name: "Pragmatic Play Slots", integration: 40 },
  { id: 102, name: "Pragmatic Play Live", integration: 40 },
  { id: 192, name: "Virtual Pragmatic", integration: 40 },
  { id: 111, name: "Amusnet", integration: 50 },
  { id: 112, name: "Amusnet Live", integration: 50 },
  { id: 121, name: "3Oaks", integration: 50 },
  { id: 132, name: "BGaming", integration: 80 },
  { id: 133, name: "Belatra", integration: 80 },
  { id: 134, name: "Evolution", integration: 80 },
  { id: 136, name: "NetEnt", integration: 80 },
  { id: 137, name: "Novomatic", integration: 80 },
  { id: 138, name: "Playson", integration: 90 },
  { id: 139, name: "Wazdan", integration: 90 },
  { id: 141, name: "Ezugi", integration: 90 },
  { id: 174, name: "Vivo Live", integration: 90 },
  { id: 175, name: "Macaw", integration: 95 },
  { id: 177, name: "Play'n GO", integration: 95 },
  { id: 180, name: "Habanero", integration: 95 },
  { id: 181, name: "PG Soft", integration: 95 },
  { id: 182, name: "Endorphina", integration: 95 },
  { id: 183, name: "Booming Games", integration: 95 },
  { id: 191, name: "Golden Race", integration: 95 },
  { id: 193, name: "Leap Gaming", integration: 95 },
  { id: 194, name: "Betradar Virtuals", integration: 95 },
];
const HSK2_PROV_BY_ID = Object.fromEntries(HSK2_PROVIDERS.map(p => [p.id, p]));

/* GameCategoriesController::getCategoriesList() — the `gamecategories` table. Same ids the taxonomy
   screen (HostCmsGameTaxonomy) edits. */
const HSK2_CATEGORIES = [[1, "Casino"], [2, "Casino Live"], [4, "Virtual"], [5, "Poker"], [6, "Sport"], [9, "Lottery"], [11, "Crash"]];
const HSK2_CAT_NAME = (id) => (HSK2_CATEGORIES.find(c => c[0] === Number(id)) || [0, `#${id}`])[1];

/* `gamesubcategories` visible to a skin — GameSubcategoriesController::getSubcategoriesBySkin
   (GSC:509-527), ordered category ASC / priority DESC. Subset of the taxonomy screen's seed. */
const HSK2_SUBCATS = [
  { id: 87, name: "Recommended", cat: 1, priority: 472 },
  { id: 88, name: "Joker", cat: 1, priority: 471 },
  { id: 84, name: "New Games", cat: 1, priority: 500 },
  { id: 77, name: "Jackpots", cat: 1, priority: 240 },
  { id: 79, name: "Buy Bonus", cat: 1, priority: 255 },
  { id: 78, name: "Megaways", cat: 1, priority: 250 },
  { id: 70, name: "Table Games", cat: 1, priority: 0 },
  { id: 71, name: "Slots Populares", cat: 1, priority: 0 },
  { id: 83, name: "Live Roulette", cat: 2, priority: 300 },
  { id: 82, name: "Live Blackjack", cat: 2, priority: 305 },
  { id: 81, name: "Game Shows", cat: 2, priority: 310 },
  { id: 76, name: "Virtual Football", cat: 4, priority: 120 },
  { id: 73, name: "Sport Highlights", cat: 6, priority: 60 },
  { id: 80, name: "Crash Games", cat: 11, priority: 260 },
];

/* `gamelabels` restricted through skins_labels — GameLabelsController::getLabelsListBySkin (GLC:402-422). */
const HSK2_LABELS = [
  { id: 1, name: "New" }, { id: 2, name: "Hot" }, { id: 3, name: "Popular" }, { id: 4, name: "Buy Bonus" },
  { id: 5, name: "Nuevo" }, { id: 6, name: "Jackpot" }, { id: 7, name: "Top Rated" }, { id: 8, name: "Exclusive" },
];

/* ImportGamesController::statiGiochi() (IGC:48-56) — the Active filter on the game grid. */
const HSK2_GAME_STATI = [["", "- All -"], ["1", "Attivo"], ["0", "Non attivo"]];

/* getSkinTemplates() (SC:657-670). Exactly two rows. */
const HSK2_TEMPLATES = [["", "Select Template"], ["frontend", "Default"], ["brasil", "Brasil"]];

/* getThemeHeaders() (SC:628-656) = v2…v16 and getThemeFooter() (SC:598-626) = v1…v15.
   The reference quotes only three labels verbatim ("Default", "v7 - Acaraybets", "v16 - Jugaygana");
   the remaining rows carry their version key as their own label rather than an invented brand name. */
const HSK2_HEADER_VERSIONS = ["", "v2", "v3", "v4", "v5", "v6", "v7", "v8", "v9", "v10", "v11", "v12", "v13", "v14", "v15", "v16"]
  .map(v => [v, v === "" ? "Select header" : v === "v7" ? "v7 - Acaraybets" : v === "v16" ? "v16 - Jugaygana" : v]);
const HSK2_FOOTER_VERSIONS = ["", "v1", "v2", "v3", "v4", "v5", "v6", "v7", "v8", "v9", "v10", "v11", "v12", "v13", "v14", "v15"]
  .map(v => [v, v === "" ? "Select footer" : v === "v1" ? "v1 - Default" : v]);

/* $userLevels for the Sport / Financial / Security tabs — showTab passes levels 0,1,2,4,6,8,10,15,20
   and adds 30 only for `financial` (SC:4237-4250). Labels from UsersController::usersLevels()
   (UC:1188-1218); 8/10/15/20 are overridable per skin through the custom_*_name settings. */
const HSK2_LEVELS = [
  [0, "Super Admin"], [1, "Affiliate"], [2, "Skin Access"], [4, "Customer Care"], [6, "Administration"],
  [8, "Master"], [10, "Agent"], [15, "Promoter"], [20, "Shop"],
];
const HSK2_LEVEL_NAME = (l) => (HSK2_LEVELS.find(x => String(x[0]) === String(l)) || [l, String(l) === "30" ? "Player" : `Level ${l}`])[1];

/* FaqFooter::getLangKeys() (FaqFooter.php:33-36) — TWELVE hardcoded keys, English required
   (FaqFooter.php:41-44). SkinFooterController reuses the same set through getLangConf() (SFC:29).

   ── Ledger conflict resolved here ────────────────────────────────────────────────────────────
   The retired prototype (HostSkins.jsx:103, SK_SEO_LANGS) hardcoded THIRTEEN languages —
   EN IT DE TR AR RO ZH ES FR PT PT-BR HU + a 13th "BR" — and the build ledger flagged that it
   disagrees with the `languages` table the Languages screen (HostSetLanguages.jsx, HSL_SEED_LANGS)
   edits. Resolved toward the reference:
     · the per-language editors are driven by FaqFooter::getLangKeys(), NOT by `languages` — so the
       set is these twelve, and the 13th "BR" entry is dropped (it duplicated pt_br; the `languages`
       table does carry a separate legacy `br` row, id 13, but no entries feature can ever use it).
     · the two lists genuinely diverge in the real platform and that divergence is surfaced in the
       UI instead of being smoothed over: `ar` + `ro` are offered here but are not rows in
       `languages`; `nl`, `pl` and `br` are rows in `languages` but cannot receive an entry.
   (The 13-language list also sat on the prototype's SEO tab. `seo-content-blocks` is one of the
   four undocumented catch-all tabs, so nothing here re-creates that screen — see the header.) */
const HSK2_LANGS = [
  ["en", "English", true], ["es", "Español", false], ["it", "Italiano", false], ["de", "Deutsche", false],
  ["tr", "Türkçe", false], ["ar", "Arabic", false], ["ro", "Română", false], ["zh", "Chinese", false],
  ["fr", "Français", false], ["pt", "Português", false], ["pt_br", "Português-Brasil", false], ["hu", "Magyar", false],
];
/* Rows of the `languages` table that no entries editor can reach, and entry languages with no row.
   Both directions come from comparing FaqFooter::getLangKeys() with HostSetLanguages' HSL_SEED_LANGS. */
const HSK2_LANG_ONLY_IN_TABLE = ["nl — Nederlands", "pl — Polski", "br — Brazilian Portuguese (legacy duplicate of pt_br)"];
const HSK2_LANG_ONLY_IN_ENTRIES = ["ar — Arabic", "ro — Română"];

/* homepage.blade.php:49-54 — the widget block's four enum options. */
const HSK2_WIDGETS = [["jackpot_banner", "Jackpot banner"], ["jackpot_compact", "Jackpot compact"], ["leaderboard", "Leaderboard"], ["tournament", "Tournament"]];

/* homepage.blade.php:149-200 — per-block scroll behaviour. "" is a real stored value (No Scroll). */
const HSK2_SCROLL = [["", "No Scroll"], ["auto_scroll", "Auto scroll"], ["arrows", "Arrows"]];

/* PromotionsController / ProviderPromotionRepository->get($skinId, null, 'all') (SC:4476-4477). */
const HSK2_PROMOS = [
  { id: 415, name: "Giros Gratis Pragmatic" }, { id: 411, name: "Evolution Live Cashback" },
  { id: 402, name: "Bono de Bienvenida 100%" }, { id: 398, name: "Recarga de Viernes" },
];

/* deposit_methods / withdraw_methods — the two global catalogs. Mirrors of HSD_CATALOG
   (src/pages/HostSetDepositMethods.jsx) and HSW_CATALOG (HostSetWithdrawMethods.jsx) so the skin tab
   and the Settings ▾ catalog screens never disagree about what a method is. Read from the live
   globals when those files are loaded, and only fall back to these copies if they are not. */
const HSK2_DEP_FALLBACK = [
  { id: 31, name: "Cripten PIX (BRL)", code: "cripten_pix" },
  { id: 30, name: "Cripten Bank Transfer (ARS)", code: "cripten_bank_transfer" },
  { id: 29, name: "Transferencia Bancaria 3 (123Hub)", code: "wire-123hub" },
  { id: 28, name: "Transferencia Bancaria 2 (VamosPago)", code: "wire-vamospago" },
  { id: 27, name: "Transferencia Bancaria 1 (HighHelp)", code: "wire-highhelp" },
  { id: 26, name: "Bank", code: "bank" },
  { id: 23, name: "Transferencia Bancaria", code: "wire-argentina" },
];
const HSK2_WIT_FALLBACK = [
  { id: 12, name: "Transferencia Bancaria", code: "bank" },
  { id: 13, name: "Voucher", code: "voucher" },
  { id: 14, name: "Crypto", code: "crypto" },
  { id: 15, name: "crypto", code: "crypto" },
  { id: 16, name: "Transferencia Bancaria 1 (HighHelp)", code: "wire-argentina" },
  { id: 17, name: "Transferencia Bancaria 2 (VamosPago)", code: "wire-vamospago" },
  { id: 18, name: "Transferencia Bancaria 3 (123Hub)", code: "wire-123hub" },
  { id: 19, name: "Cripten Bank Transfer (ARS)", code: "cripten" },
  { id: 20, name: "Cripten PIX (BRL)", code: "pix" },
  { id: 21, name: "Online (gateway)", code: "online" },
];
/* Resolved lazily (at render, never at load) so script order cannot matter. */
const hsk2DepCatalog = () => (typeof window !== "undefined" && window.HSD_CATALOG ? window.HSD_CATALOG : (typeof HSD_CATALOG !== "undefined" ? HSD_CATALOG : HSK2_DEP_FALLBACK));
const hsk2WitCatalog = () => (typeof window !== "undefined" && window.HSW_CATALOG ? window.HSW_CATALOG : (typeof HSW_CATALOG !== "undefined" ? HSW_CATALOG : HSK2_WIT_FALLBACK));

/* ══════════════════════════════════════════════════════════════════════════════════════════════
   2. Deterministic mock builders. Every list is seeded off the skin id, so a skin always renders
      the same rows — values fake, shapes real.
   ══════════════════════════════════════════════════════════════════════════════════════════ */

const hsk2Rnd = (key) => hskRng(hskHash(String(key)));
const hsk2Num = (v) => (v === "" || v == null ? "—" : Number(v).toLocaleString("en-US"));
const hsk2Round = (n, step) => String(Math.max(step, Math.round(n / step) * step));

/* skins_providers rows. `active` models row EXISTENCE — there is no status column. `percentage` is
   carried because the column exists and the Blade reads it (providers.blade.php:54,67); it is
   deliberately rendered nowhere, exactly as on the real screen. */
const hsk2BlankProv = () => ({ active: false, show_banner: false, featured: false, view: true, priority: "", percentage: "" });
const hsk2GenProviders = (skinId) => {
  const out = {};
  HSK2_PROVIDERS.forEach((p, i) => {
    const r = hsk2Rnd(`prov|${skinId}|${p.id}`);
    if (r() > 0.62) { out[p.id] = hsk2BlankProv(); return; }
    out[p.id] = {
      active: true,
      show_banner: r() < 0.3,
      featured: r() < 0.22,
      view: r() < 0.9,
      priority: r() < 0.55 ? String((i + 1) * 10) : "0",
      percentage: r() < 0.4 ? (Math.round(r() * 400) / 100).toFixed(2) : "",
    };
  });
  return out;
};

const HSK2_GAME_WORDS = [
  ["Gates of", "Olympus"], ["Sweet", "Bonanza"], ["Big Bass", "Splash"], ["Book of", "Dead"], ["Wolf", "Gold"],
  ["Sugar", "Rush"], ["Fruit", "Party"], ["Money", "Train"], ["Dog", "House"], ["Starlight", "Princess"],
  ["Mega", "Roulette"], ["Crazy", "Time"], ["Lightning", "Blackjack"], ["Football", "Studio"], ["Dream", "Catcher"],
  ["Aviator", "Crash"], ["Space", "Man"], ["Mines", "Deluxe"], ["Plinko", "Xl"], ["Penalty", "Shootout"],
  ["Virtual", "Derby"], ["Instant", "Racing"], ["Bingo", "Royal"], ["Keno", "Express"], ["Poker", "Cash"],
];
/* `games` LEFT JOIN providers/skins_providers/gamecategories — the grid feed of
   getSkinGamesManagement (SC:1821-2016). `enabled` is games.enabled (GLOBAL); `disabled` models a
   skins_games_disabled row (per-skin). */
const hsk2GenGames = (skinId, provRows) => {
  const active = HSK2_PROVIDERS.filter(p => provRows[p.id] && provRows[p.id].active);
  const out = [];
  active.forEach(p => {
    const r = hsk2Rnd(`games|${skinId}|${p.id}`);
    const n = 2 + Math.floor(r() * 4);
    for (let i = 0; i < n; i++) {
      const w = HSK2_GAME_WORDS[Math.floor(r() * HSK2_GAME_WORDS.length)];
      const cat = p.name.includes("Live") || p.name === "Evolution" || p.name === "Ezugi" || p.name === "Macaw" ? 2
        : p.name.includes("Virtual") || p.name.includes("Race") || p.name === "Leap Gaming" ? 4
          : p.name === "Sportsbook" || p.name === "Altenar" || p.name === "Betxchange" ? 6 : 1;
      const subs = HSK2_SUBCATS.filter(s => s.cat === cat);
      out.push({
        id: 10000 + p.id * 37 + i,
        name: `${w[0]} ${w[1]}${i ? " " + (i + 1) : ""}`,
        provider: p.id,
        category: cat,
        enabled: r() > 0.08,                                     // games.enabled — globally disabled games get no toggle
        disabled: r() < 0.18,                                    // skins_games_disabled row exists
        subs: subs.length && r() < 0.7 ? [subs[Math.floor(r() * subs.length)].id] : [],
        labels: r() < 0.3 ? [HSK2_LABELS[Math.floor(r() * HSK2_LABELS.length)].id] : [],
        priority: r() < 0.5 ? String(Math.floor(r() * 900)) : "0",
      });
    }
  });
  return out.sort((a, b) => a.name.localeCompare(b.name)).reverse();   // DataTable default: column 1 (Name) desc
};

/* skin_domains rows, read from the skin the editor already fetched. The
   generator this replaces invented `<code>.com`, `www.<code>.com` and — on a
   coin flip — `m.<code>.com`, which is how an operator ends up reading a
   domain list that names hosts nobody owns. */
const hsk2DomainRows = (skin) => (skin.domains || []).map((d, i) => ({
  key: `d${skin.id}-${i}`,
  domain: d.domain,
  url: /^https?:\/\//i.test(d.domain) ? d.domain : `https://${d.domain}`,
  main: !!d.is_primary,
}));

/* `jackpots` rows. setupJackpots seeds percent / start_balance / max_range / min_bet from the GLOBAL
   jp{i}_* settings and balance = 0 (SC:4027-4042); the balances below are what accumulation has
   since added. */
const HSK2_JP_GLOBAL = [
  { jp: 1, percent: "0.50", start_balance: "1000.00", max_range: "5000.00", min_bet: "1.00" },
  { jp: 2, percent: "0.75", start_balance: "5000.00", max_range: "25000.00", min_bet: "2.00" },
  { jp: 3, percent: "1.00", start_balance: "25000.00", max_range: "150000.00", min_bet: "5.00" },
];
const hsk2GenJackpots = (skinId) => {
  const r = hsk2Rnd(`jp|${skinId}`);
  return HSK2_JP_GLOBAL.map(g => ({
    id: skinId * 10 + g.jp, jp_id: g.jp,
    start_balance: g.start_balance, percent: g.percent, max_range: g.max_range, min_bet: g.min_bet,
    balance: (Number(g.start_balance) * (1 + r() * 2)).toFixed(2),
  }));
};

/* skins.* limits columns. */
const hsk2GenLimits = (skinId) => {
  const r = hsk2Rnd(`lim|${skinId}`);
  return {
    sport_balance: hsk2Round(r() * 400000 + 50000, 5000), sport_ggr_limit: r() < 0.4,
    casino_balance: hsk2Round(r() * 400000 + 50000, 5000), casino_ggr_limit: r() < 0.4,
    casinolive_balance: hsk2Round(r() * 200000 + 20000, 5000), casinolive_ggr_limit: r() < 0.3,
    virtual_balance: hsk2Round(r() * 100000 + 10000, 5000), virtual_ggr_limit: r() < 0.2,
    limit_notes: "",
  };
};

/* skins.* graphic columns. */
const hsk2GenGraphic = (skinId, code) => {
  const r = hsk2Rnd(`gfx|${skinId}`);
  const t = 1750000000 + Math.floor(r() * 40000000);
  const pal = ["#e2011a", "#0f1420", "#f5f6f8", "#ffffff", "#1f2937", "#facc15", "#16a34a", "#dc2626", "#2563eb", "#7c3aed", "#0891b2", "#ea580c", "#4d7c0f", "#be123c"];
  const g = { template_id: r() < 0.2 ? "brasil" : "frontend", header_version: `v${2 + Math.floor(r() * 15)}`, footer_version: `v${1 + Math.floor(r() * 15)}`, scripts_header: "", scripts_footer: "", custom_css: "", sport_custom_css: "" };
  ["logo_img", "logo_black", "favicon_img", "footer_img"].forEach((k, i) => {
    g[k] = r() < (i === 3 ? 0.5 : 0.9) ? `${skinId}${k === "logo_black" ? "_black" : ""}_${t + i}.${k === "favicon_img" ? "png" : "svg"}` : "";
  });
  for (let i = 1; i <= 14; i++) g[`colore${i}`] = i <= 4 || r() < 0.45 ? pal[i - 1] : "";
  return g;
};

/* ══════════════════════════════════════════════════════════════════════════════════════════════
   3. Small shared bits used by several tab bodies.
   ══════════════════════════════════════════════════════════════════════════════════════════ */

/* A tick that is a real checkbox (the matrices below are checkbox grids in the Blade, not switches). */
const Hsk2Tick = ({ checked, onChange, disabled, title }) => (
  <label className={`hsk2-tick${disabled ? " is-off" : ""}`} title={title}>
    <input type="checkbox" checked={!!checked} disabled={disabled} onChange={e => onChange(e.target.checked)} />
  </label>
);

/* Segmented control — used where the real screen has a small set of mutually exclusive views
   (entry type, financial acting role). Presentation only: it exposes no data the screen lacks. */
const Hsk2Seg = ({ value, onChange, options }) => (
  <div className="hsk2-seg" role="tablist">
    {options.map(o => {
      const v = Array.isArray(o) ? o[0] : o.value, l = Array.isArray(o) ? o[1] : o.label;
      return (
        <button key={String(v)} role="tab" aria-selected={String(v) === String(value)}
          className={`hsk2-seg__b${String(v) === String(value) ? " is-on" : ""}`} onClick={() => onChange(v)}>{l}</button>
      );
    })}
  </div>
);

/* Compact multi-select used by the grid cells (subcategories / labels) and the bulk modals. */
const Hsk2Multi = ({ value = [], options = [], onChange, placeholder = "None", size = 4 }) => (
  <select multiple className="select input--sm hsk2-multi" size={size} value={value.map(String)}
    aria-label={placeholder}
    onChange={e => onChange(Array.from(e.target.selectedOptions).map(o => Number(o.value)))}>
    {options.map(o => <option key={o.id} value={o.id}>{o.name}</option>)}
  </select>
);

/* Small chip; tone ∈ ok | off | warn | info */
const Hsk2Chip = ({ tone = "info", children, title }) => <span className={`hsk2-chip hsk2-chip--${tone}`} title={title}>{children}</span>;

/* A numeric text input — every one of these is a plain text input on the real screen (no server-side
   numeric validation anywhere in saveEditSkin), so type="text" + inputMode is the faithful control. */
const Hsk2Num = ({ value, onChange, placeholder, disabled, wide }) => (
  <input className={`input input--sm hsk2-num${wide ? " hsk2-num--w" : ""}`} type="text" inputMode="decimal"
    value={value ?? ""} placeholder={placeholder} disabled={disabled} onChange={e => onChange(e.target.value)} />
);

/* Draft-state helper: read `draft[key]`, falling back to a generated seed, and write back through set. */
const hsk2Slot = (draft, set, key, seed) => [draft[key] === undefined ? seed : draft[key], (v) => set(key, v)];

/* ══════════════════════════════════════════════════════════════════════════════════════════════
   4. Providers tab — GET /skins/{id}/providers/ → showSkinProviders (SC:1239-1258);
      POST /skins/saveSkin/{id}/providers → case "providers" (SC:3122-3128) → updateProvidersSkin
      (SC:2252-2315). Gate canManageSkin(id,'support_skin_providers').
   ══════════════════════════════════════════════════════════════════════════════════════════ */
const Hsk2ProvidersTab = ({ tab, skin, draft, set }) => {
  const seed = hsk2UseMemo(() => hsk2GenProviders(skin.id), [skin.id]);
  const [rows, setRows] = hsk2Slot(draft, set, "skin_providers", seed);
  const setOne = (pid, patch) => setRows({ ...rows, [pid]: { ...(rows[pid] || hsk2BlankProv()), ...patch } });
  const activeCount = HSK2_PROVIDERS.filter(p => rows[p.id] && rows[p.id].active).length;

  /* "SELECT ALL PROVIDERS" (providers.blade.php:10). */
  const selectAll = () => setRows(Object.fromEntries(HSK2_PROVIDERS.map(p => [p.id, { ...(rows[p.id] || hsk2BlankProv()), active: true }])));

  const save = () => {
    /* KNOWN-BUG DIVERGENCE 1 — SkinProvider::$fillable omits show_banner (SkinProvider.php:15-22), so
       the create branch (SC:2297-2304) drops Banner while the update branch (a query-builder mass
       update, SC:2307-2312) keeps it: enabling a provider with Banner ticked in the SAME save loses
       the flag until a second save. Evident intent implemented — Banner persists on first save.
       KNOWN-BUG DIVERGENCE 2 — `case "providers"` never calls flushSkinCache (unlike home/graphic/…),
       so provider lists can be served stale. Evident intent implemented: the save flushes. */
    const next = {};
    HSK2_PROVIDERS.forEach(p => {
      const r = rows[p.id] || hsk2BlankProv();
      /* Unchecked rows are DELETED from skins_providers (SC:2262-2277) — priority and the
         non-editable percentage go with them. That is the real presence-based semantic, kept. */
      next[p.id] = r.active ? { ...r, priority: r.priority === "" ? "0" : r.priority } : hsk2BlankProv();
    });
    setRows(next);
    hskToast(`Providers saved for ${skin.name}`,
      `updateProvidersSkin deleted the unticked rows and upserted ${activeCount} rows in skins_providers (priority / view / featured / show_banner), then flushed the skin cache.`);
  };

  const cell = (p, key, title) => {
    const r = rows[p.id] || hsk2BlankProv();
    return <Hsk2Tick checked={r[key]} title={title} onChange={v => setOne(p.id, { [key]: v })} />;
  };

  const columns = [
    { key: "name", label: "Provider", render: (p) => (
      <div className="hsk2-provcell">
        <span className="hsk2-provcell__n">{p.name}</span>
        {/* providers.blade.php:82-86 renders the integration name only for a super admin. */}
        <span className="hsk2-provcell__i" title="Integration — rendered for super admins only">{HSK2_INTEGRATIONS[p.integration] || p.integration}</span>
      </div>
    ) },
    { key: "status", label: "Active", align: "center", render: (p) => cell(p, "active", "Row presence in skins_providers") },
    { key: "show_banner", label: "Banner", align: "center", render: (p) => cell(p, "show_banner", "skins_providers.show_banner") },
    { key: "featured", label: "Featured", align: "center", render: (p) => cell(p, "featured", "skins_providers.featured") },
    { key: "view", label: "Visible", align: "center", render: (p) => cell(p, "view", "skins_providers.view") },
    { key: "priority", label: "Priority", align: "center", width: 120, render: (p) => {
      const r = rows[p.id] || hsk2BlankProv();
      /* Priority ≤ 0 renders blank (providers.blade.php:73-76); the input is disabled unless Active (142-144). */
      const shown = !r.active ? "" : (Number(r.priority) > 0 ? r.priority : "");
      return <Hsk2Num value={shown} disabled={!r.active} onChange={v => setOne(p.id, { priority: v })} />;
    } },
  ];

  return (
    <>
      <HskTabHead tab={tab} />
      <HskNote tone="info" title="Rows are the whole platform catalog, not this skin's providers.">
        <code>getProvidersForSkinManagement</code> deliberately skips skin filtering (its docblock explains
        <code>getProviders()</code> would return nothing for a skin-scoped manager) — so a scoped Customer Care
        manager sees, and can enable, <b>any</b> provider that exists. Per-skin state comes from a
        <code>SkinProvider::where(skin, provider)-&gt;first()</code> inside the Blade loop — one query per row.
      </HskNote>

      <HskSection
        title={`Provider matrix — ${activeCount} of ${HSK2_PROVIDERS.length} enabled`}
        desc="Active is row presence in skins_providers; the other four are columns on that row. Ordered by integration, then name."
        actions={<button className="btn btn--secondary btn--sm" onClick={selectAll}><Icon name="check" size={12} /> Select all providers</button>}>
        <HrsTable
          columns={columns}
          rows={HSK2_PROVIDERS}
          rowKey={(p) => p.id}
          renderCard={(p) => {
            const r = rows[p.id] || hsk2BlankProv();
            return (
              <div className="hsk2-card">
                <div className="hsk2-card__t">{p.name}</div>
                <div className="hsk2-card__s">{HSK2_INTEGRATIONS[p.integration]} · provider #{p.id}</div>
                <div className="hsk2-card__grid">
                  <label className="hsk2-card__f"><span>Active</span>{cell(p, "active")}</label>
                  <label className="hsk2-card__f"><span>Banner</span>{cell(p, "show_banner")}</label>
                  <label className="hsk2-card__f"><span>Featured</span>{cell(p, "featured")}</label>
                  <label className="hsk2-card__f"><span>Visible</span>{cell(p, "view")}</label>
                </div>
                <div className="hsk2-card__f hsk2-card__f--wide">
                  <span>Priority</span>
                  <Hsk2Num value={!r.active ? "" : (Number(r.priority) > 0 ? r.priority : "")} disabled={!r.active} onChange={v => setOne(p.id, { priority: v })} />
                </div>
              </div>
            );
          }}
        />
      </HskSection>

      <HskNote tone="warn" title="Unticking Active deletes the row.">
        There is no status column: the save removes the <code>skins_providers</code> row outright, taking
        <code>priority</code> — and <code>percentage</code>, which this screen reads but never renders or edits —
        with it. Re-enabling recreates the row with defaults.
      </HskNote>
      <HskNote tone="bug" title="Two real-platform defects, implemented as intended.">
        Banner is dropped when a provider is enabled and bannered in the same save (<code>show_banner</code> is
        missing from <code>SkinProvider::$fillable</code>), and this save is the one tab that never busts the skin
        cache. Both are fixed here, and both carry a SUGGESTION at the top of this file.
      </HskNote>

      <HskSaveBar onSave={save} label="Save providers"
        note="POST /skins/saveSkin/{id}/providers · the only tab a scoped Customer Care manager may save" />
    </>
  );
};

/* ══════════════════════════════════════════════════════════════════════════════════════════════
   5. Game management tab — GET /skins/{id}/gamemanagement/ (SC:1303-1330); grid
      getSkinGamesManagement (SC:1821-2016); inline GET writers updatedbgame (SC:1398-1414) and
      updategamepriority_generic (SC:1545-1585); three bulk POSTs (SC:1588-1818).
      There is NO `case "gamemanagement"` in saveEditSkin — so there is no page-level Save here.
   ══════════════════════════════════════════════════════════════════════════════════════════ */

/* Shared body of the three bulk-flow modals. */
const Hsk2BulkModal = ({ mode, skin, skins, provOptions, onClose, onRun }) => {
  const [provs, setProvs] = hsk2UseState(provOptions.map(p => p.id));   // default = all of the skin's providers
  const [source, setSource] = hsk2UseState(() => (skins.find(s => s.id !== skin.id) || {}).id || "");
  const meta = {
    remove: { title: "Remove settings", sub: "POST /skins/{id}/gamemanagement/removeSettings — reads skin_id from the BODY, ignores the URL id",
      body: "Deletes every skins_games_disabled, gamesubcategories_skin_assoc and gamelabels_skin_assoc row for all games of the chosen providers. Priorities are not touched." },
    apply: { title: "Apply settings from Default", sub: "POST /skins/{id}/gamemanagement/applySettings",
      body: "Wipes this skin's subcategory and label associations for the chosen providers' games, then copies the global gamesubcategories_assoc / gamelabels_assoc defaults. Disabled flags and priorities are not touched." },
    fromskin: { title: "Apply settings from another Skin", sub: "POST /skins/{id}/gamemanagement/applyFromSkin",
      body: "Intersects the chosen providers with the source skin's own providers, wipes this skin's subcategory + label assocs AND its disabled rows, then copies the source skin's. The subcategory tile image column is not copied." },
  }[mode];

  return (
    <div className="bp-modal-scrim hsk-scrim" onClick={onClose}>
      <div className="bp-modal hsk-modal hsk-modal--wide" onClick={e => e.stopPropagation()}>
        <div className="hsk-modal__head">
          <div>
            <div className="hsk-modal__title">{meta.title}</div>
            <div className="hsk-modal__sub">{meta.sub}</div>
          </div>
          <button className="hsk-x" title="Close" onClick={onClose}><Icon name="x" size={14} /></button>
        </div>
        <div className="hsk-modal__body">
          <p className="hsk-p">{meta.body}</p>
          {mode === "fromskin" && (
            <HskField label="Source skin" name="selected_skin" hint="The source skin is NOT permission-checked on the real platform — a scoped Customer Care manager can copy out of any skin.">
              <HskSelect value={source} onChange={setSource} options={skins.filter(s => s.id !== skin.id).map(s => [s.id, `${s.id} — ${s.name}`])} />
            </HskField>
          )}
          <HskField label="Providers" name="providers[]" full
            hint="Defaults to every provider enabled on this skin. Ctrl/Cmd-click to narrow the set.">
            <Hsk2Multi value={provs} options={provOptions} size={8} onChange={setProvs} />
          </HskField>
          {mode === "fromskin" && (
            <HskNote tone="bug" title="Provider list fixed.">
              In the real modal the Blade's skin dropdown loop overwrites <code>$skin</code>, so this list is built from the
              <b> last skin of Skin::all()</b>, not the skin being edited. It is built from this skin here.
            </HskNote>
          )}
          <HskNote tone="warn" title="The URL id is decorative.">
            All three bulk endpoints gate and write against <code>skin_id</code> taken from the request body — consistent with
            each other, but the <code>{"{id}"}</code> in the URL is never read.
          </HskNote>
        </div>
        <div className="hsk-modal__foot">
          <button className="btn btn--secondary btn--sm" onClick={onClose}>Cancel</button>
          <button className="hrs-btn hrs-btn--filters" onClick={() => onRun(mode, provs, source)}>
            <Icon name="check" size={14} /> Run
          </button>
        </div>
      </div>
    </div>
  );
};

const Hsk2GamesTab = ({ tab, skin, skins, draft, set }) => {
  const provSeed = hsk2UseMemo(() => hsk2GenProviders(skin.id), [skin.id]);
  const provRows = draft.skin_providers === undefined ? provSeed : draft.skin_providers;
  const gameSeed = hsk2UseMemo(() => hsk2GenGames(skin.id, provRows), [skin.id, provRows]);
  const [games, setGames] = hsk2Slot(draft, set, "skin_games", gameSeed);

  const provOptions = HSK2_PROVIDERS.filter(p => provRows[p.id] && provRows[p.id].active);
  const [draftF, setDraftF] = hsk2UseState({ id: "", name: "", provider: "", category: "", active: "" });
  const [f, setF] = hsk2UseState({ id: "", name: "", provider: "", category: "", active: "" });
  const [page, setPage] = hsk2UseState(0);
  const [size, setSize] = hsk2UseState(100);          // gamemanagement.js:10-30 — pageLength 100
  const [sort, setSort] = hsk2UseState({ key: "name", dir: "desc" });   // default order: column 1 desc
  const [bulk, setBulk] = hsk2UseState(null);

  hsk2UseEffect(() => { setPage(0); }, [f, skin.id]);

  const setGame = (id, patch) => setGames(games.map(g => (g.id === id ? { ...g, ...patch } : g)));

  const FIELDS = [
    { key: "id", label: "ID", type: "number", icon: "tag", placeholder: "Exact id…", width: 150,
      tip: <>Exact match on <code>games.id</code>.</> },
    { key: "name", label: "Name", type: "text", icon: "search", placeholder: "Any part of the name…", grow: true,
      tip: <>Server-side <code>games.name LIKE '%value%'</code>.</> },
    { key: "provider", label: "Provider", type: "select", icon: "globe", placeholder: "- All -",
      options: provOptions.map(p => ({ value: String(p.id), label: p.name })),
      tip: <>Options are <code>skinProviders(skin, 1)</code> — this skin's own providers. Real-platform quirk: the value <code>1</code> is hijacked to mean “any provider”, so a provider whose id is 1 can never be filtered alone. Filtered properly here.</> },
    { key: "category", label: "Category", type: "select", icon: "grid", placeholder: "- All -",
      options: HSK2_CATEGORIES.map(([v, l]) => ({ value: String(v), label: l })) },
    { key: "active", label: "Active", type: "select", icon: "toggle_right", placeholder: "- All -",
      options: HSK2_GAME_STATI.slice(1).map(([v, l]) => ({ value: v, label: l })),
      tip: <>1 = no <code>skins_games_disabled</code> row for this skin; 0 = a row exists.</> },
  ];

  const filtered = hsk2UseMemo(() => games.filter(g => {
    if (f.id && String(g.id) !== String(f.id)) return false;
    if (f.name && !g.name.toLowerCase().includes(String(f.name).toLowerCase())) return false;
    if (f.provider && String(g.provider) !== String(f.provider)) return false;
    if (f.category && String(g.category) !== String(f.category)) return false;
    if (f.active === "1" && g.disabled) return false;
    if (f.active === "0" && !g.disabled) return false;
    return true;
  }), [games, f]);

  const sorted = hsk2UseMemo(() => {
    const rows = filtered.slice();
    const dir = sort.dir === "asc" ? 1 : -1;
    rows.sort((a, b) => {
      if (sort.key === "priority") return (Number(a.priority || 0) - Number(b.priority || 0)) * dir;
      if (sort.key === "id") return (a.id - b.id) * dir;
      if (sort.key === "provider") return (HSK2_PROV_BY_ID[a.provider].name).localeCompare(HSK2_PROV_BY_ID[b.provider].name) * dir;
      if (sort.key === "category") return HSK2_CAT_NAME(a.category).localeCompare(HSK2_CAT_NAME(b.category)) * dir;
      return a.name.localeCompare(b.name) * dir;
    });
    return rows;
  }, [filtered, sort]);

  const shown = sorted.slice(page * size, page * size + size);

  const runBulk = (mode, provs, source) => {
    setBulk(null);
    const names = provs.map(id => (HSK2_PROV_BY_ID[id] || {}).name).filter(Boolean);
    const affected = games.filter(g => provs.includes(g.provider));
    if (mode === "remove") {
      setGames(games.map(g => (provs.includes(g.provider) ? { ...g, disabled: false, subs: [], labels: [] } : g)));
      hskToast("Settings removed", `${affected.length} games across ${names.length} providers: skins_games_disabled, gamesubcategories_skin_assoc and gamelabels_skin_assoc rows deleted. Priorities untouched.`);
    } else if (mode === "apply") {
      setGames(games.map(g => {
        if (!provs.includes(g.provider)) return g;
        const subs = HSK2_SUBCATS.filter(s => s.cat === g.category).slice(0, 1).map(s => s.id);
        return { ...g, subs, labels: [] };
      }));
      hskToast("Defaults applied", `Subcategory + label assocs for ${affected.length} games replaced from the global gamesubcategories_assoc / gamelabels_assoc. Disabled flags and priorities untouched. (The skins_subcategories “validity” filter in this path is a no-op: whereNull(...)->orWhereNotNull(...) matches every row.)`);
    } else {
      const src = skins.find(s => String(s.id) === String(source)) || {};
      const srcProv = hsk2GenProviders(src.id || 0);
      const inter = provs.filter(id => srcProv[id] && srcProv[id].active);
      const srcGames = hsk2GenGames(src.id || 0, srcProv);
      setGames(games.map(g => {
        if (!inter.includes(g.provider)) return g;
        const match = srcGames.find(x => x.name === g.name && x.provider === g.provider);
        return match ? { ...g, subs: match.subs, labels: match.labels, disabled: match.disabled } : { ...g, subs: [], labels: [], disabled: false };
      }));
      hskToast(`Copied from ${src.name || "skin"}`, `Providers intersected with the source skin's own (${inter.length} kept). Subcategory + label assocs and skins_games_disabled rows replaced. The subcategory tile image column is not part of the copy.`);
    }
  };

  const columns = [
    { key: "id", label: "ID", sortable: true, width: 90, render: (g) => <span className="hsk2-mono">{g.id}</span> },
    { key: "name", label: "Name", sortable: true, firstDir: "desc", render: (g) => <span className="hsk2-gname">{g.name}</span> },
    { key: "provider", label: "Provider", sortable: true, render: (g) => (HSK2_PROV_BY_ID[g.provider] || {}).name },
    /* The category SELECT editor is dead UI: the Blade starts one and immediately overwrites it with
       the plain-text version in the same assignment chain (SC:1981-1982). Rendered as text, as it is. */
    { key: "category", label: "Category", sortable: true, render: (g) => HSK2_CAT_NAME(g.category) },
    { key: "subs", label: "Subcategory", width: 180, render: (g) => (
      <Hsk2Multi value={g.subs} options={HSK2_SUBCATS.filter(s => s.cat === g.category)} size={3}
        onChange={v => { setGame(g.id, { subs: v }); hskToast("Subcategories updated", `GET updatedbgame?param=subcategories → GamesController::updateGameSubcategoriesBySkin diffs gamesubcategories_skin_assoc for game ${g.id}.`); }} />
    ) },
    { key: "labels", label: "Label", width: 170, render: (g) => (
      <Hsk2Multi value={g.labels} options={HSK2_LABELS} size={3}
        onChange={v => { setGame(g.id, { labels: v }); hskToast("Labels updated", `GET updatedbgame?param=labels → GamesController::updateGameLabelsBySkin diffs gamelabels_skin_assoc for game ${g.id}.`); }} />
    ) },
    { key: "priority", label: "Priority", sortable: true, width: 130, render: (g) => (
      <Hsk2Num value={g.priority} onChange={v => setGame(g.id, { priority: v })} />
    ) },
    { key: "active", label: "Active", align: "center", width: 110, render: (g) => (
      /* games.enabled = 0 → a static red "Disabled" cell with no toggle (SC:1970-1971). */
      !g.enabled ? <Hsk2Chip tone="off" title="games.enabled = 0 — disabled platform-wide, not per skin">Disabled</Hsk2Chip>
        : <HskSwitch value={!g.disabled} on="Active" off="Off"
          onChange={v => { setGame(g.id, { disabled: !v }); hskToast(v ? "Game enabled" : "Game disabled", v ? `GET updatedbgame?param=enabled deleted the skins_games_disabled rows for game ${g.id}.` : `GET updatedbgame?param=enabled inserted a skins_games_disabled row for game ${g.id} — a blind insert with no unique constraint, so a double-fire can duplicate it.`); }} />
    ) },
  ];

  return (
    <>
      <HskTabHead tab={tab} />
      <HskNote tone="warn" title="This tab has no Save button — and no save case.">
        Every edit below is written the moment you make it, through <b>state-changing GET</b> endpoints
        (<code>updatedbgame</code>, <code>updategamepriority</code>) that carry no CSRF token by construction.
        The empty form the Blade renders posts to <code>/skins/saveSkin/{"{id}"}/gamemanagement</code>, for which
        <code>saveEditSkin</code> has no case at all — a post would fall off the switch and return null.
      </HskNote>

      <HrsFilters fields={FIELDS} values={draftF} onChange={(k, v) => setDraftF(s => ({ ...s, [k]: v }))}
        onSearch={(vals) => setF({ ...vals })} onReset={() => { const e = { id: "", name: "", provider: "", category: "", active: "" }; setDraftF(e); setF(e); }}
        resultLabel={`${filtered.length} of ${games.length}`} />

      <div className="hsk2-bulkbar">
        <span className="hsk2-bulkbar__l">Bulk</span>
        <button className="btn btn--secondary btn--sm" onClick={() => setBulk("remove")}><Icon name="trash" size={12} /> Remove settings</button>
        <button className="btn btn--secondary btn--sm" onClick={() => setBulk("apply")}><Icon name="refresh" size={12} /> Apply settings from Default</button>
        <button className="btn btn--secondary btn--sm" onClick={() => setBulk("fromskin")}><Icon name="copy" size={12} /> Apply settings from another Skin</button>
      </div>

      <HrsTable
        columns={columns} rows={shown} sort={sort} onSort={setSort} rowKey={(g) => g.id}
        empty={provOptions.length ? "No game matches these filters." : "This skin has no enabled providers, so the grid joins to nothing — enable a provider on the Provider tab first."}
        renderCard={(g) => (
          <div className="hsk2-card">
            <div className="hsk2-card__t">{g.name}</div>
            <div className="hsk2-card__s">#{g.id} · {(HSK2_PROV_BY_ID[g.provider] || {}).name} · {HSK2_CAT_NAME(g.category)}</div>
            <div className="hsk2-card__f hsk2-card__f--wide">
              <span>Active</span>
              {!g.enabled ? <Hsk2Chip tone="off">Disabled</Hsk2Chip>
                : <HskSwitch value={!g.disabled} on="Active" off="Off" onChange={v => setGame(g.id, { disabled: !v })} />}
            </div>
            <div className="hsk2-card__f hsk2-card__f--wide"><span>Priority</span><Hsk2Num value={g.priority} onChange={v => setGame(g.id, { priority: v })} /></div>
            <details className="hsk2-more">
              <summary>Subcategories &amp; labels</summary>
              <div className="hsk2-card__f hsk2-card__f--wide"><span>Subcategory</span>
                <Hsk2Multi value={g.subs} options={HSK2_SUBCATS.filter(s => s.cat === g.category)} size={4} onChange={v => setGame(g.id, { subs: v })} /></div>
              <div className="hsk2-card__f hsk2-card__f--wide"><span>Label</span>
                <Hsk2Multi value={g.labels} options={HSK2_LABELS} size={4} onChange={v => setGame(g.id, { labels: v })} /></div>
            </details>
          </div>
        )} />

      <HrsPager page={page} pageSize={size} total={filtered.length} onPage={setPage} onPageSize={(n) => { setSize(n); setPage(0); }} sizes={[25, 50, 100]} />

      <HskNote tone="info" title="Only games of enabled providers appear.">
        The grid inner-joins <code>skins_providers</code> for this skin, so disabling a provider hides its whole
        catalogue here. The Category column is read-only: its select editor is dead code, though the
        <code>updatedbgame?param=category_id</code> endpoint it used to drive is still live — and it edits
        <code>games.category_id</code> <b>globally, for every skin</b>, while wiping this skin's subcategory assocs
        for that game.
      </HskNote>
      <HskNote tone="bug" title="Subcategory refresh is doubly broken upstream.">
        The JS calls <code>/gamemanagement/getsubcatgories</code> (typo → 404) and the correctly spelled route points at
        <code>SkinsController@getsubcategories</code>, a method that does not exist (→ 500). Changing a game's category
        therefore never refreshes its subcategory options on the real screen.
      </HskNote>

      {bulk && <Hsk2BulkModal mode={bulk} skin={skin} skins={skins} provOptions={provOptions} onClose={() => setBulk(null)} onRun={runBulk} />}
    </>
  );
};

/* ══════════════════════════════════════════════════════════════════════════════════════════════
   6. Subcategories management tab — GET /skins/{id}/subcategoriesmanagement/ (SC:1332-1363);
      grid getSkinGamesSubcategoryManagement (SC:2018-2143); priority GET updategamepriority
      (SC:1498-1543); POST uploadImage (SC:1434-1468) / removeImage (SC:1470-1497).
   ══════════════════════════════════════════════════════════════════════════════════════════ */
const Hsk2SubcatPanel = ({ skin, sub, games, onPriority, image, onImage }) => {
  const [draftF, setDraftF] = hsk2UseState({ id: "", name: "", provider: "" });
  const [f, setF] = hsk2UseState({ id: "", name: "", provider: "" });
  const [page, setPage] = hsk2UseState(0);
  const [sort, setSort] = hsk2UseState({ key: "priority", dir: "desc" });   // initTable default: column 3 desc

  const provOptions = hsk2UseMemo(() => {
    const ids = Array.from(new Set(games.map(g => g.provider)));
    return ids.map(id => HSK2_PROV_BY_ID[id]).filter(Boolean);
  }, [games]);

  const rows = hsk2UseMemo(() => {
    const out = games.filter(g => {
      if (f.id && String(g.id) !== String(f.id)) return false;
      if (f.name && !g.name.toLowerCase().includes(String(f.name).toLowerCase())) return false;
      if (f.provider && String(g.provider) !== String(f.provider)) return false;
      return true;
    });
    const dir = sort.dir === "asc" ? 1 : -1;
    out.sort((a, b) => (sort.key === "priority" ? (Number(a.subPriority || 0) - Number(b.subPriority || 0)) * dir
      : sort.key === "id" ? (a.id - b.id) * dir
        : sort.key === "provider" ? HSK2_PROV_BY_ID[a.provider].name.localeCompare(HSK2_PROV_BY_ID[b.provider].name) * dir
          : a.name.localeCompare(b.name) * dir));
    return out;
  }, [games, f, sort]);

  const FIELDS = [
    { key: "id", label: "ID", type: "number", icon: "tag", placeholder: "Exact id…", width: 140 },
    { key: "name", label: "Name", type: "text", icon: "search", placeholder: "Any part of the name…", grow: true },
    { key: "provider", label: "Provider", type: "select", icon: "globe", placeholder: "- All -", options: provOptions.map(p => ({ value: String(p.id), label: p.name })) },
  ];

  const columns = [
    { key: "id", label: "ID", sortable: true, width: 90, render: (g) => <span className="hsk2-mono">{g.id}</span> },
    { key: "name", label: "Name", sortable: true, render: (g) => g.name },
    { key: "provider", label: "Provider", sortable: true, render: (g) => (HSK2_PROV_BY_ID[g.provider] || {}).name },
    { key: "priority", label: "Priority", sortable: true, firstDir: "desc", width: 140, render: (g) => (
      <Hsk2Num value={g.subPriority} onChange={v => onPriority(g.id, v)} />
    ) },
  ];

  return (
    <div className="hsk2-acc__body">
      <div className="hsk2-imgrow">
        <div className="hsk2-img">
          {image
            ? <div className="hsk2-img__prev" title={`storage/subcategories/img/${image}`}><Icon name="star" size={20} /></div>
            : <div className="hsk2-img__prev hsk2-img__prev--empty"><Icon name="upload" size={18} /></div>}
          <div className="hsk2-img__meta">
            <div className="hsk2-img__n">{image || "No tile image"}</div>
            <div className="hsk2-img__p">
              <code>gamesubcategories_skin_assoc.image</code> · served from <code>storage/subcategories/img</code>
            </div>
            <div className="hsk2-img__acts">
              <label className="btn btn--secondary btn--sm hsk2-file">
                <Icon name="upload" size={12} /> Upload image
                <input type="file" accept="image/*" onChange={(e) => {
                  const file = e.target.files && e.target.files[0];
                  if (!file) return;
                  if (file.size > 2 * 1024 * 1024) { hskToast("File too large", "Dropzone caps this uploader at 1 file / 2 MB / image/*."); e.target.value = ""; return; }
                  const ext = (file.name.split(".").pop() || "png").toLowerCase();
                  onImage(`${skin.id}_${sub.id}_${Math.floor(Date.now() / 1000)}.${ext}`);
                  e.target.value = "";
                }} />
              </label>
              {image && <button className="btn btn--secondary btn--sm hsk2-danger" onClick={() => onImage("")}><Icon name="trash" size={12} /> Remove image</button>}
            </div>
          </div>
        </div>
      </div>

      <HrsFilters fields={FIELDS} values={draftF} onChange={(k, v) => setDraftF(s => ({ ...s, [k]: v }))}
        onSearch={(vals) => { setF({ ...vals }); setPage(0); }}
        onReset={() => { const e = { id: "", name: "", provider: "" }; setDraftF(e); setF(e); setPage(0); }}
        resultLabel={`${rows.length} games`} />

      <HrsTable columns={columns} rows={rows.slice(page * 10, page * 10 + 10)} sort={sort} onSort={setSort} rowKey={(g) => g.id}
        empty="No game of this skin is associated with this subcategory."
        renderCard={(g) => (
          <div className="hsk2-card">
            <div className="hsk2-card__t">{g.name}</div>
            <div className="hsk2-card__s">#{g.id} · {(HSK2_PROV_BY_ID[g.provider] || {}).name}</div>
            <div className="hsk2-card__f hsk2-card__f--wide"><span>Priority</span><Hsk2Num value={g.subPriority} onChange={v => onPriority(g.id, v)} /></div>
          </div>
        )} />
      <HrsPager page={page} pageSize={10} total={rows.length} onPage={setPage} />
    </div>
  );
};

const Hsk2SubcategoriesTab = ({ tab, skin, draft, set }) => {
  const provSeed = hsk2UseMemo(() => hsk2GenProviders(skin.id), [skin.id]);
  const provRows = draft.skin_providers === undefined ? provSeed : draft.skin_providers;
  const gameSeed = hsk2UseMemo(() => hsk2GenGames(skin.id, provRows), [skin.id, provRows]);
  const games = draft.skin_games === undefined ? gameSeed : draft.skin_games;

  /* gamesubcategories_skin_priorities rows, keyed `${subcategoryId}:${gameId}`. */
  const prioSeed = hsk2UseMemo(() => {
    const r = hsk2Rnd(`subprio|${skin.id}`);
    const out = {};
    HSK2_SUBCATS.forEach(s => games.forEach(g => { if (g.subs.includes(s.id)) out[`${s.id}:${g.id}`] = String(Math.floor(r() * 900)); }));
    return out;
  }, [skin.id, games]);
  const [prio, setPrio] = hsk2Slot(draft, set, "subcat_priorities", prioSeed);

  const imgSeed = hsk2UseMemo(() => {
    const r = hsk2Rnd(`subimg|${skin.id}`);
    return Object.fromEntries(HSK2_SUBCATS.map(s => [s.id, r() < 0.55 ? `${skin.id}_${s.id}_${1750000000 + Math.floor(r() * 4e7)}.png` : ""]));
  }, [skin.id]);
  const [images, setImages] = hsk2Slot(draft, set, "subcat_images", imgSeed);

  const [open, setOpen] = hsk2UseState(null);

  /* Ordered category ASC, priority DESC (GameSubcategoriesController::getSubcategoriesBySkin). */
  const subs = hsk2UseMemo(() => HSK2_SUBCATS.slice().sort((a, b) => (a.cat - b.cat) || (b.priority - a.priority)), []);

  return (
    <>
      <HskTabHead tab={tab} />
      <HskNote tone="info" title="Two writes live here, both outside the shared save.">
        Game order inside a subcategory is a <b>GET</b> upsert into <code>gamesubcategories_skin_priorities</code>;
        the tile image is a Dropzone POST. There is no <code>saveEditSkin</code> case for this tab.
      </HskNote>

      <div className="hsk2-acc">
        {subs.map(s => {
          const inSub = games.filter(g => g.subs.includes(s.id) && !g.disabled)
            .map(g => ({ ...g, subPriority: prio[`${s.id}:${g.id}`] || "0" }));
          const isOpen = open === s.id;
          return (
            <div key={s.id} className={`hsk2-acc__item${isOpen ? " is-open" : ""}`}>
              <button className="hsk2-acc__head" aria-expanded={isOpen} onClick={() => setOpen(isOpen ? null : s.id)}>
                <Icon name={isOpen ? "chevron_down" : "chevron_right"} size={14} />
                <span className="hsk2-acc__n">{s.name}</span>
                <span className="hsk2-acc__c">{HSK2_CAT_NAME(s.cat)}</span>
                <span className="hsk2-acc__meta">{inSub.length} games</span>
                {images[s.id] ? <Hsk2Chip tone="ok">image</Hsk2Chip> : <Hsk2Chip tone="off">no image</Hsk2Chip>}
              </button>
              {isOpen && (
                <Hsk2SubcatPanel skin={skin} sub={s} games={inSub} image={images[s.id]}
                  onPriority={(gid, v) => { setPrio({ ...prio, [`${s.id}:${gid}`]: v }); hskToast("Priority saved", `GET updategamepriority upserted gamesubcategories_skin_priorities (skin ${skin.id}, subcategory ${s.id}, game ${gid}) = ${v || "0"}. The endpoint accepts any string — there is no numeric validation.`); }}
                  onImage={(name) => {
                    /* KNOWN-BUG DIVERGENCE — uploadSubcategoryImage's updateOrInsert key omits game_id, so
                       the image lands on an arbitrary (skin, subcategory) row (usually a game-association
                       row) and is wiped by anything that clears assocs; removeSubcategoryImage then deletes
                       from `…/subcategories/images/` while uploads go to `…/subcategories/img/`, so the file
                       survives forever. Evident intent implemented: one image per (skin, subcategory),
                       independent of assoc rows, and Remove really removes. */
                    setImages({ ...images, [s.id]: name });
                    hskToast(name ? "Image uploaded" : "Image removed",
                      name ? `Stored as public/subcategories/img/${name} and written to gamesubcategories_skin_assoc.image for (skin ${skin.id}, subcategory ${s.id}).`
                        : `image blanked on every (skin ${skin.id}, subcategory ${s.id}) row, and the stored file deleted.`);
                  }} />
              )}
            </div>
          );
        })}
      </div>

      <HskNote tone="bug" title="The tile image is stored on the wrong key upstream.">
        <code>updateOrInsert</code> matches only (skin, subcategory), so the image is written onto whichever
        association row it finds — meaning an inline subcategory edit, or any of the three bulk flows on the
        Games tab, silently erases it. And <code>removeImage</code> deletes from a path uploads never use, so old
        files accumulate. Both are implemented as intended here; see the SUGGESTIONs at the top of this file.
      </HskNote>
      <HskNote tone="info" title="Disabled games are excluded.">
        The grid <code>whereNull</code>s <code>skins_games_disabled</code>, so a game switched off on the Games tab
        disappears from every subcategory panel — its stored priority row stays behind.
      </HskNote>
    </>
  );
};

/* ══════════════════════════════════════════════════════════════════════════════════════════════
   7. Graphic tab — GET /skins/{id}/graphic/ → showSkinGraphic (SC:1260-1282);
      POST /skins/saveSkin/{id}/graphic → case "graphic" (SC:3325-3524). Gate isadmin().
   ══════════════════════════════════════════════════════════════════════════════════════════ */

/* The complete image set — there is no background / mobile background / app icon / loader slot on
   this screen (whole view: graphic.blade.php:1-417). Accept lists are the view's own. */
const HSK2_IMAGE_SLOTS = [
  { key: "logo_img", label: "Logo", dir: "skins/logo/", accept: ".png,.jpg,.jpeg,.svg" },
  { key: "logo_black", label: "Black logo", dir: "skins/logo/", accept: ".png,.jpg,.jpeg,.svg", bug: "validated as logo_logo" },
  { key: "favicon_img", label: "Favicon", dir: "skins/favicon/", accept: ".png,.jpg,.jpeg" },
  { key: "footer_img", label: "Footer image", dir: "skins/footer/", accept: ".png,.jpg,.jpeg,.svg" },
];

const Hsk2GraphicTab = ({ tab, skin, draft, set }) => {
  const seed = hsk2UseMemo(() => hsk2GenGraphic(skin.id, skin.skin_code), [skin.id, skin.skin_code]);
  const [g, setG] = hsk2Slot(draft, set, "graphic", seed);
  const [err, setErr] = hsk2UseState({});
  const setK = (k, v) => setG({ ...g, [k]: v });

  const save = () => {
    const e = {};
    if (!g.template_id) e.template_id = "Select Template";                       /* label inferred */
    if (!g.header_version) e.header_version = "Select a header version.";        /* label inferred */
    if (!g.footer_version) e.footer_version = "Select a footer version.";        /* label inferred */
    if (!g.colore1) e.colore1 = "Colour 1 is required.";                         /* label inferred */
    if (!g.colore2) e.colore2 = "Colour 2 is required.";                         /* label inferred */
    setErr(e);
    if (Object.keys(e).length) { hskToast("Graphic not saved", "Fix the highlighted fields and save again."); return; }
    hskToast(`Graphic saved for ${skin.name}`,
      "Uploads stored as {skinId}_{time}.{ext} under storage/app/public/skins/{logo|favicon|footer}, then Skin::update on template_id / header_version / footer_version / colore1-14 / scripts_header / scripts_footer / custom_css / sport_custom_css + updatedByUser, then flushSkinCache(id).");
  };

  const colourRow = (i) => {
    const k = `colore${i}`;
    const dead = i >= 11;   // colore11-14 render in the real form but their save block is commented out
    return (
      <div key={k} className={`hsk2-color${dead ? " is-dead" : ""}`}>
        <label className="hsk2-color__l" htmlFor={`hsk2-${k}`}>
          Colour {i}{i <= 2 && <span className="hsk-req" title="Required">*</span>}
          <code className="hsk-name">{k}</code>
          {dead && <Hsk2Chip tone="warn" title="Rendered by the real form but never persisted — the save block is commented out. Implemented as intended here.">not persisted upstream</Hsk2Chip>}
        </label>
        <div className="hsk2-color__c">
          <input id={`hsk2-${k}`} type="color" className="hsk2-swatch" value={g[k] || "#ffffff"} onChange={e => setK(k, e.target.value)} />
          <input className="input input--sm hsk2-hex" value={g[k] || ""} placeholder="#rrggbb" onChange={e => setK(k, e.target.value)} />
          {g[k] && <button className="hsk2-clear" title="Clear" onClick={() => setK(k, "")}><Icon name="x" size={11} /></button>}
        </div>
        {err[k] && <div className="hsk-field__err"><Icon name="alert" size={11} /> {err[k]}</div>}
      </div>
    );
  };

  return (
    <>
      <HskTabHead tab={tab} />

      <HskSection title="Images" desc="Four slots — and only four. There is no background, mobile background, app icon or loader image on this screen.">
        {HSK2_IMAGE_SLOTS.map(s => (
          <HskField key={s.key} label={s.label} name={s.key}
            hint={<>Stored under <code>{s.dir}</code> as <code>{"{skinId}"}{s.key === "logo_black" ? "_black" : ""}_{"{time}.{ext}"}</code> · accepts <code>{s.accept}</code>{s.bug ? " · upstream this upload is validated against the wrong request key (logo_logo), so any file type passes — the accept list is enforced here" : ""}</>}>
            <div className="hsk2-slot">
              <div className={`hsk2-slot__prev${g[s.key] ? "" : " is-empty"}`}>
                <Icon name={g[s.key] ? "star" : "upload"} size={16} />
              </div>
              <div className="hsk2-slot__b">
                <div className="hsk2-slot__n">{g[s.key] || "No file"}</div>
                <div className="hsk2-slot__acts">
                  <label className="btn btn--secondary btn--sm hsk2-file">
                    <Icon name="upload" size={12} /> Choose file
                    <input type="file" accept={s.accept} onChange={(e) => {
                      const f = e.target.files && e.target.files[0];
                      if (!f) return;
                      const ext = (f.name.split(".").pop() || "png").toLowerCase();
                      if (!s.accept.includes(ext)) { hskToast("Wrong file type", `${s.label} accepts ${s.accept}.`); e.target.value = ""; return; }
                      setK(s.key, `${skin.id}${s.key === "logo_black" ? "_black" : ""}_${Math.floor(Date.now() / 1000)}.${ext}`);
                      e.target.value = "";
                    }} />
                  </label>
                  {/* KNOWN-BUG DIVERGENCE — the four *_remove hidden inputs are never read server-side, so
                      on the real screen an image can be replaced but never cleared. Evident intent: Remove
                      clears the column. */}
                  {g[s.key] && <button className="btn btn--secondary btn--sm hsk2-danger" onClick={() => setK(s.key, "")}><Icon name="trash" size={12} /> Remove</button>}
                </div>
              </div>
            </div>
          </HskField>
        ))}
      </HskSection>

      <HskSection title="Theme" desc="Which Blade template and which header / footer version this skin renders.">
        <HskField label="Template" name="template_id" required error={err.template_id} hint="getSkinTemplates() — exactly two rows.">
          <HskSelect value={g.template_id} onChange={v => setK("template_id", v)} options={HSK2_TEMPLATES} />
        </HskField>
        <HskField label="Header version" name="header_version" required error={err.header_version}
          hint="getThemeHeaders() — v2 … v16. The reference quotes only two labels verbatim; the rest carry their version key rather than an invented name.">
          <HskSelect value={g.header_version} onChange={v => setK("header_version", v)} options={HSK2_HEADER_VERSIONS} />
        </HskField>
        <HskField label="Footer version" name="footer_version" required error={err.footer_version} hint="getThemeFooter() — v1 … v15.">
          <HskSelect value={g.footer_version} onChange={v => setK("footer_version", v)} options={HSK2_FOOTER_VERSIONS} />
        </HskField>
      </HskSection>

      <HskSection title="Colours" desc="colore1 and colore2 are required; 3–10 are optional (their error lines are commented out); 11–14 render on the real form but are never saved.">
        <div className="hsk2-colors">{[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14].map(colourRow)}</div>
      </HskSection>

      <HskSection title="Custom code" desc="Free text, all optional — injected into the player-facing pages.">
        <HskField label="Header scripts" name="scripts_header" full>
          <HskTextarea mono rows={4} value={g.scripts_header || ""} onChange={e => setK("scripts_header", e.target.value)} placeholder="<!-- injected into <head> -->" />
        </HskField>
        <HskField label="Footer scripts" name="scripts_footer" full>
          <HskTextarea mono rows={4} value={g.scripts_footer || ""} onChange={e => setK("scripts_footer", e.target.value)} placeholder="<!-- injected before </body> -->" />
        </HskField>
        <HskField label="Custom CSS" name="custom_css" full>
          <HskTextarea mono rows={4} value={g.custom_css || ""} onChange={e => setK("custom_css", e.target.value)} />
        </HskField>
        <HskField label="Sport custom CSS" name="sport_custom_css" full>
          <HskTextarea mono rows={4} value={g.sport_custom_css || ""} onChange={e => setK("sport_custom_css", e.target.value)} />
        </HskField>
      </HskSection>

      <HskNote tone="bug" title="Three defects, implemented as intended.">
        Colours 11–14 are editable but discarded upstream; the black logo is validated against
        <code>logo_logo</code> so any file type is accepted; and the Remove buttons post
        <code>*_remove</code> flags nobody reads. Each carries a SUGGESTION at the top of this file.
      </HskNote>

      <HskSaveBar onSave={save} label="Save graphic" note="POST /skins/saveSkin/{id}/graphic · Skin::update + flushSkinCache(id)" />
    </>
  );
};

/* ══════════════════════════════════════════════════════════════════════════════════════════════
   8. Domains tab — GET /skins/{id}/domains/ → showSkinDomains (SC:1285-1302);
      POST /skins/saveSkin/{id}/domains → case "domains" (SC:3525-3533) → updateDomainsSkin
      (SC:945-986). Gate isadmin().
   ══════════════════════════════════════════════════════════════════════════════════════════ */
const Hsk2DomainsTab = ({ tab, skin, draft, set }) => {
  const seed = hsk2UseMemo(() => hsk2DomainRows(skin), [skin.id, skin.domains]);
  const [rows, setRows] = hsk2Slot(draft, set, "domains", seed);
  const [err, setErr] = hsk2UseState({});

  const setRow = (key, patch) => setRows(rows.map(r => (r.key === key ? { ...r, ...patch } : r)));
  const addRow = () => setRows(rows.concat([{ key: `new${Date.now()}${rows.length}`, domain: "", url: "", main: rows.length === 0, isNew: true }]));
  const delRow = (key) => setRows(rows.filter(r => r.key !== key));
  /* KNOWN-BUG DIVERGENCE — nothing in updateDomainsSkin stops zero or several `main` rows (`main` is
     just "checkbox present ? 1 : 0"). Evident intent implemented: main behaves as a radio. */
  const setMain = (key) => setRows(rows.map(r => ({ ...r, main: r.key === key })));

  const save = () => {
    /* KNOWN-BUG DIVERGENCE — the real save validates nothing at all: no host format, no uniqueness,
       no main-row rule. Evident intent implemented as inline validation. */
    const e = {};
    const seen = {};
    rows.forEach(r => {
      const d = String(r.domain || "").trim().toLowerCase();
      if (!d) e[r.key] = "Enter a domain.";
      else if (!/^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/.test(d)) e[r.key] = "Not a valid hostname.";
      else if (seen[d]) e[r.key] = "This domain is already listed.";
      seen[d] = true;
    });
    if (rows.length && !rows.some(r => r.main)) e._main = "Pick one main domain.";
    setErr(e);
    if (Object.keys(e).length) { hskToast("Domains not saved", "Fix the highlighted rows and save again."); return; }
    setRows(rows.map(r => ({ ...r, isNew: false, key: r.isNew ? `srv${hskHash(r.domain).toString(16)}` : r.key })));
    hskToast(`Domains saved for ${skin.name}`,
      `updateDomainsSkin deleted the rows whose uniqid was not resubmitted, created the new ones with a server-side uniqid = md5(uniqid()) — the browser key is discarded — and updated domain / url / main on the rest. ${rows.length} rows in skins_domains.`);
  };

  return (
    <>
      <HskTabHead tab={tab} />
      <HskSection title="Hostnames" desc="Every host that resolves to this skin. The main row is the one skin_main_domain_{id} and the CORS origin allowlist are derived from."
        actions={<button className="btn btn--secondary btn--sm" onClick={addRow}><Icon name="plus" size={12} /> New domain</button>}>
        {rows.length === 0 && <div className="hsk-empty">No domain rows. The skin is unreachable by hostname until one is added.</div>}
        {rows.map(r => (
          <div key={r.key} className={`hsk2-domrow${err[r.key] ? " is-err" : ""}`}>
            <div className="hsk2-domrow__f">
              <label>Domain <code className="hsk-name">domains[{r.key}][domain]</code></label>
              <HskInput value={r.domain} placeholder="Domain..." onChange={e => setRow(r.key, { domain: e.target.value })} />
            </div>
            <div className="hsk2-domrow__f">
              <label>Url <code className="hsk-name">domains[{r.key}][url]</code></label>
              <HskInput value={r.url} placeholder="Url..." onChange={e => setRow(r.key, { url: e.target.value })} />
            </div>
            <div className="hsk2-domrow__m">
              <label className="hsk-radio" title="domains[…][main]">
                <input type="radio" name="hsk2-main" checked={!!r.main} onChange={() => setMain(r.key)} /> Main
              </label>
            </div>
            <button className="hsk-act hsk-act--danger" title="Remove row" onClick={() => delRow(r.key)}><Icon name="x" size={13} /></button>
            {err[r.key] && <div className="hsk2-domrow__e"><Icon name="alert" size={11} /> {err[r.key]}</div>}
          </div>
        ))}
        {err._main && <div className="hsk-field__err"><Icon name="alert" size={11} /> {err._main}</div>}
      </HskSection>

      <HskNote tone="bug" title="Two defects, implemented as intended.">
        The real save validates nothing — no host format, no uniqueness, and nothing prevents zero or several
        main rows — and it calls <code>flushSkinCache</code> <b>before</b> the write, so it clears the keys derived
        from the <i>old</i> main domain (including the CORS origin allowlist) and never flushes the new one.
      </HskNote>

      <HskSaveBar onSave={save} label="Save domains" note="POST /skins/saveSkin/{id}/domains · updateDomainsSkin on skins_domains" />
    </>
  );
};

/* ══════════════════════════════════════════════════════════════════════════════════════════════
   9. Jackpot tab — GET /skins/{id}/jackpot/ → showSkinJackpot (SC:2147-2164);
      POST /skins/saveSkin/{id}/jackpot → case "jackpot" (SC:3535-3550). Gate isadmin().
      The pots only exist while skins.jackpot = 1; the switch that creates/destroys them is on the
      Home tab and fires GET /skins/setupjackpots/{id}/ (SC:4005-4044, super admin only).
   ══════════════════════════════════════════════════════════════════════════════════════════ */
const HSK2_JP_FIELDS = [
  { k: "start_balance", l: "Start balance", h: "Seeded from the global jp{N}_start_balance setting when the pots are created." },
  { k: "percent", l: "% accumulation", h: "Share of each qualifying bet that flows into this pot. Seeded from jp{N}_percentage." },
  { k: "balance", l: "Balance", h: "Current pot. Created at 0 and accumulated by play — editing it here overwrites the live figure." },
  { k: "max_range", l: "Max range", h: "Upper bound of the drop window. Seeded from jp{N}_max_range." },
  { k: "min_bet", l: "Min bet", h: "Smallest stake that qualifies for this pot. Seeded from jp{N}_min_bet." },
];

const Hsk2JackpotTab = ({ tab, skin, draft, set }) => {
  const seed = hsk2UseMemo(() => hsk2GenJackpots(skin.id), [skin.id]);
  const [pots, setPots] = hsk2Slot(draft, set, "jackpots", seed);
  const [err, setErr] = hsk2UseState({});
  const setPot = (id, k, v) => setPots(pots.map(p => (p.id === id ? { ...p, [k]: v } : p)));

  const save = () => {
    /* KNOWN-BUG DIVERGENCE — `case "jackpot"` writes the five columns with no validation whatsoever,
       so a typo silently becomes a jackpot amount. Evident intent implemented: numeric validation. */
    const e = {};
    pots.forEach(p => HSK2_JP_FIELDS.forEach(f => {
      const v = String(p[f.k] ?? "").trim();
      if (v === "" || isNaN(Number(v)) || Number(v) < 0) e[`${p.id}:${f.k}`] = true;
    }));
    setErr(e);
    if (Object.keys(e).length) { hskToast("Jackpots not saved", "Every field must be a non-negative number."); return; }
    hskToast(`Jackpots saved for ${skin.name}`,
      `Three Jackpot::where(id)->where(skin_id) updates on start_balance / percent / balance / max_range / min_bet, then flushSkinCache(${skin.id}) — which also forgets jackpot_winners_${skin.id}.`);
  };

  if (!draft.jackpot) {
    return (
      <>
        <HskTabHead tab={tab} />
        {/* jackpot.blade.php:5,94 — the whole view is wrapped in `@if skins.jackpot == 1`. */}
        <div className="hsk-empty hsk2-jpempty">
          <div className="hsk2-jpempty__t"><Icon name="crown" size={16} /> The jackpots are not active</div>
          <div className="hsk2-jpempty__d">
            This skin has <code>skins.jackpot = 0</code>, so it has no <code>jackpots</code> rows to edit. Turn the
            <b> Jackpot</b> switch on from the <b>Home</b> tab: <code>setupJackpots</code> creates three pots
            (<code>jp_id</code> 1–3) with <code>balance = 0</code> and seeds percent / start balance / max range /
            min bet from the global <code>jp1_*</code>, <code>jp2_*</code>, <code>jp3_*</code> settings.
          </div>
        </div>
        <HskNote tone="warn" title="Super admin only.">
          <code>setupJackpots</code> hard-stops (<code>die</code>) unless <code>user_level</code> is 0 — the switch renders for
          every admin who can open the editor, but only a super admin can actually create or clear the pots.
          Turning it back off <b>deletes</b> all three rows; the amounts below are not recoverable.
        </HskNote>
      </>
    );
  }

  return (
    <>
      <HskTabHead tab={tab} />
      <HskNote tone="info" title="Three pots, fixed.">
        The rows are whatever <code>setupJackpots</code> created — always <code>jp_id</code> 1, 2 and 3. This tab edits
        them; it cannot add or remove a pot. Clearing them is the Home tab's switch, and it is destructive.
      </HskNote>

      <div className="hsk2-pots">
        {pots.map(p => (
          <div key={p.id} className="hsk2-pot">
            <div className="hsk2-pot__head">
              <div className="hsk2-pot__n"><Icon name="crown" size={14} /> Jackpot {p.jp_id}</div>
              <div className="hsk2-pot__sub">jackpots #{p.id} · <code>jp_id = {p.jp_id}</code></div>
            </div>
            <div className="hsk2-pot__body">
              {HSK2_JP_FIELDS.map(f => (
                <div key={f.k} className={`hsk2-pot__f${err[`${p.id}:${f.k}`] ? " is-err" : ""}`}>
                  <label>
                    {f.l}
                    <code className="hsk-name">{`jackpot[${p.id}][${f.k}]`}</code>
                  </label>
                  <Hsk2Num value={p[f.k]} onChange={v => setPot(p.id, f.k, v)} wide />
                  <div className="hsk2-pot__h">{f.h}</div>
                </div>
              ))}
            </div>
          </div>
        ))}
      </div>

      <HskNote tone="warn" title="Balance is a live figure.">
        <code>balance</code> is the accumulated pot the player-facing widgets read. It is editable here because the
        real form makes it editable — a save overwrites whatever accumulation has reached in the meantime.
      </HskNote>

      <HskSaveBar onSave={save} label="Save jackpots" note="POST /skins/saveSkin/{id}/jackpot · 3 × Jackpot::update + flushSkinCache(id)" />
    </>
  );
};

/* ══════════════════════════════════════════════════════════════════════════════════════════════
   10. Limits tab — GET /skins/{id}/limits/ → showSkinLimitsMethods (SC:2204-2221);
       POST /skins/saveSkin/{id}/limits → case "limits" (SC:3707-3726). Gate isadmin().
   ══════════════════════════════════════════════════════════════════════════════════════════ */
const HSK2_LIMIT_ROWS = [
  { label: "Sport", bal: "sport_balance", ggr: "sport_ggr_limit", calc: true },
  { label: "Casino", bal: "casino_balance", ggr: "casino_ggr_limit" },
  { label: "Casino Live", bal: "casinolive_balance", ggr: "casinolive_ggr_limit" },
  { label: "Virtual", bal: "virtual_balance", ggr: "virtual_ggr_limit" },
];

/* The sport balance calculator (limits.blade.php:80-81,190-225 + template.blade.php:510-554).
   totalAmount = usd * 100 / percentage; finalAmount = totalAmount * rate; applied client-side to the
   sport_balance input and persisted only by the tab's normal Save. */
const Hsk2SportCalcModal = ({ sign, cur, pct, rate, current, onClose, onApply }) => {
  const [usd, setUsd] = hsk2UseState("");
  const total = pct > 0 ? (Number(usd || 0) * 100) / pct : 0;
  const final = total * rate;
  const next = Math.max(0, Number(current || 0) + (sign === "+" ? final : -final));
  return (
    <div className="bp-modal-scrim hsk-scrim" onClick={onClose}>
      <div className="bp-modal hsk-modal hsk-modal--sm" onClick={e => e.stopPropagation()}>
        <div className="hsk-modal__head">
          <div>
            <div className="hsk-modal__title">{sign === "+" ? "Add to" : "Subtract from"} sport balance</div>
            <div className="hsk-modal__sub">client-side only — the result is persisted by this tab's Save</div>
          </div>
          <button className="hsk-x" title="Close" onClick={onClose}><Icon name="x" size={14} /></button>
        </div>
        <div className="hsk-modal__body">
          {/* The real label hardcodes "(12% of)" whatever the configured percentage is
              (limits.blade.php:207). Evident intent implemented: the configured value is shown. */}
          <HskField label={`Amount in USD (${pct}% of)`} name="usd"
            hint={rate > 0
              ? <>Percentage <code>custom_settings['sportsbook_balance_limit_perc']</code> = {pct}; the skin's currency is already <code>{cur}</code>, so no conversion applies.</>
              : <><b>No exchange rate available.</b> Percentage <code>custom_settings['sportsbook_balance_limit_perc']</code> = {pct}, but converting to <code>{cur}</code> needs a rate from <code>currency_rates</code> and this calculator is not wired to it. The converted figure below stays 0 rather than showing a guess — a wrong limit either lets a player bet past it or blocks one who is inside it.</>}>
            <Hsk2Num value={usd} onChange={setUsd} wide />
          </HskField>
          <div className="hsk2-calc">
            <div><span>Total amount</span><b>{hsk2Num(Math.round(total))}</b></div>
            <div><span>Converted ({cur})</span><b>{hsk2Num(Math.round(final))}</b></div>
            <div className="hsk2-calc__r"><span>New sport balance</span><b>{hsk2Num(Math.round(next))}</b></div>
          </div>
        </div>
        <div className="hsk-modal__foot">
          <button className="btn btn--secondary btn--sm" onClick={onClose}>Cancel</button>
          <button className="hrs-btn hrs-btn--filters" onClick={() => onApply(String(Math.round(next)))} disabled={!usd || pct <= 0 || rate <= 0}
            title={rate <= 0 ? "No exchange rate — the converted amount cannot be computed" : undefined}>
            <Icon name="check" size={14} /> Apply
          </button>
        </div>
      </div>
    </div>
  );
};

const Hsk2LimitsTab = ({ tab, skin, draft, set }) => {
  const seed = hsk2UseMemo(() => hsk2GenLimits(skin.id), [skin.id]);
  const [lim, setLim] = hsk2Slot(draft, set, "limits", seed);
  const [calc, setCalc] = hsk2UseState(null);
  const setK = (k, v) => setLim({ ...lim, [k]: v });

  /* limits.blade.php:31-46 — the whole calculator block is gated on the presence setting. */
  const calcOn = !!(draft.settings && draft.settings.enable_sportsbook_balance_limits);
  const calcCur = (draft.values && draft.values.sportsbook_balance_limit_currency) || "EUR";
  const calcPct = calcOn ? Number((draft.values && draft.values.sportsbook_balance_limit_perc) || 12) : 0;
  /* The conversion rate used to be `rnd() * 900 + 100` — a made-up FX rate on
     a screen that computes a balance LIMIT from it. A wrong limit either lets
     a player bet past it or blocks one who is inside it, so this shows nothing
     rather than a plausible number. Rates live in `currency_rates` and are
     surfaced by the Currencies screen; wiring them here means picking the
     nearest-date rate the same way currency_latest_rate does, which is a real
     piece of work rather than a one-liner.
     <!-- SUGGESTION: feed this calculator from currency_latest_rate. It converts a per-vertical balance limit between the skin's currency and the limit currency; with no rate it cannot compute one, and with a guessed rate it computes the wrong one. --> */
  const calcRate = calcOn && skin.currency === calcCur ? 1 : 0;

  const save = () => hskToast(`Limits saved for ${skin.name}`,
    "Skin::update on sport_balance / casino_balance / casinolive_balance / virtual_balance, the four *_ggr_limit flags (1/0) and limit_notes, then flushSkinCache(id). No validation runs on any of them.");

  return (
    <>
      <HskTabHead tab={tab} />
      <HskSection title="Per-vertical balance" desc="One balance and one GGR-limit switch per vertical. Both are plain columns on the skins row.">
        <div className="hsk2-limits">
          <div className="hsk2-limits__h">
            <span>Category</span><span>Balance</span><span>GGR limit</span>
          </div>
          {HSK2_LIMIT_ROWS.map(r => (
            <div key={r.bal} className="hsk2-limits__r">
              <div className="hsk2-limits__c">
                {r.label}
                <code className="hsk-name">{r.bal}</code>
              </div>
              <div className="hsk2-limits__b">
                <Hsk2Num value={lim[r.bal]} onChange={v => setK(r.bal, v)} wide />
                {r.calc && (
                  <div className="hsk2-limits__calc">
                    <button className="hsk-act" title={calcOn ? "Add via the sport balance calculator" : "Calculator disabled — enable_sportsbook_balance_limits is off"}
                      disabled={!calcOn} onClick={() => setCalc("+")}><Icon name="plus" size={13} /></button>
                    <button className="hsk-act" title={calcOn ? "Subtract via the sport balance calculator" : "Calculator disabled — enable_sportsbook_balance_limits is off"}
                      disabled={!calcOn} onClick={() => setCalc("−")}><Icon name="arrow_down" size={13} /></button>
                  </div>
                )}
              </div>
              <div className="hsk2-limits__g">
                <HskSwitch value={!!lim[r.ggr]} onChange={v => setK(r.ggr, v)} />
                <code className="hsk-name">{r.ggr}</code>
              </div>
            </div>
          ))}
        </div>
      </HskSection>

      <HskSection title="Payment reminder">
        <HskField label="Note" name="limit_notes" full hint="Free text shown to whoever handles this skin's payments.">
          <HskTextarea rows={3} value={lim.limit_notes || ""} onChange={e => setK("limit_notes", e.target.value)} />
        </HskField>
      </HskSection>

      {!calcOn && (
        <HskNote tone="warn" title="Sport calculator is off for this skin.">
          It is gated on the presence setting <code>enable_sportsbook_balance_limits</code> (Settings tab). On the real
          screen the +/− buttons still render with the setting off, but the rate and percentage are both 0, so they
          can only ever add nothing — they are disabled here instead.
        </HskNote>
      )}
      <HskNote tone="info" title="No validation, anywhere.">
        <code>case "limits"</code> writes all nine fields straight onto the skin row. The calculator itself is a Blade
        that defines a bare top-level <code>getExchangeRate()</code> function — a redeclare hazard flagged as a SUGGESTION.
      </HskNote>

      <HskSaveBar onSave={save} label="Save limits" note="POST /skins/saveSkin/{id}/limits · Skin::update + flushSkinCache(id)" />

      {calc && (
        <Hsk2SportCalcModal sign={calc} cur={calcCur} pct={calcPct} rate={calcRate} current={lim.sport_balance}
          onClose={() => setCalc(null)} onApply={(v) => { setK("sport_balance", v); setCalc(null); hskToast("Sport balance updated", "Applied client-side. Nothing is stored until you press Save."); }} />
      )}
    </>
  );
};

/* ══════════════════════════════════════════════════════════════════════════════════════════════
   11. Homepage View (API) tab — GET /skins/{id}/homepage_view/ → showHomepageView (SC:4428-4491);
       POST /skins/{id}/homepage_view → saveHomepageView (SC:4493-4536);
       POST /skins/{id}/homepage_view/reset → resetHomepageView (SC:4538-4545);
       GET  /skins/{id}/homepage_view/games → getGamesBySubcategory (SC:4547-4595).
       Stored per skin in `homepage_views` (view_json + footer_json).
       Only the SHOW action checks isadmin() — save / reset / games carry no role guard.
   ══════════════════════════════════════════════════════════════════════════════════════════ */

/* Hardcoded aliasing: skins 70/71/72 read skin 69's subcategories, games, providers and promotions
   (SC:4460-4462 and again SC:4555-4557). Surfaced, not hidden. */
const hsk2HomeSource = (id) => ((id === 70 || id === 71 || id === 72) ? 69 : id);

const HSK2_BLOCK_TYPES = [
  ["subcategory", "Subcategory block"],
  ["providers", "Providers block"],
  ["promotions", "Promotions block"],
  ["widget", "Widget block"],
];

const Hsk2HomepageTab = ({ tab, skin, skins, draft, set }) => {
  const srcId = hsk2HomeSource(skin.id);
  const aliased = srcId !== skin.id;
  const srcName = (skins.find(s => s.id === srcId) || { name: `skin ${srcId}` }).name;

  /* Everything the builder can offer comes from the ALIASED skin. */
  const srcProv = hsk2UseMemo(() => hsk2GenProviders(srcId), [srcId]);
  const srcGames = hsk2UseMemo(() => hsk2GenGames(srcId, srcProv), [srcId, srcProv]);
  /* Only casino (1) and live casino (2) subcategories; live labels get the " (Live-Casino)" suffix. */
  const subOptions = hsk2UseMemo(() => HSK2_SUBCATS.filter(s => s.cat === 1 || s.cat === 2)
    .map(s => ({ id: s.id, name: s.cat === 2 ? `${s.name} (Live-Casino)` : s.name })), []);
  /* ProviderService::getProvidersWithBanners — providers with an ENABLED banner only. */
  const bannerProviders = hsk2UseMemo(() => HSK2_PROVIDERS.filter(p => srcProv[p.id] && srcProv[p.id].active && srcProv[p.id].show_banner), [srcProv]);

  const seed = hsk2UseMemo(() => {
    const r = hsk2Rnd(`home|${skin.id}`);
    const s1 = subOptions[Math.floor(r() * subOptions.length)];
    const gamesOf = (subId) => srcGames.filter(g => g.subs.includes(subId)).slice(0, 6).map((g, i) => ({ id: g.id, name: g.name, is_big: i === 0 }));
    return [
      { uid: "b1", type: "widget", widget: "jackpot_banner" },
      { uid: "b2", type: "subcategory", sub: s1 ? s1.id : subOptions[0].id, scrollType: "arrows", rowsCount: 4, games: gamesOf(s1 ? s1.id : subOptions[0].id) },
      { uid: "b3", type: "providers", items: bannerProviders.slice(0, 5).map((p, i) => ({ id: p.id, name: p.name, is_big: i === 0 })) },
      { uid: "b4", type: "promotions", items: HSK2_PROMOS.slice(0, 2).map(p => ({ id: p.id, name: p.name, is_big: false })) },
    ];
  }, [skin.id, subOptions, srcGames, bannerProviders]);

  const [blocks, setBlocks] = hsk2Slot(draft, set, "homepage_blocks", seed);
  const [footerText, setFooterText] = hsk2Slot(draft, set, "homepage_footer", "<p>© " + skin.name + "</p>");
  const [footOpen, setFootOpen] = hsk2UseState(false);
  const [footDraft, setFootDraft] = hsk2UseState(footerText);
  const [adding, setAdding] = hsk2UseState("");

  const patchBlock = (uid, patch) => setBlocks(blocks.map(b => (b.uid === uid ? { ...b, ...patch } : b)));
  const move = (i, d) => {
    const next = blocks.slice();
    const j = i + d;
    if (j < 0 || j >= next.length) return;
    const t = next[i]; next[i] = next[j]; next[j] = t;
    setBlocks(next);
  };
  const remove = (uid) => setBlocks(blocks.filter(b => b.uid !== uid));
  const add = (type) => {
    if (!type) return;
    const uid = `b${Date.now()}`;
    const blank = type === "subcategory" ? { uid, type, sub: subOptions[0].id, scrollType: "", rowsCount: 4, games: [] }
      : type === "widget" ? { uid, type, widget: "jackpot_banner" }
        : { uid, type, items: [] };
    setBlocks(blocks.concat([blank]));
    setAdding("");
  };

  const save = () => hskToast(`Homepage layout saved for ${skin.name}`,
    `${blocks.length} blocks numbered order 1…${blocks.length}, game / provider / promotion values json_decoded (nulls dropped), then updateOrCreate on homepage_views.view_json = {"items": …}. footer_json is untouched by this save. No skin cache is flushed.`);
  const reset = () => {
    if (!window.confirm("Reset the homepage layout? Every block is removed.")) return;
    setBlocks([]);
    hskToast("Homepage layout reset", "resetHomepageView nulls view_json only — footer_json survives.");
  };

  const blockTitle = (b) => b.type === "subcategory" ? (subOptions.find(s => s.id === b.sub) || {}).name || `Subcategory #${b.sub}`
    : b.type === "providers" ? "Providers" : b.type === "promotions" ? "Promotions"
      : (HSK2_WIDGETS.find(w => w[0] === b.widget) || [, b.widget])[1];

  const itemList = (b, options, label) => (
    <>
      <div className="hsk2-items">
        {(b.items || []).length === 0 && <div className="hsk-empty">No {label} picked yet.</div>}
        {(b.items || []).map(it => (
          <div key={it.id} className="hsk2-item">
            <span className="hsk2-item__n">{it.name}</span>
            <label className="hsk-check" title="is_big is written into the JSON option value itself">
              <input type="checkbox" checked={!!it.is_big}
                onChange={e => patchBlock(b.uid, { items: b.items.map(x => (x.id === it.id ? { ...x, is_big: e.target.checked } : x)) })} /> Big Size
            </label>
            <button className="hsk-act hsk-act--danger" title="Remove" onClick={() => patchBlock(b.uid, { items: b.items.filter(x => x.id !== it.id) })}><Icon name="x" size={12} /></button>
          </div>
        ))}
      </div>
      <div className="hsk2-add">
        <HskSelect value="" onChange={(v) => {
          if (!v) return;
          const o = options.find(x => String(x.id) === String(v));
          if (!o || (b.items || []).some(x => x.id === o.id)) return;
          patchBlock(b.uid, { items: (b.items || []).concat([{ id: o.id, name: o.name, is_big: false }]) });
        }} options={[["", `+ Add ${label}`]].concat(options.filter(o => !(b.items || []).some(x => x.id === o.id)).map(o => [o.id, o.name]))} />
      </div>
    </>
  );

  return (
    <>
      <HskTabHead tab={tab} />

      {aliased && (
        <HskNote tone="bug" title={`This skin edits ${srcName}'s catalogue.`}>
          <code>showHomepageView</code> and <code>getGamesBySubcategory</code> both hardcode
          <code>if ($id == 70 || $id == 71 || $id == 72) $skinId = 69;</code>, so skins 70, 71 and 72 pick their
          blocks from skin {srcId}'s subcategories, games, providers and promotions. The layout is still saved
          against <b>this</b> skin's <code>homepage_views</code> row. Surfaced rather than smoothed over.
        </HskNote>
      )}
      <HskNote tone="warn" title="Only the page load is gated.">
        <code>isadmin()</code> guards the show action alone — <code>saveHomepageView</code>,
        <code>resetHomepageView</code> and the games lookup have no role check beyond the shared admin middleware.
      </HskNote>

      <HskSection title={`Blocks — ${blocks.length}`} desc="Rendered top to bottom on the player homepage. Save numbers them order 1…n."
        actions={
          <>
            <HskSelect value={adding} onChange={add} options={[["", "+ Add block"]].concat(HSK2_BLOCK_TYPES)} />
            <button className="btn btn--secondary btn--sm" onClick={() => { setFootDraft(footerText); setFootOpen(true); }}><Icon name="edit" size={12} /> Footer text</button>
          </>
        }>
        {blocks.length === 0 && <div className="hsk-empty">No blocks. The player homepage falls back to its default layout until one is added.</div>}
        <div className="hsk2-blocks">
          {blocks.map((b, i) => (
            <div key={b.uid} className="hsk2-block">
              <div className="hsk2-block__head">
                <span className="hsk2-block__ord">{i + 1}</span>
                <span className="hsk2-block__t">{blockTitle(b)}</span>
                <Hsk2Chip tone="info">{b.type}</Hsk2Chip>
                <div className="hsk2-block__acts">
                  <button className="hsk-act" title="Move up" disabled={i === 0} onClick={() => move(i, -1)}><Icon name="arrow_up" size={12} /></button>
                  <button className="hsk-act" title="Move down" disabled={i === blocks.length - 1} onClick={() => move(i, 1)}><Icon name="arrow_down" size={12} /></button>
                  <button className="hsk-act hsk-act--danger" title="Remove block" onClick={() => remove(b.uid)}><Icon name="trash" size={12} /></button>
                </div>
              </div>
              <div className="hsk2-block__body">
                {b.type === "subcategory" && (
                  <>
                    <HskField label="Subcategory" name={`subcategories[${b.sub}]`}>
                      <HskSelect value={b.sub} onChange={v => patchBlock(b.uid, { sub: Number(v), games: [] })} options={subOptions.map(s => [s.id, s.name])} />
                    </HskField>
                    <HskField label="Scroll" name={`subcategories[${b.sub}][scrollType]`} hint="Empty string is a real stored value — “No Scroll”.">
                      <div className="hsk-radios">
                        {HSK2_SCROLL.map(([v, l]) => (
                          <label key={v || "none"} className="hsk-radio">
                            <input type="radio" name={`hsk2-scroll-${b.uid}`} checked={(b.scrollType || "") === v} onChange={() => patchBlock(b.uid, { scrollType: v })} /> {l}
                          </label>
                        ))}
                      </div>
                    </HskField>
                    <HskField label="Rows" name={`subcategories[${b.sub}][rowsCount]`} hint="1–10, default 4.">
                      <input className="input input--sm hsk2-num" type="number" min="1" max="10" value={b.rowsCount}
                        onChange={e => patchBlock(b.uid, { rowsCount: Math.min(10, Math.max(1, Number(e.target.value) || 1)) })} />
                    </HskField>
                    <HskField label="Games" name={`subcategories[${b.sub}][games][]`} full
                      hint="Each value is a JSON-encoded game object; Big Size writes is_big into that same object.">
                      <div className="hsk2-items">
                        {b.games.length === 0 && <div className="hsk-empty">No games picked — the save drops null games, so an empty block stores nothing.</div>}
                        {b.games.map(g => (
                          <div key={g.id} className="hsk2-item">
                            <span className="hsk2-item__n">{g.name}</span>
                            <label className="hsk-check">
                              <input type="checkbox" checked={!!g.is_big}
                                onChange={e => patchBlock(b.uid, { games: b.games.map(x => (x.id === g.id ? { ...x, is_big: e.target.checked } : x)) })} /> Big Size
                            </label>
                            <button className="hsk-act hsk-act--danger" title="Remove" onClick={() => patchBlock(b.uid, { games: b.games.filter(x => x.id !== g.id) })}><Icon name="x" size={12} /></button>
                          </div>
                        ))}
                      </div>
                      <div className="hsk2-add">
                        <HskSelect value="" onChange={(v) => {
                          const g = srcGames.find(x => String(x.id) === String(v));
                          if (!g || b.games.some(x => x.id === g.id)) return;
                          patchBlock(b.uid, { games: b.games.concat([{ id: g.id, name: g.name, is_big: false }]) });
                        }} options={[["", "+ Add game"]].concat(srcGames.filter(g => g.subs.includes(b.sub) && !b.games.some(x => x.id === g.id)).map(g => [g.id, g.name]))} />
                      </div>
                    </HskField>
                  </>
                )}
                {b.type === "providers" && (
                  <HskField label="Providers" name="subcategories[providers][items][]" full
                    hint={bannerProviders.length ? "Only providers whose banner is enabled on the Provider tab are eligible." : "No provider on this skin has Banner ticked, so this block can hold nothing."}>
                    {itemList(b, bannerProviders.map(p => ({ id: p.id, name: p.name })), "provider")}
                  </HskField>
                )}
                {b.type === "promotions" && (
                  <HskField label="Promotions" name="subcategories[promotions][items][]" full hint="ProviderPromotionRepository->get(skin, null, 'all').">
                    {itemList(b, HSK2_PROMOS, "promotion")}
                  </HskField>
                )}
                {b.type === "widget" && (
                  <HskField label="Widget" name="subcategories[widget][]" hint="Four options, fixed by the view.">
                    <HskSelect value={b.widget} onChange={v => patchBlock(b.uid, { widget: v })} options={HSK2_WIDGETS} />
                  </HskField>
                )}
              </div>
            </div>
          ))}
        </div>
      </HskSection>

      <HskNote tone="info" title="“Big Size” has one home.">
        The builder writes <code>is_big</code> (1/0) inside each block's JSON option value; the loose hidden inputs
        named <code>big_size</code> that sit next to every row are never read server-side, so they are not rendered here.
      </HskNote>

      <HskSaveBar onSave={save} label="Save layout"
        note="POST /skins/{id}/homepage_view · updateOrCreate homepage_views.view_json"
        extra={<button className="btn btn--secondary btn--sm hsk2-danger" onClick={reset}><Icon name="refresh" size={12} /> Reset layout</button>} />

      {footOpen && (
        <div className="bp-modal-scrim hsk-scrim" onClick={() => setFootOpen(false)}>
          <div className="bp-modal hsk-modal" onClick={e => e.stopPropagation()}>
            <div className="hsk-modal__head">
              <div>
                <div className="hsk-modal__title">Homepage footer text</div>
                <div className="hsk-modal__sub">POST /skins/{"{id}"}/homepage_view · footer[text] → homepage_views.footer_json</div>
              </div>
              <button className="hsk-x" title="Close" onClick={() => setFootOpen(false)}><Icon name="x" size={14} /></button>
            </div>
            <div className="hsk-modal__body">
              <HskField label="Footer HTML" name="footer[text]" full hint="A TinyMCE field on the real screen — raw HTML here. Reset does not clear it: resetHomepageView nulls view_json only.">
                <HskTextarea mono rows={8} value={footDraft} onChange={e => setFootDraft(e.target.value)} />
              </HskField>
            </div>
            <div className="hsk-modal__foot">
              <button className="btn btn--secondary btn--sm" onClick={() => setFootOpen(false)}>Cancel</button>
              <button className="hrs-btn hrs-btn--filters" onClick={() => { setFooterText(footDraft); setFootOpen(false); hskToast("Footer text saved", "Written to homepage_views.footer_json — a separate post from the block layout."); }}>
                <Icon name="check" size={14} /> Save footer
              </button>
            </div>
          </div>
        </div>
      )}
    </>
  );
};

/* ══════════════════════════════════════════════════════════════════════════════════════════════
   12. FAQ & FOOTER (API) entries tab — GET /skins/{id}/entries/ → showFAQFooter (SC:4598-4628);
       POST /skins/{id}/entries/{type} → saveFAQFooter (SC:4649-4691);
       PUT  /skins/{id}/entries/{type}/{faqId} → updateFAQFooter (SC:4704-4746);
       DELETE /skins/{id}/entries/{type}/{faqId} → removeFAQFooter (SC:4693-4702).
       Single table `faq_footer_entries`; twelve language pairs; English required. No role check on
       any of the four actions.
   ══════════════════════════════════════════════════════════════════════════════════════════ */

/* Per-language editor shared by the entries modal and the footer-navigation modal. */
const Hsk2LangEditor = ({ value, onChange, qLabel, aLabel, qName, aName, rows = 4 }) => {
  const [lang, setLang] = hsk2UseState("en");
  const filled = (code) => !!String((value[`q_${code}`] || "") + (value[`a_${code}`] || "")).trim();
  return (
    <>
      <div className="hsk2-langtabs" role="tablist">
        {HSK2_LANGS.map(([code, name, req]) => (
          <button key={code} role="tab" aria-selected={lang === code} title={name}
            className={`hsk2-langtab${lang === code ? " is-on" : ""}${filled(code) ? " is-filled" : ""}`}
            onClick={() => setLang(code)}>
            {code.replace("_", "-").toUpperCase()}{req && <span className="hsk-req">*</span>}
          </button>
        ))}
      </div>
      <HskField label={`${qLabel} — ${(HSK2_LANGS.find(l => l[0] === lang) || [, lang])[1]}`} name={`${qName}_${lang}`} full
        required={lang === "en"}>
        <HskInput value={value[`q_${lang}`] || ""} onChange={e => onChange({ ...value, [`q_${lang}`]: e.target.value })} />
      </HskField>
      <HskField label={`${aLabel} — ${(HSK2_LANGS.find(l => l[0] === lang) || [, lang])[1]}`} name={`${aName}_${lang}`} full
        required={lang === "en"}>
        <HskTextarea rows={rows} value={value[`a_${lang}`] || ""} onChange={e => onChange({ ...value, [`a_${lang}`]: e.target.value })} />
      </HskField>
      <div className="hsk2-langstat">
        {HSK2_LANGS.filter(([c]) => filled(c)).length} of {HSK2_LANGS.length} languages filled · English is the only required one
      </div>
    </>
  );
};

const hsk2BlankEntry = () => Object.fromEntries(HSK2_LANGS.flatMap(([c]) => [[`q_${c}`, ""], [`a_${c}`, ""]]));

const Hsk2EntriesTab = ({ tab, skin, draft, set }) => {
  const seed = hsk2UseMemo(() => {
    const mk = (id, type, q, a) => ({ id, type, ...hsk2BlankEntry(), q_en: q, a_en: a, q_es: q, a_es: a });
    return [
      mk(311, "faq", "How do I deposit?", "Open the cashier, pick a method and follow the gateway instructions."),
      mk(312, "faq", "How long does a withdrawal take?", "Bank payouts settle within 24–72h once approved."),
      mk(313, "faq", "How do I verify my account?", "Upload an ID document and a proof of address from your profile."),
      mk(401, "footer", "Responsible gaming", "Set your own deposit and loss limits at any time from your account."),
      mk(402, "footer", "Terms & conditions", "The full terms of service for this brand."),
    ];
  }, [skin.id]);
  const [entries, setEntries] = hsk2Slot(draft, set, "entries", seed);
  const [type, setType] = hsk2UseState("faq");
  const [edit, setEdit] = hsk2UseState(null);   // { row, isNew }
  const [err, setErr] = hsk2UseState("");

  const rows = entries.filter(e => e.type === type);
  const isFooter = type === "footer";
  const qLabel = isFooter ? "Title" : "Question";
  const aLabel = isFooter ? "Content" : "Answer";

  const commit = () => {
    if (!String(edit.row.q_en || "").trim() || !String(edit.row.a_en || "").trim()) {
      setErr(`English ${qLabel.toLowerCase()} and ${aLabel.toLowerCase()} are required.`);   /* label inferred */
      return;
    }
    setErr("");
    if (edit.isNew) {
      const id = Math.max(0, ...entries.map(e => e.id)) + 1;
      setEntries(entries.concat([{ ...edit.row, id, type }]));
      hskToast("Entry created",
        `POST /skins/${skin.id}/entries/${type} inserted one faq_footer_entries row with all 12 question_/answer_ pairs, mirroring English into the legacy question / answer columns. No cache is flushed.`);
    } else {
      setEntries(entries.map(e => (e.id === edit.row.id ? { ...edit.row } : e)));
      hskToast("Entry updated", `PUT /skins/${skin.id}/entries/${type}/${edit.row.id} — only the submitted language fields plus the legacy columns are written.`);
    }
    setEdit(null);
  };

  return (
    <>
      <HskTabHead tab={tab} />
      <HskNote tone="warn" title="Ungated — all four actions.">
        Show, create, update and delete carry no <code>isadmin()</code> check. Any authenticated, 2FA'd back-office
        session that knows the URL can rewrite this skin's FAQ and footer copy.
      </HskNote>

      <div className="hsk2-typerow">
        <Hsk2Seg value={type} onChange={setType} options={[["faq", "FAQ entries"], ["footer", "Footer entries"]]} />
        <button className="btn btn--secondary btn--sm" onClick={() => { setErr(""); setEdit({ row: { ...hsk2BlankEntry(), id: 0 }, isNew: true }); }}>
          <Icon name="plus" size={12} /> New {isFooter ? "footer entry" : "FAQ entry"}
        </button>
      </div>

      <HskSection title={`${rows.length} ${isFooter ? "footer" : "FAQ"} ${rows.length === 1 ? "entry" : "entries"}`}
        desc={isFooter
          ? "Footer entries reuse the same question / answer columns as title and content — the view just relabels them (footer_question / footer_answer)."
          : "One faq_footer_entries row per question, with twelve question_/answer_ pairs on the row itself."}>
        {rows.length === 0 && <div className="hsk-empty">No {type} entries for this skin.</div>}
        <div className="hsk2-entries">
          {rows.map(e => (
            <div key={e.id} className="hsk2-entry">
              <div className="hsk2-entry__b">
                <div className="hsk2-entry__q">{e.q_en}</div>
                <div className="hsk2-entry__a">{e.a_en}</div>
                <div className="hsk2-entry__l">
                  <span className="hsk2-mono">#{e.id}</span>
                  {HSK2_LANGS.filter(([c]) => String((e[`q_${c}`] || "") + (e[`a_${c}`] || "")).trim()).map(([c]) => (
                    <Hsk2Chip key={c} tone="ok">{c.replace("_", "-")}</Hsk2Chip>
                  ))}
                </div>
              </div>
              <div className="hsk2-entry__acts">
                <button className="hsk-act hsk-act--edit" title="Edit" onClick={() => { setErr(""); setEdit({ row: { ...e }, isNew: false }); }}><Icon name="edit" size={13} /></button>
                <button className="hsk-act hsk-act--danger" title="Delete" onClick={() => {
                  if (!window.confirm(`Delete entry #${e.id}?`)) return;
                  setEntries(entries.filter(x => x.id !== e.id));
                  hskToast("Entry deleted", `DELETE /skins/${skin.id}/entries/${type}/${e.id} — redirects back with a flash message; nothing is cached, so nothing is flushed.`);
                }}><Icon name="trash" size={13} /></button>
              </div>
            </div>
          ))}
        </div>
      </HskSection>

      <HskNote tone="info" title="Twelve languages — and they are not the `languages` table.">
        The set is <code>FaqFooter::getLangKeys()</code>, a hardcoded model constant: <b>en es it de tr ar ro zh fr pt
        pt_br hu</b>, English required. It drifts from the <code>languages</code> rows the Languages screen edits:{" "}
        <b>{HSK2_LANG_ONLY_IN_ENTRIES.join(", ")}</b> are offered here but are not rows in <code>languages</code>, while{" "}
        <b>{HSK2_LANG_ONLY_IN_TABLE.join(", ")}</b> exist there and can never receive an entry. The retired prototype
        listed thirteen languages — that count came from a duplicate <code>BR</code> alongside <code>PT-BR</code>, and it is
        dropped here.
      </HskNote>

      {edit && (
        <div className="bp-modal-scrim hsk-scrim" onClick={() => setEdit(null)}>
          <div className="bp-modal hsk-modal hsk-modal--wide" onClick={ev => ev.stopPropagation()}>
            <div className="hsk-modal__head">
              <div>
                <div className="hsk-modal__title">{edit.isNew ? `New ${isFooter ? "footer entry" : "FAQ entry"}` : `Edit entry #${edit.row.id}`}</div>
                <div className="hsk-modal__sub">
                  {edit.isNew ? `POST /skins/{id}/entries/${type}` : `PUT /skins/{id}/entries/${type}/${edit.row.id}`}
                </div>
              </div>
              <button className="hsk-x" title="Close" onClick={() => setEdit(null)}><Icon name="x" size={14} /></button>
            </div>
            <div className="hsk-modal__body">
              <Hsk2LangEditor value={edit.row} onChange={(v) => setEdit({ ...edit, row: v })}
                qLabel={qLabel} aLabel={aLabel}
                qName={isFooter ? "entries[0][footer_question]" : "entries[0][question]"}
                aName={isFooter ? "entries[0][footer_answer]" : "entries[0][answer]"} />
              {err && <div className="hsk-field__err"><Icon name="alert" size={11} /> {err}</div>}
              <HskNote tone="info" title="English does double duty.">
                The save mirrors the English pair into the legacy <code>question</code> / <code>answer</code> columns
                “for backward compatibility”, which is why it is the one required language.
              </HskNote>
            </div>
            <div className="hsk-modal__foot">
              <button className="btn btn--secondary btn--sm" onClick={() => setEdit(null)}>Cancel</button>
              <button className="hrs-btn hrs-btn--filters" onClick={commit}><Icon name="check" size={14} /> {edit.isNew ? "Create" : "Save"}</button>
            </div>
          </div>
        </div>
      )}
    </>
  );
};

/* ══════════════════════════════════════════════════════════════════════════════════════════════
   13. FOOTER (API) navigation tab — GET /skins/{id}/footer/ → SkinFooterController::showFooterNavigation
       (SFC:15-75, isadmin()); POST …/footer/save (SFC:93-129); POST …/footer/texts/save (SFC:131-154);
       DELETE …/footer/delete/{itemId} (SFC:156-170); GET …/footer/json (SFC:77-91) — ungated.
   ══════════════════════════════════════════════════════════════════════════════════════════ */
const HSK2_ITEM_TYPES = [["parent", "Parent"], ["menu", "Menu"], ["text", "Text only"]];

const hsk2BlankMenu = (parent) => ({ id: 0, group_key: "default", item_type: parent ? "menu" : "parent", parent_id: parent || null, path: "", position: 0, ...hsk2BlankEntry() });

const Hsk2FooterNavTab = ({ tab, skin, draft, set }) => {
  const seed = hsk2UseMemo(() => {
    const mk = (id, item_type, parent_id, position, title, path) => ({
      id, group_key: "default", item_type, parent_id, position, path,
      ...hsk2BlankEntry(), q_en: title, q_es: title,
    });
    return [
      mk(101, "parent", null, 1, skin.name, ""),
      mk(102, "menu", 101, 1, "About Us", "/about"),
      mk(103, "menu", 101, 2, "Blog", "/blogs"),
      mk(104, "menu", 101, 3, "Promotions", "/promotions"),
      mk(110, "parent", null, 2, "Casino", ""),
      mk(111, "menu", 110, 1, "Home", "/"),
      mk(112, "menu", 110, 2, "Live Casino", "/casino-live"),
      mk(120, "text", null, 3, "Licence", ""),
    ];
  }, [skin.id, skin.name]);
  const [items, setItems] = hsk2Slot(draft, set, "footer_menu", seed);

  const textSeed = hsk2UseMemo(() => Object.fromEntries(HSK2_LANGS.map(([c]) => [c, { ownership: c === "en" ? `${skin.name} is operated under licence.` : "", copyright: c === "en" ? `© ${new Date().getFullYear()} ${skin.name}` : "" }])), [skin.name]);
  const [texts, setTexts] = hsk2Slot(draft, set, "footer_texts", textSeed);

  const [edit, setEdit] = hsk2UseState(null);
  const [err, setErr] = hsk2UseState("");
  const [tLang, setTLang] = hsk2UseState("en");

  const roots = items.filter(i => !i.parent_id).sort((a, b) => a.position - b.position);
  const childrenOf = (id) => items.filter(i => i.parent_id === id).sort((a, b) => a.position - b.position);

  const commit = () => {
    /* SFC:112-126 — the only rejection is "every language empty title AND content" → 422. */
    const any = HSK2_LANGS.some(([c]) => String((edit.row[`q_${c}`] || "") + (edit.row[`a_${c}`] || "")).trim());
    if (!any) { setErr("Translations are required — fill at least one language."); return; }
    setErr("");
    if (edit.row.id) {
      setItems(items.map(i => (i.id === edit.row.id ? { ...edit.row } : i)));
      hskToast("Footer item saved", "FooterMenu::updateOrCreate on the item, then updateOrCreate per language in footer_menu_translations. No cache flush.");
    } else {
      const id = Math.max(0, ...items.map(i => i.id)) + 1;
      setItems(items.concat([{ ...edit.row, id }]));
      hskToast("Footer item created", `FooterMenu::updateOrCreate created footer_menu #${id} (group_key "default"), then one footer_menu_translations row per filled language.`);
    }
    setEdit(null);
  };

  const del = (item) => {
    const kids = childrenOf(item.id);
    if (!window.confirm(kids.length ? `Delete “${item.q_en || item.id}” and its ${kids.length} child item(s)?` : `Delete “${item.q_en || item.id}”?`)) return;
    /* KNOWN-BUG DIVERGENCE — SFC::delete removes the item, its DIRECT children and its OWN
       translations, leaving every child's translation rows orphaned. Evident intent implemented:
       descendants' translations go too. */
    const ids = [item.id].concat(kids.map(k => k.id));
    setItems(items.filter(i => !ids.includes(i.id)));
    hskToast("Footer item deleted", `Transactional delete of footer_menu #${item.id}, its ${kids.length} direct children and every one of their footer_menu_translations rows.`);
  };

  const row = (i, depth) => (
    <div key={i.id} className={`hsk2-tree__i hsk2-tree__i--d${depth}`}>
      <span className="hsk2-tree__pos" title="position">{i.position}</span>
      <span className="hsk2-tree__n">{i.q_en || <i>untitled</i>}</span>
      <Hsk2Chip tone={i.item_type === "parent" ? "info" : i.item_type === "text" ? "warn" : "ok"}>{i.item_type}</Hsk2Chip>
      {i.path ? <code className="hsk-name">{i.path}</code> : null}
      <span className="hsk2-tree__langs">{HSK2_LANGS.filter(([c]) => String((i[`q_${c}`] || "") + (i[`a_${c}`] || "")).trim()).length}/12 langs</span>
      <div className="hsk2-tree__acts">
        {i.item_type === "parent" && <button className="hsk-act" title="Add child item" onClick={() => { setErr(""); setEdit({ row: { ...hsk2BlankMenu(i.id), position: childrenOf(i.id).length + 1 } }); }}><Icon name="plus" size={12} /></button>}
        <button className="hsk-act hsk-act--edit" title="Edit" onClick={() => { setErr(""); setEdit({ row: { ...i } }); }}><Icon name="edit" size={12} /></button>
        <button className="hsk-act hsk-act--danger" title="Delete" onClick={() => del(i)}><Icon name="trash" size={12} /></button>
      </div>
    </div>
  );

  return (
    <>
      <HskTabHead tab={tab} />
      <HskNote tone="info" title="A different feature from the tab next door.">
        This is <code>SkinFooterController</code> and the <code>footer_menu</code> / <code>footer_menu_translations</code> /
        <code>footer_texts</code> tables — the footer <b>navigation tree</b>. The FAQ &amp; FOOTER tab writes
        <code>faq_footer_entries</code>. Same word, two features, two storages.
      </HskNote>

      <HskSection title="Navigation tree" desc="Ordered by position. Parent items group the columns; menu items carry a link path; text-only items render as plain copy."
        actions={<button className="btn btn--secondary btn--sm" onClick={() => { setErr(""); setEdit({ row: { ...hsk2BlankMenu(null), position: roots.length + 1 } }); }}><Icon name="plus" size={12} /> New root item</button>}>
        <div className="hsk2-tree">
          {roots.length === 0 && <div className="hsk-empty">No footer items for this skin.</div>}
          {roots.map(r => <React.Fragment key={r.id}>{row(r, 0)}{childrenOf(r.id).map(c => row(c, 1))}</React.Fragment>)}
        </div>
      </HskSection>

      <HskSection title="Footer legal texts" desc="Per-language ownership and copyright lines, keyed (skin_id, language) in footer_texts — a separate save from the tree.">
        <div className="hsk2-langtabs" role="tablist">
          {HSK2_LANGS.map(([code, name]) => (
            <button key={code} role="tab" aria-selected={tLang === code} title={name}
              className={`hsk2-langtab${tLang === code ? " is-on" : ""}${String((texts[code] || {}).ownership || "") + String((texts[code] || {}).copyright || "") ? " is-filled" : ""}`}
              onClick={() => setTLang(code)}>{code.replace("_", "-").toUpperCase()}</button>
          ))}
        </div>
        <HskField label="Ownership" name={`texts[${tLang}][ownership]`} full>
          <HskTextarea rows={2} value={(texts[tLang] || {}).ownership || ""} onChange={e => setTexts({ ...texts, [tLang]: { ...(texts[tLang] || {}), ownership: e.target.value } })} />
        </HskField>
        <HskField label="Copyright" name={`texts[${tLang}][copyright]`} full>
          <HskInput value={(texts[tLang] || {}).copyright || ""} onChange={e => setTexts({ ...texts, [tLang]: { ...(texts[tLang] || {}), copyright: e.target.value } })} />
        </HskField>
        <HskSaveBar label="Save footer texts" note="POST /skins/{id}/footer/texts/save · FooterText::updateOrCreate per language"
          onSave={() => hskToast("Footer texts saved", "One FooterText::updateOrCreate per language keyed (skin_id, language).")} />
      </HskSection>

      <HskNote tone="warn" title="One endpoint of the four is ungated.">
        <code>showFooterNavigation</code>, <code>save</code>, <code>saveTexts</code> and <code>delete</code> all check
        <code>isadmin()</code> — <code>GET /skins/{"{id}"}/footer/json</code>, which returns the whole tree with every
        translation, does not.
      </HskNote>
      <HskNote tone="bug" title="Deleting a parent orphans its grandchildren's translations.">
        The real delete removes the item, its direct children and its own translations — the children's
        <code>footer_menu_translations</code> rows are left behind. Implemented as intended here.
      </HskNote>
      <HskNote tone="info" title="Dead alternates, honestly absent.">
        <code>saveEditSkin case "manage_footer"</code> is a complete second save path for these same tables (with a
        seven-language list of its own) that nothing posts to, and <code>SkinsController::footer()</code> renders the
        same view from an unrouted method. Neither is reproduced here.
      </HskNote>

      {edit && (
        <div className="bp-modal-scrim hsk-scrim" onClick={() => setEdit(null)}>
          <div className="bp-modal hsk-modal hsk-modal--wide" onClick={ev => ev.stopPropagation()}>
            <div className="hsk-modal__head">
              <div>
                <div className="hsk-modal__title">{edit.row.id ? `Edit footer item #${edit.row.id}` : "New footer item"}</div>
                <div className="hsk-modal__sub">POST /skins/{"{id}"}/footer/save · FooterMenu + FooterMenuTranslation</div>
              </div>
              <button className="hsk-x" title="Close" onClick={() => setEdit(null)}><Icon name="x" size={14} /></button>
            </div>
            <div className="hsk-modal__body">
              <HskField label="Group" name="group_key" hint="Hidden on the real form — every item is created in the “default” group.">
                <HskInput value={edit.row.group_key} readOnly />
              </HskField>
              <HskField label="Type" name="item_type">
                <HskSelect value={edit.row.item_type} onChange={v => setEdit({ ...edit, row: { ...edit.row, item_type: v } })} options={HSK2_ITEM_TYPES} />
              </HskField>
              <HskField label="Parent" name="parent_id" hint="Self-reference on footer_menu. One level of nesting is what the tree renders.">
                <HskSelect value={edit.row.parent_id || ""} onChange={v => setEdit({ ...edit, row: { ...edit.row, parent_id: v ? Number(v) : null } })}
                  options={[["", "— none (root) —"]].concat(items.filter(i => !i.parent_id && i.id !== edit.row.id).map(i => [i.id, i.q_en || `#${i.id}`]))} />
              </HskField>
              <HskField label="Path" name="path" hint="The link the item points at, e.g. /promotions. Left empty for parent and text-only items.">
                <HskInput value={edit.row.path} onChange={e => setEdit({ ...edit, row: { ...edit.row, path: e.target.value } })} />
              </HskField>
              <HskField label="Position" name="position" hint="Ordering within the parent. The real UI sets it by drag-sorting with Sortable.js.">
                <Hsk2Num value={edit.row.position} onChange={v => setEdit({ ...edit, row: { ...edit.row, position: Number(v) || 0 } })} />
              </HskField>
              <Hsk2LangEditor value={edit.row} onChange={(v) => setEdit({ ...edit, row: v })}
                qLabel="Title" aLabel="Content" qName="translations[…][title]" aName="translations[…][content]" rows={3} />
              {err && <div className="hsk-field__err"><Icon name="alert" size={11} /> {err}</div>}
            </div>
            <div className="hsk-modal__foot">
              <button className="btn btn--secondary btn--sm" onClick={() => setEdit(null)}>Cancel</button>
              <button className="hrs-btn hrs-btn--filters" onClick={commit}><Icon name="check" size={14} /> Save item</button>
            </div>
          </div>
        </div>
      )}
    </>
  );
};

/* ══════════════════════════════════════════════════════════════════════════════════════════════
   14. Sport / Financial / Security — the three catch-all tabs `showTab` actually authorizes
       (SC:4217-4300 + SkinPolicy::update, SkinPolicy.php:55-67). Value settings for all three go
       through updateSkinValueSettings (SC:2393-2444): a truthy value is upserted into
       `skin_settings`, a falsy one DELETES the row — except for the store-zero list
       [sport_taxes_perc, allow_change_password, allow_change_password_{0,1,2,4,6,8,10,15,20}].
   ══════════════════════════════════════════════════════════════════════════════════════════ */

const HSK2_YESNO = [["0", "No"], ["1", "Yes"]];

const Hsk2SportTab = ({ tab, skin, draft, set }) => {
  const settings = draft.settings || {};
  const seed = hsk2UseMemo(() => ({
    fixed_sport_iframe_lang: "", custom_coupon_tpl: "sport_default_template",
    sport_taxes: "0", sport_bet_taxes: "0", sport_win_taxes: "0",
    profit_formula: "sub_win_tax", allow_cancel_bets: "0", allow_cancel_bet_max_mins: "5",
    ...Object.fromEntries(HSK2_LEVELS.map(([l]) => [`allow_user_level_${l}`, l === 0 || l === 2 ? "1" : "0"])),
  }), [skin.id]);
  const [v, setV] = hsk2Slot(draft, set, "sport_vsettings", seed);
  const setK = (k, val) => setV({ ...v, [k]: val });
  const taxesOn = v.sport_taxes === "1";
  const cancelOn = v.allow_cancel_bets === "1";

  const save = () => hskToast(`Sport settings saved for ${skin.name}`,
    `print_always_copy ${settings.print_always_copy ? "subscribed" : "unsubscribed"} as a presence row, then updateSkinValueSettings upserted the vsettings — a falsy value deletes its skin_settings row unless the key is in the store-zero list. Policy re-checked as "sport" (super admin) before the write.`);

  return (
    <>
      <HskTabHead tab={tab} />
      <HskSection title="Coupons">
        <HskField label="Print always Copy" name="settings[print_always_copy]"
          hint="A presence row in skin_settings, not a value — the save subscribes or unsubscribes the key. It is the one setting the Settings tab deliberately never touches.">
          <HskSwitch value={!!settings.print_always_copy} onChange={val => set("settings", { ...settings, print_always_copy: val })} />
        </HskField>
        <HskField label="Fixed sport iframe lang" name="vsettings[fixed_sport_iframe_lang]" hint="Free text — pins the sportsbook iframe to one language regardless of the player's choice.">
          <HskInput value={v.fixed_sport_iframe_lang} onChange={e => setK("fixed_sport_iframe_lang", e.target.value)} />
        </HskField>
        <HskField label="Custom coupon TPL" name="vsettings[custom_coupon_tpl]" hint="An enum of exactly one option — the only coupon template the platform ships.">
          <HskSelect value={v.custom_coupon_tpl} onChange={val => setK("custom_coupon_tpl", val)} options={[["sport_default_template", "Default template"]]} />
        </HskField>
      </HskSection>

      <HskSection title="Taxes" desc="The master select shows or hides the two rows below it.">
        <HskField label="Taxes" name="vsettings[sport_taxes]">
          <HskSelect value={v.sport_taxes} onChange={val => setK("sport_taxes", val)} options={HSK2_YESNO} />
        </HskField>
        {/* KNOWN-BUG DIVERGENCE — the real Blade prints the stored `sport_taxes` value into BOTH of these
            selects, and the page JS force-resets them to 0 whenever the master changes, so their own
            stored values are unreachable. Evident intent implemented: each select renders and keeps its
            own key. */}
        {taxesOn && (
          <>
            <HskField label="Bet tax" name="vsettings[sport_bet_taxes]">
              <HskSelect value={v.sport_bet_taxes} onChange={val => setK("sport_bet_taxes", val)} options={HSK2_YESNO} />
            </HskField>
            <HskField label="Win tax" name="vsettings[sport_win_taxes]">
              <HskSelect value={v.sport_win_taxes} onChange={val => setK("sport_win_taxes", val)} options={HSK2_YESNO} />
            </HskField>
          </>
        )}
        <HskField label="Profit formula" name="vsettings[profit_formula]" hint="Whether the win tax is subtracted when profit is computed. Default: subtract.">
          <HskSelect value={v.profit_formula} onChange={val => setK("profit_formula", val)}
            options={[["sub_win_tax", "Subtract win tax"], ["no_sub_win_tax", "Do not subtract win tax"]]} />
        </HskField>
        <HskNote tone="bug" title="Two selects that never showed their own value.">
          Bet tax and Win tax render <code>sport_taxes</code> upstream, and the page script zeroes them both on every
          master change. Fixed here — see the SUGGESTION at the top of this file.
        </HskNote>
        <HskNote tone="warn" title="One store-zero key has no input anywhere.">
          <code>sport_taxes_perc</code> (<code>SkinSetting::SPORT_TAXES_PERCENTAGE</code>) is in the list of keys whose 0
          is persisted rather than deleted, but no admin view renders a field for it. Where it is edited is UNCLEAR,
          so nothing is invented here.
        </HskNote>
      </HskSection>

      <HskSection title="Cancelling open bets">
        <HskField label="Cancel open bets" name="vsettings[allow_cancel_bets]">
          <HskSelect value={v.allow_cancel_bets} onChange={val => setK("allow_cancel_bets", val)} options={HSK2_YESNO} />
        </HskField>
        {cancelOn && (
          <>
            <HskField label="Max elapsed time (mins)" name="vsettings[allow_cancel_bet_max_mins]" hint="How long after placement a bet may still be cancelled.">
              <Hsk2Num value={v.allow_cancel_bet_max_mins} onChange={val => setK("allow_cancel_bet_max_mins", val)} />
            </HskField>
            <HskField label="Who may cancel" name="vsettings[allow_user_level_{level}]" full
              hint="One switch per back-office role, posted as a hidden 1/0.">
              <div className="hsk2-levels">
                {HSK2_LEVELS.map(([l, name]) => (
                  <div key={l} className="hsk2-level">
                    <span className="hsk2-level__n">{name} <code className="hsk-name">{l}</code></span>
                    <HskSwitch value={v[`allow_user_level_${l}`] === "1"} onChange={val => setK(`allow_user_level_${l}`, val ? "1" : "0")} />
                  </div>
                ))}
              </div>
            </HskField>
          </>
        )}
      </HskSection>

      <HskSaveBar onSave={save} label="Save sport" note="POST /skins/saveSkin/{id}/sport · policy `sport` = isadmin()" />
    </>
  );
};

/* ---- Financial ---------------------------------------------------------------------------------
   financial.blade.php — a matrix of INVERTED "disable" flags. Switch ON = allowed = no row stored;
   switch OFF writes `…=1`. Acting roles 0,1,2,4,6,8,10,15,20; targets are every role strictly below
   the actor, plus the pseudo-target `30_1` ("Default player created"). */
const HSK2_FIN_COLS = [
  { key: "deposit", label: "Deposit" },
  { key: "deposit_to_third_party", label: "Third party deposit", playerOnly: true },
  { key: "withdraw", label: "Withdraw" },
  { key: "credit_deposit", label: "Credit deposit" },
  { key: "credit_withdraw", label: "Credit withdraw" },
];
const HSK2_FIN_ACTORS = [0, 1, 2, 4, 6, 8, 10, 15, 20];
const HSK2_FIN_TARGETS = [1, 2, 4, 6, 8, 10, 15, 20, 30];
/* The key shapes are literal: deposit / withdraw / credit_deposit / credit_withdraw read
   `disable_user_level_{actor}_{col}_to_{target}`, while third-party reads
   `disable_user_level_{actor}_deposit_to_third_party_{target}`. */
const hsk2FinKey = (actor, col, target) => (col === "deposit_to_third_party"
  ? `disable_user_level_${actor}_deposit_to_third_party_${target}`
  : `disable_user_level_${actor}_${col}_to_${target}`);

const Hsk2FinancialTab = ({ tab, skin, draft, set }) => {
  const [actor, setActor] = hsk2UseState(0);
  const seed = hsk2UseMemo(() => {
    const r = hsk2Rnd(`fin|${skin.id}`);
    const out = {};
    HSK2_FIN_ACTORS.forEach(a => HSK2_FIN_TARGETS.concat(["30_1"]).forEach(t => {
      if (String(t) !== "30_1" && Number(t) <= a) return;
      HSK2_FIN_COLS.forEach(c => {
        if (c.playerOnly && String(t) !== "30" && String(t) !== "30_1") return;
        if (r() < 0.18) out[hsk2FinKey(a, c.key, t)] = "1";       // stored 1 = DISABLED
      });
    }));
    return out;
  }, [skin.id]);
  const [flags, setFlags] = hsk2Slot(draft, set, "financial", seed);

  const allowed = (a, c, t) => flags[hsk2FinKey(a, c, t)] !== "1";
  const setAllowed = (a, c, t, val) => {
    const k = hsk2FinKey(a, c, t);
    const next = { ...flags };
    if (val) delete next[k]; else next[k] = "1";     // allowed = NO row stored
    setFlags(next);
  };

  const targets = HSK2_FIN_TARGETS.filter(t => t > actor).map(String).concat(["30_1"]);
  const disabledCount = Object.keys(flags).filter(k => k.startsWith(`disable_user_level_${actor}_`)).length;

  const save = () => hskToast(`Financial matrix saved for ${skin.name}`,
    `${Object.keys(flags).length} disable_* rows written to skin_settings; every allowed pair stores no row at all. On the real platform this form posts to the SPORT endpoint — see the note on this tab.`);

  return (
    <>
      <HskTabHead tab={tab} />
      <HskNote tone="bug" title="This form posts to the Sport endpoint.">
        <code>financial.blade.php</code> has <code>action="/skins/saveSkin/{"{id}"}/sport"</code>. Two consequences on the
        real platform: a skin admin who is <i>allowed to view</i> this tab is refused on save (the save re-checks the
        <code>sport</code> policy, which is super-admin only), and because the financial form carries no
        <code>settings[print_always_copy]</code>, every financial save runs the sport case's else-branch and
        <b> silently deletes the skin's print_always_copy flag</b>. Saving here writes only the matrix.
      </HskNote>

      <div className="hsk2-typerow">
        <div className="hsk2-actorpick">
          <label htmlFor="hsk2-actor">Acting role</label>
          <select id="hsk2-actor" className="select input--sm" value={actor} onChange={e => setActor(Number(e.target.value))}>
            {HSK2_FIN_ACTORS.map(a => <option key={a} value={a}>{HSK2_LEVEL_NAME(a)} ({a})</option>)}
          </select>
        </div>
        <span className="hsk2-hint">
          {/* One actor table at a time — the real page stacks all nine. Presentation only: the same
              rows, the same keys, nothing added or hidden. */}
          The real screen stacks one table per acting role; they are shown one at a time here.
          {disabledCount > 0 && <> <b>{disabledCount}</b> restriction{disabledCount === 1 ? "" : "s"} stored for this role.</>}
        </span>
      </div>

      <HskSection title={`${HSK2_LEVEL_NAME(actor)} may move money to…`}
        desc="A switch that is ON means allowed and stores nothing. Switching it OFF writes disable_user_level_… = 1.">
        <div className="hsk2-matrixwrap">
          <table className="hsk2-matrix">
            <thead>
              <tr>
                <th>Target role</th>
                {HSK2_FIN_COLS.map(c => <th key={c.key} className="hsk2-matrix__c">{c.label}</th>)}
              </tr>
            </thead>
            <tbody>
              {targets.map(t => (
                <tr key={t}>
                  <th scope="row">
                    <span className="hsk2-matrix__r">{t === "30_1" ? "Default player created" : HSK2_LEVEL_NAME(t)}</span>
                    <code className="hsk-name">{t}</code>
                  </th>
                  {HSK2_FIN_COLS.map(c => {
                    /* Third party deposit renders only against the player target (and its 30_1 twin,
                       which passes the view's `== 30` check through PHP loose comparison). */
                    const show = !c.playerOnly || t === "30" || t === "30_1";
                    return (
                      <td key={c.key} className="hsk2-matrix__c">
                        {show
                          ? <Hsk2Tick checked={allowed(actor, c.key, t)} title={hsk2FinKey(actor, c.key, t)}
                            onChange={val => setAllowed(actor, c.key, t, val)} />
                          : <span className="hsk2-na" title="Not rendered for this target on the real screen">—</span>}
                      </td>
                    );
                  })}
                </tr>
              ))}
            </tbody>
          </table>
        </div>
      </HskSection>

      <HskNote tone="info" title="Absence is the permission.">
        Nothing is stored for an allowed pair — the matrix is a list of exceptions. That is also why an
        accidental save from the wrong form can only ever <i>add</i> restrictions, never clear them.
      </HskNote>

      <HskSaveBar onSave={save} label="Save financial matrix"
        note="posts to /skins/saveSkin/{id}/sport on the real platform · updateSkinValueSettings" />
    </>
  );
};

/* ---- Security ----------------------------------------------------------------------------------
   security.blade.php + `case "security"` (SC:3747-3758). Consumed by Skin::allowChangePassword(),
   which also gates this very tab — null-or-1 counts as allowed. */
const Hsk2SecurityTab = ({ tab, skin, draft, set }) => {
  const seed = hsk2UseMemo(() => {
    const r = hsk2Rnd(`sec|${skin.id}`);
    const out = { allow_change_password: "1" };
    HSK2_LEVELS.forEach(([l]) => { out[`allow_change_password_${l}`] = l === 0 || l === 2 || r() < 0.45 ? "1" : "0"; });
    return out;
  }, [skin.id]);
  const [v, setV] = hsk2Slot(draft, set, "security", seed);
  const setK = (k, val) => setV({ ...v, [k]: val });
  const masterOn = v.allow_change_password !== "0";

  const save = () => hskToast(`Security saved for ${skin.name}`,
    `updateSkinValueSettings upserted allow_change_password and the nine allow_change_password_{level} keys — all of them are in the store-zero list, so a 0 is persisted rather than deleting the row. Skin::allowChangePassword caches for 1h.`);

  return (
    <>
      <HskTabHead tab={tab} />
      <HskNote tone="info" title="This tab gates itself.">
        <code>SkinPolicy::update</code> lets a non-super-admin in only when the master switch is on <i>and</i> their own
        role's switch is on — <code>Skin::allowChangePassword()</code> treats an absent value as allowed. The same
        reader hides or shows the tab link in the sidebar.
      </HskNote>

      <HskSection title="Password changes" desc="Who may set another user's password on this skin.">
        <HskField label="Allow changing passwords" name="vsettings[allow_change_password]"
          hint="Defaults to Yes when the row is absent. Turning it off hides the per-role rows and locks the tab for everyone but a super admin.">
          <HskSelect value={v.allow_change_password} onChange={val => setK("allow_change_password", val)} options={HSK2_YESNO} />
        </HskField>
        {masterOn && (
          <HskField label="Per role" name="vsettings[allow_change_password_{level}]" full>
            <div className="hsk2-levels">
              {HSK2_LEVELS.map(([l, name]) => (
                <div key={l} className="hsk2-level">
                  <span className="hsk2-level__n">{name} <code className="hsk-name">{l}</code></span>
                  {/* KNOWN-BUG DIVERGENCE — the real hidden input is initialised from the settings ROW array
                      instead of its ['value'], so a role stored as 0 renders unchecked but posts 1, and an
                      untouched save silently re-enables it. Evident intent: the switch reflects the value. */}
                  <HskSwitch value={v[`allow_change_password_${l}`] === "1"} onChange={val => setK(`allow_change_password_${l}`, val ? "1" : "0")} />
                </div>
              ))}
            </div>
          </HskField>
        )}
      </HskSection>

      <HskNote tone="bug" title="A switch that lies, upstream.">
        Because the hidden input tests the settings row rather than its value, a role you deliberately switched
        off comes back on the next time anyone saves this tab without touching it. Fixed here; see the SUGGESTION
        at the top of this file.
      </HskNote>

      <HskSaveBar onSave={save} label="Save security" note="POST /skins/saveSkin/{id}/security · policy `security` re-checked before the write" />

      {/* TWO-FACTOR — inside Security, as asked (2026-08-16; it briefly had a
          tab of its own). The section below is Iwakiri-native and LIVE: the
          switches write skin_settings through set_skin_2fa_switch, unlike the
          password matrix above which is the upstream screen's prototype state.
          The line between the two is drawn on purpose, so nobody reads a saved
          matrix as a saved database row. */}
      <div style={{ borderTop: "1px solid var(--border-default)", margin: "22px 0 16px" }} />
      <div style={{ display: "flex", alignItems: "baseline", gap: 10, marginBottom: 10 }}>
        <div style={{ fontSize: 15, fontWeight: 800 }}>Two-factor (Google Authenticator)</div>
        <div style={{ fontSize: 12, color: "var(--n-600)" }}>
          per-role requirement for this brand · live — writes <code>skin_settings</code> via RPC
        </div>
      </div>
      <Hsk2TwoFactorTab skin={skin} />
    </>
  );
};

/* ══════════════════════════════════════════════════════════════════════════════════════════════
   15. Deposit / Withdrawal methods tabs.
       Deposit : GET /skins/{id}/depositmethods/ (SC:2166-2183) · case "depositmethods"
                 (SC:3551-3590) → updateSkinDeposits (SC:2319-2341) on `skin_deposit_methods`.
       Withdraw: GET /skins/{id}/withdrawmethods/ (SC:2185-2202) · case "withdrawmethods"
                 (SC:3591-3627) → updateSkinWithdraws (SC:2344-2365) on `skin_withdraw_methods`.
       Both gate isadmin(). Cross-screen contract: these grids must agree with the Settings ▾
       catalogs — src/pages/HostSetDepositMethods.jsx (HSD_CATALOG / hsdCommitSkin) and
       HostSetWithdrawMethods.jsx (HSW_CATALOG) — which model the same two tables from the other side.
   ══════════════════════════════════════════════════════════════════════════════════════════ */

/* Column names differ between the two tables; everything else is symmetric. */
const HSK2_METHOD_SHAPE = {
  deposit: {
    min: "min_dep", max: "max_dep", posted: ["min_dep", "max_dep", "limit_day", "limit_week", "limit_month", "bo_status", "limited"],
    wiped: ["fee_pct", "currency", "agents", "limit_year"], table: "skin_deposit_methods", fk: "deposit_id",
    cache: "payment_deposite_active_%s_%s / payment_deposit_limit_%s_%s",
  },
  withdraw: {
    min: "min_with", max: "max_with", posted: ["min_with", "max_with", "limit_day", "limit_week", "limit_month", "bo_status", "limited"],
    wiped: ["fee_pct", "currency", "auto_approve_under"], table: "skin_withdraw_methods", fk: "withdraw_id",
    cache: "payment_withdraw_active_%s_%s / payment_withdraw_limit_%s_%s",
  },
};

const hsk2BlankMethod = (kind) => {
  const s = HSK2_METHOD_SHAPE[kind];
  const row = { active: false, bo_status: false, limited: false, limit_day: "", limit_week: "", limit_month: "" };
  row[s.min] = ""; row[s.max] = "";
  s.wiped.forEach(k => { row[k] = ""; });
  return row;
};

const hsk2GenMethods = (kind, skinId, currency) => {
  const s = HSK2_METHOD_SHAPE[kind];
  const catalog = kind === "deposit" ? hsk2DepCatalog() : hsk2WitCatalog();
  const scale = currency === "ARS" ? 1000 : currency === "PYG" ? 5000 : currency === "LBP" ? 10000 : currency === "BOB" ? 10 : 1;
  const out = {};
  catalog.forEach(m => {
    const r = hsk2Rnd(`${kind}|${skinId}|${m.id}`);
    if (r() > 0.55) { out[m.id] = hsk2BlankMethod(kind); return; }
    const limited = r() < 0.5;
    const day = Number(hsk2Round(r() * 400 * scale + 50 * scale, 50 * scale));
    const row = {
      active: true, bo_status: r() < 0.8, limited,
      limit_day: limited ? String(day) : "", limit_week: limited ? String(day * 5) : "", limit_month: limited ? String(day * 18) : "",
    };
    row[s.min] = hsk2Round(r() * 4 * scale + scale, scale);
    row[s.max] = hsk2Round(r() * 500 * scale + 100 * scale, 100 * scale);
    /* Columns only the modern Payments admin (and the Cripten / Mercurio register commands) writes —
       seeded so the data the legacy save destroys is visible. */
    s.wiped.forEach(k => {
      row[k] = k === "fee_pct" ? (r() < 0.6 ? (Math.round(r() * 350) / 100).toFixed(2) : "")
        : k === "currency" ? (r() < 0.3 ? currency : "")
          : k === "agents" ? (r() < 0.3 ? "10,15" : "")
            : (r() < 0.35 ? String(day * 30) : "");
    });
    out[m.id] = row;
  });
  return out;
};

const Hsk2MethodsTab = ({ tab, skin, draft, set, onPatchSkin, kind }) => {
  const s = HSK2_METHOD_SHAPE[kind];
  const catalog = hsk2UseMemo(() => (kind === "deposit" ? hsk2DepCatalog() : hsk2WitCatalog()), [kind]);
  const slot = `${kind}_methods`;
  const seed = hsk2UseMemo(() => hsk2GenMethods(kind, skin.id, skin.currency), [kind, skin.id, skin.currency]);
  const stored = skin[slot] === undefined ? seed : skin[slot];
  const rows = draft[slot] === undefined ? stored : draft[slot];
  const setRows = (v) => set(slot, v);
  const setOne = (id, patch) => setRows({ ...rows, [id]: { ...(rows[id] || hsk2BlankMethod(kind)), ...patch } });
  const enabled = catalog.filter(m => rows[m.id] && rows[m.id].active).length;

  const save = () => {
    /* KNOWN-BUG DIVERGENCE — updateSkinDeposits / updateSkinWithdraws (both flagged
       "///// DA SISTEMARE!!!!!!!!!") delete every row for the skin and recreate only the posted
       columns, so fee_pct / currency / agents / limit_year / auto_approve_under — written by the
       modern Payments admin and the register commands — are wiped on every legacy save. Evident
       intent implemented: the posted columns are merged onto the STORED row, the rest survive, and
       rows the operator unticked are dropped. Identical to hsdCommitSkin in HostSetDepositMethods.jsx,
       so the two screens agree about what a save does. */
    const next = {};
    catalog.forEach(m => {
      const d = rows[m.id] || hsk2BlankMethod(kind);
      const st = stored[m.id] || hsk2BlankMethod(kind);
      if (!d.active) { next[m.id] = hsk2BlankMethod(kind); return; }
      const row = { active: true, bo_status: !!d.bo_status, limited: !!d.limited, limit_day: d.limit_day, limit_week: d.limit_week, limit_month: d.limit_month };
      row[s.min] = d[s.min]; row[s.max] = d[s.max];
      s.wiped.forEach(k => { row[k] = st[k]; });     // preserved — never posted by this tab
      next[m.id] = row;
    });
    setRows(next);
    onPatchSkin && onPatchSkin({ [slot]: next });
    hskToast(`${kind === "deposit" ? "Deposit" : "Withdrawal"} methods saved for ${skin.name}`,
      `${enabled} rows in ${s.table} (${s.posted.join(", ")}), PaymentLimitService caches busted per method_code (${s.cache}), then flushSkinCache(${skin.id}). The columns only the Payments admin writes (${s.wiped.join(", ")}) are preserved here — the real save destroys them.`);
  };

  const preserved = catalog.filter(m => (stored[m.id] || {}).active && s.wiped.some(k => (stored[m.id] || {})[k])).length;

  const columns = [
    { key: "name", label: "Method", render: (m) => (
      <div className="hsk2-provcell">
        <span className="hsk2-provcell__n">{m.name}</span>
        <span className="hsk2-provcell__i"><code>{m.code}</code> · #{m.id}</span>
      </div>
    ) },
    { key: "active", label: "Active", align: "center", width: 90, render: (m) => (
      <Hsk2Tick checked={(rows[m.id] || {}).active} title={`Row presence in ${s.table}`} onChange={v => setOne(m.id, { active: v })} />
    ) },
    { key: "bo_status", label: "Active on BO", align: "center", width: 110, render: (m) => (
      <Hsk2Tick checked={(rows[m.id] || {}).bo_status} disabled={!(rows[m.id] || {}).active} title="bo_status — gates the back-office side and the payment-init path" onChange={v => setOne(m.id, { bo_status: v })} />
    ) },
    { key: "limited", label: "Limited", align: "center", width: 90, render: (m) => (
      <Hsk2Tick checked={(rows[m.id] || {}).limited} disabled={!(rows[m.id] || {}).active} title="limited — opts the method into day/week/month limit enforcement" onChange={v => setOne(m.id, { limited: v })} />
    ) },
    { key: "min", label: "Min", align: "right", width: 120, render: (m) => (
      <Hsk2Num value={(rows[m.id] || {})[s.min]} disabled={!(rows[m.id] || {}).active} onChange={v => setOne(m.id, { [s.min]: v })} />
    ) },
    { key: "max", label: "Max", align: "right", width: 120, render: (m) => (
      <Hsk2Num value={(rows[m.id] || {})[s.max]} disabled={!(rows[m.id] || {}).active} onChange={v => setOne(m.id, { [s.max]: v })} />
    ) },
    { key: "limit_day", label: "Day", align: "right", width: 120, render: (m) => (
      <Hsk2Num value={(rows[m.id] || {}).limit_day} disabled={!(rows[m.id] || {}).limited} onChange={v => setOne(m.id, { limit_day: v })} />
    ) },
    { key: "limit_week", label: "Week", align: "right", width: 120, render: (m) => (
      <Hsk2Num value={(rows[m.id] || {}).limit_week} disabled={!(rows[m.id] || {}).limited} onChange={v => setOne(m.id, { limit_week: v })} />
    ) },
    { key: "limit_month", label: "Month", align: "right", width: 120, render: (m) => (
      <Hsk2Num value={(rows[m.id] || {}).limit_month} disabled={!(rows[m.id] || {}).limited} onChange={v => setOne(m.id, { limit_month: v })} />
    ) },
  ];

  return (
    <>
      <HskTabHead tab={tab} />
      <HskNote tone="info" title="Two tables, two screens.">
        The catalog itself — name, <code>method_code</code>, description, image — is edited on
        <b> Settings ▾ → {kind === "deposit" ? "Deposit methods" : "Withdrawal methods"}</b>. This tab only decides
        which of those methods this skin offers, and with what bounds. <b>Active</b> is row presence (there is no
        status column); <b>Active on BO</b> is the <code>bo_status</code> column; <b>Limited</b> opts the method into
        the day / week / month limits.
      </HskNote>

      <HskSection title={`${enabled} of ${catalog.length} methods enabled`}
        desc={`Amounts are in the skin's currency (${skin.currency}) unless a per-method currency override was set by the Payments admin.`}>
        <HrsTable columns={columns} rows={catalog} rowKey={(m) => m.id}
          renderCard={(m) => {
            const r = rows[m.id] || hsk2BlankMethod(kind);
            return (
              <div className="hsk2-card">
                <div className="hsk2-card__t">{m.name}</div>
                <div className="hsk2-card__s"><code>{m.code}</code> · #{m.id}</div>
                <div className="hsk2-card__grid">
                  <label className="hsk2-card__f"><span>Active</span><Hsk2Tick checked={r.active} onChange={v => setOne(m.id, { active: v })} /></label>
                  <label className="hsk2-card__f"><span>On BO</span><Hsk2Tick checked={r.bo_status} disabled={!r.active} onChange={v => setOne(m.id, { bo_status: v })} /></label>
                  <label className="hsk2-card__f"><span>Limited</span><Hsk2Tick checked={r.limited} disabled={!r.active} onChange={v => setOne(m.id, { limited: v })} /></label>
                </div>
                <div className="hsk2-card__f hsk2-card__f--wide"><span>Min</span><Hsk2Num value={r[s.min]} disabled={!r.active} onChange={v => setOne(m.id, { [s.min]: v })} /></div>
                <div className="hsk2-card__f hsk2-card__f--wide"><span>Max</span><Hsk2Num value={r[s.max]} disabled={!r.active} onChange={v => setOne(m.id, { [s.max]: v })} /></div>
                <details className="hsk2-more">
                  <summary>Limits</summary>
                  <div className="hsk2-card__f hsk2-card__f--wide"><span>Day</span><Hsk2Num value={r.limit_day} disabled={!r.limited} onChange={v => setOne(m.id, { limit_day: v })} /></div>
                  <div className="hsk2-card__f hsk2-card__f--wide"><span>Week</span><Hsk2Num value={r.limit_week} disabled={!r.limited} onChange={v => setOne(m.id, { limit_week: v })} /></div>
                  <div className="hsk2-card__f hsk2-card__f--wide"><span>Month</span><Hsk2Num value={r.limit_month} disabled={!r.limited} onChange={v => setOne(m.id, { limit_month: v })} /></div>
                </details>
              </div>
            );
          }} />
      </HskSection>

      <HskNote tone="bug" title="The real save is a delete-and-recreate.">
        <code>{kind === "deposit" ? "updateSkinDeposits" : "updateSkinWithdraws"}</code> — flagged
        <code>DA SISTEMARE</code> in the source — deletes every one of this skin's rows and re-inserts only the
        columns this form posts, so <b>{s.wiped.join(", ")}</b> are wiped on every save. Those columns are written
        by the modern Payments admin and by the Cripten / Mercurio register commands, never here.
        {preserved > 0 && <> {preserved} enabled method{preserved === 1 ? " on this skin carries" : "s on this skin carry"} such a value right now.</>}
        {" "}This tab merges the posted columns onto the stored row instead, so they survive.
      </HskNote>
      <HskNote tone="warn" title="Unticking Active still drops the row.">
        That part is the real intent: a method with no row is not offered to players at all. Its bounds and any
        Payments-admin configuration go with it.
      </HskNote>

      <HskSaveBar onSave={save} label={`Save ${kind === "deposit" ? "deposit" : "withdrawal"} methods`}
        note={`POST /skins/saveSkin/{id}/${kind === "deposit" ? "depositmethods" : "withdrawmethods"} · ${s.table} + PaymentLimitService cache bust + flushSkinCache(id)`} />
    </>
  );
};

const Hsk2DepositMethodsTab = (props) => <Hsk2MethodsTab {...props} kind="deposit" />;
const Hsk2WithdrawMethodsTab = (props) => <Hsk2MethodsTab {...props} kind="withdraw" />;

/* ══════════════════════════════════════════════════════════════════════════════════════════════
   16. Registration into part 1's registry. Ids are HSK_TABS ids, verbatim — an id that is not in
       HSK_TABS would simply never render, and inventing one would break "nothing added".
       `operational-settings` and `customerio` are deliberately absent: both point at protected
       files and are already registered by part 1.
   ══════════════════════════════════════════════════════════════════════════════════════════ */
const HSK2_REGISTER = {
  providers: Hsk2ProvidersTab,
  gamemanagement: Hsk2GamesTab,
  subcategories: Hsk2SubcategoriesTab,
  graphic: Hsk2GraphicTab,
  domains: Hsk2DomainsTab,
  jackpot: Hsk2JackpotTab,
  "deposit-methods": Hsk2DepositMethodsTab,
  "withdrawal-methods": Hsk2WithdrawMethodsTab,
  limits: Hsk2LimitsTab,
  "homepage-view": Hsk2HomepageTab,
  "faq-footer": Hsk2EntriesTab,
  footer: Hsk2FooterNavTab,
  sport: Hsk2SportTab,
  financial: Hsk2FinancialTab,
  security: Hsk2SecurityTab,
};

/* Part 1 exports the registry object on window and reads it at render time, so assigning into it
   here is enough — no re-render plumbing needed. The fallback only matters if the script order is
   ever inverted, in which case part 1's own const would win and this file would be inert; the
   console line makes that visible instead of silent. */

/* SECURITY TAB — per-skin 2FA policy (spec §6).

   Five independent switches, one per conditional level. `Skin` is Skin admin's
   own switch, not a gate over the others — that was confirmed after an earlier
   reading treated it as a master, which would have made enabling Agents on a
   brand silently make its Skin admins mandatory too.

   Presence-based: on is a row, off is no row. The RPC deletes rather than
   writing a false value, because skin_settings' `value` column is never read
   anywhere in this codebase and a stored 'false' would leave the switch ON.

   The count beside each switch is the point of §4's blast-radius line: flipping
   this for four hundred cashiers currently looks identical to flipping it for
   three. A count, never a list — §10 excludes the preview, not the number. */
const Hsk2TwoFactorTab = ({ skin }) => {
  /* IDENTICAL to the password matrix above it — the owner's rule, stated after
     two of my variants invented their own interaction: same hsk2-level chip
     (label + count badge + HskSwitch), toggles that flip freely as a DRAFT,
     and one HskSaveBar that applies the lot. No per-row confirm, no pending
     outline, no second on/off language. The blast-radius counts sit on the
     rows permanently and the save bar totals what a click will change, so the
     count-before-confirm requirement is met by the page's own pattern. */
  const [busy, setBusy] = React.useState(false);
  const [err, setErr] = React.useState(null);
  const [msg, setMsg] = React.useState(null);
  const [tick, setTick] = React.useState(0);
  const [draft, setDraft] = React.useState(null);   // {key: bool} once loaded

  const keys = useHrsFetch(() => window.sb.list("twofaSettingKeys", { limit: 20 }), []);
  const cur  = useHrsFetch(() => window.sb.list("skinSettings", { limit: 500, filters: { skin: skin.id } }),
                           [skin.id, tick]);
  const saved = React.useMemo(() => {
    const set = new Set((cur.data || []).map(r => String(r.setting)));
    const out = {};
    (keys.data || []).forEach(k => { out[k.key] = set.has(k.key); });
    return out;
  }, [cur.data, keys.data]);
  React.useEffect(() => { setDraft({ ...saved }); }, [skin.id, cur.data, keys.data]);

  const [counts, setCounts] = React.useState({});
  React.useEffect(() => {
    let dead = false;
    (async () => {
      const out = {};
      for (const k of (keys.data || [])) {
        const r = await window.sb.twofaAffectedCount(Number(skin.id), k.key);
        if (dead) return;
        out[k.key] = (r && r.ok) ? r.data : null;
      }
      if (!dead) setCounts(out);
    })();
    return () => { dead = true; };
  }, [keys.data, skin.id, tick]);

  const changed = React.useMemo(() => {
    if (!draft) return [];
    return (keys.data || []).filter(k => draft[k.key] !== saved[k.key]);
  }, [draft, saved, keys.data]);
  const affected = changed.reduce((n, k) => n + (counts[k.key] || 0), 0);

  const save = async () => {
    if (busy || !changed.length) return;
    setBusy(true); setErr(null); setMsg(null);
    const failed = [];
    for (const k of changed) {
      const r = await window.sb.setSkin2faSwitch(Number(skin.id), k.key, draft[k.key]);
      if (!r || !r.ok) failed.push(`${k.label}: ${(r && r.error && r.error.message) || "refused"}`);
    }
    setBusy(false);
    if (failed.length) setErr(failed.join(" · "));
    else setMsg(`Saved — ${changed.length} switch${changed.length === 1 ? "" : "es"} changed.`);
    setTick(t => t + 1);
  };

  if (keys.loading || cur.loading || !draft) return <div className="hrs-skel" style={{ height: 160 }} />;
  if (keys.error || cur.error) {
    return <HrsError error={keys.error || cur.error} onRetry={() => setTick(t => t + 1)} />;
  }

  return (
    <div style={{ display: "grid", gap: 14, maxWidth: 980 }}>
      <div className="hsk2-note">
        Only a super admin can see or change these. Every skin starts with all five
        off, and turning one off again <b>preserves</b> the users' existing
        enrolments in case it is turned back on.
      </div>

      <div className="hsk2-levels">
        {(keys.data || []).map(k => {
          const n = counts[k.key];
          return (
            <div key={k.key} className="hsk2-level">
              <span className="hsk2-level__n">
                {k.label} <code className="hsk-name">{n == null ? "…" : `${n} account${n === 1 ? "" : "s"}`}</code>
              </span>
              <HskSwitch value={!!draft[k.key]}
                onChange={v => setDraft(d => ({ ...d, [k.key]: v }))} />
            </div>
          );
        })}
      </div>

      <HskSaveBar onSave={save} label="Save two-factor" disabled={busy || !changed.length}
        note={changed.length
          ? `${changed.length} change${changed.length === 1 ? "" : "s"} · affects ${affected} account${affected === 1 ? "" : "s"} · set_skin_2fa_switch per switch`
          : "no changes · toggles apply on Save, like everything else on this tab"} />

      {msg && <div style={{ fontSize: 13, color: "var(--g-700, #15803d)" }}>{msg}</div>}
      {err && <div className="hu-errbox"><Icon name="alert" size={14} /> {err}</div>}
    </div>
  );
};



/* REGISTRATION TAB — the builder (071). Three decisions and a live preview.

   The preview is not decoration: this tab exists to be the spec the isystem
   frontend team builds from, and "what does the player see under this config"
   is the entire contract. The preview renders from the same resolver the spec
   defines (hsk2ResolveRegForm), so the builder cannot claim one form and
   describe another.

   FAST and TRADITIONAL reproduce isystem's two field files verbatim —
   fastplayer_fields.php (username + password) and player_fields.php (the
   17-field set, firstname/lastname commented out there and therefore optional
   here). CUSTOMISED is the new mode, where the matrix is the whole answer. */
const HSK2_REG_TRADITIONAL = {
  username: "required", password: "required", sex: "required",
  country_birth: "required", province_birth: "required", city_birth: "required",
  birthday: "required", email: "required", mobile: "required",
  address_residence: "required", zip_residence: "required",
  country_residence: "required", province_residence: "required", city_residence: "required",
  document_type: "required", document_number: "required", fiscal_code: "required",
  firstname: "optional", lastname: "optional", promoter_code: "optional",
};
const HSK2_REG_FAST = { username: "required", password: "required", promoter_code: "optional" };

/* The resolver — the one function the frontend reimplements. Given the config
   row, what fields does the form show and in what state? */
/* The phone field in the preview is INTERACTIVE where every other field is a
   dead box, because it demonstrates a contract the boxes cannot: the country
   drives the format (Arbi, 2026-08-16 — "Ethiopia gives an Ethiopian format").
   Pick a country, type digits, watch them shape themselves. src/phonelib.jsx
   is the demo table; the ticket sends production to a standard library. */
const Hsk2PhonePreview = ({ label, required, isUsername, otpReg, otpRec, countries, defaultIso }) => {
  /* The options are the brand's AUTHORIZED countries, not the whole library —
     the preview must offer exactly what the player will be offered. Countries
     the demo library has no format for are listed but fall back to bare
     digits; an empty authorized list falls back to the library so the field
     stays demonstrable. */
  const opts = React.useMemo(() => {
    const allowed = (countries || []).map(c => String(c).toUpperCase());
    if (!allowed.length) return PHLIB_COUNTRIES;
    const inLib = PHLIB_COUNTRIES.filter(c => allowed.includes(c[0]));
    return inLib.length ? inLib : PHLIB_COUNTRIES;
  }, [countries]);
  const first = (defaultIso && opts.find(c => c[0] === String(defaultIso).toUpperCase()))
    ? String(defaultIso).toUpperCase() : opts[0][0];
  const [iso, setIso] = React.useState(first);
  React.useEffect(() => { setIso(first); setVal(""); }, [first, (countries || []).join(",")]);
  const [val, setVal] = React.useState("");
  const c = PHLIB_COUNTRIES.find(x => x[0] === iso);
  const ok = val === "" || phlibValid(iso, val);
  return (
    <div>
      <div style={{ fontSize: 11.5, fontWeight: 600, marginBottom: 3 }}>
        {isUsername ? `${label} (this is the username)` : label}
        {required
          ? <span style={{ color: "var(--err, #dc2626)" }}> *</span>
          : <span style={{ color: "var(--n-600)", fontWeight: 400 }}> (optional)</span>}
      </div>
      <div style={{ display: "flex", gap: 6 }}>
        <select value={iso} onChange={e => { setIso(e.target.value); setVal(""); }}
          style={{ height: 30, borderRadius: 7, border: "1px solid var(--border-default)",
                   fontSize: 11.5, maxWidth: 130 }}>
          {opts.map(([i, name, dial]) => (
            <option key={i} value={i}>{name} +{dial}</option>
          ))}
        </select>
        <input value={phlibFormat(iso, val)} placeholder={phlibExample(iso)}
          onChange={e => setVal(e.target.value)}
          style={{ flex: 1, height: 30, borderRadius: 7, padding: "0 8px", fontSize: 12,
                   border: `1px solid ${ok ? "var(--border-default)" : "var(--err, #dc2626)"}` }} />
      </div>
      {!ok && <div style={{ fontSize: 10.5, color: "var(--err, #dc2626)", marginTop: 2 }}>
        Not a valid {c ? c[1] : ""} number — expected like {phlibExample(iso)}
      </div>}
      {(otpReg || otpRec) && <div style={{ fontSize: 10.5, color: "var(--n-600)", marginTop: 2 }}>
        {otpReg && otpRec ? "An OTP will be sent to verify this number — at registration, and again at password recovery."
         : otpReg ? "An OTP will be sent to verify this number at registration."
         : "This number receives an OTP at password recovery."}
      </div>}
    </div>
  );
};

const hsk2ResolveRegForm = (cfg, catalogue) => {
  const mode = cfg.mode || "traditional";
  const src = cfg.username_source || "free";
  let matrix = mode === "fast" ? { ...HSK2_REG_FAST }
             : mode === "traditional" ? { ...HSK2_REG_TRADITIONAL }
             : { ...(cfg.fields || {}) };
  /* THE PROMOTER CODE IS CONFIGURABLE IN EVERY MODE (Arbi, 2026-08-16) — the
     presets stop owning it. It defaults to optional because upstream's signup
     accepts a promoter code in every flow (it is how online registration
     attributes the player to a shop); an operator can still force it required
     or hide it, in Fast and Traditional included. */
  if (mode !== "customised") {
    matrix.promoter_code = (cfg.fields || {}).promoter_code || "optional";
  }
  /* The username_source substitution: phone/email means NO separate username
     box — that field IS the username, and it is required. */
  if (src === "phone") { delete matrix.username; matrix.mobile = "required"; }
  if (src === "email") { delete matrix.username; matrix.email = "required"; }
  const order = (catalogue || []).map(c => c.key);
  const rows = order
    .filter(k => matrix[k] && matrix[k] !== "off")
    .map(k => ({ key: k, state: matrix[k],
                 label: (catalogue.find(c => c.key === k) || {}).label || k,
                 isUsername: (src === "phone" && k === "mobile") ||
                             (src === "email" && k === "email") ||
                             (src === "free" && k === "username") }));
  /* THE USERNAME LEADS, whatever carries it. With username_source = phone the
     carrier is `mobile`, whose natural place is the contact block mid-form —
     and the first thing a signup form asks must be the thing the account IS.
     Part of the resolver, hence part of the frontend contract, not a styling
     choice in the preview. */
  return rows.sort((a, b) => (b.isUsername ? 1 : 0) - (a.isUsername ? 1 : 0));
};

/* The extras live in the same modal now (one step — owner, 2026-08-16) and the
   preview SHOWS them: the help button renders on the form mock, and the
   authorized-country list is exactly what the phone picker offers. The point
   of merging was that these were never "around" the form — they are ON it. */
const Hsk2RegistrationTab = ({ skin, helpButtons, otpReg, otpRec, allowedCountries, defaultCountry }) => {
  const [tick, setTick] = React.useState(0);
  const [busy, setBusy] = React.useState(false);
  const [msg, setMsg] = React.useState(null);
  const [err, setErr] = React.useState(null);
  const [cfg, setCfg] = React.useState(null);

  const cat = useHrsFetch(() => window.sb.list("registrationFieldCatalogue", { limit: 50 }), []);
  const feed = useHrsFetch(
    () => window.sb.list("skinRegistrationConfigs", { limit: 1, filters: { skin: skin.id } }),
    [skin.id, tick]);

  React.useEffect(() => {
    if (feed.loading || feed.error) return;
    const row = (feed.data || [])[0];
    /* The matrix the editor shows must BE what resolves — with the modes gone
       it is always visible, so a legacy fast/traditional row (or no row) opens
       with its preset EXPANDED into the matrix rather than an empty grid that
       contradicts the preview beside it. */
    const expanded = !row ? { ...HSK2_REG_TRADITIONAL }
                   : row.mode === "fast" ? { ...HSK2_REG_FAST, ...(row.fields || {}) }
                   : row.mode === "traditional" ? { ...HSK2_REG_TRADITIONAL, ...(row.fields || {}) }
                   : (row.fields || {});
    setCfg(row ? { mode: "customised", username_source: row.username_source,
                   methods: row.methods || ["username"], fields: expanded }
               : { mode: "customised", username_source: "free",
                   methods: ["username"], fields: { ...HSK2_REG_TRADITIONAL } });
  }, [feed.data, feed.loading, feed.error]);

  const save = async () => {
    if (busy || !cfg) return;
    setBusy(true); setErr(null); setMsg(null);
    const r = await window.sb.saveRegistrationConfig({
      /* Always 'customised' since the modes were removed — the matrix is the
         answer. Legacy rows saved as fast/traditional still resolve via the
         resolver's preset branches, so nothing breaks; they become customised
         the first time anyone saves them again. */
      skinId: Number(skin.id), mode: "customised", usernameSource: cfg.username_source,
      methods: cfg.methods, fields: cfg.fields,
    });
    setBusy(false);
    if (!r || !r.ok) { setErr((r && r.error && r.error.message) || "Refused."); return; }
    setMsg("Saved — the frontend picks this up on its next config fetch.");
    setTick(t => t + 1);
  };

  if ((feed.loading && !cfg) || cat.loading) return <div className="hrs-skel" style={{ height: 200 }} />;
  if (feed.error || cat.error) return <HrsError error={feed.error || cat.error} onRetry={() => setTick(t => t + 1)} />;
  if (!cfg) return null;

  const set = (patch) => setCfg(c => ({ ...c, ...patch }));
  const setField = (k, v) => setCfg(c => ({ ...c, fields: { ...c.fields, [k]: v } }));
  const toggleMethod = (m) => setCfg(c => {
    const has = (c.methods || []).includes(m);
    return { ...c, methods: has ? c.methods.filter(x => x !== m) : [...c.methods, m] };
  });
  const preview = hsk2ResolveRegForm(cfg, cat.data || []);
  const groups = {};
  (cat.data || []).forEach(c => { (groups[c.grp] = groups[c.grp] || []).push(c); });

  return (
    <div style={{ display: "grid", gridTemplateColumns: "minmax(0,1fr) 320px", gap: 18, alignItems: "start" }}>
      <div style={{ display: "grid", gap: 14 }}>
        <div className="hsk2-note">
          isystem today: the field set is a <b>PHP file on disk</b> — the same for every
          brand, changed by a deploy. Here there is <b>one form</b>, pre-filled with that
          standard set, and every field below is this brand's own decision.</div>

        <div>
          <div style={{ fontWeight: 600, marginBottom: 6 }}>What is the username?</div>
          <div style={{ display: "flex", gap: 8 }}>
            {[["free", "Free username", "a separate username field"],
              ["phone", "The phone number", "no username box — the phone IS the username"],
              ["email", "The email", "no username box — the email IS the username"]].map(([id, label, why]) => (
              <button key={id} title={why}
                className={`hsk2-langtab ${cfg.username_source === id ? "is-on" : ""}`}
                onClick={() => set({ username_source: id })}>{label}</button>
            ))}
          </div>
          {cfg.username_source !== "free" && (
            <div style={{ fontSize: 12, color: "var(--n-600)", marginTop: 6 }}>
              The {cfg.username_source === "phone" ? "phone" : "email"} field is forced
              <b> required</b> — the database refuses the contradiction, not this form.
            </div>
          )}
        </div>

        <div>
          <div style={{ fontWeight: 600, marginBottom: 6 }}>Register methods offered</div>
          <div style={{ display: "flex", gap: 8 }}>
            {/* Social is OUT of the picker for now (Arbi, 2026-08-16): the
               integration does not exist, and a tab that goes nowhere is a
               dead control. The database still accepts the value, so nothing
               breaks when it returns. */}
            {["phone", "username", "email"].map(m => (
              <button key={m}
                className={`hsk2-langtab ${(cfg.methods || []).includes(m) ? "is-on" : ""}`}
                style={{ textTransform: "capitalize" }}
                onClick={() => toggleMethod(m)}>{m}</button>
            ))}
          </div>
          <div style={{ fontSize: 12, color: "var(--n-600)", marginTop: 6 }}>
            The tabs the player sees (Phone / Username / Email — Social returns when the integration exists). At least one, and the
            username source must be among them — the database refuses a form that
            cannot be completed.
          </div>
        </div>

        {true && (
          <div style={{ display: "grid", gap: 10 }}>
            <div style={{ fontWeight: 600 }}>Field matrix</div>
            {Object.entries(groups).map(([grp, fields]) => (
              <div key={grp}>
                <div style={{ fontSize: 11, fontWeight: 700, textTransform: "uppercase",
                              letterSpacing: ".05em", color: "var(--n-600)", margin: "6px 0" }}>{grp}</div>
                <div style={{ display: "grid", gap: 6 }}>
                  {fields.map(f => {
                    const st = f.always_required ? "required" : (cfg.fields[f.key] || "off");
                    return (
                      <div key={f.key} style={{ display: "flex", alignItems: "center", gap: 10,
                                                padding: "6px 10px", border: "1px solid var(--border-default)",
                                                borderRadius: 8 }}>
                        <span style={{ flex: 1, fontSize: 13 }}>{f.label}
                          {f.note && <span style={{ color: "var(--n-600)", fontSize: 11 }}> — {f.note}</span>}
                        </span>
                        {f.always_required ? (
                          <span style={{ fontSize: 12, color: "var(--n-600)" }}>always required</span>
                        ) : (
                          ["required", "optional", "off"].map(v => (
                            <button key={v}
                              className={`hsk2-langtab ${st === v ? "is-on" : ""}`}
                              style={{ fontSize: 11, padding: "3px 10px" }}
                              onClick={() => setField(f.key, v)}>{v}</button>
                          ))
                        )}
                      </div>
                    );
                  })}
                </div>
              </div>
            ))}
          </div>
        )}

        <div style={{ display: "flex", gap: 10, alignItems: "center" }}>
          <button className="hrs-btn hrs-btn--filters hsk-save"
            disabled={busy} onClick={save}><Icon name="check" size={14} /> {busy ? "Saving…" : "Save registration form"}</button>
          {msg && <span style={{ fontSize: 13, color: "var(--g-700, #15803d)" }}>{msg}</span>}
        </div>
        {err && <div className="hu-errbox"><Icon name="alert" size={14} /> {err}</div>}
      </div>

      {/* THE PREVIEW — rendered from the same resolver the frontend implements,
          so this column IS the contract. */}
      <div style={{ border: "1px solid var(--border-default)", borderRadius: 12, padding: 16,
                    position: "sticky", top: 12 }}>
        <div style={{ fontWeight: 700, marginBottom: 4 }}>What the player sees</div>
        <div style={{ fontSize: 11, color: "var(--n-600)", marginBottom: 10 }}>
          {skin.name} · username = {cfg.username_source}
        </div>
        {(cfg.methods || []).length > 1 && (
          <div style={{ display: "flex", gap: 6, marginBottom: 10 }}>
            {(cfg.methods || []).map(m => (
              <span key={m} style={{ flex: 1, textAlign: "center", fontSize: 11, fontWeight: 600,
                                     padding: "6px 0", borderRadius: 8, textTransform: "capitalize",
                                     background: "var(--p-50)", color: "var(--p-700)" }}>{m}</span>
            ))}
          </div>
        )}
        <div style={{ display: "grid", gap: 8 }}>
          {preview.map(f => f.key === "mobile" ? (
            <Hsk2PhonePreview key={f.key} label={f.label}
              required={f.state === "required"} isUsername={f.isUsername}
              otpReg={otpReg} otpRec={otpRec}
              countries={allowedCountries} defaultIso={defaultCountry} />
          ) : (
            <div key={f.key}>
              <div style={{ fontSize: 11.5, fontWeight: 600, marginBottom: 3 }}>
                {f.isUsername && f.key !== "username" ? `${f.label} (this is the username)` : f.label}
                {f.state === "required"
                  ? <span style={{ color: "var(--err, #dc2626)" }}> *</span>
                  : <span style={{ color: "var(--n-600)", fontWeight: 400 }}> (optional)</span>}
              </div>
              <div style={{ height: 30, borderRadius: 7, border: "1px solid var(--border-default)",
                            background: "var(--n-25, #fafafa)" }} />
            </div>
          ))}
          <div style={{ fontSize: 11, color: "var(--n-600)" }}>
            ☐ I am over 18 and agree to the terms — always shown, not configurable.
          </div>
          {(helpButtons || []).length > 0 && (
            <div style={{ display: "grid", gap: 3 }}>
              {(helpButtons || []).map(h => (
                <div key={h.type} style={{ fontSize: 11, color: "var(--p-700)", display: "flex",
                                           alignItems: "center", gap: 5 }}>
                  <Icon name={h.type === "email" ? "mail" : h.type === "phone" ? "phone" : "info"} size={12} />
                  {h.type === "chat" ? "Need help? Support chat"
                    : h.value || (h.type === "email" ? "(email address below)" : "(phone number below)")}
                </div>
              ))}
              <div style={{ fontSize: 10.5, color: "var(--n-600)" }}>
                — the help buttons, as configured below; none selected = none shown
              </div>
            </div>
          )}
          <div style={{ height: 34, borderRadius: 8, background: "var(--p-500)", opacity: .85,
                        color: "#fff", display: "grid", placeItems: "center",
                        fontSize: 12.5, fontWeight: 700 }}>Sign up</div>
        </div>
      </div>
    </div>
  );
};

HSK2_REGISTER['two-factor'] = Hsk2TwoFactorTab;
HSK2_REGISTER.registration = Hsk2RegistrationTab;
if (window.HskTabRegistry) Object.assign(window.HskTabRegistry, HSK2_REGISTER);
else { window.HskTabRegistry = HSK2_REGISTER; console.warn("[HostSkinsUnifiedTabs] loaded before HostSkinsUnified.jsx — check the script order in index.html."); }

