/* Pages — composed from blocks. All copy resolved via useLang() / t. */

/* Certifications strip — every accreditation the firm holds + every
   Uptime Tier seal earned on a real project. Two rows on desktop
   (7 columns × 2), wraps freely on narrower viewports. Each logo is
   sized large enough that the small print on the Tier seals (project
   name + date) stays legible. Seals with a `url` link out.
   Shared: always on the home page; also rendered on Quiénes somos
   when home.credentialsOnQuienes is set (CMS toggle). */
const CredentialsStrip = () => {
  const { t } = useLang();
  const C = t.home && t.home.credentials;
  if (!C || !C.items || C.items.length === 0) return null;
  return (
    <section className="section credentials-strip">
      <div className="container">
        <Reveal><span className="eyebrow credentials-strip__eyebrow">{C.eyebrow}</span></Reveal>
        <Reveal delay={120}>
          <div className="credentials-strip__list">
            {C.items.map((c, i) => {
              const img = <img src={c.logo} alt={c.label} loading="lazy" />;
              /* Three shapes, depending on what the seal carries:
                 PDF + verificación → selector al pasar por encima;
                 solo uno de los dos → enlace directo, como siempre;
                 ninguno            → el sello sin más. */
              if (c.url && c.verifyUrl) {
                return (
                  <div key={i} className="credentials-strip__item credentials-strip__item--dual" title={c.label}>
                    {img}
                    {/* El panel no se oculta con visibility ni display: así los
                        dos enlaces siguen siendo alcanzables con el tabulador,
                        y al recibir el foco :focus-within lo muestra igual que
                        lo haría el ratón. */}
                    <div className="credentials-strip__choice">
                      <a href={c.url} target="_blank" rel="noopener noreferrer">{C.pdfLabel}</a>
                      <a href={c.verifyUrl} target="_blank" rel="noopener noreferrer">{C.verifyLabel}</a>
                    </div>
                  </div>
                );
              }
              const only = c.url || c.verifyUrl;
              return only ? (
                <a key={i} className="credentials-strip__item" title={c.label}
                   href={only} target="_blank" rel="noopener noreferrer">
                  {img}
                </a>
              ) : (
                <span key={i} className="credentials-strip__item" title={c.label}>
                  {img}
                </span>
              );
            })}
          </div>
        </Reveal>
      </div>
    </section>
  );
};

const PageHome = ({ setRoute }) => {
  const { t } = useLang();
  const H = t.home;
  return (
    <>
      <HeroCarousel setRoute={setRoute} />
      <VerticalsHero setRoute={setRoute} />

      {/* Client trust strip — desaturated logos, marquee */}
      <ClientStrip />

      {/* Vision memo — photo full-bleed on the left, everything else stacked
          on the right (pullquote + stats + rule + foot). No more container
          constraint on the photo column. */}
      <section className="section--memo">
        <div className="memo">
          <Reveal className="memo__photo">
            <img src="assets/hero_santander_noche.jpg" alt="" loading="lazy" />
            {H.memo.photoCredit && (
              <span className="memo__credit">{H.memo.photoCredit}</span>
            )}
          </Reveal>
          <div className="memo__col">
            <Reveal className="memo__l">
              {/* Las llaves marcan la cifra que se resalta: {1961}. El split
                  con grupo de captura alterna texto y número, así que el
                  realce sale como elemento React y el texto del CMS nunca
                  se interpreta como HTML. */}
              <span className="pullquote">
                {String(H.memo.pullquote ?? "").split(/\{(\d+)\}/).map((part, i) =>
                  i % 2 ? <span key={i} className="accent">{part}</span> : part
                )}
              </span>
            </Reveal>
            <div className="memo__r">
              {H.stats.map((s,i) => (
                <Reveal key={i} delay={i * 100}>
                  <Stat {...s} />
                </Reveal>
              ))}
            </div>
          </div>
        </div>
      </section>

      {t.home && t.home.credentialsOnHome !== false && <CredentialsStrip />}

      {/* Insights — first 3 entries of the unified news source */}
      <section className="section section--alt">
        <div className="container">
          <Reveal className="section__head">
            <h2 className="section__title">{H.insightsTitle}</h2>
          </Reveal>
          <div className="grid-3">
            {t.news.slice(0, 3).map((a,i) => (
              <Reveal key={i} delay={i * 100}>
                <ArticleCard a={a} onClick={() => setRoute({route:"article", articleId:a.id})} />
              </Reveal>
            ))}
          </div>
          <Reveal delay={400} className="grid-3__cta">
            <ArrowCTA onClick={() => setRoute("newsletter")}>{H.viewAllNews}</ArrowCTA>
          </Reveal>
        </div>
      </section>

      <SocialStrip />
    </>
  );
};

const PageQuienes = () => {
  const { t } = useLang();
  const Q = t.quienes;
  return (
    <>
      {/* Banner hero — same single-photo treatment as Personas (ken-burns
          short banner, title bottom-left, scroll-down hint pointing at
          the editorial section below). Uses the existing Santander
          night shot. Headline tightened from the long parallax slug to
          the page label (matches Proyectos / Personas convention of
          one short title in the banner; long copy lives in the section
          immediately below). */}
      <PersonasHero
        heroImg={Q.heroImg || "assets/hero_santander_noche.jpg"}
        title={Q.eyebrow}
        scrollHint={Q.scrollHint}
        /* Anclada arriba: la foto son dos torres vistas desde abajo y lo
           que no puede perderse al recortar es la coronación. Con
           "center" desaparecía en pantallas estrechas. */
        objectPosition="center top" />

      <section className="section section--light">
        <div className="container editorial">
          <SectionLabel>{Q.sectionLabel}</SectionLabel>
          <div className="editorial__body">
            {/* The long tagline previously lived in the parallax banner
                ("Más de seis décadas..."). With the banner moved to the
                Personas-style format, this headline takes its proper
                place as the opening h2 of the editorial section so the
                copy still leads with the firm's positioning line. */}
            <Reveal>
              <h2 className="section__title" style={{marginBottom: "var(--sp-5)"}}>{Q.heroTitle}</h2>
            </Reveal>
            {Q.body.map((html, i) => (
              <Reveal key={i} delay={(i + 1) * 80}>
                <RichText as="p" className="body-lg" html={html} />
              </Reveal>
            ))}
          </div>
        </div>
      </section>

      {/* Historia — timeline 1961 → hoy. Cada hito puede llevar imagen de
          archivo (assets/history/) con caption en mono. */}
      {Q.history && (
        <section className="section section--alt">
          <div className="container">
            <Reveal className="section__head">
              <Eyebrow>{Q.historyEyebrow}</Eyebrow>
              <h2 className="section__title">{Q.historyTitle}</h2>
            </Reveal>
            <div className="timeline">
              {Q.history.map((h, i) => (
                <Reveal key={i} delay={i * 60} className="tl-item">
                  <div className="tl-item__year">{h.year}</div>
                  <div className="tl-item__body">
                    <h3 className="tl-item__t">{h.t}</h3>
                    {/* El texto del hito admite un párrafo o varios. Hizo
                        falta al absorber la sección del fundador dentro del
                        hito de 1961: su historia no cabía en una sola frase
                        y partirla en dos se lee mejor. Los hitos antiguos
                        siguen siendo una cadena y se pintan igual. */}
                    {(Array.isArray(h.p) ? h.p : [h.p]).filter(Boolean).map((par, k) => (
                      <p key={k}>{par}</p>
                    ))}
                    {h.img && (
                      <figure className="tl-item__fig">
                        <img src={h.img} alt={h.cap || h.t} loading="lazy" />
                        {h.cap && <figcaption>{h.cap}</figcaption>}
                      </figure>
                    )}
                  </div>
                </Reveal>
              ))}
            </div>
          </div>
        </section>
      )}

      {/* Valores — anclados en hechos de la historia; cierran con los códigos. */}
      {Q.values && (
        <section className="section section--light">
          <div className="container">
            <Reveal className="section__head"><Eyebrow>{Q.valuesEyebrow}</Eyebrow></Reveal>
            <div className="values-grid">
              {Q.values.map((v, i) => (
                /* El titular es opcional. El cliente entregó los valores
                   como cinco frases sueltas, sin nombre, así que un
                   <h3> vacío dejaría un hueco en cada tarjeta. */
                <Reveal key={i} delay={i * 80} className={"value" + (v.t ? "" : " value--untitled")}>
                  {v.t && <h3 className="value__t">{v.t}</h3>}
                  <p>{v.p}</p>
                </Reveal>
              ))}
            </div>
          </div>
        </section>
      )}

      {/* Códigos — the ethics / professional codes are published as PDF
          documents. Each column: section eyebrow, a short editable
          description, and an outline download button in the site's btn
          vocabulary. A column only renders when its URL is present. */}
      {(Q.ethicalCodeUrl || Q.professionalCodeUrl) && (
        <section className="section section--alt">
          {/* Título de sección, opcional y editable. "Quiénes somos" arriba,
              "cómo lo hacemos" aquí: los dos códigos son la respuesta. */}
          {Q.codesTitle && (
            <div className="container" style={{marginBottom:"var(--sp-7)"}}>
              <Reveal><h2 className="section__title">{Q.codesTitle}</h2></Reveal>
            </div>
          )}
          <div className="container codes">
            {Q.ethicalCodeUrl && (
              <Reveal>
                <Eyebrow>{Q.codeEthicEyebrow}</Eyebrow>
                {Q.codeEthicDesc && (
                  <p className="body-lg" style={{marginTop:14,maxWidth:420,color:"var(--fg-mid)"}}>{Q.codeEthicDesc}</p>
                )}
                <div style={{marginTop:26}}>
                  <a className="btn btn-outline-dark" href={Q.ethicalCodeUrl}
                     target="_blank" rel="noopener noreferrer">
                    <span>{Q.codePdfLabel || "Descargar (PDF)"}</span><span className="arr">↓</span>
                  </a>
                </div>
              </Reveal>
            )}
            {Q.professionalCodeUrl && (
              <Reveal delay={120}>
                <Eyebrow>{Q.codeProfEyebrow}</Eyebrow>
                {Q.codeProfDesc && (
                  <p className="body-lg" style={{marginTop:14,maxWidth:420,color:"var(--fg-mid)"}}>{Q.codeProfDesc}</p>
                )}
                <div style={{marginTop:26}}>
                  <a className="btn btn-outline-dark" href={Q.professionalCodeUrl}
                     target="_blank" rel="noopener noreferrer">
                    <span>{Q.codePdfLabel || "Descargar (PDF)"}</span><span className="arr">↓</span>
                  </a>
                </div>
              </Reveal>
            )}
          </div>
        </section>
      )}

      {/* Same certifications strip as the home page — shown here when
          the CMS toggle (Inicio · Certificados) turns it on. */}
      {t.home && t.home.credentialsOnQuienes && <CredentialsStrip />}

    </>
  );
};

/* Per-sector hover-preview clip. Each tile mounts its <video> paused on the
   poster image; mouseenter rewinds to frame 0 and starts playback, mouseleave
   pauses + rewinds. The clips are silent (no audio track) so play() never
   blocks on autoplay policy. */
const TILE_VIDEO_BY_ID = {
  "data-centers":          "assets/videos/proj_tile_dc.mp4",
  "industria-farmaceutica":             "assets/videos/proj_tile_farma.mp4",
  "industria-avanzada":  "assets/videos/proj_tile_fab.mp4",
  "instalaciones-singulares":  "assets/videos/proj_tile_edif.mp4",
};
/* Per-tile poster = first frame of the tile's preview clip (extracted via
   ffmpeg). Using the actual frame instead of the sector's legacy hero image
   means the static poster and the playing video share the same scene/style,
   so the hover transition feels continuous instead of swapping to a totally
   different photograph. Falls back to sector.img if no poster exists. */
const TILE_POSTER_BY_ID = {
  "data-centers":          "assets/videos/proj_tile_dc.jpg",
  "industria-farmaceutica":             "assets/videos/proj_tile_farma.jpg",
  "industria-avanzada":  "assets/videos/proj_tile_fab.jpg",
  "instalaciones-singulares":  "assets/videos/proj_tile_edif.jpg",
};

const SectorTile = ({ sector, setRoute, isWide }) => {
  const videoRef = React.useRef(null);
  const src    = TILE_VIDEO_BY_ID[sector.id];
  const poster = TILE_POSTER_BY_ID[sector.id] || sector.img;
  /* `<video poster>` doesn't render reliably across browsers when preload=
     "metadata" — Chrome and Safari often paint a black box until the first
     frame is decoded, which means the user briefly sees nothing on tiles
     they haven't hovered yet. We render an <img> underneath as the always-
     visible base and fade the <video> in on top only while it's playing.
     On mouseleave the video fades back out, returning the poster img — so
     the tile is never empty regardless of preload state. */
  const [playing, setPlaying] = React.useState(false);
  const playFromStart = () => {
    const v = videoRef.current;
    if (!v) return;
    v.currentTime = 0;
    setPlaying(true);
    const p = v.play();
    if (p && typeof p.catch === "function") p.catch(() => setPlaying(false));
  };
  const rewindPause = () => {
    const v = videoRef.current;
    if (!v) return;
    v.pause();
    try { v.currentTime = 0; } catch (_) { /* iOS quirk: ignore */ }
    setPlaying(false);
  };
  return (
    <a className={"sector-stage__tile"
                  + (isWide ? " sector-stage__tile--wide" : "")
                  + (playing ? " is-playing" : "")}
       onClick={() => setRoute({route:"sector", sectorId: sector.id})}
       onMouseEnter={playFromStart}
       onMouseLeave={rewindPause}
       onFocus={playFromStart}
       onBlur={rewindPause}
       tabIndex={0}
       aria-label={sector.label}>
      <img className="sector-stage__poster" src={poster} alt="" loading="lazy" />
      {src && (
        <video ref={videoRef}
               className="sector-stage__video"
               src={src}
               muted playsInline loop preload="metadata"
               aria-hidden="true" />
      )}
      <div className="sector-stage__overlay" />
      <div className="sector-stage__label">{sector.label}</div>
    </a>
  );
};

/* ProyectosHero — 100vh image carousel at the top of /proyectos.
 *
 * Four photos cycle every 6s (auto), the page title "Projects" sits as a
 * constant minimalist overlay (no per-slide labels — the verticals are
 * named further down by the sector tiles, so the hero stays purely
 * atmospheric). A circular dot strip shows position 1/4, 2/4, etc., with a
 * thin progress ring driven by a CSS keyframe over the slide duration
 * (videos drive their own duration in the home carousel — here it's a
 * fixed 6s per slide). A small "↓" + label at the bottom-centre hints
 * that the sector grid lives below, so users know to scroll. */
const ProyectosHero = () => {
  const { t } = useLang();
  const slides = (t.proyectos && t.proyectos.heroSlides) || [];
  const [i, setI] = React.useState(0);
  // Autoplay — self-rescheduling 6s setTimeout. Each slide change cancels
  // any pending advance and schedules a fresh 6s timer, so manual dot
  // clicks restart the cycle from now.
  React.useEffect(() => {
    if (slides.length <= 1) return;
    const tm = setTimeout(() => setI(x => (x + 1) % slides.length), 6000);
    return () => clearTimeout(tm);
  }, [i, slides.length]);
  // Scroll hint click → smooth-scroll to the sector grid below.
  const scrollToSectors = () => {
    const el = document.querySelector(".sector-stage");
    if (el && typeof el.scrollIntoView === "function") {
      el.scrollIntoView({behavior: "smooth", block: "start"});
    }
  };
  return (
    <section className="proj-hero" aria-label={t.proyectos.title}>
      {slides.map((s, idx) => (
        <div key={idx} className={"proj-hero__slide " + (idx === i ? "is-active" : "")}>
          <img className="proj-hero__img"
               src={s.img}
               alt=""
               loading={idx === 0 ? "eager" : "lazy"}
               style={s.objectPosition ? {objectPosition: s.objectPosition} : null} />
        </div>
      ))}
      <div className="proj-hero__overlay" />
      <div className="container proj-hero__inner">
        <h1 className="proj-hero__title">{t.proyectos.title}</h1>
      </div>
      {/* Circular dot strip — same vocabulary as the Data Centers stage hero.
          The active dot's ring SVG only renders for the active dot; on
          slide change the SVG unmounts/remounts and the CSS keyframe
          restarts from 0, so the ring perfectly tracks the autoplay timer. */}
      {slides.length > 1 && (
        <div className="proj-hero__dots" role="tablist" aria-label="Slides">
          {slides.map((_, idx) => {
            const isActive = idx === i;
            return (
              <button key={idx}
                      type="button"
                      role="tab"
                      aria-selected={isActive}
                      className={"proj-hero__dot " + (isActive ? "is-on" : "")}
                      onClick={() => setI(idx)}
                      aria-label={"Slide " + (idx + 1)}>
                <span className="proj-hero__dot-core" />
              </button>
            );
          })}
        </div>
      )}
      {/* Scroll-down hint — sits below the dots, points at the sector tiles
          which live in the section directly underneath. Clickable: smooth-
          scrolls to .sector-stage so users can jump without scrolling
          manually. */}
      <button type="button" className="proj-hero__scroll" onClick={scrollToSectors} aria-label={t.proyectos.scrollHint}>
        <span className="proj-hero__scroll-label">{t.proyectos.scrollHint}</span>
        <span className="proj-hero__scroll-arrow" aria-hidden="true">↓</span>
      </button>
      {/* Architect credit for the active slide — bottom-right at the
          same vertical level as the scroll hint. Renders only when the
          slide defines `architect` (slide 4 = Sede Santander · Ayala). */}
      {slides[i] && slides[i].architect && (
        <span className="proj-hero__credit" aria-label={slides[i].architect}>
          {slides[i].architect}
        </span>
      )}
    </section>
  );
};

const PageProyectos = ({ setRoute }) => {
  const { t } = useLang();
  return (
    <>
      {/* 1. Hero carousel — 4 photo slides + minimalist "Projects" overlay +
            circular dot ring + scroll-down hint pointing to the sectors. */}
      <ProyectosHero />

      {/* 2. Sector stage — F+P-style editorial grid on a deep navy band. The
            title now lives in the hero above, so this section opens directly
            with the tile grid (no duplicate "Projects" heading). */}
      <section className="sector-stage">
        <div className="container">
          {/* Chevron pattern in a 3-col grid — wide tiles (span 2 cols) alternate
              with narrow tiles (1 col) to create an asymmetric F+P-style
              rhythm. Idx 0,3,4 are wide; 1,2,5 are narrow. Each row sums to
              3 cols. Wide and narrow tiles share the same row height (their
              aspect-ratios are tuned so the height stays constant). */}
          <div className="sector-stage__grid">
            {(t.sectors || []).map((s, i) => {
              const isWide = i === 0 || i === 3 || i === 4;
              return (
                <SectorTile key={s.id}
                            sector={s}
                            setRoute={setRoute}
                            isWide={isWide}
                            delay={(i % 3) * 80} />
              );
            })}
          </div>
        </div>
      </section>

      {/* Client trust strip closes the page */}
      <ClientStrip />
    </>
  );
};

/* PageSector — sector landing page. Reached via setRoute({route:"sector", sectorId}).
 *
 * Layout (adopted from the Aguilera Ingenieros Design System bundle, Claude
 * Design handoff):
 *   1. SectorHero — split panel. Left: eyebrow, h1, sub, 4-stat strip.
 *      Right: framed media panel with engineering-grid background and corner
 *      brackets. Holds the isometric line-drawing (cpd / farma / edif) or a
 *      photo (fab / hos / sos) — the panel is reserved as a future video slot.
 *   2. "Qué diseñamos" — 4 numbered discipline cards on a 2-col bordered grid.
 *   3. "Capacidades técnicas" — dark section with a numbered spec list.
 *   4. "Certificaciones y estándares" — 4-cell bordered grid.
 *   5. CTA accent band → contacto.
 */
/* SectorHeroPanel — the framed media panel on the right of every sector hero.
 *
 * Three flavours, picked in priority order:
 *   1. video   (sector.video present) — full-bleed clip inside the frame. Two
 *              shapes are supported:
 *                video: { src, poster?, cap? }                  → single loop
 *                video: { clips:[{src,cap?}, …], poster? }      → auto-cycling reel
 *              In the reel shape, each clip plays end-to-end and the panel
 *              advances on the `ended` event — so the rotation matches each
 *              clip's natural duration without us hardcoding a timer.
 *   2. drawing (sector.building present) — isometric line-drawing on the
 *              engineered grid background; the panel reads as a technical
 *              elevation.
 *   3. photo   (fallback) — sector.img dropped in full-bleed with a dimming
 *              overlay so the caption strip stays legible.
 *
 * The chrome (frame, corners, caption strip, video CTA) is identical across
 * all three flavours so the visual language of every sector page stays in sync. */
const SectorHeroPanel = ({ sector, common }) => {
  const useVideo    = !!sector.video;
  const useBuilding = !useVideo && !!sector.building;
  const usePhoto    = !useVideo && !useBuilding;

  const v = sector.video;
  const b = sector.building;
  // Normalise both video shapes into a uniform `clips[]` array of length ≥1.
  // If `sector.video.src` is present we treat it as a single-clip reel; the
  // playlist logic below loops automatically once it advances back to index 0.
  const clips = useVideo
    ? (Array.isArray(v.clips) && v.clips.length > 0
        ? v.clips
        : [{ src: v.src, cap: v.cap }])
    : [];
  const isReel = useVideo && clips.length > 1;
  const [clipIdx, setClipIdx] = React.useState(0);
  const videoRef = React.useRef(null);

  // On clip change, kick the video to (re)load the new src and play. The
  // browser won't autoplay across src swaps without this on Safari/iOS.
  React.useEffect(() => {
    if (!useVideo) return;
    const el = videoRef.current;
    if (!el) return;
    // The <video> src is bound declaratively below so React will swap it for
    // us; we just need to nudge the element to load and play afterwards.
    el.load();
    const p = el.play();
    if (p && typeof p.catch === "function") p.catch(() => {});
  }, [clipIdx, useVideo]);

  const onEnded = () => {
    if (!isReel) return;
    setClipIdx((i) => (i + 1) % clips.length);
  };

  const currentClip = clips[clipIdx] || {};
  const photoStyle = usePhoto ? { backgroundImage: `url(${sector.img})` } : null;
  const variant    = useVideo ? "shero__media--video"
                    : useBuilding ? "shero__media--drawing"
                    : "shero__media--photo";
  const cap = useVideo ? (currentClip.cap || sector.label.toUpperCase())
            : useBuilding ? b.cap
            : sector.label.toUpperCase();

  return (
    <Reveal delay={160} className="shero__right">
      <div className={"shero__media " + variant}
           data-video-slot={sector.cat}
           style={photoStyle}>
        {useVideo && (
          /* muted + playsInline + autoPlay is the standard contract for a
             silent loop browsers won't block. `loop` only applies in the
             single-clip case; for a reel we let `ended` fire so we can advance
             the index. `poster` paints instantly so the panel isn't blank
             while the next clip buffers. */
          <video ref={videoRef}
                 className="shero__video"
                 src={currentClip.src}
                 autoPlay
                 muted
                 playsInline
                 preload="auto"
                 loop={!isReel}
                 onEnded={onEnded}
                 aria-hidden="true" />
        )}
        {useBuilding && <div className="shero__grid-bg" />}
        {useBuilding && (
          <img className="shero__building"
               src={b.src} alt=""
               style={{aspectRatio: String(b.ratio)}} />
        )}
        {/* Tick row — one notch per clip in a reel; the active clip's notch
            is bright. Sits inside the panel near the bottom so it reads as
            "you are on shot N of M" without dominating the frame. */}
        {isReel && (
          <div className="shero__reel-ticks" aria-hidden="true">
            {clips.map((_, i) => (
              <span key={i} className={"shero__reel-tick " + (i === clipIdx ? "is-on" : "")} />
            ))}
          </div>
        )}
        <div className="shero__media-foot">
          <span className="shero__media-cap">{cap}</span>
          <span className="shero__media-video">
            <span className="shero__play">
              <svg width="11" height="13" viewBox="0 0 11 13" fill="currentColor"><path d="M0 0l11 6.5L0 13z"/></svg>
            </span>
            {common.videoLabel}
          </span>
        </div>
        <div className="shero__corner shero__corner--tl" />
        <div className="shero__corner shero__corner--tr" />
        <div className="shero__corner shero__corner--bl" />
        <div className="shero__corner shero__corner--br" />
      </div>
    </Reveal>
  );
};

/* SectorStageHero — full-bleed 100vh video hero in the Foster + Partners
 * cadence. Used when sector.heroVariant === "stage" (Data Centers).
 *
 * Layout:
 *   - Background <video> at object-fit:cover, autoplaying through sector.video.clips
 *   - Top-to-bottom darkening gradient for legibility under the nav + over the copy
 *   - Bottom-left copy column: Eyebrow + h1 + sub
 *   - Bottom-centre dot strip: one small filled dot per clip; the active dot is
 *     surrounded by a thin SVG ring whose stroke-dashoffset fills clockwise as
 *     the clip plays. The fill is driven by requestAnimationFrame mutating the
 *     circle's style.strokeDashoffset directly — no React re-render per frame.
 *
 * Clicking a dot jumps to that clip; the rAF loop resets and the new clip's
 * ring starts from empty. The `ended` event advances index automatically. */
const SectorStageHero = ({ sector, common, setRoute }) => {
  const v = sector.video || {};
  /* El CMS ofrece un interruptor "Vídeos activados" y, debajo, un
     carrusel de hasta seis fotos. Prometía que al apagarlo la cabecera
     pasaba a las fotos — y no lo hacía: el interruptor no lo leía nadie
     y las fotos no se pintaban en ninguna parte. Aquí se cumple la
     promesa. La estructura es la misma en los dos casos (una lista de
     tomas con su pie), así que los puntos, el anillo de progreso y el
     crédito del arquitecto funcionan igual con fotos que con vídeo. */
  const photoMode = sector.videosEnabled === false
    && Array.isArray(sector.gallery) && sector.gallery.length > 0;
  const PHOTO_MS = 5000;                 // lo que dura cada foto en pantalla
  const clips = photoMode
    ? sector.gallery.map((g) => ({ src: g.img, cap: g.cap }))
    : (Array.isArray(v.clips) && v.clips.length > 0
        ? v.clips
        : (v.src ? [{ src: v.src, cap: v.cap }] : []));
  const [clipIdx, setClipIdx] = React.useState(0);
  const videoRef = React.useRef(null);
  const ringRef  = React.useRef(null);
  const stageRef = React.useRef(null);
  /* scrollPastHero — clicking the heroHint smooth-scrolls past the
     full-bleed stage into the sector's first content section (the
     section directly after .shero--stage in the DOM). Mirrors the
     "Ver sectores" pattern on the Proyectos hero. */
  const scrollPastHero = () => {
    const next = stageRef.current && stageRef.current.nextElementSibling;
    if (next && typeof next.scrollIntoView === "function") {
      next.scrollIntoView({behavior: "smooth", block: "start"});
    }
  };
  const RADIUS = 9;
  const RING_C = 2 * Math.PI * RADIUS;   // ~56.55 — full-circle stroke length

  // Load + play the current clip when clipIdx changes, and drive the progress
  // ring via rAF. Mutating the SVG circle's style directly avoids 60 React
  // re-renders per second; the only state change is on `ended` (clip swap).
  /* Con fotos no hay evento `ended`, así que el avance y el anillo los
     lleva un temporizador. Se respeta prefers-reduced-motion: quien pide
     menos movimiento se queda en la primera foto. */
  React.useEffect(() => {
    if (!photoMode || clips.length <= 1) return;
    const quieto = window.matchMedia
      && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
    if (quieto) return;
    const t0 = Date.now();
    let raf;
    const tick = () => {
      const r = ringRef.current;
      if (r) {
        const pct = Math.min(1, (Date.now() - t0) / PHOTO_MS);
        r.style.strokeDashoffset = String(RING_C * (1 - pct));
      }
      raf = requestAnimationFrame(tick);
    };
    raf = requestAnimationFrame(tick);
    const id = setTimeout(() => setClipIdx((i) => (i + 1) % clips.length), PHOTO_MS);
    return () => { cancelAnimationFrame(raf); clearTimeout(id); };
  }, [photoMode, clipIdx, clips.length, RING_C]);

  React.useEffect(() => {
    if (photoMode) return;
    const vid = videoRef.current;
    if (!vid) return;
    vid.load();
    const p = vid.play();
    if (p && typeof p.catch === "function") p.catch(() => {});
    // Reset ring to empty at the start of the new clip.
    if (ringRef.current) ringRef.current.style.strokeDashoffset = String(RING_C);
    let raf;
    const tick = () => {
      const v2 = videoRef.current;
      const r  = ringRef.current;
      if (v2 && r && v2.duration > 0) {
        const pct = Math.min(1, Math.max(0, v2.currentTime / v2.duration));
        r.style.strokeDashoffset = String(RING_C * (1 - pct));
      }
      raf = requestAnimationFrame(tick);
    };
    raf = requestAnimationFrame(tick);
    return () => cancelAnimationFrame(raf);
  }, [photoMode, clipIdx, RING_C]);

  const onEnded = () => {
    if (clips.length <= 1) return;
    setClipIdx((i) => (i + 1) % clips.length);
  };

  const currentClip = clips[clipIdx] || {};
  return (
    <section ref={stageRef} className="shero shero--stage" aria-label={sector.label}>
      {/* Per-clip objectPosition override — same pattern as the
          ProyectosHero photo slides. When a clip's source isn't
          centred for the wide stage container (e.g. a tall portrait
          tower shot that loses its crown to a default center crop),
          the data record sets objectPosition: "center top" and the
          inline style flips object-fit's anchor for that clip. */}
      {photoMode ? (
        <img className="shero__bg-video"
             src={currentClip.src}
             alt=""
             style={currentClip.objectPosition ? {objectPosition: currentClip.objectPosition} : undefined}
             aria-hidden="true" />
      ) : (
        <video ref={videoRef}
               className="shero__bg-video"
               src={currentClip.src}
               autoPlay
               muted
               playsInline
               preload="auto"
               loop={clips.length <= 1}
               onEnded={onEnded}
               style={currentClip.objectPosition ? {objectPosition: currentClip.objectPosition} : undefined}
               aria-hidden="true" />
      )}
      {/* Top-to-bottom darkening pass: heavier at top (so the nav + corner brackets
          read) and at the bottom (so the copy stays legible regardless of footage). */}
      <div className="shero__bg-overlay" />

      <div className="container shero__stage-inner">
        <div className="shero__stage-content">
          {/* Stage hero copy stays tight on purpose — just an eyebrow and a
              short headline. Sector descriptions live below the hero, not on
              top of the footage. The `sub` field on the sector is preserved
              for the framed hero variants (other sectors) but not rendered
              here, so we can shorten without touching the schema. */}
          <Reveal><Eyebrow onDark>{sector.eyebrow || sector.label}</Eyebrow></Reveal>
          <Reveal delay={90}><h1 className="shero__stage-h1">{sector.h1 || sector.label}</h1></Reveal>
        </div>
      </div>

      {/* Dot strip — one per clip in the reel. The active dot wears the
          progress-ring SVG; the others are static filled dots. */}
      {clips.length > 1 && (
        <div className="shero__stage-dots" role="tablist" aria-label="Reel clips">
          {clips.map((_, i) => {
            const isActive = i === clipIdx;
            return (
              <button key={i}
                      type="button"
                      role="tab"
                      aria-selected={isActive}
                      className={"shero__stage-dot " + (isActive ? "is-on" : "")}
                      onClick={() => setClipIdx(i)}
                      aria-label={"Clip " + (i + 1) + " de " + clips.length}>
                <span className="shero__stage-dot-core" />
                {isActive && (
                  <svg className="shero__stage-dot-ring" width="28" height="28" viewBox="0 0 28 28" aria-hidden="true">
                    <circle ref={ringRef}
                            cx="14" cy="14" r={RADIUS}
                            fill="none"
                            stroke="#FFFFFF"
                            strokeWidth="1.5"
                            strokeLinecap="round"
                            strokeDasharray={RING_C}
                            strokeDashoffset={RING_C}
                            style={{transform: "rotate(-90deg)", transformOrigin: "50% 50%"}} />
                  </svg>
                )}
              </button>
            );
          })}
        </div>
      )}

      {/* Tiny "Discover our …" hint under the dots — only renders when
          the sector defines heroHint. Mirrors the Proyectos "Ver
          sectores" pattern but smaller and quieter; click smooth-
          scrolls past the stage into the content below. */}
      {sector.heroHint && (
        <button type="button"
                className="shero__stage-hint"
                onClick={scrollPastHero}
                aria-label={sector.heroHint}>
          <span className="shero__stage-hint-label">{sector.heroHint}</span>
          <span className="shero__stage-hint-arrow" aria-hidden="true">↓</span>
        </button>
      )}

      {/* Architect credit — bottom-right corner at the same vertical
          level as the heroHint. Only renders when the active clip
          carries an `architect` field. Same font/size/colour as the
          hint label so the two read as a matched pair across the
          bottom of the stage. */}
      {currentClip.architect && (
        <span className="shero__stage-credit" aria-label={currentClip.architect}>
          {currentClip.architect}
        </span>
      )}
    </section>
  );
};

/* SectorDeckHero — three-card deck layout for sectors with
 * heroVariant: "deck". Three square clips are visible at once: the active
 * one sits centred (full size + full brightness), flanked left and right by
 * the previous and next clips (scaled down + dimmed). Clicking a dot or
 * letting autoplay advance shifts the whole row — visually the centred
 * clip slides away and the next one rotates into the forefront.
 *
 * Implementation: instead of mounting N videos at a time, we always render
 * exactly 3 video elements (one per slot). Each slot's clip index is
 * computed as (activeIdx + offset + N) % N where offset ∈ {-1, 0, +1}.
 * Updating activeIdx re-keys each <video> with its new src; the React
 * effect below kicks load() + play() on the active one so the centre
 * clip is always actually playing (sides are paused on frame 0). */
const SectorDeckHero = ({ sector, common, setRoute }) => {
  const v = sector.video || {};
  const clips = Array.isArray(v.clips) && v.clips.length > 0
    ? v.clips
    : (v.src ? [{ src: v.src, cap: v.cap }] : []);
  const [activeIdx, setActiveIdx] = React.useState(0);
  /* Every clip mounts its own <video>; we keep refs so we can play/pause
     each one individually. When activeIdx changes, the CSS variable
     `--offset` on each card transitions — the boxes themselves slide to
     their new positions instead of the videos swapping in fixed slots. */
  const videoRefs = React.useRef([]);

  React.useEffect(() => {
    videoRefs.current.forEach((vid, idx) => {
      if (!vid) return;
      if (idx === activeIdx) {
        vid.currentTime = 0;
        const p = vid.play();
        if (p && typeof p.catch === "function") p.catch(() => {});
      } else {
        vid.pause();
      }
    });
  }, [activeIdx]);

  const advance = () => {
    if (clips.length <= 1) return;
    setActiveIdx(i => (i + 1) % clips.length);
  };

  /* wrapOffset turns the raw index delta (i - activeIdx) into the
     shortest-path delta around a ring of N cards. Without this, the
     card at the far end of the list sits at offset N-1 forever; with
     it, that card crosses over to offset -1 once activeIdx gets close,
     so the deck loops both ways with no dead edges. */
  const N = clips.length;
  const wrapOffset = (raw) => {
    if (N === 0) return 0;
    let o = raw;
    if (o >  N / 2) o -= N;
    if (o <= -N / 2) o += N;
    return o;
  };

  /* With N=3 every step forces one card to teleport from offset -1 to
     +1 (or back) — otherwise CSS would slide it straight through the
     active card. We detect that case per render and tag the warping
     card with `is-warp`; the CSS rule kills its transition for that
     one frame, so it snaps invisibly to the far side instead of
     crossing the centre. The ref tracks previous-frame offsets so
     subsequent renders can compare and only flag the cards that
     actually jumped > 1 slot. */
  const offsets = clips.map((_, i) => wrapOffset(i - activeIdx));
  const prevOffsetsRef = React.useRef([]);
  const warpFlags = offsets.map((o, i) => {
    const p = prevOffsetsRef.current[i];
    return p !== undefined && Math.abs(o - p) > 1;
  });
  React.useEffect(() => { prevOffsetsRef.current = offsets; });

  return (
    <section className="shero shero--deck" aria-label={sector.label}>
      <div className="shero-deck__inner">
        {/* Centred deck title — prefers the sector's heroTitle (longer
            invitational copy like "Descubre nuestros hospitales") and
            falls back to the plain label. Both are localized via the
            sector record itself, so ES/EN switch happens for free. */}
        <h1 className="shero-deck__title">{sector.heroTitle || sector.label}</h1>
        {/* Stage holds all cards absolutely positioned; their --offset
            CSS variable drives the transform. React just updates the
            offsets on activeIdx change — CSS transitions handle the slide. */}
        <div className="shero-deck__stage">
          {clips.map((clip, i) => {
            const offset = offsets[i];
            const role = offset === 0 ? "is-active"
                       : offset === -1 ? "is-prev"
                       : offset === 1  ? "is-next"
                       : "is-far";
            const warpClass = warpFlags[i] ? " is-warp" : "";
            return (
              <button key={i}
                      type="button"
                      className={"shero-deck__card " + role + warpClass}
                      style={{"--offset": offset}}
                      onClick={offset === 0 ? undefined : () => setActiveIdx(i)}
                      aria-label={"Clip " + (i + 1)}
                      aria-current={offset === 0 ? "true" : undefined}
                      tabIndex={offset === 0 ? -1 : 0}>
                {/* No `loop` — onEnded must fire to advance to the next
                    clip. The effect on activeIdx pauses the outgoing
                    video and resets the incoming one to t=0. */}
                <video ref={el => { videoRefs.current[i] = el; }}
                       src={clip.src}
                       muted playsInline preload="metadata"
                       onEnded={offset === 0 ? advance : undefined}
                       aria-hidden="true" />
              </button>
            );
          })}
        </div>

        {clips.length > 1 && (
          <DeckDots clips={clips}
                    activeIdx={activeIdx}
                    onPick={setActiveIdx}
                    activeVideoRef={() => videoRefs.current[activeIdx]} />
        )}
      </div>
    </section>
  );
};

/* DeckDots — one circular dot per clip. The active dot wears a progress
 * ring whose stroke-dashoffset is mutated each frame from the active
 * video's currentTime/duration ratio (rAF). Re-mounts the ring SVG on
 * activeIdx change so the fill restarts cleanly. */
const DeckDots = ({ clips, activeIdx, onPick, activeVideoRef }) => {
  const ringRef = React.useRef(null);
  const RING_R = 9;
  const RING_C = 2 * Math.PI * RING_R;
  React.useEffect(() => {
    if (ringRef.current) ringRef.current.style.strokeDashoffset = String(RING_C);
    let raf;
    const tick = () => {
      const v = activeVideoRef && activeVideoRef();
      const r = ringRef.current;
      if (v && r && v.duration > 0) {
        const pct = Math.min(1, Math.max(0, v.currentTime / v.duration));
        r.style.strokeDashoffset = String(RING_C * (1 - pct));
      }
      raf = requestAnimationFrame(tick);
    };
    raf = requestAnimationFrame(tick);
    return () => cancelAnimationFrame(raf);
  }, [activeIdx, RING_C, activeVideoRef]);
  return (
    <div className="shero-deck__dots" role="tablist">
      {clips.map((_, i) => {
        const isActive = i === activeIdx;
        return (
          <button key={i}
                  type="button"
                  role="tab"
                  aria-selected={isActive}
                  className={"shero-deck__dot " + (isActive ? "is-on" : "")}
                  onClick={() => onPick(i)}
                  aria-label={"Clip " + (i + 1)}>
            <span className="shero-deck__dot-core" />
            {isActive && (
              <svg className="shero-deck__dot-ring" width="28" height="28" viewBox="0 0 28 28" aria-hidden="true">
                <circle ref={ringRef}
                        cx="14" cy="14" r={RING_R}
                        fill="none" stroke="#FFFFFF" strokeWidth="1.5" strokeLinecap="round"
                        strokeDasharray={RING_C} strokeDashoffset={RING_C}
                        style={{transform:"rotate(-90deg)",transformOrigin:"50% 50%"}} />
              </svg>
            )}
          </button>
        );
      })}
    </div>
  );
};

const PageSector = ({ sectorId, setRoute }) => {
  const { t } = useLang();
  const sector = (t.sectors || []).find(s => s.id === sectorId);
  const common = t.sectorCommon || {};
  if (!sector) {
    return (
      <section className="section section--light">
        <div className="container">
          <p>—</p>
          <a className="back-link" onClick={() => setRoute("proyectos")}>← {t.proyectos.backToProyectos}</a>
        </div>
      </section>
    );
  }
  return (
    <>
      {/* ── 1. Hero ───────────────────────────────────────────────────
          Default: split panel (copy left, framed media right).
          heroVariant === "stage": full-bleed 100vh video stage with
          progress-ring dots (Foster + Partners cadence).
          heroVariant === "deck": 3-card deck rotation (centre clip in
          forefront, flanks scaled down + dimmed — used by Hospitales). */}
      {sector.heroVariant === "stage" ? (
        <SectorStageHero sector={sector} common={common} setRoute={setRoute} />
      ) : sector.heroVariant === "deck" ? (
        <SectorDeckHero sector={sector} common={common} setRoute={setRoute} />
      ) : (
        <section className="shero">
          <div className="shero__inner container">
            <div className="shero__left">
              <Reveal><Eyebrow onDark>{sector.eyebrow || t.proyectos.sectorsEyebrow}</Eyebrow></Reveal>
              <Reveal delay={90}><h1 className="shero__h1">{sector.h1 || sector.label}</h1></Reveal>
              <Reveal delay={180}><p className="shero__sub">{sector.sub || sector.intro}</p></Reveal>
              {sector.stats && sector.stats.length > 0 && (
                <Reveal delay={260}>
                  <div className="shero__stats">
                    {sector.stats.map((s, i) => (
                      <div key={i} className="shero__stat">
                        <div className="shero__stat-v">{s.v}</div>
                        <div className="shero__stat-k">{s.k}</div>
                      </div>
                    ))}
                  </div>
                </Reveal>
              )}
            </div>
            <SectorHeroPanel sector={sector} common={common} />
          </div>
        </section>
      )}


      {/* ── 2. Intro copy — ordered blocks straight under the video ───
            Same block vocabulary as a news article (lede / p / h2 / h3 /
            quote / image) rendered through the same ArticleBlock, so the
            four areas share one structure and an editor reorders or adds
            to them exactly the way they already do with news. */}
      {sector.body && sector.body.length > 0 && (
        <section className="section section--light sector-copy">
          <div className="container sector-body">
            {sector.body.map((block, i) => (
              /* Las imágenes entran con un barrido en vez de con el
                 desplazamiento genérico: la marca reveal--media apaga el
                 translate del padre y deja mandar al recorte. Cualquier
                 bloque de imagen que el editor añada desde el CMS lo
                 hereda sin tocar nada. */
              <Reveal key={i} delay={Math.min(i, 4) * 70}
                      className={block.type === "image"
                        ? ("reveal--media" + (block.wide ? " reveal--wide" : ""))
                        : undefined}>
                <ArticleBlock block={block} i={i} />
              </Reveal>
            ))}
          </div>
        </section>
      )}

      {/* ── 3. Galería ────────────────────────────────────────────────
            El CMS lleva tiempo ofreciendo este campo —hasta seis fotos
            con su pie— y el build ya lo copiaba, pero no lo pintaba
            nadie: el editor subía las fotos y no aparecían en ninguna
            parte. Aquí es donde salen. Si el área no tiene galería, la
            sección no existe. */}
      {sector.gallery && sector.gallery.length > 0 && (
        <section className="section section--light sector-gallery">
          <div className="container">
            <div className="sector-gallery__grid">
              {sector.gallery.map((shot, i) => (
                <Reveal key={i} delay={Math.min(i, 4) * 70}>
                  <figure className="sector-gallery__item">
                    <img src={shot.img} alt={shot.cap || ""} loading="lazy" />
                    {shot.cap && <figcaption>{shot.cap}</figcaption>}
                  </figure>
                </Reveal>
              ))}
            </div>
          </div>
        </section>
      )}

      {/* ── 3. CTA accent band ─────────────────────────────────────── */}
      <section className="section section--accent">
        <div className="container cta-band">
          <Reveal><h2 className="section__title section__title--light">{common.ctaTitle}</h2></Reveal>
          <Reveal delay={80}><p className="body-lg" style={{color:"rgba(255,255,255,.88)"}}>{common.ctaSub}</p></Reveal>
          {/* El botón ya no es un mailto directo: al pulsarlo enseña la
              dirección. Abrir Outlook sin avisar resultaba invasivo, y en
              el móvil a veces no se abría nada. Si desde ahí se pulsa la
              dirección, el asunto sigue viniendo relleno con el nombre
              del área para que el correo llegue ya clasificado. */}
          <Reveal delay={160}>
            <MailReveal outline dark
                        email={common.ctaEmail || t.contacto.email}
                        subject={"Proyecto — " + sector.label}
                        copiedLabel={t.contacto.iconAriaMail}>
              {common.ctaBtn}
            </MailReveal>
          </Reveal>
        </div>
      </section>
    </>
  );
};

/* PersonasHero — single-photo banner in the same vocabulary as ProyectosHero,
   but without the carousel dots (one image, the page title at bottom-left,
   and a scroll-down hint pointing at the first section below). Reuses the
   .proj-hero CSS so layout/typography/animation are exactly aligned.
   Generic by design: the scroll-down arrow finds the section's next
   sibling and scrolls there, so the hero works on any page that drops
   it in (Personas, Quiénes, etc.) without a hard-coded target selector.
   `objectPosition` lets callers nudge the framing (e.g. "center top" for
   portraits where the head should anchor at the top of the crop).
   `bottom` flips the title from bottom-left to top-left when set to
   "false" — left as default since both current consumers want it low. */
const PersonasHero = ({ heroImg, title, scrollHint, objectPosition }) => {
  const heroRef = React.useRef(null);
  const scrollDown = () => {
    const next = heroRef.current && heroRef.current.nextElementSibling;
    if (next && typeof next.scrollIntoView === "function") {
      next.scrollIntoView({behavior: "smooth", block: "start"});
    }
  };
  return (
    <section ref={heroRef} className="proj-hero proj-hero--short" aria-label={typeof title === "string" ? title : undefined}>
      <div className="proj-hero__slide is-active">
        <img className="proj-hero__img"
             src={heroImg}
             alt=""
             loading="eager"
             style={{objectPosition: objectPosition || "center top"}} />
      </div>
      <div className="proj-hero__overlay" />
      <div className="container proj-hero__inner">
        <h1 className="proj-hero__title">{title}</h1>
      </div>
      {scrollHint && (
        <button type="button" className="proj-hero__scroll" onClick={scrollDown} aria-label={scrollHint}>
          <span className="proj-hero__scroll-label">{scrollHint}</span>
          <span className="proj-hero__scroll-arrow" aria-hidden="true">↓</span>
        </button>
      )}
    </section>
  );
};

/* Cómo trabajamos y Más allá del proyecto viven en los datos de Talento y
   se pintan en DOS páginas: en Talento, donde nacieron, y bajo el equipo
   directivo de Equipo, que es donde el cliente pidió verlas ("sacado de
   talento"). Un solo componente y un solo JSON: cambiar una foto en el
   CMS cambia las dos páginas. */
const TalentoMoments = ({ T }) => T.moments ? (
  <section className="section section--alt">
    <div className="container">
      <Reveal className="section__head">
        <Eyebrow onDark>{T.momentsEyebrow}</Eyebrow>
        {/* momentsTitle "Three moments from the day-to-day" cut —
            the eyebrow alone is enough framing and the three
            cards below speak for themselves. */}
      </Reveal>
      <div className="moments">
        {T.moments.map((m,i) => (
          <Reveal key={i} delay={i * 100} className="moments__item">
            <div className="moments__ph"><img src={m.img} alt="" loading="lazy" /></div>
            <h3 className="moments__t">{m.t}</h3>
            <p className="moments__p">{m.p}</p>
          </Reveal>
        ))}
      </div>
    </div>
  </section>
) : null;

const TalentoBeyond = ({ T }) => T.beyondShots ? (
  <section className="section section--alt">
    <div className="container">
      <Reveal className="section__head">
        <Eyebrow onDark>{T.beyondEyebrow}</Eyebrow>
        <h2 className="section__title">{T.beyondTitle}</h2>
      </Reveal>
      <div className="beyond">
        {T.beyondShots.map((s,i) => (
          <Reveal key={i} delay={i * 120} className="beyond__item">
            <div className="beyond__ph"><img src={s.img} alt="" loading="lazy" /></div>
            {s.caption && <p className="beyond__cap">{s.caption}</p>}
          </Reveal>
        ))}
      </div>
    </div>
  </section>
) : null;

const PagePersonas = () => {
  const { t } = useLang();
  const P = t.personas;
  return (
    <>
      <PersonasHero heroImg={P.heroImg} title={P.title} scrollHint={P.scrollHint} />

      {/* Team grid on the same deep navy as the Proyectos sector stage —
          continuous editorial canvas, no parallax interruption. */}
      <section className="personas-team">
        <div className="container">
          <Reveal className="section__head"><Eyebrow onDark>{P.teamEyebrow}</Eyebrow></Reveal>
          <div className="team-grid">
            {t.team.map((person,i) => (
              <Reveal key={i} delay={i * 80}>
                <TeamCard t={person} />
              </Reveal>
            ))}
          </div>
        </div>
      </section>

      {/* La página se queda en la cabecera y la rejilla del equipo.
          Llevaba debajo la cita de "más de 60 años de experiencia
          colectiva" y, desde el documento del 9 de septiembre, "Cómo
          trabajamos" y "Más allá del proyecto" traídas de Talento. El
          cliente las quitó todas el 23 de septiembre: las dos secciones
          se quedan sólo en Talento, que es de donde salieron. Los
          componentes siguen compartidos por si vuelven. */}
    </>
  );
};

const PageTalento = ({ setRoute }) => {
  const { t, lang } = useLang();
  /* RetoLangProvider lee localStorage al montarse, así que el idioma
     tiene que escribirse durante el render, antes de devolver el JSX. */
  if (typeof window !== "undefined") {
    try { window.localStorage.setItem("reto_lang", lang); } catch (e) {}
  }
  const T = t.talento;
  /* El camino de la trayectoria y el juego vuelven al final de esta
     página; el volcado del idioma a RetoLangProvider vuelve con ellos,
     arriba del todo. */
  return (
    <>
      {/* Banner hero — same single-photo treatment as Personas / Quiénes
          / Newsletter. Title is the page LABEL (T.eyebrow → "Talento"
          / "Careers") so it fits in the bottom-left band without
          overlapping the centred scroll hint. The longer "Únete a
          Aguilera Ingenieros." headline + the T.sub tagline both move
          into the manifesto section below, where the dark band gives
          them their own stage. */}
      <PersonasHero
        heroImg={T.heroImg}
        title={T.eyebrow}
        scrollHint={T.scrollHint} />


      {/* Open positions — opens with a short lead paragraph that
          adapts to whether there are positions to browse. The
          previous .talento-find hero ("Solve real challenges…") was
          collapsed into this section so the careers page reads as a
          single hand-off from game → roles, not two banner-style
          sections in a row. */}
      {/* Why join — the case for applying, between the banner and the
          vacancy list. The game used to fill this stretch; once it moved to
          its own route the page went from a photograph straight into a list
          of job titles, which asks someone to be interested before telling
          them why.
          The three pillars are the ones that used to open the Vida page and
          were dropped from it as a recap of what that page said elsewhere.
          Here they are not a recap — this is the only place making the case,
          and the paragraph above them sets it up. */}
      {(T.positionsIntro || (T.pillars && T.pillars.length > 0)) && (
        <section className="section section--light talento-intro">
          <div className="container">
            <div className="talento-intro__inner">
              {T.positionsIntroTitle && (
                <Reveal><h2 className="section__title talento-intro__title">{T.positionsIntroTitle}</h2></Reveal>
              )}
              {T.positionsIntro && (
                <Reveal delay={80}>
                  <p className="body-lg talento-intro__body">{T.positionsIntro}</p>
                </Reveal>
              )}
            </div>
            {/* No pillars--light modifier: /talento reassigns the colour
                tokens to their on-dark values page-wide, so the base pillar
                styles — written for a dark band — are already correct. */}
            {T.pillars && T.pillars.length > 0 && (
              <div className="pillars talento-intro__pillars">
                {T.pillars.map((p, i) => (
                  <Reveal key={i} delay={140 + i * 80} className="pillar">
                    <div className="pillar__num">{p.num}</div>
                    <h3 className="pillar__t">{p.t}</h3>
                    <p>{p.p}</p>
                  </Reveal>
                ))}
              </div>
            )}
          </div>
        </section>
      )}

      {/* ── Vida en Aguilera ──────────────────────────────────────
          Antes vivía en su propia subpágina. Se trae aquí entera:
          el manifiesto, los tres momentos del día a día, la
          mentoría y el tramo de fotos. La idea es que quien llega
          buscando trabajo lea primero cómo se trabaja y encuentre
          las vacantes justo después, en la misma página. */}
      {/* Manifesto — just the eyebrow + the quote. The "Join Aguilera
          Ingenieros / We're looking for engineers…" title+sub block
          was redundant with the page's overall framing and got cut,
          so the section reads as a single confident statement now. */}
      <section className="section section--dark section--manifesto">
        <div className="container manifesto">
          <Reveal><Eyebrow onDark>{T.manifestoEyebrow}</Eyebrow></Reveal>
          <Reveal delay={120}>
            <p className="manifesto__quote">{T.manifestoQuote}</p>
          </Reveal>
        </div>
      </section>

      {/* "Why join" pillars section removed — the manifesto quote
          already carries the commitment message, and the moments /
          mentor / beyond sections below cover the substance. The
          numbered pillars felt like a bullet-point recap of what was
          said elsewhere on the same page. */}

      {/* How we work — three moments, three photos */}
      <TalentoMoments T={T} />

      {/* Mentorship — editorial 2-col */}
      {T.mentorImg && (
        <section className="section section--light section--mentor">
          <div className="mentor">
            <Reveal className="mentor__ph">
              <img src={T.mentorImg} alt="" loading="lazy" />
            </Reveal>
            <div className="mentor__col">
              <Reveal><Eyebrow onDark>{T.mentorEyebrow}</Eyebrow></Reveal>
              <Reveal delay={80}><h2 className="section__title" style={{marginTop:12}}>{T.mentorTitle}</h2></Reveal>
              <Reveal delay={160}><p className="body-lg" style={{marginTop:18,color:"var(--fg-mid)",maxWidth:540,lineHeight:1.55}}>{T.mentorBody}</p></Reveal>
            </div>
          </div>
        </section>
      )}

      {/* Beyond the project — two-photo strip + integrated
          "Ready for the challenge?" CTA (used to live in its own
          dark band below, but the whole page already closes on a
          warm note via this section's photography — the CTA reads
          better centred on the same light bg than as a separate
          navy outro). */}
      <TalentoBeyond T={T} />


      <section className="section talento-positions">
        <div className="container">
          {/* "Open positions" rendered as a full section title (white,
              bold, display font) instead of a small eyebrow — this is
              the headline of the careers landing, not a sub-label. */}
          <Reveal className="section__head">
            <h2 className="section__title section__title--light talento-positions__title">{T.positionsEyebrow}</h2>
          </Reveal>
          {(() => {
            /* Only positions with `open: true` show on the public site.
               Legacy entries without the field are treated as open for
               back-compat. Closed positions stay in the data for
               historical record + future re-open. */
            const openPositions = (t.positions || []).filter(p => p.open !== false);
            return openPositions.length > 0;
          })() ? (
            <>
              <Reveal delay={80}>
                <p className="body-lg talento-positions__lead">{T.positionsLead}</p>
              </Reveal>
              <Accordion items={(t.positions || []).filter(p => p.open !== false)}
                         applyLabel={T.applyCta}
                         applyEmail={T.applyEmail} />
            </>
          ) : (
            /* Sin vacantes abiertas. Antes solo salía el párrafo y la
               sección moría ahí; el CMS tenía además un título, un
               subtítulo y un botón para este caso que no leía nadie.
               Ahora se usan: el mensaje explica que no hay puestos y
               debajo queda una vía abierta para escribir igualmente. */
            <div className="talento-positions__empty">
              <Reveal delay={80}>
                <p className="body-lg talento-positions__lead talento-positions__lead--empty">{T.positionsLeadEmpty}</p>
              </Reveal>
              {(T.noPosTitle || T.contactCta) && (
                <Reveal delay={160} className="talento-positions__empty-cta">
                  {T.noPosTitle && <h3 className="talento-positions__empty-t">{T.noPosTitle}</h3>}
                  {T.noPosSub && <p className="talento-positions__empty-p">{T.noPosSub}</p>}
                  {T.contactCta && T.applyEmail && (
                    <MailReveal outline email={T.applyEmail}>
                      {T.contactCta}
                    </MailReveal>
                  )}
                </Reveal>
              )}
            </div>
          )}
        </div>
      </section>

      {/* EL RETO, al final de la página y no en una ruta aparte.
          Estuvo en medio de esta misma página, luego se sacó a
          /talento/reto-centro-de-datos porque cortaba la lectura de
          quien venía a ver vacantes, y el 23 de septiembre el cliente
          pidió traerlo de vuelta, esta vez al final: quien quiera leer
          las ofertas ya las ha pasado.

          El camino de la trayectoria viaja con el juego: su última
          parada —"empieza con un reto"— es la presentación del juego, y
          por separado no dicen nada. */}
      {T.journey && window.JourneyPath
        ? React.createElement(window.JourneyPath, { data: T.journey })
        : null}
      <section className="talento-reto" aria-label="Reto centro de datos">
        {window.RetoMount
          ? React.createElement(window.RetoMount, { key: "reto-" + lang, lang })
          : null}
      </section>
    </>
  );
};

const ContactMap = ({ mapEmbedUrl, mapCta }) => {
  const [loaded, setLoaded] = React.useState(false);
  return (
    <div className={"ai-contact-map " + (loaded ? "is-loaded" : "")} aria-label={mapCta}>
      <div className="ai-contact-map__placeholder" aria-hidden="true">
        <Icon.MapPin s={26} c="rgba(255,255,255,0.55)" />
        <span className="ai-contact-map__placeholder-text">{mapCta}</span>
      </div>
      <iframe className="ai-contact-map__frame"
              src={mapEmbedUrl}
              title={mapCta}
              loading="eager"
              onLoad={() => setLoaded(true)}
              referrerPolicy="no-referrer-when-downgrade"
              allowFullScreen />
    </div>
  );
};

const PageContacto = () => {
  const { t } = useLang();
  const C = t.contacto;
  const telHref = "tel:" + (C.phone || "").replace(/[^+\d]/g, "");
  const mailHref = "mailto:" + (C.email || "");
  return (
    <section className="ai-contact">
      <div className="container ai-contact__inner">
        {/* "Contact" page title removed — the card carries its own
            "Madrid" heading and is now the dominant element on the
            page, with its photo banner pulled up under the nav. */}

        {/* 2-column layout: contact card on the left, embedded Google
            Map on the right. Both share the same rounded surface and
            shadow vocabulary so they read as a matched pair. The grid
            collapses to a single column on phones. */}
        <Reveal delay={120}>
          <div className="ai-contact__grid">
          <article className="ai-contact-card">
            <div className="ai-contact-card__photo">
              <img src={C.photo} alt="" loading="eager" />
            </div>
            <div className="ai-contact-card__body">
              <header className="ai-contact-card__head">
                <div className="ai-contact-card__head-text">
                  <h2 className="ai-contact-card__city">{C.city}</h2>
                  <div className="ai-contact-card__company">{C.company}</div>
                </div>
                <div className="ai-contact-card__icons" role="group" aria-label={C.eyebrow}>
                  <a className="ai-contact-card__ic"
                     href={C.mapUrl}
                     target="_blank" rel="noopener noreferrer"
                     aria-label={C.iconAria && C.iconAria.map}>
                    <Icon.MapPin s={16} />
                  </a>
                  <a className="ai-contact-card__ic"
                     href={mailHref}
                     aria-label={C.iconAriaMail || (C.iconAria && C.iconAria.mail)}>
                    <Icon.Mail s={16} />
                  </a>
                  <a className="ai-contact-card__ic"
                     href={telHref}
                     aria-label={C.iconAria && C.iconAria.phone}>
                    <Icon.Phone s={16} />
                  </a>
                </div>
              </header>

              {/* 2-column body row: office (entrance + phone + email)
                  on the left, registered HQ on the right. Side-by-side
                  is the trick that keeps the card short enough to fit
                  in a 720px viewport. Collapses to a single column on
                  phones via the @media block. */}
              <div className="ai-contact-card__addrs">
                <div className="ai-contact-card__col">
                  {C.officeLabel && <div className="ai-contact-card__addr-label">{C.officeLabel}</div>}
                  <address className="ai-contact-card__addr">
                    {(C.addressLines || []).map((line, i) => (
                      <React.Fragment key={i}>
                        {line}
                        {i < C.addressLines.length - 1 && <br />}
                      </React.Fragment>
                    ))}
                  </address>
                  <div className="ai-contact-card__lines">
                    <a className="ai-contact-card__line" href={telHref}>{C.phone}</a>
                    <a className="ai-contact-card__line ai-contact-card__line--mail" href={mailHref}>{C.email}</a>
                    {/* Dirección comercial: va etiquetada porque, sin etiqueta,
                        dos correos seguidos no dicen a cuál escribir. La
                        etiqueta va encima y no delante: la columna mide unos
                        250px y en línea partía el correo por la mitad del
                        dominio. */}
                    {C.commercialEmail && (
                      <React.Fragment>
                        {C.commercialLabel && (
                          <div className="ai-contact-card__line-tag">{C.commercialLabel}</div>
                        )}
                        <a className="ai-contact-card__line ai-contact-card__line--mail"
                           href={"mailto:" + C.commercialEmail}>
                          {C.commercialEmail}
                        </a>
                      </React.Fragment>
                    )}
                  </div>
                </div>

                {C.legal && (
                  <div className="ai-contact-card__col">
                    <div className="ai-contact-card__addr-label">{C.legal.label}</div>
                    <address className="ai-contact-card__legal-addr">
                      {(C.legal.lines || []).map((line, i) => (
                        <React.Fragment key={i}>
                          {line}
                          {i < C.legal.lines.length - 1 && <br />}
                        </React.Fragment>
                      ))}
                    </address>
                  </div>
                )}
              </div>

              {C.addressNote && <div className="ai-contact-card__note">{C.addressNote}</div>}
            </div>
          </article>

          {/* Map box — Google Maps embed of the office address. A CSS
              skeleton placeholder (navy panel + pin icon + "Cargando
              mapa…" caption) renders immediately while the iframe
              fetches; once the iframe fires `onLoad`, we flip the
              `is-loaded` flag and the placeholder fades out behind
              the iframe. `loading="eager"` so the request starts on
              page load instead of waiting for the user to scroll. */}
          {C.mapEmbedUrl && <ContactMap mapEmbedUrl={C.mapEmbedUrl} mapCta={C.mapCta} />}
          </div>
        </Reveal>

        {/* Social strip — tucked under the card, low-key. URLs come
            from window.AI_SOCIAL so the Footer + Contact stay aligned;
            external links open in a new tab. */}
        <Reveal delay={220}>
          <div className="ai-contact__social" aria-label="Social">
            <a className="ai-contact__social-ic"
               href={AI_SOCIAL.linkedin}
               target="_blank" rel="noopener noreferrer"
               aria-label="LinkedIn">
              <Icon.LinkedIn />
            </a>
            <a className="ai-contact__social-ic"
               href={AI_SOCIAL.youtube}
               target="_blank" rel="noopener noreferrer"
               aria-label="YouTube">
              <Icon.YouTube />
            </a>
          </div>
        </Reveal>
      </div>
    </section>
  );
};

/* -------------------- Newsletter -------------------- */

const PageNewsletter = ({ setRoute }) => {
  const { t } = useLang();
  const N = t.newsletter;
  const open = (id) => setRoute({route:"article", articleId:id});
  return (
    <>
      {/* Banner hero — matches Personas / Talento / Quiénes treatment.
          N.eyebrow ("Noticias" / "News") is the banner title. */}
      <PersonasHero
        heroImg={N.heroImg}
        title={N.eyebrow}
        scrollHint={N.scrollHint} />

      <section className="section section--alt">
        <div className="container newsletter">
          <ul className="newsletter__archive">
            {t.news.map((a, i) => (
              <Reveal key={i} delay={80 + i * 80}>
                <li className="newsletter__issue" onClick={() => open(a.id)} style={{cursor:"pointer"}}>
                  <a className="newsletter__issue-thumb">
                    <img src={a.img} alt="" loading="lazy" />
                  </a>
                  <div className="newsletter__issue-body">
                    <div className="newsletter__issue-meta">
                      <span className="newsletter__issue-n">{a.n}</span>
                      <span className="newsletter__issue-date">{a.date}</span>
                      <span className="newsletter__issue-tag">{a.tag}</span>
                      <span className="newsletter__issue-read">{a.read}</span>
                    </div>
                    <a className="newsletter__issue-title">{a.title} <span className="arr">→</span></a>
                    <p className="newsletter__issue-excerpt">{a.excerpt}</p>
                  </div>
                </li>
              </Reveal>
            ))}
          </ul>
        </div>
      </section>
    </>
  );
};

/* -------------------- Article body renderer + Article page -------------------- */

/* Renders one block from an article body. Maps block.type → the brand
   typography token. Adding a new type (e.g. "list", "code") is just one case
   here plus a matching .article-block--* CSS rule. The future editor will
   produce this same shape. */
const ArticleBlock = ({ block, i }) => {
  switch (block.type) {
    case "lede":
      return <p className="article-block article-block--lede" key={i}>{block.text}</p>;
    case "p":
      return <p className="article-block article-block--p" key={i}>{block.text}</p>;
    case "h2":
      return <h2 className="article-block article-block--h2" key={i}>{block.text}</h2>;
    case "h3":
      return <h3 className="article-block article-block--h3" key={i}>{block.text}</h3>;
    case "image":
      return (
        <figure className="article-block article-block--image" key={i}>
          <img src={block.src} alt={block.caption || ""} loading="lazy" />
          {block.caption && <figcaption>{block.caption}</figcaption>}
        </figure>
      );
    case "quote":
      return <blockquote className="article-block article-block--quote" key={i}>{block.text}</blockquote>;
    /* A plain link and a downloadable document are the same shape with
       different affordances: the link opens a page, the document opens a
       file and says so with a ↓. Both are editable as blocks so an editor
       can drop a report or a policy into any body without a developer. */
    case "link":
      return (
        <p className="article-block article-block--link" key={i}>
          <a href={block.url}
             target={block.blank === false ? undefined : "_blank"}
             rel={block.blank === false ? undefined : "noopener noreferrer"}>
            {block.text || block.url}<span className="article-block__ic" aria-hidden="true">→</span>
          </a>
        </p>
      );
    case "file":
      return (
        <p className="article-block article-block--file" key={i}>
          <a href={block.src} target="_blank" rel="noopener noreferrer">
            {block.text || block.src}<span className="article-block__ic" aria-hidden="true">↓</span>
          </a>
        </p>
      );
    default:
      return null;
  }
};

const PageArticle = ({ articleId, setRoute }) => {
  const { t } = useLang();
  const N = t.newsletter;
  const article = t.news.find(a => a.id === articleId);

  // Defensive — if someone lands here with no/bad id, send them back to the list.
  if (!article) return (
    <section className="section section--alt">
      <div className="container" style={{textAlign:"center",padding:"80px 0"}}>
        <Eyebrow>{N.eyebrow}</Eyebrow>
        <p className="body-lg" style={{marginTop:16}}>{N.notFound}</p>
        <a className="article__back" onClick={() => setRoute("newsletter")} style={{marginTop:24,display:"inline-block"}}>← {N.backToList}</a>
      </div>
    </section>
  );

  return (
    <article className="article">
      <div className="article__hero" style={{backgroundImage:`url(${article.img})`}}>
        <div className="article__hero-ov"/>
        <div className="container article__hero-inner">
          <a className="article__back" onClick={() => setRoute("newsletter")}>← {N.backToList}</a>
          <Reveal delay={120}>
            <div className="article__meta">
              <span className="article__meta-n">{article.n}</span>
              <span className="article__meta-date">{article.date}</span>
              <span className="article__meta-tag">{article.tag}</span>
              <span className="article__meta-read">{article.read}</span>
            </div>
          </Reveal>
          <Reveal delay={200}>
            <h1 className="article__title">{article.title}</h1>
          </Reveal>
        </div>
      </div>

      <div className="container article__body">
        {article.body && article.body.map((block, i) => (
          <Reveal key={i} delay={Math.min(i * 40, 240)}>
            <ArticleBlock block={block} i={i} />
          </Reveal>
        ))}
      </div>
    </article>
  );
};

/* PageLegal — single component that renders any of the long-form legal
   docs (cookies / privacidad / aviso legal). Body content comes from
   t.legalDocs[key] with a fall-back to ES, since the legal text is
   locale-independent (Spanish jurisdiction). Each doc is an array of
   typed BLOCKS — paragraphs, headings, lists, tables — rendered by a
   switch below. */
const PageLegal = ({ legalKey, setRoute }) => {
  const { t, lang } = useLang();
  /* Resolve the doc — prefer the active locale, fall back to ES since
     the legal body is locale-independent (Spanish jurisdiction). If
     the active locale defined an empty/missing doc we'd otherwise
     render nothing; explicitly fall back to ES.blocks when that
     happens so the page is never visually empty. */
  const tDoc  = t.legalDocs && t.legalDocs[legalKey];
  const esDoc = window.AI_I18N && window.AI_I18N.es && window.AI_I18N.es.legalDocs && window.AI_I18N.es.legalDocs[legalKey];
  const doc = (tDoc && Array.isArray(tDoc.blocks) && tDoc.blocks.length > 0) ? tDoc : esDoc;
  const backLabel = lang === "es" ? "Volver" : "Back";
  if (!doc) {
    return (
      <section className="section section--light">
        <div className="container"><p>—</p></div>
      </section>
    );
  }
  const renderBlock = (b, i) => {
    switch (b.type) {
      case "h":
        return <h2 key={i} className="legal__h2">{b.text}</h2>;
      case "h3":
        return <h3 key={i} className="legal__h3">{b.text}</h3>;
      case "p":
        return <RichText key={i} as="p" className="legal__p" html={b.html} />;
      case "ul":
        return (
          <ul key={i} className="legal__ul">
            {b.items.map((it, j) => <RichText key={j} as="li" html={it} />)}
          </ul>
        );
      case "table":
        return (
          <div key={i} className="legal__table-wrap">
            <table className="legal__table">
              <thead>
                <tr>{b.headers.map((h, j) => <th key={j}>{h}</th>)}</tr>
              </thead>
              <tbody>
                {b.rows.map((row, ri) => (
                  <tr key={ri}>{row.map((cell, ci) => <td key={ci}>{cell}</td>)}</tr>
                ))}
              </tbody>
            </table>
          </div>
        );
      default:
        return null;
    }
  };
  return (
    <section className="legal">
      <div className="container legal__inner">
        <Reveal>
          <a className="legal__back" onClick={() => setRoute("home")}>← {backLabel}</a>
        </Reveal>
        {/* Single title only — the previous eyebrow + h1 pair rendered
            the same string twice ("AVISO LEGAL" + "Aviso Legal") since
            doc.eyebrow and doc.title carry the same value across all
            three legal docs. The h1 alone is enough. */}
        <Reveal delay={60}>
          <h1 className="legal__title">{doc.title}</h1>
        </Reveal>
        {/* Body NOT wrapped in Reveal — the legal text is many viewport
            heights tall, so an IntersectionObserver with threshold 0.15
            on the wrapper would never see 15% of the body visible at
            once and the .is-revealed class would never get added,
            leaving the whole block at opacity 0 forever. Rendering the
            body directly skips the animation but guarantees it's
            visible immediately. */}
        <div className="legal__body">
          {doc.blocks.map(renderBlock)}
        </div>
      </div>
    </section>
  );
};

Object.assign(window, { PageHome, PageQuienes, PageProyectos, PageSector, PagePersonas, PageTalento, PageNewsletter, PageArticle, PageContacto, PageLegal });
