// Represents: GET /slideshow · SlideshowsController — see docs/ISYSTEM_REFERENCE.md §Batch 4 "Banners (Slideshow)"
/* CMS ▾ → Banners. Screen actions: SlideshowsController::index (L24 — 404s for Customer Care
   without `support_cms_banners`), ::slideshowForm (L61, GET /slideshow/form/?id=, the AJAX modal
   body), ::saveSlideshow (L73, POST /slideshow/saveSlideshow/?id=) and ::delete (L105,
   GET /slideshow/delete/{id}/). Routes routes/admin.php:L964-974 (+ L1625-1627 for delete) — all
   UNNAMED, inside Route::name('admin.')->middleware(['auth','admin','2fa','g2fa']). Enum helpers
   ::statiSlideshows L156 / ::tipologieSlideshow L173. All business logic lives in
   App\Services\SlideshowAdminService (MAX_IMAGES = 6); validation in
   App\Http\Requests\Admin\StoreSlideshowRequest. Views: admin/slideshow/index.blade.php + modal
   admin/slideshow/modals/slideshow.blade.php (generaModalGestione) + AJAX form
   admin/slideshow/forms/slideshow.blade.php with partial _upload-meta and component
   components/admin/slideshow/classic-image-card.blade.php; JS public/js/pages/slideshow/form.js.

   A `slideshows` row is ONE banner set, scoped to (skin_id, page, lang), rendered by the front
   office either as a Blade slideshow (SlideshowsController::showSlideShow → frontend.slideshow.
   default|slick) or through FAPI `GET /api/slideshow` (routes/fapi-v2.php:L45 → Fapi\Slideshow
   Controller::getList → Fapi\SlideshowService → SlideshowRepository). Each row carries up to
   MAX_IMAGES = 6 image slots (img_1..img_6 + their _mobile/_url/_nome_tasto/_priority/_login
   siblings); which of those slots the form exposes, and how, is decided entirely by
   `slideshow_type`:
     · default / slick     → the six classic image cards
     · grid_view           → max 3 drag-ordered grid banners with per-language texts stored as
                             JSON {lang: value} in img_N_tag_name/_title/_subtitle/_nome_tasto,
                             slots 4-6 zeroed, actives compacted into 1-3 by priority on save
     · sign_in_sign_up     → two fixed sections pinned to slots 1 and 2, per-language image JSON
                             in img_1/img_2, active forced to 1

   Known real-platform defects, handled per the repo's known-bug policy (CLAUDE.md):

   1. MOBILE IMAGE DROPPED ON CREATE. `img_*_mobile` is missing from Slideshow::$fillable (it only
      appears in SHOW_COLUMNS L90-95), so Slideshow::create($datip) mass-assignment strips every
      mobile file uploaded while CREATING a classic slideshow; the same fields persist on EDIT
      because that path is a query-builder Slideshow::where(...)->update() which bypasses
      $fillable. Net effect on the real platform: upload desktop + mobile on a new banner, save,
      and the mobile image is silently gone until you re-open and save a second time. The evident
      intent — the form offers the field, the front office reads it, the edit path stores it — is
      implemented here: mobile files survive the create. See hcbSaveRow() and the Tip on the
      mobile uploader in HcbClassicCard.
      <!-- SUGGESTION: add 'img_1_mobile' … 'img_6_mobile' to App\Models\Slideshow::$fillable so SlideshowAdminService's create path stops discarding mobile artwork. Until then the "create with both images" flow needs a second save to stick, and nothing in the UI tells the operator. -->

   2. `whosee` IS WRITTEN, `show_type` IS READ. The form's "Who should see the slideshow" select is
      persisted to slideshows.whosee (SlideshowAdminService L144), but BOTH front-office readers
      filter on a different column — SlideshowsController L212 and SlideshowRepository L27 query
      show_type IN [0,1] when the viewer is authenticated and [0,2] when they are a guest — and
      nothing anywhere in the codebase writes show_type. So on the real platform the visibility
      select does not drive the visibility gate. The reference records this as UNCLEAR (show_type
      may be maintained by some external process), so this page does not pretend the divergence
      away: the evident intent (one visibility choice, honoured by the front office) is implemented
      by writing BOTH columns, and the divergence is stated in the field hint and in the Explainer
      rather than hidden. Both enums use the same 0/1/2 domain, which is what makes the intent
      evident: whosee 0 ALL / 1 Registered users / 2 Guests ≡ show_type 0 everyone / 1 logged-in
      only / 2 guests only.
      <!-- SUGGESTION: decide which column is canonical and delete the other. If `whosee` is canonical, make SlideshowAdminService write show_type from it (or change both readers to filter on whosee); if `show_type` is canonical, expose it in the admin form. Today the operator picks a value that no reader consults, and there is no UI anywhere for the value that every reader consults. -->

   Faithful real-platform behaviour deliberately reproduced, NOT "fixed" (these are documented
   design choices, not bugs, so the known-bug policy does not apply):
   - Grid banners never store mobile artwork — SlideshowAdminService nulls img_N_mobile for
     grid_view. Grid is desktop-image-only and the form says so instead of offering a dead field.
   - Sign In/Sign Up reuses slots 1 and 2 and forces active = 1 on both.
   - Grid slots 4-6 are zeroed on save and the enabled banners are compacted into slots 1-3 by
     priority, so the stored slot number can differ from the on-screen order before saving.
   - The dimensions/mime helper text on the sign-auth uploads ("720 x 868" / "720 x 1477", .png)
     is display-only; only the GRID image is server-validated (image|mimes:png|dimensions:
     width=556,height=494 — SlideshowAdminService L338-345). Classic desktop/mobile files are
     validated as `image` only (L441, L453).
   - "Max size: 20mb" (_upload-meta.blade.php) is a caption; no size rule exists server-side.
   - The list is fixed ORDER BY slideshows.id DESC — no column is sortable.
   - The per-page filter is submitted as `page_id`, not `page`, because `page` is taken by the
     Laravel paginator.
   - Delete is a GET with no CSRF token, gated by canManageBannerForSkin() (own-skin scope;
     out-of-scope rows answer "Permission error"). Reproduced as-is, honestly labelled.
     <!-- SUGGESTION: move /slideshow/delete/{id} to DELETE (or POST) behind the CSRF middleware and give the four slideshow routes names (admin.slideshow.index/form/save/delete) — today every href and every JS call has to hardcode the literal URL, and a destructive action is reachable by a plain link a browser (or a link prefetcher) can follow. -->
     <!-- SUGGESTION: gate SlideshowsController::slideshowForm the way index() and saveSlideshow() are gated. It has NO permission check at all, so any authenticated 2FA'd back-office user can fetch the form HTML — including the skin list — for any slideshow id. -->
   - The modal's "new" title parameter is the leftover Italian "Nuovo slideshow"; the English
     label is used here, see HcbFormModal.
     <!-- SUGGESTION: replace the hardcoded "Nuovo slideshow" modal title with __('backend.new_slideshow'); it is the last Italian string on an otherwise English screen and it reappears every time the operator uses save-and-new. -->
   - Sortable.js is pulled from the jsdelivr CDN and loaded twice (index L25 + form L378), with a
     retry loop in form.js to dodge an async-script race. Not reproduced (this prototype has no
     external dependency); grid reordering here is native drag-and-drop plus keyboard-reachable
     move buttons, which is the same outcome.

   Deliberately NOT built, because the real screen does not have it: no export (none exists), no
   bulk actions, no sortable columns, no KPI cards beyond the Results count + the status chip
   strip, no preview/duplicate/reorder-from-the-list actions, and no restore of any kind.

   Label policy (CLAUDE.md): every `backend.*` key this screen uses that resolves NOWHERE in the
   committed public/default-lang/en/backend.php — slideshow_index_subtitle, slideshow_search_
   placeholder, type_of_banners, every slideshow_form_* key (big_size, grid_banners, remove,
   tag_name, title, subtitle, button_name, keep_background, add_section, sign_in_sign_up_banners,
   banner_link, network, upload_image, dimensions, max_size), user_not_found, select_user,
   results, apply, showing, prev, next, no_records, created — is rendered with a sensible
   operator-facing label carrying an inline "label inferred" marker. Runtime translations live in
   the gitignored storage/lang, so these are inferred, not invented from nothing.

   Mock data: skins are the shared HSD_SKINS/HRBZ_SKINS ids+names used by the rest of the Host
   rebuild, languages the shared HSL_SEED_LANGS set, so one prototype persona spans the build.
   Persona = super admin (isadmin()), which is why the Skin filter renders — see the field Tip. */

const { useState: hcbUseState, useMemo: hcbUseMemo } = React;

/* Deterministic PRNG (FNV-1a + mulberry32), same convention as the sibling Host pages: every
   derived detail (image slots, priorities, notes, network targets) is a pure function of the row
   id, so the list and every form render identically on every load. */
const hcbHash = (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 hcbRng = pbRng;   // was a local copy of mulberry32 — see pbRng in src/data.jsx
const hcbPick = (rnd, arr) => arr[Math.floor(rnd() * arr.length)];

/* SkinsController::skinPages() L4132 — the exact 11 entries, in source order. Feeds both the Page
   filter (submitted as `page_id`) and the form's required Page select. */
const HCB_PAGES = [
  { value: "home", label: "Home" },
  { value: "promotion", label: "Promotion" },
  { value: "race", label: "Race" },
  { value: "casino", label: "Casino" },
  { value: "casino-live", label: "Casino Live" },
  { value: "sport", label: "Sport" },
  { value: "poker", label: "Poker" },
  { value: "virtual", label: "Virtual" },
  { value: "crashgames_list", label: "Crash Games" },
  { value: "tablegames_list", label: "Table Games" },
  { value: "virtual_pragmatic", label: "Virtual Pragmatic" },
];

/* SkinsController::getSkinsListForCurrentUser() (L753) — all skins for a super admin, owned skins
   otherwise. Same ids/names the Report ▾ and Settings ▾ rebuilds use. */
const HCB_SKINS = [
  { id: 47, name: "win24hs" },
  { id: 52, name: "apostando365" },
  { id: 55, name: "apuestadepana" },
  { id: 58, name: "PlaySpin" },
  { id: 60, name: "Anchodeespada" },
  { id: 62, name: "Jokerenvivo" },
  { id: 64, name: "Donjoker" },
  { id: 66, name: "Juegojoker" },
  { id: 68, name: "Tucasino" },
  { id: 70, name: "Jugaygana" },
];

/* `languages` rows via LanguagesController::getLanguages() (cached 24h), English forced first —
   same seed set as the Languages settings screen. Used for the `lang` select (prefixed with the
   literal 'all' option) and for the per-language translation/upload tabs of the grid and
   sign-auth types. */
const HCB_LANGS = [
  { code: "en", name: "English" },
  { code: "es", name: "Español" },
  { code: "pt", name: "Português" },
  { code: "pt_br", name: "Português-Brasil" },
  { code: "it", name: "Italiano" },
  { code: "fr", name: "Français" },
  { code: "de", name: "Deutsch" },
  { code: "tr", name: "Türkçe" },
];

/* tipologieSlideshow() L173. `grid_view` maps to ITSELF on the real platform — the array has no
   human label for it, so the table prints the raw slug. Per the label policy an operator-facing
   label is shown here and the raw value is kept visible in the cell tooltip + the Type column
   header Tip, so nothing is quietly renamed. */
const HCB_TYPES = [
  { value: "default", label: "Default", inferred: false },
  { value: "slick", label: "Slick", inferred: false },
  { value: "grid_view", label: "Grid view", inferred: true },
  { value: "sign_in_sign_up", label: "Sign In/Sign Up Banners", inferred: false },
];

/* statiSlideshows() L156 */
const HCB_STATI = [{ value: "1", label: "Active" }, { value: "0", label: "Disabled" }];

/* whosee 0/1/2 — 0 is __('backend.all_selections'). The same 0/1/2 domain is what the front
   office reads out of show_type (0 everyone / 1 logged-in only / 2 guests only). */
const HCB_WHOSEE = [
  { value: "0", label: "ALL", show: "everyone" },
  { value: "1", label: "Registered users", show: "logged-in only" },
  { value: "2", label: "Guests", show: "guests only" },
];

const HCB_MAX_IMAGES = 6;   // SlideshowAdminService::MAX_IMAGES
const HCB_GRID_MAX = 3;     // grid_view caps at 3 banners; slots 4-6 are zeroed on save
const HCB_PAGE_SIZE = 25;   // listForTable() default (L25) → Laravel paginate(25)

/* UsersController::searchUsers2 (GET /users2, routes/admin.php L504-506). The select2 sends
   user_types: [2,8,10,15,20] and the chosen skin_id, and is disabled until a skin is picked.
   Levels per App\Constants\UserRole: 2 ADMIN (Skin) · 8 MASTER (Agent) · 10 AGENT (Promoter) ·
   15 PROMOTER (Shop) · 20 SHOP (Cashier). */
const HCB_NETWORK_USERS = [
  { id: 1104, username: "jokerenvivo_admin", level: 2, skin: 62, user_path: "/1/62/1104" },
  { id: 1187, username: "centro_agent", level: 8, skin: 62, user_path: "/1/62/1104/1187" },
  { id: 1244, username: "promotor_norte", level: 10, skin: 62, user_path: "/1/62/1104/1187/1244" },
  { id: 1301, username: "shop_palermo", level: 15, skin: 62, user_path: "/1/62/1104/1187/1244/1301" },
  { id: 1355, username: "caja_palermo_1", level: 20, skin: 62, user_path: "/1/62/1104/1187/1244/1301/1355" },
  { id: 2101, username: "jugaygana_admin", level: 2, skin: 70, user_path: "/1/70/2101" },
  { id: 2188, username: "jyg_agente_sur", level: 8, skin: 70, user_path: "/1/70/2101/2188" },
  { id: 2242, username: "jyg_promotor_2", level: 10, skin: 70, user_path: "/1/70/2101/2188/2242" },
  { id: 3102, username: "tucasino_admin", level: 2, skin: 68, user_path: "/1/68/3102" },
  { id: 3190, username: "tc_agente_este", level: 8, skin: 68, user_path: "/1/68/3102/3190" },
  { id: 4103, username: "win24_admin", level: 2, skin: 47, user_path: "/1/47/4103" },
  { id: 4177, username: "w24_agente_1", level: 8, skin: 47, user_path: "/1/47/4103/4177" },
  { id: 5104, username: "donjoker_admin", level: 2, skin: 64, user_path: "/1/64/5104" },
  { id: 6105, username: "juegojoker_admin", level: 2, skin: 66, user_path: "/1/66/6105" },
];
const HCB_LEVELS = { 2: "Skin admin", 8: "Agent", 10: "Promoter", 15: "Shop", 20: "Cashier" };

/* ------------------------------------------------------------------ *
 * Lookups
 * ------------------------------------------------------------------ */
const hcbSkin = (id) => HCB_SKINS.find(s => s.id === Number(id)) || null;
/* skinPages()[$row->page]['name'] with a raw-slug fallback — a page slug that is no longer in the
   map still renders, exactly as the Blade does. */
const hcbPageName = (slug) => { const p = HCB_PAGES.find(x => x.value === slug); return p ? p.label : slug; };
const hcbType = (v) => HCB_TYPES.find(t => t.value === v) || { value: v, label: v, inferred: false };
const hcbLangName = (code) => { const l = HCB_LANGS.find(x => x.code === code); return l ? l.name : String(code || "").toUpperCase(); };
const hcbInitials = (name) => String(name || "").trim().split(/[\s_-]+/).slice(0, 2).map(w => w.charAt(0).toUpperCase()).join("") || "?";
const HCB_MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
/* date('j M Y, H:i', addedTime) — `addedTime` is a unix-timestamp column (model getDateFormat()
   returns 'U'); an empty value renders "—". */
const hcbCreated = (t) => {
  if (!t) return null;
  const d = new Date(t * 1000);
  return `${d.getDate()} ${HCB_MONTHS[d.getMonth()]} ${d.getFullYear()}, ${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
};
const hcbUnix = (y, m, d, h, mi) => Math.floor(new Date(y, m - 1, d, h, mi, 0).getTime() / 1000);

/* ------------------------------------------------------------------ *
 * `slideshows` rows. Fixed ORDER BY slideshows.id DESC — the list has no
 * sortable column, so the seed is authored already in display order.
 * ------------------------------------------------------------------ */
const HCB_SEED = [
  { id: 79, skin_id: 62, nome: "signup", page: "home", lang: "all", type: "sign_in_sign_up", stato: 1, addedTime: hcbUnix(2026, 3, 24, 17, 15) },
  { id: 78, skin_id: 62, nome: "Prod_Banners", page: "home", lang: "all", type: "grid_view", stato: 1, addedTime: hcbUnix(2026, 3, 9, 16, 39) },
  { id: 77, skin_id: 70, nome: "Home main ES", page: "home", lang: "es", type: "default", stato: 1, addedTime: hcbUnix(2026, 3, 2, 11, 4) },
  { id: 76, skin_id: 47, nome: "Casino Live hero", page: "casino-live", lang: "all", type: "slick", stato: 1, addedTime: hcbUnix(2026, 2, 25, 9, 22) },
  { id: 75, skin_id: 64, nome: "signup", page: "home", lang: "all", type: "sign_in_sign_up", stato: 1, addedTime: hcbUnix(2026, 3, 24, 17, 15) },
  { id: 74, skin_id: 64, nome: "Prod_Banners", page: "home", lang: "all", type: "grid_view", stato: 1, addedTime: hcbUnix(2026, 3, 9, 16, 39) },
  { id: 73, skin_id: 52, nome: "Promos febrero", page: "promotion", lang: "es", type: "default", stato: 0, addedTime: hcbUnix(2026, 2, 11, 15, 47) },
  { id: 72, skin_id: 68, nome: "Table games", page: "tablegames_list", lang: "all", type: "default", stato: 1, addedTime: hcbUnix(2026, 2, 4, 10, 18) },
  { id: 71, skin_id: 66, nome: "signup", page: "home", lang: "all", type: "sign_in_sign_up", stato: 1, addedTime: hcbUnix(2026, 3, 24, 17, 15) },
  { id: 70, skin_id: 66, nome: "Prod_Banners", page: "home", lang: "all", type: "grid_view", stato: 1, addedTime: hcbUnix(2026, 3, 9, 16, 39) },
  { id: 69, skin_id: 55, nome: "Sport top", page: "sport", lang: "es", type: "slick", stato: 1, addedTime: hcbUnix(2026, 1, 28, 8, 55) },
  { id: 68, skin_id: 58, nome: "Virtual Pragmatic", page: "virtual_pragmatic", lang: "all", type: "default", stato: 0, addedTime: hcbUnix(2026, 1, 20, 13, 31) },
  { id: 67, skin_id: 60, nome: "Poker lobby", page: "poker", lang: "all", type: "default", stato: 1, addedTime: hcbUnix(2026, 1, 12, 17, 2) },
  { id: 66, skin_id: 70, nome: "Jugaygana Live", page: "casino-live", lang: "all", type: "default", stato: 1, addedTime: hcbUnix(2025, 11, 25, 10, 51) },
  { id: 65, skin_id: 47, nome: "Race weekly", page: "race", lang: "en", type: "default", stato: 0, addedTime: hcbUnix(2025, 11, 21, 16, 20) },
  { id: 64, skin_id: 47, nome: "Casino", page: "home", lang: "all", type: "default", stato: 1, addedTime: hcbUnix(2025, 11, 19, 18, 55) },
  { id: 63, skin_id: 68, nome: "tucasino banner", page: "home", lang: "all", type: "grid_view", stato: 1, addedTime: hcbUnix(2025, 11, 13, 13, 18) },
  { id: 62, skin_id: 70, nome: "Crash Games", page: "crashgames_list", lang: "all", type: "default", stato: 1, addedTime: hcbUnix(2025, 10, 30, 10, 6) },
  { id: 61, skin_id: 52, nome: "Apostando365CasinoLive", page: "casino-live", lang: "all", type: "default", stato: 1, addedTime: hcbUnix(2025, 10, 16, 10, 4) },
  { id: 60, skin_id: 52, nome: "Apostando365Crash", page: "crashgames_list", lang: "all", type: "default", stato: 0, addedTime: hcbUnix(2025, 10, 16, 10, 3) },
  { id: 59, skin_id: 68, nome: "Casino", page: "home", lang: "all", type: "default", stato: 0, addedTime: hcbUnix(2025, 10, 9, 14, 7) },
  { id: 58, skin_id: 62, nome: "Home main", page: "home", lang: "all", type: "grid_view", stato: 0, addedTime: hcbUnix(2025, 10, 2, 9, 33) },
  { id: 57, skin_id: 64, nome: "Promotion PT", page: "promotion", lang: "pt", type: "default", stato: 1, addedTime: hcbUnix(2025, 9, 24, 12, 40) },
  { id: 56, skin_id: 66, nome: "Casino", page: "casino", lang: "all", type: "slick", stato: 1, addedTime: hcbUnix(2025, 9, 15, 11, 11) },
  { id: 55, skin_id: 60, nome: "signup", page: "home", lang: "all", type: "sign_in_sign_up", stato: 0, addedTime: hcbUnix(2025, 9, 3, 19, 26) },
  { id: 54, skin_id: 58, nome: "Sport IT", page: "sport", lang: "it", type: "default", stato: 1, addedTime: hcbUnix(2025, 8, 27, 9, 48) },
  { id: 53, skin_id: 55, nome: "Home", page: "home", lang: "all", type: "default", stato: 1, addedTime: hcbUnix(2025, 8, 14, 15, 5) },
  /* addedTime is nullable in practice on very old rows — the Blade prints "—". */
  { id: 52, skin_id: 47, nome: "Virtual", page: "virtual", lang: "all", type: "default", stato: 0, addedTime: 0 },
  { id: 51, skin_id: 70, nome: "Table games", page: "tablegames_list", lang: "all", type: "slick", stato: 1, addedTime: hcbUnix(2025, 7, 21, 14, 12) },
  { id: 50, skin_id: 62, nome: "Casino Live", page: "casino-live", lang: "all", type: "default", stato: 1, addedTime: hcbUnix(2025, 7, 9, 8, 36) },
];

const HCB_HEADLINES = ["Bono de bienvenida 150%", "Ruleta en vivo 24/7", "Crash x1000", "Torneo semanal", "Cashback los lunes", "Apuesta y gana", "Nuevos slots", "Giros gratis"];
const HCB_TAGS = ["NUEVO", "PROMO", "VIP", "HOT", "EXCLUSIVO"];
const HCB_BTNS = ["Jugar ahora", "Ver más", "Registrarse", "Depositar", "Participar"];

const hcbBlankSlot = (n) => ({
  n, active: false, img: "", mobile: "", url: "", nome_tasto: "", priority: String(n), login: false,
});
const hcbBlankGrid = (n) => ({
  n, order: n, enabled: n === 1, image: "", image_existing: "", keep_background: false,
  url: "", translations: {},
});
const hcbBlankAuth = (section) => ({ section, images: {}, url: "" });

/* Image payload for a row, derived deterministically from its id. Real column shapes: classic
   rows fill img_N/_mobile/_url/_nome_tasto/_priority/_login; grid rows fill img_N plus the four
   JSON {lang: value} text columns and share one img_N_url; sign-auth rows fill img_1/img_2 with
   a JSON {lang: filename} map and one img_N_url each.
   All three shapes are always allocated (blank where the type does not use them) because the six
   img_* column families exist on EVERY row: the real form is rendered server-side with all three
   sections present and form.js only toggles which one is visible, so changing Type of banners
   mid-edit has to land on a usable section rather than an empty one. */
const hcbEmptySlots = () => Array.from({ length: HCB_MAX_IMAGES }, (_, i) => hcbBlankSlot(i + 1));
const hcbEmptyGrid = () => Array.from({ length: HCB_GRID_MAX }, (_, i) => hcbBlankGrid(i + 1));
const hcbEmptyAuth = () => [hcbBlankAuth("sign_in"), hcbBlankAuth("sign_up")];
const hcbBuildMedia = (row) => {
  const rnd = hcbRng(hcbHash(`slideshow|${row.id}|${row.type}`));
  const slug = String(row.nome).toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_|_$/g, "") || "banner";
  if (row.type === "grid_view") {
    const count = 1 + Math.floor(rnd() * HCB_GRID_MAX);
    const grid = [];
    for (let i = 1; i <= HCB_GRID_MAX; i++) {
      const g = hcbBlankGrid(i);
      g.enabled = i <= count;
      if (g.enabled) {
        g.image_existing = `${slug}_grid_${i}_${Math.floor(rnd() * 9000 + 1000)}.png`;
        g.keep_background = rnd() > 0.6;
        g.url = i === 1 ? "/promotions" : `/promotions/${slug}-${i}`;
        HCB_LANGS.forEach((l, li) => {
          if (li > 2 && rnd() > 0.35) return;  // not every language is translated
          g.translations[l.code] = {
            tag_name: hcbPick(rnd, HCB_TAGS),
            title: hcbPick(rnd, HCB_HEADLINES),
            subtitle: "Términos y condiciones aplican",
            button_text: hcbPick(rnd, HCB_BTNS),
          };
        });
      }
      grid.push(g);
    }
    return { slots: hcbEmptySlots(), grid, auth: hcbEmptyAuth() };
  }
  if (row.type === "sign_in_sign_up") {
    const auth = ["sign_in", "sign_up"].map((section, idx) => {
      const a = hcbBlankAuth(section);
      a.url = idx === 0 ? "/login" : "/signup";
      HCB_LANGS.forEach((l, li) => {
        if (li > 1 && rnd() > 0.45) return;
        a.images[l.code] = `${slug}_${section}_${l.code}.png`;
      });
      return a;
    });
    return { slots: hcbEmptySlots(), grid: hcbEmptyGrid(), auth };
  }
  const count = 1 + Math.floor(rnd() * 4);
  const slots = [];
  for (let i = 1; i <= HCB_MAX_IMAGES; i++) {
    const s = hcbBlankSlot(i);
    if (i <= count) {
      s.active = true;
      s.img = `${slug}_${i}_${Math.floor(rnd() * 9000 + 1000)}.jpg`;
      /* Some rows carry no mobile artwork at all — on the real platform that is often not a
         choice but bug #1 above: the file was uploaded on the create form and dropped. */
      s.mobile = rnd() > 0.4 ? `${slug}_${i}_mobile_${Math.floor(rnd() * 9000 + 1000)}.jpg` : "";
      s.url = i === 1 ? "/casino" : `/casino/${slug}-${i}`;
      s.nome_tasto = rnd() > 0.5 ? hcbPick(rnd, HCB_BTNS) : "";
      s.priority = String(i);
      s.login = rnd() > 0.7;
    }
    slots.push(s);
  }
  return { slots, grid: hcbEmptyGrid(), auth: hcbEmptyAuth() };
};

const hcbBuildRows = () => HCB_SEED.map(r => {
  const rnd = hcbRng(hcbHash(`meta|${r.id}|${r.nome}`));
  /* `whosee` is nullable in validation but the label carries obbligatorio(); rows created before
     the field existed can legitimately be null, which is why the select keeps an empty option. */
  const whosee = rnd() > 0.75 ? String(1 + Math.floor(rnd() * 2)) : "0";
  const targeted = rnd() > 0.8;
  const candidates = HCB_NETWORK_USERS.filter(u => u.skin === r.skin_id);
  const target = targeted && candidates.length ? candidates[Math.floor(rnd() * candidates.length)] : null;
  return {
    ...r,
    is_big: r.type === "default" && rnd() > 0.65 ? 1 : 0,
    whosee,
    /* Evident intent (bug #2): the prototype keeps show_type in step with whosee. On the real
       platform this column is whatever some out-of-band process left in it. */
    show_type: whosee,
    user_id: target ? target.id : null,
    user_path: target ? target.user_path : "",
    note: rnd() > 0.7 ? "Rotación aprobada por marketing." : "",
    media: hcbBuildMedia(r),
  };
});

const hcbNewRow = () => ({
  id: null, skin_id: "", nome: "", page: "", lang: "all", type: "default", stato: 0,
  is_big: 0, whosee: "0", show_type: "0", user_id: null, user_path: "", note: "",
  addedTime: 0,
  media: { slots: hcbEmptySlots(), grid: hcbEmptyGrid(), auth: hcbEmptyAuth() },
});

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

/* Settings.jsx-style sectioned panel: a titled block that can carry its own Explainer callout. */
const HcbSection = ({ title, hint, right, children }) => (
  <div className="hcb-sec">
    <div className="hcb-sec__head">
      <div className="hcb-sec__title">{title}</div>
      {right}
    </div>
    {hint && <div className="hcb-sec__hint">{hint}</div>}
    <div className="hcb-sec__body">{children}</div>
  </div>
);

const HcbField = ({ label, required, tip, hint, error, children, wide }) => (
  <div className={`hcb-field${wide ? " hcb-field--wide" : ""}`}>
    {label && (
      <label className="hcb-label">
        {label}{required && <span className="hcb-req">*</span>}
        {tip && <Tip size={12}>{tip}</Tip>}
      </label>
    )}
    {children}
    {hint && <div className="hcb-hint">{hint}</div>}
    {error && <div className="hcb-fielderr">{error}</div>}
  </div>
);

const HcbCheck = ({ checked, onChange, label, disabled, tip }) => (
  <label className={`hcb-check${disabled ? " hcb-check--off" : ""}`}>
    <input type="checkbox" checked={!!checked} disabled={disabled} onChange={e => onChange(e.target.checked)} />
    <span>{label}</span>
    {tip && <Tip size={12}>{tip}</Tip>}
  </label>
);

/* ------------------------------------------------------------------ *
 * Upload widget. Real markup: a file input plus the _upload-meta partial
 * ("Upload image" / dimensions / "Max size: 20mb"). Nothing uploads in the
 * prototype — the picker records the chosen filename so the rest of the
 * form behaves exactly as it would with a stored file. Saved files land in
 * storeAs('public/slideshow', …) and are then @copy'd to
 * config('media.MEDIA_SLIDESHOWS_ROOT_PATH').
 * ------------------------------------------------------------------ */
const HcbUpload = ({ id, label, accept, value, existing, onChange, dimensions, tip, error, note }) => (
  <div className={`hcb-up${error ? " hcb-up--err" : ""}`}>
    <div className="hcb-up__lab">
      {label}
      {tip && <Tip size={12}>{tip}</Tip>}
    </div>
    <div className="hcb-up__row">
      <div className="hcb-up__thumb" aria-hidden="true">
        <Icon name={value || existing ? "eye" : "upload"} size={15} />
      </div>
      <div className="hcb-up__main">
        <label className="hcb-up__btn" htmlFor={id}>
          <Icon name="upload" size={12} /> Upload image{/* label inferred — backend.slideshow_form_upload_image */}
        </label>
        <input id={id} type="file" accept={accept} className="hcb-up__input"
          onChange={e => onChange(e.target.files && e.target.files[0] ? e.target.files[0].name : "")} />
        <div className="hcb-up__name">
          {value
            ? <><b>{value}</b> <span className="hcb-up__new">new</span></>
            : existing
              ? <span title="Stored filename">{existing}</span>
              : <span className="hcb-up__none">No file selected</span>}
        </div>
        <div className="hcb-up__meta">
          {accept && <span>{accept.split(",").join(" / ")}</span>}
          {dimensions && <span>{dimensions}</span>}
          {/* backend.slideshow_form_max_size — caption only, never enforced anywhere server-side */}
          <span className="hcb-up__softcap">Max size: 20mb <Tip size={11}>Display-only caption from <code>_upload-meta.blade.php</code>. No size rule exists in <code>StoreSlideshowRequest</code> or <code>SlideshowAdminService</code>, so a larger file is rejected by PHP's upload limits, not by the application.</Tip></span>
        </div>
        {note && <div className="hcb-up__note">{note}</div>}
      </div>
      {(value || existing) && (
        <button type="button" className="hcb-up__clear" title="Remove file" onClick={() => onChange("")}>
          <Icon name="x" size={12} />
        </button>
      )}
    </div>
    {error && <div className="hcb-fielderr">{error}</div>}
  </div>
);

/* ------------------------------------------------------------------ *
 * Classic image card (types default + slick) — one per img_1..img_6.
 * Mirrors components/admin/slideshow/classic-image-card.blade.php.
 * ------------------------------------------------------------------ */
const HcbClassicCard = ({ slot, onPatch, isNew }) => (
  <div className={`hcb-imgcard${slot.active ? "" : " hcb-imgcard--off"}`}>
    <div className="hcb-imgcard__head">
      <span className="hcb-imgcard__n">Banner {slot.n}</span>
      <HcbCheck checked={slot.active} onChange={v => onPatch({ active: v })} label="Active" />
    </div>
    <div className="hcb-imgcard__body">
      <HcbUpload id={`hcb-img-${slot.n}`} label="Desktop image" accept=".png,.jpg,.jpeg"
        value={slot.imgNew} existing={slot.img}
        onChange={v => onPatch({ imgNew: v, img: v ? slot.img : "" })}
        tip={<>Field <code>img_{slot.n}</code>. The input accepts <code>.png,.jpg,.jpeg</code>, but the server rule is just <code>image</code> (SlideshowAdminService L441) — no mime or dimension check.</>} />

      {/* KNOWN BUG — DIVERGENCE (see header #1): img_*_mobile is absent from Slideshow::$fillable,
          so the real create path drops whatever is uploaded here and only a later edit persists
          it. Evident intent implemented: this prototype keeps the file on create too. */}
      <HcbUpload id={`hcb-imgm-${slot.n}`} label="Mobile image" accept=".png,.jpg,.jpeg"
        value={slot.mobileNew} existing={slot.mobile}
        onChange={v => onPatch({ mobileNew: v, mobile: v ? slot.mobile : "" })}
        tip={<>Field <code>img_{slot.n}_mobile</code>. On the real platform this column is missing from <code>Slideshow::$fillable</code>, so <b>a mobile image uploaded while creating a new slideshow is silently discarded</b> — it only sticks if you save the row a second time from the edit form. This prototype implements the evident intent and keeps it on create.</>}
        note={isNew ? <><Icon name="alert" size={11} /> Kept on create here; the real platform would drop it until the next save.</> : null} />

      <HcbField label="Link">{/* img_N_url */}
        <input className="input" value={slot.url} placeholder="/casino" onChange={e => onPatch({ url: e.target.value })} />
      </HcbField>
      <HcbField label="Button text">{/* img_N_nome_tasto */}
        <input className="input" value={slot.nome_tasto} placeholder="Jugar ahora" onChange={e => onPatch({ nome_tasto: e.target.value })} />
      </HcbField>
      <HcbField label="Priority" tip={<>Field <code>img_{slot.n}_priority</code> — a free-text field, not validated. It orders the slides inside the set.</>}>
        <input className="input" value={slot.priority} inputMode="numeric" onChange={e => onPatch({ priority: e.target.value })} />
      </HcbField>
      <HcbField label="Login button" tip={<>Field <code>img_{slot.n}_login</code> — renders the slide's call-to-action as a login button in the front office.</>}>
        <Toggle value={!!slot.login} onChange={v => onPatch({ login: v })} onLabel="On" offLabel="Off" size="sm" />
      </HcbField>
    </div>
  </div>
);

/* ------------------------------------------------------------------ *
 * Grid banner (type grid_view) — max 3, drag-reorder (the real form uses
 * Sortable.js from a CDN; native DnD + move buttons here), Add/Remove
 * section, banner 1 non-removable and forced enabled.
 * ------------------------------------------------------------------ */
const HcbGridBanner = ({ banner, index, count, lang, onLang, onPatch, onMove, onRemove, onDragStart, onDragOver, onDrop, error }) => {
  const tr = banner.translations[lang] || {};
  const patchTr = (patch) => onPatch({ translations: { ...banner.translations, [lang]: { ...tr, ...patch } } });
  const first = index === 0;
  return (
    <div className={`hcb-grid-b${banner.enabled ? "" : " hcb-grid-b--off"}`}
      draggable onDragStart={onDragStart} onDragOver={onDragOver} onDrop={onDrop}>
      <div className="hcb-grid-b__head">
        <span className="hcb-grip" title="Drag to reorder"><Icon name="more" size={14} /></span>
        <span className="hcb-imgcard__n">Banner {index + 1}</span>
        <span className="hcb-slotchip" title="Stored slot — active banners are compacted into slots 1-3 by priority when the form is saved">slot img_{banner.n}</span>
        <div className="hcb-grid-b__acts">
          <button type="button" className="hcb-act" title="Move up" disabled={index === 0} onClick={() => onMove(-1)}><Icon name="arrow_up" size={12} /></button>
          <button type="button" className="hcb-act" title="Move down" disabled={index === count - 1} onClick={() => onMove(1)}><Icon name="arrow_down" size={12} /></button>
          {/* banner 1 is non-removable and force-enabled by form.js */}
          <HcbCheck checked={first ? true : banner.enabled} disabled={first} label="Enabled"
            onChange={v => onPatch({ enabled: v })}
            tip={first ? <>The first grid banner cannot be disabled or removed — <code>form.js</code> forces <code>grid_banners[1][enabled] = 1</code>.</> : null} />
          <button type="button" className="hcb-act hcb-act--danger" title="Remove section" disabled={first} onClick={onRemove}>
            <Icon name="trash" size={12} />{/* label inferred — backend.slideshow_form_remove */}
          </button>
        </div>
      </div>

      <div className="hcb-grid-b__body">
        <HcbUpload id={`hcb-grid-${banner.n}`} label="Background image" accept=".png"
          value={banner.image} existing={banner.image_existing}
          onChange={v => onPatch({ image: v })}
          dimensions="Exactly 556 x 494 px"
          error={error}
          tip={<>Field <code>grid_banner_{banner.n}_image</code> — the ONLY upload on this screen with a real server rule: <code>image|mimes:png|dimensions:width=556,height=494</code> (SlideshowAdminService L338-345). A JPEG or an off-size PNG is rejected on save.</>} />

        <HcbCheck checked={banner.keep_background} label="Keep background"
          onChange={v => onPatch({ keep_background: v })}
          tip={<>Field <code>grid_banners[{banner.n}][keep_background]</code>. Keeps the currently stored artwork when no new file is chosen.</>} />{/* label inferred */}

        <div className="hcb-langbar" role="tablist">
          <span className="hcb-langbar__lab">Language</span>
          {HCB_LANGS.map(l => (
            <button key={l.code} type="button" role="tab" aria-selected={lang === l.code}
              className={`hcb-langpill${lang === l.code ? " is-on" : ""}${banner.translations[l.code] ? " has-val" : ""}`}
              onClick={() => onLang(l.code)}>{l.name}</button>
          ))}
        </div>

        <div className="hcb-grid-b__tr">
          <HcbField label="Tag name" hint={`Stored as JSON {lang: value} in img_${banner.n}_tag_name`}>{/* label inferred */}
            <input className="input" value={tr.tag_name || ""} placeholder="NUEVO" onChange={e => patchTr({ tag_name: e.target.value })} />
          </HcbField>
          <HcbField label="Title" hint={`img_${banner.n}_title`}>{/* label inferred */}
            <input className="input" value={tr.title || ""} onChange={e => patchTr({ title: e.target.value })} />
          </HcbField>
          <HcbField label="Subtitle" hint={`img_${banner.n}_subtitle`} wide>{/* label inferred */}
            <input className="input" value={tr.subtitle || ""} onChange={e => patchTr({ subtitle: e.target.value })} />
          </HcbField>
          <HcbField label="Button name" hint={`img_${banner.n}_nome_tasto`}>{/* label inferred */}
            <input className="input" value={tr.button_text || ""} onChange={e => patchTr({ button_text: e.target.value })} />
          </HcbField>
          <HcbField label="Button link"
            tip={<>The per-language <code>button_link</code> inputs are mirrored by <code>form.js</code> into one shared value and stored in a single <code>img_{banner.n}_url</code> column — so this link is the same for every language, by design.</>}
            hint="Shared across all languages">{/* label inferred — backend.slideshow_form_banner_link */}
            <input className="input" value={banner.url} placeholder="/promotions" onChange={e => onPatch({ url: e.target.value })} />
          </HcbField>
        </div>
      </div>
    </div>
  );
};

/* ------------------------------------------------------------------ *
 * Sign In / Sign Up section (type sign_in_sign_up) — two fixed sections
 * pinned to slots 1 and 2, per-language upload, one shared link each.
 * ------------------------------------------------------------------ */
const HcbAuthBanner = ({ banner, slot, title, dims, lang, onLang, onPatch }) => (
  <div className="hcb-authb">
    <div className="hcb-authb__head">
      <div className="hcb-imgcard__n">{title}</div>
      <span className="hcb-slotchip" title="Fixed storage slot">slot img_{slot}</span>
      <span className="hcb-forced" title="SlideshowAdminService forces active = 1 on both sign-auth slots">always active</span>
    </div>
    <div className="hcb-authb__body">
      <div className="hcb-langbar" role="tablist">
        <span className="hcb-langbar__lab">Language</span>
        {HCB_LANGS.map(l => (
          <button key={l.code} type="button" role="tab" aria-selected={lang === l.code}
            className={`hcb-langpill${lang === l.code ? " is-on" : ""}${banner.images[l.code] ? " has-val" : ""}`}
            onClick={() => onLang(l.code)}>{l.name}</button>
        ))}
      </div>
      <HcbUpload id={`hcb-auth-${banner.section}`} label={`Image — ${hcbLangName(lang)}`} accept=".png"
        existing={banner.images[lang] || ""}
        value=""
        onChange={v => onPatch({ images: { ...banner.images, [lang]: v } })}
        dimensions={dims}
        tip={<>Field <code>auth_banner_{banner.section}_image_{lang}</code>, stored as JSON <code>{"{lang: filename}"}</code> in <code>img_{slot}</code>. The dimensions and the <code>.png</code> restriction are helper text only — <b>neither is validated server-side</b> for this type.</>} />
      <HcbField label="Banner link" hint={`Shared across languages → img_${slot}_url`}>{/* label inferred */}
        <input className="input" value={banner.url} placeholder="/signup" onChange={e => onPatch({ url: e.target.value })} />
      </HcbField>
    </div>
  </div>
);

/* ------------------------------------------------------------------ *
 * Network picker — the real control is a select2 fed by GET /users2
 * (UsersController::searchUsers2) with user_types [2,8,10,15,20] and the
 * chosen skin_id, DISABLED until a skin is selected. On save the chosen
 * user's `user_path` is denormalised onto the slideshow row; the front
 * office then shows the banner only to viewers whose own user_path starts
 * with it, otherwise falling back to a row with an empty user_path
 * (SlideshowsController L216-224).
 * ------------------------------------------------------------------ */
const HcbNetworkPicker = ({ skinId, value, onChange, error }) => {
  const [q, setQ] = hcbUseState("");
  const [open, setOpen] = hcbUseState(false);
  const disabled = !skinId;
  const chosen = HCB_NETWORK_USERS.find(u => u.id === Number(value)) || null;
  const pool = HCB_NETWORK_USERS.filter(u => u.skin === Number(skinId));
  const hits = q.trim()
    ? pool.filter(u => u.username.toLowerCase().indexOf(q.trim().toLowerCase()) !== -1 || String(u.id) === q.trim())
    : pool;
  return (
    <div className={`hcb-net${disabled ? " hcb-net--off" : ""}${error ? " hcb-net--err" : ""}`}>
      {chosen ? (
        <div className="hcb-net__chosen">
          <span className="hcb-net__u">{chosen.username}</span>
          <span className="hcb-net__lvl">{HCB_LEVELS[chosen.level]}</span>
          <code className="hcb-net__path">{chosen.user_path}</code>
          <button type="button" className="hcb-act" title="Clear" onClick={() => onChange(null)}><Icon name="x" size={12} /></button>
        </div>
      ) : (
        <>
          <input className="input" disabled={disabled} value={q}
            placeholder={disabled ? "Select a skin first" : "Search user…"}/* label inferred — backend.select_user */
            onFocus={() => setOpen(true)} onChange={e => { setQ(e.target.value); setOpen(true); }} />
          {open && !disabled && <div className="hcb-net__scrim" onClick={() => setOpen(false)} />}
          {open && !disabled && (
            <div className="hcb-net__pop">
              {hits.length === 0 && <div className="hcb-net__none">No user found for this skin.</div>}
              {hits.map(u => (
                <button key={u.id} type="button" className="hcb-net__opt"
                  onClick={() => { onChange(u.id); setOpen(false); setQ(""); }}>
                  <span className="hcb-net__u">{u.username}</span>
                  <span className="hcb-net__lvl">{HCB_LEVELS[u.level]}</span>
                  <code className="hcb-net__path">{u.user_path}</code>
                </button>
              ))}
            </div>
          )}
        </>
      )}
    </div>
  );
};

/* ------------------------------------------------------------------ *
 * Create / Edit form. GET /slideshow/form/?id=<id> → modal → POST
 * /slideshow/saveSlideshow/?id=<id>, gated at save by
 * canManageBannerForSkin(). Validation mirrors StoreSlideshowRequest:
 * failedValidation() answers ajaxError JSON carrying a `campierrati`
 * field list, which the shared modal turns into red field outlines.
 * ------------------------------------------------------------------ */
const HcbFormModal = ({ row, onClose, onSave }) => {
  const isNew = row.id == null;
  const [f, setF] = hcbUseState(() => JSON.parse(JSON.stringify(row)));
  const [lang, setLang] = hcbUseState("en");
  const [errs, setErrs] = hcbUseState({});
  const [banner, setBanner] = hcbUseState("");
  const [drag, setDrag] = hcbUseState(null);

  const set = (patch) => { setF(x => ({ ...x, ...patch })); setBanner(""); };
  const clearErr = (k) => setErrs(x => { const n = { ...x }; delete n[k]; return n; });
  const patchSlot = (i, patch) => setF(x => {
    const slots = x.media.slots.slice(); slots[i] = { ...slots[i], ...patch };
    return { ...x, media: { ...x.media, slots } };
  });
  const patchGrid = (i, patch) => setF(x => {
    const grid = x.media.grid.slice(); grid[i] = { ...grid[i], ...patch };
    return { ...x, media: { ...x.media, grid } };
  });
  const patchAuth = (i, patch) => setF(x => {
    const auth = x.media.auth.slice(); auth[i] = { ...auth[i], ...patch };
    return { ...x, media: { ...x.media, auth } };
  });
  const moveGrid = (i, d) => setF(x => {
    const grid = x.media.grid.slice(); const j = i + d;
    if (j < 0 || j >= grid.length) return x;
    const t = grid[i]; grid[i] = grid[j]; grid[j] = t;
    return { ...x, media: { ...x.media, grid } };
  });

  const isClassic = f.type === "default" || f.type === "slick";
  const isGrid = f.type === "grid_view";
  const isAuth = f.type === "sign_in_sign_up";
  const gridShown = f.media.grid.filter((g, i) => i === 0 || g.enabled);

  const save = () => {
    /* StoreSlideshowRequest: nome required|string · page required · skin_id required ·
       lang required|string · slideshow_type required|Rule::in(default,slick,grid_view,
       sign_in_sign_up) · whosee nullable|in:0,1,2 · is_big nullable|in:0,1 · stato nullable|
       in:0,1. prepareForValidation() coerces is_big/stato to 0/1. Plus the service-side rules:
       the grid image is required + PNG + exactly 556x494, and a Network user that does not
       resolve fails with backend.user_not_found. */
    const e = {};
    if (!String(f.nome).trim()) e.nome = "Insert a name";
    if (!f.page) e.page = "Select a page";
    if (!f.skin_id) e.skin_id = "Select a skin";
    if (!f.lang) e.lang = "Select a language";
    if (!f.type) e.slideshow_type = "Select a type of banners";
    if (isGrid) {
      f.media.grid.forEach((g, i) => {
        if ((i === 0 || g.enabled) && !g.image && !g.image_existing) e[`grid_${g.n}`] = "A 556 x 494 PNG is required for this banner";
      });
    }
    if (f.user_id && !HCB_NETWORK_USERS.some(u => u.id === Number(f.user_id))) e.user_id = "User not found";/* label inferred — backend.user_not_found */
    setErrs(e);
    const keys = Object.keys(e);
    if (keys.length) { setBanner(e[keys[0]]); return; }
    setBanner("");
    onSave(f, isNew);
    onClose();
  };

  const skinOpts = HCB_SKINS;

  return (
    <HcbModal
      size="wide"
      /* The real modal's "new" title parameter is the leftover Italian "Nuovo slideshow"
         (see header). English label used here; the Italian string is flagged, not shipped. */
      title={isNew ? <>New slideshow{/* label inferred — real modal title is the hardcoded Italian "Nuovo slideshow" */}</> : `Edit ${f.nome}`}
      sub={isNew
        ? <>POST <code>/slideshow/saveSlideshow/</code> · gated by <code>canManageBannerForSkin()</code></>
        : <>ID {f.id} · POST <code>/slideshow/saveSlideshow/?id={f.id}</code></>}
      onClose={onClose}
      footer={<>
        <button className="btn btn--secondary" onClick={onClose}>Close</button>
        <button className="btn btn--primary" onClick={save}><Icon name="check" size={13} /> Save</button>
      </>}>

      {banner && <div className="hcb-err hcb-err--banner"><Icon name="alert" size={13} /> {banner}</div>}

      <HcbSection title="Data"
        hint={<>Identity of the banner set. The triple <b>skin + page + language</b> is what the front office matches on when it asks for a slideshow — two rows with the same triple both qualify, and the network targeting below breaks the tie.</>}>
        <div className="hcb-grid2">
          <HcbField label="Name" required error={errs.nome}>
            <input className={`input${errs.nome ? " hcb-invalid" : ""}`} autoFocus value={f.nome}
              onChange={e => { set({ nome: e.target.value }); clearErr("nome"); }} />
          </HcbField>

          <HcbField label="Page" required error={errs.page}
            tip={<>Stored in <code>slideshows.page</code> as the slug. The list filter submits the same value as <code>page_id</code>, not <code>page</code>, because <code>page</code> is reserved by the Laravel paginator.</>}>
            <select className={`select${errs.page ? " hcb-invalid" : ""}`} value={f.page}
              onChange={e => { set({ page: e.target.value }); clearErr("page"); }}>
              <option value="">Select option</option>
              {HCB_PAGES.map(p => <option key={p.value} value={p.value}>{p.label}</option>)}
            </select>
          </HcbField>

          <HcbField label="Skin" required error={errs.skin_id}
            tip={<>A super admin and Customer Care get this select (options limited to the skins they own); everyone else gets a hidden input pinned to <code>Auth::user()-&gt;skin_id</code>. The save re-checks scope with <code>canManageBannerForSkin()</code>, so picking a skin outside your own answers "Permission error".</>}>
            <select className={`select${errs.skin_id ? " hcb-invalid" : ""}`} value={f.skin_id}
              onChange={e => { set({ skin_id: e.target.value, user_id: null, user_path: "" }); clearErr("skin_id"); }}>
              <option value="">Select option</option>
              {skinOpts.map(s => <option key={s.id} value={s.id}>{s.name}</option>)}
            </select>
          </HcbField>

          <HcbField label="Type of banners" required error={errs.slideshow_type}
            tip={<>Field <code>slideshow_type</code>. It decides which section below renders and how the six image slots are used. <code>grid_view</code> has no human label on the real platform — <code>tipologieSlideshow()</code> maps it to itself, so the list prints the raw slug.</>}>
            <select className={`select${errs.slideshow_type ? " hcb-invalid" : ""}`} value={f.type}
              onChange={e => { set({ type: e.target.value }); clearErr("slideshow_type"); }}>
              {HCB_TYPES.map(t => <option key={t.value} value={t.value}>{t.label}{t.inferred ? " (grid_view)" : ""}</option>)}
            </select>
          </HcbField>

          {/* form.js hides the language select for grid/sign-auth types — those store their texts
              and images per language internally — but the field is still submitted, and it is
              still `required` in StoreSlideshowRequest. Hidden here for the same reason, with the
              stored value stated so nothing is silently posted. */}
          {isClassic ? (
            <HcbField label="Language" required error={errs.lang}
              tip={<>Option <b>ALL</b> is the literal string <code>all</code>; every other option is a <code>languages</code> row code. The front office matches the viewer's language against this column.</>}>
              <select className={`select${errs.lang ? " hcb-invalid" : ""}`} value={f.lang}
                onChange={e => { set({ lang: e.target.value }); clearErr("lang"); }}>
                <option value="all">ALL</option>
                {HCB_LANGS.map(l => <option key={l.code} value={l.code}>{l.name}</option>)}
              </select>
            </HcbField>
          ) : (
            <HcbField label="Language"
              tip={<>Hidden by <code>form.js</code> for this type — the grid and sign-auth banners carry their own per-language content — but the field is still submitted and is still <code>required</code> server-side.</>}>
              <div className="hcb-readonly">Submitted as <code>{f.lang || "all"}</code> (hidden by the form for this type)</div>
            </HcbField>
          )}

          <HcbField label="Big size"/* label inferred — backend.slideshow_form_big_size */
            tip={<>Field <code>is_big</code>, submitted with the hidden-0 + checkbox-1 pattern and coerced to 0/1 by <code>prepareForValidation()</code>. It selects the taller front-office layout.</>}>
            <HcbCheck checked={!!f.is_big} label="Render this set at the big size"
              onChange={v => set({ is_big: v ? 1 : 0 })} />
          </HcbField>
        </div>
      </HcbSection>

      {isClassic && (
        <HcbSection title="Banner images"
          right={<span className="hcb-sec__count">{f.media.slots.filter(s => s.active).length} of {HCB_MAX_IMAGES} active</span>}
          hint={<Explainer compact title="How the six slots work">
            Every slideshow row owns six fixed image slots (<code>img_1</code>…<code>img_6</code>). A slot is published when
            its <b>Active</b> box is ticked; the <b>Priority</b> field orders the published slides inside the set. Desktop and
            mobile artwork are separate columns, and only the desktop one is guaranteed to survive a first save on the real
            platform — see the note on the mobile uploader.
          </Explainer>}>
          <div className="hcb-imgcards">
            {f.media.slots.map((s, i) => (
              <HcbClassicCard key={s.n} slot={s} isNew={isNew} onPatch={p => patchSlot(i, p)} />
            ))}
          </div>
        </HcbSection>
      )}

      {isGrid && (
        <HcbSection title="Grid banners"/* label inferred — backend.slideshow_form_grid_banners */
          right={<span className="hcb-sec__count">{gridShown.length} of {HCB_GRID_MAX}</span>}
          hint={<Explainer compact title="How grid banners are stored">
            Up to three banners, drag-ordered. Their texts are kept per language as JSON
            <code> {"{lang: value}"} </code> maps inside the same <code>img_N_*</code> columns the classic type uses, and the
            button link is collapsed to one shared value in <code>img_N_url</code>. On save, slots 4-6 are zeroed and the
            enabled banners are compacted into slots 1-3 by priority — so a banner's stored slot can change when you reorder.
            Grid banners never store mobile artwork: <code>img_N_mobile</code> is always nulled for this type, by design.
          </Explainer>}>
          <div className="hcb-gridlist">
            {f.media.grid.map((g, i) => (i === 0 || g.enabled) && (
              <HcbGridBanner key={g.n} banner={g} index={i} count={gridShown.length} lang={lang} onLang={setLang}
                error={errs[`grid_${g.n}`]}
                onPatch={p => { patchGrid(i, p); clearErr(`grid_${g.n}`); }}
                onMove={d => moveGrid(i, d)}
                onRemove={() => patchGrid(i, { enabled: false })}
                onDragStart={() => setDrag(i)}
                onDragOver={e => e.preventDefault()}
                onDrop={() => { if (drag != null && drag !== i) moveGrid(drag, i - drag); setDrag(null); }} />
            ))}
          </div>
          {gridShown.length < HCB_GRID_MAX && (
            <button type="button" className="hcb-addsec"
              onClick={() => { const i = f.media.grid.findIndex(g => !g.enabled); if (i >= 0) patchGrid(i, { enabled: true }); }}>
              <Icon name="plus" size={13} /> Add section{/* label inferred — backend.slideshow_form_add_section */}
            </button>
          )}
        </HcbSection>
      )}

      {isAuth && (
        <HcbSection title="Sign In/Sign Up banners"/* label inferred — backend.slideshow_form_sign_in_sign_up_banners */
          hint={<Explainer compact title="How the auth banners are stored">
            Two fixed sections, not a list: sign-in always writes slot <code>img_1</code> and sign-up slot <code>img_2</code>,
            both forced active. Each section stores one filename per language as a JSON
            <code> {"{lang: filename}"} </code> map plus a single shared link. The pixel dimensions and the <code>.png</code>
            restriction shown here are helper text from the Blade — unlike the grid image, <b>neither is enforced server-side</b>.
          </Explainer>}>
          <div className="hcb-authlist">
            <HcbAuthBanner banner={f.media.auth[0]} slot={1} title="Sign In banners" dims="720 x 868" lang={lang} onLang={setLang} onPatch={p => patchAuth(0, p)} />
            <HcbAuthBanner banner={f.media.auth[1]} slot={2} title="Sign Up banners" dims="720 x 1477" lang={lang} onLang={setLang} onPatch={p => patchAuth(1, p)} />
          </div>
        </HcbSection>
      )}

      <HcbSection title="Settings"
        hint={<>Publication state, audience, and who inside the skin's user tree the set is aimed at.</>}>
        <div className="hcb-grid2">
          <HcbField label="Status"
            tip={<>Field <code>stato</code> — <code>0</code> Disabled, <code>1</code> Active (<code>statiSlideshows()</code>). New rows default to Disabled.</>}>
            <HcbCheck checked={!!f.stato} label="Active" onChange={v => set({ stato: v ? 1 : 0 })} />
          </HcbField>

          {/* KNOWN BUG — DIVERGENCE (see header #2): this select is saved to `whosee`, but both
              front-office readers filter on `show_type`, which nothing writes. Evident intent
              implemented — the prototype keeps show_type in step with the chosen value — and the
              divergence is disclosed in the hint below rather than papered over. */}
          <HcbField label="Who should see the slideshow" required error={errs.whosee}
            tip={<>Labelled required by <code>obbligatorio()</code> in the Blade, but <code>StoreSlideshowRequest</code> validates it as <code>nullable|in:0,1,2</code> — an empty value saves fine, which is why older rows can have none.</>}
            hint={<>
              Saved to <code>slideshows.whosee</code>. The front office, however, filters on a different column —
              <code> show_type</code> (<code>[0,1]</code> for a logged-in viewer, <code>[0,2]</code> for a guest) — and nothing in
              the platform writes <code>show_type</code>. This prototype writes <b>both</b>, so the choice actually takes effect:
              <b> {(HCB_WHOSEE.find(w => w.value === String(f.whosee)) || HCB_WHOSEE[0]).label}</b> → <code>show_type = {f.whosee || 0}</code> ({(HCB_WHOSEE.find(w => w.value === String(f.whosee)) || HCB_WHOSEE[0]).show}).
            </>}>
            <select className="select" value={f.whosee}
              onChange={e => { set({ whosee: e.target.value, show_type: e.target.value }); clearErr("whosee"); }}>
              <option value="">Select option</option>
              {HCB_WHOSEE.map(w => <option key={w.value} value={w.value}>{w.label}</option>)}
            </select>
          </HcbField>

          <HcbField label="Network" wide error={errs.user_id}/* label inferred — backend.slideshow_form_network */
            tip={<>Optional targeting. The picker searches <code>GET /users2</code> for user levels 2/8/10/15/20 inside the chosen skin and is disabled until a skin is selected. Saving copies the user's <code>user_path</code> onto the row.</>}
            hint={<>The front office shows the set only to viewers whose own <code>user_path</code> starts with the stored one; if none matches it falls back to a row with an empty <code>user_path</code>. Leave empty to target the whole skin.</>}>
            <HcbNetworkPicker skinId={f.skin_id} value={f.user_id} error={errs.user_id}
              onChange={(id) => {
                const u = HCB_NETWORK_USERS.find(x => x.id === id);
                set({ user_id: id, user_path: u ? u.user_path : "" }); clearErr("user_id");
              }} />
          </HcbField>
        </div>
      </HcbSection>

      <HcbSection title="Info">
        <HcbField label="Notes" wide hint="Free text on the row; not shown anywhere in the front office.">
          <textarea className="input hcb-textarea" value={f.note} onChange={e => set({ note: e.target.value })} />
        </HcbField>
      </HcbSection>
    </HcbModal>
  );
};

/* ------------------------------------------------------------------ *
 * Delete — deleteConfirm('/slideshow/delete/<id>/') → GET
 * /slideshow/delete/{id}, gated by canManageBannerForSkin(). A row on a
 * skin you do not manage answers "Permission error"; the list then does a
 * full page reload either way.
 * ------------------------------------------------------------------ */
const HcbDeleteDialog = ({ row, onClose, onConfirm }) => (
  <HcbModal title="Delete slideshow" onClose={onClose}
    footer={<>
      <button className="btn btn--secondary" onClick={onClose}>Cancel</button>
      <button className="btn btn--danger" onClick={() => { onConfirm(row); onClose(); }}><Icon name="trash" size={13} /> Delete</button>
    </>}>
    <div className="hcb-dlgq">Delete <b>{row.nome}</b> (ID {row.id})?</div>
    <div className="hcb-hint">
      Sends a plain <code>GET /slideshow/delete/{row.id}/</code> behind a JS confirm — a destructive action on a
      GET route with no CSRF token, which is this batch's generic delete convention. The endpoint re-checks
      <code> canManageBannerForSkin()</code>, so a row belonging to a skin outside your scope answers
      "Permission error" instead of deleting.
    </div>
    <div className="hcb-hint">
      The row is hard-deleted — there is no soft delete, no archive and no restore anywhere in the platform for
      slideshows. Uploaded files under <code>media/slideshow/</code> are left in place.
    </div>
  </HcbModal>
);

/* ------------------------------------------------------------------ *
 * Status chip strip — the index's alternate `stato` filter. Its counts
 * come from summarizeStatus(), which deliberately IGNORES the stato
 * filter (so the numbers stay stable while you switch chips) but does
 * respect the other filters.
 * ------------------------------------------------------------------ */
const HcbStatusTabs = ({ value, counts, onPick }) => {
  const tabs = [
    { v: "", label: "All", tone: "all", n: counts.all },
    { v: "1", label: "Active", tone: "ok", n: counts.active },
    { v: "0", label: "Disabled", tone: "off", n: counts.disabled },
  ];
  return (
    <div className="hcb-tabs">
      {tabs.map(t => (
        <button key={t.v || "all"} className={`hcb-tab hcb-tab--${t.tone}${String(value || "") === t.v ? " is-on" : ""}`}
          onClick={() => onPick(t.v)}>
          <span className="hcb-dot" />{t.label}
          <span className="hcb-tab__n">{hrsInt(t.n)}</span>
        </button>
      ))}
      <Tip size={12}>Counts come from <code>summarizeStatus()</code>, which applies every filter <b>except</b> Status — so the three numbers stay stable while you switch chips. The chips and the Status select drive the same <code>stato</code> parameter.</Tip>
    </div>
  );
};

/* Skin cell — initials avatar + name, "—" when the leftJoin found no skin row. */
const HcbSkinCell = ({ id }) => {
  const s = hcbSkin(id);
  if (!s) return <span className="hcb-dash" title="skins.name was NULL on the leftJoin">—</span>;
  return (
    <span className="hcb-skin">
      <span className="hcb-skin__av" aria-hidden="true">{hcbInitials(s.name)}</span>
      <span className="hcb-skin__n">{s.name}</span>
    </span>
  );
};

/* ------------------------------------------------------------------ */
const HostCmsBanners = () => {
  window.useLocale && window.useLocale();

  const [rows, setRows] = hcbUseState(hcbBuildRows);
  const [draft, setDraft] = hcbUseState({ search: "", skin_id: "", page_id: "", stato: "" });
  const [applied, setApplied] = hcbUseState({ search: "", skin_id: "", page_id: "", stato: "" });
  const [page, setPage] = hcbUseState(0);
  const [form, setForm] = hcbUseState(null);   // null | row (row.id == null → create)
  const [del, setDel] = hcbUseState(null);     // null | row

  const FIELDS = [
    { key: "search", label: "Search", type: "text", icon: "search", grow: true,
      placeholder: "Name or exact ID…",/* label inferred — backend.slideshow_search_placeholder */
      tip: <>Matches <code>nome LIKE %term%</code>. When the term is <b>all digits</b> the query also ORs in <code>id = &lt;n&gt;</code>, so typing a number finds that row by id as well as any name containing it.</> },
    /* Rendered only when isadmin() || isCustomCare(); a skin admin is hard-scoped server-side to
       their own skin_id and never sees this control. Prototype persona = super admin. */
    { key: "skin_id", label: "Skin", type: "select", icon: "flag", placeholder: "Select option", width: 190,
      options: HCB_SKINS.map(s => ({ value: String(s.id), label: s.name })),
      tip: <>Only rendered for a super admin or Customer Care, and only with the skins they own. Everyone else is scoped in the query instead: Customer Care to <code>getSkinIDS()</code>, a skin admin to their own <code>skin_id</code>. Auto-applies on change.</> },
    { key: "page_id", label: "Page", type: "select", icon: "list", placeholder: "Select option", width: 190,
      options: HCB_PAGES.map(p => ({ value: p.value, label: p.label })),
      tip: <>Submitted as <code>page_id</code> — the parameter is deliberately not called <code>page</code> because that name is taken by the Laravel paginator. Auto-applies on change.</> },
    { key: "stato", label: "Status", type: "select", icon: "toggle_right", placeholder: "Select option", width: 170,
      options: HCB_STATI,
      tip: <>Same parameter as the chips above the table. Auto-applies on change.</> },
  ];

  /* Everything except Status — the base for both the chip counts and the final list. */
  const preStatus = hcbUseMemo(() => {
    const q = String(applied.search || "").trim().toLowerCase();
    const digits = q && /^\d+$/.test(q);
    return rows.filter(r => {
      if (applied.skin_id && String(r.skin_id) !== String(applied.skin_id)) return false;
      if (applied.page_id && r.page !== applied.page_id) return false;
      if (q && !(r.nome.toLowerCase().indexOf(q) !== -1 || (digits && String(r.id) === q))) return false;
      return true;
    });
  }, [rows, applied]);

  const counts = hcbUseMemo(() => ({
    all: preStatus.length,
    active: preStatus.filter(r => r.stato === 1).length,
    disabled: preStatus.filter(r => r.stato !== 1).length,
  }), [preStatus]);

  const filtered = hcbUseMemo(() => (
    applied.stato === "" ? preStatus : preStatus.filter(r => String(r.stato) === String(applied.stato))
  ), [preStatus, applied.stato]);

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

  /* The three selects auto-submit onchange on the real index; only the text box waits for the
     Search button. Reproduced exactly. */
  const onChange = (k, v) => {
    setDraft(d => ({ ...d, [k]: v }));
    if (k !== "search") { setApplied(a => ({ ...a, [k]: v })); setPage(0); }
  };
  const onSearch = (v) => { setApplied({ ...v }); setPage(0); };
  const onReset = () => {
    const blank = { search: "", skin_id: "", page_id: "", stato: "" };
    setDraft(blank); setApplied(blank); setPage(0);
  };
  const pickStatus = (v) => { setDraft(d => ({ ...d, stato: v })); setApplied(a => ({ ...a, stato: v })); setPage(0); };

  const onSave = (f, isNew) => {
    /* KNOWN BUG — DIVERGENCE (header #1): the real create path runs Slideshow::create($datip),
       and img_*_mobile is not in $fillable, so every mobile filename is stripped here. This
       prototype persists them on create as well — the field exists, the front office reads it,
       and the edit path already stores it. */
    const mobileKept = (f.media.slots || []).filter(s => s.active && (s.mobileNew || s.mobile)).length;
    const commit = {
      ...f,
      /* show_type is written alongside whosee — see header #2. */
      show_type: f.whosee,
      media: {
        ...f.media,
        slots: (f.media.slots || []).map(s => ({
          ...s,
          img: s.imgNew || s.img, imgNew: "",
          mobile: s.mobileNew || s.mobile, mobileNew: "",
        })),
        /* Grid: slots 4-6 zeroed, enabled banners compacted into 1-3 by priority — so the stored
           slot number follows the on-screen order, not the order the banners were added in.
           Banner 1 is force-enabled the way form.js forces grid_banners[1][enabled] = 1. */
        grid: (() => {
          const src = (f.media.grid || []).map((g, i) => ({
            ...g, enabled: i === 0 ? true : g.enabled,
            image_existing: g.image || g.image_existing, image: "",
          }));
          const live = src.filter(g => g.enabled).map((g, i) => ({ ...g, n: i + 1, order: i + 1 }));
          while (live.length < HCB_GRID_MAX) live.push(hcbBlankGrid(live.length + 1));
          return live;
        })(),
        auth: (f.media.auth || []).map(a => ({ ...a })),
      },
    };
    if (isNew) {
      const id = rows.reduce((m, r) => Math.max(m, r.id), 0) + 1;
      const created = { ...commit, id, addedTime: Math.floor(Date.now() / 1000) };
      setRows(rs => [created, ...rs]);   /* fixed ORDER BY id DESC → newest first */
      hrsToast(`Slideshow "${created.nome}" created`,
        `${hcbSkin(created.skin_id) ? hcbSkin(created.skin_id).name : "—"} · ${hcbPageName(created.page)} · ${hcbType(created.type).label}` +
        (mobileKept ? ` · ${mobileKept} mobile image(s) kept — the real platform drops these on create (img_*_mobile is not in $fillable).` : ""));
    } else {
      setRows(rs => rs.map(r => r.id === commit.id ? commit : r));
      hrsToast(`Slideshow "${commit.nome}" saved`,
        `whosee = ${commit.whosee} written together with show_type = ${commit.show_type} so the visibility choice reaches the front office.`);
    }
  };

  const onDelete = (row) => {
    setRows(rs => rs.filter(r => r.id !== row.id));
    hrsToast(`Slideshow "${row.nome}" deleted`, `GET /slideshow/delete/${row.id}/ · scope checked with canManageBannerForSkin().`);
  };

  const openEdit = (row) => setForm(row);

  const columns = [
    { key: "id", label: "ID", width: 78, render: r => <span className="hcb-id">{r.id}</span> },
    { key: "skin", label: "Skin", render: r => <HcbSkinCell id={r.skin_id} /> },
    { key: "nome", label: "Name", render: r => (
      <button className="hcb-namelink" title="Edit" onClick={(e) => { e.stopPropagation(); openEdit(r); }}>
        <span>{r.nome}</span><Icon name="chevron_right" size={13} />
      </button>
    ) },
    { key: "page", label: "Page", render: r => hcbPageName(r.page) },
    { key: "lang", label: "Language", align: "center", width: 110,
      render: r => <span className="hcb-langchip" title={r.lang === "all" ? "Matches every language" : hcbLangName(r.lang)}>{String(r.lang).toUpperCase()}</span> },
    { key: "type", label: "Type of banners",/* backend.type_of_banners — label inferred */
      render: r => {
        const t = hcbType(r.type);
        return <span className={`hcb-typechip hcb-typechip--${r.type}`} title={t.inferred ? `Real platform prints the raw value "${r.type}"` : r.type}>{t.label}</span>;
      } },
    { key: "stato", label: "Status", align: "center", width: 120,
      render: r => (
        <span className={`hcb-status hcb-status--${r.stato === 1 ? "on" : "off"}`}>
          <span className="hcb-dot" />{r.stato === 1 ? "Active" : "Disabled"}
        </span>
      ) },
    { key: "created", label: "Created", width: 160,/* label inferred — backend.created */
      render: r => hcbCreated(r.addedTime) || <span className="hcb-dash">—</span> },
    { key: "_acts", label: "Actions", align: "center", width: 110,
      render: r => (
        <div className="hcb-acts">
          <button className="hcb-act hcb-act--edit" title="Edit" onClick={(e) => { e.stopPropagation(); openEdit(r); }}><Icon name="edit" size={13} /></button>
          <button className="hcb-act hcb-act--danger" title="Delete" onClick={(e) => { e.stopPropagation(); setDel(r); }}><Icon name="trash" size={13} /></button>
        </div>
      ) },
  ];

  return (
    <HrsShell
      title="Banners"
      subtitle="Front-office banner sets, one row per skin + page + language"/* label inferred — backend.slideshow_index_subtitle */
      gate={<>Real-platform access: <b>Super admin</b> passes unconditionally (<code>isadmin()</code>); a <b>skin admin</b> needs the <code>enable_cms</code> skin setting; <b>Customer Care</b> needs the <code>support_cms_banners</code> BO permission, re-checked in <code>SlideshowsController::index()</code> — without it the page 404s. </>}
      gateNote={<>Permission asymmetry, honestly: only <code>index</code> (404) and <code>saveSlideshow</code>/<code>delete</code> (per-skin <code>canManageBannerForSkin()</code>) are gated. <code>GET /slideshow/form</code> has <b>no permission check at all</b>, so any authenticated 2FA'd back-office user can fetch the form HTML for any slideshow id. Delete is a plain <b>GET without CSRF</b>.</>}
      explainer={{
        title: "What this screen does, in plain English",
        bullets: [
          <>One row is one <b>banner set</b> for a given <b>skin + page + language</b>. The front office asks for the set matching the visitor's skin, the page they are on and their language, then renders its image slots — either as a Blade slideshow or through the <code>GET /api/slideshow</code> FAPI endpoint.</>,
          <><b>Type of banners</b> decides how the six image slots are used: <i>Default</i> and <i>Slick</i> expose all six as classic slides; <i>Grid view</i> uses three drag-ordered banners with per-language texts; <i>Sign In/Sign Up</i> pins two auth-screen images to slots 1 and 2.</>,
          <><b>Network</b> targeting is optional: pick a user and the set is shown only to visitors below them in the tree (their <code>user_path</code> must start with the stored one), otherwise the platform falls back to a row with no network set.</>,
          <><b>Visibility is the one thing that does not work on the real platform.</b> "Who should see the slideshow" is stored in <code>whosee</code>, but both front-office readers filter on <code>show_type</code> — a separate column nothing ever writes. This rebuild writes both so the choice actually applies, and says so in the form instead of hiding it.</>,
          <>No export, no bulk actions, and no sortable columns — the list is always <code>ORDER BY slideshows.id DESC</code>, 25 rows a page, Prev/Next only. That is the real screen, not an omission.</>,
        ],
      }}
      actions={
        <button className="hrs-btn hrs-btn--filters" onClick={() => setForm(hcbNewRow())}>
          <Icon name="plus" size={14} /> New slideshow
        </button>
      }>

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

      <HcbStatusTabs value={applied.stato} counts={counts} onPick={pickStatus} />

      <HrsTable
        columns={columns} rows={paged} rowKey="id"
        onRowClick={openEdit}/* the whole row is click-to-edit on the real index */
        empty={(applied.search || applied.skin_id || applied.page_id || applied.stato)
          ? "No slideshow matches these filters."/* label inferred — backend.no_records */
          : "No slideshows yet — create one with New slideshow."}
        renderCard={r => (
          <>
            <div className="hrs-card__top">
              <b>{r.nome}</b>
              <span className={`hcb-status hcb-status--${r.stato === 1 ? "on" : "off"}`}>
                <span className="hcb-dot" />{r.stato === 1 ? "Active" : "Disabled"}
              </span>
            </div>
            <div className="hrs-card__grid">
              <span>ID</span><b>{r.id}</b>
              <span>Skin</span><b><HcbSkinCell id={r.skin_id} /></b>
              <span>Page</span><b>{hcbPageName(r.page)}</b>
            </div>
            <details className="hcb-card__more">
              <summary>More</summary>
              <div className="hrs-card__grid">
                <span>Language</span><b>{String(r.lang).toUpperCase()}</b>
                <span>Type</span><b>{hcbType(r.type).label}</b>
                <span>Created</span><b>{hcbCreated(r.addedTime) || "—"}</b>
              </div>
            </details>
            <div className="hcb-card__acts">
              <button className="btn btn--secondary btn--sm" onClick={(e) => { e.stopPropagation(); openEdit(r); }}><Icon name="edit" size={12} /> Edit</button>
              <button className="btn btn--ghost btn--sm hcb-card__del" onClick={(e) => { e.stopPropagation(); setDel(r); }}><Icon name="trash" size={12} /> Delete</button>
            </div>
          </>
        )} />

      {/* paginate(25) with Prev/Next only — no "Show N entries" control exists on this screen. */}
      <HrsPager page={safePage} pageSize={HCB_PAGE_SIZE} total={filtered.length} onPage={setPage} />

      {form && <HcbFormModal row={form} onClose={() => setForm(null)} onSave={onSave} />}
      {del && <HcbDeleteDialog row={del} onClose={() => setDel(null)} onConfirm={onDelete} />}
    </HrsShell>
  );
};

/* Loads after src/pages/HostCmsBanners.jsx, deliberately replacing its legacy HostCmsBanners
   global (app.jsx:473 renders <HostCmsBanners/> for route key "cms-banners"). */
window.HostCmsBanners = HostCmsBanners;
