/* ═══════════════════════════════════════════════════════════════════════════
   NOTÍCIES — page bundle.
   Loads after shared/components/{Nav,Footer,TweaksPanel}.jsx.

   ┌─────────────────────────────────────────────────────────────────────────┐
   │  CMS / HEADLESS INTEGRATION                                               │
   │  · News feed, press releases → editorial collections (see «DYNAMIC·CMS»). │
   │  · Newsletter form → POST to the mailing endpoint (see NewsletterSection).│
   │  Data goes live per collection at F1 via shared/cms-data.js (D3).         │
   └─────────────────────────────────────────────────────────────────────────┘
   ═══════════════════════════════════════════════════════════════════════════ */

const { useState, useEffect, useMemo, useRef } = 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;

/* CMS data layer: shared/cms-data.js (window.EsbartData, ADR-06 D3).
   Paths WITHOUT /api/v1 (the module adds the base).
   LIVE since F1: the D6 serializer returns the seed shape verbatim
   ({ id, iso, cat, title, excerpt, img? }), so no map() is needed here.
   Seeds below remain the eternal fallback (INT-1). */

const LOCALE = { ca: "ca-ES", es: "es-ES", en: "en-GB" };
function fmtDate(iso, lang) {
  try {
    return new Date(iso).toLocaleDateString(LOCALE[lang] || "ca-ES", { day: "numeric", month: "long", year: "numeric" });
  } catch (e) { return iso; }
}

function Silhouette({ src }) {
  const ref = useRef(null);
  useEffect(() => {
    let cancelled = false;
    fetch(src).then((r) => r.text()).then((txt) => {
      if (cancelled || !ref.current) return;
      ref.current.innerHTML = txt;
      const svg = ref.current.querySelector("svg");
      if (svg) { svg.setAttribute("preserveAspectRatio", "xMidYMid meet"); svg.setAttribute("aria-hidden", "true"); }
    }).catch(() => {});
    return () => { cancelled = true; };
  }, [src]);
  return <div ref={ref} className="pk-silhouette" aria-hidden="true"></div>;
}

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>
);

/* ═══════════════════════════════════════════════════════════════════════════
   STRINGS — CA / ES / EN
   `news` and `press` arrays are the CMS seed/fallback. Each news item:
     { id, iso:'YYYY-MM-DD', cat:<categoryId>, title, excerpt, img? }
   ═══════════════════════════════════════════════════════════════════════════ */
const NEWS_CA = {
  pageTitle: "Notícies · Esbart Català de Dansaires",
  heroEyebrow: "Actualitat · Agenda · Premsa",
  heroTitle: ["Tot el que ", "passa", " a l'Esbart"],
  heroLead: "Actuacions, exposicions, novetats editorials i vida associativa. L'actualitat de l'Esbart Català de Dansaires, al dia.",
  heroStats: [
    { num: "2025", label: "Temporada en curs" },
    { num: "9", label: "Categories d'actualitat" },
    { num: "1908", label: "Hemeroteca des de" },
  ],
  heroScroll: "Llegeix",

  latestEyebrow: "Últimes notícies",
  latestTitle: ["Al ", "dia"],
  allLabel: "Totes",
  readMore: "Llegir més",
  featuredTag: "Destacada",
  newsCount: "notícies",
  emptyMsg: "No hi ha notícies que coincideixin amb el filtre.",
  filterYear: "Any",
  clearFilters: "Neteja els filtres",
  showingOf: "Es mostren {shown} de {total}",
  loadMore: "Carrega'n més",
  loadingMore: "Carregant…",
  modalClose: "Tanca",
  categories: [
    { id: "actuacions", label: "Actuacions" },
    { id: "mercedansa", label: "MercèDansa" },
    { id: "exposicions", label: "Exposicions" },
    { id: "formacio", label: "Formació" },
    { id: "associativa", label: "Vida associativa" },
    { id: "premsa", label: "Premsa" },
  ],
  news: [
    { id: "n1", iso: "2025-10-18", cat: "associativa", img: "assets/news-big.jpg",
      title: "Moviments socials i veïnals als barris de Ciutat Vella, 1970–1983",
      excerpt: "Taula rodona dins el cicle «Barcelona: construcció democràtica i moviment veïnal», organitzada amb l'Associació de Festes de la Plaça Nova, el Taller d'Història de la Barceloneta i Tot Història." },
    { id: "n2", iso: "2025-10-15", cat: "actuacions",
      title: "Danses vives al Centre Catòlic de Sants",
      excerpt: "Diumenge 19 d'octubre, a les 18 h, presentem «Danses vives» al Centre Catòlic de Sants en la darrera sessió de 2025 d'aquest espectacle." },
    { id: "n3", iso: "2025-10-10", cat: "associativa",
      title: "Comencem el Ball del divendres, amb canvi d'horari",
      excerpt: "L'activitat setmanal torna del 17 d'octubre fins al 12 de juny a La Casa dels Entremesos, en el nou horari de 7 a 8 del vespre." },
    { id: "n4", iso: "2025-10-08", cat: "actuacions",
      title: "L'ECD a la XXXII Trobada d'esbarts de les comarques gironines",
      excerpt: "Amb caràcter extraordinari, l'Esbart participa a la trobada organitzada per l'Agrupació Mediterrània Dansa de Figueres." },
    { id: "n5", iso: "2025-09-23", cat: "mercedansa",
      title: "Festes de la Mercè 2025 amb l'Esbart Català de Dansaires",
      excerpt: "MercèDansa torna dissabte 27 de setembre: sessió de matí a l'Avinguda de la Catedral i sessió de tarda a la Plaça de Sant Jaume, amb balls d'arreu del país." },
    { id: "n6", iso: "2025-09-04", cat: "exposicions",
      title: "Exposició «Cavallets… endavant!»",
      excerpt: "Del 2 de setembre al 5 d'octubre a la Sala d'actes de La Casa dels Entremesos. Entrada gratuïta, de dilluns a dijous de 18 a 20.30 h." },
  ],

  archiveEyebrow: "Arxiu de notícies",
  archiveTitle: ["Explora ", "l'hemeroteca"],
  archiveSub: "Tota l'actualitat de l'Esbart, organitzada per any i per categoria, fins a l'arxiu històric (1908–avui).",
  archiveByYear: "Per any",
  archiveByCat: "Per categoria",
  years: ["2025", "2024", "2023", "2022", "2021", "2020", "2019", "Arxiu 1908–"],
  archiveCats: ["Actuacions i espectacles", "MercèDansa", "La Cafetera i formació", "Exposicions", "Publicacions i presentacions", "Premi Rafel Tudó", "Vida associativa", "Centre de Documentació", "Premsa i comunicats"],

  pressEyebrow: "Premsa i comunicats",
  pressTitle: ["Comunicats ", "oficials"],
  pressSub: "Notes de premsa de l'Esbart Català de Dansaires i aparicions als mitjans.",
  pressAll: "Veure tots els comunicats",
  press: [
    { id: "manifest-laculturapopularesnecessaria", iso: "2021-03-21", title: "MANIFEST #LaCulturaPopularÉsNecessària" },
    { id: "comunicat-de-l-esbart-catala-de-dansaires", iso: "2019-10-14", title: "Comunicat de l'Esbart Català de Dansaires" },
    { id: "manifest", iso: "2018-03-24", title: "Manifest" },
  ],

  nlEyebrow: "Butlletí",
  nlTitle: ["No et perdis ", "res"],
  nlBody: "Rep les últimes notícies, activitats i novetats de l'Esbart directament al teu correu.",
  nlPlaceholder: "El teu correu electrònic",
  nlBtn: "Subscriu-me",
  nlSuccess: "Gràcies! Revisa el teu correu per confirmar la subscripció.",
  nlError: "No s'ha pogut processar la subscripció. Torneu-ho a intentar.",
  nlNote: "Pots donar-te de baixa en qualsevol moment. Mai compartirem les teves dades.",
};

const NEWS_ES = {
  pageTitle: "Noticias · Esbart Català de Dansaires",
  heroEyebrow: "Actualidad · Agenda · Prensa",
  heroTitle: ["Todo lo que ", "pasa", " en el Esbart"],
  heroLead: "Actuaciones, exposiciones, novedades editoriales y vida asociativa. La actualidad del Esbart Català de Dansaires, al día.",
  heroStats: [
    { num: "2025", label: "Temporada en curso" },
    { num: "9", label: "Categorías de actualidad" },
    { num: "1908", label: "Hemeroteca desde" },
  ],
  heroScroll: "Lee",
  latestEyebrow: "Últimas noticias",
  latestTitle: ["Al ", "día"],
  allLabel: "Todas",
  readMore: "Leer más",
  featuredTag: "Destacada",
  newsCount: "noticias",
  emptyMsg: "No hay noticias que coincidan con el filtro.",
  filterYear: "Año",
  clearFilters: "Limpiar filtros",
  showingOf: "Se muestran {shown} de {total}",
  loadMore: "Cargar más",
  loadingMore: "Cargando…",
  modalClose: "Cerrar",
  categories: [
    { id: "actuacions", label: "Actuaciones" },
    { id: "mercedansa", label: "MercèDansa" },
    { id: "exposicions", label: "Exposiciones" },
    { id: "formacio", label: "Formación" },
    { id: "associativa", label: "Vida asociativa" },
    { id: "premsa", label: "Prensa" },
  ],
  news: [
    { id: "n1", iso: "2025-10-18", cat: "associativa", img: "assets/news-big.jpg",
      title: "Movimientos sociales y vecinales en los barrios de Ciutat Vella, 1970–1983",
      excerpt: "Mesa redonda dentro del ciclo «Barcelona: construcción democrática y movimiento vecinal», organizada con la Associació de Festes de la Plaça Nova, el Taller d'Història de la Barceloneta y Tot Història." },
    { id: "n2", iso: "2025-10-15", cat: "actuacions",
      title: "Danses vives en el Centre Catòlic de Sants",
      excerpt: "Domingo 19 de octubre, a las 18 h, presentamos «Danses vives» en el Centre Catòlic de Sants en la última sesión de 2025 de este espectáculo." },
    { id: "n3", iso: "2025-10-10", cat: "associativa",
      title: "Empezamos el Ball del divendres, con cambio de horario",
      excerpt: "La actividad semanal regresa del 17 de octubre hasta el 12 de junio en La Casa dels Entremesos, en el nuevo horario de 7 a 8 de la tarde." },
    { id: "n4", iso: "2025-10-08", cat: "actuacions",
      title: "El ECD en la XXXII Trobada d'esbarts de las comarcas gerundenses",
      excerpt: "Con carácter extraordinario, el Esbart participa en el encuentro organizado por la Agrupació Mediterrània Dansa de Figueres." },
    { id: "n5", iso: "2025-09-23", cat: "mercedansa",
      title: "Fiestas de la Mercè 2025 con el Esbart Català de Dansaires",
      excerpt: "MercèDansa vuelve el sábado 27 de septiembre: sesión de mañana en la Avinguda de la Catedral y sesión de tarde en la Plaça de Sant Jaume, con bailes de todo el país." },
    { id: "n6", iso: "2025-09-04", cat: "exposicions",
      title: "Exposición «Cavallets… endavant!»",
      excerpt: "Del 2 de septiembre al 5 de octubre en la Sala de actos de La Casa dels Entremesos. Entrada gratuita, de lunes a jueves de 18 a 20.30 h." },
  ],
  archiveEyebrow: "Archivo de noticias",
  archiveTitle: ["Explora ", "la hemeroteca"],
  archiveSub: "Toda la actualidad del Esbart, organizada por año y por categoría, hasta el archivo histórico (1908–hoy).",
  archiveByYear: "Por año",
  archiveByCat: "Por categoría",
  years: ["2025", "2024", "2023", "2022", "2021", "2020", "2019", "Archivo 1908–"],
  archiveCats: ["Actuaciones y espectáculos", "MercèDansa", "La Cafetera y formación", "Exposiciones", "Publicaciones y presentaciones", "Premio Rafel Tudó", "Vida asociativa", "Centro de Documentación", "Prensa y comunicados"],
  pressEyebrow: "Prensa y comunicados",
  pressTitle: ["Comunicados ", "oficiales"],
  pressSub: "Notas de prensa del Esbart Català de Dansaires y apariciones en los medios.",
  pressAll: "Ver todos los comunicados",
  press: [
    { id: "manifest-laculturapopularesnecessaria", iso: "2021-03-21", title: "MANIFEST #LaCulturaPopularÉsNecessària" },
    { id: "comunicat-de-l-esbart-catala-de-dansaires", iso: "2019-10-14", title: "Comunicat de l'Esbart Català de Dansaires" },
    { id: "manifest", iso: "2018-03-24", title: "Manifest" },
  ],
  nlEyebrow: "Boletín",
  nlTitle: ["No te pierdas ", "nada"],
  nlBody: "Recibe las últimas noticias, actividades y novedades del Esbart directamente en tu correo.",
  nlPlaceholder: "Tu correo electrónico",
  nlBtn: "Suscríbeme",
  nlSuccess: "¡Gracias! Revisa tu correo para confirmar la suscripción.",
  nlError: "No se ha podido procesar la suscripción. Inténtalo de nuevo.",
  nlNote: "Puedes darte de baja en cualquier momento. Nunca compartiremos tus datos.",
};

const NEWS_EN = {
  pageTitle: "News · Esbart Català de Dansaires",
  heroEyebrow: "News · Calendar · Press",
  heroTitle: ["Everything ", "happening", " at the Esbart"],
  heroLead: "Performances, exhibitions, new publications and association life. The latest from the Esbart Català de Dansaires.",
  heroStats: [
    { num: "2025", label: "Current season" },
    { num: "9", label: "News categories" },
    { num: "1908", label: "Press archive since" },
  ],
  heroScroll: "Read",
  latestEyebrow: "Latest news",
  latestTitle: ["Up to ", "date"],
  allLabel: "All",
  readMore: "Read more",
  featuredTag: "Featured",
  newsCount: "news",
  emptyMsg: "No news matches the filter.",
  filterYear: "Year",
  clearFilters: "Clear filters",
  showingOf: "Showing {shown} of {total}",
  loadMore: "Load more",
  loadingMore: "Loading…",
  modalClose: "Close",
  categories: [
    { id: "actuacions", label: "Performances" },
    { id: "mercedansa", label: "MercèDansa" },
    { id: "exposicions", label: "Exhibitions" },
    { id: "formacio", label: "Training" },
    { id: "associativa", label: "Association life" },
    { id: "premsa", label: "Press" },
  ],
  news: [
    { id: "n1", iso: "2025-10-18", cat: "associativa", img: "assets/news-big.jpg",
      title: "Social and neighbourhood movements in Ciutat Vella, 1970–1983",
      excerpt: "A round table in the series «Barcelona: democratic construction and the neighbourhood movement», organised with the Plaça Nova Festivals Association, the Barceloneta History Workshop and Tot Història." },
    { id: "n2", iso: "2025-10-15", cat: "actuacions",
      title: "«Danses vives» at the Centre Catòlic de Sants",
      excerpt: "Sunday 19 October at 6 pm we present «Danses vives» at the Centre Catòlic de Sants, the final 2025 session of this show." },
    { id: "n3", iso: "2025-10-10", cat: "associativa",
      title: "The Friday Dance returns, with a new schedule",
      excerpt: "The weekly activity runs from 17 October to 12 June at La Casa dels Entremesos, now from 7 to 8 pm." },
    { id: "n4", iso: "2025-10-08", cat: "actuacions",
      title: "The ECD at the 32nd Girona-region esbarts gathering",
      excerpt: "Exceptionally, the Esbart takes part in the gathering organised by the Agrupació Mediterrània Dansa of Figueres." },
    { id: "n5", iso: "2025-09-23", cat: "mercedansa",
      title: "La Mercè 2025 festivities with the Esbart Català de Dansaires",
      excerpt: "MercèDansa returns on Saturday 27 September: a morning session at Avinguda de la Catedral and an afternoon session at Plaça de Sant Jaume, with dances from across the country." },
    { id: "n6", iso: "2025-09-04", cat: "exposicions",
      title: "Exhibition «Cavallets… onward!»",
      excerpt: "From 2 September to 5 October in the events hall of La Casa dels Entremesos. Free entry, Monday to Thursday from 6 to 8.30 pm." },
  ],
  archiveEyebrow: "News archive",
  archiveTitle: ["Browse ", "the archive"],
  archiveSub: "All the Esbart's news, organised by year and category, back to the historical archive (1908–today).",
  archiveByYear: "By year",
  archiveByCat: "By category",
  years: ["2025", "2024", "2023", "2022", "2021", "2020", "2019", "Archive 1908–"],
  archiveCats: ["Performances & shows", "MercèDansa", "La Cafetera & training", "Exhibitions", "Publications & presentations", "Rafel Tudó Award", "Association life", "Documentation Centre", "Press & releases"],
  pressEyebrow: "Press & releases",
  pressTitle: ["Official ", "statements"],
  pressSub: "Press releases from the Esbart Català de Dansaires and media appearances.",
  pressAll: "See all releases",
  press: [
    { id: "manifest-laculturapopularesnecessaria", iso: "2021-03-21", title: "MANIFEST #LaCulturaPopularÉsNecessària" },
    { id: "comunicat-de-l-esbart-catala-de-dansaires", iso: "2019-10-14", title: "Comunicat de l'Esbart Català de Dansaires" },
    { id: "manifest", iso: "2018-03-24", title: "Manifest" },
  ],
  nlEyebrow: "Newsletter",
  nlTitle: ["Don't miss ", "a thing"],
  nlBody: "Get the latest news, activities and updates from the Esbart straight to your inbox.",
  nlPlaceholder: "Your email address",
  nlBtn: "Subscribe",
  nlSuccess: "Thanks! Check your inbox to confirm your subscription.",
  nlError: "We couldn't process your subscription. Please try again.",
  nlNote: "You can unsubscribe at any time. We will never share your data.",
};

const NEWS_STRINGS = { ca: NEWS_CA, es: NEWS_ES, en: NEWS_EN };

/* ╔═══════════════════════════════════════════════════════════════════════════╗
   ║  SEED — ARTICLE BODY (full text of the seed news items only)              ║
   ║  Live articles fetch GET /noticies/{id}?lang={lang} from the modal; this  ║
   ║  lookup is the fallback when the API is down (INT-1) or for seed ids.     ║
   ╚═══════════════════════════════════════════════════════════════════════════╝ */
const NEWS_BODIES = {
  ca: {
    n1: ["La taula rodona reunirà veus del moviment veïnal i historiadors per repassar la transformació social dels barris de Ciutat Vella entre 1970 i 1983.", "L'acte forma part del cicle «Barcelona: construcció democràtica i moviment veïnal» i és d'entrada lliure fins a completar l'aforament."],
    n2: ["«Danses vives» és l'espectacle de l'Esbart que mostra, en clau divulgativa, una selecció de balls del repertori tradicional català.", "La sessió al Centre Catòlic de Sants tanca la temporada 2025 d'aquest format itinerant."],
    n3: ["El Ball del divendres és l'activitat oberta setmanal de l'entitat a La Casa dels Entremesos.", "Aquesta temporada s'avança l'horari a les 7 del vespre per facilitar-hi la participació de tothom, del 17 d'octubre al 12 de juny."],
    n4: ["La XXXII Trobada d'esbarts de les comarques gironines, organitzada per l'Agrupació Mediterrània Dansa de Figueres, reuneix colles d'arreu del territori.", "La participació de l'Esbart hi té caràcter extraordinari."],
    n5: ["Les Festes de la Mercè 2025 arrenquen amb el Pregó i es clouen amb el Piromusical.", "El MercèDansa omple el dissabte 27 de setembre amb dues sessions —matí a l'Avinguda de la Catedral i tarda a la Plaça de Sant Jaume— amb balls vinguts d'arreu del país."],
    n6: ["L'exposició «Cavallets… endavant!» repassa la història dels Cavallets Cotoners de Barcelona, un dels entremesos més emblemàtics de la ciutat, documentat des de 1424.", "Es pot visitar a la Sala d'actes de La Casa dels Entremesos amb entrada gratuïta."],
  },
  es: {
    n1: ["La mesa redonda reunirá voces del movimiento vecinal e historiadores para repasar la transformación social de los barrios de Ciutat Vella entre 1970 y 1983.", "El acto forma parte del ciclo «Barcelona: construcción democrática y movimiento vecinal» y es de entrada libre hasta completar el aforo."],
    n2: ["«Danses vives» es el espectáculo del Esbart que muestra, en clave divulgativa, una selección de bailes del repertorio tradicional catalán.", "La sesión en el Centre Catòlic de Sants cierra la temporada 2025 de este formato itinerante."],
    n3: ["El Ball del divendres es la actividad abierta semanal de la entidad en La Casa dels Entremesos.", "Esta temporada se adelanta el horario a las 7 de la tarde para facilitar la participación de todos, del 17 de octubre al 12 de junio."],
    n4: ["La XXXII Trobada d'esbarts de las comarcas gerundenses, organizada por la Agrupació Mediterrània Dansa de Figueres, reúne grupos de todo el territorio.", "La participación del Esbart tiene carácter extraordinario."],
    n5: ["Las Fiestas de la Mercè 2025 arrancan con el Pregón y se cierran con el Piromusical.", "El MercèDansa llena el sábado 27 de septiembre con dos sesiones —mañana en la Avinguda de la Catedral y tarde en la Plaça de Sant Jaume— con bailes venidos de todo el país."],
    n6: ["La exposición «Cavallets… endavant!» repasa la historia de los Cavallets Cotoners de Barcelona, uno de los entremeses más emblemáticos de la ciudad, documentado desde 1424.", "Se puede visitar en la Sala de actos de La Casa dels Entremesos con entrada gratuita."],
  },
  en: {
    n1: ["The round table brings together neighbourhood-movement voices and historians to review the social transformation of Ciutat Vella's quarters between 1970 and 1983.", "It is part of the series «Barcelona: democratic construction and the neighbourhood movement» and entry is free while seats last."],
    n2: ["«Danses vives» is the Esbart's show presenting, in an accessible way, a selection of dances from the Catalan traditional repertoire.", "The session at the Centre Catòlic de Sants closes the 2025 season of this touring format."],
    n3: ["The Friday Dance is the entity's weekly open activity at La Casa dels Entremesos.", "This season the time moves earlier to 7 pm to make it easier for everyone to join, from 17 October to 12 June."],
    n4: ["The 32nd gathering of Girona-region esbarts, organised by the Agrupació Mediterrània Dansa of Figueres, brings together groups from across the territory.", "The Esbart's participation is exceptional."],
    n5: ["La Mercè 2025 begins with the opening address and closes with the Pyromusical.", "MercèDansa fills Saturday 27 September with two sessions —morning at Avinguda de la Catedral and afternoon at Plaça de Sant Jaume— with dances from across the country."],
    n6: ["The exhibition «Cavallets… onward!» traces the history of the Cavallets Cotoners of Barcelona, one of the city's most emblematic entremesos, documented since 1424.", "It can be visited in the events hall of La Casa dels Entremesos with free entry."],
  },
};

/* ═══════════════════════════════════════════════════════════════════════════
   SECTIONS
   ═══════════════════════════════════════════════════════════════════════════ */
function Hero({ t }) {
  return (
    <header className="pk-hero" id="inici">
      <div className="pk-hero__bg" aria-hidden="true"></div>
      <Silhouette src="assets/cavallet_silhouette.svg" />
      <div className="wrap">
        <div className="pk-hero__eyebrow">{t.heroEyebrow}</div>
        <h1 className="pk-hero__title">{t.heroTitle[0]}<em>{t.heroTitle[1]}</em>{t.heroTitle[2]}</h1>
        <p className="pk-hero__lead">{t.heroLead}</p>
        <div className="pk-hero__meta">
          {t.heroStats.map((s, i) => (
            <div key={i} className="pk-hero__meta-item">
              <div className="pk-hero__meta-num">{s.num}</div>
              <div className="pk-hero__meta-label">{s.label}</div>
            </div>
          ))}
        </div>
      </div>
      <div className="pk-hero__scroll" aria-hidden="true">{t.heroScroll}</div>
    </header>
  );
}

/* ╔═══════════════════════════════════════════════════════════════════════════╗
   ║  SanitizedHtml — renders the CMS article `body` (INT-4)                    ║
   ║  The server already sanitizes with a closed allow-list (contingut.ts).    ║
   ║  Still, the string is never injected (no innerHTML anywhere in the        ║
   ║  public web — SAST enforces it): it is parsed and rebuilt as React        ║
   ║  elements through the SAME allow-list, so even a sanitizer regression     ║
   ║  upstream could not smuggle markup or javascript: URLs into the page.     ║
   ╚═══════════════════════════════════════════════════════════════════════════╝ */
const RICH_TAGS = ["p", "br", "strong", "em", "u", "s", "a", "ul", "ol", "li",
  "h2", "h3", "h4", "blockquote", "figure", "figcaption", "img"];
const RICH_ATTRS = { a: ["href", "title"], img: ["src", "alt"] };
const safeUrl = (v) => /^(https?:|mailto:)/i.test(v) || !/^[a-z][a-z0-9+.-]*:/i.test(v);

function richChildren(el, keyBase) {
  const out = [];
  el.childNodes.forEach((n, i) => {
    const key = `${keyBase}.${i}`;
    if (n.nodeType === Node.TEXT_NODE) { out.push(n.nodeValue); return; }
    if (n.nodeType !== Node.ELEMENT_NODE) return;
    const tag = n.tagName.toLowerCase();
    const kids = richChildren(n, key);
    if (!RICH_TAGS.includes(tag)) { out.push(...kids); return; } // unwrap: text survives, markup does not
    if (tag === "br") { out.push(<br key={key} />); return; }
    const props = { key };
    for (const attr of RICH_ATTRS[tag] || []) {
      const v = n.getAttribute(attr);
      if (v != null && (attr !== "href" && attr !== "src" || safeUrl(v))) props[attr] = v;
    }
    if (tag === "a") { props.rel = "noopener noreferrer"; props.target = "_blank"; }
    if (tag === "img") { out.push(React.createElement(tag, props)); return; } // void element
    out.push(React.createElement(tag, props, kids));
  });
  return out;
}

function SanitizedHtml({ html }) {
  const nodes = useMemo(() => {
    try {
      const doc = new DOMParser().parseFromString(html, "text/html");
      return richChildren(doc.body, "b");
    } catch (e) { return []; }
  }, [html]);
  return <div className="news-modal__body">{nodes}</div>;
}

/* ╔═══════════════════════════════════════════════════════════════════════════╗
   ║  DYNAMIC · CMS — ARTICLE READER (modal)                                    ║
   ║  Live body: GET /noticies/{id}?lang={lang} (D6 detail, sanitized HTML).   ║
   ║  Until it arrives — or if it never does (INT-1/INT-2) — the seed          ║
   ║  paragraphs (NEWS_BODIES) or just title+excerpt are shown instead.        ║
   ╚═══════════════════════════════════════════════════════════════════════════╝ */
function NewsModal({ t, item, lang, categories, onClose }) {
  const seedBody = (NEWS_BODIES[lang] && NEWS_BODIES[lang][item.id]) || [];
  const [html, setHtml] = useState(null);
  useEffect(() => {
    let cancelled = false;
    setHtml(null);
    window.EsbartData.fetchJson(`noticies/${encodeURIComponent(item.id)}?lang=${lang}`)
      .then((json) => { if (!cancelled && typeof json.body === "string" && json.body) setHtml(json.body); })
      .catch((err) => { console.warn("[cms-data]", err); }); // keep the seed body
    return () => { cancelled = true; };
  }, [item.id, lang]);
  const catLabel = (id) => (categories.find((c) => c.id === id) || {}).label || id;
  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; };
  }, []);
  return (
    <div className="news-modal" role="dialog" aria-modal="true"
         onMouseDown={(e) => { if (e.target === e.currentTarget) onClose(); }}>
      <div className="news-modal__panel">
        <button className="news-modal__close" onClick={onClose} aria-label={t.modalClose}>×</button>
        {item.img && <div className="news-modal__media"><img src={item.img} alt={item.title} /></div>}
        <div className="news-modal__content">
          <div className="news-card__meta">
            <span className="news-card__cat">{catLabel(item.cat)}</span>
            <span className="news-card__date">{fmtDate(item.iso, lang)}</span>
          </div>
          <h2 className="news-modal__title">{item.title}</h2>
          <p className="news-modal__lead">{item.excerpt}</p>
          {html
            ? <SanitizedHtml html={html} />
            : seedBody.map((p, i) => <p key={i} className="news-modal__p">{p}</p>)}
        </div>
      </div>
    </div>
  );
}

/* ╔═══════════════════════════════════════════════════════════════════════════╗
   ║  DYNAMIC · CMS — NEWS FEED (live since F1)                                 ║
   ║  Endpoint : GET /noticies?lang={lang}&cat={categoryId}&year={year}        ║
   ║  Item     : { id, iso:'YYYY-MM-DD', cat, title, excerpt, img? }           ║
   ║  Detail   : each card opens the reader modal (GET /noticies/{id})         ║
   ╚═══════════════════════════════════════════════════════════════════════════╝ */
const PAGE_SIZE = 10; // lot de 10: la destacada + una graella de 3×3, mai un scroll infinit

function LatestSection({ t, lang, cat, setCat, year, setYear, categories, onOpen }) {
  // cat/year travel in the path so the SERVER filters the whole hemeroteca
  // (469+ items), not just the page we already have. Path = cache key (D3).
  // The list grows in PAGE_SIZE batches («Carrega'n més»); meta carries the
  // envelope total so every count on screen stays honest.
  const path = `noticies?lang=${lang}&size=${PAGE_SIZE}`
    + (cat !== "all" ? `&cat=${encodeURIComponent(cat)}` : "")
    + (year ? `&year=${year}` : "");
  const { items: news, live, meta } = window.EsbartData.useCollection(path, t.news, {
    meta: (j) => ({ total: j.total, pages: j.pages }),
  });

  // Pages 2+ accumulate here; changing filters or language starts afresh.
  const [extra, setExtra] = useState([]);
  const [page, setPage] = useState(1);
  const [loading, setLoading] = useState(false);
  useEffect(() => { setExtra([]); setPage(1); setLoading(false); }, [path]);

  // Dedup by id: if something is published mid-browse, page boundaries
  // shift and an item could arrive twice (duplicate React keys = console
  // noise the E2E gate treats as a failure).
  const all = useMemo(() => {
    if (!extra.length) return news;
    const seen = new Set();
    return [...news, ...extra].filter((n) => !seen.has(n.id) && seen.add(n.id));
  }, [news, extra]);

  // The client-side filter stays: a no-op on server-filtered live data, the
  // real filter while the seeds are showing (API down or not yet arrived).
  const filtered = useMemo(() => {
    let list = all;
    if (cat !== "all") list = list.filter((n) => n.cat === cat);
    if (year) list = list.filter((n) => n.iso.slice(0, 4) === String(year));
    // Ties return 0 so the API order (publication desc) is preserved.
    return [...list].sort((a, b) => (a.iso < b.iso ? 1 : a.iso > b.iso ? -1 : 0));
  }, [all, cat, year]);

  const total = live && meta ? meta.total : filtered.length;
  const hasMore = live && meta && page < meta.pages;
  const loadMore = () => {
    if (loading) return;
    setLoading(true);
    const next = page + 1;
    window.EsbartData.loadEnvelope(`${path}&page=${next}`)
      .then(({ items }) => { setExtra((xs) => [...xs, ...items]); setPage(next); })
      .catch((err) => console.warn("[cms-data]", err)) // INT-2: cap error tècnic al visitant
      .finally(() => setLoading(false));
  };

  const catLabel = (id) => (categories.find((c) => c.id === id) || {}).label || id;
  const featured = filtered[0];
  const rest = filtered.slice(1);

  return (
    <section className="pk-section pk-section--cream" id="ultimes">
      <div className="wrap">
        <div className="pk-head">
          <div className="pk-head__eyebrow">{t.latestEyebrow}</div>
          <div className="pk-head__grid">
            <h2 className="pk-head__title">{t.latestTitle[0]}<em>{t.latestTitle[1]}</em></h2>
            <p className="pk-head__sub" style={{ alignSelf: "end" }}>{total} {t.newsCount}</p>
          </div>
        </div>

        <div className="pk-chips" style={{ marginBottom: 28 }}>
          <button className={`pk-chip ${cat === "all" ? "is-active" : ""}`} onClick={() => setCat("all")}>{t.allLabel}</button>
          {categories.map((c) => (
            <button key={c.id} className={`pk-chip ${cat === c.id ? "is-active" : ""}`} onClick={() => setCat(c.id)}>{c.label}</button>
          ))}
        </div>

        {(cat !== "all" || year) && (
          <div className="news-activefilter">
            {cat !== "all" && (
              <span className="news-fpill">{catLabel(cat)}<button onClick={() => setCat("all")} aria-label="×">×</button></span>
            )}
            {year && (
              <span className="news-fpill">{t.filterYear} {year}<button onClick={() => setYear(null)} aria-label="×">×</button></span>
            )}
            <button className="news-clearbtn" onClick={() => { setCat("all"); setYear(null); }}>{t.clearFilters}</button>
          </div>
        )}

        {filtered.length === 0 ? (
          <div className="pk-empty">{t.emptyMsg}</div>
        ) : (
          <>
            {featured && (
              <article className="news-featured">
                <button type="button" className="news-featured__media" onClick={() => onOpen(featured)} aria-label={featured.title}>
                  {featured.img
                    ? <img src={featured.img} alt={featured.title} />
                    : <div className="ph" data-label="news image" style={{ position: "absolute", inset: 0 }}></div>}
                  <span className="news-featured__tag">{t.featuredTag}</span>
                </button>
                <div className="news-featured__body">
                  <div className="news-card__meta">
                    <span className="news-card__cat">{catLabel(featured.cat)}</span>
                    <span className="news-card__date">{fmtDate(featured.iso, lang)}</span>
                  </div>
                  <h3 className="news-featured__title">{featured.title}</h3>
                  <p className="news-featured__excerpt">{featured.excerpt}</p>
                  <button type="button" className="pk-card__link news-more" onClick={() => onOpen(featured)}>{t.readMore} <Arrow /></button>
                </div>
              </article>
            )}

            {rest.length > 0 && (
              <div className="pk-grid pk-grid--3" style={{ marginTop: 56 }}>
                {rest.map((n) => (
                  <article key={n.id} className="pk-card news-card">
                    <div className="news-card__meta">
                      <span className="news-card__cat">{catLabel(n.cat)}</span>
                      <span className="news-card__date">{fmtDate(n.iso, lang)}</span>
                    </div>
                    <h3 className="pk-card__title">{n.title}</h3>
                    <p className="pk-card__body">{n.excerpt}</p>
                    <button type="button" className="pk-card__link news-more" onClick={() => onOpen(n)}>{t.readMore} <Arrow /></button>
                  </article>
                ))}
              </div>
            )}

            {hasMore && (
              <div className="pk-loadmore">
                <span className="pk-loadmore__count">
                  {t.showingOf.replace("{shown}", filtered.length).replace("{total}", total)}
                </span>
                <button type="button" className="btn ghost" onClick={loadMore} disabled={loading}>
                  {loading ? t.loadingMore : t.loadMore}
                </button>
              </div>
            )}
          </>
        )}
      </div>
    </section>
  );
}

function ArchiveSection({ t, cat, year, categories, onPick }) {
  const isYear = (y) => /^\d{4}$/.test(y);
  // Live year facets (language-independent, so no lang in the path). The
  // non-year entry («Arxiu 1908–») stays from the strings: it clears the
  // year filter instead of pointing at a facet.
  const { items: liveYears } = window.EsbartData.useCollection(
    "noticies/years", t.years.filter(isYear), { map: (j) => j.years.map(String) });
  const years = [...liveYears, ...t.years.filter((y) => !isYear(y))];
  return (
    <section className="pk-section pk-section--paper" id="arxiu">
      <div className="wrap">
        <div className="pk-head">
          <div className="pk-head__eyebrow">{t.archiveEyebrow}</div>
          <div className="pk-head__grid">
            <h2 className="pk-head__title">{t.archiveTitle[0]}<em>{t.archiveTitle[1]}</em></h2>
            <p className="pk-head__sub">{t.archiveSub}</p>
          </div>
        </div>

        <div className="news-archive">
          <div className="news-archive__col">
            <h4 className="pk-doc-blockh">{t.archiveByYear}</h4>
            <div className="news-years">
              {years.map((y, i) => (
                <button
                  key={i}
                  className={`news-year ${isYear(y) && String(year) === y ? "is-active" : ""}`}
                  onClick={() => onPick({ year: isYear(y) ? y : null })}
                >
                  {y}
                </button>
              ))}
            </div>
          </div>
          <div className="news-archive__col">
            <h4 className="pk-doc-blockh">{t.archiveByCat}</h4>
            {/* Same live category set as the feed, so every entry filters it. */}
            <ul className="news-catlist">
              <li>
                <button className={`news-catlink ${cat === "all" ? "is-active" : ""}`} onClick={() => onPick({ cat: "all" })}>{t.allLabel}</button>
              </li>
              {categories.map((c) => (
                <li key={c.id}>
                  <button className={`news-catlink ${cat === c.id ? "is-active" : ""}`} onClick={() => onPick({ cat: c.id })}>{c.label}</button>
                </li>
              ))}
            </ul>
          </div>
        </div>
      </div>
    </section>
  );
}

/* ╔═══════════════════════════════════════════════════════════════════════════╗
   ║  DYNAMIC · CMS — PRESS RELEASES                                            ║
   ║  Endpoint : GET /api/v1/noticies?cat=premsa&lang={lang}                   ║
   ║  Item     : { id, iso:'YYYY-MM-DD', title, … }  (serialitzador noticies)  ║
   ╚═══════════════════════════════════════════════════════════════════════════╝ */
function PressSection({ t, lang, onPick }) {
  const { items: press } = window.EsbartData.useCollection(`noticies?cat=premsa&lang=${lang}`, t.press);
  const sorted = [...press].sort((a, b) => (a.iso < b.iso ? 1 : a.iso > b.iso ? -1 : 0));
  return (
    <section className="pk-section pk-section--bone" id="premsa">
      <div className="wrap">
        <div className="pk-head">
          <div className="pk-head__eyebrow">{t.pressEyebrow}</div>
          <div className="pk-head__grid">
            <h2 className="pk-head__title">{t.pressTitle[0]}<em>{t.pressTitle[1]}</em></h2>
            <p className="pk-head__sub">{t.pressSub}</p>
          </div>
        </div>
        <ul className="pk-explist">
          {sorted.map((c) => (
            <li key={c.id} className="pk-exprow">
              <span className="pk-exprow__year" style={{ fontSize: 18 }}>{fmtDate(c.iso, lang)}</span>
              <span className="pk-exprow__title">{c.title}</span>
            </li>
          ))}
        </ul>
        <div style={{ marginTop: 36 }}>
          {/* Shows the full press feed by filtering the news list above to «premsa». */}
          <button type="button" className="btn ghost" onClick={() => onPick({ cat: "premsa" })}>{t.pressAll}</button>
        </div>
      </div>
    </section>
  );
}

/* ╔═══════════════════════════════════════════════════════════════════════════╗
   ║  DYNAMIC · CMS — NEWSLETTER SIGN-UP — LIVE (F2)                            ║
   ║  Submit  : POST /api/v1/newsletter  body { email, lang }                  ║
   ║  Response: 202 always (anti-enumeration; double opt-in by email);         ║
   ║            network/API failure → designed error, typed email preserved.   ║
   ╚═══════════════════════════════════════════════════════════════════════════╝ */
function NewsletterSection({ t, lang }) {
  const [email, setEmail] = useState("");
  const [status, setStatus] = useState("idle"); // idle | loading | success | error
  const Spinner = () => <span className="pk-spinner" aria-hidden="true" />;
  const loading = status === "loading";

  const onSubmit = (e) => {
    e.preventDefault();
    setStatus("loading");
    window.EsbartData.fetchJson("newsletter", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ email, lang }),
    })
      .then(() => setStatus("success"))
      .catch(() => setStatus("error"));
  };

  const loadingLabel = lang === "en" ? "Sending…" : lang === "es" ? "Enviando…" : "Enviant…";
  const retryLabel   = lang === "en" ? "Retry"    : lang === "es" ? "Reintentar" : "Torna a intentar";

  return (
    <section className="pk-cta" id="butlleti">
      <div className="wrap" style={{ maxWidth: 760 }}>
        <div className="pk-cta__eyebrow">{t.nlEyebrow}</div>
        <h2 className="pk-cta__title">{t.nlTitle[0]}<em>{t.nlTitle[1]}</em></h2>
        <p className="pk-cta__lead">{t.nlBody}</p>

        {status === "success" ? (
          <div className="news-nl__success">{t.nlSuccess}</div>
        ) : status === "error" ? (
          <div className="pk-form__error" style={{ maxWidth: 520, margin: "0 auto", justifyContent: "center" }}>
            <span>{t.nlError}</span>
            <button type="button" className="pk-form__error-retry"
              onClick={() => setStatus("idle")}>{retryLabel}</button>
          </div>
        ) : (
          <form className="news-nl" onSubmit={onSubmit}>
            <input type="email" required value={email} placeholder={t.nlPlaceholder}
              onChange={(e) => setEmail(e.target.value)} aria-label={t.nlPlaceholder}
              disabled={loading} />
            <button type="submit" className={`btn light${loading ? " btn--loading" : ""}`} disabled={loading}>
              {loading ? <><Spinner />{loadingLabel}</> : t.nlBtn}
            </button>
          </form>
        )}
        <p className="news-nl__note">{t.nlNote}</p>
      </div>
    </section>
  );
}

/* ═══════════════════════════════════════════════════════════════════════════
   APP
   ═══════════════════════════════════════════════════════════════════════════ */
function App() {
  const { lang, setLang, t } = window.EsbartI18n.useI18n(NEWS_STRINGS);
  const tweaks = window.EsbartTweaks.useTweaks();
  const [heroMode] = useState(false);
  const [newsCat, setNewsCat] = useState("all");
  const [newsYear, setNewsYear] = useState(null);
  const [article, setArticle] = useState(null);
  useEffect(() => { if (t.pageTitle) document.title = t.pageTitle; }, [lang, t]);

  // Live category facets (labels administered in the CMS), shared by the
  // feed chips, the archive column and the reader modal.
  const { items: categories } = window.EsbartData.useCollection(
    `noticies/categories?lang=${lang}`, t.categories);

  // Archive picks drive the feed filters, then scroll up to the feed.
  const pick = (patch) => {
    if ("cat" in patch) setNewsCat(patch.cat);
    if ("year" in patch) setNewsYear(patch.year);
    requestAnimationFrame(() => {
      const el = document.getElementById("ultimes");
      if (el) window.scrollTo({ top: el.offsetTop - 70, behavior: "smooth" });
    });
  };

  return (
    <>
      <EsbartNav lang={lang} setLang={setLang} t={t} heroMode={heroMode} current="noticies" />
      <main id="main-content" tabIndex={-1}>
        <Hero t={t} />
        <LatestSection t={t} lang={lang} cat={newsCat} setCat={setNewsCat} year={newsYear} setYear={setNewsYear} categories={categories} onOpen={setArticle} />
        <ArchiveSection t={t} cat={newsCat} year={newsYear} categories={categories} onPick={pick} />
        <PressSection t={t} lang={lang} onPick={pick} />
        <NewsletterSection t={t} lang={lang} />
      </main>
      {article && <NewsModal t={t} item={article} lang={lang} categories={categories} onClose={() => setArticle(null)} />}
      <EsbartFooter t={t} />
      <EsbartTweaksPanel {...tweaks} />
    </>
  );
}

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