@wix/agent-skills 1.19.0 → 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 (80) hide show
  1. package/package.json +1 -1
  2. package/skills/wix-app/SKILL.md +33 -31
  3. package/skills/wix-app/references/DASHBOARD_PAGE.md +6 -10
  4. package/skills/wix-app/references/EDITOR_REACT_COMPONENT.md +17 -8
  5. package/skills/wix-app/references/WIX_PATTERNS_DOCS.md +53 -131
  6. package/skills/wix-app/references/dashboard-page/UX_SUCCESS_MODEL.md +1 -2
  7. package/skills/wix-app/references/editor-react-component/ACCESSIBILITY.md +88 -73
  8. package/skills/wix-app/references/editor-react-component/ANIMATED-COMPONENTS.md +46 -22
  9. package/skills/wix-app/references/editor-react-component/COMPONENT-CONTRACT.md +20 -6
  10. package/skills/wix-app/references/editor-react-component/CSS-GUIDELINES.md +9 -0
  11. package/skills/wix-app/references/editor-react-component/FUNCTION-HANDLERS.md +71 -31
  12. package/skills/wix-app/references/editor-react-component/PARTS.md +5 -2
  13. package/skills/wix-app/references/editor-react-component/REACT-GUIDELINES.md +4 -5
  14. package/skills/wix-app/scripts/pkg-root.cjs +73 -0
  15. package/skills/wix-app/scripts/scan-a11y-code.cjs +310 -40
  16. package/skills/wix-app/scripts/scan-a11y-eslint.cjs +118 -24
  17. package/skills/wix-app/scripts/scan-a11y-render.cjs +551 -0
  18. package/skills/wix-app/scripts/scan-a11y-review.cjs +373 -0
  19. package/skills/wix-base44-connector/SKILL.md +67 -43
  20. package/skills/wix-base44-connector/scripts/utils.cjs +39 -3
  21. package/skills/wix-docs/SKILL.md +47 -20
  22. package/skills/wix-headless/SKILL.md +1 -0
  23. package/skills/wix-headless/entry/bootstrap.mjs +106 -50
  24. package/skills/wix-headless/entry/skill.md +5 -3
  25. package/skills/wix-headless/references/CAPABILITIES.md +7 -2
  26. package/skills/wix-headless/references/SDK_HANDOFF.md +2 -1
  27. package/skills/wix-headless/references/inline-recipes/experience-store.md +48 -0
  28. package/skills/wix-headless/references/inline-recipes/how-to-code-a-store.md +350 -114
  29. package/skills/wix-headless/references/inline-recipes/how-to-code-members-astro.md +1 -1
  30. package/skills/wix-headless/references/managed/AUTHENTICATION.md +25 -5
  31. package/skills/wix-headless/references/managed/CREATE.md +5 -0
  32. package/skills/wix-headless-fast/SKILL.md +25 -7
  33. package/skills/wix-headless-fast/entry/skill.md +23 -14
  34. package/skills/wix-headless-fast/install/fast-path.mjs +48 -5
  35. package/skills/wix-headless-fast/install/pin-agents-md.mjs +57 -0
  36. package/skills/wix-headless-fast/references/shared/CUSTOM_OPERATIONS.md +3 -3
  37. package/skills/wix-headless-fast/references/storefront/INSTRUCTIONS.md +204 -35
  38. package/skills/wix-headless-fast/references/storefront/app/hooks/storefront/useShop.ts +146 -36
  39. package/skills/wix-headless-fast/references/storefront/app/wix/storefront/cart-store.ts +8 -2
  40. package/skills/wix-headless-fast/references/storefront/app/wix/storefront/catalog.ts +95 -20
  41. package/skills/wix-headless-fast/references/storefront/seed/SEED.md +21 -2
  42. package/skills/wix-headless-fast/references/storefront/seed/seed-store.mjs +104 -18
  43. package/skills/wix-manage/SKILL.md +16 -10
  44. package/skills/wix-manage/references/bookings/create-and-update-booking-services.md +4 -1
  45. package/skills/wix-manage/references/bookings/create-appointment-service.md +3 -1
  46. package/skills/wix-manage/references/bookings/create-class-service.md +3 -1
  47. package/skills/wix-manage/references/bookings/create-course-service.md +4 -2
  48. package/skills/wix-manage/references/bookings/end-to-end-booking-flow.md +68 -3
  49. package/skills/wix-manage/references/ecommerce/pricing-promotions/ecom-pricing-flow-seasonal-promotion.md +1 -1
  50. package/skills/wix-manage/references/google-ads/billing-and-payment.md +3 -3
  51. package/skills/wix-manage/references/google-ads/create-performance-max-campaign.md +8 -8
  52. package/skills/wix-manage/references/google-ads/get-campaign-suggestions.md +4 -4
  53. package/skills/wix-manage/references/google-ads/google-ads-dashboard-navigation.md +1 -1
  54. package/skills/wix-manage/references/google-ads/install-and-create-account.md +9 -9
  55. package/skills/wix-manage/references/google-ads/manage-campaign-success-guide.md +108 -14
  56. package/skills/wix-manage/references/google-ads/query-campaign-analytics.md +7 -7
  57. package/skills/wix-manage/references/seo/manage-content-plan.md +182 -136
  58. package/skills/wix-manage/references/seo/manage-google-search-console.md +272 -0
  59. package/skills/wix-manage/references/sites/upload-static-site.md +283 -0
  60. package/skills/wix-manage/references/stores/change-store-currency.md +1 -1
  61. package/skills/wix-vibe-headless/platforms/base44-ecom-light-seed-with-install.md +3 -1
  62. package/skills/wix-vibe-headless/platforms/base44-ecom-light-seed.md +3 -1
  63. package/skills/wix-vibe-headless/platforms/base44-ecom-light.md +1 -1
  64. package/skills/wix-vibe-headless/references/storefront/INSTRUCTIONS.md +69 -27
  65. package/skills/wix-vibe-headless/references/storefront/app/components/CartDrawer.jsx +32 -14
  66. package/skills/wix-vibe-headless/references/storefront/app/context/CartContext.jsx +14 -3
  67. package/skills/wix-vibe-headless/references/storefront/app/hooks/useProductCard.js +41 -35
  68. package/skills/wix-vibe-headless/references/storefront/app/hooks/useProductDetail.js +77 -30
  69. package/skills/wix-vibe-headless/references/storefront/app/rest/wix-store-cart.js +31 -2
  70. package/skills/wix-vibe-headless/references/storefront/app/rest/wix-store-catalog.js +31 -6
  71. package/skills/wix-app/references/dashboard-page/COLLECTION_TOOLKIT.md +0 -80
  72. package/skills/wix-app/references/dashboard-page/ENTITY_PAGE_TOOLKIT.md +0 -140
  73. package/skills/wix-app/references/dashboard-page/PATTERNS_BUNDLE_READING.md +0 -112
  74. package/skills/wix-headless-fast/references/storefront/app/components/storefront/ProductCard.tsx +0 -100
  75. package/skills/wix-headless-fast/references/storefront/app/components/storefront/ProductDetailView.tsx +0 -130
  76. package/skills/wix-headless-fast/references/storefront/app/components/storefront/ProductGrid.tsx +0 -48
  77. package/skills/wix-headless-fast/references/storefront/app/components/storefront/ShopView.tsx +0 -66
  78. package/skills/wix-headless-fast/references/storefront/app/components/storefront/VariantPicker.tsx +0 -103
  79. package/skills/wix-headless-fast/references/storefront/app-astro/pages/products/[slug].astro +0 -56
  80. package/skills/wix-headless-fast/references/storefront/app-astro/pages/shop.astro +0 -23
@@ -1,12 +1,12 @@
1
1
  ---
2
2
  name: "How to Code a Store"
3
- description: The frontend read/cart contract for a Catalog V3 storefront — which SDK modules to import, how to resolve the mandatory variantId, the exact add-to-cart shape, how to filter by category, and how to read inventory. Specifies the *how* (modules + exact calls + the failure modes the docs omit); which products/categories to render come from the catalog the storefront reads.
3
+ description: The frontend contract for a Catalog V3 storefront — which SDK modules to import, the exact product reads (listing, product page, categories), how to resolve the variant and price the buyer actually gets, the exact add-to-cart shape, cart totals, checkout, and what a complete storefront shows. Specifies the *how* (modules + exact calls + the failure modes the docs omit); which products/categories to render come from the catalog the storefront reads.
4
4
  ---
5
- **RECIPE**: How to Code a Wix Online Store Frontend (Catalog V3 + eCommerce cart)
5
+ **RECIPE**: How to Code a Wix Online Store Frontend (Catalog V3 + eCommerce Cart V2)
6
6
 
7
- A concise contract for writing the **frontend code** of a storefront against a Catalog V3 store: listing products, filtering by category, adding to cart, and checking out. **This recipe is the *how* (which modules, which calls, which fields), not the *what*** — which products to show, how the page looks, and the framework are decided by the request you're fulfilling.
7
+ A concise contract for writing the **frontend code** of a storefront against a Catalog V3 store: the shop/category gallery, the product page, cart, and checkout. **This recipe is the *how* (which modules, which calls, which fields, which rules), not the *what*** — which products to show and how the store looks are decided by the request you're fulfilling and the catalog you read. The one exception is the last section: what a *complete* storefront shows, because a wired-but-thin store is the most common failure.
8
8
 
9
- > **This recipe is for CODING the storefront, not for seeding it.** It assumes a Catalog V3 store already exists (products, variants, categories, inventory). It says nothing about creating products — only how to read and purchase them from frontend code.
9
+ > **This recipe is for CODING the storefront, not for seeding it.** It assumes a Catalog V3 store already exists (products, variants, categories, inventory). It says nothing about creating products — only how to read and purchase them from frontend code. Read the catalog; never create, repair, or fake catalog content from the frontend. If content is missing, show an honest state and report the gap.
10
10
 
11
11
  > **⚠️ Reading rule — always append `.md?apiView=SDK` to every doc link below.** The Wix docs render two views of the same page. The **bare / REST view shows `id`**; the **`?apiView=SDK` view shows `_id`** — and the SDK is what your frontend calls. Reading the REST view by mistake is the single most common source of the cart-killing `product.id` bug (see the `_id` rule under *Listing products*). Fetch the `.md?apiView=SDK` form directly; don't re-discover these with search.
12
12
 
@@ -21,65 +21,94 @@ A concise contract for writing the **frontend code** of a storefront against a C
21
21
 
22
22
  | Need | Package | Module |
23
23
  |---|---|---|
24
- | Products (list, get, search, filter) | `@wix/stores` | `productsV3` |
25
- | Variants (to resolve `variantId`) | `@wix/stores` | `readOnlyVariantsV3` |
26
- | Categories | `@wix/stores` | `categories` |
27
- | Cart (add / get / checkout) | `@wix/ecom` | `currentCartV2` |
24
+ | Products (list, search, count, get by slug — variants come inside) | `@wix/stores` | `productsV3` |
25
+ | Categories (nav, category page) | `@wix/categories` | `categories` |
26
+ | Current cart (add / read / update / estimate) | `@wix/ecom` | `currentCartV2` |
27
+ | Standalone cart for Buy Now | `@wix/ecom` | `cartV2` |
28
28
  | Redirect to hosted checkout | `@wix/redirects` | `redirects` |
29
+ | Promotions, product groups, inventory, back-in-stock — **only when the catalog has them** | `@wix/stores` | see *Catalog-conditional capabilities* |
29
30
 
30
- > Migrating from Cart V1 / Checkout V1? The code below is V2-only — see the [migration guide](https://dev.wix.com/docs/api-reference/business-solutions/e-commerce/purchase-flow/cart-v2/migration-guide) for the before/after.
31
-
32
- **Never** import the V1 `products` or `collections` modules from `@wix/stores`.
31
+ > Migrating from Cart V1 / Checkout V1? The code below is V2-only — see the [migration guide](https://dev.wix.com/docs/api-reference/business-solutions/e-commerce/purchase-flow/cart-v2/migration-guide) for the before/after. **Never** import the V1 `products` or `collections` modules from `@wix/stores`, `currentCart` (V1), `checkout.createCheckout`, or `createCheckoutFromCurrentCart`.
33
32
 
34
33
  **Auth / client — framework split:**
35
- - **Astro (Wix-managed):** authentication is ambient. Call `currentCartV2` / `productsV3` / `readOnlyVariantsV3` directly from server components and backend routes (`src/pages/api/*.ts`) — **no `createClient`, no `OAuthStrategy`, no `clientId`.**
34
+ - **Astro (Wix-managed):** authentication is ambient. Call `currentCartV2` / `productsV3` / `categories` directly from server components and backend routes (`src/pages/api/*.ts`) — **no `createClient`, no `OAuthStrategy`, no `clientId`.**
36
35
  - **Non-Astro (Vite/React/Vue/static):** build one manual visitor client and reuse it:
37
36
  ```js
38
37
  import { createClient, OAuthStrategy } from '@wix/sdk';
39
- import { productsV3, readOnlyVariantsV3 } from '@wix/stores';
40
- import { currentCartV2 } from '@wix/ecom';
38
+ import { productsV3 } from '@wix/stores';
39
+ import { categories } from '@wix/categories';
40
+ import { currentCartV2, cartV2 } from '@wix/ecom';
41
41
  import { redirects } from '@wix/redirects';
42
42
 
43
43
  const client = createClient({
44
- modules: { productsV3, readOnlyVariantsV3, currentCartV2, redirects },
44
+ modules: { productsV3, categories, currentCartV2, cartV2, redirects },
45
45
  auth: OAuthStrategy({ clientId: /* the project's PUBLIC OAuth client id */ }),
46
46
  });
47
47
  ```
48
- The `clientId` is public, not a secret.
48
+ The `clientId` is public, not a secret. **One visitor identity per shopper**, persisted by the strategy — never a shared token, and never an API key or app secret in browser code.
49
49
 
50
50
  ---
51
51
 
52
+ ## The fields you request (two constants, reused everywhere)
53
+
54
+ Every product read takes a `fields` array; without it the response is missing the things the storefront renders. Define these **once** and reuse them so home, shop, category, and product pages can't drift:
55
+
56
+ ```js
57
+ // Listing / gallery / homepage rails — summary data, 24 products at a time.
58
+ const LIST_FIELDS = [
59
+ 'CURRENCY', // formattedAmount on every price (without it prices are bare numbers)
60
+ 'PLAIN_DESCRIPTION', // plainDescription for cards
61
+ 'MEDIA_ITEMS_INFO', // media.itemsInfo.items — the hover image
62
+ 'MIN_PRICE_VARIANT', // variantSummary.minPriceVariant — Direct Add's variant id + the card price
63
+ 'DISCOUNT_INFO', // priceAfterDiscount on that variant (automatic discounts)
64
+ ];
65
+ // Product page — one complete purchasable product.
66
+ const DETAIL_FIELDS = [
67
+ ...LIST_FIELDS,
68
+ 'DESCRIPTION', 'INFO_SECTION', 'INFO_SECTION_DESCRIPTION', // rich description + info sections
69
+ 'VARIANT_OPTION_CHOICE_NAMES', // populates variantsInfo.variants — WITHOUT it variants are null
70
+ 'PRODUCT_CHOICES_MEDIA_REFERENCES',// per-choice images (choice.media.items[].mediaId — the swatch photo)
71
+ 'SUBSCRIPTION_PRICES_INFO', // subscription plans + prices, when the product has them
72
+ 'BREADCRUMBS_INFO', // category breadcrumbs for the PDP
73
+ ];
74
+ ```
75
+
52
76
  ## The shapes you read (field cheat-sheet)
53
77
 
54
- The exact field paths the storefront reads, and the **plausible-wrong sibling** each is mistaken for — the sections below reference these instead of re-describing them. All `amount`s are **strings**. These are **read** shapes; the cart-add body (under *Adding to cart*) is a separate **write** shape, and the `_id` rule applies to read **entities**, not to request params (note the redirect session's `checkoutId`, which is just the cart's `_id`).
78
+ The exact field paths the storefront reads, and the **plausible-wrong sibling** each is mistaken for. All `amount`s are **strings**. These are **read** shapes; the cart-add body (under *Adding to cart*) is a separate **write** shape, and the `_id` rule applies to read **entities**, not to request params.
55
79
 
56
80
  ```jsonc
57
- // Request CURRENCY and PLAIN_DESCRIPTION on every product read — they populate formattedAmount and plainDescription.
58
- const PRODUCT_FIELDS = ['CURRENCY', 'PLAIN_DESCRIPTION'];
59
- // productsV3.queryProducts({ fields: PRODUCT_FIELDS }).…find() → result.items[]
60
- // productsV3.searchProducts({ ..., fields: PRODUCT_FIELDS }) → result.products[]
61
81
  product = {
62
- _id, // links · cart catalogItemId · variant filter (NOT .id → empty → HTTP 500)
82
+ _id, // links · cart catalogItemId (NOT .id → empty → HTTP 500)
63
83
  slug, name, visible, // only visible:true is returned to a visitor token
64
84
  currency, // present when CURRENCY is requested
65
- actualPriceRange: { minValue: { amount, formattedAmount } }, // PRICE (NOT price.actualPrice.amount — that's the seed/WRITE shape → $NaN)
66
- compareAtPriceRange: { minValue: { amount, formattedAmount } }, // strike-through price
67
- inventory: { availabilityStatus }, // product-level stock, "IN_STOCK"
68
- media: { main: { image } }, // image is a wix:image:// id → resolve it (see Rendering images)
69
- plainDescription, // plain string; description.nodes is the rich-text form
85
+ actualPriceRange: { minValue: { amount, formattedAmount }, maxValue: {...} }, // the RANGE before a variant is picked
86
+ compareAtPriceRange: { minValue: {...}, maxValue: {...} }, // "was" range (NOT price.actualPrice.amount — that's the seed/WRITE shape → $NaN)
87
+ ribbon: { name }, additionalRibbons: [{ name }], // ALL of them render (see Ribbons)
88
+ discountInfo: { discountRuleNames: [] }, // automatic-discount names, product level (DISCOUNT_INFO)
89
+ inventory: { availabilityStatus, preorderStatus }, // "IN_STOCK" | "OUT_OF_STOCK" | "PARTIALLY_OUT_OF_STOCK"; preorderStatus "ENABLED"
90
+ media: { main: { image }, itemsInfo: { items: [{ image, altText }] } }, // image is a wix:image:// id → resolve it (see Rendering images)
91
+ plainDescription, description, // plainDescription is an HTML STRING (<p>…</p>) despite the name; description.nodes is the rich-text form
92
+ options: [{ _id, name, optionRenderType, choicesSettings: { choices: [{ choiceId, name, colorCode, inStock, visible, media }] } }],
93
+ modifiers: [{ key, name, mandatory, modifierRenderType, freeTextSettings: { key }, choicesSettings: { choices: [{ key, name }] } }],
94
+ variantSummary: { minPriceVariant: { _id, price } }, // LIST reads (MIN_PRICE_VARIANT)
95
+ variantsInfo: { variants: [ variant ] }, // DETAIL reads (VARIANT_OPTION_CHOICE_NAMES)
96
+ subscriptionPricesInfo, allowOneTimePurchases, // when SUBSCRIPTION_PRICES_INFO is requested
97
+ seoData, // merchant SEO settings — always present on Product/Category (no field enum)
70
98
  }
71
99
 
72
- // readOnlyVariantsV3.queryVariants().eq('productData.productId', product._id).find() → result.items[]
73
- // variants are a SEPARATE resource — queryProducts / getProduct return variantsInfo: null
74
100
  variant = {
75
- variantId, // → cart options.variantId (use variant.variantId ?? variant._id)
76
- optionChoices: [{ optionChoiceNames: { optionName, choiceName } }], // match the buyer's Size/Color selection
77
- inventoryStatus: { inStock }, // variant-level stock (boolean)
101
+ _id, // → cart options.variantId
102
+ visible,
103
+ choices: [{ optionChoiceIds: { optionId, choiceId }, optionChoiceNames: { optionName, choiceName } }],
104
+ price: { actualPrice: { amount, formattedAmount }, compareAtPrice: {...} | undefined, priceAfterDiscount: {...} | undefined },
105
+ inventoryStatus: { inStock, preorderEnabled }, // variant-level stock (booleans)
106
+ media, // the resolved image for this combination, when set
78
107
  }
79
108
 
80
109
  // currentCartV2.getCurrentCart() → { cart: { _id, lineItems: [...] } } // NOTE: returns { cart } — destructure it
81
- lineItem = { _id, name: { original }, quantityInfo: { confirmedQuantity }, pricing: { unitPrice: { amount } }, attributes: { image } }
82
- // price → pricing.unitPrice (ConvertedMoney, NO formatted string in V2 — format it yourself; .amount is site currency, .convertedAmount the buyer's display currency); qty → quantityInfo.confirmedQuantity; image → attributes.image (wix:image:// → resolve)
110
+ lineItem = { _id, name: { original }, quantityInfo: { confirmedQuantity, availableQuantity }, pricing: { unitPrice: { amount, convertedAmount }, totalPrice: {...} }, attributes: { image, descriptionLines }, status }
111
+ // price → pricing.unitPrice (ConvertedMoney, NO formatted string in V2 — format it yourself); qty → quantityInfo.confirmedQuantity; image → attributes.image (wix:image:// → resolve)
83
112
 
84
113
  // the cart's _id is the checkout id → pass to the redirect session:
85
114
  // redirects.createRedirectSession({ ecomCheckout: { checkoutId: cart._id }, callbacks }) → { redirectSession: { fullUrl } }
@@ -89,109 +118,245 @@ lineItem = { _id, name: { original }, quantityInfo: { confirmedQuantity }, prici
89
118
 
90
119
  ## The storefront features (build the ones the site needs)
91
120
 
92
- Each section below is a **self-contained storefront feature** — implement only the ones the site uses; they don't have to be built in order, and some sites need just a few of them. The only ordering is *within* a feature (e.g. resolve the variant before adding it to the cart).
121
+ Each section below is a **self-contained storefront feature** — implement only the ones the site uses. The only ordering is *within* a feature (e.g. resolve the variant before adding it to the cart). The gallery and the product page are **two different reads** — summary data for many products vs one complete product — so don't build one "products" flow and reuse it for both.
93
122
 
94
123
  ### Listing products (and the `_id` rule)
95
124
 
96
- Query products with `productsV3.queryProducts()` / `.searchProducts()`. **Every product listing,
97
- category search, and detail read must request `CURRENCY` and `PLAIN_DESCRIPTION`**. Without `CURRENCY`, `formattedAmount` is omitted
98
- and a product card renders an unsymbolled number such as `34.99` instead of a localized price. Without `PLAIN_DESCRIPTION`, `product.plainDescription` is absent for cards and detail pages.
99
- Doc: <https://dev.wix.com/docs/api-reference/business-solutions/stores/catalog-v3/products-v3/query-products.md?apiView=SDK>
125
+ Query products with `productsV3.searchProducts()` (the gallery: filter/sort/paging server-side) or `productsV3.queryProducts()` (a simple all-products list). Always pass `LIST_FIELDS`.
126
+ Docs: <https://dev.wix.com/docs/api-reference/business-solutions/stores/catalog-v3/products-v3/search-products.md?apiView=SDK> · <https://dev.wix.com/docs/api-reference/business-solutions/stores/catalog-v3/products-v3/query-products.md?apiView=SDK>
100
127
 
101
- **⚠️ `queryProducts()` returns a builder, not a Promise.** Pass requested fields when needed, then chain `.eq(...)`/`.limit(...)`/`.find()` — for example:
128
+ **⚠️ `queryProducts()` returns a builder, not a Promise.** Pass requested fields, then chain `.limit(...)`/`.find()`:
102
129
 
103
130
  ```js
104
- const PRODUCT_FIELDS = ['CURRENCY', 'PLAIN_DESCRIPTION'];
105
- const { items } = await productsV3
106
- .queryProducts({ fields: PRODUCT_FIELDS })
107
- .limit(50)
108
- .find();
131
+ const { items } = await productsV3.queryProducts({ fields: LIST_FIELDS }).limit(24).find();
109
132
  ```
110
133
 
111
- Do not pass a search/filter object to `queryProducts`; use `searchProducts` for that (see category filtering below). Reuse the same `PRODUCT_FIELDS` constant for every product read so home, shop, category, and detail pages cannot drift.
134
+ Do not pass a search/filter object to `queryProducts`; use `searchProducts` for that (below).
112
135
 
113
- **⚠️ CRITICAL: the entity id is `_id`, NOT `id`.** The SDK normalizes every entity's id to **`_id`**. `product.id` is `undefined` in SDK code. This is the cart-killer: feeding `product.id` into the cart's `catalogItemId` sends an empty string and the add returns **HTTP 500** (`"catalogItemId" has size 0`). Use `product._id` everywhere — in links, as the cart `catalogItemId`, and as the variant-query filter value. (If a field name surprises you, you are probably reading the REST doc view — re-open it with `?apiView=SDK`.)
136
+ **⚠️ CRITICAL: the entity id is `_id`, NOT `id`.** The SDK normalizes every entity's id to **`_id`**. `product.id` is `undefined` in SDK code. This is the cart-killer: feeding `product.id` into the cart's `catalogItemId` sends an empty string and the add returns **HTTP 500** (`"catalogItemId" has size 0`). Use `product._id` everywhere — in links, as the cart `catalogItemId`, as the variant id. (If a field name surprises you, you are probably reading the REST doc view — re-open it with `?apiView=SDK`.)
114
137
 
115
- **Scope of the `_id` rule — entity reads only.** `_id` is the id of a read **entity** (product, variant, cart line item). It is **not** a universal "every id field is `_id`" rule: request params name their own fields (e.g. the redirect session takes `ecomCheckout.checkoutId`, *not* `_id` — see the *Checkout* section below — even though the value you pass is the cart's `_id`). Don't assume every id-shaped field is spelled `_id`.
138
+ **Scope of the `_id` rule — entity reads only.** Request params name their own fields (the redirect session takes `ecomCheckout.checkoutId`, *not* `_id` — even though the value you pass is the cart's `_id`). Don't assume every id-shaped field is spelled `_id`.
116
139
 
117
140
  **Visibility:** only `visible: true` products are returned to a visitor token, so a missing product usually means it wasn't seeded visible — not a query bug.
118
141
 
119
- **Price:** after requesting `CURRENCY`, render `actualPriceRange.minValue.formattedAmount` (and the matching `compareAtPriceRange` value) directly. Fall back to raw `.amount` only if the formatted value is unexpectedly absent. Never use `price.actualPrice.amount`: that is the seed/write shape and reads `undefined` → `$NaN`.
142
+ ### The gallery query — sort, filter, and page on Wix, never on a loaded page
143
+
144
+ Search Products applies sort, filter, and search **across the whole catalog before** paging — the only correct place for them. Re-sorting or filtering an array you already fetched only rearranges the slice you happen to hold, and a catalog past the page size silently truncates. The shape (two arguments — the search, then `{ fields }`):
145
+
146
+ ```js
147
+ const conditions = [{ visible: true }];
148
+ if (categoryId) conditions.push({ 'allCategoriesInfo.categories': { $matchItems: [{ id: categoryId }] } });
149
+ // Search rejects two operators in ONE field object — price bounds are SEPARATE conditions joined by $and:
150
+ if (minPrice != null) conditions.push({ 'actualPriceRange.minValue.amount': { $gte: String(minPrice) } });
151
+ if (maxPrice != null) conditions.push({ 'actualPriceRange.minValue.amount': { $lte: String(maxPrice) } });
152
+ if (inStockOnly) conditions.push({ 'inventory.availabilityStatus': { $eq: 'IN_STOCK' } });
153
+ const filter = { $and: conditions };
154
+
155
+ const SORTS = {
156
+ featured: undefined, // the catalog's own order — NOT a sales ranking
157
+ priceAsc: [{ fieldName: 'actualPriceRange.minValue.amount', order: 'ASC' }, { fieldName: 'name', order: 'ASC' }],
158
+ priceDesc: [{ fieldName: 'actualPriceRange.minValue.amount', order: 'DESC' }, { fieldName: 'name', order: 'ASC' }],
159
+ nameAsc: [{ fieldName: 'name', order: 'ASC' }],
160
+ newest: [{ fieldName: 'createdDate', order: 'DESC' }, { fieldName: 'name', order: 'ASC' }],
161
+ };
162
+
163
+ const { products, pagingMetadata } = await productsV3.searchProducts(
164
+ {
165
+ ...(cursor
166
+ ? {} // a cursor CONTINUES the original query — send it alone
167
+ : { filter, ...(SORTS[sort] ? { sort: SORTS[sort] } : {}),
168
+ ...(search ? { search: { expression: search.trim(), fields: ['name'] } } : {}) }),
169
+ cursorPaging: { limit: 24, ...(cursor ? { cursor } : {}) },
170
+ },
171
+ { fields: LIST_FIELDS },
172
+ );
173
+ const nextCursor = pagingMetadata?.cursors?.next ?? null; // null → no more pages
174
+ const { count } = await productsV3.countProducts({ filter }); // the "N products" line; issue alongside the page
175
+ ```
176
+
177
+ - **Any change to category, sort, filters, or search starts over without a cursor.** Reset paging; ignore responses from a superseded query (version or abort in-flight requests so a late older response can't roll back a newer selection).
178
+ - Keep filter/sort state in the URL so back/forward and refresh preserve it — but translate back to the IDs Wix expects before querying.
179
+ - Page size **24**. Offer "load more" or prev/next from the cursor; never raise the limit to avoid paging.
180
+ - Docs: <https://dev.wix.com/docs/api-reference/business-solutions/stores/catalog-v3/products-v3/supported-filters-and-sorting.md?apiView=SDK>
181
+
182
+ **⚠️ CRITICAL: category filtering MUST use `searchProducts`, NOT `queryProducts`.** The categories field is **not declared as filterable in `queryProducts`** — passing it there returns HTTP `400 "... is not declared as filterable"`, which the SDK **swallows silently**, leaving an empty category page that looks like "no products". Use the exact condition above:
183
+
184
+ - **Field:** `allCategoriesInfo.categories` — includes parent categories, so a product in a subcategory shows on the parent's page too.
185
+ - **`categoryId`** is the stable id from the live `categories.queryCategories()` result (a category's `_id`) — read it from the render context, never a hardcoded seed-time id list (a product the owner adds to the category later must appear with no code change).
186
+ - **Operator:** `$matchItems`, never `$hasSome` (the natural-looking guess returns nothing). **Inner key:** `id` (the category GUID).
187
+ - **Never** the V1 `collectionIds` / `collections.id` paths — they return empty against V3 data.
120
188
 
121
189
  ### Category navigation — list categories live
122
190
 
123
- **Build the category nav/rail from a live `categories` query (`@wix/categories`), never from a seeded category list** — a category the owner adds later then self-registers in the nav with no code change. Query at render time and read each `{ _id, name, slug }`; render the bar only when it returns **more than one** category, and treat it as **non-fatal** (wrap in try/catch, render without the bar if it fails).
191
+ **Build the category nav/rail from a live `categories` query (`@wix/categories`), never from a seeded category list** — a category the owner adds later then self-registers in the nav with no code change. Render the bar only when it returns **more than one** category, and treat it as **non-fatal** (wrap in try/catch, render without the bar if it fails).
124
192
 
125
193
  ```js
126
194
  import { categories } from '@wix/categories';
127
195
  const res = await categories.queryCategories({
128
196
  treeReference: { appNamespace: '@wix/stores' },
129
- }).exists('name', true).find(); // read the returned array per the SDK doc; ids are `_id`; link each to /category/<slug>
197
+ }).exists('name', true).find(); // items: { _id, name, slug, visible, parentCategory? }; link each to /category/<slug>
130
198
  ```
131
199
 
132
- **⚠️ The query MUST carry a filter condition — chain `.exists('name', true)` (as above), do NOT call a bare `.find()`.** A `.find()` with no chained filter serializes an empty `"filter": {}`, which `categories/v1/categories/query` rejects with `400 INVALID_FILTER` ("Filter expression cannot contain an empty condition"). **This is fatal on the visitor/manual-client (non-Astro) path** — the call throws and the nav never renders; managed-Astro's server-side transport happens to tolerate the empty filter, so a bare `.find()` *looks* fine there but breaks the moment the same code runs client-side. `.exists('name', true)` is a tautology (every category has a `name`), so it matches **all** categories and is accepted on **both** paths — use this one shape everywhere. **Do NOT filter on `visible`** (`.eq('visible', true)`): unlike `name`, `visible` is **not declared filterable** on `queryCategories`, so it triggers a silently-swallowed `400` and the nav renders blank; if you ever need to hide a category, filter the returned array **client-side**. (`getCategoryBySlug(slug, { appNamespace: '@wix/stores' })` on the category page is unaffected — it takes no query filter.) This is a **distinct API from bookings' `categoriesV2`** — don't copy that module's query shape here; read the exact result-array key + field names from the `@wix/categories` SDK doc: <https://dev.wix.com/docs/api-reference/business-solutions/stores/catalog-v3/categories/introduction.md?apiView=SDK>.
200
+ **⚠️ The query MUST carry a filter condition — chain `.exists('name', true)` (as above), do NOT call a bare `.find()`.** A `.find()` with no chained filter serializes an empty `"filter": {}`, which `categories/v1/categories/query` rejects with `400 INVALID_FILTER` — **fatal on the visitor/manual-client (non-Astro) path** (managed-Astro's server-side transport happens to tolerate it, so a bare `.find()` *looks* fine there and breaks the moment the same code runs client-side). `.exists('name', true)` is a tautology, so it matches **all** categories and is accepted on **both** paths. **Do NOT filter on `visible`** (`.eq('visible', true)`) — `visible` is **not declared filterable** on `queryCategories`, so it triggers a silently-swallowed `400` and the nav renders blank; filter `visible === false` out of the returned array **client-side**. Skip the auto-created `all-products` system category. Page through when the store has more than one page. Index by `_id`, rebuild parent/child from `parentCategory`, and **keep the `_id` for product queries — the slug is only for URLs and route matching.** This is a **distinct API from bookings' `categoriesV2`** — don't copy that module's query shape here: <https://dev.wix.com/docs/api-reference/business-solutions/stores/catalog-v3/categories/introduction.md?apiView=SDK>.
133
201
 
134
- ### Filtering products by category
202
+ For a **category page**, resolve the slug from the URL with `categories.getCategoryBySlug(slug, { appNamespace: '@wix/stores' })` → its `_id` feeds the gallery query above, its `name`/`description` head the page, its `seoData` is the page's SEO input. A missing or `visible: false` category is a real **404**, never a fallback to all products.
135
203
 
136
- **Filter server-side by the live `categoryId` — keyed on the stable category id, so products the owner adds to a category later appear with no code change or re-publish.** This is the **prescribed** approach; do **not** freeze a seed-time `category→productIds` map into the code and filter client-side against it (a product added to that category in the backoffice would never appear on its category page — the exact "owner edit is lost" failure this recipe exists to prevent).
204
+ ### The product page read (one call, variants included)
137
205
 
138
- **⚠️ CRITICAL: category filtering MUST use `searchProducts`, NOT `queryProducts`.** `directCategoriesInfo.categories` is **not declared as filterable in `queryProducts`** — passing it there returns HTTP `400 "... is not declared as filterable"`, which the SDK **swallows silently**, leaving an empty category page that looks like "no products". This is the #1 way this breaks. Use Search Products:
206
+ The product page needs **one complete product** — a different read from the gallery:
139
207
 
140
208
  ```js
141
- const { products } = await productsV3.searchProducts({
142
- filter: { 'directCategoriesInfo.categories': { $matchItems: [{ id: categoryId }] } },
143
- fields: PRODUCT_FIELDS,
144
- });
209
+ const { product } = await productsV3.getProductBySlug(slug, { fields: DETAIL_FIELDS });
210
+ if (!product) return notFound(); // a real 404 — NEVER fall back to the first product in a list
145
211
  ```
212
+ Doc: <https://dev.wix.com/docs/api-reference/business-solutions/stores/catalog-v3/products-v3/get-product-by-slug.md?apiView=SDK>
146
213
 
147
- - **`categoryId`** is the stable id from the live `categories.queryCategories()` result above (a category's `_id`) — read it from the render context, never a hardcoded seed-time id list.
148
- - **Method:** `searchProducts`, never `queryProducts` (the field is only filterable in search).
149
- - **Operator:** `$matchItems`, never `$hasSome` (the natural-looking guess returns nothing).
150
- - **Inner key:** `id` (the category GUID), inside `$matchItems: [{ id: … }]`.
151
- - **Never** the V1 `collectionIds` / `collections.id` paths — they return empty against V3 data.
214
+ **⚠️ `variantsInfo` is `null` unless `VARIANT_OPTION_CHOICE_NAMES` is in `fields`** — the docs say variants "aren't returned" and that's true of a bare read; with the field, `product.variantsInfo.variants` carries every variant with its `optionChoiceIds`, `price`, and `inventoryStatus`. There is no second call to make. (`readOnlyVariantsV3` still exists for bulk variant queries; the product page doesn't need it.)
152
215
 
153
- Docs: <https://dev.wix.com/docs/api-reference/business-solutions/stores/catalog-v3/products-v3/search-products.md?apiView=SDK> · <https://dev.wix.com/docs/api-reference/business-solutions/stores/catalog-v3/products-v3/supported-filters-and-sorting.md?apiView=SDK>
216
+ Render the description as HTML (see *Rendering product descriptions*), the info sections as sections/accordions matching their length, breadcrumbs from `breadcrumbsInfo`, and **every** image in `media.itemsInfo.items` as a browsable gallery (main first, de-duplicated) — not just `media.main`.
154
217
 
155
- ### Adding to cart — the V3 cart contract
218
+ **How the page is split — one price, on the page in the HTML:**
156
219
 
157
- Adding to cart is two parts of **one feature**: resolve the variant first, then add it. The variant resolution is not a standalone concern — it exists only to feed the add call.
220
+ ```astro
221
+ ---
222
+ // src/pages/products/[slug].astro — the page owns the read, the name, the gallery, the description.
223
+ const { product } = await productsV3.getProductBySlug(slug, { fields: DETAIL_FIELDS });
224
+ if (!product) return new Response(null, { status: 404 });
225
+ ---
226
+ <ProductPurchase client:load product={product} /> <!-- the island owns price, options, quantity, action -->
227
+ ```
158
228
 
159
- **1 · Resolve the `variantId` (mandatory).** Variants are a **separate read-only resource** — `queryProducts` / `getProduct` do **not** return variant data (this is documented; `variantsInfo` comes back `null`). Resolve the variant yourself:
229
+ The purchase island takes the **server-fetched product as a prop** and mounts **`client:load`**, so the price, the option controls, and the buy action are in the initial HTML and hydrate in place. `client:only` is for widgets whose state exists only in the browser (the cart badge, the drawer — `astro.md` A4), not for the purchase area. **Render the price once**, inside the island, where it can follow the selected variant; the page doesn't print its own copy above it.
230
+
231
+ ### Prices — which one the buyer actually pays
232
+
233
+ Three prices can exist on a variant; the precedence is exact:
234
+
235
+ ```js
236
+ // price = variant.price (PDP, after selection) or variantSummary.minPriceVariant.price (cards).
237
+ // Each field is { amount, formattedAmount } — render formattedAmount.
238
+ function sellingPrice(price) {
239
+ if (price?.priceAfterDiscount !== undefined) { // explicit check: a valid ZERO discount price is a real price
240
+ return { current: price.priceAfterDiscount, original: price.actualPrice }; // strike actualPrice, NOT compareAtPrice
241
+ }
242
+ return { current: price?.actualPrice, original: price?.compareAtPrice }; // original may be undefined
243
+ }
244
+ ```
245
+
246
+ - **Before a variant is selected**, show the product range (`actualPriceRange.minValue`–`maxValue`, one value when equal) — never an empty price and never an arbitrary variant. Once every option is chosen, switch to the selected variant's `sellingPrice(variant.price)` and update the buy button's price with it.
247
+ - **Strike the original only when it is defined and higher than the current price**, and label it ("was", "regular price") — strikethrough or color alone doesn't carry the meaning for assistive tech.
248
+ - **On cards, never pair a price *range* with one lone struck "was" minimum** — it implies a saving that may not apply to every variant. Show the "was" only for a single-price product; otherwise omit the comparison until a variant is picked.
249
+ - `discountInfo.discountRuleNames` (product level) names the automatic discounts — render the names if you like, but **never compute a percentage or a savings claim yourself**; the cart's calculated summary is the only source of applied discounts.
250
+ - **A ribbon is a label, never proof of a price.** Don't infer "on sale" from a "Sale" ribbon, and don't render a ribbon from a compare-at price.
251
+
252
+ ### Ribbons — render all of them
253
+
254
+ A product carries a primary `ribbon.name` **and** up to four `additionalRibbons[].name`. Render **every** label, on cards and on the product page, with one shared presentation (same shape, typography, placement; a semantic accent such as a "Sale" color is fine but applied by label, not by which field it came from). Wrap or use a clearly labeled overflow control — never keep only the first.
255
+
256
+ ### Variants — resolve by choice IDs, from the product itself
257
+
258
+ Selections start **empty** (no pre-picked first choice — a buyer who never noticed the default would buy the wrong color). Key the variants by their sorted **choice IDs**; option order then doesn't matter:
160
259
 
161
260
  ```js
162
- const { items } = await readOnlyVariantsV3
163
- .queryVariants()
164
- .eq('productData.productId', product._id) // NOTE: productData.productId is the filter field
165
- .find();
261
+ const key = (choiceIds) => [...choiceIds].sort().join('_');
262
+ const variantByKey = new Map();
263
+ for (const v of product.variantsInfo?.variants ?? []) {
264
+ if (v.visible === false) continue;
265
+ const k = key(v.choices.map((c) => c.optionChoiceIds.choiceId));
266
+ if (variantByKey.has(k)) throw new Error(`Two variants share the choices ${k}`); // report, don't silently overwrite
267
+ variantByKey.set(k, v);
268
+ }
269
+ // selected = { [optionId]: choiceId } — the buyer's picks so far (option._id → choice.choiceId)
270
+ const complete = product.options.every((o) => selected[o._id]);
271
+ const variant = complete ? (variantByKey.get(key(Object.values(selected))) ?? null) : null; // null = combination doesn't exist
272
+ const available = (v) => v.inventoryStatus?.inStock !== false || v.inventoryStatus?.preorderEnabled === true;
166
273
  ```
167
- Doc: <https://dev.wix.com/docs/api-reference/business-solutions/stores/catalog-v3/read-only-variants-v3/query-variants.md?apiView=SDK>
168
274
 
169
- Each `variant` carries `variant.optionChoices[].optionChoiceNames` — `{ optionName, choiceName }`. Match the buyer's selected options (Size = "Small", Color = "Red", …) against those names to pick the variant. For a **single-variant** product, use the only item. Fall back to `items[0]` if matching yields nothing. The id to send to the cart is **`variant.variantId ?? variant._id`**.
275
+ - **Which choices to disable:** before any selection, use each choice's own flags (`choice.inStock ?? true`, `choice.visible ?? true`). After a selection, a choice is selectable when at least one visible variant compatible with the current picks is `available` (in stock **or** preorder-enabled).
276
+ - **A single-variant product** (no options) resolves to its only variant — `variantsInfo.variants[0]` on the PDP, `variantSummary.minPriceVariant` on a card.
277
+ - Changing an option **resets quantity to 1**. Modifiers never take part in the key — they're separate inputs (next section).
278
+ - **A choice image** lives at `choice.media.items[].mediaId` (with `PRODUCT_CHOICES_MEDIA_REFERENCES`); a resolved variant may carry `variant.media`. Make choice media *available* (highlight it in the gallery) rather than replacing the gallery on every option change.
279
+
280
+ **Which action to show** — precedence, top wins: **(1)** the chosen combination doesn't exist → "unavailable"; **(2)** preorder (before selection: `inventory.preorderStatus === 'ENABLED'` and the product is out of stock; after: the variant's `preorderEnabled`) → "Pre-order"; **(3)** back-in-stock notify (see *Catalog-conditional*); **(4)** out of stock; **(5)** purchasable. Until the buyer has picked everything, the button is disabled with a **neutral** reason next to it ("Choose a size") — not error styling; promote to an error only after they try to buy.
281
+
282
+ ### Adding to cart — the Cart V2 contract
170
283
 
171
- **2 · Add it.** Doc: <https://dev.wix.com/docs/api-reference/business-solutions/e-commerce/purchase-flow/cart-v2/add-line-items-to-current-cart.md?apiView=SDK> · catalogReference contract: <https://dev.wix.com/docs/api-reference/business-solutions/stores/catalog-v3/e-commerce-integration.md?apiView=SDK>
284
+ One serializer builds the catalog item for **both** the product page and any gallery add. Doc: <https://dev.wix.com/docs/api-reference/business-solutions/e-commerce/purchase-flow/cart-v2/add-line-items-to-current-cart.md?apiView=SDK> · catalogReference contract: <https://dev.wix.com/docs/api-reference/business-solutions/stores/catalog-v3/e-commerce-integration.md?apiView=SDK>
172
285
 
173
286
  ```js
174
- await currentCartV2.addLineItemsToCurrentCart({
175
- catalogItems: [{ // the write shape uses `catalogItems`
176
- quantity,
287
+ function catalogItem({ product, variant, quantity, modifierChoices = {}, customTextFields = {}, subscriptionOptionId, preorder }) {
288
+ return {
289
+ quantity, // top-level, beside catalogReference
177
290
  catalogReference: {
178
- catalogItemId: product._id, // the product's _id (the `_id` rule above)
179
291
  appId: '215238eb-22a5-4c36-9e7b-e7c08025e04e',
180
- options: { variantId }, // the resolved variantId from part 1
292
+ catalogItemId: product._id, // the product's _id (the `_id` rule)
293
+ options: {
294
+ variantId: variant._id, // MANDATORY for any product with options
295
+ ...(Object.keys(modifierChoices).length ? { options: modifierChoices } : {}), // { [modifier.key]: choice.key }
296
+ ...(Object.keys(customTextFields).length ? { customTextFields } : {}), // { [modifier.freeTextSettings.key]: text }
297
+ ...(subscriptionOptionId ? { subscriptionOptionId } : {}), // a recurring plan only; omit for one-time
298
+ ...(preorder ? { preOrderRequested: true } : {}), // preorder only
299
+ },
181
300
  },
182
- }],
301
+ };
302
+ }
303
+
304
+ const { cart } = await currentCartV2.addLineItemsToCurrentCart({ catalogItems: [item] }); // `catalogItems` — never Cart V1's `lineItems`
305
+ ```
306
+
307
+ **⚠️ CRITICAL: `options.variantId` is MANDATORY for any product that has variants.** Adding by `catalogItemId` alone **fails** — Cart V2 rejects the add with an explicit error. The method's required-params list omits `variantId`, so it's easy to miss. Resolve it (previous section) and include it.
308
+
309
+ **⚠️ CRITICAL: `options.options` is for MODIFIERS, not variant selection.** Product option selections (Size/Color) are resolved to a **variant** and referenced by `variantId`. `options.options` maps a **choice modifier**'s `key` to the chosen choice `key`; `customTextFields` maps a **free-text modifier**'s `freeTextSettings.key` to the buyer's text. Do **not** encode Size/Color as `options.options` — that is the coffee-grind bug: the variant never resolves, so Cart V2 rejects the add.
310
+
311
+ **Validate before calling:** every option selected; every `mandatory` modifier filled (a choice modifier's value is one of its choices, a free-text modifier's value non-empty); quantity ≥ 1 and not above the known ceiling; a plan chosen when subscriptions are shown; a resolved variant. Show each missing thing next to its control.
312
+
313
+ **Read the result, don't assume it.** Find the returned line (`lineItems[].source.catalogReference.catalogItemId` + `options.variantId`); a line whose `status` isn't `IN_STOCK`, or no line / `confirmedQuantity: 0`, means the add was refused — surface it. Then update your cart state from the returned `cart` (or `getCurrentCart()`) and **open the cart drawer**.
314
+
315
+ **CRITICAL: `ITEM_NOT_FOUND_IN_CATALOG` for a product that exists is a catalog defect, not a stale id.** A `DIGITAL` product whose variant has no `digitalProperties.digitalFile` is rejected under that code, and one with no stock is rejected as `exceeds available inventory` — both read back `visible: true`. Fix the product (`setup-online-store.md` → digital products); re-reading ids won't help.
316
+
317
+ ### Changing quantity and removing lines
318
+
319
+ Both take the **line's `_id`** (`cart.lineItems[]._id`), never the product id. The update body is a `lineItems` array with a nested `quantity` object — a flat `{ _id, quantity }` returns **400**:
320
+
321
+ ```js
322
+ const { cart } = await currentCartV2.updateLineItemsInCurrentCart({
323
+ lineItems: [{ lineItemId, quantity: { newQuantity } }],
183
324
  });
325
+ const { cart } = await currentCartV2.removeLineItemsFromCurrentCart([lineItemId]);
184
326
  ```
185
327
 
186
- **⚠️ CRITICAL: `options.variantId` is MANDATORY for any product that has variants.** Adding by `catalogItemId` alone **fails** — the catalog can't resolve a variant-bearing product without it, and Cart V2 **rejects the add with an explicit error** rather than accepting an invalid line. The cart method's required-params list omits `variantId`, so it's an easy one to miss. Always resolve and include it (part 1 above).
328
+ Docs: <https://dev.wix.com/docs/api-reference/business-solutions/e-commerce/purchase-flow/cart-v2/update-line-items-in-current-cart.md?apiView=SDK> · <https://dev.wix.com/docs/api-reference/business-solutions/e-commerce/purchase-flow/cart-v2/remove-line-items-from-current-cart.md?apiView=SDK>
329
+
330
+ After either, refresh the estimate (below). **Never swallow the error**: an `await … catch {}` around a mutation leaves the shopper clicking "+" with nothing happening — render the message next to the line. `quantityInfo.availableQuantity` caps the stepper when it is finite.
331
+
332
+ **Serialize price-affecting operations** (add/update/remove) per cart so a stale response can't overwrite newer state; disable checkout while one is pending. If an add may have succeeded before a timeout, re-read the cart before offering a retry — don't replay it blindly and double the line.
187
333
 
188
- **CRITICAL: `ITEM_NOT_FOUND_IN_CATALOG` for a product that exists is a catalog defect, not a stale id.** A `DIGITAL` product whose variant has no `digitalProperties.digitalFile` is rejected under that code, and one with no stock is rejected as `exceeds available inventory` — both read back `visible: true` and in the catalog. Fix the product (`setup-online-store.md` → digital products); re-reading ids won't help.
334
+ ### Three ways to buy from the gallery
189
335
 
190
- **⚠️ CRITICAL: `options.options` is for MODIFIERS, not variant selection.** Product option selections (Size/Color) are resolved to a **variant** and referenced by `variantId`. `options.options` is only for free-text / TEXT_CHOICES add-on **modifiers**. Do **not** encode Size/Color as `options.options` — that is the coffee-grind bug: the variant never resolves, so Cart V2 rejects the add with an explicit error.
336
+ Route each card by what the product needs from the buyer:
337
+
338
+ 1. **Direct Add** — no options, no modifiers, no subscriptions: add `variantSummary.minPriceVariant._id` with quantity 1, through the same serializer.
339
+ 2. **Quick Add** — the product has options or choice modifiers: fetch the full product lazily when the picker opens (`productsV3.getProduct(product._id, { fields: DETAIL_FIELDS })`), require a resolved variant and mandatory modifiers, fix quantity at 1. Anchor the picker **to its card** (an inline or attached panel; a bottom sheet on mobile) — a centered dialog is the exception for a demonstrably large configuration, not the default. Its first view shows identity, price, the first required choice, and the action.
340
+ 3. **Go to the product page** — the product has a free-text modifier or subscriptions: the gallery can't collect those inputs.
341
+
342
+ ### Cart totals — from Wix, never computed in the client
343
+
344
+ Line prices come with the cart (`pricing.totalPrice`). The **subtotal after discounts** comes from the estimate, never from summing lines yourself, and delivery/tax resolve at checkout:
345
+
346
+ ```js
347
+ const { summary } = await currentCartV2.estimateCurrentCart(); // after every add/update/remove
348
+ // summary.priceSummary.subtotal — items after item-level discounts
349
+ // summary.priceSummary.discount — the CART-level discount only (a coupon / cart rule), not the sum of every saving
350
+ // summary.priceSummary.total — estimated, without delivery/tax unless you asked for them
351
+ ```
352
+
353
+ - Show **Subtotal**, a **Discount** row only when it's non-zero, and **"Shipping and taxes are calculated at checkout"** — never a hardcoded shipping charge, free-shipping threshold, tax, or delivery date, and never a synthesized `0` for something that wasn't calculated.
354
+ - `estimateCurrentCart` is a display nicety — if it fails, the cart is still valid; show lines without the summary row.
355
+ - Doc: <https://dev.wix.com/docs/api-reference/business-solutions/e-commerce/purchase-flow/cart-v2/estimate-current-cart.md?apiView=SDK>
191
356
 
192
357
  ### Checkout — redirect to the hosted checkout page
193
358
 
194
- The cart's `_id` **is** the checkout id — pass it into the redirect session's `ecomCheckout.checkoutId`. Read the current cart, then hand its id to a redirect session, which carries the visitor/member session across to the hosted checkout on its own domain.
359
+ The cart's `_id` **is** the checkout id — pass it into the redirect session's `ecomCheckout.checkoutId`. Read the current cart, refuse an empty one or any line whose `status` isn't `IN_STOCK` (say which), then hand the id to a redirect session, which carries the visitor/member session across to the hosted checkout on its own domain.
195
360
  Doc: <https://dev.wix.com/docs/api-reference/business-solutions/e-commerce/purchase-flow/cart-v2/get-current-cart.md?apiView=SDK>
196
361
 
197
362
  ```js
@@ -200,16 +365,26 @@ const session = await redirects.createRedirectSession({
200
365
  ecomCheckout: { checkoutId: cart._id }, // the cart's _id IS the checkout id
201
366
  callbacks: { postFlowUrl: `${origin}/`, thankYouPageUrl: `${origin}/` },
202
367
  });
203
- window.location.href = session.redirectSession.fullUrl; // the hosted-checkout URL
368
+ window.location.assign(session.redirectSession.fullUrl); // the hosted-checkout URL — navigate the FULL document
204
369
  ```
205
370
 
206
- **⚠️ The cart's `_id` is the checkout id.** Pass `cart._id` straight into the redirect session's `ecomCheckout.checkoutId`. And `getCurrentCart()` returns **`{ cart }`** — destructure it, or `cart` is `undefined` and `cart._id` throws *"Cannot read properties of undefined (reading '_id')"*.
371
+ **⚠️ `getCurrentCart()` returns `{ cart }`** — destructure it, or `cart` is `undefined` and `cart._id` throws.
372
+
373
+ **⚠️ CRITICAL: `origin` for `postFlowUrl`/`thankYouPageUrl` MUST be the `https://` published host — derive it from `window.location.origin`, NEVER `new URL(request.url).origin`.** The Headless redirect allowlist registers the site's **`https://`** host and treats **`http://<same host>` as a different, unlisted origin**. When the buyer returns from the hosted checkout, an `http://` `postFlowUrl` **403s** with *"… isn't listed as an allowed redirect domain."* If you build the redirect session in a **server route**, `new URL(request.url).origin` resolves to **`http://`** behind Wix's TLS-terminating proxy → guaranteed 403 on return. So **pass `window.location.origin` from the client**, or force `https`. Doc: <https://dev.wix.com/docs/go-headless/getting-started/setup/manage-urls/add-allowed-redirect-domains>.
374
+
375
+ Keep the checkout button's copy destination-neutral ("Continue to secure checkout"). Wix-hosted checkout owns confirmation and thank-you; never build a local `/checkout` route or claim an order completed from a redirect alone. **Checkout must be reachable directly from the cart drawer** — a separate cart page is optional, never a mandatory step before checkout.
207
376
 
208
- **⚠️ CRITICAL: `origin` for `postFlowUrl`/`thankYouPageUrl` MUST be the `https://` published host — derive it from `window.location.origin`, NEVER `new URL(request.url).origin`.** The Headless redirect allowlist registers the site's **`https://`** host and treats **`http://<same host>` as a different, unlisted origin**. When the buyer returns from the hosted checkout (e.g. clicks "Continue Browsing"), the redirect goes through the allowlist — and an `http://` `postFlowUrl` **403s** with *"… isn't listed as an allowed redirect domain."* If you build the redirect session in a **server route** (`src/pages/api/*`), `new URL(request.url).origin` resolves to **`http://`** behind Wix's TLS-terminating proxy → guaranteed 403 on return. So **pass `window.location.origin` from the client** into the route (don't read the origin off the request), or force the scheme to `https`. Doc: <https://dev.wix.com/docs/go-headless/getting-started/setup/manage-urls/add-allowed-redirect-domains>.
377
+ **Buy Now** — a Buy Now button must **not** add to (or replace) the shopper's current cart. Create a **standalone** cart with the same catalog item, then hand *its* `_id` to the same redirect-session call above:
378
+
379
+ ```js
380
+ const cart = await cartV2.createCart({ cart: { source: { channelType: cartV2.ChannelType.WEB } }, catalogItems: [item] });
381
+ // then: redirects.createRedirectSession({ ecomCheckout: { checkoutId: cart._id }, callbacks })
382
+ ```
383
+ Doc: <https://dev.wix.com/docs/api-reference/business-solutions/e-commerce/purchase-flow/cart-v2/create-cart.md?apiView=SDK>. **⚠️ VERIFY IN A LIVE BUILD** that the redirect session accepts a standalone cart's id under the visitor identity before shipping Buy Now; if it doesn't, keep Buy Now off rather than routing it through the current cart.
209
384
 
210
385
  ### Formatting cart prices
211
386
 
212
- **Product** prices from `productsV3` still carry a ready-to-show `actualPriceRange.minValue.formattedAmount` — use it directly. But **Cart V2 money does not**: every cart amount — line-item `pricing.unitPrice` / `pricing.totalPrice` **and** the `estimateCurrentCart`/`calculateCurrentCart` `summary.priceSummary.*` — is a `ConvertedMoney` `{ amount, convertedAmount }` with **no** formatted string. So once items are in the cart, you format the price yourself. The currency isn't on the money object; read it from the cart (`cart.customerInfo?.currencyCode ?? cart.businessInfo?.currencyCode`), and use `convertedAmount` (buyer's display currency) when present, else `amount` (site currency):
387
+ **Product** prices from `productsV3` carry a ready-to-show `formattedAmount` — use it directly. But **Cart V2 money does not**: every cart amount — line-item `pricing.unitPrice` / `pricing.totalPrice` **and** the estimate's `summary.priceSummary.*` — is a `ConvertedMoney` `{ amount, convertedAmount }` with **no** formatted string. The currency isn't on the money object; read it from the cart, and use `convertedAmount` (buyer's display currency) when present, else `amount` (site currency):
213
388
 
214
389
  ```js
215
390
  function formatCartMoney(money, cart) {
@@ -217,58 +392,119 @@ function formatCartMoney(money, cart) {
217
392
  const currency = cart?.customerInfo?.currencyCode ?? cart?.businessInfo?.currencyCode ?? 'USD';
218
393
  return value == null ? '' : new Intl.NumberFormat(undefined, { style: 'currency', currency }).format(Number(value));
219
394
  }
220
- // e.g. line item: formatCartMoney(item.pricing.totalPrice, cart)
221
- // subtotal: formatCartMoney(estimate.summary.priceSummary.subtotal, cart)
222
395
  ```
223
396
 
224
397
  Never hardcode `$` or assume USD — stores run in EUR/GBP too.
225
398
 
226
- ### Showing stock state
399
+ ### Showing stock state and quantity
227
400
 
228
- Read the **V3** inventory fields: product-level in-stock is `product.inventory.availabilityStatus` (`"IN_STOCK"`); variant-level is `variant.inventoryStatus.inStock`. Reading the V1 inventory field on V3 data returns `undefined` → everything renders out-of-stock (the all-OOS bug). These come from `productsV3` / `readOnlyVariantsV3`, not the V1 module.
401
+ Read the **V3** inventory fields: product-level is `product.inventory.availabilityStatus` (`IN_STOCK` | `OUT_OF_STOCK` | `PARTIALLY_OUT_OF_STOCK`), variant-level is `variant.inventoryStatus.inStock` / `preorderEnabled`. Reading the V1 inventory field on V3 data returns `undefined` → everything renders out-of-stock (the all-OOS bug). In the cart, `quantityInfo.availableQuantity` caps the stepper when it's finite.
402
+
403
+ If you show a quantity ceiling from a tracked inventory count, **a tracked zero stays zero**: use `Math.max(0, remaining)`, never `remaining || 99999` — that `||` turns "sold out" into "buy any amount". Untracked inventory gets a deliberate UI ceiling (e.g. 99). Wix rejects an over-stock add explicitly either way — surface that message; don't clamp silently.
229
404
 
230
405
  ### Rendering product images
231
406
 
232
- Product media may come back as a **`wix:image://v1/<hash>/<file>#originWidth=…` identifier, not a ready URL** — this is what the SDK returns for images stored in Wix Media (e.g. once brand imagery is attached). Putting that string straight into `<img src>` shows nothing. `media.main` may carry **either** an already-absolute `.url` (e.g. an Unsplash placeholder seeded when imagery is off) **or** an `.image` that is a `wix:image://` id needing resolution — handle both with **one** helper and reuse it on every page that renders an image:
407
+ Product media may come back as a **`wix:image://v1/<hash>/<file>#originWidth=…` identifier, not a ready URL**. Putting that string straight into `<img src>` shows nothing. `media.main` may carry **either** an already-absolute `.url` (a placeholder seeded when imagery is off) **or** an `.image` that is a `wix:image://` id needing resolution — handle both with **one** helper and reuse it on every page that renders an image:
233
408
 
234
409
  ```js
235
410
  import { media } from '@wix/sdk';
236
- function imgSrc(mediaMain, w = 600, h = 600) {
237
- const v = mediaMain?.image ?? mediaMain?.url ?? mediaMain; // the value can be a string or {url}
411
+ function imgSrc(mediaLike, w = 600, h = 600) {
412
+ const v = mediaLike?.image ?? mediaLike?.url ?? mediaLike; // the value can be a string or {url}
238
413
  if (!v) return '';
239
- if (typeof v === 'string' && v.startsWith('wix:image://')) return media.getScaledToFillImageUrl(v, w, h);
414
+ if (typeof v === 'string' && v.startsWith('wix:image://')) return media.getScaledToFillImageUrl(v, w, h, {});
240
415
  return typeof v === 'string' ? v : (v.url ?? ''); // already an absolute https URL
241
416
  }
417
+ // Responsive delivery: several scaled candidates + sizes, so a card never downloads a hero-sized file.
418
+ function imgSrcSet(mediaLike, widths = [320, 480, 640, 960], ratio = 1) {
419
+ return widths.map((w) => `${imgSrc(mediaLike, w, Math.round(w * ratio))} ${w}w`).join(', ');
420
+ }
421
+ // <img src={imgSrc(m, 640, 640)} srcset={imgSrcSet(m)} sizes="(min-width: 1024px) 25vw, 50vw" loading="lazy" width="640" height="640" alt={item.altText ?? product.name} />
242
422
  ```
243
423
 
244
- **⚠️ Do NOT write `m.url ?? m.image` (or `image?.url ?? image`).** That returns the bare **`wix:image://` string** whenever `.url` is absent — which is exactly the Wix-Media case — and the browser fails it with **`ERR_UNKNOWN_URL_SCHEME`** (blank image). The `wix:image://` branch must go through `media.getScaledToFillImageUrl`; never return it raw. Define the helper **once** and call it from every render path (home, listing, product, cart) — resolving on some pages but not others is the common partial failure.
424
+ **⚠️ Do NOT write `m.url ?? m.image` (or `image?.url ?? image`).** That returns the bare **`wix:image://` string** whenever `.url` is absent — exactly the Wix-Media case — and the browser fails it with **`ERR_UNKNOWN_URL_SCHEME`**. The `wix:image://` branch must go through `media.getScaledToFillImageUrl`; never return it raw. **Never hand-build a `static.wixstatic.com/.../v1/fit/...` URL** either — the format is easy to get wrong and the image then **403s**. Doc: <https://dev.wix.com/docs/sdk/core-modules/sdk/media>
245
425
 
246
- **Never hand-build a `static.wixstatic.com/.../v1/fit/...` URL** either — the format is easy to get wrong and the image then **403s**. Only `wix:image://` values need resolving; an already-absolute `https://` URL goes straight into `<img src>`. Doc: <https://dev.wix.com/docs/sdk/core-modules/sdk/media>
247
-
248
- **This applies to cart line-item images too, not just product reads.** A cart `lineItem.attributes.image` is the same `wix:image://` identifier — run it through the same `imgSrc()` helper before `<img src>`. (If you build the cart over an API route, resolve there and return a ready URL so the component never sees a `wix:image://`.)
426
+ **This applies to cart line-item images too** — a cart `lineItem.attributes.image` is the same identifier; run it through the same helper. Give every image a stable aspect ratio (`aspect-ratio` + `object-fit`) and `width`/`height` so the grid doesn't jump while loading; lazy-load below the fold. Never stretch a thumbnail into a hero — when hero-scale media is absent, compose the hero from typography or a product tile instead.
249
427
 
250
428
  ### Rendering product descriptions
251
429
 
252
- Don't print the raw node object. A product description is rich text (`description.nodes`). Render the rich-text nodes, or use `plainDescription` for a plain string. Printing the raw node object dumps literal `<p>…</p>` into the page.
430
+ **`plainDescription` is an HTML string** (`<p>…</p>`, `<br>`, `<strong>`) despite its name — the run that prints it as text shows literal `<p>` on every card. Render it as HTML (`set:html` in Astro, `dangerouslySetInnerHTML` in React) after sanitizing; for a card teaser or a meta description, strip the tags first (`plainDescription.replace(/<[^>]+>/g, '')`) and truncate. The rich-text form is `description.nodes` — render the nodes (headings, lists, links, emphasis preserved, sanitized) when you want structure; never print the raw node object. Info sections (`INFO_SECTION`) are the same shape — render them as sections or accordions, don't flatten them into one paragraph.
431
+
432
+ ### SEO on item pages
433
+
434
+ A **product page** and a **category page** are Wix **item pages**: their `<title>`/description/OG/canonical come from what the owner sets in the dashboard (the entity's `seoData`).
435
+
436
+ **Astro (Wix-managed):** the three pieces live in **three different import paths** — guessing them from the root package fails the build (`"loadSEOTagsServiceConfig" is not exported by @wix/seo`), and dropping SEO to unblock the build is not an acceptable fix. This block is the whole contract; reproduce it exactly (deps: `@wix/seo`, `@wix/essentials ≥ 1.0.10` — both already in a scaffolded project):
253
437
 
254
- ### SEO on item pages (Astro, Wix-managed)
438
+ ```astro
439
+ ---
440
+ // src/pages/products/[slug].astro
441
+ import { WIX_APPS } from "@wix/essentials";
442
+ import { SEO } from "@wix/seo/components";
443
+ import { loadSEOTagsServiceConfig } from "@wix/seo/services";
444
+ import { seoTags } from "@wix/seo";
445
+
446
+ // Registers the route for the sitemap + the dashboard SEO editor. checkoutAndOrders, NOT
447
+ // WIX_APPS.stores (stores.id is the catalog id for catalogReference).
448
+ export const wixMetadata = {
449
+ appDefId: WIX_APPS.checkoutAndOrders.id,
450
+ pageIdentifier: WIX_APPS.checkoutAndOrders.productPageMetadata.pageIdentifier,
451
+ identifiers: { slug: WIX_APPS.checkoutAndOrders.productPageMetadata.identifiers.handle },
452
+ };
453
+
454
+ const slug = Astro.params.slug!;
455
+ // Behind Wix's proxy the request URL is internal — the public page URL arrives on x-wix-forwarded-url.
456
+ const forwardedUrl = Astro.request.headers.get("x-wix-forwarded-url");
457
+ const pageUrl =
458
+ forwardedUrl && URL.canParse(forwardedUrl) && /^https?:$/.test(new URL(forwardedUrl).protocol)
459
+ ? forwardedUrl
460
+ : Astro.url.href;
461
+
462
+ let product = null, seoTagsServiceConfig = null;
463
+ try {
464
+ [product, seoTagsServiceConfig] = await Promise.all([
465
+ productsV3.getProductBySlug(slug, { fields: DETAIL_FIELDS }).then((r) => r.product ?? null),
466
+ loadSEOTagsServiceConfig({ pageUrl, itemType: seoTags.ItemType.STORES_PRODUCT, itemData: { slug } }),
467
+ ]);
468
+ } catch {} // an unguarded SSR throw truncates the response
469
+ if (!product) return new Response(null, { status: 404 });
470
+ ---
471
+ <Layout title={product.name}>
472
+ <SEO.Tags seoTagsServiceConfig={seoTagsServiceConfig} slot="seo-tags" /> <!-- Layout renders <slot name="seo-tags" /> in <head> -->
473
+ …
474
+ </Layout>
475
+ ```
476
+
477
+ A dedicated category route uses `categoryPageMetadata` + `seoTags.ItemType.STORES_CATEGORY` with the same imports. (A category rendered only as a query-string *filter* on the shop page is a main page — automatic SEO, no `wixMetadata`.) Guide: <https://dev.wix.com/docs/go-headless/wix-managed-headless/seo/add-seo-support-to-item-pages.md>.
478
+
479
+ **Non-Astro (you render the head yourself):** map `product.seoData` / `category.seoData` tags into the head and fill only what's missing from truthful entity data. Rules that hold on both paths: **one** document title and **one** canonical per page (a merchant override wins over a generated fallback; a tag the merchant disabled stays absent); a missing entity returns a **real 404**, never a rendered shell with another product's tags; clear route tags on client navigation so a product's metadata can't leak into the next page; a `Product` JSON-LD block only from real data — never fabricated reviews, ratings, or availability.
255
480
 
256
- A **product detail** page is a Wix **item page**: its `<title>`/description/OG/canonical come from what the owner sets in the dashboard. On the Astro (Wix-managed) frontend, wire it per the canonical guide — **[Add SEO Support to Item Pages](https://dev.wix.com/docs/go-headless/wix-managed-headless/seo/add-seo-support-to-item-pages.md)** — which covers the three steps: export `wixMetadata` (registers the route → sitemap + dashboard SEO editor), call `loadSEOTagsServiceConfig(...)`, and render `<SEO.Tags>` (from `@wix/seo`; deps + `@wix/essentials ≥ 1.0.10` are in the guide's "Before you begin").
481
+ ### Catalog-conditional capabilities (build when the catalog has them — never fabricate them)
257
482
 
258
- For a product page use:
259
- - **`wixMetadata`** from `WIX_APPS.checkoutAndOrders.productPageMetadata` — referenced **directly** in the export (module scope). ⚠️ It's `WIX_APPS.checkoutAndOrders`, **not** `WIX_APPS.stores` (`stores.id` is the catalog id for `catalogReference`, a different value). The `identifiers` key is your route param; the token is `…productPageMetadata.identifiers.handle`.
260
- - **`itemType`**: `seoTags.ItemType.STORES_PRODUCT`.
483
+ Read the catalog first; then, **only when the data is present**:
484
+
485
+ - **Subscriptions** (`subscriptionPricesInfo` present): show each plan's price and billing interval as comparable terms; require an **explicit** plan choice (never pre-select the first); offer one-time purchase only when `allowOneTimePurchases` is true; serialize a recurring plan as `subscriptionOptionId`, omit it for one-time. Don't apply the one-time automatic-discount price to a subscription quote.
486
+ - **Preorder** (`inventory.preorderStatus === 'ENABLED'` / `variant.inventoryStatus.preorderEnabled`): label "Pre-order", include `preOrderRequested: true`. Preorder and a subscription plan may coexist.
487
+ - **Product groups** (`extendedFields.namespaces['@stores/product-groups'].productGroupId` is a string): the group's members are **navigation between products** (the "same shirt in linen" case), not variants — query the group via the Product Groups API and render members as choices that navigate to the member's slug.
488
+ - **Positioned promotions**: `promotionsV3.resolvePromotions(categoryId)` returns category-gallery banners with a **one-based** `position` — insert at `position - 1` only once that many products have loaded. They are editorial media, not evidence of a discount, and they belong to that category's gallery, not the homepage. Docs: <https://dev.wix.com/docs/api-reference/business-solutions/stores/catalog-v3/promotions-v3.md>
489
+ - **Back-in-stock ("Notify me")**: only when the store's back-in-stock settings allow it, the selected variant is out of stock, and you have an implemented, session-safe notification endpoint — a form whose submit goes nowhere is not a graceful placeholder.
490
+ - **Filter facets** beyond price/stock (color, size): the option choice fields (`options.choicesSettings.choices.choiceId` with `$hasSome`) discover *products*; Quick Add or the PDP still resolves the actual variant.
491
+
492
+ For the exact shapes of the group, notification, and inventory APIs, find the method pages via `DOC_DISCOVERY.md` (`document_type: SDK`); they aren't pinned here because most catalogs don't use them.
493
+
494
+ ---
261
495
 
262
- **If you build a dedicated category route** (e.g. `/category/[slug]` or `/search/[collection]`), wire it the same way with `WIX_APPS.checkoutAndOrders.categoryPageMetadata` + `seoTags.ItemType.STORES_CATEGORY`. (A category rendered only as a query-string *filter* on the products listing is a main page — it gets its SEO from automatic injection, no `wixMetadata` needed.)
496
+ ## What a complete storefront shows
263
497
 
264
- Optional: render a `Product` schema.org JSON-LD `<script>` from the fetched product for rich results (see the guide's structured-data step).
498
+ The API wiring above is necessary, not sufficient. **Read `experience-store.md`** (same folder) before designing — the store's own bar on top of `DESIGN.md`/`CONTENT.md`: derive the direction from the catalog, what the homepage, gallery, product page, and cart must show, the overlay contract, truthful commerce copy, and image discipline. `CAPABILITIES.md` → stores carries the same bar in plain language for the handoff.
265
499
 
266
500
  ---
267
501
 
268
502
  ## Conclusion
269
503
  A correct Catalog V3 storefront frontend:
270
- - imports **`productsV3` / `readOnlyVariantsV3` / `categories` / `currentCartV2` / `redirects`** — never the V1 `products`/`collections` modules;
271
- - uses **`product._id`** (never `product.id`) as the cart's `catalogItemId`;
272
- - resolves the **mandatory `variantId`** via `readOnlyVariantsV3` and passes it as `options.variantId` (not `options.options`);
273
- - builds its category nav from a **live `categories.queryCategories()`** and filters category pages server-side with **`searchProducts` + `$matchItems: [{ id: categoryId }]`** keyed on the live `categoryId` — never a frozen seed-time `productIds` map, never `queryProducts` for category filtering, never `$hasSome`, never V1 `collectionIds`;
274
- - reads inventory from the **V3** shape and renders rich-text descriptions, not raw nodes.
504
+ - imports **`productsV3` / `categories` / `currentCartV2` / `cartV2` / `redirects`** — never the V1 `products`/`collections` modules or Cart V1;
505
+ - requests **`LIST_FIELDS` / `DETAIL_FIELDS`** on every read, and uses **`product._id`** (never `product.id`) as the cart's `catalogItemId`;
506
+ - reads the product page with **`getProductBySlug` + `VARIANT_OPTION_CHOICE_NAMES`** and resolves the variant by **sorted choice IDs** — selections start empty, the mandatory **`variantId`** goes in `options.variantId` (not `options.options`);
507
+ - prices with **`priceAfterDiscount` → `actualPrice` → `compareAtPrice`** precedence and renders **every ribbon**, never inferring one from the other;
508
+ - sorts, filters, and pages **on Wix** (`searchProducts` + `$matchItems: [{ id: categoryId }]` on `allCategoriesInfo.categories`, cursor paging at 24) — never a frozen seed-time list, never `queryProducts` for categories, never `$hasSome`, never V1 `collectionIds`;
509
+ - shows cart totals from **`estimateCurrentCart`**, changes quantity with **`{ lineItems: [{ lineItemId, quantity: { newQuantity } }] }`**, checks out through the **redirect session** with an `https://` origin, and keeps Buy Now on a standalone cart;
510
+ - builds the homepage, gallery, product page, and side cart to the bar in `experience-store.md` — the store is the surfaces, not the calls.