@wix/agent-skills 1.19.1 → 1.19.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/package.json +1 -1
  2. package/skills/wix-app/references/EDITOR_REACT_COMPONENT.md +1 -1
  3. package/skills/wix-app/references/editor-react-component/ACCESSIBILITY.md +7 -2
  4. package/skills/wix-app/references/editor-react-component/ANIMATED-COMPONENTS.md +46 -22
  5. package/skills/wix-app/references/editor-react-component/COMPONENT-CONTRACT.md +17 -4
  6. package/skills/wix-app/references/editor-react-component/FUNCTION-HANDLERS.md +71 -31
  7. package/skills/wix-app/references/editor-react-component/REACT-GUIDELINES.md +3 -4
  8. package/skills/wix-base44-connector/SKILL.md +46 -24
  9. package/skills/wix-headless/SKILL.md +1 -0
  10. package/skills/wix-headless/references/CAPABILITIES.md +7 -2
  11. package/skills/wix-headless/references/SDK_HANDOFF.md +2 -1
  12. package/skills/wix-headless/references/inline-recipes/experience-store.md +48 -0
  13. package/skills/wix-headless/references/inline-recipes/how-to-code-a-store.md +350 -114
  14. package/skills/wix-headless/references/inline-recipes/how-to-code-members-astro.md +1 -1
  15. package/skills/wix-headless/references/managed/CREATE.md +5 -0
  16. package/skills/wix-manage/SKILL.md +4 -4
  17. package/skills/wix-manage/references/bookings/create-and-update-booking-services.md +4 -1
  18. package/skills/wix-manage/references/bookings/create-appointment-service.md +3 -1
  19. package/skills/wix-manage/references/bookings/create-class-service.md +3 -1
  20. package/skills/wix-manage/references/bookings/create-course-service.md +4 -2
  21. package/skills/wix-manage/references/bookings/end-to-end-booking-flow.md +68 -3
  22. package/skills/wix-manage/references/ecommerce/pricing-promotions/ecom-pricing-flow-seasonal-promotion.md +1 -1
  23. package/skills/wix-manage/references/google-ads/manage-campaign-success-guide.md +108 -14
  24. package/skills/wix-manage/references/seo/manage-content-plan.md +1 -1
  25. package/skills/wix-manage/references/sites/upload-static-site.md +264 -34
  26. package/skills/wix-vibe-headless/references/storefront/INSTRUCTIONS.md +68 -26
  27. package/skills/wix-vibe-headless/references/storefront/app/components/CartDrawer.jsx +32 -14
  28. package/skills/wix-vibe-headless/references/storefront/app/context/CartContext.jsx +14 -3
  29. package/skills/wix-vibe-headless/references/storefront/app/hooks/useProductCard.js +41 -35
  30. package/skills/wix-vibe-headless/references/storefront/app/hooks/useProductDetail.js +77 -30
  31. package/skills/wix-vibe-headless/references/storefront/app/rest/wix-store-cart.js +31 -2
  32. package/skills/wix-vibe-headless/references/storefront/app/rest/wix-store-catalog.js +31 -6
@@ -3,6 +3,7 @@
3
3
  import { createContext, useContext, useState, useEffect, useCallback } from "react";
4
4
  import {
5
5
  getCurrentCart,
6
+ estimateCurrentCart,
6
7
  addToCart as apiAdd,
7
8
  removeFromCart as apiRemove,
8
9
  updateCartItemQuantity as apiQty,
@@ -12,7 +13,11 @@ import {
12
13
  const CartContext = createContext(null);
13
14
 
14
15
  export function CartProvider({ children }) {
15
- const [cart, setCart] = useState(null);
16
+ const [cart, setCartState] = useState(null);
17
+ // The calculated summary (after-discount subtotal, cart-level discount) — from the estimate, never
18
+ // summed in the client. Refreshed with every cart change; null while unknown or when the estimate
19
+ // fails, in which case surfaces fall back to cart.subtotal.
20
+ const [summary, setSummary] = useState(null);
16
21
  const [isOpen, setIsOpen] = useState(false);
17
22
  const [loading, setLoading] = useState(false);
18
23
  // The cart helpers throw on refusal with a buyer-readable reason — an empty cart, or line items
@@ -20,7 +25,13 @@ export function CartProvider({ children }) {
20
25
  // this the rejection is unhandled and the shopper sees the spinner stop with nothing said.
21
26
  const [error, setError] = useState(null);
22
27
 
23
- const refreshCart = useCallback(async () => setCart(await getCurrentCart()), []);
28
+ // Every cart write goes through here so the summary always describes the cart on screen.
29
+ const setCart = useCallback((next) => {
30
+ setCartState(next);
31
+ if (!next?.lineItems?.length) { setSummary(null); return; }
32
+ estimateCurrentCart().then((s) => setSummary(s)).catch(() => setSummary(null));
33
+ }, []);
34
+ const refreshCart = useCallback(async () => setCart(await getCurrentCart()), [setCart]);
24
35
  useEffect(() => {
25
36
  refreshCart();
26
37
  const onVisible = () => document.visibilityState === "visible" && refreshCart();
@@ -59,7 +70,7 @@ export function CartProvider({ children }) {
59
70
  run(async () => { window.location.href = await apiCheckout(); }, { reread: true });
60
71
 
61
72
  return (
62
- <CartContext.Provider value={{ cart, itemCount, isOpen, setIsOpen, loading, error, clearError: () => setError(null), addToCart, removeItem, updateQuantity, checkout, refreshCart }}>
73
+ <CartContext.Provider value={{ cart, summary, itemCount, isOpen, setIsOpen, loading, error, clearError: () => setError(null), addToCart, removeItem, updateQuantity, checkout, refreshCart }}>
63
74
  {children}
64
75
  </CartContext.Provider>
65
76
  );
@@ -1,22 +1,16 @@
1
1
  // Headless data layer for a product grid tile.
2
2
  // Normalises raw Wix product fields into render-agnostic structures so you can build
3
3
  // whatever card UI you want (image layout, badge style, price display, quick-add trigger)
4
- // without touching the badge-priority logic, price-range maths, or colour-dot extraction.
4
+ // without touching the badge logic, price rules, or colour-dot extraction.
5
5
  //
6
6
  // Usage:
7
- // const { isSoldOut, leftBadges, promoBadge, priceDisplay, compareAtDisplay,
8
- // colors, optionLabel, isQuickAddable, image, hoverImage } = useProductCard(product);
9
- // // then render however you want, call addToCart(product.id) from useCart() for quick-add.
7
+ // const { isSoldOut, leftBadges, ribbons, promoBadge, priceDisplay, compareAtDisplay,
8
+ // colors, optionLabel, isQuickAddable, directAddVariantId, image, hoverImage } = useProductCard(product);
9
+ // // then render however you want; quick-add: addToCart(product.id, directAddVariantId) from useCart().
10
10
 
11
11
  import { useMemo } from "react";
12
12
  import { productImage, productGallery } from "@/lib/storeImage";
13
-
14
- function discountPercent(product) {
15
- const now = Number(product?.actualPriceRange?.minValue?.amount);
16
- const was = Number(product?.compareAtPriceRange?.minValue?.amount);
17
- if (!now || !was || was <= now) return null;
18
- return Math.round(((was - now) / was) * 100);
19
- }
13
+ import { sellingPrice } from "@/rest/wix-store-catalog";
20
14
 
21
15
  export function useProductCard(product) {
22
16
  return useMemo(() => {
@@ -32,23 +26,31 @@ export function useProductCard(product) {
32
26
  else if (isSoldOut) leftBadges.push({ type: "sold-out", label: "Sold out" });
33
27
  if (isPartiallyOutOfStock) leftBadges.push({ type: "limited-stock", label: "Limited stock" });
34
28
 
35
- // Right-side promo badge: discount % beats a merchant ribbon (a "Sale" ribbon is redundant
36
- // when the % is already showing). Only one at a time.
37
- const discount = discountPercent(product);
38
- const ribbon = product?.ribbon?.name;
39
- const promoBadge = discount
40
- ? { type: "discount", label: `-${discount}%` }
41
- : ribbon
42
- ? { type: "ribbon", label: ribbon }
43
- : null;
29
+ // Merchant ribbons — the primary one plus every additional one. Render ALL of them, in one shared
30
+ // style (a "Sale" accent is fine, applied by label). Never compute a "-20%" badge from the price
31
+ // range: a range's minimum says nothing about the variant the buyer picks, and a ribbon is a
32
+ // label, not a price claim.
33
+ const ribbons = [product?.ribbon?.name, ...(product?.additionalRibbons ?? []).map((r) => r?.name)]
34
+ .filter((name, i, all) => name && all.indexOf(name) === i);
35
+ const promoBadge = ribbons[0] ? { type: "ribbon", label: ribbons[0] } : null;
44
36
 
45
- // Price: show a min–max range when variants span different prices so the PDP doesn't
46
- // appear to raise the price once a variant is selected.
47
- const priceMin = product?.actualPriceRange?.minValue?.formattedAmount;
48
- const priceMax = product?.actualPriceRange?.maxValue?.formattedAmount;
49
- const compareAt = product?.compareAtPriceRange?.minValue?.formattedAmount;
50
- const priceDisplay = priceMax && priceMax !== priceMin ? `${priceMin} – ${priceMax}` : priceMin;
51
- const compareAtDisplay = compareAt && compareAt !== priceMin ? compareAt : null;
37
+ // Price. A single-price product shows the price the buyer pays — the lowest-priced variant's
38
+ // discounted price when an automatic discount applies (priceAfterDiscount), else its regular
39
+ // price — with the struck "was" price beside it. Variants priced differently show a min–max
40
+ // range with NO struck price: one lone "was" against a range implies a saving that may not
41
+ // apply to the variant the buyer picks; the PDP shows the real comparison once a variant is chosen.
42
+ const min = product?.actualPriceRange?.minValue;
43
+ const max = product?.actualPriceRange?.maxValue;
44
+ const isRange = !!(min?.amount && max?.amount && min.amount !== max.amount);
45
+ const minVariant = product?.variantSummary?.minPriceVariant ?? null;
46
+ const { current, original } = sellingPrice(minVariant?.price);
47
+ const priceDisplay = isRange
48
+ ? `${min?.formattedAmount ?? ""} – ${max?.formattedAmount ?? ""}`
49
+ : current?.formattedAmount ?? min?.formattedAmount;
50
+ const compareAtDisplay =
51
+ !isRange && original?.formattedAmount && Number(original.amount) > Number(current?.amount ?? min?.amount)
52
+ ? original.formattedAmount
53
+ : null;
52
54
 
53
55
  // Options preview for the tile summary row.
54
56
  // Colour options → real hex dots (more informative than "3 colours").
@@ -68,6 +70,8 @@ export function useProductCard(product) {
68
70
  // Quick-add is only safe for single-variant products (no option choices to resolve).
69
71
  // Sold-out with pre-order still shows a CTA, but it links to the PDP, not quick-add.
70
72
  const isQuickAddable = !hasOptions && !isSoldOut;
73
+ // The variant a direct add sends (a product with no options still has one variant).
74
+ const directAddVariantId = isQuickAddable ? minVariant?.id ?? null : null;
71
75
 
72
76
  // Images: normalised through lib/storeImage so URLs are consistent across the tile,
73
77
  // the PDP gallery, and the cart. Hover image is the second gallery shot (if one exists).
@@ -78,15 +82,17 @@ export function useProductCard(product) {
78
82
  isSoldOut,
79
83
  isPreorder,
80
84
  isPartiallyOutOfStock,
81
- leftBadges, // [{ type: 'pre-order'|'sold-out'|'limited-stock', label }]
82
- promoBadge, // { type: 'discount'|'ribbon', label } | null
83
- priceDisplay, // formatted price (range or single value)
84
- compareAtDisplay, // formatted compare-at price | null
85
- colors, // hex strings — render as dots; the tile shows up to however many you want
86
- optionLabel, // "3 sizes · 2 materials" or empty string
85
+ leftBadges, // [{ type: 'pre-order'|'sold-out'|'limited-stock', label }]
86
+ ribbons, // every merchant ribbon label, primary first — render all of them
87
+ promoBadge, // { type: 'ribbon', label } | null — the primary ribbon, for a single-badge slot
88
+ priceDisplay, // formatted price the buyer pays, or a min–max range
89
+ compareAtDisplay, // formatted struck "was" price | null (never beside a range)
90
+ colors, // hex strings — render as dots; the tile shows up to however many you want
91
+ optionLabel, // "3 sizes · 2 materials" or empty string
87
92
  isQuickAddable,
88
- image, // primary image URL | null
89
- hoverImage, // second image URL for hover effect | null
93
+ directAddVariantId, // pass as variantId to addToCart on quick-add
94
+ image, // primary image URL | null
95
+ hoverImage, // second image URL for hover effect | null
90
96
  };
91
97
  }, [product]);
92
98
  }
@@ -1,13 +1,15 @@
1
- // useProductDetail — all PDP logic, no markup: load product by slug, pre-select first in-stock
2
- // choice per option, resolve the buyer's selections to a Wix variant, gate add-to-cart on stock +
3
- // mandatory modifiers, and build the addToCart payload. The data paths here (variant resolution,
4
- // modifier keys, stock gating) are the bug-prone part — keep them verbatim; the PDP page only
5
- // renders what this returns.
1
+ // useProductDetail — all PDP logic, no markup: load product by slug, resolve the buyer's selections
2
+ // to a Wix variant, gate add-to-cart on availability + mandatory modifiers, and build the addToCart
3
+ // payload. The data paths here (variant resolution, price precedence, modifier keys, stock gating)
4
+ // are the bug-prone part — keep them verbatim; the PDP page only renders what this returns.
6
5
  import { useState, useEffect, useMemo } from "react";
7
- import { getProductBySlug } from "@/rest/wix-store-catalog";
6
+ import { getProductBySlug, sellingPrice } from "@/rest/wix-store-catalog";
8
7
  import { choiceImage, variantImage } from "@/lib/storeImage";
9
8
  import { useCart } from "@/context/CartContext";
10
9
 
10
+ // A variant is identified by the SET of its choice ids — sorted, so option order can't matter.
11
+ const choiceKey = (choiceIds) => [...choiceIds].sort().join("_");
12
+
11
13
  export function useProductDetail(slug) {
12
14
  const { addToCart } = useCart();
13
15
  const [product, setProduct] = useState(null);
@@ -16,6 +18,8 @@ export function useProductDetail(slug) {
16
18
  // link, the other is worth retrying. Without the split, a network blip reads as "product deleted".
17
19
  const [error, setError] = useState(null);
18
20
  const [adding, setAdding] = useState(false);
21
+ // Selections start EMPTY. Pre-picking the first choice lets a buyer who never noticed the default
22
+ // buy the wrong colour; the PDP shows a neutral "choose a size" until every option is picked.
19
23
  const [selectedOptions, setSelectedOptions] = useState({});
20
24
  const [modifierValues, setModifierValues] = useState({});
21
25
  const [quantity, setQuantity] = useState(1);
@@ -26,17 +30,13 @@ export function useProductDetail(slug) {
26
30
  setProduct(null);
27
31
  setNotFound(false);
28
32
  setError(null);
33
+ setSelectedOptions({});
34
+ setQuantity(1);
29
35
  getProductBySlug(slug)
30
36
  .then((p) => {
31
37
  if (cancelled) return;
32
38
  if (!p) return setNotFound(true);
33
39
  setProduct(p);
34
- const initial = {};
35
- (p.options || []).forEach((o) => {
36
- const first = o.choicesSettings?.choices?.find((c) => c.inStock !== false);
37
- if (first) initial[o.id] = first.choiceId;
38
- });
39
- setSelectedOptions(initial);
40
40
  })
41
41
  .catch((e) => { if (!cancelled) setError(e?.message || "Couldn't load this product."); });
42
42
  return () => { cancelled = true; };
@@ -44,29 +44,70 @@ export function useProductDetail(slug) {
44
44
 
45
45
  const options = product?.options || [];
46
46
  const modifiers = product?.modifiers || [];
47
- const variants = product?.variantsInfo?.variants || [];
48
47
 
48
+ // Visible variants keyed by their sorted choice ids. Two variants with the same choices is a
49
+ // catalog defect — report it instead of silently letting one overwrite the other.
50
+ const { variantByKey, duplicateKey } = useMemo(() => {
51
+ const map = new Map();
52
+ let dup = null;
53
+ for (const v of product?.variantsInfo?.variants || []) {
54
+ if (v.visible === false) continue;
55
+ const k = choiceKey((v.choices || []).map((c) => c.optionChoiceIds?.choiceId));
56
+ if (map.has(k) && !dup) dup = k;
57
+ map.set(k, v);
58
+ }
59
+ return { variantByKey: map, duplicateKey: dup };
60
+ }, [product]);
61
+
62
+ const selectionComplete = options.length > 0 && options.every((o) => selectedOptions[o.id]);
49
63
  const variant = useMemo(() => {
50
- if (options.length === 0) return variants[0] || null;
51
- if (!options.every((o) => selectedOptions[o.id])) return null;
52
- return variants.find((v) => (v.choices || []).every((c) =>
53
- selectedOptions[c.optionChoiceIds?.optionId] === c.optionChoiceIds?.choiceId)) || null;
54
- }, [options, variants, selectedOptions]);
64
+ if (options.length === 0) return variantByKey.values().next().value || null; // the single variant
65
+ if (!selectionComplete || duplicateKey) return null;
66
+ return variantByKey.get(choiceKey(options.map((o) => selectedOptions[o.id]))) || null;
67
+ }, [options, variantByKey, duplicateKey, selectedOptions, selectionComplete]);
55
68
 
69
+ // In stock OR pre-orderable counts as buyable; a preorder add carries preOrderRequested.
56
70
  const inStock = variant ? variant.inventoryStatus?.inStock !== false : true;
71
+ const isPreorder = !!variant && !inStock && variant.inventoryStatus?.preorderEnabled === true;
72
+ const available = inStock || isPreorder;
73
+
74
+ const missingModifier = modifiers.find((m) => m.mandatory && !(
75
+ m.modifierRenderType === "FREE_TEXT" ? modifierValues[m.freeTextSettings?.key] : modifierValues[m.key]));
76
+
57
77
  const canAdd = useMemo(() => {
78
+ if (!product) return false;
58
79
  if (options.length > 0 && !variant) return false;
59
- if (variant && !inStock) return false;
60
- return modifiers.filter((m) => m.mandatory).every((m) =>
61
- m.modifierRenderType === "FREE_TEXT" ? !!modifierValues[m.freeTextSettings?.key] : !!modifierValues[m.key]);
62
- }, [options, variant, inStock, modifiers, modifierValues]);
80
+ if (variant && !available) return false;
81
+ return !missingModifier;
82
+ }, [product, options, variant, available, missingModifier]);
83
+
84
+ // Why the buy button is disabled — neutral guidance to render next to the action ("Choose a
85
+ // size"), not an error; null when the buyer can add. Promote to error styling only after they try.
86
+ const blockedReason = useMemo(() => {
87
+ if (!product) return null;
88
+ if (duplicateKey) return "This product's variants can't be resolved right now.";
89
+ if (options.length > 0 && !selectionComplete) {
90
+ const missing = options.filter((o) => !selectedOptions[o.id]).map((o) => o.name);
91
+ return `Choose ${missing.join(" and ")}`;
92
+ }
93
+ if (options.length > 0 && !variant) return "This combination isn't available";
94
+ if (variant && !available) return "Out of stock";
95
+ if (missingModifier) return `Add ${missingModifier.name}`;
96
+ return null;
97
+ }, [product, duplicateKey, options, selectionComplete, selectedOptions, variant, available, missingModifier]);
63
98
 
64
- // Everything the buyer sees follows the resolved `variant` (also returned below, so an agent can
65
- // surface more from it — sku, barcode, subscriptionPricesInfo — without new plumbing). Price and
66
- // its struck-through "was" price come off the variant, falling back to the product's range before
67
- // every option is picked.
68
- const price = variant?.price?.actualPrice?.formattedAmount || product?.actualPriceRange?.minValue?.formattedAmount || "";
69
- const compareAtPrice = variant?.price?.compareAtPrice?.formattedAmount || product?.compareAtPriceRange?.minValue?.formattedAmount || "";
99
+ // Everything the buyer sees follows the resolved `variant` (also returned below). The price obeys
100
+ // the exact precedence in sellingPrice(): an automatic discount beats the regular price and strikes
101
+ // it; else the regular price with the merchant's compare-at as the "was". Before every option is
102
+ // picked, the product's min–max range (one value when equal) — never an empty price.
103
+ const min = product?.actualPriceRange?.minValue, max = product?.actualPriceRange?.maxValue;
104
+ const rangeDisplay = min?.amount && max?.amount && min.amount !== max.amount
105
+ ? `${min.formattedAmount} – ${max.formattedAmount}` : (min?.formattedAmount || "");
106
+ const resolved = variant ? sellingPrice(variant.price) : null;
107
+ const price = resolved?.current?.formattedAmount || rangeDisplay;
108
+ const compareAtPrice = resolved && resolved.original?.formattedAmount &&
109
+ Number(resolved.original.amount) > Number(resolved.current?.amount) ? resolved.original.formattedAmount : "";
110
+ const discountNames = product?.discountInfo?.discountRuleNames || []; // automatic discounts, product level
70
111
 
71
112
  // The gallery follows the selection. Prefer the selected choice's own image (the per-swatch photo,
72
113
  // choice.media.items[].mediaId) — Wix's per-variant media is often NOT differentiated (it defaults to
@@ -82,7 +123,11 @@ export function useProductDetail(slug) {
82
123
  return variantImage(variant);
83
124
  }, [variant, options, selectedOptions]);
84
125
 
85
- const selectOption = (optionId, choiceId) => setSelectedOptions((s) => ({ ...s, [optionId]: choiceId }));
126
+ // Changing an option resets the quantity — the ceiling belongs to the newly resolved variant.
127
+ const selectOption = (optionId, choiceId) => {
128
+ setSelectedOptions((s) => ({ ...s, [optionId]: choiceId }));
129
+ setQuantity(1);
130
+ };
86
131
  const setModifier = (key, value) => setModifierValues((s) => ({ ...s, [key]: value }));
87
132
 
88
133
  async function submit() {
@@ -101,6 +146,7 @@ export function useProductDetail(slug) {
101
146
  return await addToCart(product.id, variant?.id, Math.max(1, Number(quantity) || 1), {
102
147
  modifierChoices: Object.keys(modifierChoices).length ? modifierChoices : undefined,
103
148
  customTextFields: Object.keys(customTextFields).length ? customTextFields : undefined,
149
+ preorder: isPreorder,
104
150
  });
105
151
  } finally {
106
152
  setAdding(false);
@@ -111,6 +157,7 @@ export function useProductDetail(slug) {
111
157
  product, notFound, error, retry: () => setReloadKey((k) => k + 1),
112
158
  options, modifiers,
113
159
  selectedOptions, selectOption, modifierValues, setModifier,
114
- quantity, setQuantity, variant, inStock, canAdd, adding, price, compareAtPrice, submit, focusMediaUrl,
160
+ quantity, setQuantity, variant, inStock, isPreorder, canAdd, blockedReason, adding,
161
+ price, compareAtPrice, discountNames, submit, focusMediaUrl,
115
162
  };
116
163
  }
@@ -40,17 +40,24 @@ const STORES_APP_ID = "215238eb-22a5-4c36-9e7b-e7c08025e04e";
40
40
  * Throws on out-of-stock so the buyer can't reach checkout with an unbuyable line.
41
41
  * Full catalogReference reference: https://dev.wix.com/docs/api-reference/business-solutions/stores/catalog-v3/e-commerce-integration.md
42
42
  *
43
+ * A pre-order variant (inventoryStatus.preorderEnabled, out of stock) is added with `preorder: true`;
44
+ * a recurring subscription plan with its `subscriptionOptionId` (omit it for a one-time purchase).
45
+ * The two may coexist.
46
+ *
43
47
  * @param {string} catalogItemId Product GUID (product.id).
44
48
  * @param {string} [variantId] variantsInfo.variants[].id — required for products with variants.
45
49
  * @param {number} [quantity]
46
- * @param {{ modifierChoices?: Record<string,string>, customTextFields?: Record<string,string> }} [extras]
50
+ * @param {{ modifierChoices?: Record<string,string>, customTextFields?: Record<string,string>,
51
+ * subscriptionOptionId?: string, preorder?: boolean }} [extras]
47
52
  * @returns {Promise<object>} Updated cart.
48
53
  */
49
- export async function addToCart(catalogItemId, variantId, quantity = 1, { modifierChoices, customTextFields } = {}) {
54
+ export async function addToCart(catalogItemId, variantId, quantity = 1, { modifierChoices, customTextFields, subscriptionOptionId, preorder } = {}) {
50
55
  const catalogReferenceOptions = {};
51
56
  if (variantId) catalogReferenceOptions.variantId = variantId;
52
57
  if (modifierChoices && Object.keys(modifierChoices).length) catalogReferenceOptions.options = modifierChoices;
53
58
  if (customTextFields && Object.keys(customTextFields).length) catalogReferenceOptions.customTextFields = customTextFields;
59
+ if (subscriptionOptionId) catalogReferenceOptions.subscriptionOptionId = subscriptionOptionId;
60
+ if (preorder) catalogReferenceOptions.preOrderRequested = true;
54
61
 
55
62
  const catalogReference = { appId: STORES_APP_ID, catalogItemId };
56
63
  if (Object.keys(catalogReferenceOptions).length) catalogReference.options = catalogReferenceOptions;
@@ -92,8 +99,30 @@ export async function getCurrentCart() {
92
99
  }
93
100
  }
94
101
 
102
+ /**
103
+ * Estimate the current cart's totals — the ONLY source of the after-discount subtotal and the
104
+ * cart-level discount. Line prices already carry item-level discounts; delivery and tax resolve at
105
+ * checkout unless asked for, so never invent them. Returns null when there is no cart or the
106
+ * estimate fails (a display nicety — the cart itself stays valid).
107
+ * https://dev.wix.com/docs/api-reference/business-solutions/e-commerce/purchase-flow/cart-v2/estimate-current-cart.md
108
+ * @returns {Promise<{ priceSummary: { subtotal: object, discount: object, total: object } }|null>}
109
+ * priceSummary amounts are ConvertedMoney { amount, convertedAmount } — format like line prices.
110
+ */
111
+ export async function estimateCurrentCart() {
112
+ try {
113
+ const res = await wixApiRequest("/ecom/v2/carts/current/estimate", { method: "POST", body: {} });
114
+ return res?.summary ?? null;
115
+ } catch {
116
+ return null;
117
+ }
118
+ }
119
+
95
120
  /**
96
121
  * Start the hosted checkout for the current cart and return its URL.
122
+ * This is the shopper's CURRENT cart. A "Buy now" that skips the cart needs a standalone cart
123
+ * (POST /ecom/v2/carts) handed to the redirect session — it must never append to or replace the
124
+ * current cart. Not shipped here; verify the redirect session accepts a standalone cart id under
125
+ * the visitor token before adding it.
97
126
  * In Cart V2 the cart id IS the checkout id — there is no separate checkout-creation call; the
98
127
  * redirect session is created straight from the current cart's id.
99
128
  * Throws on empty cart, unavailable lines, or a missing redirect URL.
@@ -13,8 +13,13 @@ import { wixApiRequest } from "./wix-client.js";
13
13
  * actualPriceRange.minValue.formattedAmount {string} — lowest price with currency symbol,
14
14
  * actualPriceRange.maxValue.formattedAmount {string} — highest price with currency symbol,
15
15
  * compareAtPriceRange.minValue.formattedAmount {string} — strikethrough price (present when on sale),
16
- * actualPriceRange/compareAtPriceRange .minValue.amount {string} — the same figures unformatted
17
- * ("129.00"). Use these for arithmetic: a percent-off badge can't be computed from "€129.00",
16
+ * actualPriceRange/compareAtPriceRange .minValue.amount {string} — the same figures unformatted.
17
+ * Never compute a percent-off or "you save" claim from them: a range's minimum says nothing about
18
+ * the variant the buyer picks. The price the buyer pays comes from sellingPrice() below.
19
+ * variantSummary.minPriceVariant {object} — the lowest-priced visible variant (MIN_PRICE_VARIANT):
20
+ * { id, price: { actualPrice, compareAtPrice?, priceAfterDiscount? } } — a card's price, and the
21
+ * variantId for a direct add on a product with no options,
22
+ * discountInfo.discountRuleNames {string[]} — automatic-discount names (DISCOUNT_INFO), product level,
18
23
  * inventory.availabilityStatus {string} — "IN_STOCK"|"OUT_OF_STOCK"|"PARTIALLY_OUT_OF_STOCK",
19
24
  * inventory.preorderStatus {string} — "ENABLED"|"DISABLED". ENABLED + OUT_OF_STOCK is a pre-order,
20
25
  * which is buyable — label it "Pre-order" rather than "Sold out",
@@ -23,8 +28,9 @@ import { wixApiRequest } from "./wix-client.js";
23
28
  * getProductBySlug call per product,
24
29
  * ribbon {object} — { id, name } merchant-set badge: "New", "Sale", "Best Seller". This is the
25
30
  * catalogue's own label, so prefer it over anything derived — a "new" badge computed from
26
- * createdDate flags every product at once right after seeding,
27
- * additionalRibbons {array} — further { id, name } badges,
31
+ * createdDate flags every product at once right after seeding. A ribbon is a LABEL: a "Sale"
32
+ * ribbon is not proof of a lower price, and a compareAtPrice does not earn a ribbon,
33
+ * additionalRibbons {array} — up to four further { id, name } badges. Render ALL of them,
28
34
  * createdDate / updatedDate {string} — ISO timestamps,
29
35
  * variantSummary.variantCount {number} — how many variants exist, without fetching them,
30
36
  * options {array} — product options e.g. Size, Color:
@@ -45,9 +51,11 @@ import { wixApiRequest } from "./wix-client.js";
45
51
  * dangerouslySetInnerHTML; strip tags only for plain-text contexts (meta description, teaser),
46
52
  * variantsInfo.variants {array} — returned only by getProductBySlug:
47
53
  * [{ id, visible, choices [{ optionChoiceIds: { optionId, choiceId } }],
48
- * price: { actualPrice, compareAtPrice }, media, inventoryStatus: { inStock } }]
54
+ * price: { actualPrice, compareAtPrice?, priceAfterDiscount? }, media,
55
+ * inventoryStatus: { inStock, preorderEnabled } }]
49
56
  * To resolve a buyer's option selections to a variantId: find the variant whose choices
50
57
  * match all selected { optionId, choiceId } pairs, then pass variant.id to addToCart.
58
+ * A variant is buyable when inStock OR preorderEnabled (a preorder add carries preOrderRequested).
51
59
  *
52
60
  * Category: { id, name, slug, visible, description, image, itemCounter, parentCategory.id }
53
61
  * NB: queryCategories includes the auto-created system category { slug: "all-products" } —
@@ -55,6 +63,20 @@ import { wixApiRequest } from "./wix-client.js";
55
63
  * Full model: https://dev.wix.com/docs/api-reference/business-solutions/stores/catalog-v3/categories
56
64
  */
57
65
 
66
+ /**
67
+ * The price the buyer pays, and the price to strike — exact precedence, shared by cards and the PDP.
68
+ * An automatic discount (priceAfterDiscount, present only when DISCOUNT_INFO is requested) wins and
69
+ * strikes the regular actualPrice; otherwise actualPrice with the merchant's compareAtPrice as the
70
+ * "was". `!== undefined` on purpose: a discounted price of 0 is a real price.
71
+ * @param {{ actualPrice?: object, compareAtPrice?: object, priceAfterDiscount?: object }} [price]
72
+ * variant.price, or variantSummary.minPriceVariant.price. Each is { amount, formattedAmount }.
73
+ * @returns {{ current: object|undefined, original: object|undefined }} render .formattedAmount
74
+ */
75
+ export function sellingPrice(price) {
76
+ if (price?.priceAfterDiscount !== undefined) return { current: price.priceAfterDiscount, original: price.actualPrice };
77
+ return { current: price?.actualPrice, original: price?.compareAtPrice };
78
+ }
79
+
58
80
  // Search Products supports server-side sort/filter before cursor paging:
59
81
  // https://dev.wix.com/docs/api-reference/business-solutions/stores/catalog-v3/products-v3/search-products.md
60
82
  export const CATALOG_SORTS = {
@@ -108,7 +130,9 @@ export async function searchProducts({ limit = 100, cursor, categoryId, sort = "
108
130
  }
109
131
  const res = await wixApiRequest("/stores/v3/products/search", {
110
132
  method: "POST",
111
- body: { fields: ["CURRENCY", "PLAIN_DESCRIPTION", "MEDIA_ITEMS_INFO"], search: query },
133
+ // MIN_PRICE_VARIANT + DISCOUNT_INFO → variantSummary.minPriceVariant with its discounted price:
134
+ // the card's price and the direct-add variantId, without a per-product fetch.
135
+ body: { fields: ["CURRENCY", "PLAIN_DESCRIPTION", "MEDIA_ITEMS_INFO", "MIN_PRICE_VARIANT", "DISCOUNT_INFO"], search: query },
112
136
  });
113
137
  return { products: res?.products ?? [], nextCursor: res?.pagingMetadata?.cursors?.next ?? null };
114
138
  }
@@ -137,6 +161,7 @@ export async function getProductBySlug(slug) {
137
161
  "MEDIA_ITEMS_INFO",
138
162
  "PRODUCT_CHOICES_MEDIA_REFERENCES",
139
163
  "VARIANT_OPTION_CHOICE_NAMES",
164
+ "DISCOUNT_INFO", // variant price.priceAfterDiscount + discountInfo.discountRuleNames
140
165
  ],
141
166
  },
142
167
  });