/* ═══════════════════════════════════════════════════════════════════════════
   CATÀLEG EN LÍNIA · Arxiu i Biblioteca de Dansa Tradicional — page bundle.
   Loads after shared/components/{Nav,Footer,TweaksPanel}.jsx.

   ┌─────────────────────────────────────────────────────────────────────────┐
   │  CMS / DATABASE INTEGRATION — LIVE (F2)                                    │
   │  This is the public search front-end for the Archive & Library catalogue. │
   │  Search runs on the server (GET /api/v1/cataleg: real FTS with ranking,   │
   │  exact format facet, per-column advanced fields, server-side pagination)  │
   │  via useCatalogSearch below. The in-memory SEED (searchCatalog) remains   │
   │  the eternal fallback: any API anomaly degrades to it silently (INT-1/2). │
   │  Requests POST to /api/v1/consultes from the record modal.                │
   └─────────────────────────────────────────────────────────────────────────┘
   ═══════════════════════════════════════════════════════════════════════════ */

const { useState, useEffect } = React;

const EsbartNav = window.EsbartNav;
const EsbartFooter = window.EsbartFooter;
const EsbartTweaksPanel = window.EsbartTweaksPanel;

const ESBART_TWEAKS = /*EDITMODE-BEGIN*/{
  "accentHue": 350,
  "typeScale": 1
}/*EDITMODE-END*/;
window.ESBART_TWEAKS = ESBART_TWEAKS;

/* ───────────────────────────────────────────────────────────────────────────
   CATALOGUE RECORDS — SEED / fallback (records are stored in their original
   language; only the INTERFACE is translated). Shape is the canonical schema.
     { id, format, title, authors[], editorial, any, lloc, descripcio,
       materia[], comunes[], isbn, geografic, cronologic, topografic,
       colleccio, entitat, notes }
   ─────────────────────────────────────────────────────────────────────────── */
const CATALOG_SEED = [
  {
    id: "B-III-0421", format: "llibre",
    title: "Gest a Catalunya, El",
    authors: ["Amades Gelats, Joan", "Mir, Paco (pròleg)", "Serés Aguilar, Antoni"],
    editorial: "Morera", any: "2025", lloc: "Barcelona",
    descripcio: "99 p. : il.",
    materia: ["Gest"], comunes: ["Autors", "Reculls"],
    isbn: "979-13-991031-1-3", geografic: "Catalunya", cronologic: "segle XX",
    topografic: "B-III", colleccio: "", entitat: "",
    notes: "Edició de l'obra El gest a Catalunya editat per Joan Amades a Anales del Instituto de Lingüística de Mendoza (Argentina), l'any 1957.",
  },
  {
    id: "B-I-0188", format: "llibre",
    title: "Aureli Capmany. La dansa i l'Esbart Català de Dansaires",
    authors: ["Garrich, Montserrat", "Bartolomé, J. (col·l.)", "Bigas, A. (col·l.)"],
    editorial: "Esbart Català de Dansaires", any: "2018", lloc: "Barcelona",
    descripcio: "248 p. : il., fot.",
    materia: ["Biografia", "Dansa tradicional", "Aureli Capmany"], comunes: ["Autors"],
    isbn: "978-84-09-04321-7", geografic: "Catalunya", cronologic: "1868–1954",
    topografic: "B-I", colleccio: "Publicacions de l'ECD", entitat: "Esbart Català de Dansaires",
    notes: "Conté dos textos inèdits d'Aureli Capmany. Editat amb motiu dels 150 anys del seu naixement.",
  },
  {
    id: "B-II-0233", format: "llibre",
    title: "Bastons! Història del Ball de Bastons a Vilafranca del Penedès",
    authors: ["Ribas Beltran, Antoni"],
    editorial: "Esbart Català de Dansaires", any: "2019", lloc: "Barcelona",
    descripcio: "312 p. : il., partitures",
    materia: ["Ball de bastons", "Vilafranca del Penedès"], comunes: ["Autors", "Premis"],
    isbn: "978-84-09-12876-1", geografic: "Alt Penedès", cronologic: "segles XIX–XXI",
    topografic: "B-II", colleccio: "Publicacions de l'ECD", entitat: "",
    notes: "Obra guanyadora del V Premi Rafael Tudó per a estudis de dansa catalana.",
  },
  {
    id: "AF-1952-0074", format: "fotografia",
    title: "Cavallets Cotoners a la plaça de Sant Jaume",
    authors: ["Autor desconegut"],
    editorial: "", any: "1952", lloc: "Barcelona",
    descripcio: "1 fotografia : b/n ; 18 × 24 cm",
    materia: ["Cavallets Cotoners", "Festes de la Mercè"], comunes: ["Imatges"],
    isbn: "", geografic: "Barcelonès", cronologic: "1952",
    topografic: "AF / 1952", colleccio: "Arxiu fotogràfic ECD", entitat: "",
    notes: "Procedent del fons fotogràfic de l'entitat. Reproducció subjecta a la normativa interna.",
  },
  {
    id: "FON-CD-0512", format: "so",
    title: "Músiques del MercèDansa. Volum 5",
    authors: ["Gómez, Joan (dir.)"],
    editorial: "Esbart Català de Dansaires", any: "2024", lloc: "Barcelona",
    descripcio: "1 disc compacte (CD-DA) ; 12 cm",
    materia: ["Música de dansa", "MercèDansa"], comunes: ["Enregistraments"],
    isbn: "", geografic: "Catalunya", cronologic: "2024",
    topografic: "FON / CD", colleccio: "Fonoteca ECD", entitat: "",
    notes: "Presentat dins el cicle Presentacions 2024.",
  },
  {
    id: "B-IV-0301", format: "llibre",
    title: "Aportacions a la dansa catalana 2020",
    authors: ["Diversos autors", "Garrich, Montserrat (ed.)"],
    editorial: "Esbart Català de Dansaires", any: "2020", lloc: "Barcelona",
    descripcio: "186 p. : il.",
    materia: ["Dansa tradicional", "Congressos i jornades"], comunes: ["Reculls"],
    isbn: "978-84-09-22011-3", geografic: "Catalunya", cronologic: "2020",
    topografic: "B-IV", colleccio: "Aportacions a la dansa catalana", entitat: "",
    notes: "Recull de les comunicacions presentades a la jornada Aportacions a la dansa catalana de 2020.",
  },
  {
    id: "PRO-1998-0011", format: "programa",
    title: "Mostra de dansa de l'Esbart Català de Dansaires (programa)",
    authors: ["Esbart Català de Dansaires"],
    editorial: "", any: "1998", lloc: "Barcelona",
    descripcio: "1 fullet : il. ; 21 × 15 cm",
    materia: ["Mostra de dansa", "Espectacles"], comunes: ["Programes"],
    isbn: "", geografic: "Barcelonès", cronologic: "1998",
    topografic: "PRO / 1998", colleccio: "Arxiu de programes i cartells", entitat: "",
    notes: "Document del fons d'organització pròpia de l'entitat.",
  },
  {
    id: "ARX-MUS-0156", format: "partitura",
    title: "Ball de cavallets — harmonització per a cobla",
    authors: ["Serra, Joaquim"],
    editorial: "", any: "1948", lloc: "Barcelona",
    descripcio: "1 partitura : manuscrita ; 33 cm",
    materia: ["Música de dansa", "Cobla", "Cavallets"], comunes: ["Partitures"],
    isbn: "", geografic: "Catalunya", cronologic: "1948",
    topografic: "AM / 0156", colleccio: "Arxiu musical ECD", entitat: "",
    notes: "Manuscrit original. Part de l'Arxiu musical de dansa catalana de l'entitat.",
  },
  {
    id: "B-I-0205", format: "llibre",
    title: "Costumari català. El curs de l'any",
    authors: ["Amades Gelats, Joan"],
    editorial: "Salvat", any: "1950", lloc: "Barcelona",
    descripcio: "5 v. : il.",
    materia: ["Costums", "Festes", "Calendari festiu"], comunes: ["Autors", "Reculls"],
    isbn: "", geografic: "Catalunya", cronologic: "segle XX",
    topografic: "B-I", colleccio: "", entitat: "",
    notes: "Obra de referència sobre el calendari festiu i els costums de Catalunya.",
  },
  {
    id: "ART-0788", format: "article",
    title: "La sardana, dansa nacional? Apunts per a un debat",
    authors: ["Garrich, Montserrat"],
    editorial: "Revista d'Etnologia de Catalunya", any: "2011", lloc: "Barcelona",
    descripcio: "p. 142–151",
    materia: ["Sardana", "Identitat", "Dansa tradicional"], comunes: ["Articles"],
    isbn: "1132-6581", geografic: "Catalunya", cronologic: "2011",
    topografic: "HEM / 0788", colleccio: "Hemeroteca", entitat: "",
    notes: "Article publicat en publicació periòdica especialitzada.",
  },
  {
    id: "AF-1929-0033", format: "fotografia",
    title: "Esbart Català de Dansaires a l'Exposició Internacional de Barcelona",
    authors: ["Autor desconegut"],
    editorial: "", any: "1929", lloc: "Barcelona",
    descripcio: "1 fotografia : b/n ; 13 × 18 cm",
    materia: ["Exposició 1929", "Vestuari"], comunes: ["Imatges"],
    isbn: "", geografic: "Barcelonès", cronologic: "1929",
    topografic: "AF / 1929", colleccio: "Arxiu fotogràfic ECD", entitat: "",
    notes: "Imatge històrica del fons fotogràfic de l'entitat.",
  },
  {
    id: "VID-2009-0012", format: "video",
    title: "Cent anys d'història (1908–2008) — documental",
    authors: ["Esbart Català de Dansaires"],
    editorial: "Esbart Català de Dansaires", any: "2009", lloc: "Barcelona",
    descripcio: "1 DVD-Video (52 min) : so, col.",
    materia: ["Història de l'ECD", "Centenari"], comunes: ["Enregistraments"],
    isbn: "", geografic: "Catalunya", cronologic: "2008",
    topografic: "VID / 2009", colleccio: "Videoteca ECD", entitat: "",
    notes: "Documental produït amb motiu del centenari de l'entitat.",
  },
  {
    id: "B-II-0177", format: "llibre",
    title: "El ball de gitanes al Vallès",
    authors: ["Mas i Solench, Josep M."],
    editorial: "Rafael Dalmau", any: "1994", lloc: "Barcelona",
    descripcio: "186 p. : il.",
    materia: ["Ball de gitanes", "Vallès", "Carnaval"], comunes: ["Autors"],
    isbn: "84-232-0467-2", geografic: "Vallès Occidental", cronologic: "segle XX",
    topografic: "B-II", colleccio: "", entitat: "",
    notes: "Estudi monogràfic sobre el ball de gitanes a la comarca del Vallès.",
  },
  {
    id: "MAN-1918-0004", format: "manuscrit",
    title: "Llibre d'actes de l'Esbart Català de Dansaires (1918–1925)",
    authors: ["Esbart Català de Dansaires"],
    editorial: "", any: "1918", lloc: "Barcelona",
    descripcio: "1 v. manuscrit ; 31 cm",
    materia: ["Història de l'ECD", "Documentació institucional"], comunes: ["Manuscrits"],
    isbn: "", geografic: "Barcelonès", cronologic: "1918–1925",
    topografic: "AH / Actes", colleccio: "Arxiu històric ECD", entitat: "",
    notes: "Sèrie de llibres d'actes de l'arxiu històric, d'ordenació cronològica.",
  },
  {
    id: "FON-CD-0410", format: "so",
    title: "Cançons i danses de Catalunya (antologia sonora)",
    authors: ["Diversos intèrprets"],
    editorial: "Audiovisuals de Sarrià", any: "1998", lloc: "Barcelona",
    descripcio: "2 discs compactes (CD-DA)",
    materia: ["Música tradicional", "Cançó"], comunes: ["Enregistraments"],
    isbn: "", geografic: "Catalunya", cronologic: "1998",
    topografic: "FON / CD", colleccio: "Fonoteca ECD", entitat: "",
    notes: "Antologia sonora de cançons i danses tradicionals.",
  },
  {
    id: "PRO-1985-0021", format: "programa",
    title: "Cartell del 75è aniversari de l'Esbart Català de Dansaires",
    authors: ["Esbart Català de Dansaires"],
    editorial: "", any: "1983", lloc: "Barcelona",
    descripcio: "1 cartell : col. ; 70 × 50 cm",
    materia: ["Aniversari", "Difusió"], comunes: ["Programes", "Imatges"],
    isbn: "", geografic: "Barcelonès", cronologic: "1983",
    topografic: "PRO / 1983", colleccio: "Arxiu de programes i cartells", entitat: "",
    notes: "Cartell commemoratiu del 75è aniversari de l'entitat.",
  },
  {
    id: "B-IV-0312", format: "llibre",
    title: "Aportacions a la dansa catalana 2014",
    authors: ["Diversos autors", "Garrich, Montserrat (ed.)"],
    editorial: "Esbart Català de Dansaires", any: "2014", lloc: "Barcelona",
    descripcio: "164 p. : il.",
    materia: ["Dansa tradicional", "Congressos i jornades"], comunes: ["Reculls"],
    isbn: "978-84-616-9921-0", geografic: "Catalunya", cronologic: "2014",
    topografic: "B-IV", colleccio: "Aportacions a la dansa catalana", entitat: "",
    notes: "Recull de les comunicacions de la jornada de 2014.",
  },
  {
    id: "ART-0654", format: "article",
    title: "Els cavallets cotoners de Barcelona: 600 anys de documentació",
    authors: ["Trullols, David"],
    editorial: "Caramella", any: "2016", lloc: "Reus",
    descripcio: "p. 64–70",
    materia: ["Cavallets Cotoners", "Entremesos"], comunes: ["Articles"],
    isbn: "1696-2575", geografic: "Barcelonès", cronologic: "2016",
    topografic: "HEM / 0654", colleccio: "Hemeroteca", entitat: "",
    notes: "Article de divulgació sobre la història dels cavallets cotoners.",
  },
  {
    id: "ARX-MUS-0233", format: "partitura",
    title: "Galop de la Mercè — partitura per a cobla",
    authors: ["Ventura, Pep"],
    editorial: "", any: "1965", lloc: "Barcelona",
    descripcio: "1 partitura ; 33 cm",
    materia: ["Música de dansa", "Cobla", "MercèDansa"], comunes: ["Partitures"],
    isbn: "", geografic: "Catalunya", cronologic: "segle XX",
    topografic: "AM / 0233", colleccio: "Arxiu musical ECD", entitat: "",
    notes: "Partitura de l'arxiu musical de dansa catalana.",
  },
  {
    id: "B-I-0260", format: "llibre",
    title: "Joan Amades i la cultura popular",
    authors: ["Calvo, Lluís", "Roma, Josefina"],
    editorial: "Alta Fulla", any: "2002", lloc: "Barcelona",
    descripcio: "224 p. : il.",
    materia: ["Joan Amades", "Folklore", "Biografia"], comunes: ["Autors"],
    isbn: "84-7900-167-4", geografic: "Catalunya", cronologic: "segle XX",
    topografic: "B-I", colleccio: "", entitat: "",
    notes: "Estudi sobre la figura de Joan Amades, soci i president de l'Esbart.",
  },
];

/* ╔═══════════════════════════════════════════════════════════════════════════╗
   ║  SEED FALLBACK — in-memory search (INT-1: the page always works)           ║
   ║  Only runs when the live search (useCatalogSearch) cannot reach the API    ║
   ║  or the response violates the contract. `format` matches loosely because   ║
   ║  live chips carry real DB names («Llibre») while seed records carry ids    ║
   ║  («llibre»).                                                                ║
   ╚═══════════════════════════════════════════════════════════════════════════╝ */
function searchCatalog(params) {
  const { q = "", format = "", adv = {}, sort = "rel" } = params;
  const norm = (s) => (s || "").toString().toLowerCase();
  const hay = (r) => norm([r.title, r.authors.join(" "), r.materia.join(" "), r.comunes.join(" "),
    r.editorial, r.any, r.isbn, r.geografic, r.lloc, r.topografic, r.colleccio, r.entitat, r.cronologic, r.notes].join(" "));
  const match = (field, val) => !val || norm(field).includes(norm(val));

  let items = CATALOG_SEED.filter((r) => {
    if (q && !hay(r).includes(norm(q))) return false;
    if (format && r.format !== norm(format)) return false;
    return (
      match(r.authors.join(" "), adv.autor) &&
      match(r.materia.join(" "), adv.materia) &&
      match(r.comunes.join(" "), adv.comunes) &&
      match(r.title, adv.titol) &&
      match(r.isbn, adv.isbn) &&
      match(r.any, adv.any) &&
      match(r.colleccio, adv.colleccio) &&
      match(r.lloc, adv.lloc) &&
      match(r.entitat, adv.entitat) &&
      match(r.geografic, adv.geografic) &&
      match(r.topografic, adv.topografic) &&
      match(r.cronologic, adv.cronologic) &&
      match(r.notes, adv.notes)
    );
  });

  if (sort === "yearDesc") items = [...items].sort((a, b) => (b.any > a.any ? 1 : -1));
  else if (sort === "yearAsc") items = [...items].sort((a, b) => (a.any > b.any ? 1 : -1));
  else if (sort === "title") items = [...items].sort((a, b) => a.title.localeCompare(b.title));
  return { total: items.length, items };
}

/* ╔═══════════════════════════════════════════════════════════════════════════╗
   ║  DYNAMIC · DB — LIVE SEARCH (F2)                                           ║
   ║  GET /api/v1/cataleg?q=&format=&<camps avançats>&sort=&page=&size=         ║
   ║  Response: { items:[<fitxa completa>], total, page, size, pages }          ║
   ║  The list already carries FULL records (contract D6), so opening a         ║
   ║  «fitxa» needs no second request. Any anomaly → seed, never an error.      ║
   ╚═══════════════════════════════════════════════════════════════════════════╝ */

// Contracte → forma del seed: les multivalor que el component pinta com a
// cadena única (editorial, lloc, geografic, entitat) es fusionen aquí — mai
// tocant el contracte (ADR-06 F1: el desajust es resol al map()).
// La importació legacy va deixar fitxes amb el títol literal «[]»: es
// normalitza a buit i la interfície mostra t.untitled (decisió del client,
// 2026-07-12). El valor cru queda intacte a la BD i al CMS per poder-lo
// corregir — mai s'inventa un títol (INT-11).
const JUNK_TITLE = /^[\s[\]()]*$/;
const cleanTitle = (v) => (JUNK_TITLE.test(v) ? "" : v);

function apiRecord(r) {
  const s = (v) => (v == null ? "" : String(v));
  const arr = (v) => (Array.isArray(v) ? v.filter(Boolean).map(String) : []);
  const join = (v) => arr(v).join(", ");
  return {
    id: s(r.id), format: s(r.format), title: cleanTitle(s(r.title)), authors: arr(r.authors),
    editorial: join(r.editorial), any: s(r.any), lloc: join(r.lloc),
    descripcio: s(r.descripcio), materia: arr(r.materia), comunes: arr(r.comunes),
    isbn: s(r.isbn), geografic: join(r.geografic), cronologic: s(r.cronologic),
    topografic: s(r.topografic), colleccio: s(r.colleccio), entitat: join(r.entitat),
    edicio: s(r.edicio), notes: s(r.notes), fragment: s(r.fragment),
  };
}

// El fragment ressaltat arriba amb marcadors ⟦⟧ del servidor (ts_headline):
// es parteix i es pinta amb <mark> com a elements React — mai innerHTML (INT-4).
function Fragment({ text }) {
  return text.split(/⟦(.*?)⟧/g).map((part, i) => (i % 2 ? <mark key={i}>{part}</mark> : part));
}

// Cerca viva amb debounce de 350 ms. Retorna { items (la pàgina), total,
// pages, live }. Regles D3: mai llança, mai spinner infinit — mentre arriba
// la resposta es continuen veient els resultats anteriors, i qualsevol
// fallada degrada EN SILENCI a la cerca local sobre el seed.
function useCatalogSearch({ q, format, adv, sort, page }) {
  const advKey = JSON.stringify(adv);
  const seedResult = () => {
    const r = searchCatalog({ q, format, adv, sort });
    return {
      items: r.items.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE),
      total: r.total,
      pages: Math.max(1, Math.ceil(r.total / PAGE_SIZE)),
      live: false,
    };
  };
  const [state, setState] = useState(seedResult);

  useEffect(() => {
    let cancelled = false;
    const params = new URLSearchParams();
    if (q.trim()) params.set("q", q.trim());
    if (format) params.set("format", format);
    for (const [k, v] of Object.entries(adv)) {
      if (v && String(v).trim()) params.set(k, String(v).trim());
    }
    params.set("sort", sort);
    params.set("page", String(page));
    params.set("size", String(PAGE_SIZE));

    const timer = setTimeout(() => {
      window.EsbartData.fetchJson(`cataleg?${params.toString()}`)
        .then((j) => {
          if (cancelled) return;
          if (!j || !Array.isArray(j.items) || typeof j.total !== "number") {
            throw new Error("resposta fora de contracte");
          }
          setState({
            items: j.items.map(apiRecord),
            total: j.total,
            pages: Math.max(1, Number(j.pages) || 1),
            live: true,
          });
        })
        .catch((err) => {
          if (cancelled) return;
          console.warn("[cataleg]", err);
          setState(seedResult());
        });
    }, 350);
    return () => { cancelled = true; clearTimeout(timer); };
  }, [q, format, advKey, sort, page]);

  return state;
}

const Arrow = () => (<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8"><path d="M5 12h14M13 6l6 6-6 6" /></svg>);
const Back = () => (<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8"><path d="M19 12H5M11 6l-6 6 6 6" /></svg>);
const SearchIcon = () => (<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8"><circle cx="11" cy="11" r="7" /><path d="m21 21-4.3-4.3" /></svg>);

/* ═══════════════════════════════════════════════════════════════════════════
   STRINGS — CA / ES / EN  (interface only; records stay in original language)
   ═══════════════════════════════════════════════════════════════════════════ */
const CAT_CA = {
  pageTitle: "Catàleg en línia · Esbart Català de Dansaires",
  eyebrow: "Arxiu i Biblioteca de Dansa Tradicional",
  title: ["Catàleg ", "en línia"],
  sub: "Cerca a l'Arxiu i la Biblioteca de l'Esbart Català de Dansaires: llibres, articles, fotografies, enregistraments, partitures, programes i manuscrits sobre dansa tradicional catalana.",
  searchPh: "Cerca per títol, autor, matèria, ISBN…",
  searchBtn: "Cerca",
  advShow: "Cerca avançada",
  advHide: "Amaga la cerca avançada",
  clear: "Neteja",
  allFormats: "Tots els formats",
  resultsOne: "resultat",
  untitled: "(Sense títol)",
  pivotHint: "Cerca tot el que tenim de «{value}»",
  resultsMany: "resultats",
  sortLabel: "Ordena",
  sorts: [
    { id: "rel", label: "Rellevància" },
    { id: "yearDesc", label: "Any (recent)" },
    { id: "yearAsc", label: "Any (antic)" },
    { id: "title", label: "Títol (A–Z)" },
  ],
  empty: "No s'ha trobat cap document. Prova amb altres termes o amplia la cerca.",
  emptyHint: "Consell: cerca per cognom de l'autor, per matèria o pel topogràfic (p. ex. «B-III»).",
  didYouMean: "Potser volies dir:",
  zeroAskText: "No trobes el que busques?",
  zeroAskLink: "Escriu al Centre de Documentació",
  pagPrev: "Anterior", pagNext: "Següent", pagRange: "{from}–{to} de {total}",
  back: "Tornar als resultats",
  recordLabel: "Fitxa del document",
  signatura: "Topogràfic",
  consult: "Sol·licita consulta",
  reproduce: "Servei de reproducció",
  consultNote: "Consulta pública i gratuïta a l'Arxiu i la Biblioteca, amb cita convinguda.",
  reqConsultaTitle: "Sol·licitud de consulta",
  reqReproTitle: "Sol·licitud de reproducció",
  reqIntro: "La consulta i la reproducció es fan amb cita convinguda amb la Vocalia d'Arxiu i Biblioteca. Deixa'ns les teves dades i et respondrem amb les indicacions.",
  reqDocLabel: "Document sol·licitat",
  reqName: "Nom i cognoms",
  reqEmail: "Correu electrònic",
  reqPhone: "Telèfon",
  reqMsg: "Missatge",
  reqSend: "Envia la sol·licitud",
  reqSuccess: "Hem rebut la teva sol·licitud. Et respondrem ben aviat amb els passos a seguir.",
  reqClose: "Tanca",
  reqPrefillConsulta: "Voldria sol·licitar la consulta del document «{title}» (topogràfic {sig}).",
  reqPrefillRepro: "Voldria sol·licitar la reproducció del document «{title}» (topogràfic {sig}).",
  reqIntroConsulta: "La consulta és presencial, gratuïta i amb cita convinguda amb la Vocalia d'Arxiu i Biblioteca. Indica'ns la teva disponibilitat i et confirmarem dia i hora.",
  reqIntroRepro: "La reproducció de documents es fa segons la normativa interna de l'entitat. Indica'ns el format i l'ús previst i t'informarem de les condicions.",
  reqDateLabel: "Data preferent de visita",
  reqFormatLabel: "Format de la reproducció",
  reqFormatOptions: ["Digital", "Paper", "Altres"],
  reqUseLabel: "Ús previst i observacions",
  reqNoteConsulta: "Consulta pública i gratuïta, amb cita prèvia.",
  reqNoteRepro: "Subjecte a la normativa de reproducció i als drets aplicables.",
  reqSending: "Enviant…",
  reqError: "No hem pogut enviar la sol·licitud. Torna-ho a provar d'aquí a uns instants — el text que has escrit es conserva.",
  formats: {
    llibre: "Llibre", article: "Article", fotografia: "Fotografia", so: "Enregistrament sonor",
    video: "Vídeo", programa: "Programa / Cartell", manuscrit: "Manuscrit", partitura: "Partitura",
  },
  fields: {
    format: "Format", autor: "Autor", materia: "Matèria", comunes: "Comunes", titol: "Títol",
    isbn: "ISBN / ISSN", any: "Any", colleccio: "Col·lecció", lloc: "Lloc", entitat: "Entitat",
    geografic: "Geogràfic", topografic: "Topogràfic", cronologic: "Cronològic", notes: "Notes",
    editorial: "Editorial", descripcio: "Descripció", edicio: "Edició",
  },
};
const CAT_ES = {
  pageTitle: "Catálogo en línea · Esbart Català de Dansaires",
  eyebrow: "Archivo y Biblioteca de Danza Tradicional",
  title: ["Catálogo ", "en línea"],
  sub: "Busca en el Archivo y la Biblioteca del Esbart Català de Dansaires: libros, artículos, fotografías, grabaciones, partituras, programas y manuscritos sobre danza tradicional catalana.",
  searchPh: "Busca por título, autor, materia, ISBN…",
  searchBtn: "Buscar",
  advShow: "Búsqueda avanzada",
  advHide: "Ocultar la búsqueda avanzada",
  clear: "Limpiar",
  allFormats: "Todos los formatos",
  resultsOne: "resultado",
  untitled: "(Sin título)",
  pivotHint: "Busca todo lo que tenemos de «{value}»",
  resultsMany: "resultados",
  sortLabel: "Ordenar",
  sorts: [
    { id: "rel", label: "Relevancia" },
    { id: "yearDesc", label: "Año (reciente)" },
    { id: "yearAsc", label: "Año (antiguo)" },
    { id: "title", label: "Título (A–Z)" },
  ],
  empty: "No se ha encontrado ningún documento. Prueba con otros términos o amplía la búsqueda.",
  emptyHint: "Consejo: busca por apellido del autor, por materia o por el topográfico (p. ej. «B-III»).",
  didYouMean: "Quizás querías decir:",
  zeroAskText: "¿No encuentras lo que buscas?",
  zeroAskLink: "Escribe al Centro de Documentación",
  pagPrev: "Anterior", pagNext: "Siguiente", pagRange: "{from}–{to} de {total}",
  back: "Volver a los resultados",
  recordLabel: "Ficha del documento",
  signatura: "Topográfico",
  consult: "Solicitar consulta",
  reproduce: "Servicio de reproducción",
  consultNote: "Consulta pública y gratuita en el Archivo y la Biblioteca, con cita previa.",
  reqConsultaTitle: "Solicitud de consulta",
  reqReproTitle: "Solicitud de reproducción",
  reqIntro: "La consulta y la reproducción se realizan con cita previa con la Vocalía de Archivo y Biblioteca. Déjanos tus datos y te responderemos con las indicaciones.",
  reqDocLabel: "Documento solicitado",
  reqName: "Nombre y apellidos",
  reqEmail: "Correo electrónico",
  reqPhone: "Teléfono",
  reqMsg: "Mensaje",
  reqSend: "Envía la solicitud",
  reqSuccess: "Hemos recibido tu solicitud. Te responderemos muy pronto con los pasos a seguir.",
  reqClose: "Cerrar",
  reqPrefillConsulta: "Quisiera solicitar la consulta del documento «{title}» (topográfico {sig}).",
  reqPrefillRepro: "Quisiera solicitar la reproducción del documento «{title}» (topográfico {sig}).",
  reqIntroConsulta: "La consulta es presencial, gratuita y con cita previa con la Vocalía de Archivo y Biblioteca. Indícanos tu disponibilidad y te confirmaremos día y hora.",
  reqIntroRepro: "La reproducción de documentos se realiza según la normativa interna de la entidad. Indícanos el formato y el uso previsto y te informaremos de las condiciones.",
  reqDateLabel: "Fecha preferente de visita",
  reqFormatLabel: "Formato de la reproducción",
  reqFormatOptions: ["Digital", "Papel", "Otros"],
  reqUseLabel: "Uso previsto y observaciones",
  reqNoteConsulta: "Consulta pública y gratuita, con cita previa.",
  reqNoteRepro: "Sujeto a la normativa de reproducción y a los derechos aplicables.",
  reqSending: "Enviando…",
  reqError: "No hemos podido enviar la solicitud. Vuelve a intentarlo en unos instantes — el texto que has escrito se conserva.",
  formats: {
    llibre: "Libro", article: "Artículo", fotografia: "Fotografía", so: "Grabación sonora",
    video: "Vídeo", programa: "Programa / Cartel", manuscrit: "Manuscrito", partitura: "Partitura",
  },
  fields: {
    format: "Formato", autor: "Autor", materia: "Materia", comunes: "Comunes", titol: "Título",
    isbn: "ISBN / ISSN", any: "Año", colleccio: "Colección", lloc: "Lugar", entitat: "Entidad",
    geografic: "Geográfico", topografic: "Topográfico", cronologic: "Cronológico", notes: "Notas",
    editorial: "Editorial", descripcio: "Descripción", edicio: "Edición",
  },
};
const CAT_EN = {
  pageTitle: "Online catalogue · Esbart Català de Dansaires",
  eyebrow: "Traditional Dance Archive & Library",
  title: ["Online ", "catalogue"],
  sub: "Search the Archive and Library of the Esbart Català de Dansaires: books, articles, photographs, recordings, scores, programmes and manuscripts on Catalan traditional dance.",
  searchPh: "Search by title, author, subject, ISBN…",
  searchBtn: "Search",
  advShow: "Advanced search",
  advHide: "Hide advanced search",
  clear: "Clear",
  allFormats: "All formats",
  resultsOne: "result",
  untitled: "(Untitled)",
  pivotHint: "Search everything we hold for “{value}”",
  resultsMany: "results",
  sortLabel: "Sort",
  sorts: [
    { id: "rel", label: "Relevance" },
    { id: "yearDesc", label: "Year (newest)" },
    { id: "yearAsc", label: "Year (oldest)" },
    { id: "title", label: "Title (A–Z)" },
  ],
  empty: "No documents found. Try other terms or broaden your search.",
  emptyHint: "Tip: search by the author's surname, by subject, or by call number (e.g. “B-III”).",
  didYouMean: "Did you mean:",
  zeroAskText: "Can't find what you're looking for?",
  zeroAskLink: "Write to the Documentation Centre",
  pagPrev: "Previous", pagNext: "Next", pagRange: "{from}–{to} of {total}",
  back: "Back to results",
  recordLabel: "Document record",
  signatura: "Call number",
  consult: "Request consultation",
  reproduce: "Reproduction service",
  consultNote: "Public, free consultation at the Archive and Library, by appointment.",
  reqConsultaTitle: "Consultation request",
  reqReproTitle: "Reproduction request",
  reqIntro: "Consultation and reproduction are by appointment with the Archive & Library officer. Leave us your details and we'll reply with instructions.",
  reqDocLabel: "Requested document",
  reqName: "Full name",
  reqEmail: "Email",
  reqPhone: "Phone",
  reqMsg: "Message",
  reqSend: "Send request",
  reqSuccess: "We've received your request. We'll reply very soon with the next steps.",
  reqClose: "Close",
  reqPrefillConsulta: "I would like to request consultation of the document “{title}” (call number {sig}).",
  reqPrefillRepro: "I would like to request reproduction of the document “{title}” (call number {sig}).",
  reqIntroConsulta: "Consultation is in person, free and by appointment with the Archive & Library officer. Tell us your availability and we'll confirm a day and time.",
  reqIntroRepro: "Document reproduction follows the entity's internal rules. Tell us the format and intended use and we'll let you know the conditions.",
  reqDateLabel: "Preferred visit date",
  reqFormatLabel: "Reproduction format",
  reqFormatOptions: ["Digital", "Paper", "Other"],
  reqUseLabel: "Intended use & notes",
  reqNoteConsulta: "Public, free consultation, by appointment.",
  reqNoteRepro: "Subject to reproduction rules and applicable rights.",
  reqSending: "Sending…",
  reqError: "We couldn't send your request. Please try again in a moment — the text you've written is preserved.",
  formats: {
    llibre: "Book", article: "Article", fotografia: "Photograph", so: "Sound recording",
    video: "Video", programa: "Programme / Poster", manuscrit: "Manuscript", partitura: "Score",
  },
  fields: {
    format: "Format", autor: "Author", materia: "Subject", comunes: "Common headings", titol: "Title",
    isbn: "ISBN / ISSN", any: "Year", colleccio: "Collection", lloc: "Place", entitat: "Organisation",
    geografic: "Geographic", topografic: "Call number", cronologic: "Chronological", notes: "Notes",
    editorial: "Publisher", descripcio: "Description", edicio: "Edition",
  },
};
const CAT_STRINGS = { ca: CAT_CA, es: CAT_ES, en: CAT_EN };

const FORMAT_IDS = ["llibre", "article", "fotografia", "so", "video", "programa", "manuscrit", "partitura"];
const ADV_FIELDS = ["titol", "autor", "materia", "isbn", "any", "colleccio", "lloc", "entitat", "geografic", "topografic", "cronologic", "comunes", "notes"];
const PAGE_SIZE = 8; // results per page (mirrors the backend `size` param)
const CHIP_COUNT = 8; // live formats shown as quick chips (the rest, in the select)

// Format label & CSS key: seed ids are translated via t.formats; live DB
// names («Llibre») display as-is — original language, like the records — and
// inherit the colour of their lowercase seed twin when one exists.
const fmtLabel = (t, f) => t.formats[f] || f;
const fmtKey = (f) => {
  const k = String(f || "").toLowerCase();
  return FORMAT_IDS.includes(k) ? k : "altre";
};

/* ═══════════════════════════════════════════════════════════════════════════
   COMPONENTS
   ═══════════════════════════════════════════════════════════════════════════ */
function Masthead({ t, q, setQ, onSubmit, advOpen, setAdvOpen, adv, setAdv, format, setFormat, formats, onClear }) {
  return (
    <header className="cat-masthead" id="inici">
      <div className="cat-masthead__bg" aria-hidden="true"></div>
      <div className="wrap">
        <div className="cat-masthead__eyebrow">{t.eyebrow}</div>
        <h1 className="cat-masthead__title">{t.title[0]}<em>{t.title[1]}</em></h1>
        <p className="cat-masthead__sub">{t.sub}</p>

        <form className="cat-search" onSubmit={(e) => { e.preventDefault(); onSubmit(); }} role="search">
          <span className="cat-search__icon"><SearchIcon /></span>
          <input
            className="cat-search__input" type="search" value={q}
            placeholder={t.searchPh} aria-label={t.searchPh}
            onChange={(e) => setQ(e.target.value)} autoComplete="off"
          />
          <button type="submit" className="cat-search__btn">{t.searchBtn}</button>
        </form>

        <div className="cat-search__row">
          <button className="cat-adv-toggle" onClick={() => setAdvOpen(!advOpen)} aria-expanded={advOpen}>
            <span className={`cat-adv-toggle__plus ${advOpen ? "is-open" : ""}`} aria-hidden="true"></span>
            {advOpen ? t.advHide : t.advShow}
          </button>
          <button className="cat-clear" onClick={onClear}>{t.clear}</button>
        </div>

        <div className={`cat-adv ${advOpen ? "is-open" : ""}`}>
          <div className="cat-adv__grid">
            <div className="pk-field">
              <label htmlFor="adv-format">{t.fields.format}</label>
              <select id="adv-format" value={format} onChange={(e) => setFormat(e.target.value)}>
                <option value="">{t.allFormats}</option>
                {formats.map((f) => <option key={f} value={f}>{fmtLabel(t, f)}</option>)}
              </select>
            </div>
            {ADV_FIELDS.map((f) => (
              <div key={f} className="pk-field">
                <label htmlFor={`adv-${f}`}>{t.fields[f]}</label>
                <input id={`adv-${f}`} type="text" value={adv[f] || ""}
                  onChange={(e) => setAdv({ ...adv, [f]: e.target.value })} autoComplete="off" />
              </div>
            ))}
          </div>
          <div className="cat-adv__actions">
            <button className="btn" onClick={onSubmit}>{t.searchBtn}</button>
            <button className="btn ghost" onClick={onClear}>{t.clear}</button>
          </div>
        </div>
      </div>
    </header>
  );
}

function ResultRow({ t, r, onOpen }) {
  return (
    <li className="cat-row" onClick={() => onOpen(r)} tabIndex="0"
        onKeyDown={(e) => { if (e.key === "Enter") onOpen(r); }}>
      <div className="cat-row__format" data-fmt={fmtKey(r.format)}>{fmtLabel(t, r.format)}</div>
      <div className="cat-row__main">
        <h3 className="cat-row__title">{r.title || t.untitled}</h3>
        <div className="cat-row__authors">{r.authors.join(" · ")}</div>
        {r.fragment && <p className="cat-row__fragment"><Fragment text={r.fragment} /></p>}
        <div className="cat-row__meta">
          {r.editorial && <span>{r.editorial}</span>}
          {r.lloc && <span>{r.lloc}</span>}
          {r.any && <span>{r.any}</span>}
          {r.materia.slice(0, 3).map((m, i) => <span key={i} className="cat-row__tag">{m}</span>)}
        </div>
      </div>
      <div className="cat-row__sig">
        <span className="cat-row__sig-k">{t.signatura}</span>
        <span className="cat-row__sig-v">{r.topografic}</span>
      </div>
      <span className="cat-row__go" aria-hidden="true"><Arrow /></span>
    </li>
  );
}

/* ╔═══════════════════════════════════════════════════════════════════════════╗
   ║  DYNAMIC · DB — CONSULTATION / REPRODUCTION REQUEST (LIVE, F2)             ║
   ║  Modal request form tied to ONE catalogue record (keeps the user in        ║
   ║  context — no jump to a generic contact page). The document reference is   ║
   ║  pre-filled. Submit:                                                       ║
   ║    POST /api/v1/consultes  body { recordId, type:'consulta'|'reproduccio', ║
   ║                                    name, email, phone, message, lang }      ║
   ║  On failure the form STAYS MOUNTED so the visitor's text survives          ║
   ║  (acceptance criterion F2) and the designed error shows above the button.  ║
   ╚═══════════════════════════════════════════════════════════════════════════╝ */
function RequestModal({ t, record, type, lang, onClose }) {
  const isRepro = type === "reproduccio";
  const tmpl = isRepro ? t.reqPrefillRepro : t.reqPrefillConsulta;
  const prefill = tmpl.replace("{title}", record.title).replace("{sig}", record.topografic);
  const [status, setStatus] = useState("idle"); // idle | sending | error | done

  useEffect(() => {
    const onKey = (e) => { if (e.key === "Escape") onClose(); };
    document.addEventListener("keydown", onKey);
    const prev = document.body.style.overflow;
    document.body.style.overflow = "hidden";
    return () => { document.removeEventListener("keydown", onKey); document.body.style.overflow = prev; };
  }, []);

  const onSubmit = (e) => {
    e.preventDefault();
    const data = Object.fromEntries(new FormData(e.target).entries());
    setStatus("sending");
    window.EsbartData.fetchJson("consultes", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ recordId: record.id, type, lang, ...data }),
    })
      .then(() => setStatus("done"))
      .catch(() => setStatus("error"));
  };

  return (
    <div className="cat-modal" role="dialog" aria-modal="true"
         onMouseDown={(e) => { if (e.target === e.currentTarget) onClose(); }}>
      <div className="cat-modal__panel">
        <button className="cat-modal__close" onClick={onClose} aria-label={t.reqClose}>×</button>
        {status === "done" ? (
          <div className="cat-modal__done">
            <div className="pk-form__success">{t.reqSuccess}</div>
            <button className="btn ghost" onClick={onClose} style={{ marginTop: 22, marginLeft: 0 }}>{t.reqClose}</button>
          </div>
        ) : (
          <>
            <div className="cat-modal__eyebrow">{t.eyebrow}</div>
            <h3 className="cat-modal__title">{isRepro ? t.reqReproTitle : t.reqConsultaTitle}</h3>
            <p className="cat-modal__intro">{isRepro ? t.reqIntroRepro : t.reqIntroConsulta}</p>
            <div className="cat-modal__doc">
              <span className="cat-modal__doc-k">{t.reqDocLabel}</span>
              <span className="cat-modal__doc-v">{record.title || t.untitled} · <em>{record.topografic}</em></span>
            </div>
            <form className="pk-form" onSubmit={onSubmit}>
              {/* Honeypot: el servidor descarta en silenci qualsevol enviament
                  que l'ompli (només els bots ho fan — el camp és invisible). */}
              <input type="text" name="website" tabIndex="-1" autoComplete="off"
                aria-hidden="true" style={{ position: "absolute", left: "-9999px", height: 0, width: 0, opacity: 0 }} />
              <div className="pk-field">
                <label htmlFor="rq-name">{t.reqName}</label>
                <input id="rq-name" name="name" type="text" required />
              </div>
              <div className="pk-field--row">
                <div className="pk-field">
                  <label htmlFor="rq-email">{t.reqEmail}</label>
                  <input id="rq-email" name="email" type="email" required />
                </div>
                <div className="pk-field">
                  <label htmlFor="rq-phone">{t.reqPhone}</label>
                  <input id="rq-phone" name="phone" type="tel" />
                </div>
              </div>
              {isRepro ? (
                /* Reproduction-specific: which format the user wants */
                <div className="pk-field">
                  <label htmlFor="rq-format">{t.reqFormatLabel}</label>
                  <select id="rq-format" name="reproFormat">
                    {t.reqFormatOptions.map((o) => <option key={o} value={o}>{o}</option>)}
                  </select>
                </div>
              ) : (
                /* Consultation-specific: preferred appointment date */
                <div className="pk-field">
                  <label htmlFor="rq-date">{t.reqDateLabel}</label>
                  <input id="rq-date" name="preferredDate" type="date" />
                </div>
              )}
              <div className="pk-field">
                <label htmlFor="rq-msg">{isRepro ? t.reqUseLabel : t.reqMsg}</label>
                <textarea id="rq-msg" name="message" defaultValue={prefill}></textarea>
              </div>
              <p className="pk-form__note">{isRepro ? t.reqNoteRepro : t.reqNoteConsulta}</p>
              {status === "error" && (
                <div className="pk-form__error"><span>{t.reqError}</span></div>
              )}
              <div>
                <button type="submit" className="btn" style={{ marginLeft: 0 }} disabled={status === "sending"}>
                  {status === "sending" ? t.reqSending : t.reqSend}
                </button>
              </div>
            </form>
          </>
        )}
      </div>
    </div>
  );
}

function Fitxa({ t, r, onBack, onRequest, onPivot }) {
  // pivot: camps amb valor discret (mai cadenes fusionades) que tenen un
  // filtre visible a la cerca avançada — el clic anuncia exactament on porta.
  const rows = [
    { k: t.fields.autor, v: r.authors, list: true, pivot: "autor" },
    { k: t.fields.titol, v: r.title },
    { k: t.fields.editorial, v: r.editorial },
    { k: t.fields.lloc, v: r.lloc },
    { k: t.fields.any, v: r.any },
    { k: t.fields.edicio, v: r.edicio },
    { k: t.fields.format, v: fmtLabel(t, r.format) },
    { k: t.fields.descripcio, v: r.descripcio },
    { k: t.fields.materia, v: r.materia, list: true, pivot: "materia" },
    { k: t.fields.comunes, v: r.comunes, list: true, pivot: "comunes" },
    { k: t.fields.isbn, v: r.isbn },
    { k: t.fields.geografic, v: r.geografic },
    { k: t.fields.cronologic, v: r.cronologic },
    { k: t.fields.topografic, v: r.topografic, mono: true },
    { k: t.fields.colleccio, v: r.colleccio, pivot: "colleccio" },
    { k: t.fields.entitat, v: r.entitat },
    { k: t.fields.notes, v: r.notes, full: true },
  ].filter((row) => row.list ? (row.v && row.v.length) : row.v);

  const pivotable = (row, value) => (
    row.pivot && onPivot
      ? <button type="button" className="cat-fitxa__pivot"
          title={t.pivotHint.replace("{value}", value)}
          onClick={() => onPivot(row.pivot, value)}>{value}</button>
      : value
  );

  return (
    <div className="cat-fitxa">
      <button className="cat-fitxa__back" onClick={onBack}><Back /> {t.back}</button>
      <div className="cat-fitxa__card">
        <div className="cat-fitxa__head">
          <span className="cat-row__format" data-fmt={fmtKey(r.format)}>{fmtLabel(t, r.format)}</span>
          <span className="cat-fitxa__label">{t.recordLabel} · {r.id}</span>
        </div>
        <h2 className="cat-fitxa__title">{r.title || t.untitled}</h2>

        <dl className="cat-fitxa__grid">
          {rows.map((row, i) => (
            <div key={i} className={`cat-fitxa__row ${row.full ? "is-full" : ""}`}>
              <dt>{row.k}</dt>
              <dd className={row.mono ? "is-mono" : ""}>
                {row.list
                  ? row.v.map((x, j) => <div key={j}>{pivotable(row, x)}</div>)
                  : pivotable(row, row.v)}
              </dd>
            </div>
          ))}
        </dl>

        <div className="cat-fitxa__foot">
          <p className="cat-fitxa__note">{t.consultNote}</p>
          <div className="cat-fitxa__actions">
            <button type="button" className="btn" style={{ marginLeft: 0 }} onClick={() => onRequest("consulta")}>{t.consult}</button>
            <button type="button" className="btn ghost" onClick={() => onRequest("reproduccio")}>{t.reproduce}</button>
          </div>
        </div>
      </div>
    </div>
  );
}

function Results({ t, result, items, page, pageCount, pageSize, setPage, onOpen, format, setFormat, formats, sort, setSort, suggestion, onSuggest }) {
  const n = result.total;
  const from = n === 0 ? 0 : (page - 1) * pageSize + 1;
  const to = Math.min(n, page * pageSize);
  const range = (t.pagRange || "{from}\u2013{to} / {total}")
    .replace("{from}", from).replace("{to}", to).replace("{total}", n);
  // Compact, windowed page numbers for large result sets.
  const pages = [];
  if (pageCount <= 7) {
    for (let i = 1; i <= pageCount; i++) pages.push(i);
  } else {
    const add = (p) => { if (p >= 1 && p <= pageCount && !pages.includes(p)) pages.push(p); };
    add(1); add(2);
    for (let i = page - 1; i <= page + 1; i++) add(i);
    add(pageCount - 1); add(pageCount);
    pages.sort((a, b) => a - b);
  }
  return (
    <section className="cat-results" id="resultats">
      <div className="wrap">
        <div className="cat-toolbar">
          <div className="pk-chips">
            <button className={`pk-chip ${format === "" ? "is-active" : ""}`} onClick={() => setFormat("")}>{t.allFormats}</button>
            {formats.slice(0, CHIP_COUNT).map((f) => (
              <button key={f} className={`pk-chip ${format === f ? "is-active" : ""}`} onClick={() => setFormat(f)}>{fmtLabel(t, f)}</button>
            ))}
          </div>
          <div className="cat-toolbar__right">
            <span className="cat-count">{n} {n === 1 ? t.resultsOne : t.resultsMany}</span>
            <label className="cat-sort">
              <span>{t.sortLabel}</span>
              <select value={sort} onChange={(e) => setSort(e.target.value)}>
                {t.sorts.map((s) => <option key={s.id} value={s.id}>{s.label}</option>)}
              </select>
            </label>
          </div>
        </div>

        {n === 0 ? (
          <div className="cat-empty">
            <p className="cat-empty__msg">{t.empty}</p>
            {suggestion && (
              <p className="cat-empty__suggest">
                {t.didYouMean}{" "}
                <button type="button" className="cat-suggest" onClick={() => onSuggest(suggestion)}>{suggestion}</button>
              </p>
            )}
            <p className="cat-empty__hint">{t.emptyHint}</p>
            <p className="cat-empty__ask">
              {t.zeroAskText}{" "}
              <a href="Participa.html#contacte">{t.zeroAskLink}</a>
            </p>
          </div>
        ) : (
          <>
            <ul className="cat-list" data-live={result.live ? "1" : undefined}>
              {items.map((r) => <ResultRow key={r.id} t={t} r={r} onOpen={onOpen} />)}
            </ul>
            <div className="cat-pagebar">
              <span className="cat-pagebar__range">{range}</span>
              {pageCount > 1 && (
                <nav className="cat-pager" aria-label="Paginació">
                  <button className="cat-pager__btn" disabled={page === 1} onClick={() => setPage(page - 1)}>{t.pagPrev}</button>
                  <div className="cat-pager__nums">
                    {pages.map((p, i) => {
                      const gap = i > 0 && p - pages[i - 1] > 1;
                      return (
                        <React.Fragment key={p}>
                          {gap && <span className="cat-pager__gap">…</span>}
                          <button className={`cat-pager__num ${p === page ? "is-active" : ""}`} onClick={() => setPage(p)} aria-current={p === page ? "page" : undefined}>{p}</button>
                        </React.Fragment>
                      );
                    })}
                  </div>
                  <button className="cat-pager__btn" disabled={page === pageCount} onClick={() => setPage(page + 1)}>{t.pagNext}</button>
                </nav>
              )}
            </div>
          </>
        )}
      </div>
    </section>
  );
}

/* ═══════════════════════════════════════════════════════════════════════════
   APP
   ═══════════════════════════════════════════════════════════════════════════ */
function App() {
  const { lang, setLang, t } = window.EsbartI18n.useI18n(CAT_STRINGS);
  const tweaks = window.EsbartTweaks.useTweaks();

  const [q, setQ] = useState("");
  const [advOpen, setAdvOpen] = useState(false);
  const [adv, setAdv] = useState({});
  const [format, setFormat] = useState("");
  const [sort, setSort] = useState("rel");
  const [selected, setSelected] = useState(null); // la fitxa oberta (objecte sencer)
  const [request, setRequest] = useState(null); // 'consulta' | 'reproduccio' | null
  const [page, setPage] = useState(1);

  useEffect(() => { if (t.pageTitle) document.title = t.pageTitle; }, [lang, t]);

  // Faceta viva de formats (F2): la BD mana; els 8 ids del seed són el
  // fallback. Els xips en mostren els primers (ordenats per nombre de
  // fitxes publicades) i el desplegable de la cerca avançada, tots.
  const { items: formats } = window.EsbartData.useCollection(
    "cataleg/formats", FORMAT_IDS,
    { map: (j) => j.items.map((f) => String(f.nom)) },
  );

  // Cerca viva al servidor (FTS + facetes + paginació); seed si l'API falla.
  const result = useCatalogSearch({ q, format, adv, sort, page });

  // Reset to page 1 whenever the query / filters / sort change.
  useEffect(() => { setPage(1); }, [q, format, adv, sort]);
  // Si els filtres nous deixen la pàgina fora de rang, torna a l'última.
  useEffect(() => { setPage((p) => Math.min(p, result.pages)); }, [result.pages]);
  const pageCount = result.pages;
  const pageItems = result.items;
  const goPage = (p) => {
    setPage(p);
    requestAnimationFrame(() => {
      const el = document.getElementById("resultats");
      if (el) window.scrollTo({ top: el.offsetTop - 80, behavior: "smooth" });
    });
  };

  // La llista viva ja porta la fitxa completa (contracte D6): obrir-la és
  // guardar l'objecte — cap segona crida, i la fitxa no canvia si la cerca
  // de fons es refresca mentre el visitant la llegeix.
  const open = (r) => {
    setSelected(r);
    requestAnimationFrame(() => {
      const el = document.getElementById("resultats");
      if (el) window.scrollTo({ top: el.offsetTop - 80, behavior: "smooth" });
    });
  };
  const back = () => { setSelected(null); setRequest(null); };
  const clearAll = () => { setQ(""); setAdv({}); setFormat(""); setSort("rel"); };

  // Rescat de zero resultats: quan la cerca viva torna buida, demanem un
  // «potser volies dir» al servidor (vocabularis publicats, fuzzy pg_trgm).
  // Silenci absolut si falla: el suggeriment és un extra, mai un error.
  const [suggestion, setSuggestion] = useState(null);
  useEffect(() => {
    const query = q.trim();
    setSuggestion(null);
    if (!result.live || result.total !== 0 || query.length < 3) return;
    let alive = true;
    window.EsbartData.fetchJson(`cataleg/suggeriment?q=${encodeURIComponent(query)}`)
      .then((j) => {
        if (alive && j && typeof j.suggeriment === "string" && j.suggeriment.toLowerCase() !== query.toLowerCase()) {
          setSuggestion(j.suggeriment);
        }
      })
      .catch(() => {});
    return () => { alive = false; };
  }, [q, result.live, result.total]);
  const applySuggestion = (s) => { setAdv({}); setFormat(""); setQ(s); };

  // Pivot des de la fitxa (patró OPAC: «tot el que tenim de…»): executa la
  // cerca avançada amb aquell únic filtre i deixa el panell OBERT perquè el
  // filtre actiu sigui visible i esborrable — mai un destí opac.
  const pivot = (field, value) => {
    setSelected(null); setRequest(null);
    setQ(""); setFormat(""); setSort("rel");
    setAdv({ [field]: value });
    setAdvOpen(true);
    requestAnimationFrame(() => {
      const el = document.getElementById("resultats");
      if (el) window.scrollTo({ top: el.offsetTop - 80, behavior: "smooth" });
    });
  };

  return (
    <>
      <EsbartNav lang={lang} setLang={setLang} t={t} heroMode={false} current="patrimoni" />
      <main id="main-content" tabIndex={-1}>
        <Masthead
          t={t} q={q} setQ={setQ} onSubmit={() => { setSelected(null); }}
          advOpen={advOpen} setAdvOpen={setAdvOpen} adv={adv} setAdv={setAdv}
          format={format} setFormat={setFormat} formats={formats} onClear={clearAll}
        />
        {selected ? (
          <section className="cat-results" id="resultats">
            <div className="wrap"><Fitxa t={t} r={selected} onBack={back} onRequest={setRequest} onPivot={pivot} /></div>
          </section>
        ) : (
          <Results t={t} result={result} items={pageItems} page={page} pageCount={pageCount} pageSize={PAGE_SIZE} setPage={goPage} onOpen={open} format={format} setFormat={setFormat} formats={formats} sort={sort} setSort={setSort} suggestion={suggestion} onSuggest={applySuggestion} />
        )}
      </main>
      {request && selected && (
        <RequestModal t={t} record={selected} type={request} lang={lang} onClose={() => setRequest(null)} />
      )}
      <EsbartFooter t={t} />
      <EsbartTweaksPanel {...tweaks} />
    </>
  );
}

ReactDOM.createRoot(document.getElementById("app")).render(<App />);
