services/content.jsxjavascript
/*
  Provides the app's editorial content (texts, media references, ...), so
  exhibit content can be edited without touching code. Which JSON file(s) get
  loaded depends on the current route: each entry in PRESENTATION_MODES
  (presentationModes.js) names either one contentFile or several contentFiles
  for its route, so every presentation app can have its own small content
  file(s) instead of one huge shared one - a mode that outgrows a single file
  (e.g. value-partnership, split across a shell/points/partnerships/
  globe-countries file) just lists several. The ?contentFile= URL param
  (config-schema.js) overrides that route-based choice with a single file,
  e.g. for testing one file in isolation against any route.

  ContentContext: holds the merged content object, empty ({}) until loaded.
  useContent:     hook to read the content anywhere in the tree.
  ContentProvider: resolves the file(s) for the current route, fetches them
                    (once per file list - already-seen combinations are
                    served from an in-memory cache so switching between apps
                    doesn't refetch), merges them into one object and
                    provides it via context.
*/

// allow export of contexts and hooks - shouldn't be flagged as non-component
/* eslint react-refresh/only-export-components: 0 */

import React from "react";
import PropTypes from "prop-types";
import { useLocation } from "react-router";

import { useConfig } from "./config/config";
import { PRESENTATION_MODES } from "./presentationModes";
import { resolveAssetPath, resolveAssetPathsDeep } from "../utils/resolveAssetPath";

/**
 * Context carrying `{ content, views, stories, error, getView,
 * getAdjacentRootViews }` - consume via {@link useContent}.
 */
export const ContentContext = React.createContext();

/**
 * React hook to access the loaded content object.
 * Returns {} until the content.json fetch in ContentProvider has resolved.
 */
export const useContent = () => React.useContext(ContentContext);

// which PRESENTATION_MODES route (if any) the given pathname belongs to -
// matches the mode's own path exactly, or one of its `:viewId?` sub-routes
const getActiveMode = (pathname) =>
  PRESENTATION_MODES.find(({ path }) => pathname === path || pathname.startsWith(`${path}/`));

const isPlainObject = (value) =>
  typeof value === "object" && value !== null && !Array.isArray(value);

// combines the fetched content files of one mode into a single object. Each
// top-level key that's a plain object (e.g. `valuePartnership`) is merged
// one level deep, so files that each contribute a different sub-key (points,
// partnerships, countryDisplay, ...) combine instead of the last file
// clobbering the others; anything else (arrays, primitives) is overwritten
// by whichever file lists it last. Deliberately mode-agnostic - it has no
// knowledge of any specific content shape.
const mergeContentFiles = (objects) =>
  objects.reduce((merged, obj) => {
    for (const [key, value] of Object.entries(obj)) {
      merged[key] =
        isPlainObject(value) && isPlainObject(merged[key]) ? { ...merged[key], ...value } : value;
    }
    return merged;
  }, {});

/**
 * Resolves the content file(s) for the current route, fetches them (once per
 * file-list combination - already-fetched combinations are served from an
 * in-memory cache so switching between apps doesn't refetch) and makes the
 * merged result available to all descendants via ContentContext. On fetch
 * failure, content stays {}, the failure is exposed as `error` (pages show a
 * visible message instead of a blank screen) and the fetch is retried until
 * it succeeds.
 */
const RETRY_DELAY_MS = 10000;

export const ContentProvider = ({ children }) => {
  const { contentFile: contentFileOverride } = useConfig();
  const { pathname } = useLocation();
  const [content, setContent] = React.useState({});
  const [views, setViews] = React.useState(new Map());
  const [stories, setStories] = React.useState({});
  const [error, setError] = React.useState(null);
  const cacheRef = React.useRef(new Map());

  const activeMode = getActiveMode(pathname);
  const contentFiles = contentFileOverride
    ? [contentFileOverride]
    : (activeMode?.contentFiles ?? (activeMode?.contentFile ? [activeMode.contentFile] : []));
  const cacheKey = contentFiles.join("|");

  React.useEffect(() => {
    if (!contentFiles.length) {
      setContent({});
      setViews(new Map());
      setStories({});
      setError(null);
      return undefined;
    }

    const cached = cacheRef.current.get(cacheKey);
    if (cached) {
      setContent(cached);
      setViews(new Map(Object.entries(cached.views || {})));
      setStories(cached.stories || {});
      setError(null);
      return undefined;
    }

    let cancelled = false;
    let retryTimer;

    const load = async () => {
      try {
        // both the fetch URL and the asset paths inside the JSON are authored
        // root-absolute - resolve them against the app's base path once here,
        // so no consumer has to (see utils/resolveAssetPath.js)
        const files = await Promise.all(
          contentFiles.map(async (file) => {
            const response = await fetch(resolveAssetPath(file));
            if (!response.ok) throw new Error(`HTTP ${response.status} for ${file}`);
            return resolveAssetPathsDeep(await response.json());
          })
        );
        if (cancelled) return;

        const data = mergeContentFiles(files);
        cacheRef.current.set(cacheKey, data);
        setContent(data);
        setViews(new Map(Object.entries(data.views || {})));
        setStories(data.stories || {});
        setError(null);
      } catch (fetchError) {
        // eslint-disable-next-line no-console
        console.error("Error fetching content:", fetchError);
        if (cancelled) return;
        // unattended kiosk: surface the failure and keep retrying - a
        // transient network drop must not leave a permanently blank exhibit
        setError(fetchError);
        retryTimer = setTimeout(load, RETRY_DELAY_MS);
      }
    };

    load();

    return () => {
      cancelled = true;
      if (retryTimer) clearTimeout(retryTimer);
    };
    // eslint-disable-next-line react-hooks/exhaustive-deps -- cacheKey is contentFiles' identity for effect purposes
  }, [cacheKey]);

  const getView = React.useCallback(
    (id) => {
      if (!views.size) return null;
      return views.get(id);
    },
    [views]
  );

  // reverse of the nextView links between standalone (story-less) views,
  // e.g. "rad-intro" -> "rad-select-story": lets us answer "which view leads
  // here" without the content file needing a prevView field. Story views
  // don't use nextView - their order lives in the story's `slides` array.
  const standalonePrevMap = React.useMemo(() => {
    const map = new Map();
    for (const [id, view] of views) {
      if (!view.story && view.nextView) map.set(view.nextView, id);
    }
    return map;
  }, [views]);

  // `storyHubViewId` (e.g. "rad-select-story") closes the loop at the start
  // of a story: a root slide's "back" leads to the hub, since stories are
  // entered by choice, not by a fixed link. A topic story's last slide's
  // "next" instead advances to the first slide of the next topic (looping
  // back to the first topic after the last one), so once a visitor has
  // picked a topic they can keep paging through all of them without
  // returning to the hub/start each time. `homeViewId` stays the fallback
  // for everything that isn't a topic story (the intro chain itself), and
  // is still the explicit "Home" menu button's target (SlideNavigation.jsx).
  const getAdjacentRootViews = React.useCallback(
    (id, { storyHubViewId, homeViewId } = {}) => {
      const view = views.get(id);
      if (!view) return { prevId: null, nextId: null };

      // the hub view itself is a member of its own "intro" story (its last
      // slide) - looping "back to the hub" from the hub would just point it
      // at itself, so it never falls back to hubId
      const hubId = storyHubViewId && storyHubViewId !== id ? storyHubViewId : null;
      // same self-reference guard as hubId, for when homeViewId is itself
      // the current view (e.g. stepping through the intro chain)
      const homeId = homeViewId && homeViewId !== id ? homeViewId : null;

      if (!view.story) {
        // standalone chain (intro views, the hub itself)
        const nextId = view.nextView && views.has(view.nextView) ? view.nextView : null;
        return { prevId: standalonePrevMap.get(id) || null, nextId };
      }

      const slides = stories[view.story]?.slides || [];
      // a sub view's "back" is the root it branched off from; its "next" is
      // the slide after that root
      const rootId = view.parent || id;
      const slideIndex = slides.indexOf(rootId);

      // the intro chain's first slide (homeViewId) has nothing before it -
      // it must not loop back to the hub the way a value-driver story's
      // first slide does
      const prevFallback = id === homeViewId ? null : hubId;
      const prevId = view.parent || (slideIndex > 0 ? slides[slideIndex - 1] : prevFallback);
      const nextCandidate = slideIndex >= 0 ? slides[slideIndex + 1] : null;

      // topic stories are every story but the hub's own (the intro chain);
      // their order - and thus "the next topic" - follows the stories
      // object's key order, same order the story picker/SlidesOverlay use
      const introStoryId = views.get(storyHubViewId)?.story;
      const topicStoryIds = Object.keys(stories).filter((sid) => sid !== introStoryId);
      const topicIndex = topicStoryIds.indexOf(view.story);
      const nextTopicFirstSlide =
        topicIndex >= 0
          ? stories[topicStoryIds[(topicIndex + 1) % topicStoryIds.length]]?.slides?.[0]
          : null;

      // the hub's own dead end (id === storyHubViewId) stays null here rather
      // than falling back to the next topic - its "next" is armed by the
      // selectWidget choice instead, see SlidePage's selectedTargetViewId
      const nextFallback =
        id === storyHubViewId
          ? null
          : nextTopicFirstSlide && views.has(nextTopicFirstSlide)
            ? nextTopicFirstSlide
            : homeId;
      const nextId = nextCandidate && views.has(nextCandidate) ? nextCandidate : nextFallback;

      return { prevId, nextId };
    },
    [views, stories, standalonePrevMap]
  );

  const value = React.useMemo(
    () => ({ content, views, stories, error, getView, getAdjacentRootViews }),
    [content, views, stories, error, getView, getAdjacentRootViews]
  );

  return <ContentContext.Provider value={value}>{children}</ContentContext.Provider>;
};

ContentProvider.propTypes = {
  children: PropTypes.node.isRequired,
};