@wix/agent-skills 1.19.1 → 1.19.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/skills/wix-app/references/EDITOR_REACT_COMPONENT.md +1 -1
- package/skills/wix-app/references/editor-react-component/ACCESSIBILITY.md +7 -2
- package/skills/wix-app/references/editor-react-component/ANIMATED-COMPONENTS.md +46 -22
- package/skills/wix-app/references/editor-react-component/COMPONENT-CONTRACT.md +17 -4
- package/skills/wix-app/references/editor-react-component/FUNCTION-HANDLERS.md +71 -31
- package/skills/wix-app/references/editor-react-component/REACT-GUIDELINES.md +3 -4
- package/skills/wix-base44-connector/SKILL.md +46 -24
- package/skills/wix-headless/SKILL.md +1 -0
- package/skills/wix-headless/references/CAPABILITIES.md +7 -2
- package/skills/wix-headless/references/SDK_HANDOFF.md +2 -1
- package/skills/wix-headless/references/inline-recipes/experience-store.md +48 -0
- package/skills/wix-headless/references/inline-recipes/how-to-code-a-store.md +350 -114
- package/skills/wix-headless/references/inline-recipes/how-to-code-members-astro.md +1 -1
- package/skills/wix-headless/references/managed/CREATE.md +5 -0
- package/skills/wix-manage/SKILL.md +4 -4
- package/skills/wix-manage/references/bookings/create-and-update-booking-services.md +4 -1
- package/skills/wix-manage/references/bookings/create-appointment-service.md +3 -1
- package/skills/wix-manage/references/bookings/create-class-service.md +3 -1
- package/skills/wix-manage/references/bookings/create-course-service.md +4 -2
- package/skills/wix-manage/references/bookings/end-to-end-booking-flow.md +68 -3
- package/skills/wix-manage/references/ecommerce/pricing-promotions/ecom-pricing-flow-seasonal-promotion.md +1 -1
- package/skills/wix-manage/references/google-ads/manage-campaign-success-guide.md +108 -14
- package/skills/wix-manage/references/seo/manage-content-plan.md +1 -1
- package/skills/wix-manage/references/sites/upload-static-site.md +264 -34
- package/skills/wix-vibe-headless/references/storefront/INSTRUCTIONS.md +68 -26
- package/skills/wix-vibe-headless/references/storefront/app/components/CartDrawer.jsx +32 -14
- package/skills/wix-vibe-headless/references/storefront/app/context/CartContext.jsx +14 -3
- package/skills/wix-vibe-headless/references/storefront/app/hooks/useProductCard.js +41 -35
- package/skills/wix-vibe-headless/references/storefront/app/hooks/useProductDetail.js +77 -30
- package/skills/wix-vibe-headless/references/storefront/app/rest/wix-store-cart.js +31 -2
- 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
|
|
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
|
|
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:
|
|
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,
|
|
25
|
-
|
|
|
26
|
-
|
|
|
27
|
-
|
|
|
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` / `
|
|
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
|
|
40
|
-
import {
|
|
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,
|
|
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
|
|
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
|
|
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 } }, //
|
|
66
|
-
compareAtPriceRange: { minValue: {
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
|
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
|
|
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.
|
|
97
|
-
|
|
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
|
|
128
|
+
**⚠️ `queryProducts()` returns a builder, not a Promise.** Pass requested fields, then chain `.limit(...)`/`.find()`:
|
|
102
129
|
|
|
103
130
|
```js
|
|
104
|
-
const
|
|
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 (
|
|
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`,
|
|
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.**
|
|
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
|
-
|
|
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.
|
|
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(); //
|
|
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`
|
|
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
|
-
|
|
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
|
-
|
|
204
|
+
### The product page read (one call, variants included)
|
|
137
205
|
|
|
138
|
-
|
|
206
|
+
The product page needs **one complete product** — a different read from the gallery:
|
|
139
207
|
|
|
140
208
|
```js
|
|
141
|
-
const {
|
|
142
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
218
|
+
**How the page is split — one price, on the page in the HTML:**
|
|
156
219
|
|
|
157
|
-
|
|
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
|
-
**
|
|
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
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
.
|
|
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
|
-
|
|
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
|
-
**
|
|
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
|
-
|
|
175
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
334
|
+
### Three ways to buy from the gallery
|
|
189
335
|
|
|
190
|
-
|
|
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
|
|
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.
|
|
368
|
+
window.location.assign(session.redirectSession.fullUrl); // the hosted-checkout URL — navigate the FULL document
|
|
204
369
|
```
|
|
205
370
|
|
|
206
|
-
**⚠️
|
|
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
|
-
|
|
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`
|
|
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
|
|
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
|
|
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(
|
|
237
|
-
const v =
|
|
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 —
|
|
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
|
-
**
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
481
|
+
### Catalog-conditional capabilities (build when the catalog has them — never fabricate them)
|
|
257
482
|
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
-
|
|
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
|
-
|
|
496
|
+
## What a complete storefront shows
|
|
263
497
|
|
|
264
|
-
|
|
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` / `
|
|
271
|
-
- uses **`product._id`** (never `product.id`) as the cart's `catalogItemId`;
|
|
272
|
-
-
|
|
273
|
-
-
|
|
274
|
-
-
|
|
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.
|