← Files WixARCHIVED FILE

skills/wix-headless-templates/storefront/app/wix/storefront/shop-store.ts

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

↓ Download file

See the change to this file →

// The shop listing as a framework-free store — the logic behind useShop, usable from React
// (useShop wraps it with useSyncExternalStore), from a static page or Vue/Svelte (subscribe and
// render), or as the specification for a port. Same shape as cart-store.ts: state, actions,
// subscribe/getState, emit after every change.
//
// Category, sort, price/stock/search filters, option facets, and cursor paging are all applied
// by Wix across the whole catalog (searchCatalog), never to a page already in hand. A changed
// selection starts a fresh cursor chain; a late response from a superseded query is dropped. The
// previous page stays on screen (with `loading: true`) while the new one loads — skeletons only
// when there is nothing to show yet.
//
// Facet picks are kept PER FACET: choices inside one facet OR together, facets AND together
// ("Red or Blue, and Large"), exactly as Wix's own storefront filters; a linked choice's children
// ride along with it.
//
// SSR-friendly: seed with `initialProducts`/`initialCategories` and they render at once; `start()`
// then revalidates the first page to open the cursor chain, replacing the seed without a
// skeleton flash. `initialCategoryId` scopes the first query (a /category/[slug] page).
//
// URL state: sort, filters, facet choices (and a live category switch on /shop) are read from the
// query string by `start()` and written back with replaceState after every change — a filtered
// gallery is a link a shopper can share, reload, and step back to. Paging stays out of the URL.
//
// One store per mounted listing (a page can hold a shop and a featured rail): createShopStore(),
// not a singleton. The cart store is a singleton because the cart is one per visitor.
import { CATALOG_SORTS, fetchCategories, fetchFacetData, searchCatalog, type CatalogSort, type FacetSelection } from "./catalog";
import { expandFacetChoiceIds } from "./catalog-core";
import type { Category, Facet, PriceRange, ProductSummary } from "./types";

export const SORTS = CATALOG_SORTS;

export interface ShopFilters {
  minPrice?: number | string;
  maxPrice?: number | string;
  inStockOnly?: boolean;
  /** Name search, max 100 chars. */
  search?: string;
}

export interface ShopStoreOptions {
  initialProducts?: ProductSummary[];
  initialCategories?: Category[];
  /** Scope the listing to one category from the first render (a /category/[slug] page). */
  initialCategoryId?: string | null;
  pageSize?: number;
  /** Mirror the selection into the query string (default true; off for a rail that isn't the page's subject). */
  syncUrl?: boolean;
}

/** Everything a listing surface renders from. Read it with getState() or through a subscription. */
export interface ShopState {
  /**
   * null while the FIRST load is in flight — render skeletons. During a later selection change the
   * previous page stays here with `loading: true` (dim it, don't blank it).
   */
  products: ProductSummary[] | null;
  /** Matching products across the whole catalog for the current selection; null until known. */
  total: number | null;
  categories: Category[];
  /** null = "all products". */
  activeCategoryId: string | null;
  sort: CatalogSort;
  filters: ShopFilters;
  /** The filterable customizations of the current scope (Color, Size, a choice modifier…), from the catalog itself. */
  facets: Facet[];
  /** Lowest and highest product price in the scope — the price slider's bounds; null until known or when equal. */
  priceRange: PriceRange | null;
  /** Selected facet choice ids (every facet together) — what the chips and the panel's pressed state read. */
  selectedChoiceIds: string[];
  /** True when any filter or facet is active. */
  hasActiveFilters: boolean;
  /** A query is in flight for the current selection (the products shown may be the previous page's). */
  loading: boolean;
  error: string | null;
  hasMore: boolean;
  loadingMore: boolean;
}

export interface ShopStore {
  getState(): ShopState;
  subscribe(listener: () => void): () => void;
  /** Adopt the URL, fetch categories/facets, open the cursor chain. Call once when mounted (a browser). */
  start(): void;
  /** Stop reacting; drop late responses. */
  stop(): void;
  setActiveCategoryId(id: string | null): void;
  setSort(sort: CatalogSort): void;
  setFilters(filters: ShopFilters): void;
  /** Select / deselect a facet choice (the facet is found from the loaded facets). */
  toggleChoice(choiceId: string): void;
  /** Clears price/stock/search filters and facet selections (keeps category and sort). */
  clearFilters(): void;
  retry(): void;
  loadMore(): Promise<void>;
}

/** A pick remembers its facet so the query can group it before the facets are loaded (a shared link). */
interface SelectedChoice {
  kind: Facet["kind"];
  facetId: string;
  choiceId: string;
}

const URL_KEYS = { sort: "sort", min: "min", max: "max", stock: "stock", q: "q", choice: "choice", category: "category" } as const;

// choice=<kind>:<facetId>:<choiceId>; a bare choice id (an older link) counts as an option pick.
const encodeChoice = (s: SelectedChoice): string => `${s.kind}:${s.facetId}:${s.choiceId}`;
function decodeChoice(token: string): SelectedChoice | null {
  const parts = token.split(":");
  if (parts.length === 3 && (parts[0] === "option" || parts[0] === "modifier") && parts[2]) return { kind: parts[0], facetId: parts[1], choiceId: parts[2] };
  if (parts.length === 1 && parts[0]) return { kind: "option", facetId: "", choiceId: parts[0] };
  return null;
}

function readUrlState(): { sort?: CatalogSort; filters: ShopFilters; selected: SelectedChoice[]; categoryId?: string | null } | null {
  if (typeof window === "undefined") return null;
  const p = new URLSearchParams(window.location.search);
  const sort = p.get(URL_KEYS.sort);
  const filters: ShopFilters = {};
  if (p.get(URL_KEYS.min)) filters.minPrice = p.get(URL_KEYS.min)!;
  if (p.get(URL_KEYS.max)) filters.maxPrice = p.get(URL_KEYS.max)!;
  if (p.get(URL_KEYS.stock) === "1") filters.inStockOnly = true;
  if (p.get(URL_KEYS.q)) filters.search = p.get(URL_KEYS.q)!;
  return {
    sort: sort && sort in CATALOG_SORTS ? (sort as CatalogSort) : undefined,
    filters,
    selected: p.getAll(URL_KEYS.choice).map(decodeChoice).filter((s): s is SelectedChoice => s !== null),
    categoryId: p.has(URL_KEYS.category) ? p.get(URL_KEYS.category) : undefined,
  };
}

function writeUrlState(s: { sort: CatalogSort; filters: ShopFilters; selected: SelectedChoice[]; activeCategoryId: string | null }, initialCategoryId: string | null): void {
  if (typeof window === "undefined") return;
  const p = new URLSearchParams(window.location.search);
  for (const k of Object.values(URL_KEYS)) p.delete(k);
  if (s.sort !== "featured") p.set(URL_KEYS.sort, s.sort);
  if (s.filters.minPrice != null && s.filters.minPrice !== "") p.set(URL_KEYS.min, String(s.filters.minPrice));
  if (s.filters.maxPrice != null && s.filters.maxPrice !== "") p.set(URL_KEYS.max, String(s.filters.maxPrice));
  if (s.filters.inStockOnly) p.set(URL_KEYS.stock, "1");
  if (s.filters.search?.trim()) p.set(URL_KEYS.q, s.filters.search.trim());
  for (const c of s.selected) p.append(URL_KEYS.choice, encodeChoice(c));
  if (s.activeCategoryId !== initialCategoryId) p.set(URL_KEYS.category, s.activeCategoryId ?? "");
  const qs = p.toString();
  const next = `${window.location.pathname}${qs ? `?${qs}` : ""}${window.location.hash}`;
  if (next !== `${window.location.pathname}${window.location.search}${window.location.hash}`) window.history.replaceState(window.history.state, "", next);
}

export function createShopStore({ initialProducts, initialCategories, initialCategoryId = null, pageSize = 24, syncUrl = true }: ShopStoreOptions = {}): ShopStore {
  // selection
  let activeCategoryId: string | null = initialCategoryId;
  let sort: CatalogSort = "featured";
  let filters: ShopFilters = {};
  let selected: SelectedChoice[] = [];
  let attempt = 0;
  // data
  let categories: Category[] = initialCategories ?? [];
  let facets: Facet[] = [];
  let priceRange: PriceRange | null = null;
  // the current page; `key` is the selection it answers to (null = the SSR seed, no query yet)
  let pageKey: string | null = null;
  let products: ProductSummary[] | null = initialProducts ?? null;
  let total: number | null = null;
  let cursor: string | null = null;
  let hasMore = false;
  let error: string | null = null;
  let loadingMore = false;
  // control
  let started = false;
  let generation = 0;
  let pendingMore: object | null = null;
  const listeners = new Set<() => void>();

  // The picks grouped per facet, each with its linked children (known once the facets loaded),
  // in a stable order — the query and its key are built from this.
  function facetSelections(): FacetSelection[] {
    const groups = new Map<string, FacetSelection>();
    for (const s of selected) {
      const key = `${s.kind}:${s.facetId}`;
      const g = groups.get(key) ?? { id: s.facetId, kind: s.kind, choiceIds: [] };
      for (const cid of expandFacetChoiceIds(facets.find((f) => f.id === s.facetId), [s.choiceId])) if (!g.choiceIds.includes(cid)) g.choiceIds.push(cid);
      groups.set(key, g);
    }
    return [...groups.values()]
      .map((g) => ({ ...g, choiceIds: [...g.choiceIds].sort() }))
      .sort((a, b) => `${a.kind}:${a.id}`.localeCompare(`${b.kind}:${b.id}`));
  }

  const selectionKey = () =>
    JSON.stringify([pageSize, activeCategoryId, sort, filters.minPrice ?? null, filters.maxPrice ?? null, !!filters.inStockOnly, filters.search ?? "", facetSelections(), attempt]);

  let snapshot: ShopState | null = null;
  const emit = () => {
    snapshot = null;
    for (const fn of listeners) fn();
  };

  function getState(): ShopState {
    if (snapshot) return snapshot;
    const key = selectionKey();
    const current = pageKey === key;
    const hasActiveFilters =
      selected.length > 0 ||
      !!filters.inStockOnly ||
      (filters.minPrice != null && filters.minPrice !== "") ||
      (filters.maxPrice != null && filters.maxPrice !== "") ||
      !!(filters.search && filters.search.trim());
    snapshot = {
      // the seed or the previous page stays visible while a query runs; null only before anything loaded
      products,
      total: current ? total : null,
      categories,
      activeCategoryId,
      sort,
      filters,
      facets,
      priceRange,
      selectedChoiceIds: selected.map((s) => s.choiceId),
      hasActiveFilters,
      // Content on screen is not "loading" in the skeleton sense — the flag says a query is in flight.
      loading: !current || products === null,
      error: current ? error : null,
      hasMore: current && hasMore && !!cursor,
      loadingMore: current && loadingMore,
    };
    return snapshot;
  }

  // Facets follow the category scope (a Size facet in "Donuts" is meaningless).
  let facetScope: string | null | undefined;
  function loadFacets(): void {
    if (facetScope === activeCategoryId) return;
    facetScope = activeCategoryId;
    const scope = activeCategoryId;
    fetchFacetData({ categoryId: scope }).then((d) => {
      if (!started || scope !== activeCategoryId) return;
      facets = d.facets;
      priceRange = d.priceRange;
      // A pick from the URL may have gained linked children now that its facet is known.
      if (selectionKey() !== pageKey) query();
      else emit();
    });
  }

  // Run the query for the current selection. Keeps the seed (or the previous page) on screen while
  // it runs; skeletons only when there is nothing yet.
  function query(): void {
    if (!started) return;
    const key = selectionKey();
    const id = ++generation;
    pendingMore = null;
    pageKey = key; total = null; cursor = null; hasMore = false; error = null; loadingMore = false;
    emit();
    const [limit, categoryId, selectedSort, minPrice, maxPrice, inStockOnly, search, selections] = JSON.parse(key);
    searchCatalog({ limit, categoryId, sort: selectedSort, minPrice, maxPrice, inStockOnly, search, facetSelections: selections })
      .then((res) => {
        if (generation !== id || selectionKey() !== key) return; // superseded — drop it
        products = res.products; total = res.total; cursor = res.nextCursor; hasMore = res.hasMore; error = null;
        emit();
      })
      .catch((e) => {
        if (generation !== id || selectionKey() !== key) return;
        products = []; total = null; cursor = null; hasMore = false;
        error = e instanceof Error ? e.message : "Couldn't load products.";
        emit();
      });
  }

  function changed(): void {
    if (syncUrl) writeUrlState({ sort, filters, selected, activeCategoryId }, initialCategoryId);
    loadFacets();
    // A new filters object with the same values keeps the page (same selection, same query).
    if (selectionKey() === pageKey) emit();
    else query();
  }

  return {
    getState,
    subscribe(fn) {
      listeners.add(fn);
      return () => listeners.delete(fn);
    },
    start() {
      if (started) return;
      started = true;
      // Adopt the URL's selection once (the server rendered the defaults).
      const u = syncUrl ? readUrlState() : null;
      if (u) {
        if (u.sort) sort = u.sort;
        if (Object.keys(u.filters).length) filters = u.filters;
        if (u.selected.length) selected = u.selected;
        if (u.categoryId !== undefined && u.categoryId !== initialCategoryId) activeCategoryId = u.categoryId || null;
      }
      if (!initialCategories) {
        fetchCategories().then((list) => { if (started) { categories = list; emit(); } }).catch(() => { if (started) { categories = []; emit(); } });
      }
      loadFacets();
      query();
    },
    stop() {
      started = false;
      generation++;
    },
    setActiveCategoryId(id) { if (id === activeCategoryId) return; activeCategoryId = id; changed(); },
    setSort(next) { if (next === sort) return; sort = next; changed(); },
    setFilters(next) { filters = next; changed(); },
    toggleChoice(choiceId) {
      const existing = selected.find((s) => s.choiceId === choiceId);
      if (existing) selected = selected.filter((s) => s !== existing);
      else {
        const facet = facets.find((f) => f.choices.some((c) => c.id === choiceId));
        if (!facet) return; // not a choice of this scope's facets
        selected = [...selected, { kind: facet.kind, facetId: facet.id, choiceId }];
      }
      changed();
    },
    clearFilters() { filters = {}; selected = []; changed(); },
    retry() { attempt++; changed(); },
    async loadMore() {
      const key = selectionKey();
      if (pageKey !== key || !cursor || !hasMore || pendingMore) return;
      const request = { generation, key };
      pendingMore = request; // blocks repeated clicks before a re-render
      const isCurrent = () => generation === request.generation && selectionKey() === key;
      loadingMore = true; error = null; emit();
      try {
        const res = await searchCatalog({ limit: pageSize, cursor });
        if (isCurrent()) {
          const seen = new Set((products ?? []).map((p) => p.id));
          products = [...(products ?? []), ...res.products.filter((p) => (seen.has(p.id) ? false : (seen.add(p.id), true)))];
          cursor = res.nextCursor;
          hasMore = res.hasMore;
        }
      } catch (e) {
        if (isCurrent()) error = e instanceof Error ? e.message : "Couldn't load more products.";
      } finally {
        if (pendingMore === request) pendingMore = null;
        if (isCurrent()) { loadingMore = false; emit(); }
      }
    },
  };
}

SHA-256: cfbf2aeb92a6ffd636bb7d5219c2dc44126d97c2c9f195531ed937c38e36135a