@wix/agent-skills 1.19.1 → 1.20.0

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 (99) 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/SERVICE_PLUGIN.md +14 -1
  4. package/skills/wix-app/references/editor-react-component/ACCESSIBILITY.md +7 -2
  5. package/skills/wix-app/references/editor-react-component/ANIMATED-COMPONENTS.md +53 -28
  6. package/skills/wix-app/references/editor-react-component/COMPONENT-CONTRACT.md +17 -4
  7. package/skills/wix-app/references/editor-react-component/FUNCTION-HANDLERS.md +71 -31
  8. package/skills/wix-app/references/editor-react-component/REACT-GUIDELINES.md +3 -4
  9. package/skills/wix-app/references/service-plugin/ADDITIONAL-FEES.md +4 -0
  10. package/skills/wix-app/references/service-plugin/AVAILABILITY-TIME-SLOTS-CONFIGURATION.md +118 -0
  11. package/skills/wix-app/references/service-plugin/BOOKING-POLICY.md +65 -0
  12. package/skills/wix-app/references/service-plugin/BOOKINGS-PRICING.md +71 -0
  13. package/skills/wix-app/references/service-plugin/BOOKINGS-STAFF-SORTING.md +11 -3
  14. package/skills/wix-app/references/service-plugin/BOOKINGS-VALIDATION.md +137 -0
  15. package/skills/wix-app/references/service-plugin/DISCOUNT-TRIGGERS.md +23 -16
  16. package/skills/wix-app/references/service-plugin/GIFT-CARDS.md +4 -0
  17. package/skills/wix-app/references/service-plugin/PAYMENT-SETTINGS.md +4 -2
  18. package/skills/wix-app/references/service-plugin/REALTIME-PERMISSIONS.md +45 -0
  19. package/skills/wix-app/references/service-plugin/SHIPPING-RATES.md +15 -0
  20. package/skills/wix-app/references/service-plugin/TOOLS_PROVIDER.md +4 -0
  21. package/skills/wix-app/references/service-plugin/VALIDATIONS.md +4 -0
  22. package/skills/wix-base44-connector/SKILL.md +46 -24
  23. package/skills/wix-headless/SKILL.md +1 -0
  24. package/skills/wix-headless/references/CAPABILITIES.md +7 -2
  25. package/skills/wix-headless/references/SDK_HANDOFF.md +2 -1
  26. package/skills/wix-headless/references/inline-recipes/experience-store.md +49 -0
  27. package/skills/wix-headless/references/inline-recipes/how-to-code-a-store.md +431 -115
  28. package/skills/wix-headless/references/inline-recipes/how-to-code-members-astro.md +1 -1
  29. package/skills/wix-headless/references/managed/CREATE.md +5 -0
  30. package/skills/wix-headless/references/managed/DEPLOYMENT.md +8 -8
  31. package/skills/wix-headless-fast/install/fast-path.mjs +3 -1
  32. package/skills/wix-headless-fast/references/shared/app/wix/media.ts +13 -0
  33. package/skills/wix-headless-fast/references/storefront/INSTRUCTIONS.md +227 -56
  34. package/skills/wix-headless-fast/references/storefront/app/components/storefront/CartDrawer.tsx +37 -3
  35. package/skills/wix-headless-fast/references/storefront/app/components/storefront/FilterPanel.tsx +266 -0
  36. package/skills/wix-headless-fast/references/storefront/app/components/storefront/QuickAdd.tsx +165 -0
  37. package/skills/wix-headless-fast/references/storefront/app/hooks/storefront/useProductDetail.ts +48 -15
  38. package/skills/wix-headless-fast/references/storefront/app/hooks/storefront/useShop.ts +129 -11
  39. package/skills/wix-headless-fast/references/storefront/app/wix/storefront/cart.ts +36 -6
  40. package/skills/wix-headless-fast/references/storefront/app/wix/storefront/catalog.ts +190 -24
  41. package/skills/wix-headless-fast/references/storefront/app/wix/storefront/types.ts +62 -4
  42. package/skills/wix-headless-fast/references/storefront/app-astro/layouts/SiteLayout.astro +21 -2
  43. package/skills/wix-headless-fast/references/storefront/seed/SEED.md +5 -1
  44. package/skills/wix-headless-fast/references/storefront/seed/seed-store.mjs +59 -6
  45. package/skills/wix-manage/SKILL.md +8 -5
  46. package/skills/wix-manage/references/bookings/create-and-update-booking-services.md +4 -1
  47. package/skills/wix-manage/references/bookings/create-appointment-service.md +3 -1
  48. package/skills/wix-manage/references/bookings/create-class-service.md +3 -1
  49. package/skills/wix-manage/references/bookings/create-course-service.md +4 -2
  50. package/skills/wix-manage/references/bookings/end-to-end-booking-flow.md +68 -3
  51. package/skills/wix-manage/references/ecommerce/pricing-promotions/ecom-pricing-flow-seasonal-promotion.md +1 -1
  52. package/skills/wix-manage/references/events/create-wix-event.md +46 -0
  53. package/skills/wix-manage/references/events/manage-wix-events.md +51 -1
  54. package/skills/wix-manage/references/google-ads/manage-campaign-lifecycle.md +6 -6
  55. package/skills/wix-manage/references/google-ads/manage-campaign-success-guide.md +108 -14
  56. package/skills/wix-manage/references/seo/manage-content-plan.md +1 -1
  57. package/skills/wix-manage/references/seo/manage-seo-suggestions.md +351 -0
  58. package/skills/wix-manage/references/sites/upload-static-site.md +264 -34
  59. package/skills/wix-vibe-headless/install/deploy.cjs +33 -2
  60. package/skills/wix-vibe-headless/platforms/base44-ecom-light-seed-with-install.md +1 -1
  61. package/skills/wix-vibe-headless/platforms/base44-ecom-light-seed.md +13 -4
  62. package/skills/wix-vibe-headless/platforms/base44-ecom-light.md +1 -1
  63. package/skills/wix-vibe-headless/platforms/base44.md +19 -4
  64. package/skills/wix-vibe-headless/platforms/generic.md +6 -0
  65. package/skills/wix-vibe-headless/references/_shared/nav/nav.tanstack.js +30 -0
  66. package/skills/wix-vibe-headless/references/_shared/routing.md +86 -0
  67. package/skills/wix-vibe-headless/references/blog/INSTRUCTIONS.md +44 -0
  68. package/skills/wix-vibe-headless/references/blog/app/components/PostCard.jsx +1 -1
  69. package/skills/wix-vibe-headless/references/blog/app/components/PostChips.jsx +1 -1
  70. package/skills/wix-vibe-headless/references/blog/app/pages/CategoryPage.jsx +1 -1
  71. package/skills/wix-vibe-headless/references/blog/app/pages/PostDetail.jsx +1 -1
  72. package/skills/wix-vibe-headless/references/blog/app/pages/TagPage.jsx +1 -1
  73. package/skills/wix-vibe-headless/references/bookings/INSTRUCTIONS.md +42 -0
  74. package/skills/wix-vibe-headless/references/bookings/app/components/ServiceCard.jsx +1 -1
  75. package/skills/wix-vibe-headless/references/bookings/app/pages/ServiceDetail.jsx +1 -1
  76. package/skills/wix-vibe-headless/references/events/INSTRUCTIONS.md +42 -0
  77. package/skills/wix-vibe-headless/references/events/app/components/EventCard.jsx +1 -1
  78. package/skills/wix-vibe-headless/references/events/app/pages/EventDetail.jsx +1 -1
  79. package/skills/wix-vibe-headless/references/members/INSTRUCTIONS.md +42 -0
  80. package/skills/wix-vibe-headless/references/members/app/components/MemberMenu.jsx +1 -1
  81. package/skills/wix-vibe-headless/references/members/app/components/RequireAuth.jsx +1 -1
  82. package/skills/wix-vibe-headless/references/members/app/pages/Login.jsx +1 -1
  83. package/skills/wix-vibe-headless/references/portfolio/INSTRUCTIONS.md +43 -0
  84. package/skills/wix-vibe-headless/references/portfolio/app/components/CollectionCard.jsx +1 -1
  85. package/skills/wix-vibe-headless/references/portfolio/app/components/ProjectCard.jsx +1 -1
  86. package/skills/wix-vibe-headless/references/portfolio/app/pages/CollectionPage.jsx +1 -1
  87. package/skills/wix-vibe-headless/references/portfolio/app/pages/ProjectDetail.jsx +1 -1
  88. package/skills/wix-vibe-headless/references/pricing-plans/INSTRUCTIONS.md +43 -0
  89. package/skills/wix-vibe-headless/references/pricing-plans/app/components/PlanCard.jsx +1 -1
  90. package/skills/wix-vibe-headless/references/pricing-plans/app/pages/PlanDetail.jsx +1 -1
  91. package/skills/wix-vibe-headless/references/restaurants/INSTRUCTIONS.md +36 -0
  92. package/skills/wix-vibe-headless/references/shared/app/lib/nav.js +9 -0
  93. package/skills/wix-vibe-headless/references/storefront/INSTRUCTIONS.md +110 -27
  94. package/skills/wix-vibe-headless/references/storefront/app/components/CartDrawer.jsx +32 -14
  95. package/skills/wix-vibe-headless/references/storefront/app/context/CartContext.jsx +14 -3
  96. package/skills/wix-vibe-headless/references/storefront/app/hooks/useProductCard.js +47 -35
  97. package/skills/wix-vibe-headless/references/storefront/app/hooks/useProductDetail.js +77 -30
  98. package/skills/wix-vibe-headless/references/storefront/app/rest/wix-store-cart.js +31 -2
  99. package/skills/wix-vibe-headless/references/storefront/app/rest/wix-store-catalog.js +31 -6
@@ -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,95 @@ 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
+ subscriptionInfo: { title: { original }, subscriptionSettings: { frequency /* DAY|WEEK|MONTH|YEAR */, interval, billingCycles, autoRenewal } } } // present on a subscription line only
112
+ // price → pricing.unitPrice (ConvertedMoney, NO formatted string in V2 — format it yourself); qty → quantityInfo.confirmedQuantity; image → attributes.image (wix:image:// → resolve)
83
113
 
84
114
  // the cart's _id is the checkout id → pass to the redirect session:
85
115
  // redirects.createRedirectSession({ ecomCheckout: { checkoutId: cart._id }, callbacks }) → { redirectSession: { fullUrl } }
@@ -89,109 +119,294 @@ lineItem = { _id, name: { original }, quantityInfo: { confirmedQuantity }, prici
89
119
 
90
120
  ## The storefront features (build the ones the site needs)
91
121
 
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).
122
+ 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
123
 
94
124
  ### Listing products (and the `_id` rule)
95
125
 
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>
126
+ Query products with `productsV3.searchProducts()` (the gallery: filter/sort/paging server-side) or `productsV3.queryProducts()` (a simple all-products list). Always pass `LIST_FIELDS`.
127
+ 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
128
 
101
- **⚠️ `queryProducts()` returns a builder, not a Promise.** Pass requested fields when needed, then chain `.eq(...)`/`.limit(...)`/`.find()` — for example:
129
+ **⚠️ `queryProducts()` returns a builder, not a Promise.** Pass requested fields, then chain `.limit(...)`/`.find()`:
102
130
 
103
131
  ```js
104
- const PRODUCT_FIELDS = ['CURRENCY', 'PLAIN_DESCRIPTION'];
105
- const { items } = await productsV3
106
- .queryProducts({ fields: PRODUCT_FIELDS })
107
- .limit(50)
108
- .find();
132
+ const { items } = await productsV3.queryProducts({ fields: LIST_FIELDS }).limit(24).find();
109
133
  ```
110
134
 
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.
135
+ Do not pass a search/filter object to `queryProducts`; use `searchProducts` for that (below).
112
136
 
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`.)
137
+ **⚠️ 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
138
 
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`.
139
+ **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
140
 
117
141
  **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
142
 
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`.
143
+ ### The gallery query — sort, filter, and page on Wix, never on a loaded page
144
+
145
+ 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 }`):
146
+
147
+ ```js
148
+ const conditions = [{ visible: true }];
149
+ if (categoryId) conditions.push({ 'allCategoriesInfo.categories': { $matchItems: [{ id: categoryId }] } });
150
+ // Search rejects two operators in ONE field object — price bounds are SEPARATE conditions joined by $and:
151
+ if (minPrice != null) conditions.push({ 'actualPriceRange.minValue.amount': { $gte: String(minPrice) } });
152
+ if (maxPrice != null) conditions.push({ 'actualPriceRange.minValue.amount': { $lte: String(maxPrice) } });
153
+ if (inStockOnly) conditions.push({ 'inventory.availabilityStatus': { $eq: 'IN_STOCK' } });
154
+ // Option facets (Color, Size…) — selected choice IDs; matches products carrying ANY of them:
155
+ if (choiceIds.length) conditions.push({ 'options.choicesSettings.choices.choiceId': { $hasSome: choiceIds } });
156
+ const filter = { $and: conditions };
157
+
158
+ const SORTS = {
159
+ featured: undefined, // the catalog's own order — NOT a sales ranking
160
+ priceAsc: [{ fieldName: 'actualPriceRange.minValue.amount', order: 'ASC' }, { fieldName: 'name', order: 'ASC' }],
161
+ priceDesc: [{ fieldName: 'actualPriceRange.minValue.amount', order: 'DESC' }, { fieldName: 'name', order: 'ASC' }],
162
+ nameAsc: [{ fieldName: 'name', order: 'ASC' }],
163
+ newest: [{ fieldName: 'createdDate', order: 'DESC' }, { fieldName: 'name', order: 'ASC' }],
164
+ };
165
+
166
+ const { products, pagingMetadata } = await productsV3.searchProducts(
167
+ {
168
+ ...(cursor
169
+ ? {} // a cursor CONTINUES the original query — send it alone
170
+ : { filter, ...(SORTS[sort] ? { sort: SORTS[sort] } : {}),
171
+ ...(search ? { search: { expression: search.trim(), fields: ['name'] } } : {}) }),
172
+ cursorPaging: { limit: 24, ...(cursor ? { cursor } : {}) },
173
+ },
174
+ { fields: LIST_FIELDS },
175
+ );
176
+ const nextCursor = pagingMetadata?.cursors?.next ?? null; // null → no more pages
177
+ const { count } = await productsV3.countProducts({ filter }); // the "N products" line; issue alongside the page
178
+ ```
179
+
180
+ - **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).
181
+ - **The filter panel is part of the gallery, not an option** — whenever the catalog has anything to filter (more than a handful of products, prices that differ, an option like Color or Size), the gallery ships sort, price bounds, in-stock, and the option facets, plus the result `count`, active-filter chips, and a clear-all. Build the facets from the scope's own products (one 100-product `searchProducts` with the same category condition; collect each `options[].name` with its visible `choicesSettings.choices[]` — `choiceId`, `name`, `colorCode`; keep facets with more than one choice) so the panel never offers a filter nothing matches; a `SWATCH_CHOICES`/`COLOR_CHOICES` facet renders as swatches. Filters that stay visible beside the results commit immediately; filters in a sheet (small screens) stage changes until Apply.
182
+ - Keep filter/sort state in the URL so back/forward and refresh preserve it — but translate back to the IDs Wix expects before querying.
183
+ - Page size **24**. Offer "load more" or prev/next from the cursor; never raise the limit to avoid paging.
184
+ - Docs: <https://dev.wix.com/docs/api-reference/business-solutions/stores/catalog-v3/products-v3/supported-filters-and-sorting.md?apiView=SDK>
185
+
186
+ **⚠️ 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:
187
+
188
+ - **Field:** `allCategoriesInfo.categories` — includes parent categories, so a product in a subcategory shows on the parent's page too.
189
+ - **`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).
190
+ - **Operator:** `$matchItems`, never `$hasSome` (the natural-looking guess returns nothing). **Inner key:** `id` (the category GUID).
191
+ - **Never** the V1 `collectionIds` / `collections.id` paths — they return empty against V3 data.
120
192
 
121
193
  ### Category navigation — list categories live
122
194
 
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).
195
+ **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
196
 
125
197
  ```js
126
198
  import { categories } from '@wix/categories';
127
199
  const res = await categories.queryCategories({
128
200
  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>
201
+ }).exists('name', true).find(); // items: { _id, name, slug, visible, parentCategory? }
202
+ // Every category is a PAGE at /category/<slug> — link each one from the nav (or a menu/footer when
203
+ // the tree is large). A category that exists only as a filter toggle on the shop page has no URL a
204
+ // shopper can share or a search engine can index.
130
205
  ```
131
206
 
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>.
207
+ **⚠️ 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
208
 
134
- ### Filtering products by category
209
+ 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
210
 
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).
211
+ ### The product page read (one call, variants included)
137
212
 
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:
213
+ The product page needs **one complete product** — a different read from the gallery:
139
214
 
140
215
  ```js
141
- const { products } = await productsV3.searchProducts({
142
- filter: { 'directCategoriesInfo.categories': { $matchItems: [{ id: categoryId }] } },
143
- fields: PRODUCT_FIELDS,
144
- });
216
+ const { product } = await productsV3.getProductBySlug(slug, { fields: DETAIL_FIELDS });
217
+ if (!product) return notFound(); // a real 404 — NEVER fall back to the first product in a list
145
218
  ```
219
+ Doc: <https://dev.wix.com/docs/api-reference/business-solutions/stores/catalog-v3/products-v3/get-product-by-slug.md?apiView=SDK>
146
220
 
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.
221
+ **⚠️ `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.)
222
+
223
+ 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`. **De-duplicate on the media identity** (the item's `_id`, or the raw `wix:image://` id before its `#` suffix), never on a resolved URL: `media.main` and its copy in `itemsInfo.items` scaled to two sizes are two URLs of one photo, and the gallery then shows two thumbnails that open the same image.
224
+
225
+ **How the page is split — one price, on the page in the HTML:**
152
226
 
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>
227
+ ```astro
228
+ ---
229
+ // src/pages/products/[slug].astro — the page owns the read, the name, the gallery, the description.
230
+ const { product } = await productsV3.getProductBySlug(slug, { fields: DETAIL_FIELDS });
231
+ if (!product) return new Response(null, { status: 404 });
232
+ ---
233
+ <!-- Two columns only from md; on a phone the gallery is a bounded band (e.g. max-h-[45vh]) so the
234
+ name, price, first option, and the action are still in the first screen at 390px wide. -->
235
+ <ProductPurchase client:load product={product} /> <!-- the island owns price, options, quantity, action -->
236
+ ```
154
237
 
155
- ### Adding to cart — the V3 cart contract
238
+ 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. Description and info sections come **after** the buy action, never between the price and the button.
156
239
 
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.
240
+ ### Prices — which one the buyer actually pays
158
241
 
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:
242
+ Three prices can exist on a variant; the precedence is exact:
160
243
 
161
244
  ```js
162
- const { items } = await readOnlyVariantsV3
163
- .queryVariants()
164
- .eq('productData.productId', product._id) // NOTE: productData.productId is the filter field
165
- .find();
245
+ // price = variant.price (PDP, after selection) or variantSummary.minPriceVariant.price (cards).
246
+ // Each field is { amount, formattedAmount } — render formattedAmount.
247
+ function sellingPrice(price) {
248
+ if (price?.priceAfterDiscount !== undefined) { // explicit check: a valid ZERO discount price is a real price
249
+ return { current: price.priceAfterDiscount, original: price.actualPrice }; // strike actualPrice, NOT compareAtPrice
250
+ }
251
+ return { current: price?.actualPrice, original: price?.compareAtPrice }; // original may be undefined
252
+ }
166
253
  ```
167
- Doc: <https://dev.wix.com/docs/api-reference/business-solutions/stores/catalog-v3/read-only-variants-v3/query-variants.md?apiView=SDK>
168
254
 
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`**.
255
+ - **Before a variant is selected, show the range — not the minimum with a struck "was".** A product priced 39.99–44.99 with a 54.99 compare-at must read "₪39.99 – ₪44.99" until the buyer picks; "was ₪54.99 ₪39.99" claims a saving that may not apply to the variant they choose. Once every option is chosen, switch to `sellingPrice(variant.price)` and update the buy button with it:
170
256
 
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>
257
+ ```js
258
+ const { minValue: min, maxValue: max } = product.actualPriceRange ?? {};
259
+ const rangeDisplay = min && max && min.amount !== max.amount
260
+ ? `${min.formattedAmount} – ${max.formattedAmount}`
261
+ : min?.formattedAmount ?? '';
262
+ const shown = variant ? sellingPrice(variant.price) : { current: null, original: null };
263
+ // render: variant ? shown.current.formattedAmount (+ labelled shown.original when higher) : rangeDisplay (no "was")
264
+ ```
265
+ - **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.
266
+ - **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.
267
+ - `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.
268
+ - **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.
269
+
270
+ ### Ribbons — render all of them
271
+
272
+ 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. One helper, used by the card and the PDP:
273
+
274
+ ```js
275
+ const ribbons = (p) => [p.ribbon?.name, ...(p.additionalRibbons ?? []).map((r) => r.name)].filter(Boolean);
276
+ ```
277
+
278
+ A card that reads only `product.ribbon.name` drops "Sale" on a product ribboned "New" + "Sale" — the merchant set both.
279
+
280
+ **Cards may preview a color option's choices as swatches** — the list read already carries `options[].choicesSettings.choices[].colorCode` for a `SWATCH_CHOICES`/`COLOR_CHOICES` option, so "3 colors" as text is the weaker choice when the tile has room for three dots. A swatch on a card is a preview, not a picker: selection still happens in Quick Add or on the product page. Keep the tile's hierarchy stable under a long name and a ranged price — name and price on separate lines, the name allowed to wrap, the price never clipped.
281
+
282
+ ### Variants — resolve by choice IDs, from the product itself
283
+
284
+ 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:
172
285
 
173
286
  ```js
174
- await currentCartV2.addLineItemsToCurrentCart({
175
- catalogItems: [{ // the write shape uses `catalogItems`
176
- quantity,
287
+ const key = (choiceIds) => [...choiceIds].sort().join('_');
288
+ const variantByKey = new Map();
289
+ for (const v of product.variantsInfo?.variants ?? []) {
290
+ if (v.visible === false) continue;
291
+ const k = key(v.choices.map((c) => c.optionChoiceIds.choiceId));
292
+ if (variantByKey.has(k)) throw new Error(`Two variants share the choices ${k}`); // report, don't silently overwrite
293
+ variantByKey.set(k, v);
294
+ }
295
+ // selected = { [optionId]: choiceId } — the buyer's picks so far (option._id → choice.choiceId)
296
+ const complete = product.options.every((o) => selected[o._id]);
297
+ const variant = complete ? (variantByKey.get(key(Object.values(selected))) ?? null) : null; // null = combination doesn't exist
298
+ const available = (v) => v.inventoryStatus?.inStock !== false || v.inventoryStatus?.preorderEnabled === true;
299
+ ```
300
+
301
+ - **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).
302
+ - **A single-variant product** (no options) resolves to its only variant — `variantsInfo.variants[0]` on the PDP, `variantSummary.minPriceVariant` on a card.
303
+ - Changing an option **resets quantity to 1**. Modifiers never take part in the key — they're separate inputs (next section).
304
+ - **Color options render as swatches, not text pills.** When `option.optionRenderType` is `SWATCH_CHOICES` or `COLOR_CHOICES`, each choice carries `colorCode` (`#7c3aed`) — render a color swatch with the name as its label. Text options (`TEXT_CHOICES`) are pills.
305
+ - **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.
306
+
307
+ **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.
308
+
309
+ ### Adding to cart — the Cart V2 contract
310
+
311
+ 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>
312
+
313
+ ```js
314
+ function catalogItem({ product, variant, quantity, modifierChoices = {}, customTextFields = {}, subscriptionOptionId, preorder }) {
315
+ return {
316
+ quantity, // top-level, beside catalogReference
177
317
  catalogReference: {
178
- catalogItemId: product._id, // the product's _id (the `_id` rule above)
179
318
  appId: '215238eb-22a5-4c36-9e7b-e7c08025e04e',
180
- options: { variantId }, // the resolved variantId from part 1
319
+ catalogItemId: product._id, // the product's _id (the `_id` rule)
320
+ options: {
321
+ variantId: variant._id, // MANDATORY for any product with options
322
+ ...(Object.keys(modifierChoices).length ? { options: modifierChoices } : {}), // { [modifier.key]: choice.key }
323
+ ...(Object.keys(customTextFields).length ? { customTextFields } : {}), // { [modifier.freeTextSettings.key]: text }
324
+ ...(subscriptionOptionId ? { subscriptionOptionId } : {}), // a recurring plan only; omit for one-time
325
+ ...(preorder ? { preOrderRequested: true } : {}), // preorder only
326
+ },
181
327
  },
182
- }],
183
- });
328
+ };
329
+ }
330
+
331
+ const { cart } = await currentCartV2.addLineItemsToCurrentCart({ catalogItems: [item] }); // `catalogItems` — never Cart V1's `lineItems`
332
+ ```
333
+
334
+ **⚠️ 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.
335
+
336
+ **⚠️ 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.
337
+
338
+ **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.
339
+
340
+ **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**.
341
+
342
+ **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.
343
+
344
+ ### Changing quantity and removing lines
345
+
346
+ 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**:
347
+
348
+ ```js
349
+ try {
350
+ const { cart } = await currentCartV2.updateLineItemsInCurrentCart({
351
+ lineItems: [{ lineItemId, quantity: { newQuantity } }],
352
+ });
353
+ setCart(cart);
354
+ } catch (e) {
355
+ setLineError(lineItemId, e?.message ?? "Couldn't update the quantity"); // shown next to the line — never `catch {}`
356
+ }
357
+ const { cart } = await currentCartV2.removeLineItemsFromCurrentCart([lineItemId]);
358
+ ```
359
+
360
+ 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>
361
+
362
+ 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.
363
+
364
+ **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.
365
+
366
+ ### Three ways to buy from the gallery
367
+
368
+ **Every card carries a buy path** — a gallery whose only action is "view" is incomplete. Route each card by what the product needs from the buyer; one function, used by every card:
369
+
370
+ ```js
371
+ // From LIST_FIELDS data alone — no per-card detail fetch.
372
+ function purchasePath(p) {
373
+ const mods = p.modifiers ?? [];
374
+ if (mods.some((m) => m.modifierRenderType === 'FREE_TEXT') || p.subscriptionPricesInfo) return 'pdp';
375
+ if ((p.options?.length ?? 0) > 0 || mods.some((m) => m.mandatory)) return 'quickAdd';
376
+ if (p.inventory?.availabilityStatus === 'OUT_OF_STOCK' && p.inventory?.preorderStatus !== 'ENABLED') return 'soldOut';
377
+ return 'directAdd';
378
+ }
184
379
  ```
185
380
 
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).
381
+ 1. **`directAdd`** — no options, no mandatory modifiers, no subscriptions: one click adds `variantSummary.minPriceVariant._id` with quantity 1, through the same serializer.
382
+ 2. **`quickAdd`** — the product has options or a mandatory choice modifier: fetch the full product **only when the picker opens**, then resolve exactly as the PDP does:
383
+ ```js
384
+ const { product: full } = await productsV3.getProduct(p._id, { fields: DETAIL_FIELDS });
385
+ // → the same option controls, variantByKey resolution, and catalogItem() serializer as the PDP;
386
+ // selections start empty; quantity fixed at 1; the action disabled with its neutral reason until
387
+ // a variant resolves and every mandatory modifier has a value.
388
+ ```
389
+ Anchor the picker **to its card** (an inline or attached panel; a bottom sheet on small screens) — 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; it follows the overlay contract (`experience-store.md` §3).
390
+ 3. **`pdp`** — a free-text modifier or subscriptions: link to the product page; the gallery can't collect those inputs.
187
391
 
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.
392
+ ### Cart totals — from Wix, never computed in the client
189
393
 
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.
394
+ 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:
395
+
396
+ ```js
397
+ const { summary } = await currentCartV2.estimateCurrentCart(); // after every add/update/remove
398
+ // summary.priceSummary.subtotal — items after item-level discounts
399
+ // summary.priceSummary.discount — the CART-level discount only (a coupon / cart rule), not the sum of every saving
400
+ // summary.priceSummary.total — estimated, without delivery/tax unless you asked for them
401
+ ```
402
+
403
+ - 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.
404
+ - **Call `estimateCurrentCart` only when the cart has lines** — on an absent or empty cart the endpoint returns **404**, and a drawer that estimates on every page load logs that error for every first-time visitor. It is a display nicety — if it fails, the cart is still valid; show lines without the summary row.
405
+ - Doc: <https://dev.wix.com/docs/api-reference/business-solutions/e-commerce/purchase-flow/cart-v2/estimate-current-cart.md?apiView=SDK>
191
406
 
192
407
  ### Checkout — redirect to the hosted checkout page
193
408
 
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.
409
+ 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
410
  Doc: <https://dev.wix.com/docs/api-reference/business-solutions/e-commerce/purchase-flow/cart-v2/get-current-cart.md?apiView=SDK>
196
411
 
197
412
  ```js
@@ -200,16 +415,26 @@ const session = await redirects.createRedirectSession({
200
415
  ecomCheckout: { checkoutId: cart._id }, // the cart's _id IS the checkout id
201
416
  callbacks: { postFlowUrl: `${origin}/`, thankYouPageUrl: `${origin}/` },
202
417
  });
203
- window.location.href = session.redirectSession.fullUrl; // the hosted-checkout URL
418
+ window.location.assign(session.redirectSession.fullUrl); // the hosted-checkout URL — navigate the FULL document
204
419
  ```
205
420
 
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')"*.
421
+ **⚠️ `getCurrentCart()` returns `{ cart }`** — destructure it, or `cart` is `undefined` and `cart._id` throws.
422
+
423
+ **⚠️ 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>.
424
+
425
+ 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
426
 
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>.
427
+ **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:
428
+
429
+ ```js
430
+ const cart = await cartV2.createCart({ cart: { source: { channelType: cartV2.ChannelType.WEB } }, catalogItems: [item] });
431
+ // then: redirects.createRedirectSession({ ecomCheckout: { checkoutId: cart._id }, callbacks })
432
+ ```
433
+ 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
434
 
210
435
  ### Formatting cart prices
211
436
 
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):
437
+ **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
438
 
214
439
  ```js
215
440
  function formatCartMoney(money, cart) {
@@ -217,58 +442,149 @@ function formatCartMoney(money, cart) {
217
442
  const currency = cart?.customerInfo?.currencyCode ?? cart?.businessInfo?.currencyCode ?? 'USD';
218
443
  return value == null ? '' : new Intl.NumberFormat(undefined, { style: 'currency', currency }).format(Number(value));
219
444
  }
220
- // e.g. line item: formatCartMoney(item.pricing.totalPrice, cart)
221
- // subtotal: formatCartMoney(estimate.summary.priceSummary.subtotal, cart)
222
445
  ```
223
446
 
224
447
  Never hardcode `$` or assume USD — stores run in EUR/GBP too.
225
448
 
226
- ### Showing stock state
449
+ ### Showing stock state and quantity
227
450
 
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.
451
+ 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.
452
+
453
+ 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
454
 
230
455
  ### Rendering product images
231
456
 
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:
457
+ 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
458
 
234
459
  ```js
235
460
  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}
461
+ function imgSrc(mediaLike, w = 600, h = 600) {
462
+ const v = mediaLike?.image ?? mediaLike?.url ?? mediaLike; // the value can be a string or {url}
238
463
  if (!v) return '';
239
- if (typeof v === 'string' && v.startsWith('wix:image://')) return media.getScaledToFillImageUrl(v, w, h);
464
+ if (typeof v === 'string' && v.startsWith('wix:image://')) return media.getScaledToFillImageUrl(v, w, h, {});
240
465
  return typeof v === 'string' ? v : (v.url ?? ''); // already an absolute https URL
241
466
  }
467
+ // Responsive delivery: several scaled candidates + sizes, so a card never downloads a hero-sized file.
468
+ function imgSrcSet(mediaLike, widths = [320, 480, 640, 960], ratio = 1) {
469
+ return widths.map((w) => `${imgSrc(mediaLike, w, Math.round(w * ratio))} ${w}w`).join(', ');
470
+ }
471
+ // <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
472
  ```
243
473
 
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.
245
-
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>
474
+ **⚠️ 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>
247
475
 
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://`.)
476
+ **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
477
 
250
478
  ### Rendering product descriptions
251
479
 
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.
480
+ **`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.
253
481
 
254
- ### SEO on item pages (Astro, Wix-managed)
482
+ ### SEO on item pages
483
+
484
+ 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`).
485
+
486
+ **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):
487
+
488
+ ```astro
489
+ ---
490
+ // src/pages/products/[slug].astro
491
+ import { WIX_APPS } from "@wix/essentials";
492
+ import { SEO } from "@wix/seo/components";
493
+ import { loadSEOTagsServiceConfig } from "@wix/seo/services";
494
+ import { seoTags } from "@wix/seo";
495
+
496
+ // Registers the route for the sitemap + the dashboard SEO editor. checkoutAndOrders, NOT
497
+ // WIX_APPS.stores (stores.id is the catalog id for catalogReference).
498
+ export const wixMetadata = {
499
+ appDefId: WIX_APPS.checkoutAndOrders.id,
500
+ pageIdentifier: WIX_APPS.checkoutAndOrders.productPageMetadata.pageIdentifier,
501
+ identifiers: { slug: WIX_APPS.checkoutAndOrders.productPageMetadata.identifiers.handle },
502
+ };
503
+
504
+ const slug = Astro.params.slug!;
505
+ // Behind Wix's proxy the request URL is internal — the public page URL arrives on x-wix-forwarded-url.
506
+ const forwardedUrl = Astro.request.headers.get("x-wix-forwarded-url");
507
+ const pageUrl =
508
+ forwardedUrl && URL.canParse(forwardedUrl) && /^https?:$/.test(new URL(forwardedUrl).protocol)
509
+ ? forwardedUrl
510
+ : Astro.url.href;
511
+
512
+ let product = null, seoTagsServiceConfig = null;
513
+ try {
514
+ [product, seoTagsServiceConfig] = await Promise.all([
515
+ productsV3.getProductBySlug(slug, { fields: DETAIL_FIELDS }).then((r) => r.product ?? null),
516
+ loadSEOTagsServiceConfig({ pageUrl, itemType: seoTags.ItemType.STORES_PRODUCT, itemData: { slug } }),
517
+ ]);
518
+ } catch {} // an unguarded SSR throw truncates the response
519
+ if (!product) return new Response(null, { status: 404 });
520
+ ---
521
+ <Layout title={product.name}>
522
+ <SEO.Tags seoTagsServiceConfig={seoTagsServiceConfig} slot="seo-tags" /> <!-- Layout renders <slot name="seo-tags" /> in <head> -->
523
+ <!-- SEO.Tags renders the <title>. The layout must NOT print its own when the slot is filled — two titles otherwise:
524
+ in Layout.astro: {!Astro.slots.has("seo-tags") && <title>{title}</title>} <slot name="seo-tags" /> -->
525
+ …
526
+ </Layout>
527
+ ```
255
528
 
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").
529
+ The **category page** is the same block with the category identifiers — same imports, same `pageUrl`, same guard:
257
530
 
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`.
531
+ ```astro
532
+ ---
533
+ // src/pages/category/[slug].astro
534
+ export const wixMetadata = {
535
+ appDefId: WIX_APPS.checkoutAndOrders.id,
536
+ pageIdentifier: WIX_APPS.checkoutAndOrders.categoryPageMetadata.pageIdentifier,
537
+ identifiers: { slug: WIX_APPS.checkoutAndOrders.categoryPageMetadata.identifiers.handle },
538
+ };
539
+ const slug = Astro.params.slug!;
540
+ let category = null, seoTagsServiceConfig = null;
541
+ try {
542
+ [category, seoTagsServiceConfig] = await Promise.all([
543
+ categories.getCategoryBySlug(slug, { appNamespace: "@wix/stores" }).then((r) => r.category ?? null),
544
+ loadSEOTagsServiceConfig({ pageUrl, itemType: seoTags.ItemType.STORES_CATEGORY, itemData: { slug } }),
545
+ ]);
546
+ } catch {}
547
+ if (!category || category.visible === false) return new Response(null, { status: 404 });
548
+ // category._id → the gallery query's categoryId; category.name / description head the page
549
+ ---
550
+ <Layout title={category.name}>
551
+ <SEO.Tags seoTagsServiceConfig={seoTagsServiceConfig} slot="seo-tags" />
552
+ …
553
+ </Layout>
554
+ ```
555
+
556
+ (A category rendered only as a query-string *filter* on the shop page is a main page — automatic SEO, no `wixMetadata` — but it is not a substitute for the category pages above.) Guide: <https://dev.wix.com/docs/go-headless/wix-managed-headless/seo/add-seo-support-to-item-pages.md>.
557
+
558
+ **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.
559
+
560
+ ### Catalog-conditional capabilities (build when the catalog has them — never fabricate them)
561
+
562
+ Read the catalog first; then, **only when the data is present**:
563
+
564
+ - **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. **In the cart, a subscription line says so**: render `lineItem.subscriptionInfo` — the plan's `title.original` and its billing terms from `subscriptionSettings` (every `interval` `frequency`, `billingCycles` unless `autoRenewal`) — under the line, from the supplied fields only, never a generic "recurring".
565
+ - **Preorder** (`inventory.preorderStatus === 'ENABLED'` / `variant.inventoryStatus.preorderEnabled`): label "Pre-order", include `preOrderRequested: true`. Preorder and a subscription plan may coexist.
566
+ - **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.
567
+ - **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>
568
+ - **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.
569
+ - **Filter facets** beyond price/stock (color, size) are in the gallery query above, not conditional: the option choice fields (`options.choicesSettings.choices.choiceId` with `$hasSome`) discover *products*; Quick Add or the PDP still resolves the actual variant.
570
+
571
+ 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.
572
+
573
+ ---
261
574
 
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.)
575
+ ## What a complete storefront shows
263
576
 
264
- Optional: render a `Product` schema.org JSON-LD `<script>` from the fetched product for rich results (see the guide's structured-data step).
577
+ 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
578
 
266
579
  ---
267
580
 
268
581
  ## Conclusion
269
582
  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.
583
+ - imports **`productsV3` / `categories` / `currentCartV2` / `cartV2` / `redirects`** — never the V1 `products`/`collections` modules or Cart V1;
584
+ - requests **`LIST_FIELDS` / `DETAIL_FIELDS`** on every read, and uses **`product._id`** (never `product.id`) as the cart's `catalogItemId`;
585
+ - 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`);
586
+ - prices with **`priceAfterDiscount` → `actualPrice` → `compareAtPrice`** precedence and renders **every ribbon**, never inferring one from the other;
587
+ - sorts, filters, and pages **on Wix** (`searchProducts` + `$matchItems: [{ id: categoryId }]` on `allCategoriesInfo.categories`, option facets via `$hasSome` on choice IDs, cursor paging at 24) — never a frozen seed-time list, never `queryProducts` for categories, never V1 `collectionIds`;
588
+ - ships the gallery's filter panel and gives every card its buy path (`purchasePath`: direct add / quick add / product page), and gives every category a page at `/category/<slug>` linked from the chrome;
589
+ - 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;
590
+ - builds the homepage, gallery, product page, and side cart to the bar in `experience-store.md` — the store is the surfaces, not the calls.