/* ============================================================
 * Hero — cinematic, full bleed, layered parallax, two-story carousel
 *
 * Two independent stories, each with its own silent background loop and
 * its own full film (opened via "Watch the film"). HERO_STORIES is the
 * single source of truth both this component and FilmLightbox read from
 * — see window.__gmHeroStories below.
 *
 * Slide 1 "MEMORY" — Mediterranean, golden hour, a returning guest
 * recognised across YEARS at the same hotel.
 * Slide 2 "ONCE" — urban night, a guest recognised across CHANNELS in
 * the same few weeks. Design record:
 * docs/plans/2026-09-03-homepage-hero-carousel-once-film-design.md
 *
 * Parallax stays 2.5D on a single master plate per story; when a
 * subject-removed pass exists for either, add { src, rate } layer
 * entries under that story and nothing else changes.
 *
 * Perf: one rAF loop, armed by IntersectionObserver, writes only
 * transforms via CSS custom properties. No per-frame React state,
 * no layout reads inside the loop. Only the ACTIVE story's video is ever
 * mounted (plus the outgoing one for the ~700ms crossfade window) — a
 * hero this size cannot afford two videos downloading at once on a cold
 * load.
 * ============================================================ */

const CDN = "https://cdn.guestmaker.ai/marketing/homepage-v4/assets";

// Order is display order — THE NOTE leads (Dani, 2026-09-04: "our best video
// ... the one that will be placed first"), ONCE and MEMORY follow. Nothing
// else references array position (both components key off `id`).
const HERO_STORIES = [
  {
    id: "the-note",
    name: "THE NOTE",
    // Uploaded via scripts/upload-the-note-assets-to-r2.ts. Bump ?v= on
    // re-upload. Background loop is the hotel host at reception, not the
    // family — Dani's call: a still-life or the family mid-scene reads as
    // ambiguous at a glance, the host at a desk reads instantly as "hotel."
    poster: `${CDN}/hero-3-master.jpg?v=1`,
    // v2: wrap-point pop fixed. The seam scored ~3.2x any other moment in
    // the clip on frame-difference analysis (her hair/head didn't quite
    // return to its start position) — a plain crossfade attempt traded it
    // for a worse, subtler bug (decelerating to a near-freeze right before
    // the cut), so the fix blends the tail against REVERSED motion instead
    // of a frozen frame; the seam now scores below the clip's own natural
    // variation. Verified both ways (frame-diff scoring, not just by eye).
    loop: `${CDN}/hero-3-loop.mp4?v=2`,
    alt: "A hotel host smiling warmly at the reception desk of a sunlit resort",
    // v2 (scripts/reupload-the-note-film-to-r2.ts): dropped the VO track
    // (title cards instead, matching MEMORY/ONCE — VO quality didn't clear
    // the bar), added 3 more cards covering the hotel-arrival section that
    // read as unclear with none, re-staged M1's school-gate reunion as a
    // completed hug (was cutting away mid-run, read as the boy skipping past
    // her rather than embracing her), dialled back M7's celebration to quiet
    // warmth, and fixed M8's note from a blurred illegible page to a sealed,
    // unopened card — no on-screen text anywhere, by construction rather
    // than by blur.
    // v3: re-rendered the 5 caption cards that used an em dash (Dani: no em
    // dashes in video captions) with a colon/comma/period instead — same
    // line lengths, same layout, same score/grade, nothing else changed.
    // v4: added a subtle GuestMaker brand overlay — a translucent chat-bubble
    // mark (HyperFrames, real 3D lighting + glass translucency, generic
    // silhouette to stay trademark-safe, GuestMaker's own 2-tone heart mark
    // sealed inside it) that appears/disappears at the three moments the
    // guest receives a WhatsApp reply, timed and positioned per-shot against
    // the actual graded/captioned footage (not the raw cut) so it never
    // collides with a caption card or a shot cut. Poster frame (t=0)
    // untouched, so filmPoster is not bumped.
    // v5: brand overlay REMOVED (Dani: reads as low quality for now — may
    // revisit the concept later, not a rejection of the idea itself). Also
    // fixes M7's menu card, which had gone through with real gibberish text
    // ("E&DAAKISD MERASE" and fake body copy) rather than the soft-focus the
    // shot was always meant to have per its own generation brief ("held at
    // an angle or slightly soft-focus so no individual word is ever
    // legible") — a tracked gaussian blur over the card's full range of
    // motion within the shot now delivers that. Also adds a caption right
    // after the checkbox-tick ("His coeliac menu, already prepared.") since
    // nothing previously named what the handoff was — the existing card
    // ("Before they could even ask, the front desk already knew.") sets up
    // the anticipation but never says menu, and the beat was otherwise
    // caption-free from before Shot 3 through Shot 4.
    // v6: M7's menu card RESHOT (Dani: a blur is still just hiding the
    // defect, not fixing it) via Higgsfield/Seedance 2.0, same host
    // reference (pulled from the reception loop, already likeness-cleared),
    // same wardrobe/camera/optics/lighting/style language as the rest of the
    // film. The fix is geometric, not another "make it illegible"
    // instruction — the card's printed face is turned fully away from
    // camera for the entire shot, so no text can leak through because
    // there is no printed surface ever facing the lens, not because the
    // model was told to blur it. Spliced in frame-exact (both cut points
    // verified); the new shot runs 2 frames longer than the old one, so the
    // "His coeliac menu" caption's timing shifted by the same +2/24s to
    // stay aligned with the checkbox-tick shot.
    filmSrc: `${CDN}/the-note-film.mp4?v=6`,
    filmPoster: `${CDN}/the-note-poster.jpg?v=2`,
    filmDuration: "1 min 27 sec",
    // Measured directly off hero-3-master.jpg (2206x946, a 2.33:1 plate, not
    // 16:9): her face sits at ~48% across. A 390-wide portrait crop only
    // ever shows ~20% of that width, so the mobile object-position has to be
    // tuned per plate rather than shared — see the "mobile focal points"
    // comment in hero-cinematic.css. (Height always matches the container
    // exactly for a plate this wide, so only the horizontal figure matters —
    // vertical is left at "center" everywhere.)
    mobileFocus: "47% center",
  },
  {
    id: "once",
    name: "ONCE",
    // Uploaded via scripts/upload-once-assets-to-r2.ts — see that script's
    // header for the one-time upload command. Bump ?v= on re-upload.
    // v2: reshot facing toward camera — v1's back-to-camera framing drew
    // direct feedback that her face wasn't visible. v3: extracted from the
    // multi-vignette rebuild below (2026-09-04).
    poster: `${CDN}/hero-2-master.jpg?v=3`,
    // v2 was a self-crossfade of ONE continuous shot — still popped on
    // restart because the source clip's head pose drifted from its own tail
    // within the 8.5s take (confirmed by building a double-loop test file
    // and comparing frames at the join, not just checking brightness — a
    // flat signalstats graph is NOT sufficient proof a loop is seamless).
    // v4 is a real multi-vignette rebuild instead — three shots (her at the
    // turntable, a no-person record-detail insert, her at the window)
    // crossfading into each other, matching how hero-1-loop.mp4 itself is
    // built. No single shot has to hold a perfectly static pose for 8+
    // seconds, so there's nothing left to drift. Every join (including the
    // wrap) verified via a double-loop test file, both signalstats AND
    // actually looking at frames spanning each join.
    // scripts/upload-once-loop-multivignette-to-r2.ts (2026-09-04).
    loop: `${CDN}/hero-2-loop.mp4?v=4`,
    alt: "A record turning by a window high above an anonymous city skyline at night",
    filmSrc: `${CDN}/once-film.mp4?v=1`,
    filmPoster: `${CDN}/once-poster.jpg?v=1`,
    filmDuration: "1 min 18 sec",
    // hero-2-master.jpg (4032x1728, same 2.33:1 family): she sits at ~32%
    // across, well left of centre. The old shared "right center" mobile
    // crop showed only the rightmost ~20% of the plate — the turntable and
    // window, with her cropped out of frame entirely.
    mobileFocus: "28% center",
  },
  {
    id: "memory",
    name: "MEMORY",
    // CDN, not a local path. public/preview/**/*.{jpg,mp4} is gitignored by
    // guardrail #560, so a relative src ships as a 404 the moment this is
    // deployed from a clean checkout. Upload with
    // scripts/upload-homepage-v4-to-r2.ts.
    poster: `${CDN}/hero-1-master.jpg?v=6`,
    // Motion layer. `poster` doubles as the video's own poster, so the frame
    // is identical whether or not the video plays — no flash on swap, and
    // the still is what mobile and reduced-motion users get.
    loop: `${CDN}/hero-1-loop.mp4?v=7`,
    alt: "A guest standing at the parapet of a hotel terrace above the sea, late in the day",
    filmSrc: `${CDN}/memory-film.mp4?v=8`,
    filmPoster: `${CDN}/memory-poster.jpg?v=2`,
    filmDuration: "1 min 17 sec",
    // hero-1-master.jpg (1920x810, same family): she sits at ~62% across.
    // This is the plate the old blanket "right center" rule was actually
    // tuned for (close, not exact) — kept close to that as ~65%.
    mobileFocus: "65% center",
  },
];

// FilmLightbox reads the same list so the two components can never drift —
// see film-lightbox.jsx's window.__gmOpenFilm(storyId) call.
Object.assign(window, { __gmHeroStories: HERO_STORIES });

/* One badge, one claim, one line of proof behind it — the tooltip exists so
   a terse pill ("Self-Healing Data") doesn't read as a buzzword with
   nothing under it. Hover AND focus (tabIndex + onFocus/onBlur) so it's
   reachable without a mouse; role="tooltip" + aria-describedby wires it to
   the trigger the way a screen reader expects. No shadcn/Radix here — this
   page has no build step (plain React + Babel-in-browser, no npm), so this
   is a hand-built equivalent: same fade+rise-on-hover behavior, same small
   dark panel + arrow, just no dependency to install. */
const HeroBadge = ({ id, icon, iconBg, label, tip, live }) => {
  const [open, setOpen] = useState(false);
  const tipId = `hero-badge-tip-${id}`;
  return (
    <div
      className="hero-cine__badge"
      style={{ "--tint": iconBg }}
      onMouseEnter={() => setOpen(true)}
      onMouseLeave={() => setOpen(false)}
      onFocus={() => setOpen(true)}
      onBlur={() => setOpen(false)}
      tabIndex={0}
      aria-describedby={tipId}
    >
      <span className="hero-cine__badge-icon" aria-hidden="true">
        {icon}
      </span>
      <span className="hero-cine__badge-text">{label}</span>
      {live && <span className="hero-cine__badge-dot" aria-hidden="true" />}
      <span
        id={tipId}
        role="tooltip"
        className={`hero-cine__badge-tip ${open ? "is-open" : ""}`}
      >
        {tip}
        <span className="hero-cine__badge-tip-arrow" aria-hidden="true" />
      </span>
    </div>
  );
};

// Shared plate motion — both stories use the same rate/scale so switching
// slides never changes the parallax character, only the picture.
const PLATE_RATE = 0.12;
const PLATE_SCALE = 1.08;

// How long a slide holds before auto-advancing. Close to the loop's own
// ~10s cycle so the swap feels like "the loop finished, moving on" rather
// than an arbitrary interruption.
const AUTO_ADVANCE_MS = 9500;
const CROSSFADE_MS = 700;

const HeroCinematic = () => {
  const rootRef = useRef(null);
  const layerRefs = useRef({});
  const contentRef = useRef(null);
  const [ready, setReady] = useState(false);
  // Starts false so the poster is always the first paint. The video is only
  // attached after mount, and never under reduced motion or a metered/slow
  // connection (data-saver, 2G) — mobile on a normal connection gets it too.
  const [motionOk, setMotionOk] = useState(false);

  const [activeIndex, setActiveIndex] = useState(0);
  // The slide being faded OUT during a crossfade — both it and the active
  // slide are briefly mounted together, then this clears and the outgoing
  // video unmounts. null outside a transition.
  const [prevIndex, setPrevIndex] = useState(null);
  // Once true, auto-advance stops for good — a visitor who has already
  // chosen a slide should never get yanked away from it.
  const userChoseRef = useRef(false);
  const pausedRef = useRef(false);
  const crossfadeTimerRef = useRef(null);

  const activeStory = HERO_STORIES[activeIndex];

  const goToSlide = (index) => {
    if (index === activeIndex) return;
    if (crossfadeTimerRef.current) clearTimeout(crossfadeTimerRef.current);
    setPrevIndex(activeIndex);
    setActiveIndex(index);
    crossfadeTimerRef.current = setTimeout(() => setPrevIndex(null), CROSSFADE_MS + 80);
  };

  // Auto-advance between the two stories. Stops on manual navigation, on
  // hover/focus/touch, and under reduced motion — a background element
  // should never fight a visitor's attention or their OS-level motion
  // preference.
  useEffect(() => {
    if (HERO_STORIES.length < 2) return;
    const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
    if (reduced) return;

    const id = setInterval(() => {
      if (userChoseRef.current || pausedRef.current) return;
      goToSlide((activeIndex + 1) % HERO_STORIES.length);
    }, AUTO_ADVANCE_MS);
    return () => clearInterval(id);
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [activeIndex]);

  useEffect(() => {
    const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
    // Background loops now play on mobile too (Dani, 2026-09-13: "leave them
    // on") — the earlier blanket `max-width: 860px` cutoff is gone. Still
    // skip the download entirely on a metered or slow connection, which is
    // the one case a phone genuinely needs the static poster instead.
    const conn = navigator.connection;
    const thrifty =
      !!conn && (conn.saveData || /2g/.test(conn.effectiveType || ""));
    // The extra render IS the mechanism here: motionOk starts false so the
    // poster is guaranteed to be the first paint, and the video only attaches
    // once we have measured that this device should get it. A lazy useState
    // initialiser would decide during the FIRST render and mount the video
    // immediately, which is exactly what this avoids.
    if (!reduced && !thrifty) setMotionOk(true);
  }, []);

  useEffect(() => {
    const root = rootRef.current;
    if (!root) return;

    const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;

    // Entrance runs regardless; the CSS strips it under reduced motion.
    const readyTimer = setTimeout(() => setReady(true), 60);
    if (reduced) return () => clearTimeout(readyTimer);

    let rafId = null;
    let running = false;

    const frame = () => {
      // Single cheap read. offsetHeight is read once outside via cached h.
      const y = window.scrollY || window.pageYOffset || 0;
      const h = root.offsetHeight || 1;
      const p = Math.min(Math.max(y / h, 0), 1);

      // Applies to whichever layer(s) are currently mounted — just the
      // active one normally, plus the outgoing one during a crossfade.
      Object.values(layerRefs.current).forEach((el) => {
        if (!el) return;
        el.style.setProperty("--py", `${p * h * PLATE_RATE}px`);
        el.style.setProperty("--scale", String(PLATE_SCALE));
      });

      if (contentRef.current) {
        contentRef.current.style.setProperty("--content-py", `${p * -90}px`);
        contentRef.current.style.setProperty(
          "--content-op",
          String(Math.max(0, 1 - p * 1.7))
        );
      }
      root.style.setProperty("--grade", String(Math.min(p * 0.8, 0.72)));

      if (running) rafId = requestAnimationFrame(frame);
    };

    // Only burn frames while the hero is actually on screen.
    const io = new IntersectionObserver(
      ([entry]) => {
        if (entry.isIntersecting && !running) {
          running = true;
          rafId = requestAnimationFrame(frame);
        } else if (!entry.isIntersecting && running) {
          running = false;
          if (rafId) cancelAnimationFrame(rafId);
        }
      },
      { threshold: 0 }
    );
    io.observe(root);

    return () => {
      clearTimeout(readyTimer);
      running = false;
      if (rafId) cancelAnimationFrame(rafId);
      io.disconnect();
    };
  }, []);

  // Render whichever stories are currently visible: the active one always,
  // plus the outgoing one for the ~700ms crossfade window. Keying each
  // layer by story.id (not array position) is what lets React keep the
  // SAME <video> element mounted across the active→prev handoff instead of
  // tearing it down and re-fetching — a story that was already playing
  // keeps playing right through the fade.
  const visibleIndices = prevIndex === null ? [activeIndex] : [prevIndex, activeIndex];

  return (
    <section
      ref={rootRef}
      className={`hero-cine ${ready ? "is-ready" : ""}`}
      aria-label="GuestMaker"
      onMouseEnter={() => { pausedRef.current = true; }}
      onMouseLeave={() => { pausedRef.current = false; }}
      onFocus={() => { pausedRef.current = true; }}
      onBlur={() => { pausedRef.current = false; }}
      onTouchStart={() => { pausedRef.current = true; }}
    >
      {visibleIndices.map((i) => {
        const story = HERO_STORIES[i];
        const isActive = i === activeIndex;
        return (
          <div
            key={story.id}
            ref={(el) => {
              if (el) layerRefs.current[story.id] = el;
              else delete layerRefs.current[story.id];
            }}
            className={`hero-cine__layer hero-cine__layer--back ${isActive ? "is-active" : "is-outgoing"}`}
            aria-hidden={i === activeIndex ? undefined : "true"}
            style={{ "--mobile-focus": story.mobileFocus || "50% center" }}
          >
            {motionOk ? (
              <video
                className="hero-cine__video"
                poster={story.poster}
                autoPlay
                muted
                loop
                playsInline
                preload="auto"
                aria-label={i === activeIndex ? story.alt : undefined}
              >
                <source src={story.loop} type="video/mp4" />
              </video>
            ) : (
              // next/image does not exist here — these preview pages are static
              // HTML with in-browser Babel, served from public/, outside the
              // Next app entirely. The active plate is already preloaded as
              // LCP in the page head with fetchpriority=high.
              <img
                src={story.poster}
                alt={i === activeIndex ? story.alt : ""}
                {...(i === activeIndex && i === 0 ? { fetchpriority: "high" } : {})}
                decoding="async"
              />
            )}
          </div>
        );
      })}

      <div className="hero-cine__scrim hero-cine__scrim--grade" />
      <div className="hero-cine__scrim hero-cine__scrim--base" />
      <div className="hero-cine__scrim hero-cine__scrim--top" />
      <div className="hero-cine__scrim hero-cine__scrim--handoff" />

      <div ref={contentRef} className="hero-cine__content">
       <div className="hero-cine__text">
        <div className="hero-cine__eyebrow hero-cine__rise">
          The platform behind the stay
        </div>

        {/* "Agentic" moved out of the H1 and into what the lead describes.
            It is the word that separates us from chatbot vendors, but it is
            insider language in a headline; "AI" is what a hotel group
            actually searches for. The concierge doing things on its own is
            what makes the claim agentic, so the lead carries it now.

            Note "Agentic CRM" survives as the NAME of the CRM module across
            the CRM and pricing pages. That is a product name, not this
            brand line, and is deliberately left alone. */}
        {/* The break is explicit because `text-wrap: balance` optimises for
            equal line LENGTHS, and on this copy that split the product name:
            "The Omnichannel AI" / "CRM for hotel groups.", which orphans the
            italic accent onto the head of line 2 and reads bottom-heavy.
            Balance is still on for narrow viewports, where line 1 has to
            wrap again on its own. If you edit this headline, re-check the
            break — the old copy needed no <br> and this one does. */}
        {/* "AI CRM" is accented as ONE phrase, not two words. Highlighting
            AI and CRM separately would spend the accent twice in a six-word
            headline; because they sit next to each other, one <em> covers
            the whole product category and the accent stays in one place. */}
        <h1 className="hero-cine__title hero-cine__rise">
          {/* The {" "} is load-bearing. The <br> is display:none under 560px
              (see hero-cinematic.css), and with nothing between the </em> and
              "for" the headline renders "AI CRMfor hotel groups." on every
              phone. Where the <br> IS shown the space sits at the start of a
              line and the browser strips it, so it costs nothing. */}
          The Omnichannel <em>AI CRM</em><br />{" "}for hotel groups.
        </h1>

        {/* Three claims, three colours already in the platform's own
            palette (--c-whatsapp / --accent / --c-email from styles.css) —
            not picked for contrast alone, each ties the badge to what it's
            actually about: WhatsApp green for the WhatsApp partnership,
            the brand's own fuchsia for GuestMaker's own AI, blue (already
            the email-channel colour) for the data-layer claim. Only the
            first gets the "live" pulse dot — that one is a verified
            partner STATUS; the other two are capability claims, not a
            live/online signal, so a pulse on them would be dishonest
            decoration. */}
        <div className="hero-cine__badges hero-cine__rise">
          {/* WhatsApp icon, not Meta's own mark: the official Tech Provider
              badge artwork is only distributable through Meta's partner
              portal under brand terms that don't permit redrawing it (same
              reason section-footer.jsx's "Meta Business Partner" credential
              is typographic-only, not a logo). The claim is made in words
              instead — "Official Meta Tech Provider" is Meta's own current
              term, confirmed in lib/whatsapp/embedded-signup.ts's own
              comment referencing their docs — and the icon shown is the
              channel this status is actually about, already proven at this
              exact size and treatment in journeys-hero.jsx's trust strip. */}
          <HeroBadge
            id="meta"
            iconBg="var(--c-whatsapp)"
            label="Official Meta Tech Provider"
            tip="Certified to connect directly to WhatsApp's official API, not a reseller layered on top."
            live
            icon={
              <svg width="13" height="13" viewBox="0 0 24 24" fill="#fff"><path d="M20.52 3.449C12.831-3.984.106 1.407.101 11.893c0 2.096.549 4.142 1.595 5.945L0 24l6.335-1.652a11.882 11.882 0 005.71 1.447h.006c10.493 0 16.965-12.5 11.987-19.875A12.062 12.062 0 0020.52 3.449z"/></svg>
            }
          />
          {/* Brain glyph reused verbatim from icons.jsx's IconBrain (the same
              mark already labelled "Hotel Brain" in the Platform mega menu),
              so this badge and that KB feature read as the same product
              language rather than a one-off icon invented for the hero.
              Sized and weighted up from the first pass (11px/2px stroke):
              at that size a 24-unit viewBox's stroke resolves to under 1
              real CSS pixel on a standard-DPI display, which anti-aliases
              as a soft blur rather than a crisp line — read as "pixelated"
              because it effectively was sub-pixel. 15px + 2.4 stroke keeps
              the glyph a clean line at real size instead of a smudge. */}
          <HeroBadge
            id="memory"
            iconBg="var(--accent)"
            label="AI Guest Memory"
            tip="Every stay and conversation is remembered, so guests never repeat themselves, even months later."
            icon={
              <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="#fff" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round">
                <path d="M9 4a3 3 0 0 0-3 3v0a3 3 0 0 0-2 5v0a3 3 0 0 0 2 5v0a3 3 0 0 0 3 3h.5a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2H9z" />
                <path d="M15 4a3 3 0 0 1 3 3v0a3 3 0 0 1 2 5v0a3 3 0 0 1-2 5v0a3 3 0 0 1-3 3h-.5a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2H15z" />
              </svg>
            }
          />
          {/* "Reconciled automatically", not "zero manual cleanup, ever" —
              backfill_contact_dupes() auto-merges most duplicates but
              deliberately routes risky cases (no DOB, large clusters) to a
              human review queue rather than silently merging them. The
              tooltip has to stay one notch short of what the pipeline
              actually promises. Same size/weight bump as the brain icon,
              same reason. */}
          <HeroBadge
            id="healing"
            iconBg="var(--c-email)"
            label="Self-Healing Data"
            tip="Duplicate and inconsistent guest records are found and reconciled automatically: no manual cleanup projects."
            icon={
              <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="#fff" strokeWidth="2.6" strokeLinecap="round" strokeLinejoin="round">
                <path d="M21 12a9 9 0 1 1-3.4-7.03" />
                <path d="M21 3v6h-6" />
              </svg>
            }
          />
        </div>

        {/* Two sentences doing two different jobs, and both are load-bearing.

            The first replaced a bare channel list, which is a commodity
            claim — every WhatsApp vendor says "omnichannel". What is ours is
            that each channel is STAFFED by something that knows the hotel.

            The second used to continue that thought (trained on your hotel,
            connected to your PMS, sharing one memory) and that made the
            whole hero sound like an inbox product. Campaigns, journeys,
            loyalty, the CDP and the B2B pipeline are most of the platform
            and the lead never mentioned them. So the second sentence now
            pivots to the profile everything downstream runs on, which is
            also what earns the word "CRM" in the headline.

            The trade is deliberate: "trained on your hotel" and the PMS
            connection moved out of the hero. Both are made further down the
            page, by the Knowledge and CDP sections, and neither was worth a
            fourth line here.

            "OTA chat" is dropped deliberately: Booking.com messaging is
            built, but it is not something we want to lead with yet. The nav
            and the Channels section still name it. */}
        <p className="hero-cine__lead hero-cine__rise">
          An AI concierge on every channel your guests already use: WhatsApp,
          phone, email, web and social. Behind them, one guest profile that your
          campaigns, journeys, loyalty and B2B pipeline all run on.
        </p>
       </div>

        {/* One invitation, not three. "Book a demo" already lives in the nav,
            so the hero does not need to repeat it — and a background loop with
            two CTAs plus a film link is three competing asks. */}
        <div className="hero-cine__actions hero-cine__rise">
          <button
            className="hero-cine__watch"
            onClick={() => window.__gmOpenFilm && window.__gmOpenFilm(activeStory.id)}
          >
            <span className="hero-cine__watch-ring" aria-hidden="true">
              <svg viewBox="0 0 24 24" width="20" height="20">
                <path d="M8 5v14l11-7z" fill="currentColor" />
              </svg>
            </span>
            <span className="hero-cine__watch-text">
              <span className="hero-cine__watch-label">Watch the film</span>
              <span className="hero-cine__watch-meta">{activeStory.filmDuration}</span>
            </span>
          </button>
        </div>
      </div>

      {/* Centered on the whole hero, not tucked under "Watch the film" — a
          carousel indicator reads as UI chrome for the slide, not part of
          the copy block, so it sits on its own axis the same way .hero-cine__cue
          does below it. Only rendered once there's more than one story to
          switch between, so a future third slide costs nothing extra here. */}
      {HERO_STORIES.length > 1 && (
        <div className="hero-cine__dots hero-cine__rise" role="tablist" aria-label="Hero story">
          {HERO_STORIES.map((story, i) => (
            <button
              key={story.id}
              role="tab"
              aria-selected={i === activeIndex}
              aria-label={`Show the ${story.name} story`}
              className={`hero-cine__dot ${i === activeIndex ? "is-active" : ""}`}
              onClick={() => {
                userChoseRef.current = true;
                goToSlide(i);
              }}
            />
          ))}
        </div>
      )}

      {/* Announces the swap for screen-reader users without stealing focus —
          the carousel itself never moves focus, so this is the only signal
          a non-visual visitor gets that the background changed. */}
      <span className="sr-only" aria-live="polite">{activeStory.name} story showing</span>

      <div className="hero-cine__cue" aria-hidden="true">Scroll</div>
    </section>
  );
};

Object.assign(window, { HeroCinematic });
