components/SlidePage/SlidePage.jsxjavascript
import React from "react";
import { useParams, useNavigate, useLocation, Navigate } from "react-router";
import { useContent } from "../../services/content";
import { navigateWithTransition } from "../../utils/viewTransition";
import { ComponentRenderer } from "../ComponentRenderer";
import { CacheableVideo } from "../CacheableVideo";
import { SlideNavigation } from "../SlideNavigation/SlideNavigation";
import { getBlockRegion } from "../elements/registry";

/**
 * Shared skeleton of the slide-based presentation pages (ValueAdvisoryServices,
 * RadEnablement): viewId routing, slide navigation, element-bucket layout and
 * the content error/redirect states. The pages themselves only supply their
 * constants, their CSS (via `classPrefix`) and an optional header.
 *
 * @param {string} basePath - route prefix, e.g. "/rad-enablement"
 * @param {string} startViewId - view shown at the bare route, e.g. "rad-intro"
 * @param {string} storyHubViewId - the selectWidget hub view (e.g. "rad-select-story"), see getAdjacentRootViews
 * @param {string} [homeViewId=storyHubViewId] - menu's Home button target; defaults to storyHubViewId but can
 *   point elsewhere (e.g. an intro slide) when Home shouldn't land on the story picker
 * @param {string} classPrefix - CSS class prefix, e.g. "rad" -> "rad-page", "rad-slide-content"
 * @param {function(string): *} [renderHeader] - optional per-page header, returning a React node
 * @param {boolean} [disableAnimations=false] - mirrored onto the page div as
 *   `data-disable-animations` so a page's own CSS can opt into reacting to it
 *   (e.g. RadEnablement.scss dropping its background image) - SlidePage itself
 *   has no opinion on what it should disable, see config-schema.js
 */
export const SlidePage = ({
  basePath,
  startViewId,
  storyHubViewId,
  homeViewId = storyHubViewId,
  classPrefix,
  renderHeader,
  disableAnimations = false,
}) => {
  const { viewId = startViewId } = useParams();
  const navigate = useNavigate();
  const { search } = useLocation();
  const { content, views, error, getView, getAdjacentRootViews } = useContent();

  // the story hub's selectWidget picks the target view (its options' ids are
  // view ids, e.g. "cancer-1") rather than following a fixed nextView; the
  // choice only arms the "next" button, it doesn't navigate by itself, so
  // it's reset whenever the current slide changes
  const [selectedTargetViewId, setSelectedTargetViewId] = React.useState(null);
  React.useEffect(() => {
    setSelectedTargetViewId(null);
  }, [viewId]);

  // keeps the current URL params (kiosk/device config, see config-schema.js -
  // useConfig re-reads them from the URL on every render) alive across slide
  // navigation, and cross-fades the content
  const navigateToView = React.useCallback(
    (targetViewId) => {
      navigateWithTransition(navigate, `${basePath}/${targetViewId}${search}`);
    },
    [navigate, search, basePath]
  );

  // visible failure state instead of a blank kiosk screen; ContentProvider
  // keeps retrying in the background (see services/content.jsx)
  if (error) {
    return (
      <div className={`${classPrefix}-page page content-error`}>
        <p>Inhalte konnten nicht geladen werden.</p>
        <p>Es wird automatisch ein neuer Versuch gestartet …</p>
      </div>
    );
  }

  const view = getView(viewId);
  // a typo'd/stale view id would otherwise render an empty page without any
  // navigation (SlideNavigation hides itself when adjacency is empty)
  if (views.size > 0 && !view) {
    return <Navigate to={`${basePath}/${startViewId}${search}`} replace />;
  }

  const blocks = view?.elements || [];
  const { prevId, nextId } = getAdjacentRootViews(viewId, { storyHubViewId, homeViewId });
  const effectiveNextId = selectedTargetViewId || nextId;

  // each layout region gets its own wrapper below; a block's region comes
  // from its element's registry default, overridable per block in the JSON
  const blocksIn = (region) => blocks.filter((block) => getBlockRegion(block) === region);
  const slideStoryTitleBlocks = blocksIn("storyTitle");
  const slideHeadlineBlocks = blocksIn("headline");
  const contentBlocks = blocksIn("content");

  const renderElement = (block, index) => (
    <ComponentRenderer
      key={`${block.type}-${index}`}
      block={block}
      selectedTargetViewId={selectedTargetViewId}
      onSelectTarget={setSelectedTargetViewId}
      onNavigate={navigateToView}
    />
  );

  return (
    <div
      className={`${classPrefix}-page page`}
      data-disable-animations={disableAnimations ? "true" : undefined}>
      {content.slideBackgroundVideo && !disableAnimations && (
        <CacheableVideo
          className={`${classPrefix}-slide-background-video${
            slideStoryTitleBlocks.length > 0 ? " is-hidden" : ""
          }`}
          src={content.slideBackgroundVideo}
          autoPlay
          loop
          muted
          playsInline
          fallback={null}
        />
      )}
      {renderHeader?.(viewId)}
      {slideStoryTitleBlocks.length > 0 && (
        <div className={`${classPrefix}-slide-story-title`}>
          {slideStoryTitleBlocks.map(renderElement)}
        </div>
      )}
      {(slideHeadlineBlocks.length > 0 || contentBlocks.length > 0) && (
        <div className={`${classPrefix}-slide-content`}>
          {slideHeadlineBlocks.length > 0 && (
            <div className={`${classPrefix}-slide-headline-container`}>
              {slideHeadlineBlocks.map(renderElement)}
            </div>
          )}
          {contentBlocks.map(renderElement)}
        </div>
      )}

      <SlideNavigation
        prevViewId={prevId}
        nextViewId={view && view.parent ? view.parent : effectiveNextId}
        homeViewId={homeViewId}
        currentViewId={viewId}
        onNavigate={navigateToView}
      />
    </div>
  );
};