← Files WixARCHIVED FILE

skills/wix-headless-templates/faq/app/wix/faq/faq-store.ts

8.49 KB · Oct 8, 2026 · 12:02 UTC

↓ Download file

See the change to this file →

// FAQ page state — a module-scope store, deliberately NOT a React context and not a per-surface
// factory: the category nav, the search box, and the accordion are separate Astro islands on one
// page and must share one state (a context can't span islands; each island is its own React root).
// Framework-free — consume it through useFaq() (hooks/), or subscribe directly from a static page,
// Vue, or Svelte. Same shape as the storefront cart store: getState/subscribe + actions, emit after
// every change.
//
// Seeding: every island receives the SSR data and calls seedFaq(); on the server every seed replaces
// the state (a request renders its own props), in the browser the first seed wins and the rest are
// no-ops, so the SSR HTML and the first client paint agree. A page with no SSR data (a SPA) fetches on
// the first subscription.
//
// URL state (syncUrl, default on): `?category=<id>` and `#q-<slug>` are read once when the first
// island subscribes and written with history.replaceState on every change — a visitor can copy the
// address bar at any point and get back to the same view.
import { fetchFaq } from "./faq";
import { anchorId, findByAnchor, parseDeepLink, visibleQuestions } from "./faq-core";
import type { FaqData, FaqQuestion } from "./types";

export interface FaqState {
  /** null while the first load is in flight and nothing was seeded — render skeletons, not an empty state. */
  data: FaqData | null;
  /** null = all categories (sections); an id = that category only. */
  activeCategoryId: string | null;
  query: string;
  /** Expanded question ids; at most one when expandOnlyOne. */
  expandedIds: string[];
  /** Derived on every change: category filter, then search, in owner order — what the accordion renders. */
  visible: FaqQuestion[];
  /** visible.length while a query is typed, 0 otherwise — for the results announcement. */
  resultsCount: number;
  loading: boolean;
  /** The last failed load's message — render it with a retry; a new load clears it. */
  error: string | null;
}

export interface FaqStoreOptions {
  /** SSR data — no client fetch happens when present. */
  initialData?: FaqData;
  /** The `?category=` the server saw (validated by the page); null/undefined = all. */
  initialCategoryId?: string | null;
  /** One open answer at a time (default true — the accordion behaviour). */
  expandOnlyOne?: boolean;
  /** Open the first visible answer on load (default false). */
  openFirst?: boolean;
  /** Read `?category` and `#q-…` on start and write them on change (default true). */
  syncUrl?: boolean;
}

const EMPTY_DATA: FaqData = { categories: [], questions: [], truncated: false };

let state: FaqState = {
  data: null,
  activeCategoryId: null,
  query: "",
  expandedIds: [],
  visible: [],
  resultsCount: 0,
  loading: false,
  error: null,
};
let expandOnlyOne = true;
let openFirst = false;
let syncUrl = true;
let seeded = false;
let started = false;
let generation = 0; // bumped by every load; a response from an older generation is dropped
const listeners = new Set<() => void>();

const isBrowser = (): boolean => typeof window !== "undefined";

function setState(patch: Partial<FaqState>): void {
  const next = { ...state, ...patch };
  next.visible = visibleQuestions(next.data, next.activeCategoryId, next.query);
  next.resultsCount = next.query.trim() ? next.visible.length : 0;
  state = next;
  for (const l of listeners) l();
}

function writeUrl(): void {
  if (!syncUrl || !isBrowser() || typeof history === "undefined") return;
  const url = new URL(location.href);
  if (state.activeCategoryId) url.searchParams.set("category", state.activeCategoryId);
  else url.searchParams.delete("category");
  const open = state.expandedIds.length === 1 ? state.visible.find((q) => q.id === state.expandedIds[0]) : undefined;
  url.hash = open ? anchorId(open) : "";
  try {
    history.replaceState(history.state, "", url.pathname + url.search + url.hash);
  } catch {
    /* a sandboxed frame may refuse — the view is right, only the address bar lags */
  }
}

/** `?category` and `#q-…` from the address bar → state: the anchor's question expands and its category is shown. */
function applyDeepLink(): void {
  if (!syncUrl || !isBrowser() || !state.data) return;
  const { categoryId, anchor } = parseDeepLink(location.search, location.hash);
  const patch: Partial<FaqState> = {};
  if (categoryId && state.data.categories.some((c) => c.id === categoryId)) patch.activeCategoryId = categoryId;
  const target = anchor ? findByAnchor(state.data.questions, anchor) : undefined;
  if (target) {
    patch.expandedIds = expandOnlyOne ? [target.id] : [...new Set([...state.expandedIds, target.id])];
    // The deep link decides the category: a question hidden by the active filter switches the filter to its own.
    const active = patch.activeCategoryId ?? state.activeCategoryId;
    if (active && active !== target.categoryId) patch.activeCategoryId = target.categoryId;
  }
  if (Object.keys(patch).length) setState(patch);
  if (target && typeof document !== "undefined") {
    requestAnimationFrame(() => document.getElementById(anchorId(target))?.scrollIntoView({ block: "start" }));
  }
}

function load(): void {
  const id = ++generation;
  setState({ loading: true, error: null });
  fetchFaq()
    .then((data) => {
      if (generation !== id) return; // superseded — drop it
      setState({ data, loading: false, expandedIds: openFirst ? firstId(data) : state.expandedIds });
      applyDeepLink();
    })
    .catch((e) => {
      if (generation !== id) return;
      // An empty data set with an error: the accordion shows the error line and a retry, not skeletons forever.
      setState({ data: state.data ?? EMPTY_DATA, loading: false, error: e instanceof Error ? e.message : String(e) });
    });
}

function firstId(data: FaqData): string[] {
  const first = visibleQuestions(data, state.activeCategoryId, state.query)[0];
  return first ? [first.id] : [];
}

function start(): void {
  if (started) return;
  started = true;
  if (state.data) applyDeepLink();
  else load();
}

/**
 * Hand the store its SSR data and options. Call from every island (useFaq does) — on the server each
 * call replaces the state (one request, one data set); in the browser the first call seeds and later
 * calls are no-ops, so hydration matches the server HTML.
 */
export function seedFaq(o: FaqStoreOptions = {}): void {
  if (o.expandOnlyOne !== undefined) expandOnlyOne = o.expandOnlyOne;
  if (o.openFirst !== undefined) openFirst = o.openFirst;
  if (o.syncUrl !== undefined) syncUrl = o.syncUrl;
  if (!o.initialData || (seeded && isBrowser())) return;
  seeded = true;
  const activeCategoryId =
    o.initialCategoryId && o.initialData.categories.some((c) => c.id === o.initialCategoryId) ? o.initialCategoryId : null;
  const data = o.initialData;
  state = { ...state, data, activeCategoryId, loading: false, error: null };
  state.visible = visibleQuestions(data, activeCategoryId, state.query);
  state.resultsCount = 0;
  state.expandedIds = openFirst ? firstId(data) : [];
  // No emit: seeding happens during render, before any subscriber exists.
}

export function getFaqState(): FaqState {
  return state;
}

/** The first browser subscriber starts the store: the deep link is applied, and unseeded data is fetched. */
export function subscribeFaq(listener: () => void): () => void {
  listeners.add(listener);
  if (isBrowser()) start();
  return () => listeners.delete(listener);
}

/** Show one category (null = all). Keeps the search query — the two compose. */
export function selectCategory(id: string | null): void {
  if (id === state.activeCategoryId) return;
  setState({ activeCategoryId: id });
  writeUrl();
}

/** Set the search query (debounce lives in the component). */
export function setQuery(q: string): void {
  if (q === state.query) return;
  setState({ query: q });
}

export function toggleQuestion(id: string): void {
  if (state.expandedIds.includes(id)) collapseQuestion(id);
  else expandQuestion(id);
}

export function expandQuestion(id: string): void {
  if (state.expandedIds.includes(id)) return;
  setState({ expandedIds: expandOnlyOne ? [id] : [...state.expandedIds, id] });
  writeUrl();
}

export function collapseQuestion(id: string): void {
  if (!state.expandedIds.includes(id)) return;
  setState({ expandedIds: state.expandedIds.filter((x) => x !== id) });
  writeUrl();
}

export function collapseAll(): void {
  if (!state.expandedIds.length) return;
  setState({ expandedIds: [] });
  writeUrl();
}

/** Re-run the load after an error. */
export function retry(): void {
  load();
}

SHA-256: 6f2ded9d900a9c3d9c72e4048c83055d3863e1e1c91b332d2b44993445faf8dc