← Files WixARCHIVED FILE

skills/wix-headless/references/astro/templates/stores/ProductPurchase.tsx

16.8 KB · Oct 3, 2026 · 06:02 UTC

↓ Download file

import { useState, useMemo, useEffect } from "react";
import { currentCart } from "@wix/ecom";
import AddToCartButton from "./AddToCartButton";
import BackInStockForm from "./BackInStockForm";

interface Choice { choiceId?: string | null; name?: string | null; key?: string | null; }
interface Option { _id?: string | null; name?: string | null; choicesSettings?: { choices?: Choice[]; }; }
// Modifiers: customization choices without separate variants/inventory/price.
interface FreeTextSettings { title?: string | null; key?: string | null; maxLength?: number | null; }
interface Modifier {
  // SDK convention: outer entity IDs are `_id` (same as Variant/Option/Product).
  // REST returns `id`; @wix/stores remaps to `_id` in the SDK response.
  _id?: string | null;
  name?: string | null;
  key?: string | null;
  mandatory?: boolean;
  modifierRenderType?: string | null;   // "TEXT_CHOICES" | "SWATCH_CHOICES" | "FREE_TEXT"
  choicesSettings?: { choices?: Choice[] };
  freeTextSettings?: FreeTextSettings;
}
interface VariantChoice { optionChoiceIds?: { optionId?: string; choiceId?: string; }; }
interface Variant {
  _id?: string | null;
  choices?: VariantChoice[];
  price?: { actual?: { amount?: string; formattedAmount?: string; }; };
  // productsV3 also exposes a per-variant inventoryStatus, but it's a STALE
  // cached flag — a variant with live quantity 0 can still report
  // `inStock: true`. Only used as a last-resort fallback when the
  // inventoryByVariant prop is empty. The authoritative signal is
  // inventoryItemsV3 — see the page-level query in PRODUCT_PAGES.md.
  inventoryStatus?: { inStock?: boolean; preorderEnabled?: boolean };
  // Future-proofing: if a future wiring passes real quantities in props
  // (e.g. via @wix/inventory join by variantId), maxQuantity honors them.
  stock?: { quantity?: number | null; inStock?: boolean; };
  inventoryItem?: { quantity?: number | null; inStock?: boolean; trackQuantity?: boolean };
}
interface Stock { trackInventory?: boolean; inventoryStatus?: string; quantity?: number | null; }
interface VariantStock { quantity: number; trackQuantity: boolean; preorderEnabled: boolean }

// Single `product` prop (the full productsV3 object) mirrors ProductCard's
// contract so both components take the same shape. Keeps `[slug].astro`
// mount sites greppable: <ProductPurchase client:load product={product}
// inventoryByVariant={inventoryByVariant} />.
interface ProductLike {
  _id?: string | null;
  name?: string | null;
  actualPriceRange?: { minValue?: { currency?: string | null } | null } | null;
  options?: Option[] | null;
  modifiers?: Modifier[] | null;
  variantsInfo?: { variants?: Variant[] } | null;
  stock?: Stock | null;
}
interface ProductPurchaseProps {
  product: ProductLike;
  // Live per-variant stock from inventoryItemsV3, keyed by variantId.
  // AUTHORITATIVE source for OOS gating. productsV3's cached inStock flag is
  // not reliable — see PRODUCT_PAGES.md anti-patterns table.
  inventoryByVariant?: Record<string, VariantStock>;
  // SSR probe result from getBackInStockEnabled() — gates the OOS subscribe
  // form. When false, OOS branches render only the existing "Out of Stock"
  // / "Sold Out" stock-status copy. See references/BACK_IN_STOCK.md.
  backInStockEnabled?: boolean;
  // Numeric product price for the back-in-stock subscribe call. The Wix
  // endpoint validates `itemDetails.price >= 0` and rejects formatted
  // strings; pass a number, not "$695".
  priceAmount?: number;
}

export default function ProductPurchase({
  product,
  inventoryByVariant = {},
  backInStockEnabled = false,
  priceAmount,
}: ProductPurchaseProps) {
  const productId = product._id ?? "";
  const productName = product.name ?? "";
  const currency = product.actualPriceRange?.minValue?.currency ?? undefined;
  const options = product.options ?? [];
  const modifiers = product.modifiers ?? [];
  const variantsInfo = product.variantsInfo ?? { variants: [] };
  const stock = product.stock ?? undefined;

  const variants = variantsInfo.variants ?? [];
  const hasMeaningfulOptions = options.length > 0 && options.some(
    (opt) => (opt.choicesSettings?.choices?.length ?? 0) > 1,
  );
  const [selections, setSelections] = useState<Record<string, string>>({});
  const handleSelect = (optionId: string, choiceId: string) =>
    setSelections((prev) => ({ ...prev, [optionId]: choiceId }));
  const allSelected = hasMeaningfulOptions && options.every((opt) => opt._id && selections[opt._id]);

  // Modifier state. Two parallel maps keyed by modifier._id, flattened below
  // into the shapes cart expects.
  const [modifierChoice, setModifierChoice] = useState<Record<string, string>>({});
  const [modifierText, setModifierText] = useState<Record<string, string>>({});
  const handleModifierChoice = (id: string, key: string) => setModifierChoice((p) => ({ ...p, [id]: key }));
  const handleModifierText = (id: string, v: string) => setModifierText((p) => ({ ...p, [id]: v }));

  const mandatoryModifiersSatisfied = useMemo(() => modifiers.every((m) => {
    if (!m.mandatory) return true;
    const id = m._id ?? "";
    if (m.modifierRenderType === "FREE_TEXT") return (modifierText[id] ?? "").trim().length > 0;
    return !!modifierChoice[id];
  }), [modifiers, modifierChoice, modifierText]);

  // Flatten to catalogReference shapes:
  //   options          → { [modifier.key]: choice.key }    for TEXT_CHOICES / SWATCH_CHOICES
  //   customTextFields → { [freeTextSettings.key]: text }  for FREE_TEXT
  const catalogModifierChoices = useMemo(() => {
    const out: Record<string, string> = {};
    for (const m of modifiers) {
      if (!m.key || !m._id || m.modifierRenderType === "FREE_TEXT") continue;
      const c = modifierChoice[m._id];
      if (c) out[m.key] = c;
    }
    return out;
  }, [modifiers, modifierChoice]);
  const catalogCustomTextFields = useMemo(() => {
    const out: Record<string, string> = {};
    for (const m of modifiers) {
      if (!m._id || m.modifierRenderType !== "FREE_TEXT") continue;
      const k = m.freeTextSettings?.key;
      const v = (modifierText[m._id] ?? "").trim();
      if (k && v) out[k] = v;
    }
    return out;
  }, [modifiers, modifierText]);

  const resolvedVariant = useMemo(() => {
    if (!hasMeaningfulOptions) return variants[0] ?? null;
    if (!allSelected) return null;
    return variants.find((v) =>
      (v.choices ?? []).every(
        (c) => c.optionChoiceIds?.optionId &&
          selections[c.optionChoiceIds.optionId] === c.optionChoiceIds.choiceId,
      ),
    ) ?? null;
  }, [hasMeaningfulOptions, allSelected, selections, variants]);

  const variantLabel = useMemo(() => {
    if (!hasMeaningfulOptions) return undefined;
    return options.map((opt) => {
      const choiceId = opt._id ? selections[opt._id] : undefined;
      const choice = opt.choicesSettings?.choices?.find((c) => c.choiceId === choiceId);
      return choice?.name ? `${opt.name}: ${choice.name}` : null;
    }).filter(Boolean).join(", ") || undefined;
  }, [hasMeaningfulOptions, options, selections]);

  const variantPrice = resolvedVariant?.price?.actual?.amount
    ? Number(resolvedVariant.price.actual.amount)
    : undefined;

  // Binary OOS gate:
  //  - Source of truth = inventoryByVariant (from inventoryItemsV3).
  //  - Fallback = productsV3's variantsInfo[].inventoryStatus.inStock
  //    (stale, don't trust it unless the live map is empty).
  //  - productOutOfStock short-circuits the whole render (applies to the
  //    entire product e.g. unpublished or fully sold out).
  //  - variantOutOfStock keeps option pills visible so the shopper can
  //    pick a different finish; hides the stepper + button.
  const isVariantSoldOut = (variant: Variant | null | undefined): boolean => {
    if (!variant?._id) return false;
    const live = inventoryByVariant[variant._id];
    if (live) {
      if (!live.trackQuantity) return false;
      if (live.preorderEnabled) return false;
      return live.quantity <= 0;
    }
    return (
      variant.inventoryStatus?.inStock === false &&
      variant.inventoryStatus?.preorderEnabled !== true
    );
  };
  const productOutOfStock = stock?.inventoryStatus === "OUT_OF_STOCK";
  const variantOutOfStock = isVariantSoldOut(resolvedVariant);
  const isOutOfStock = productOutOfStock;
  // maxQuantity caps the stepper. Primary source: inventoryByVariant (live
  // from inventoryItemsV3). Fallbacks cover partial wirings. The cart
  // backend still enforces stock on add-to-cart — this clamp just avoids
  // letting shoppers build a quantity they can't actually buy.
  const maxQuantity = useMemo(() => {
    const live = resolvedVariant?._id ? inventoryByVariant[resolvedVariant._id] : undefined;
    if (live) {
      if (!live.trackQuantity) return 99;
      if (live.preorderEnabled) return 99;
      return Math.max(1, live.quantity);
    }
    if (stock?.trackInventory === false) return 99;
    if (resolvedVariant?.stock?.quantity != null) return resolvedVariant.stock.quantity;
    if (resolvedVariant?.inventoryItem?.quantity != null) return resolvedVariant.inventoryItem.quantity;
    if (stock?.quantity != null) return stock.quantity;
    return 99;
  }, [inventoryByVariant, stock, resolvedVariant]);
  // Count how much of each variant is already in the cart so the stepper cap
  // reflects "stock minus already-in-cart" instead of raw stock. Wix enforces
  // the same rule at add-to-cart time — this is just so the UI doesn't let
  // the shopper think they can add "max" after already adding "max".
  const [qtyInCartByVariant, setQtyInCartByVariant] = useState<Record<string, number>>({});
  useEffect(() => {
    let cancelled = false;
    const syncCart = async () => {
      try {
        const cart = await currentCart.getCurrentCart();
        if (cancelled) return;
        const tally: Record<string, number> = {};
        for (const item of cart.lineItems ?? []) {
          const ref = item.catalogReference as any;
          if (ref?.catalogItemId !== productId) continue;
          const vId = ref?.options?.variantId as string | undefined;
          if (!vId) continue;
          tally[vId] = (tally[vId] ?? 0) + (item.quantity ?? 0);
        }
        setQtyInCartByVariant(tally);
      } catch { if (!cancelled) setQtyInCartByVariant({}); }
    };
    syncCart();
    const handler = () => syncCart();
    window.addEventListener("cart-updated", handler);
    return () => { cancelled = true; window.removeEventListener("cart-updated", handler); };
  }, [productId]);

  const qtyInCartForResolved = resolvedVariant?._id ? qtyInCartByVariant[resolvedVariant._id] ?? 0 : 0;
  const effectiveMax = Math.max(0, maxQuantity - qtyInCartForResolved);
  const alreadyMaxedInCart = qtyInCartForResolved > 0 && effectiveMax === 0;

  const [quantity, setQuantity] = useState(1);
  // When max shrinks (variant switch, or cart sync), clamp the current selection.
  useEffect(() => { setQuantity((q) => Math.max(1, Math.min(q, Math.max(1, effectiveMax)))); }, [effectiveMax]);

  const quantitySelector = !isOutOfStock && (
    <div className="quantity-selector">
      <button className="quantity-btn" onClick={() => setQuantity((q) => Math.max(1, q - 1))} disabled={quantity <= 1}>−</button>
      <span className="quantity-value">{quantity}</span>
      <button className="quantity-btn" onClick={() => setQuantity((q) => Math.min(effectiveMax, q + 1))} disabled={quantity >= effectiveMax}>+</button>
    </div>
  );

  const inCartNote = qtyInCartForResolved > 0 && (
    <p className="stock-status" aria-live="polite">{qtyInCartForResolved} already in cart</p>
  );

  // Modifier selectors — rendered between options and quantity in both branches.
  const modifierSelectors = modifiers.length > 0 && (
    <>
      {modifiers.map((m) => {
        const id = m._id ?? "";
        const req = m.mandatory === true;
        if (m.modifierRenderType === "FREE_TEXT") {
          return (
            <div key={id} className="option-group">
              <div className="option-label">{m.name}{req && <span aria-hidden="true"> *</span>}</div>
              <textarea className="modifier-text"
                value={modifierText[id] ?? ""}
                onChange={(e) => handleModifierText(id, e.target.value)}
                placeholder={m.freeTextSettings?.title ?? ""}
                maxLength={m.freeTextSettings?.maxLength ?? undefined}
                rows={2} aria-label={m.name ?? ""} aria-required={req || undefined}
              />
            </div>
          );
        }
        const choices = m.choicesSettings?.choices ?? [];
        return (
          <div key={id} className="option-group">
            <div className="option-label">{m.name}{req && <span aria-hidden="true"> *</span>}</div>
            <div className="option-choices">
              {choices.map((c) => {
                const k = c.key ?? c.choiceId ?? "";
                const sel = modifierChoice[id] === k;
                return (
                  <button key={k} type="button" onClick={() => handleModifierChoice(id, k)}
                    className={`option-pill${sel ? " selected" : ""}`} aria-pressed={sel}>
                    {c.name}
                  </button>
                );
              })}
            </div>
          </div>
        );
      })}
    </>
  );

  // Render the back-in-stock subscribe form in OOS branches when the
  // dashboard probe is on. Variant-aware: pass the resolved variantId when
  // a single variant is OOS, omit it when the whole product is OOS. The
  // form sends only required fields per the bare-fields rule
  // (BACK_IN_STOCK.md) — no itemUrl, no image.
  const renderBackInStockForm = (variantIdForForm?: string) =>
    backInStockEnabled ? (
      <BackInStockForm
        productId={productId}
        variantId={variantIdForForm}
        productName={productName}
        productPrice={variantPrice ?? priceAmount ?? 0}
        variantLabel={variantIdForForm ? variantLabel : undefined}
      />
    ) : null;

  if (isOutOfStock) {
    return (
      <div className="purchase-area">
        <p className="stock-status">Out of Stock</p>
        {renderBackInStockForm()}
      </div>
    );
  }

  if (!hasMeaningfulOptions) {
    const singleVariant = variants[0];
    const singleVariantOOS = isVariantSoldOut(singleVariant);
    if (singleVariantOOS) {
      return (
        <div className="purchase-area">
          <p className="stock-status">Sold Out</p>
          {renderBackInStockForm(singleVariant?._id ?? undefined)}
        </div>
      );
    }
    return (
      <div className="purchase-area">
        {modifierSelectors}
        {inCartNote}
        {alreadyMaxedInCart ? (
          <p className="stock-status">Maximum quantity already in cart</p>
        ) : (
          <>
            {quantitySelector}
            <AddToCartButton
              productId={productId} productName={productName}
              price={variantPrice} currency={currency}
              variantId={singleVariant?._id ?? undefined}
              quantity={quantity}
              modifierChoices={catalogModifierChoices}
              customTextFields={catalogCustomTextFields}
              disabled={!mandatoryModifiersSatisfied}
            />
          </>
        )}
      </div>
    );
  }

  return (
    <div className="purchase-area">
      {options.map((option) => {
        const choices = option.choicesSettings?.choices ?? [];
        const optionId = option._id ?? "";
        return (
          <div key={optionId} className="option-group">
            <div className="option-label">{option.name}</div>
            <div className="option-choices">
              {choices.map((choice) => {
                const choiceId = choice.choiceId ?? "";
                const isSelected = selections[optionId] === choiceId;
                return (
                  <button key={choiceId} onClick={() => handleSelect(optionId, choiceId)}
                    className={`option-pill${isSelected ? " selected" : ""}`}>
                    {choice.name}
                  </button>
                );
              })}
            </div>
          </div>
        );
      })}
      {modifierSelectors}
      {variantOutOfStock ? (
        <>
          {renderBackInStockForm(resolvedVariant?._id ?? undefined)}
          <p className="stock-status back-in-stock-alt">
            Or try another option above.
          </p>
        </>
      ) : alreadyMaxedInCart ? (
        <>
          {inCartNote}
          <p className="stock-status">Maximum quantity already in cart</p>
        </>
      ) : (
        <>
          {inCartNote}
          {quantitySelector}
          <AddToCartButton
            productId={productId} productName={productName}
            price={variantPrice} currency={currency}
            variantId={resolvedVariant?._id ?? undefined}
            variantName={variantLabel} quantity={quantity}
            modifierChoices={catalogModifierChoices}
            customTextFields={catalogCustomTextFields}
            disabled={!resolvedVariant || !mandatoryModifiersSatisfied}
          />
        </>
      )}
    </div>
  );
}

SHA-256: 1962103ac24e0496c62af253b32c2ab4c7e3ec63db7f758affeea289691d5468