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,
};