← Files WixARCHIVED FILE

skills/wix-headless-templates/storefront/project/src/wix/storefront/product-detail-store.ts

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

↓ Download file

See the change to this file →

// Product detail as a framework-free store — the logic behind useProductDetail, usable from React
// (useProductDetail wraps it), from a static page's PDP and quick-add picker, from Vue/Svelte, or as
// the specification for a port. Option selection → variant resolution → add-to-cart, plus modifier
// inputs, the plan picker for a subscription product, the live inventory (stock left, purchase
// cap, pre-order message) and "notify me" for a sold-out item. This is where the "adds variants[0]
// regardless of the buyer's choice" class of bugs comes from — always drive a purchase surface
// through this store, never resolve variants by hand.
//
// Selections start EMPTY (a buyer who never noticed a pre-picked default buys the wrong colour).
// Pass the server-fetched ProductDetail as `initial` (SSR) or a `slug` (the store fetches on start).
// One store per product surface: createProductDetailStore(), not a singleton.
import { fetchBackInStockEnabled, fetchInventory, fetchProductBySlug, requestBackInStock, resolveVariant } from "./catalog";
import { choiceAvailability, maxPurchasable, mergeInventory } from "./catalog-core";
import { addLine } from "./cart-store";
import type { OptionChoice, ProductDetail, ProductOption, ProductVariant, SubscriptionPlan } from "./types";

export interface ProductDetailStoreOptions {
  initial?: ProductDetail | null;
  slug?: string;
}

/** The buyer's choice for "buy once" on a product that also sells plans. */
export const ONE_TIME_PLAN = "one-time";

export interface OptionGroupView extends ProductOption {
  /** Each choice judged against the OTHER selections: `inStock` = buyable with them, `exists` = some variant has that combination. */
  choices: (OptionChoice & { selected: boolean; exists: boolean })[];
}

export interface PlanView extends SubscriptionPlan {
  /** This variant's price on the plan, formatted; "" until every option is picked. */
  price: string;
  selected: boolean;
}

export interface ProductDetailState {
  /** null while loading (or when the slug doesn't resolve — check notFound). */
  product: ProductDetail | null;
  notFound: boolean;
  /** Option groups with per-choice `selected` / `inStock` / `exists`, ready to render as pills/swatches. */
  optionGroups: OptionGroupView[];
  /** Modifier inputs keyed by modifier key. */
  modifierValues: Record<string, string>;
  /** The resolved variant; null while the selection is incomplete. */
  variant: ProductVariant | null;
  /**
   * The image for the current selection: the last-picked choice's own image, else any picked
   * choice's, else the resolved variant's; null when none is linked (keep showing the gallery).
   * Render it as the main image so a colour pick changes the photo, as on Wix's own product page.
   */
  imageUrl: string | null;
  /** The RANGE ("€24.99 – €34.99"; "From €24.99" on a discounted range) until every option is picked, then the variant's price — or its price on the selected plan. */
  price: string;
  /** Struck "was" price — only once a variant is resolved (or for a single-price product), never beside a range or a plan price. */
  compareAtPrice: string | null;
  /** Price per unit ("€2.50 / 100 g") of the resolved variant, else the cheapest; null when not sold by unit. */
  pricePerUnit: string | null;
  /** The plans to pick from (plus "one-time" when the merchant allows it — render that from `product.allowOneTimePurchase`); [] for a one-time product. */
  plans: PlanView[];
  /** The picked plan id, ONE_TIME_PLAN, or null (nothing picked yet). */
  subscriptionPlanId: string | null;
  /** The resolved variant is out of stock but pre-orderable — label the action "Pre-order". */
  isPreorder: boolean;
  /** The merchant's pre-order note for the resolved variant ("Ships in 3 weeks"); null when none. */
  preorderMessage: string | null;
  /** Units left of the resolved variant when stock is counted; null when uncounted or unknown. Show "Only N left" when small. */
  remaining: number | null;
  /** The stepper's ceiling: the remaining units when counted, else Wix's 99999 cap. */
  maxQuantity: number;
  /** False until every option is selected and the resolved variant is in stock or pre-orderable (and a plan is picked when plans exist). */
  canAdd: boolean;
  /** Why the action is disabled — neutral guidance ("Choose Size"), not an error; null when addable. */
  blockedReason: string | null;
  quantity: number;
  adding: boolean;
  error: string | null;
  /** The item is sold out (not pre-orderable) and the merchant collects back-in-stock requests — offer an email field. */
  canNotify: boolean;
  notifying: boolean;
  /** The outcome of the last notify(): "created", "already-subscribed", or null. */
  notifyResult: "created" | "already-subscribed" | null;
  notifyError: string | null;
}

export interface ProductDetailStore {
  getState(): ProductDetailState;
  subscribe(listener: () => void): () => void;
  /** Fetch by slug when no `initial` was given; load the live inventory. Call once when mounted. */
  start(): void;
  stop(): void;
  selectOption(optionId: string, choiceId: string): void;
  setModifier(key: string, value: string): void;
  /** Clamped to [1, maxQuantity]. */
  setQuantity(n: number): void;
  /** Pick a plan by id, or ONE_TIME_PLAN. */
  selectPlan(planId: string): void;
  /** Adds the resolved variant to the cart (the cart store opens the drawer). Throws on refusal. */
  add(): Promise<void>;
  /** Ask to be emailed when the sold-out variant is back. Resolves with the outcome; records notifyError on failure. */
  notify(email: string): Promise<"created" | "already-subscribed">;
}

const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;

export function createProductDetailStore({ initial, slug }: ProductDetailStoreOptions): ProductDetailStore {
  let product: ProductDetail | null = initial ?? null;
  let notFound = false;
  let selections: Record<string, string> = {}; // optionId -> choiceId
  let lastPicked: string | null = null; // the option picked most recently — its choice's image wins
  let modifierValues: Record<string, string> = {};
  let subscriptionPlanId: string | null = null;
  let quantity = 1;
  let adding = false;
  let error: string | null = null;
  let notifyEnabled = false;
  let notifying = false;
  let notifyResult: ProductDetailState["notifyResult"] = null;
  let notifyError: string | null = null;
  let started = false;
  let enriched: string | null = null; // product id whose inventory was loaded
  const listeners = new Set<() => void>();
  let snapshot: ProductDetailState | null = null;
  const emit = () => { snapshot = null; for (const fn of listeners) fn(); };

  function getState(): ProductDetailState {
    if (snapshot) return snapshot;
    const variant = product ? resolveVariant(product, selections) : null;
    const availability = product ? choiceAvailability(product, selections) : {};
    const optionGroups: OptionGroupView[] = (product?.options ?? []).map((o) => ({
      ...o,
      choices: o.choices.map((c) => {
        const a = availability[o.id]?.[c.choiceId];
        return { ...c, selected: selections[o.id] === c.choiceId, inStock: a ? a.inStock : c.inStock, exists: a ? a.exists : true };
      }),
    }));
    const pickedImage = (optionId: string): string | null => {
      const o = product?.options.find((x) => x.id === optionId);
      return o?.choices.find((c) => c.choiceId === selections[optionId])?.imageUrl ?? null;
    };
    const pickedImages = (product?.options ?? []).map((o) => pickedImage(o.id)).filter((u): u is string => !!u);
    const imageUrl = (lastPicked ? pickedImage(lastPicked) : null) ?? pickedImages[pickedImages.length - 1] ?? variant?.imageUrl ?? null;
    const missingOptions = (product?.options ?? []).filter((o) => !selections[o.id]).map((o) => o.name);
    const selectionComplete = missingOptions.length === 0;
    const missingModifier = (product?.modifiers ?? []).find((m) => m.mandatory && (modifierValues[m.key] ?? "").trim().length === 0);
    const shortText = (product?.modifiers ?? []).find((m) => m.type === "text" && m.minChars && (modifierValues[m.key] ?? "").length > 0 && (modifierValues[m.key] ?? "").length < m.minChars);
    const plans = product?.subscriptions ?? [];
    const plan = subscriptionPlanId && subscriptionPlanId !== ONE_TIME_PLAN ? plans.find((p) => p.id === subscriptionPlanId) ?? null : null;
    const planMissing = plans.length > 0 && (!subscriptionPlanId || (subscriptionPlanId === ONE_TIME_PLAN && !product?.allowOneTimePurchase) || (subscriptionPlanId !== ONE_TIME_PLAN && !plan));
    // In stock OR pre-orderable counts as buyable; a preorder add carries preOrderRequested.
    const isPreorder = !!variant && !variant.inStock && variant.preorderEnabled;
    const available = !!variant && (variant.inStock || variant.preorderEnabled);
    const canAdd = !!product && available && !missingModifier && !shortText && !planMissing;
    // Wix's own precedence: unavailable > pre-order > sold out > the inputs still needed.
    const blockedReason: string | null = !product
      ? null
      : !selectionComplete ? `Choose ${missingOptions.join(" and ")}`
      : !variant ? "This combination isn't available"
      : !available ? "Out of stock"
      : missingModifier ? `Add ${missingModifier.title || missingModifier.name}`
      : shortText ? `${shortText.title || shortText.name} needs at least ${shortText.minChars} characters`
      : planMissing ? "Choose a plan"
      : null;
    // Before every option is picked, the range — never an empty price, never a lone struck minimum.
    const isRange = !!product && product.price !== product.maxPrice && !!product.maxPrice;
    const rangeDisplay = product ? (product.fromPrice ? `From ${product.price}` : isRange ? `${product.price} – ${product.maxPrice}` : product.price) : "";
    const planPrice = plan && variant ? variant.subscriptionPrices[plan.id] ?? "" : "";
    snapshot = {
      product, notFound, optionGroups, modifierValues, variant, imageUrl,
      price: planPrice || variant?.price || rangeDisplay,
      // a plan price is its own thing — never strike the one-time price beside it
      compareAtPrice: plan ? null : variant ? variant.compareAtPrice : isRange ? null : (product?.compareAtPrice ?? null),
      pricePerUnit: variant?.pricePerUnit ?? product?.pricePerUnit ?? null,
      plans: plans.map((p) => ({ ...p, price: variant?.subscriptionPrices[p.id] ?? "", selected: subscriptionPlanId === p.id })),
      subscriptionPlanId,
      isPreorder,
      preorderMessage: isPreorder ? variant?.preorderMessage ?? null : null,
      remaining: variant?.quantity ?? null,
      maxQuantity: maxPurchasable(variant),
      canAdd, blockedReason, quantity, adding, error,
      canNotify: !!product && !!variant && !available && notifyEnabled,
      notifying, notifyResult, notifyError,
    };
    return snapshot;
  }

  // The live inventory (stock left, pre-order allowance and message) refines the catalog's flags;
  // "notify me" only shows when the merchant collects requests. Both are non-blocking niceties.
  function enrich(): void {
    if (!product || enriched === product.id) return;
    const id = enriched = product.id;
    fetchInventory(id).then((inv) => {
      if (!started || !product || product.id !== id || !Object.keys(inv).length) return;
      product = { ...product, variants: mergeInventory(product.variants, inv) };
      emit();
    });
    if (product.variants.some((v) => !v.inStock && !v.preorderEnabled) || product.availability !== "IN_STOCK") {
      fetchBackInStockEnabled().then((on) => { if (started && on !== notifyEnabled) { notifyEnabled = on; emit(); } });
    }
  }

  return {
    getState,
    subscribe(fn) { listeners.add(fn); return () => listeners.delete(fn); },
    start() {
      if (started) return;
      started = true;
      if (product) { enrich(); return; }
      if (!slug) return;
      fetchProductBySlug(slug)
        .then((p) => { if (!started) return; product = p; notFound = p === null; emit(); enrich(); })
        .catch(() => { if (started) { notFound = true; emit(); } });
    },
    stop() { started = false; },
    selectOption(optionId, choiceId) {
      selections = { ...selections, [optionId]: choiceId };
      lastPicked = optionId;
      quantity = 1; // the ceiling belongs to the newly resolved variant
      notifyResult = null; notifyError = null;
      emit();
    },
    setModifier(key, value) { modifierValues = { ...modifierValues, [key]: value }; emit(); },
    setQuantity(n) {
      const max = getState().maxQuantity;
      quantity = Math.min(Math.max(1, Math.floor(Number(n) || 1)), max);
      emit();
    },
    selectPlan(planId) { subscriptionPlanId = planId; emit(); },
    async add() {
      const s = getState();
      if (!product || !s.variant) return;
      adding = true; error = null; emit();
      try {
        // The variant id AND every option's key/choice-key pair, as Wix's own storefront sends them.
        const optionChoices: Record<string, string> = {};
        for (const o of product.options) {
          const choice = o.choices.find((c) => c.choiceId === s.variant!.choiceIds[o.id]);
          if (o.key && choice?.key) optionChoices[o.key] = choice.key;
        }
        const modifierChoices: Record<string, string> = {};
        const customTextFields: Record<string, string> = {};
        for (const m of product.modifiers) {
          const value = modifierValues[m.key];
          if (!value) continue;
          if (m.type === "text") customTextFields[m.key] = value; else modifierChoices[m.key] = value;
        }
        const plan = subscriptionPlanId && subscriptionPlanId !== ONE_TIME_PLAN ? subscriptionPlanId : undefined;
        await addLine(product.id, s.variant.variantId, quantity, { optionChoices, modifierChoices, customTextFields, preorder: s.isPreorder, subscriptionOptionId: plan });
      } catch (e) {
        error = e instanceof Error ? e.message : String(e);
        throw e;
      } finally {
        adding = false; emit();
      }
    },
    async notify(email) {
      const s = getState();
      if (!product) throw new Error("No product.");
      if (!EMAIL.test(email.trim())) {
        notifyError = "Enter a valid email address.";
        emit();
        throw new Error(notifyError);
      }
      notifying = true; notifyError = null; notifyResult = null; emit();
      try {
        const result = await requestBackInStock(product.id, s.variant?.variantId ?? null, email.trim(), {
          name: product.name,
          price: String(s.variant?.priceAmount ?? product.variants[0]?.priceAmount ?? 0),
          imageUrl: product.imageUrl || undefined,
        });
        notifyResult = result;
        return result;
      } catch (e) {
        notifyError = e instanceof Error && e.message ? e.message : "Couldn't save your request. Please try again.";
        throw e;
      } finally {
        notifying = false; emit();
      }
    },
  };
}

SHA-256: b95e5a0da0b8b95e48f7c650d122635132a07d16b9c485587a3fceaa3daa247e