// Represents: GET /providerpromotions · ProviderPromotionController; GET /promoplaypromotions · PromoPlayPromotionController — see docs/ISYSTEM_REFERENCE.md §Batch 4 "Provider + Promoplay promotions"
/* CMS ▾ → "Provider Promotions" (sidebar.blade.php:801-806, anchor /providerpromotions) and
   CMS ▾ → "Promoplay Promotions" (sidebar.blade.php:807-812, anchor /promoplaypromotions).
   The reference documents the two screens together as twins, so they are built together here:
   same list chrome, same hidden switch, same expired-only delete guard, same modal editor —
   different columns, different filters, different form tail.

   ── Routes / controllers ───────────────────────────────────────────────────────────────────
   Provider promotions   admin.providerpromotions.index      GET  /providerpromotions            routes/admin.php:1070
                         (unnamed)                           GET  /providerpromotions/form/                    :1073
                         (unnamed)                           POST /providerpromotions/savePromotion/           :1077
                         (unnamed)                           POST /providerpromotions/{id}/hidden              :1081
                         (unnamed)                           GET  /providerpromotions/delete/{id}/             :1085
                         + 4 AJAX pickers (getProvidersByCategory / getGamesByProviders /
                           getGamesByProvider / loadProviders)                                   :1090-1093
   Promoplay promotions  admin.promoplaypromotions.index     GET  /promoplaypromotions                         :1098
                         (unnamed)                           GET  /promoplaypromotions/form/                   :1101
                         (unnamed)                           POST /promoplaypromotions/savePromotion/          :1105
                         (unnamed)                           POST /promoplaypromotions/{id}/hidden             :1109
                         (unnamed)                           GET  /promoplaypromotions/delete/{id}/            :1113
                         + the pp-api proxy group                                                :1048-1054
   All of it sits inside Route::group(['admin','adminsettings']) (routes/admin.php:15) →
   Route::name('admin.')->middleware(['auth','admin','2fa','g2fa']) (:25).

   ── THE gotcha this screen exists to make visible ──────────────────────────────────────────
   `hidden` has DB default **1** on BOTH tables (migrations 2026_06_24_155202_create_provider_
   promotions_table.php:57 and _create_promoplay_promotions_table.php:35) and `savePromotion()`
   NEVER writes the column. So every promotion an operator creates is born invisible to players,
   and the ONLY thing that can reveal it is POST /<screen>/{id}/hidden — which is gated
   `isadmin()` and silently no-ops for everyone else, skin admins included. A skin admin can
   therefore create a promotion and has no way at all to publish it. The create modal states
   this up front (HppHiddenNotice), the save button says so, the post-create banner says so
   again, and hidden rows are visually damped in the list.
   <!-- SUGGESTION: either default `hidden` to 0 on both tables, or let savePromotion() write the column from the form so the operator who creates a promotion can decide its visibility. As shipped, a skin admin can create a provider/promoplay promotion but can never publish it — the only unhide path is an isadmin()-gated endpoint. -->
   <!-- SUGGESTION: give the toggleHidden endpoints a real 403 for non-isadmin callers instead of `return;` — today a skin admin's switch flips in the UI, the page reloads unchanged, and nothing tells them the write was dropped. -->

   ── Permission asymmetry, recorded honestly (surfaced in the gate hints) ────────────────────
   index                 isAdmin() || isSkinAdmin(), else abort(404)   ProviderPromotionController:83 / PromoPlayPromotionController:66
   toggleHidden          isadmin() only, silent no-op otherwise        :204 / :288
   delete                isadmin() || isSkinAdmin() + own-skin + expired-only   :216 / :300
   promotionForm/save    NO role gate at all (the FormRequests' authorize() return true)
   pp-api (5 endpoints)  NO role check inside PromoPlayController — any authenticated, 2FA'd
                         back-office session can read a player's PromoPlay balance, register
                         them, credit points, withdraw points and change their level. Consumed
                         by the Player 360 page (admin/players/template.blade.php:119-251).
   <!-- SUGGESTION: gate promotionForm/savePromotion on both controllers with the same isAdmin() || isSkinAdmin() check index() already runs (plus the skin-ownership check delete() runs) — the 404 on index protects nothing while the form and save URLs are open to every authenticated back-office user. -->
   <!-- SUGGESTION: put an isadmin()/isSkinAdmin() + skin-ownership check on the five /pp-api/player/{playerId}/* routes. add-balance and withdraw-balance move real PromoPlay points for an arbitrary player id and carry no role check whatsoever. -->

   ── Known real-platform defects, handled per the repo's known-bug policy ────────────────────
   1. Promoplay mobile image is stored with storeAs('public/promoplayPromotions/logo', …) (capital
      P, controller L437-438) while the saved URL is built with lowercase `promoplaypromotions`,
      so on a case-sensitive filesystem every mobile image 404s. Evident intent implemented here:
      one lowercase path for both write and URL. See HppImageField's promoplay note.
      <!-- SUGGESTION: fix PromoPlayPromotionController L437-438 to storeAs('public/promoplaypromotions/logo', …) so the stored path matches the URL that is written to the DB; existing rows need a one-off rename of storage/app/public/promoplayPromotions → promoplaypromotions. -->
   2. `remove_image` is coerced to a boolean by both FormRequests' prepareForValidation() and then
      never read by savePromotion(), so clearing an image in the KTImageInput widget does nothing —
      the old image survives the save. Evident intent implemented here: the clear button really
      clears the field.
      <!-- SUGGESTION: consume `remove_image` in both savePromotion() methods (null the img / img_mobile column and unlink the file) — the widget already posts it, the server just drops it. -->
   3. `hidden` is likewise merged by prepareForValidation() and never consumed — see above.

   ── Faithful to the real screens, deliberately NOT added ────────────────────────────────────
   · No sortable columns — both lists are a fixed ORDER BY id DESC (ProviderPromotionController:128,
     PromoPlayPromotionController:107). The ID header Tip says so.
   · No export (the only export UI in the tree is the unreferenced dead admin/providerpromotions/
     modals/list.blade.php, a copy of the Promotions activations list).
   · No bulk actions, no row-level duplicate/preview/activate.
   · No `featured` and no `button_url` inputs: both are commented out in the blade
     (index.blade.php form L293-303 / L246-254) while savePromotion still writes them, so from this
     form they are permanently 0 / null. Stated in the form's footer note, not rendered as fields.
   · No "General Odd Value / Min bet / Amount" sport-pivot inputs: ProviderPromotionBonus.js:77-107
     can render them but the blade's per-category $opts only ever sets show_cnt_selections
     (forms/promotion.blade.php:321-337), and the providersSettings[...] array savePromotion reads
     (L316) is never posted — those pivot columns are write-dead from this UI.
   · No Promoplay provider/game pickers: PromoplayPromotionGame / PromoplayPromotionProvider are
     imported by the controller (L18-19) and never used; that flow has no game linkage.
   · No Featured Large filter/column on Promoplay — it does not exist on that screen.
   · getGamesByProviders (ProviderPromotionController L592-598) joins skins_providers twice without
     an alias and would raise a duplicate-table SQL error; the shipped JS never calls it. Dead, so
     nothing here calls it either.
   · The legacy datetimepicker JS disables `start` in edit for promotype birthday/anniversary —
     values neither promoTypes() list offers. Vestigial, not reproduced.

   Label policy: `provider_promotions`, `new_provider_promotion`, `featured_large`, `hidden`,
   `show_expired_promotions`, `search`, `apply`, `results`, `showing`, `prev`, `next`, `delete`,
   `no_records`, `promoplay_promotions`, `new_promoplay_promotion` all resolve to raw `backend.*`
   keys in the committed lang file (storage/lang is gitignored), so the operator-facing wording
   below is inferred and marked with a "label inferred" JSX comment at each site. */

const { useState: hppUseState, useMemo: hppUseMemo } = React;

/* Deterministic PRNG (FNV-1a + mulberry32) — same convention as the sibling Host pages, so both
   lists and every generated provider/game set render identically on every load. */
const hppHash = (s) => { let h = 2166136261 >>> 0; for (let i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = Math.imul(h, 16777619); } return h >>> 0; };
const hppRng = pbRng;   // was a local copy of mulberry32 — see pbRng in src/data.jsx
/* Demo session. isadmin() → SUPER_ADMIN(0): sees the Skin column + Skin filter (both rendered only
   for $canPickSkin = isadmin), and is the only role whose Hidden switch actually writes. A skin
   admin would land on the same page with the skin scope forced to their own skin and a switch that
   silently does nothing — surfaced in the Hidden column Tip and the gate note. */
const HPP_ME = { id: 1, username: "iwadmin", roleName: "Super admin", isAdmin: true, skin: "Jokerenvivo" };

/* SkinsController::getSkinsList() — same operator skin vocabulary as HostUsers.jsx / HostSetSupportUsers.jsx. */
const HPP_SKINS = ["Jokerenvivo", "Donjoker", "Juegojoker", "Tucasino", "win24hs", "Jugaygana", "apuestadepana", "apostando365"];

/* LanguagesController::getLanguages() + the literal "ALL" option the form prepends (lang column is
   varchar(5) default 'all'). Codes mirror HostSetLanguages.jsx's seed table. */
const HPP_LANGS = [
  { code: "all", label: "ALL" },
  { code: "en", label: "English" }, { code: "es", label: "Español" }, { code: "pt", label: "Português" },
  { code: "pt_br", label: "Português-Brasil" }, { code: "it", label: "Italiano" }, { code: "fr", label: "Français" },
  { code: "de", label: "Deutsch" }, { code: "tr", label: "Türkçe" },
];
const hppLangLabel = (c) => (HPP_LANGS.find(l => l.code === c) || { label: c }).label;

/* GameCategoriesController::getCategoriesList() with poker (GameCategory::POKER = 5) skipped by the
   form (forms/promotion.blade.php). Ids are the model constants: CASINO=1, CASINO_LIVE=2,
   VIRTUAL=4, POKER=5, SPORT=6. */
const HPP_CATEGORIES = [
  { id: 1, name: "Casino" }, { id: 2, name: "Casino Live" }, { id: 4, name: "Virtual" }, { id: 6, name: "Sport" },
];
const hppCategoryName = (id) => (HPP_CATEGORIES.find(c => c.id === Number(id)) || { name: `#${id}` }).name;

/* `providers` rows per game category — what GET /providerpromotions/getProvidersByCategory returns
   for (category, skin). Ids/names are aligned with the other Host pages (HostSetVendorsGroups.jsx,
   HostReportBusiness.jsx) so a provider means the same thing across the prototype. */
const HPP_PROVIDERS_BY_CAT = {
  1: [
    { id: 101, name: "Pragmatic Play Slots" }, { id: 111, name: "Amusnet" }, { id: 121, name: "3Oaks" },
    { id: 132, name: "BGaming" }, { id: 133, name: "Belatra" }, { id: 136, name: "NetEnt" },
    { id: 137, name: "Novomatic" }, { id: 138, name: "Playson" }, { id: 139, name: "Wazdan" },
    { id: 177, name: "Play'n GO" }, { id: 180, name: "Habanero" }, { id: 181, name: "PG Soft" },
    { id: 182, name: "Endorphina" }, { id: 183, name: "Booming Games" },
  ],
  2: [
    { id: 102, name: "Pragmatic Play Live" }, { id: 112, name: "Amusnet Live" }, { id: 134, name: "Evolution" },
    { id: 141, name: "Ezugi" }, { id: 174, name: "Vivo Live" }, { id: 175, name: "Macaw" },
  ],
  4: [
    { id: 191, name: "Golden Race" }, { id: 192, name: "Virtual Pragmatic" }, { id: 193, name: "Leap Gaming" },
    { id: 194, name: "Betradar Virtuals" },
  ],
  6: [
    { id: 69, name: "Sportsbook" }, { id: 195, name: "Betxchange" }, { id: 196, name: "Altenar" },
  ],
};
const HPP_ALL_PROVIDERS = Object.keys(HPP_PROVIDERS_BY_CAT).reduce((acc, k) => acc.concat(HPP_PROVIDERS_BY_CAT[k]), []);
const hppProviderName = (id) => (HPP_ALL_PROVIDERS.find(p => p.id === Number(id)) || { name: `#${id}` }).name;

/* The picker labels every option "Name (Active|Inactive)" from `skins_providers` presence, i.e. the
   flag is per (provider, skin) — an Inactive provider can still be ticked, it just is not enabled
   for that skin. Deterministic so the label never flickers between renders. */
const hppProviderActive = (providerId, skin) => hppRng(hppHash(`skins_providers|${providerId}|${skin}`))() > 0.25;

/* `games` per provider — what GET /providerpromotions/getGamesByProvider returns. Deterministic
   slice of a shared title pool; ids are stable per (provider, index). */
const HPP_GAME_POOL = [
  "Gates of Olympus", "Sweet Bonanza", "Big Bass Splash", "Wolf Gold", "Fruit Party", "Sugar Rush",
  "Zeus vs Hades", "Starlight Princess", "Wild West Gold", "Book of Fallen", "Elvis Frog", "Aztec Magic",
  "Dead or Alive", "Gonzo's Quest", "Twin Spin", "Reactoonz", "Rise of Olympus", "Legacy of Dead",
  "Hot Fiesta", "Rocket Blast", "Buffalo King", "Money Train", "Crazy Time", "Lightning Roulette",
  "Blackjack Party", "Speed Baccarat", "Dragon Tiger", "Andar Bahar", "Mega Wheel", "Boom City",
  "Coin Strike", "Lucky Lady Moon", "Shining Crown", "Burning Hot", "Zombie Carnival", "Rush Fever",
];
const hppGamesFor = (providerId) => {
  const rnd = hppRng(hppHash(`games|${providerId}`));
  const n = 6 + Math.floor(rnd() * 7);
  const pool = HPP_GAME_POOL.slice();
  const out = [];
  for (let i = 0; i < n && pool.length; i++) {
    const idx = Math.floor(rnd() * pool.length);
    const title = pool.splice(idx, 1)[0];
    out.push({ id: providerId * 100 + i, name: title });
  }
  return out;
};
const hppGameName = (providerId, gameId) => {
  const hit = hppGamesFor(providerId).find(g => g.id === Number(gameId));
  return hit ? hit.name : `#${gameId}`;
};

/* promoTypes() — ProviderPromotionController L39-53 / PromoPlayPromotionController L39-51.
   Two different vocabularies; neither offers birthday/anniversary. */
const HPP_PROMOTYPES = [
  { value: "tournament", label: "Tournament" }, { value: "promotion", label: "Promotion" },
  { value: "freespins", label: "Free Spins" }, { value: "cashback", label: "Cashback" },
];
const HPP_PP_TYPES = [{ value: "type1", label: "Type 1" }, { value: "type2", label: "Type 2" }];
const hppTypeLabel = (list, v) => (list.find(t => t.value === v) || { label: v || "—" }).label;

const HPP_PAGE_SIZE = 25;                      // paginate(25) on both controllers
const HPP_DAY = 86400;
const HPP_NOW = Math.floor(Date.now() / 1000); // the `time()` both controllers compare `end` against

/* ---------- dates: unix columns (models set $dateFormat = 'U'), rendered j M Y, H:i ---------- */
const HPP_MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
const hppPad2 = (n) => String(n).padStart(2, "0");
const hppFmtDT = (unix) => {
  if (!unix) return "—";
  const d = new Date(unix * 1000);
  return `${d.getDate()} ${HPP_MONTHS[d.getMonth()]} ${d.getFullYear()}, ${hppPad2(d.getHours())}:${hppPad2(d.getMinutes())}`;
};
/* The real widget is a bootstrap datetimepicker text input with format dd/mm/yyyy hh:ii, parsed
   server-side by sistemadatatime(). The prototype uses a native datetime-local control (same value,
   fewer ways to mistype it) and echoes the platform's own format underneath so the wire format
   stays visible. */
const hppToInput = (unix) => {
  if (!unix) return "";
  const d = new Date(unix * 1000);
  return `${d.getFullYear()}-${hppPad2(d.getMonth() + 1)}-${hppPad2(d.getDate())}T${hppPad2(d.getHours())}:${hppPad2(d.getMinutes())}`;
};
const hppFromInput = (s) => { if (!s) return null; const t = new Date(s).getTime(); return isNaN(t) ? null : Math.floor(t / 1000); };
const hppPickerFmt = (unix) => {
  if (!unix) return "";
  const d = new Date(unix * 1000);
  return `${hppPad2(d.getDate())}/${hppPad2(d.getMonth() + 1)}/${d.getFullYear()} ${hppPad2(d.getHours())}:${hppPad2(d.getMinutes())}`;
};
const hppExpired = (row) => !!row.end && row.end < HPP_NOW;
const hppSlug = (s) => String(s).toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");

/* ------------------------------------------------------------------ *
 * Seed rows. `provider_promotions` / `promoplay_promotions`:
 * [id, name, skin, lang, category, promotype, featured_large, hidden, startOffsetDays,
 *  endOffsetDays, _order]. Offsets are relative to now, so the Valid/Expired split and the
 * "Show expired promotions = No" default behave the same whenever the prototype is opened.
 * ------------------------------------------------------------------ */
const HPP_PRV_SEED = [
  [418, "Drops & Wins — August", "Jokerenvivo", "es", 1, "tournament", 1, 0, -6, 24, 10],
  [417, "Cashback Semanal Casino", "Jokerenvivo", "es", 1, "cashback", 0, 0, -13, 17, 20],
  [416, "Ruleta en Vivo — Bono Bienvenida", "Jokerenvivo", "es", 2, "promotion", 1, 0, -3, 27, 5],
  [415, "Giros Gratis Pragmatic", "Donjoker", "es", 1, "freespins", 0, 1, -1, 44, 0],
  [414, "Torneo Amusnet Verano", "Donjoker", "es", 1, "tournament", 0, 0, -20, 10, 30],
  [413, "Virtuales — Reembolso 10%", "Donjoker", "es", 4, "cashback", 0, 1, -2, 61, 0],
  [412, "Copa Deportiva — Apuesta Gratis", "Juegojoker", "es", 6, "promotion", 1, 0, -9, 21, 15],
  [411, "Evolution Live Cashback", "Juegojoker", "es", 2, "cashback", 0, 0, -30, 3, 40],
  [410, "Free Spins Book of Fallen", "Juegojoker", "all", 1, "freespins", 0, 1, 0, 30, 0],
  [409, "Tucasino Weekend Tournament", "Tucasino", "en", 1, "tournament", 1, 0, -5, 9, 10],
  [408, "Tucasino Live Dealer Boost", "Tucasino", "en", 2, "promotion", 0, 0, -45, 15, 25],
  [407, "Golden Race Virtuals Race", "Tucasino", "en", 4, "tournament", 0, 1, -4, 40, 0],
  [406, "win24hs — Bono de Recarga", "win24hs", "es", 1, "promotion", 0, 0, -11, 19, 12],
  [405, "win24hs Sport Freebet", "win24hs", "es", 6, "promotion", 0, 1, -1, 55, 0],
  [404, "Jugaygana Slots Marathon", "Jugaygana", "es", 1, "tournament", 1, 0, -25, 6, 8],
  [403, "Jugaygana Cashback Live", "Jugaygana", "es", 2, "cashback", 0, 0, -60, 30, 18],
  [402, "Ruleta Relámpago", "apuestadepana", "es", 2, "promotion", 0, 1, -7, 23, 0],
  [401, "Tragamonedas Premium", "apuestadepana", "es", 1, "freespins", 0, 0, -14, 46, 22],
  [399, "Apostando365 Combinada Segura", "apostando365", "es", 6, "promotion", 1, 0, -8, 52, 6],
  [398, "Apostando365 Giros de Bienvenida", "apostando365", "es", 1, "freespins", 0, 1, -2, 88, 0],
  [397, "Torneo de Invierno (cerrado)", "Jokerenvivo", "es", 1, "tournament", 1, 0, -120, -60, 10],
  [396, "Cashback Julio (cerrado)", "Jokerenvivo", "es", 1, "cashback", 0, 0, -95, -35, 20],
  [395, "Promo Mundial (cerrada)", "Juegojoker", "es", 6, "promotion", 1, 1, -210, -150, 5],
  [394, "Free Spins Halloween (cerrada)", "Donjoker", "es", 1, "freespins", 0, 0, -300, -260, 0],
  [393, "Live Casino Kickoff (closed)", "Tucasino", "en", 2, "promotion", 0, 0, -180, -120, 15],
  [392, "Virtual Cup (closed)", "Tucasino", "en", 4, "tournament", 0, 1, -140, -110, 0],
  [391, "Recarga de Aniversario (cerrada)", "win24hs", "es", 1, "promotion", 0, 0, -75, -20, 9],
  [390, "Reembolso Deportivo (cerrado)", "Jugaygana", "es", 6, "cashback", 0, 0, -160, -100, 30],
  [389, "Slot Race Primavera (cerrada)", "apuestadepana", "es", 1, "tournament", 1, 1, -250, -220, 0],
  [388, "Bono Bienvenida 2025 (cerrado)", "apostando365", "es", 1, "promotion", 0, 0, -400, -330, 1],
];

const hppBuildProviderPromos = () => HPP_PRV_SEED.map(s => {
  const [id, name, skin, lang, cat, promotype, featured, hidden, so, eo, order] = s;
  const rnd = hppRng(hppHash(`provider_promotion|${id}|${name}`));
  const pool = HPP_PROVIDERS_BY_CAT[cat] || [];
  /* provider_promotions_providers pivot: at least one row (savePromotion rejects an empty list). */
  const nProv = 1 + Math.floor(rnd() * Math.min(4, pool.length));
  const providers = [];
  while (providers.length < nProv) { const p = pool[Math.floor(rnd() * pool.length)]; if (providers.indexOf(p.id) === -1) providers.push(p.id); }
  /* provider_promotions_games pivot: often empty — an empty selection means "all games of that
     provider", which is why most real rows carry no game rows at all. */
  const games = {};
  providers.forEach(pid => {
    if (rnd() > 0.55) {
      const gl = hppGamesFor(pid);
      const k = 1 + Math.floor(rnd() * Math.min(4, gl.length));
      const picked = [];
      while (picked.length < k) { const g = gl[Math.floor(rnd() * gl.length)]; if (picked.indexOf(g.id) === -1) picked.push(g.id); }
      games[pid] = picked;
    }
  });
  return {
    id, name, skin, lang, game_category: cat, promotype,
    featured_large: featured, hidden, _order: order,
    start: HPP_NOW + so * HPP_DAY, end: HPP_NOW + eo * HPP_DAY,
    providers, games,
    breve_descrizione: `${name} — condiciones y fechas en la página de la promoción.`,
    descrizione: `<p>${name}</p><p>Participa jugando en los proveedores seleccionados durante el periodo de la promoción. Se aplican los términos y condiciones generales.</p>`,
    img: `/storage/providerpromotions/logo/${hppSlug(name)}.png`,
    img_mobile: `/storage/providerpromotions/logo/${hppSlug(name)}_mobile.png`,
  };
});

/* promoplay_promotions — no lang, no category, no provider/game pivots; three PromoPlay campaign
   columns instead. */
const HPP_PP_SEED = [
  [212, "PromoPlay Free Games — Agosto", "Jokerenvivo", "type1", 0, -5, 25, 10, 4821, "free-games-agosto", "FG-{{current-date}}"],
  [211, "PromoPlay Shop — Puntos Dobles", "Jokerenvivo", "type2", 0, -12, 18, 20, 4822, "shop-puntos-dobles", ""],
  [210, "Misiones Semanales", "Donjoker", "type1", 1, -1, 41, 0, 4830, "misiones-semanales", "MS-{{current-date}}"],
  [209, "PromoPlay Bienvenida", "Donjoker", "type2", 0, -22, 8, 15, 4831, "bienvenida", ""],
  [208, "Free Games Fin de Semana", "Juegojoker", "type1", 0, -3, 11, 5, 4840, "free-games-finde", "FGW-{{current-date}}"],
  [207, "Torneo de Puntos", "Juegojoker", "type1", 1, 0, 60, 0, 4841, "torneo-de-puntos", ""],
  [206, "PromoPlay Loyalty Shop", "Tucasino", "type2", 0, -40, 20, 30, 4850, "loyalty-shop", ""],
  [205, "Daily Missions", "Tucasino", "type1", 0, -6, 24, 12, 4851, "daily-missions", "DM-{{current-date}}"],
  [204, "Puntos por Depósito", "win24hs", "type2", 1, -2, 33, 0, 4860, "puntos-por-deposito", ""],
  [203, "Ruleta de Premios", "Jugaygana", "type1", 0, -9, 14, 8, 4870, "ruleta-de-premios", "RP-{{current-date}}"],
  [202, "Free Games Julio (cerrada)", "Jokerenvivo", "type1", 0, -95, -35, 10, 4810, "free-games-julio", ""],
  [201, "Misiones de Verano (cerradas)", "Donjoker", "type1", 0, -150, -100, 0, 4811, "misiones-verano", "MV-{{current-date}}"],
  [200, "Shop Aniversario (cerrada)", "Juegojoker", "type2", 1, -220, -190, 20, 4812, "shop-aniversario", ""],
  [199, "Winter Missions (closed)", "Tucasino", "type1", 0, -300, -250, 0, 4813, "winter-missions", ""],
  [198, "Puntos Dobles Marzo (cerrada)", "apuestadepana", "type2", 0, -170, -140, 5, 4814, "puntos-dobles-marzo", ""],
  [197, "Free Games 2025 (cerrada)", "apostando365", "type1", 1, -410, -350, 0, 4815, "free-games-2025", ""],
];

const hppBuildPpPromos = () => HPP_PP_SEED.map(s => {
  const [id, name, skin, promotype, hidden, so, eo, order, campId, campSlug, freeKey] = s;
  return {
    id, name, skin, promotype, hidden, _order: order,
    start: HPP_NOW + so * HPP_DAY, end: HPP_NOW + eo * HPP_DAY,
    pp_campaign_id: campId, pp_campaign_slug: campSlug, pp_free_sub_key: freeKey,
    breve_descrizione: `${name} — canjea tus puntos PromoPlay.`,
    descrizione: `<p>${name}</p><p>Acumula puntos jugando y canjéalos en PromoPlay durante el periodo de la campaña.</p>`,
    img: `/storage/promoplaypromotions/logo/${hppSlug(name)}.png`,
    /* Evident intent, not the shipped casing — see known bug 1 in the header block. */
    img_mobile: `/storage/promoplaypromotions/logo/${hppSlug(name)}_mobile.png`,
  };
});

/* ================================================================== *
 * Shared atoms
 * ================================================================== */

/* Modal chrome — .bp-modal shell, full-screen on mobile (brief §11). The real thing is
   generaModalGestione() → admin/utils/modal.blade.php (modal `gestionePromotionModal`, body
   AJAX-loaded from /<screen>/form). */
const HppModal = ({ title, sub, onClose, children, footer, wide }) => (
  <div className="bp-modal-scrim hpp-scrim" onClick={onClose}>
    <div className={`bp-modal hpp-modal${wide ? " hpp-modal--wide" : ""}`} onClick={e => e.stopPropagation()}>
      <div className="hpp-modal__head">
        <div>
          <div className="hpp-modal__title">{title}</div>
          {sub && <div className="hpp-modal__sub">{sub}</div>}
        </div>
        <button className="hpp-x" title="Close" onClick={onClose}><Icon name="x" size={14} /></button>
      </div>
      <div className="hpp-modal__body">{children}</div>
      {footer && <div className="hpp-modal__foot">{footer}</div>}
    </div>
  </div>
);

/* Sectioned config panel + helper callout — the Settings.jsx editor shape. */
const HppSection = ({ title, sub, children }) => (
  <div className="hpp-sec">
    <div className="hpp-sec__head">
      <div className="hpp-sec__title">{title}</div>
      {sub && <div className="hpp-sec__sub">{sub}</div>}
    </div>
    <div className="hpp-sec__body">{children}</div>
  </div>
);

const HppNote = ({ tone = "info", icon = "info", children }) => (
  <div className={`hpp-note hpp-note--${tone}`}>
    <Icon name={icon} size={13} />
    <div>{children}</div>
  </div>
);

const HppField = ({ label, required, uiOnly, htmlFor, error, hint, children }) => (
  <div className="hpp-field">
    <label className="hpp-label" htmlFor={htmlFor}>
      {label}
      {required && <span className="hpp-req">*</span>}
      {uiOnly && <span className="hpp-uionly" title="The blade marks this field required via obbligatorio(), but savePromotion never checks it — a save without it succeeds.">UI-required only</span>}
    </label>
    {children}
    {error && <div className="hpp-fielderr">{error}</div>}
    {hint && <div className="hpp-hint">{hint}</div>}
  </div>
);

const HppSkinCell = ({ skin }) => (
  <span className="hpp-skin">
    <span className="hpp-skin__av">{String(skin || "?").slice(0, 2).toUpperCase()}</span>
    <span className="hpp-skin__n">{skin || "—"}</span>
  </span>
);

const HppLangChip = ({ code }) => <span className={`hpp-lang${code === "all" ? " hpp-lang--all" : ""}`}>{String(code || "").toUpperCase()}</span>;

const HppYesNo = ({ on }) => <span className={`hpp-yn${on ? " hpp-yn--y" : ""}`}>{on ? "Yes" : "No"}</span>;

/* Derived validity — `end >= time()` → backend.promo_valid "Valid", else backend.promo_expired
   "Expired". Not a stored column. */
const HppValidChip = ({ row }) => hppExpired(row)
  ? <span className="hpp-valid hpp-valid--exp">Expired</span>
  : <span className="hpp-valid hpp-valid--ok">Valid</span>;

/* Inverted switch: checked = visible (hidden = 0). Writes POST /<screen>/{id}/hidden, isadmin() only. */
const HppHiddenSwitch = ({ row, onToggle, canToggle = true }) => (
  <label className={`hpp-switch${canToggle ? "" : " hpp-switch--locked"}`}
    title={canToggle
      ? (row.hidden ? "Hidden from players — switch on to publish" : "Visible to players — switch off to hide")
      : "Only isadmin() can write this — for any other role the endpoint returns silently and the page reloads unchanged"}>
    <input type="checkbox" checked={row.hidden === 0} disabled={!canToggle} onChange={() => onToggle(row)} />
    <span className="hpp-switch__track"><span className="hpp-switch__knob" /></span>
    <span className="hpp-switch__txt">{row.hidden ? "Hidden" : "Visible"}</span>{/* label inferred */}
  </label>
);

/* Status chip strip — All / Visible / Hidden with counts, acting as an alternate `hidden` filter
   that preserves the other params (index.blade.php:110-120, $summary controller L133-140). */
const HppChips = ({ counts, value, onPick }) => {
  const items = [
    { v: "", label: "All", n: counts.all },
    { v: "0", label: "Visible", n: counts.visible },   /* backend.visible = "Visible" */
    { v: "1", label: "Hidden", n: counts.hidden },     /* label inferred */
  ];
  return (
    <div className="hpp-chips">
      {items.map(it => (
        <button key={it.v || "all"} type="button"
          className={`hpp-chip${String(value) === it.v ? " hpp-chip--on" : ""} hpp-chip--${it.v === "1" ? "hidden" : it.v === "0" ? "visible" : "all"}`}
          onClick={() => onPick(it.v)}>
          {it.label}<span className="hpp-chip__n">{hrsInt(it.n)}</span>
        </button>
      ))}
      <span className="hpp-chips__note">
        Chips are the same <code>hidden</code> filter as the select — the real page renders them as links that keep every other query param.
      </span>
    </div>
  );
};

/* THE gotcha, stated where it cannot be missed: at the top of every create form. */
const HppHiddenNotice = ({ table }) => (
  <div className="hpp-hiddenwarn">
    <div className="hpp-hiddenwarn__icon"><Icon name="eye" size={16} /></div>
    <div>
      <div className="hpp-hiddenwarn__t">This promotion will be created <u>hidden</u>. Players will not see it.</div>
      <div className="hpp-hiddenwarn__b">
        <code>{table}.hidden</code> has DB default <b>1</b> and <code>savePromotion()</code> never writes the column —
        there is deliberately no visibility field in this form. After saving, the only way to publish it is the
        <b> Hidden switch</b> in the list, which posts <code>{`POST /${table === "provider_promotions" ? "providerpromotions" : "promoplaypromotions"}/{id}/hidden`}</code> and
        is gated <code>isadmin()</code>. A Skin admin can create this promotion but cannot publish it.
      </div>
    </div>
  </div>
);

/* Post-create banner above the list — repeats the same fact against the row that was just made. */
const HppCreatedBanner = ({ created, onDismiss }) => (
  <div className="hpp-created">
    <Icon name="alert" size={14} />
    <div>
      <b>#{created.id} “{created.name}” was created hidden.</b> It is stored, it is listed below with a damped row,
      and it is invisible on the player frontend until the <b>Hidden</b> switch on its row is turned on.
    </div>
    <button className="hpp-created__x" title="Dismiss" onClick={onDismiss}><Icon name="x" size={12} /></button>
  </div>
);

/* Image field. KTImageInput, accept .png,.jpg,.jpeg. The upload itself is out of scope for the
   prototype — picking a file just records its name, as the real widget records the stored URL. */
const HppImageField = ({ label, value, onChange, hint, uiOnly }) => (
  <HppField label={label} uiOnly={uiOnly} hint={hint}>
    <div className="hpp-img">
      <div className="hpp-img__box">
        {value ? <span className="hpp-img__name" title={value}>{value.split("/").pop()}</span> : <span className="hpp-img__ph">No image</span>}
      </div>
      <div className="hpp-img__acts">
        <label className="btn btn--secondary btn--sm hpp-img__pick">
          <Icon name="upload" size={12} /> {value ? "Replace" : "Choose file"}
          <input type="file" accept=".png,.jpg,.jpeg" style={{ display: "none" }}
            onChange={e => { const f = e.target.files && e.target.files[0]; if (f) onChange(`/storage/${hppSlug(label)}/${f.name}`); }} />
        </label>
        {/* KNOWN BUG — DIVERGENCE: the widget's clear button posts `remove_image`, which
            prepareValidation() coerces to a boolean and savePromotion() then never reads, so on the
            real platform clearing an image does nothing. Evident intent implemented: it clears. */}
        {value && <button type="button" className="btn btn--ghost btn--sm" onClick={() => onChange("")}><Icon name="x" size={12} /> Clear</button>}
      </div>
    </div>
  </HppField>
);

/* ------------------------------------------------------------------ *
 * providers_list[] — loumultiselect fed by GET /providerpromotions/
 * getProvidersByCategory (per category + skin), each option labelled
 * "Name (Active|Inactive)" from skins_providers presence. Select all /
 * deselect all links are part of the real form.
 * ------------------------------------------------------------------ */
const HppProviderPicker = ({ category, skin, value, onChange, invalid }) => {
  const [q, setQ] = hppUseState("");
  const pool = HPP_PROVIDERS_BY_CAT[Number(category)] || [];
  const shown = pool.filter(p => !q || p.name.toLowerCase().includes(q.trim().toLowerCase()));
  const sel = value.map(Number);
  const toggle = (id) => onChange(sel.indexOf(id) === -1 ? [...sel, id] : sel.filter(x => x !== id));

  if (!category) return <div className="hpp-picker hpp-picker--empty">Pick a game category first — the provider list is loaded per category and skin.</div>;

  return (
    <div className={`hpp-picker${invalid ? " hpp-picker--err" : ""}`}>
      <div className="hpp-picker__bar">
        <input className="input hpp-picker__search" placeholder="Search providers…" value={q} onChange={e => setQ(e.target.value)} />
        <button type="button" className="hpp-link" onClick={() => onChange(pool.map(p => p.id))}>Select all</button>
        <button type="button" className="hpp-link" onClick={() => onChange([])}>Deselect all</button>
        <span className="hpp-picker__count">{sel.length} / {pool.length}</span>
      </div>
      <div className="hpp-picker__list">
        {shown.length === 0 && <div className="hpp-picker__none">No provider matches.</div>}
        {shown.map(p => {
          const active = hppProviderActive(p.id, skin);
          return (
            <label key={p.id} className={`hpp-opt${sel.indexOf(p.id) !== -1 ? " hpp-opt--on" : ""}`}>
              <input type="checkbox" checked={sel.indexOf(p.id) !== -1} onChange={() => toggle(p.id)} />
              <span className="hpp-opt__n">{p.name}</span>
              <span className={`hpp-pstate${active ? " hpp-pstate--on" : ""}`}>{active ? "Active" : "Inactive"}</span>
            </label>
          );
        })}
      </div>
    </div>
  );
};

/* games_list[{provider_id}][] — one accordion card per selected provider, options from
   GET /providerpromotions/getGamesByProvider. Empty selection = every game of that provider. */
const HppGamesAccordion = ({ providers, value, onChange }) => {
  const [open, setOpen] = hppUseState({});
  if (!providers.length) return <div className="hpp-games hpp-games--empty">Select at least one provider to narrow the promotion down to specific games.</div>;
  return (
    <div className="hpp-games">
      {providers.map(pid => {
        const list = hppGamesFor(pid);
        const sel = (value[pid] || []).map(Number);
        const isOpen = !!open[pid];
        const toggleGame = (gid) => {
          const next = sel.indexOf(gid) === -1 ? [...sel, gid] : sel.filter(x => x !== gid);
          const patch = { ...value };
          if (next.length) patch[pid] = next; else delete patch[pid];
          onChange(patch);
        };
        return (
          <div key={pid} className={`hpp-gcard${isOpen ? " hpp-gcard--open" : ""}`}>
            <button type="button" className="hpp-gcard__head" onClick={() => setOpen(o => ({ ...o, [pid]: !o[pid] }))}>
              <Icon name={isOpen ? "chevron_down" : "chevron_right"} size={12} />
              <span className="hpp-gcard__n">{hppProviderName(pid)}</span>
              <span className={`hpp-gcard__c${sel.length ? " hpp-gcard__c--set" : ""}`}>{sel.length ? `${sel.length} game(s)` : "All games"}</span>
            </button>
            {isOpen && (
              <div className="hpp-gcard__body">
                {list.map(g => (
                  <label key={g.id} className={`hpp-opt${sel.indexOf(g.id) !== -1 ? " hpp-opt--on" : ""}`}>
                    <input type="checkbox" checked={sel.indexOf(g.id) !== -1} onChange={() => toggleGame(g.id)} />
                    <span className="hpp-opt__n">{g.name}</span>
                  </label>
                ))}
                {sel.length > 0 && <button type="button" className="hpp-link hpp-gcard__clear" onClick={() => { const patch = { ...value }; delete patch[pid]; onChange(patch); }}>Clear — back to all games</button>}
              </div>
            )}
          </div>
        );
      })}
    </div>
  );
};

/* ------------------------------------------------------------------ *
 * Delete — GET /<screen>/delete/{id}/ behind deleteConfirm(). Gated
 * isadmin() || isSkinAdmin() with an own-skin check, and refused unless
 * the promotion has already expired ("Cannot delete an active
 * (non-expired) promotion"). The refusal is the whole point of the
 * dialog, so it is shown the way the server produces it: on submit.
 * ------------------------------------------------------------------ */
const HppDeleteDialog = ({ row, kind, onClose, onDelete }) => {
  const [refused, setRefused] = hppUseState(false);
  const expired = hppExpired(row);
  const base = kind === "provider" ? "providerpromotions" : "promoplaypromotions";
  const run = () => {
    if (!expired) { setRefused(true); return; }
    onDelete(row);
    hrsToast(`Promotion #${row.id} deleted`, kind === "provider"
      ? "Row + provider_promotions_providers + provider_promotions_games removed in one transaction; pro_promotion_providers_and_games and user_promotions caches flushed."
      : "promoplay_promotions row removed. Plain $promotion->delete() — no pivot cleanup and no cache flush on this screen.");
    onClose();
  };
  return (
    <HppModal title={refused ? "Delete refused" : "Delete promotion"} onClose={onClose}
      footer={refused
        ? <button className="btn btn--secondary" onClick={onClose}>Close</button>
        : <>
          <button className="btn btn--secondary" onClick={onClose}>Cancel</button>
          <button className="btn btn--danger" onClick={run}><Icon name="trash" size={13} /> Delete</button>
        </>}>
      {!refused ? (
        <>
          <div className="hpp-dlgq">Are you sure?{/* backend.are_you_sure */}</div>
          <div className="hpp-dlgrow"><b>#{row.id}</b> {row.name} · {row.skin} · ends {hppFmtDT(row.end)}</div>
          <HppNote tone={expired ? "info" : "warn"} icon={expired ? "info" : "alert"}>
            {expired
              ? <>This promotion has expired (<code>end &lt; time()</code>), so the server will accept the delete. {kind === "provider"
                ? <>It removes the row plus its <code>provider_promotions_providers</code> and <code>provider_promotions_games</code> pivot rows inside a transaction, then flushes the <code>pro_promotion_providers_and_games:{row.id}</code> and <code>user_promotions:{"{skin_id}"}*</code> caches.</>
                : <>It is a plain <code>$promotion-&gt;delete()</code> — no pivot cleanup (this flow has no pivots) and <b>no cache flush</b>.</>}</>
              : <>This promotion is still active, so the server will refuse. Delete only works once <code>end</code> is in the past.</>}
          </HppNote>
          <div className="hpp-hint">The real UI sends a plain <code>GET /{base}/delete/{row.id}/</code> behind a JS confirm.</div>
        </>
      ) : (
        <>
          <div className="hpp-err"><Icon name="alert" size={13} /> Cannot delete an active (non-expired) promotion</div>
          <div className="hpp-hint">
            The expired-only guard is the whole promotions family's convention (Promotions, Provider promotions,
            Promoplay promotions, Promo Triggers): <code>delete()</code> compares <code>end</code> to <code>time()</code> and
            answers <code>ajaxError</code> for anything still running. <b>#{row.id}</b> ends {hppFmtDT(row.end)} and was not deleted.
          </div>
          <div className="hpp-hint">
            There is no deactivate action either — to take a live promotion off the frontend an operator either edits
            its <code>end</code> date into the past, or hides it with the <b>Hidden</b> switch (isadmin() only).
          </div>
        </>
      )}
    </HppModal>
  );
};

/* ================================================================== *
 * Provider promotion editor — GET /providerpromotions/form?id= then
 * POST /providerpromotions/savePromotion/. Neither endpoint has any
 * role gate. Section grouping is a prototype presentation choice; the
 * fields, their order and their validation are the blade's.
 * ================================================================== */
const HppProviderForm = ({ row, onClose, onSave }) => {
  const isNew = !row;
  const [name, setName] = hppUseState(row ? row.name : "");
  const [skin, setSkin] = hppUseState(row ? row.skin : "");
  const [lang, setLang] = hppUseState(row ? row.lang : "");
  const [img, setImg] = hppUseState(row ? row.img : "");
  const [imgMobile, setImgMobile] = hppUseState(row ? row.img_mobile : "");
  const [short, setShort] = hppUseState(row ? row.breve_descrizione : "");
  const [desc, setDesc] = hppUseState(row ? row.descrizione : "");
  const [cat, setCat] = hppUseState(row ? String(row.game_category) : "");
  const [providers, setProviders] = hppUseState(row ? row.providers.slice() : []);
  const [games, setGames] = hppUseState(row ? { ...row.games } : {});
  const [promotype, setPromotype] = hppUseState(row ? row.promotype : "");
  const [order, setOrder] = hppUseState(row ? String(row._order) : "0");
  const [start, setStart] = hppUseState(row ? row.start : null);
  const [end, setEnd] = hppUseState(row ? row.end : null);
  const [featured, setFeatured] = hppUseState(row ? !!row.featured_large : false);
  const [errs, setErrs] = hppUseState({});
  const [banner, setBanner] = hppUseState("");

  const clearErr = (k) => { setErrs(x => { const n = { ...x }; delete n[k]; return n; }); setBanner(""); };

  /* Validation order + messages copied from savePromotion L271-364 (the FormRequest only covers
     name/breve_descrizione/descrizione; everything else is inline). The endpoint answers
     ajaxError($message, ["campierrati" => [...]]) — one banner message plus the offending field
     list, which the shared modal turns into red outlines. */
  const save = () => {
    const e = {};
    if (!name.trim()) e.name = "Insert name";
    else if (name.trim().length > 255) e.name = "The name may not be greater than 255 characters.";
    if (!skin) e.skin_id = "Select skin";
    if (!lang) e.lang = "Select language";
    if (!short.trim()) e.breve_descrizione = "Insert short description";
    if (!desc.trim()) e.descrizione = "Insert description";
    if (!cat) e.game_category = "Insert wagering product";
    if (!providers.length) e.providers_list = "Select at least one provider";
    if (!promotype) e.promotype = "Select typology";
    if (!start) e.start = "Fill in the start date field";
    if (!end) e.end = "Fill in the end date field";
    setErrs(e);
    const first = ["name", "skin_id", "lang", "breve_descrizione", "descrizione", "game_category", "providers_list", "promotype", "start", "end"]
      .map(k => e[k]).filter(Boolean)[0];
    if (first) { setBanner(first); return; }
    setBanner("");
    onSave({
      id: isNew ? null : row.id, name: name.trim(), skin, lang, img, img_mobile: imgMobile,
      breve_descrizione: short.trim(), descrizione: desc.trim(), game_category: Number(cat),
      providers: providers.map(Number), games, promotype, _order: Number(order || 0),
      start, end, featured_large: featured ? 1 : 0,
      /* hidden is NOT part of the payload — savePromotion never writes it. New rows take the DB
         default 1; existing rows keep whatever the toggle endpoint last wrote. */
      hidden: isNew ? 1 : row.hidden,
    });
    onClose();
  };

  return (
    <HppModal wide onClose={onClose}
      title={isNew ? <>New Provider Promotion{/* label inferred — backend.new_provider_promotion is missing */}</> : `Edit ${row.name}`}
      sub={isNew ? "POST /providerpromotions/savePromotion/ — no role gate on this endpoint" : `provider_promotions #${row.id} · POST /providerpromotions/savePromotion/?id=${row.id}`}
      footer={<>
        <button className="btn btn--secondary" onClick={onClose}>Close</button>
        <button className="btn btn--primary" onClick={save}>
          <Icon name="check" size={13} /> {isNew ? "Save — creates it hidden" : "Save"}
        </button>
      </>}>

      {isNew && <HppHiddenNotice table="provider_promotions" />}
      {!isNew && (
        <HppNote tone={row.hidden ? "warn" : "info"} icon={row.hidden ? "eye" : "info"}>
          Currently <b>{row.hidden ? "Hidden" : "Visible"}</b>. Visibility is not editable here on purpose — the form has no
          <code> hidden</code> field and the save endpoint never writes the column. Use the switch on the list row
          (<code>{"POST /providerpromotions/{id}/hidden"}</code>, <code>isadmin()</code> only).
        </HppNote>
      )}
      {banner && <div className="hpp-err hpp-err--banner"><Icon name="alert" size={13} /> {banner}</div>}

      <HppSection title="Details" sub="Who the promotion belongs to and in which language it is served."> {/* grouping inferred */}
        <HppField label="Name" required htmlFor="hpp-p-name" error={errs.name}>
          <input id="hpp-p-name" className={`input${errs.name ? " hpp-invalid" : ""}`} style={{ width: "100%" }} autoFocus
            maxLength={300} value={name} onChange={e => { setName(e.target.value); clearErr("name"); }} />
        </HppField>

        {/* Rendered only for isadmin(); a skin admin never sees this and the controller forces
            skin_id to their own skin (L277). */}
        {HPP_ME.isAdmin && (
          <HppField label="Skin" required htmlFor="hpp-p-skin" error={errs.skin_id}
            hint={<>Options come from <code>SkinsController::getSkinsList()</code>. For a Skin admin the select is not rendered at all and the server writes their own <code>skin_id</code>.</>}>
            <select id="hpp-p-skin" className={`select${errs.skin_id ? " hpp-invalid" : ""}`} style={{ width: "100%" }}
              value={skin} onChange={e => { setSkin(e.target.value); clearErr("skin_id"); setProviders([]); setGames({}); }}>
              <option value="">Select skin</option>
              {HPP_SKINS.map(s => <option key={s} value={s}>{s}</option>)}
            </select>
          </HppField>
        )}

        <HppField label="Language" required htmlFor="hpp-p-lang" error={errs.lang}
          hint={<><code>provider_promotions.lang</code> is a varchar(5) defaulting to <code>all</code>. "ALL" serves the promotion to every language of the skin.</>}>
          <select id="hpp-p-lang" className={`select${errs.lang ? " hpp-invalid" : ""}`} style={{ width: "100%" }}
            value={lang} onChange={e => { setLang(e.target.value); clearErr("lang"); }}>
            <option value="">Select language</option>
            {HPP_LANGS.map(l => <option key={l.code} value={l.code}>{l.code === "all" ? "ALL" : `${l.label} (${l.code})`}</option>)}
          </select>
        </HppField>
      </HppSection>

      <HppSection title="Media" sub="Stored under storage/providerpromotions/logo; the saved column holds the public URL."> {/* grouping inferred */}
        <HppImageField label="Image [Desktop]" value={img} onChange={setImg} uiOnly
          hint={<>Accepts <code>.png .jpg .jpeg</code>. The blade marks it required through <code>obbligatorio()</code>, but <code>savePromotion</code> never validates it — a save with no image succeeds and the frontend card renders without one.</>} />
        <HppImageField label="Image [Mobile]" value={imgMobile} onChange={setImgMobile} uiOnly
          hint={<>Same handling with a <code>_mobile</code> suffix.</>} />
      </HppSection>

      <HppSection title="Content" sub="What the player reads on the promotion card and on its detail page."> {/* grouping inferred */}
        <HppField label="Short description" required htmlFor="hpp-p-short" error={errs.breve_descrizione}
          hint="Shown on the promotion card in the frontend list.">
          <textarea id="hpp-p-short" rows={2} className={`input${errs.breve_descrizione ? " hpp-invalid" : ""}`} style={{ width: "100%" }}
            value={short} onChange={e => { setShort(e.target.value); clearErr("breve_descrizione"); }} />
        </HppField>
        <HppField label="Complete description" required htmlFor="hpp-p-desc" error={errs.descrizione}
          hint={<>A TinyMCE rich-text field on the real form — HTML is stored verbatim, so the raw markup is shown here rather than a fake editor.</>}>
          <textarea id="hpp-p-desc" rows={5} className={`input hpp-mono${errs.descrizione ? " hpp-invalid" : ""}`} style={{ width: "100%" }}
            value={desc} onChange={e => { setDesc(e.target.value); clearErr("descrizione"); }} />
        </HppField>
      </HppSection>

      <HppSection title="Targeting" sub="Which product, which providers and (optionally) which games the promotion applies to."> {/* grouping inferred */}
        <HppField label="Game category" required htmlFor="hpp-p-cat" error={errs.game_category}
          hint={<>The error message for a missing value is <code>backend.insert_wagering_product</code> — "Insert wagering product". Poker (category 5) is deliberately skipped by this form.</>}>
          <select id="hpp-p-cat" className={`select${errs.game_category ? " hpp-invalid" : ""}`} style={{ width: "100%" }}
            value={cat} onChange={e => { setCat(e.target.value); setProviders([]); setGames({}); clearErr("game_category"); clearErr("providers_list"); }}>
            <option value="">Select</option>
            {HPP_CATEGORIES.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
          </select>
        </HppField>

        <HppField label="Select providers" required error={errs.providers_list}
          hint={<>Loaded per category <i>and</i> skin by <code>GET /providerpromotions/getProvidersByCategory</code>; the Active/Inactive tag is the presence of a <code>skins_providers</code> row. Changing the category or the skin reloads the list, which is why both reset the selection.</>}>
          <HppProviderPicker category={cat} skin={skin} value={providers} invalid={!!errs.providers_list}
            onChange={(v) => { setProviders(v); const g = {}; v.forEach(id => { if (games[id]) g[id] = games[id]; }); setGames(g); clearErr("providers_list"); }} />
        </HppField>

        <HppField label="Games per provider"
          hint={<>Optional. Leaving a provider with no game ticked means <b>all</b> of its games qualify — that is why most rows carry no <code>provider_promotions_games</code> pivot rows at all.</>}>
          <HppGamesAccordion providers={providers} value={games} onChange={setGames} />
        </HppField>
      </HppSection>

      <HppSection title="Scheduling & display" sub="Validity window, ordering and the large-card flag."> {/* grouping inferred */}
        <HppField label="Promotion type" required htmlFor="hpp-p-type" error={errs.promotype}>
          <select id="hpp-p-type" className={`select${errs.promotype ? " hpp-invalid" : ""}`} style={{ width: "100%" }}
            value={promotype} onChange={e => { setPromotype(e.target.value); clearErr("promotype"); }}>
            <option value="">Select typology</option>
            {HPP_PROMOTYPES.map(t => <option key={t.value} value={t.value}>{t.label}</option>)}
          </select>
        </HppField>

        <div className="hpp-grid2">
          <HppField label="Start" required htmlFor="hpp-p-start" error={errs.start}
            hint={start ? <>Posted as <code>{hppPickerFmt(start)}</code> (dd/mm/yyyy hh:ii), parsed by <code>sistemadatatime()</code>.</> : <>Format on the wire: <code>dd/mm/yyyy hh:ii</code>.</>}>
            <input id="hpp-p-start" type="datetime-local" className={`input${errs.start ? " hpp-invalid" : ""}`} style={{ width: "100%" }}
              value={hppToInput(start)} onChange={e => { setStart(hppFromInput(e.target.value)); clearErr("start"); }} />
          </HppField>
          <HppField label="End" required htmlFor="hpp-p-end" error={errs.end}
            hint={end ? <>Drives the derived Valid/Expired chip and the expired-only delete guard.</> : <>Drives the derived Valid/Expired chip and the expired-only delete guard.</>}>
            <input id="hpp-p-end" type="datetime-local" className={`input${errs.end ? " hpp-invalid" : ""}`} style={{ width: "100%" }}
              value={hppToInput(end)} onChange={e => { setEnd(hppFromInput(e.target.value)); clearErr("end"); }} />
          </HppField>
        </div>

        <div className="hpp-grid2">
          <HppField label="Order" htmlFor="hpp-p-order" hint="Optional, defaults to 0.">
            <input id="hpp-p-order" type="number" className="input" style={{ width: "100%" }}
              value={order} onChange={e => setOrder(e.target.value)} />
          </HppField>
          <HppField label="Featured Large" hint={<>Renders the promotion as a wide hero card; read on the frontend by <code>getFeaturedLargePromotions()</code>.</>}>{/* label inferred */}
            <Toggle value={featured} onChange={setFeatured} onLabel="Yes" offLabel="No" size="sm" />
          </HppField>
        </div>
      </HppSection>

      <div className="hpp-formfoot">
        Two more columns are written by <code>savePromotion</code> but have no input here, because the blade has both
        commented out: <code>featured</code> (index.blade.php L293-303 → always saved as <b>0</b>) and
        <code> button_url</code> (L246-254 → always saved as <b>null</b>). The sport pivot settings
        (<code>odd_value</code>, <code>amount</code>, <code>count_events</code>, <code>event_odd</code> and their
        operators) are read back for sport providers but never posted by this form either — write-dead from the
        back office.
        {/* <!-- SUGGESTION: either restore the commented-out `featured` and `button_url` inputs or stop writing those columns in savePromotion — right now every promotion created from the back office silently pins featured = 0 and button_url = null. --> */}
        {/* <!-- SUGGESTION: the sport pivot columns on provider_promotions_providers are read by getPromotionProvidersAndGames but can only ever be written by a direct DB edit; either wire the blade's $opts to emit providersSettings[...] or drop the columns. --> */}
      </div>
    </HppModal>
  );
};

/* ================================================================== *
 * Promoplay promotion editor — GET /promoplaypromotions/form?id= then
 * POST /promoplaypromotions/savePromotion/. Same ungated pair.
 * ================================================================== */
const HppPromoplayForm = ({ row, onClose, onSave }) => {
  const isNew = !row;
  const [name, setName] = hppUseState(row ? row.name : "");
  const [skin, setSkin] = hppUseState(row ? row.skin : "");
  const [img, setImg] = hppUseState(row ? row.img : "");
  const [imgMobile, setImgMobile] = hppUseState(row ? row.img_mobile : "");
  const [short, setShort] = hppUseState(row ? row.breve_descrizione : "");
  const [desc, setDesc] = hppUseState(row ? row.descrizione : "");
  const [promotype, setPromotype] = hppUseState(row ? row.promotype : "");
  const [order, setOrder] = hppUseState(row ? String(row._order) : "0");
  const [start, setStart] = hppUseState(row ? row.start : null);
  const [end, setEnd] = hppUseState(row ? row.end : null);
  const [campId, setCampId] = hppUseState(row ? String(row.pp_campaign_id || "") : "");
  const [campSlug, setCampSlug] = hppUseState(row ? row.pp_campaign_slug || "" : "");
  const [freeKey, setFreeKey] = hppUseState(row ? row.pp_free_sub_key || "" : "");
  const [errs, setErrs] = hppUseState({});
  const [banner, setBanner] = hppUseState("");

  const clearErr = (k) => { setErrs(x => { const n = { ...x }; delete n[k]; return n; }); setBanner(""); };

  /* savePromotion L347-401. Identical error keys to Provider promotions minus lang / game_category /
     providers_list. pp_campaign_id, pp_campaign_slug and pp_free_sub_key each sit behind an EMPTY
     `if (empty(...)) {}` block — deliberately optional, so nothing is enforced on them here either. */
  const save = () => {
    const e = {};
    if (!name.trim()) e.name = "Insert name";
    else if (name.trim().length > 255) e.name = "The name may not be greater than 255 characters.";
    if (!skin) e.skin_id = "Select skin";
    if (!short.trim()) e.breve_descrizione = "Insert short description";
    if (!desc.trim()) e.descrizione = "Insert description";
    if (!promotype) e.promotype = "Select typology";
    if (!start) e.start = "Fill in the start date field";
    if (!end) e.end = "Fill in the end date field";
    setErrs(e);
    const first = ["name", "skin_id", "breve_descrizione", "descrizione", "promotype", "start", "end"].map(k => e[k]).filter(Boolean)[0];
    if (first) { setBanner(first); return; }
    setBanner("");
    onSave({
      id: isNew ? null : row.id, name: name.trim(), skin, img, img_mobile: imgMobile,
      breve_descrizione: short.trim(), descrizione: desc.trim(), promotype, _order: Number(order || 0),
      start, end,
      pp_campaign_id: campId ? Number(campId) : null, pp_campaign_slug: campSlug.trim(), pp_free_sub_key: freeKey.trim(),
      hidden: isNew ? 1 : row.hidden,   // never posted — DB default 1 on create
    });
    onClose();
  };

  return (
    <HppModal wide onClose={onClose}
      title={isNew ? <>New Promoplay Promotion{/* label inferred — backend.new_promoplay_promotion is missing */}</> : `Edit ${row.name}`}
      sub={isNew ? "POST /promoplaypromotions/savePromotion/ — no role gate on this endpoint" : `promoplay_promotions #${row.id} · POST /promoplaypromotions/savePromotion/?id=${row.id}`}
      footer={<>
        <button className="btn btn--secondary" onClick={onClose}>Close</button>
        <button className="btn btn--primary" onClick={save}>
          <Icon name="check" size={13} /> {isNew ? "Save — creates it hidden" : "Save"}
        </button>
      </>}>

      {isNew && <HppHiddenNotice table="promoplay_promotions" />}
      {!isNew && (
        <HppNote tone={row.hidden ? "warn" : "info"} icon={row.hidden ? "eye" : "info"}>
          Currently <b>{row.hidden ? "Hidden" : "Visible"}</b>. Same rule as Provider promotions: no <code>hidden</code> field in
          this form, no <code>hidden</code> write in the save endpoint — only <code>{"POST /promoplaypromotions/{id}/hidden"}</code> (<code>isadmin()</code>) changes it.
        </HppNote>
      )}
      {banner && <div className="hpp-err hpp-err--banner"><Icon name="alert" size={13} /> {banner}</div>}

      <HppSection title="Details" sub="Owner skin and title. There is no language column on this table."> {/* grouping inferred */}
        <HppField label="Name" required htmlFor="hpp-pp-name" error={errs.name}>
          <input id="hpp-pp-name" className={`input${errs.name ? " hpp-invalid" : ""}`} style={{ width: "100%" }} autoFocus
            maxLength={300} value={name} onChange={e => { setName(e.target.value); clearErr("name"); }} />
        </HppField>
        {HPP_ME.isAdmin && (
          <HppField label="Skin" required htmlFor="hpp-pp-skin" error={errs.skin_id}
            hint={<>The launch URL is later built from this skin's <code>pp_api_url</code> and signed with its <code>pp_secret_key</code>, so the skin decides which PromoPlay account the campaign talks to.</>}>
            <select id="hpp-pp-skin" className={`select${errs.skin_id ? " hpp-invalid" : ""}`} style={{ width: "100%" }}
              value={skin} onChange={e => { setSkin(e.target.value); clearErr("skin_id"); }}>
              <option value="">Select skin</option>
              {HPP_SKINS.map(s => <option key={s} value={s}>{s}</option>)}
            </select>
          </HppField>
        )}
      </HppSection>

      <HppSection title="Media" sub="Stored under storage/promoplaypromotions/logo."> {/* grouping inferred */}
        <HppImageField label="Image [Desktop]" value={img} onChange={setImg} uiOnly
          hint={<>Accepts <code>.png .jpg .jpeg</code>; UI-required only, never validated server-side.</>} />
        {/* KNOWN BUG — DIVERGENCE: the controller writes the file with
            storeAs('public/promoplayPromotions/logo', …) (capital P, L437-438) while the URL saved
            in the column uses lowercase `promoplaypromotions`, so every mobile image 404s on a
            case-sensitive filesystem. The prototype uses the lowercase path for both — the
            evident intent. */}
        <HppImageField label="Image [Mobile]" value={imgMobile} onChange={setImgMobile} uiOnly
          hint={<><b>Real-platform bug, fixed here:</b> the mobile file is written to <code>public/promoplayPromotions/logo</code> (capital P) while the URL stored in the column is built with lowercase <code>promoplaypromotions</code> — on a case-sensitive filesystem the saved image 404s. This prototype uses the lowercase path for both, which is the evident intent.</>} />
      </HppSection>

      <HppSection title="Content" sub="Card copy and the detail page body."> {/* grouping inferred */}
        <HppField label="Short description" required htmlFor="hpp-pp-short" error={errs.breve_descrizione}>
          <textarea id="hpp-pp-short" rows={2} className={`input${errs.breve_descrizione ? " hpp-invalid" : ""}`} style={{ width: "100%" }}
            value={short} onChange={e => { setShort(e.target.value); clearErr("breve_descrizione"); }} />
        </HppField>
        <HppField label="Complete description" required htmlFor="hpp-pp-desc" error={errs.descrizione}
          hint="TinyMCE on the real form — raw HTML is stored.">
          <textarea id="hpp-pp-desc" rows={5} className={`input hpp-mono${errs.descrizione ? " hpp-invalid" : ""}`} style={{ width: "100%" }}
            value={desc} onChange={e => { setDesc(e.target.value); clearErr("descrizione"); }} />
        </HppField>
      </HppSection>

      <HppSection title="Scheduling & display" sub="Validity window and ordering. No Featured Large on this screen."> {/* grouping inferred */}
        <HppField label="Promotion type" required htmlFor="hpp-pp-type" error={errs.promotype}
          hint={<>This screen's <code>promoTypes()</code> is its own two-value list — it does not share the Provider promotions vocabulary.</>}>
          <select id="hpp-pp-type" className={`select${errs.promotype ? " hpp-invalid" : ""}`} style={{ width: "100%" }}
            value={promotype} onChange={e => { setPromotype(e.target.value); clearErr("promotype"); }}>
            <option value="">Select typology</option>
            {HPP_PP_TYPES.map(t => <option key={t.value} value={t.value}>{t.label}</option>)}
          </select>
        </HppField>
        <div className="hpp-grid2">
          <HppField label="Start" required htmlFor="hpp-pp-start" error={errs.start}
            hint={start ? <>Posted as <code>{hppPickerFmt(start)}</code> (dd/mm/yyyy hh:ii).</> : <>Format on the wire: <code>dd/mm/yyyy hh:ii</code>.</>}>
            <input id="hpp-pp-start" type="datetime-local" className={`input${errs.start ? " hpp-invalid" : ""}`} style={{ width: "100%" }}
              value={hppToInput(start)} onChange={e => { setStart(hppFromInput(e.target.value)); clearErr("start"); }} />
          </HppField>
          <HppField label="End" required htmlFor="hpp-pp-end" error={errs.end}
            hint="Both dates are columns on this list, unlike Provider promotions.">
            <input id="hpp-pp-end" type="datetime-local" className={`input${errs.end ? " hpp-invalid" : ""}`} style={{ width: "100%" }}
              value={hppToInput(end)} onChange={e => { setEnd(hppFromInput(e.target.value)); clearErr("end"); }} />
          </HppField>
        </div>
        <HppField label="Order" htmlFor="hpp-pp-order" hint="Optional, defaults to 0.">
          <input id="hpp-pp-order" type="number" className="input" style={{ width: 200 }}
            value={order} onChange={e => setOrder(e.target.value)} />
        </HppField>
      </HppSection>

      <HppSection title="PromoPlay campaign" sub="The three optional columns that tie this row to a campaign on the PromoPlay side."> {/* grouping inferred */}
        <HppNote>
          All three are optional by design: <code>savePromotion</code> has an <b>empty</b> <code>if (empty(...))</code> block for
          each of them, so nothing is enforced. A promotion saved without a campaign slug simply has no launch target.
        </HppNote>
        <div className="hpp-grid2">
          <HppField label="PromoPlay Campaign ID" htmlFor="hpp-pp-cid" hint={<>Integer column on <code>promoplay_promotions</code>.</>}>
            <input id="hpp-pp-cid" type="number" className="input" style={{ width: "100%" }} value={campId} onChange={e => setCampId(e.target.value)} />
          </HppField>
          <HppField label="PromoPlay Campaign Slug" htmlFor="hpp-pp-slug" hint={<>varchar(100). Appended to the skin's <code>pp_api_url</code> to build the launch URL.</>}>
            <input id="hpp-pp-slug" className="input" style={{ width: "100%" }} maxLength={100} value={campSlug} onChange={e => setCampSlug(e.target.value)} />
          </HppField>
        </div>
        <HppField label="PromoPlay Free Subscription Key" htmlFor="hpp-pp-key"
          hint={<>varchar(100). The literal <code>{"{{current-date}}"}</code> is replaced with today's <code>Y-m-d</code> at launch time and the result is md5-signed with the skin's <code>pp_secret_key</code> (<code>playPromotion</code>, controller L159-160).</>}>
          <input id="hpp-pp-key" className="input" style={{ width: "100%" }} maxLength={100} placeholder="e.g. FG-{{current-date}}"
            value={freeKey} onChange={e => setFreeKey(e.target.value)} />
        </HppField>
        {freeKey.indexOf("{{current-date}}") !== -1 && (
          <HppNote icon="zap">
            At launch this resolves to <code>{freeKey.replace("{{current-date}}", new Date().toISOString().slice(0, 10))}</code> before signing.
          </HppNote>
        )}
      </HppSection>
    </HppModal>
  );
};

/* ------------------------------------------------------------------ *
 * The pp-api proxy rail — five endpoints that live on this screen's
 * route block and carry NO role check inside PromoPlayController.
 * Surfaced as a visible band, not only as a Tip, because two of them
 * move real PromoPlay points.
 * ------------------------------------------------------------------ */
const HppApiRail = () => (
  <div className="hpp-apirail">
    <div className="hpp-apirail__head">
      <Icon name="lock" size={13} />
      <b>pp-api proxy — routes/admin.php:1048-1054, ungated inside the controller</b>
      <Tip>
        The five routes below sit in the same admin route block as this screen but
        <code> PromoPlayController</code> performs no <code>isadmin()</code>/<code>isSkinAdmin()</code> check and no
        skin-ownership check on any of them. Any authenticated, 2FA'd back-office session that passes the
        <code> ['auth','admin','2fa','g2fa']</code> middleware can call them for an arbitrary <code>playerId</code>.
        They are consumed by the Player 360 page (admin/players/template.blade.php:119-251).
      </Tip>
    </div>
    <div className="hpp-apirail__list">
      {[
        { m: "GET", p: "/pp-api/player/{playerId}/balance", c: "getBalance", d: "reads checkPlayerPointsBalance", risk: false },
        { m: "POST", p: "/pp-api/player/{playerId}/register", c: "registerOnPromoplay", d: "registerOrUpdatePlayer", risk: false },
        { m: "POST", p: "/pp-api/player/{playerId}/add-balance", c: "addBalance", d: "depositPoints — requires points + reason", risk: true },
        { m: "POST", p: "/pp-api/player/{playerId}/withdraw-balance", c: "withdrawBalance", d: "withdrawPoints — requires points > 0", risk: true },
        { m: "POST", p: "/pp-api/player/{playerId}/update-level", c: "updatePlayerLevel", d: "updatePlayerLevel — requires level_id", risk: false },
      ].map(r => (
        <div key={r.p} className={`hpp-api${r.risk ? " hpp-api--risk" : ""}`}>
          <span className={`hpp-api__m hpp-api__m--${r.m.toLowerCase()}`}>{r.m}</span>
          <code className="hpp-api__p">{r.p}</code>
          <span className="hpp-api__c">{r.c}()</span>
          <span className="hpp-api__d">{r.d}</span>
          {r.risk && <span className="hpp-api__risk">moves points</span>}
        </div>
      ))}
    </div>
    <div className="hpp-apirail__foot">
      Raw cURL against the skin's <code>pp_username</code> / <code>pp_secret_key</code> / <code>pp_api_url</code>; responses are
      <code> {"{status:'OK'|'ERROR', data|message}"}</code> with 404 (player or skin missing), 400 (bad input) and 500 (missing
      credentials or upstream error). Nothing on this admin screen calls them — they belong to Player 360.
    </div>
  </div>
);

/* ================================================================== *
 * SCREEN 1 — Provider Promotions
 * GET /providerpromotions · ProviderPromotionController::index
 * ================================================================== */
const HPP_PRV_DEFAULTS = { search: "", skin: "", featured: "", expired: "0", hidden: "" };

const HostCmsProviderPromotions = () => {
  window.useLocale && window.useLocale();

  const [rows, setRows] = hppUseState(hppBuildProviderPromos);
  const [draft, setDraft] = hppUseState(HPP_PRV_DEFAULTS);
  const [applied, setApplied] = hppUseState(HPP_PRV_DEFAULTS);
  const [page, setPage] = hppUseState(0);
  const [form, setForm] = hppUseState(null);   // null | { row: row|null }
  const [del, setDel] = hppUseState(null);
  const [created, setCreated] = hppUseState(null);

  const FIELDS = [
    { key: "search", label: "Search", type: "text", icon: "search", grow: true, placeholder: "Name, or an exact ID",
      tip: <>Matches <code>name LIKE %term%</code>, plus <code>OR id = term</code> when the term is all digits.</> },
    /* Rendered only for isadmin(); a skin admin is force-filtered to their own skin server-side (L87, L100-104). */
    { key: "skin", label: "Skin", type: "select", icon: "flag", placeholder: "All skins", options: HPP_SKINS, hidden: !HPP_ME.isAdmin,
      tip: <>Options from <code>SkinsController::getSkinsList()</code>. Not rendered for a Skin admin — the server scopes them to their own <code>skin_id</code> instead.</> },
    { key: "featured", label: "Featured Large", type: "select", icon: "star", placeholder: "All", /* label inferred */
      options: [{ value: "1", label: "Yes" }, { value: "0", label: "No" }],
      tip: <>Filters the <code>featured_large</code> column. There is no equivalent on the Promoplay screen.</> },
    /* Defaults to No — the list hides everything whose `end` is already in the past (controller L117-119). */
    { key: "expired", label: "Show expired promotions", type: "select", icon: "calendar", defaultValue: "0", /* label inferred */
      options: [{ value: "0", label: "No" }, { value: "1", label: "Yes" }],
      tip: <>Default <b>No</b>: rows with <code>end &lt; time()</code> are hidden. Since delete only works on expired rows, an operator has to switch this to Yes before they can clean anything up.</> },
    { key: "hidden", label: "Hidden", type: "select", icon: "eye", placeholder: "All", /* label inferred */
      options: [{ value: "0", label: "Visible" }, { value: "1", label: "Hidden" }],
      tip: <>Same filter the chip strip below drives. <b>New promotions land in "Hidden"</b> — <code>hidden</code> defaults to 1 in the database.</> },
  ];

  /* The real selects auto-submit on change; only the text box waits for Apply. */
  const onChange = (k, v) => {
    const next = { ...draft, [k]: v };
    setDraft(next);
    if (k !== "search") { setApplied(next); setPage(0); }
  };
  const onSearch = (v) => { setApplied({ ...HPP_PRV_DEFAULTS, ...v }); setPage(0); };
  const onReset = () => { setDraft(HPP_PRV_DEFAULTS); setApplied(HPP_PRV_DEFAULTS); setPage(0); };

  /* Everything except the `hidden` clause — the chip counts are computed on this set, the way the
     controller's $summary is built alongside the same filtered query. */
  const base = hppUseMemo(() => {
    const q = String(applied.search || "").trim().toLowerCase();
    const digits = q && /^\d+$/.test(q);
    return rows.filter(r => {
      if (q && !(r.name.toLowerCase().indexOf(q) !== -1 || (digits && String(r.id) === q))) return false;
      if (applied.skin && r.skin !== applied.skin) return false;
      if (applied.featured !== "" && String(r.featured_large) !== String(applied.featured)) return false;
      if (applied.expired !== "1" && hppExpired(r)) return false;
      return true;
    });
  }, [rows, applied]);

  const counts = hppUseMemo(() => ({
    all: base.length,
    visible: base.filter(r => r.hidden === 0).length,
    hidden: base.filter(r => r.hidden === 1).length,
  }), [base]);

  /* Fixed ORDER BY provider_promotions.id DESC (controller L128) — no column here is sortable. */
  const filtered = hppUseMemo(() => base
    .filter(r => applied.hidden === "" || String(r.hidden) === String(applied.hidden))
    .slice().sort((a, b) => b.id - a.id), [base, applied.hidden]);

  const pageCount = Math.max(1, Math.ceil(filtered.length / HPP_PAGE_SIZE));
  const safePage = Math.min(page, pageCount - 1);
  const paged = filtered.slice(safePage * HPP_PAGE_SIZE, safePage * HPP_PAGE_SIZE + HPP_PAGE_SIZE);

  const toggleHidden = (row) => {
    setRows(rs => rs.map(r => r.id === row.id ? { ...r, hidden: r.hidden ? 0 : 1 } : r));
    hrsToast(row.hidden ? `#${row.id} is now visible` : `#${row.id} is now hidden`,
      `POST /providerpromotions/${row.id}/hidden — isadmin() only. For any other role this endpoint returns silently and the page reloads unchanged.`);
  };

  const onSave = (p) => {
    if (p.id == null) {
      const id = rows.reduce((m, r) => Math.max(m, r.id), 0) + 1;
      const row = { ...p, id };
      setRows(rs => [row, ...rs]);
      setCreated(row);
      hrsToast(`Promotion #${id} created — HIDDEN`,
        "provider_promotions.hidden defaults to 1 and savePromotion never writes it. Players will not see this promotion until an isadmin() user turns its Hidden switch on.");
    } else {
      setRows(rs => rs.map(r => r.id === p.id ? { ...r, ...p } : r));
      hrsToast(`Promotion #${p.id} saved`, `${p.providers.length} provider(s) synced in provider_promotions_providers; caches pro_promotion_providers_and_games:${p.id} and user_promotions:{skin_id}* flushed.`);
    }
  };

  const columns = [
    { key: "id", label: <>ID <Tip size={11}>Fixed <code>ORDER BY provider_promotions.id DESC</code> — no column on this screen is sortable, on the real platform or here.</Tip></>, width: 84,
      render: r => <span className="hpp-id">{r.id}</span> },
    { key: "skin", label: "Skin", width: 170, hidden: !HPP_ME.isAdmin, render: r => <HppSkinCell skin={r.skin} /> },
    { key: "name", label: "Name", render: r => (
      <button className="hpp-namelink" title="Edit" onClick={() => setForm({ row: r })}>
        <span>{r.name}</span>
        <span className="hpp-namelink__meta">{hppTypeLabel(HPP_PROMOTYPES, r.promotype)} · {hppCategoryName(r.game_category)} · {r.providers.length} provider(s)</span>
      </button>
    ) },
    { key: "lang", label: "Language", align: "center", width: 110, render: r => <HppLangChip code={r.lang} /> },
    { key: "featured_large", label: <>Featured Large{/* label inferred */}</>, align: "center", width: 130, render: r => <HppYesNo on={!!r.featured_large} /> },
    { key: "valid", label: "Valid", align: "center", width: 110, render: r => <HppValidChip row={r} /> },
    /* The switch sits inside a row-click target, so its own click must not also open the editor. */
    { key: "hidden", label: <>Hidden <Tip size={11}>Inverted switch — checked means <code>hidden = 0</code>, i.e. visible. Writes <code>{"POST /providerpromotions/{id}/hidden"}</code>, which is <code>isadmin()</code> only: for a Skin admin the request returns silently, the page reloads and nothing changed.</Tip></>,
      align: "center", width: 150, render: r => <span onClick={e => e.stopPropagation()}><HppHiddenSwitch row={r} onToggle={toggleHidden} canToggle={HPP_ME.isAdmin} /></span> },
    { key: "_acts", label: "Actions", align: "center", width: 110, render: r => (
      <div className="hpp-acts">
        <button className="hpp-act" title="Edit" onClick={(e) => { e.stopPropagation(); setForm({ row: r }); }}><Icon name="edit" size={13} /></button>
        <button className={`hpp-act hpp-act--danger${hppExpired(r) ? "" : " hpp-act--guarded"}`}
          title={hppExpired(r) ? "Delete" : "Delete — the server refuses while the promotion is still active"}
          onClick={(e) => { e.stopPropagation(); setDel(r); }}><Icon name="trash" size={13} /></button>
      </div>
    ) },
  ];

  return (
    <HrsShell
      title={<>Provider Promotions{/* label inferred — backend.provider_promotions resolves to the raw key */}</>}
      subtitle="Promotion cards served to players per skin and language, scoped to a game category and a list of providers"
      gate={<>Real-platform access: <code>index</code> requires <code>isAdmin() || isSkinAdmin()</code> and aborts <code>404</code> for everyone else; the whole CMS ▾ submenu is wrapped in <code>@if (isadmin())</code>, so a Skin admin can reach the page but never sees the link. No skin feature flag gates this screen. </>}
      gateNote={<>
        Three different gates on one screen, honestly: <code>toggleHidden</code> is <b><code>isadmin()</code> only</b> and returns
        silently for anyone else (a Skin admin's switch appears to work and writes nothing);
        <code> delete</code> takes <code>isadmin() || isSkinAdmin()</code> plus a skin-ownership check <i>and</i> refuses any
        promotion that has not expired; and <b><code>promotionForm</code> / <code>savePromotion</code> have no role check at
        all</b> — <code>SaveProviderPromotionRequest::authorize()</code> returns <code>true</code>, so every authenticated,
        2FA'd back-office user can open the form and create or edit promotions on any skin by calling the URLs directly.
      </>}
      explainer={{
        title: "What this screen is, in plain English",
        bullets: [
          <><b>New promotions are invisible by default.</b> <code>provider_promotions.hidden</code> has DB default <b>1</b> and
            <code> savePromotion()</code> never writes the column, so a promotion you create here is stored but not shown to any
            player. The only thing that publishes it is the <b>Hidden</b> switch in the list — an <code>isadmin()</code>-only
            endpoint. A Skin admin can create a promotion and cannot publish it.</>,
          <>A row ties a card (image, short + long description) to one <b>skin</b>, one <b>language</b> (or ALL), one <b>game
            category</b> and a list of <b>providers</b>; optionally it narrows further to specific <b>games</b> per provider.
            Leaving a provider's game list empty means all of its games qualify.</>,
          <><b>Delete only works on expired promotions.</b> The whole promotions family shares that guard — an active row
            answers "Cannot delete an active (non-expired) promotion". Since the list also hides expired rows by default,
            cleaning up means first switching <i>Show expired promotions</i> to Yes.</>,
          <>Validity is derived, not stored: the <b>Valid</b> chip is just <code>end &gt;= time()</code>. There is no status
            column, no activate/deactivate, no export and no sorting — the list is a fixed <code>id DESC</code>, 25 per page.</>,
          <>Players read these rows through <code>showPromotions</code> / <code>showPromotionDetails</code> (web.php:225,229)
            and through <code>Fapi\ProviderPromotionController</code> on fapi-v2 — a different class reading the same table.</>,
        ],
      }}
      actions={
        <button className="hrs-btn hrs-btn--filters" onClick={() => setForm({ row: null })}>
          <Icon name="plus" size={14} /> New Provider Promotion{/* label inferred */}
        </button>
      }>

      <HrsFilters
        fields={FIELDS} values={draft} onChange={onChange} onSearch={onSearch} onReset={onReset}
        resultLabel={`${hrsInt(filtered.length)} of ${hrsInt(rows.length)}`} />

      <HppChips counts={counts} value={applied.hidden} onPick={(v) => onChange("hidden", v)} />

      {created && <HppCreatedBanner created={created} onDismiss={() => setCreated(null)} />}

      <HrsTable
        columns={columns} rows={paged} rowKey="id"
        empty={filtered.length === 0 && rows.length > 0
          ? <>No promotion matches these filters. Remember that <b>Show expired promotions</b> defaults to <b>No</b>.</>
          : "No records"} /* label inferred — backend.no_records */
        renderCard={r => (
          <>
            <div className="hrs-card__top">
              <b>{r.name}</b>
              <HppValidChip row={r} />
            </div>
            <div className="hpp-card__meta">
              #{r.id} · {r.skin} · <HppLangChip code={r.lang} /> · {hppTypeLabel(HPP_PROMOTYPES, r.promotype)}
            </div>
            <div className="hpp-card__row"><span>Featured Large</span><HppYesNo on={!!r.featured_large} /></div>
            <div className="hpp-card__row" onClick={e => e.stopPropagation()}><span>Visibility</span><HppHiddenSwitch row={r} onToggle={toggleHidden} canToggle={HPP_ME.isAdmin} /></div>
            <details className="hpp-card__more" onClick={e => e.stopPropagation()}>
              <summary>More</summary>
              <div className="hpp-card__row"><span>Category</span><b>{hppCategoryName(r.game_category)}</b></div>
              <div className="hpp-card__row"><span>Providers</span><b>{r.providers.map(hppProviderName).join(", ")}</b></div>
              <div className="hpp-card__row"><span>Start</span><b>{hppFmtDT(r.start)}</b></div>
              <div className="hpp-card__row"><span>End</span><b>{hppFmtDT(r.end)}</b></div>
              <div className="hpp-card__row"><span>Order</span><b>{r._order}</b></div>
            </details>
            <div className="hpp-card__acts">
              <button className="btn btn--secondary btn--sm" onClick={(e) => { e.stopPropagation(); setForm({ row: r }); }}><Icon name="edit" size={12} /> Edit</button>
              <button className="btn btn--ghost btn--sm hpp-card__del" onClick={(e) => { e.stopPropagation(); setDel(r); }}><Icon name="trash" size={12} /> Delete</button>
            </div>
          </>
        )}
        /* The whole row opens the editor on this screen — the reference documents Edit as
           "row click, name link, pencil" for Provider promotions. It does NOT document a row-click
           on Promoplay promotions, so that twin deliberately keeps the name link + pencil only. */
        onRowClick={(r) => setForm({ row: r })} />

      <HrsPager page={safePage} pageSize={HPP_PAGE_SIZE} total={filtered.length} onPage={setPage} />

      {form && <HppProviderForm row={form.row} onClose={() => setForm(null)} onSave={onSave} />}
      {del && <HppDeleteDialog row={del} kind="provider" onClose={() => setDel(null)} onDelete={(r) => setRows(rs => rs.filter(x => x.id !== r.id))} />}
    </HrsShell>
  );
};

/* ================================================================== *
 * SCREEN 2 — Promoplay Promotions
 * GET /promoplaypromotions · PromoPlayPromotionController::index
 * ================================================================== */
const HPP_PP_DEFAULTS = { search: "", skin: "", expired: "0", hidden: "" };

const HostCmsPromoplayPromotions = () => {
  window.useLocale && window.useLocale();

  const [rows, setRows] = hppUseState(hppBuildPpPromos);
  const [draft, setDraft] = hppUseState(HPP_PP_DEFAULTS);
  const [applied, setApplied] = hppUseState(HPP_PP_DEFAULTS);
  const [page, setPage] = hppUseState(0);
  const [form, setForm] = hppUseState(null);
  const [del, setDel] = hppUseState(null);
  const [created, setCreated] = hppUseState(null);

  const FIELDS = [
    { key: "search", label: "Search", type: "text", icon: "search", grow: true, placeholder: "Name, or an exact ID",
      tip: <>Matches <code>name LIKE %term%</code>, plus <code>OR id = term</code> when the term is numeric.</> },
    { key: "skin", label: "Skin", type: "select", icon: "flag", placeholder: "All skins", options: HPP_SKINS, hidden: !HPP_ME.isAdmin,
      tip: <>Admin-only select; a Skin admin is forced to their own <code>skin_id</code> server-side (controller L70, L82-86).</> },
    { key: "expired", label: "Show expired promotions", type: "select", icon: "calendar", defaultValue: "0", /* label inferred */
      options: [{ value: "0", label: "No" }, { value: "1", label: "Yes" }],
      tip: <>Default <b>No</b> — rows with <code>end &lt; time()</code> are hidden (controller L96-98).</> },
    { key: "hidden", label: "Hidden", type: "select", icon: "eye", placeholder: "All", /* label inferred */
      options: [{ value: "0", label: "Visible" }, { value: "1", label: "Hidden" }],
      tip: <>Same <code>hidden</code> filter as the chips. <b>New promotions land here as Hidden</b> — the column defaults to 1.</> },
    /* Deliberately absent: there is no Featured Large filter on this screen (no such column). */
  ];

  const onChange = (k, v) => {
    const next = { ...draft, [k]: v };
    setDraft(next);
    if (k !== "search") { setApplied(next); setPage(0); }
  };
  const onSearch = (v) => { setApplied({ ...HPP_PP_DEFAULTS, ...v }); setPage(0); };
  const onReset = () => { setDraft(HPP_PP_DEFAULTS); setApplied(HPP_PP_DEFAULTS); setPage(0); };

  const base = hppUseMemo(() => {
    const q = String(applied.search || "").trim().toLowerCase();
    const digits = q && /^\d+$/.test(q);
    return rows.filter(r => {
      if (q && !(r.name.toLowerCase().indexOf(q) !== -1 || (digits && String(r.id) === q))) return false;
      if (applied.skin && r.skin !== applied.skin) return false;
      if (applied.expired !== "1" && hppExpired(r)) return false;
      return true;
    });
  }, [rows, applied]);

  const counts = hppUseMemo(() => ({
    all: base.length,
    visible: base.filter(r => r.hidden === 0).length,
    hidden: base.filter(r => r.hidden === 1).length,
  }), [base]);

  const filtered = hppUseMemo(() => base
    .filter(r => applied.hidden === "" || String(r.hidden) === String(applied.hidden))
    .slice().sort((a, b) => b.id - a.id), [base, applied.hidden]);

  const pageCount = Math.max(1, Math.ceil(filtered.length / HPP_PAGE_SIZE));
  const safePage = Math.min(page, pageCount - 1);
  const paged = filtered.slice(safePage * HPP_PAGE_SIZE, safePage * HPP_PAGE_SIZE + HPP_PAGE_SIZE);

  const toggleHidden = (row) => {
    setRows(rs => rs.map(r => r.id === row.id ? { ...r, hidden: r.hidden ? 0 : 1 } : r));
    hrsToast(row.hidden ? `#${row.id} is now visible` : `#${row.id} is now hidden`,
      `POST /promoplaypromotions/${row.id}/hidden — isadmin() only; a silent no-op for every other role.`);
  };

  const onSave = (p) => {
    if (p.id == null) {
      const id = rows.reduce((m, r) => Math.max(m, r.id), 0) + 1;
      const row = { ...p, id };
      setRows(rs => [row, ...rs]);
      setCreated(row);
      hrsToast(`Promoplay promotion #${id} created — HIDDEN`,
        "promoplay_promotions.hidden defaults to 1 and savePromotion never writes it. Nothing reaches /pp-freegames, /pp-shop or /pp-missions until an isadmin() user flips the Hidden switch.");
    } else {
      setRows(rs => rs.map(r => r.id === p.id ? { ...r, ...p } : r));
      hrsToast(`Promoplay promotion #${p.id} saved`, "No cache is flushed on this screen — unlike Provider promotions, save and delete here touch the row only.");
    }
  };

  const columns = [
    { key: "id", label: <>ID <Tip size={11}>Fixed <code>ORDER BY promoplay_promotions.id DESC</code> — nothing on this screen is sortable.</Tip></>, width: 84,
      render: r => <span className="hpp-id">{r.id}</span> },
    { key: "skin", label: "Skin", width: 170, hidden: !HPP_ME.isAdmin, render: r => <HppSkinCell skin={r.skin} /> },
    { key: "name", label: "Name", render: r => (
      <button className="hpp-namelink" title="Edit" onClick={() => setForm({ row: r })}>
        <span>{r.name}</span>
        <span className="hpp-namelink__meta">
          {hppTypeLabel(HPP_PP_TYPES, r.promotype)}{r.pp_campaign_slug ? <> · campaign <code>{r.pp_campaign_slug}</code></> : <> · no campaign slug</>}
        </span>
      </button>
    ) },
    { key: "start", label: "Start date", width: 165, render: r => <span className="hpp-dt">{hppFmtDT(r.start)}</span> },
    { key: "end", label: "End date", width: 165, render: r => <span className="hpp-dt">{hppFmtDT(r.end)}</span> },
    { key: "valid", label: "Valid", align: "center", width: 110, render: r => <HppValidChip row={r} /> },
    { key: "hidden", label: <>Hidden <Tip size={11}>Checked = <code>hidden = 0</code> (visible). <code>{"POST /promoplaypromotions/{id}/hidden"}</code> is <code>isadmin()</code> only and no-ops silently for anyone else.</Tip></>,
      align: "center", width: 150, render: r => <HppHiddenSwitch row={r} onToggle={toggleHidden} canToggle={HPP_ME.isAdmin} /> },
    { key: "_acts", label: "Actions", align: "center", width: 110, render: r => (
      <div className="hpp-acts">
        <button className="hpp-act" title="Edit" onClick={(e) => { e.stopPropagation(); setForm({ row: r }); }}><Icon name="edit" size={13} /></button>
        <button className={`hpp-act hpp-act--danger${hppExpired(r) ? "" : " hpp-act--guarded"}`}
          title={hppExpired(r) ? "Delete" : "Delete — refused while the promotion is still active"}
          onClick={(e) => { e.stopPropagation(); setDel(r); }}><Icon name="trash" size={13} /></button>
      </div>
    ) },
  ];

  return (
    <HrsShell
      title={<>Promoplay Promotions{/* the sidebar label is a hardcoded string; the page title uses backend.promoplay_promotions, which resolves to the raw key — same screen, two label sources */}</>}
      subtitle="PromoPlay campaign cards per skin — free games, shop and missions, launched with the skin's PromoPlay credentials"
      gate={<>Real-platform access: <code>index</code> requires <code>isAdmin() || isSkinAdmin()</code>, else <code>404</code>; the CMS ▾ entry itself is inside <code>@if (isadmin())</code>. No skin feature flag gates the screen — the per-skin PromoPlay credentials (<code>pp_username</code>, <code>pp_secret_key</code>, <code>pp_api_url</code> on <code>skins</code>) configure the player-side launch and the pp-api proxy, they do not gate this page.</>}
      gateNote={<>
        Same three-speed gating as Provider promotions — <code>toggleHidden</code> <code>isadmin()</code> only and silent otherwise,
        <code> delete</code> role + own-skin + expired-only, <code>promotionForm</code>/<code>savePromotion</code> ungated. On top of
        that this screen's route block carries the <b>pp-api proxy group</b> (routes/admin.php:1048-1054):
        <code> {"/pp-api/player/{playerId}/"}</code> <code>balance</code>, <code>register</code>, <code>add-balance</code>,
        <code> withdraw-balance</code>, <code>update-level</code>. <b>None of the five has a role or skin-ownership check inside
        PromoPlayController</b> — any authenticated, 2FA'd back-office session can read a player's PromoPlay balance, register
        them, credit or withdraw points and change their level for an arbitrary player id. They are listed in full on the page.
      </>}
      explainer={{
        title: "What this screen is, in plain English",
        bullets: [
          <><b>New promotions are created hidden, exactly like Provider promotions.</b>
            <code> promoplay_promotions.hidden</code> defaults to <b>1</b>, <code>savePromotion()</code> never writes it, and only
            the <code>isadmin()</code>-gated Hidden switch below can publish a row.</>,
          <>Each row points at a <b>PromoPlay campaign</b> through three optional columns — campaign id, campaign slug and a free
            subscription key. At launch the key's <code>{"{{current-date}}"}</code> placeholder becomes today's date and the whole
            thing is md5-signed with the skin's <code>pp_secret_key</code>; the URL is built from the skin's <code>pp_api_url</code>.</>,
          <>Players reach these through <code>/pp-freegames</code>, <code>/pp-freegames/play/{"{id}"}</code>, <code>/pp-shop</code> and
            <code> /pp-missions</code> (web.php:304-307); launching one also creates a casino session.</>,
          <>Thinner than the Provider screen on purpose: <b>no language</b>, <b>no game category</b>, <b>no provider or game
            pickers</b> (the two pivot models the controller imports are never used) and <b>no Featured Large</b>. It does show
            Start date and End date as columns, which the Provider list does not.</>,
          <><b>Delete is expired-only</b> here too, and it is a plain <code>delete()</code> — no pivot cleanup and, unlike Provider
            promotions, no cache flush.</>,
        ],
      }}
      actions={
        <button className="hrs-btn hrs-btn--filters" onClick={() => setForm({ row: null })}>
          <Icon name="plus" size={14} /> New Promoplay Promotion{/* label inferred */}
        </button>
      }>

      <HrsFilters
        fields={FIELDS} values={draft} onChange={onChange} onSearch={onSearch} onReset={onReset}
        resultLabel={`${hrsInt(filtered.length)} of ${hrsInt(rows.length)}`} />

      <HppChips counts={counts} value={applied.hidden} onPick={(v) => onChange("hidden", v)} />

      {created && <HppCreatedBanner created={created} onDismiss={() => setCreated(null)} />}

      <HrsTable
        columns={columns} rows={paged} rowKey="id"
        empty={filtered.length === 0 && rows.length > 0
          ? <>No promotion matches these filters. <b>Show expired promotions</b> defaults to <b>No</b>.</>
          : "No records"} /* label inferred */
        renderCard={r => (
          <>
            <div className="hrs-card__top">
              <b>{r.name}</b>
              <HppValidChip row={r} />
            </div>
            <div className="hpp-card__meta">#{r.id} · {r.skin} · {hppTypeLabel(HPP_PP_TYPES, r.promotype)}</div>
            <div className="hpp-card__row"><span>Start</span><b>{hppFmtDT(r.start)}</b></div>
            <div className="hpp-card__row"><span>End</span><b>{hppFmtDT(r.end)}</b></div>
            <div className="hpp-card__row"><span>Visibility</span><HppHiddenSwitch row={r} onToggle={toggleHidden} canToggle={HPP_ME.isAdmin} /></div>
            <details className="hpp-card__more">
              <summary>More</summary>
              <div className="hpp-card__row"><span>Campaign ID</span><b>{r.pp_campaign_id || "—"}</b></div>
              <div className="hpp-card__row"><span>Campaign slug</span><b>{r.pp_campaign_slug || "—"}</b></div>
              <div className="hpp-card__row"><span>Free sub key</span><b>{r.pp_free_sub_key || "—"}</b></div>
              <div className="hpp-card__row"><span>Order</span><b>{r._order}</b></div>
            </details>
            <div className="hpp-card__acts">
              <button className="btn btn--secondary btn--sm" onClick={() => setForm({ row: r })}><Icon name="edit" size={12} /> Edit</button>
              <button className="btn btn--ghost btn--sm hpp-card__del" onClick={() => setDel(r)}><Icon name="trash" size={12} /> Delete</button>
            </div>
          </>
        )} />

      <HrsPager page={safePage} pageSize={HPP_PAGE_SIZE} total={filtered.length} onPage={setPage} />

      <HppApiRail />

      {form && <HppPromoplayForm row={form.row} onClose={() => setForm(null)} onSave={onSave} />}
      {del && <HppDeleteDialog row={del} kind="promoplay" onClose={() => setDel(null)} onDelete={(r) => setRows(rs => rs.filter(x => x.id !== r.id))} />}
    </HrsShell>
  );
};

/* ---------- window globals (Babel already makes top-level consts implicit globals;
   explicit assignment is the project convention) ---------- */
window.HostCmsProviderPromotions = HostCmsProviderPromotions;
window.HostCmsPromoplayPromotions = HostCmsPromoplayPromotions;
