diff --git a/skills/wix-headless/references/SDK_HANDOFF.md b/skills/wix-headless/references/SDK_HANDOFF.md index a87bfa43f..77e757d3d 100644 --- a/skills/wix-headless/references/SDK_HANDOFF.md +++ b/skills/wix-headless/references/SDK_HANDOFF.md @@ -68,7 +68,7 @@ Everything else the host resolves live from the queries in §3. (Static platform - Frontend CMS access is **read**; visitor writes go through **Forms** submissions. - **Blog comments are member-gated — read public, write authenticated.** Querying/rendering comments is public, so list them server-side in SSR. **Submitting** needs a logged-in member, so don't gate the form behind an upfront login check (that branch is what burns deliberation): render the form always and resolve identity at submit — POST to a backend endpoint (`src/pages/api/*.ts`) that calls `createComment` with the request session; if the caller isn't a member, redirect to the built-in `/api/auth/login?returnUrl=…`. **Do not build the comment form as a client island** — the API-endpoint + session path is the documented shape and avoids the browser-auth detour. The comment API keys (post `referenceId`, Blog appDefId, author lookup via `post.memberId`) are in `how-to-code-a-blog.md`. - **Member auth is one mechanism split on the frontend axis (the §3 members note routes to the right recipe), and orthogonal to elevation.** Sign-up and log-in are the *same* flow (the Wix login page logs in **or** registers); log-out is its inverse. Two layers stay separate: **identity** (logged-in vs not — no app install) vs **profile** (name/photo/roles — needs the **Wix Members Area app** installed, `SETUP.md`). **pricing-plans is a hard dependency**: subscribing requires a logged-in member; for the other verticals, member login is a soft add-on for their "my …" surfaces only. **A member reading their own data (own orders/bookings/subscriptions, plan-gated content) uses the member token with NO `auth.elevate`** — elevation is the separate admin/permission axis (site-wide reads, server-side only), not something member features need. -- **Gating is a LIVE signal, never a hardcoded id list.** Whenever content is gated (members-only articles, plan-eligible class booking, any "premium" surface), decide eligibility from a signal read **at request time** — a queryable flag carried on the content (a `members-only` blog category/tag or a boolean CMS field) and live coverage/eligibility (`checkout.membershipOptions.eligibleMemberships`, or the member's active-order `planId`s matched against **live** Benefit-Program coverage). **Never** gate on a frozen set of "premium" slugs/ids or a seed-time `plan→service` map: a gated post or a newly-covered service the owner adds later would silently read as public/ineligible. See `how-to-code-a-blog.md` (member features) and `how-to-code-pricing-plans.md` (coverage read). +- **Gating is a LIVE signal, never a hardcoded id list.** Whenever content is gated (members-only articles, plan-eligible class booking, any "premium" surface), decide eligibility from a signal read **at request time** — a queryable flag carried on the content (a `members-only` blog category/tag or a boolean CMS field) and live coverage/eligibility (Cart V2's `currentCartV2.calculateCurrentCart()` → `summary.paymentSummary.memberships`, or the member's active-order `planId`s matched against **live** Benefit-Program coverage). **Never** gate on a frozen set of "premium" slugs/ids or a seed-time `plan→service` map: a gated post or a newly-covered service the owner adds later would silently read as public/ineligible. See `how-to-code-a-blog.md` (member features) and `how-to-code-pricing-plans.md` (coverage read). ### 6 · What a complete site must include (per loaded capability) For each loaded capability, carry its **Required site features** and **Implementation checklist** from `references/CAPABILITIES.md` into the guide — in plain product language, lightly tailored to what was seeded. **This is the build spec, not optional polish:** the host should build every *required feature* and cover every *checklist* item. For example, a blog must show the **author** (name + photo), the publish date and reading time, the cover image, and the **full formatted content** (not flattened text) — a posts-list-plus-plain-text-body is incomplete. The host maps these onto its own components using the packages/docs in §3 and the seeded schema in §4. diff --git a/skills/wix-headless/references/inline-recipes/how-to-code-a-store.md b/skills/wix-headless/references/inline-recipes/how-to-code-a-store.md index 3023f8c3c..416a16b3b 100644 --- a/skills/wix-headless/references/inline-recipes/how-to-code-a-store.md +++ b/skills/wix-headless/references/inline-recipes/how-to-code-a-store.md @@ -24,22 +24,24 @@ A concise contract for writing the **frontend code** of a storefront against a C | Products (list, get, search, filter) | `@wix/stores` | `productsV3` | | Variants (to resolve `variantId`) | `@wix/stores` | `readOnlyVariantsV3` | | Categories | `@wix/stores` | `categories` | -| Cart (add / get / checkout) | `@wix/ecom` | `currentCart` | +| Cart (add / get / checkout) | `@wix/ecom` | `currentCartV2` | | Redirect to hosted checkout | `@wix/redirects` | `redirects` | +> 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`. **Auth / client — framework split:** -- **Astro (Wix-managed):** authentication is ambient. Call `currentCart` / `productsV3` / `readOnlyVariantsV3` directly from server components and backend routes (`src/pages/api/*.ts`) — **no `createClient`, no `OAuthStrategy`, no `clientId`.** +- **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`.** - **Non-Astro (Vite/React/Vue/static):** build one manual visitor client and reuse it: ```js import { createClient, OAuthStrategy } from '@wix/sdk'; import { productsV3, readOnlyVariantsV3 } from '@wix/stores'; - import { currentCart } from '@wix/ecom'; + import { currentCartV2 } from '@wix/ecom'; import { redirects } from '@wix/redirects'; const client = createClient({ - modules: { productsV3, readOnlyVariantsV3, currentCart, redirects }, + modules: { productsV3, readOnlyVariantsV3, currentCartV2, redirects }, auth: OAuthStrategy({ clientId: /* the project's PUBLIC OAuth client id */ }), }); ``` @@ -49,7 +51,7 @@ A concise contract for writing the **frontend code** of a storefront against a C ## The shapes you read (field cheat-sheet) -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 method-return wrappers (note `checkoutId`). +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`). ```jsonc // productsV3.queryProducts().…find() → result.items[] @@ -72,11 +74,12 @@ variant = { inventoryStatus: { inStock }, // variant-level stock (boolean) } -// currentCart.getCurrentCart() → { lineItems: [...] } -lineItem = { quantity, price: { amount }, image } // price is HERE (NOT actualPriceRange); image is wix:image:// too → resolve +// currentCartV2.getCurrentCart() → { cart: { _id, lineItems: [...] } } // NOTE: returns { cart } — destructure it +lineItem = { _id, name: { original }, quantityInfo: { confirmedQuantity }, pricing: { unitPrice: { amount } }, attributes: { image } } +// 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) -// currentCart.createCheckoutFromCurrentCart({ channelType }) → { checkoutId } // a STRING — NOT { checkout }, NOT _id -// redirects.createRedirectSession({ ecomCheckout: { checkoutId }, callbacks }) → { redirectSession: { fullUrl } } +// the cart's _id is the checkout id → pass to the redirect session: +// redirects.createRedirectSession({ ecomCheckout: { checkoutId: cart._id }, callbacks }) → { redirectSession: { fullUrl } } ``` --- @@ -94,7 +97,7 @@ Doc: · catalogReference contract: +**2 · Add it.** Doc: · catalogReference contract: ```js -await currentCart.addToCurrentCart({ - lineItems: [{ +await currentCartV2.addLineItemsToCurrentCart({ + catalogItems: [{ // the write shape uses `catalogItems` quantity, catalogReference: { catalogItemId: product._id, // the product's _id (the `_id` rule above) @@ -164,28 +167,44 @@ await currentCart.addToCurrentCart({ }); ``` -**⚠️ CRITICAL: `options.variantId` is MANDATORY for any product that has variants.** Adding by `catalogItemId` alone returns **HTTP 200 but adds nothing** — the silent empty cart. The cart method's required-params list omits `variantId`, so this fails quietly and looks like success. Always resolve and include it (part 1 above). +**⚠️ 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). -**⚠️ 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 (`200` + empty cart). +**⚠️ 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. -### Checkout +### Checkout — redirect to the hosted checkout page -Create a checkout from the current cart, then redirect the buyer to the hosted checkout. -Docs: · +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. +Doc: ```js -const checkout = await currentCart.createCheckoutFromCurrentCart({ channelType: currentCart.ChannelType.WEB }); +const { cart } = await currentCartV2.getCurrentCart(); // NOTE: returns { cart } — destructure it const session = await redirects.createRedirectSession({ - ecomCheckout: { checkoutId: checkout.checkoutId }, // checkout.checkoutId — NOT checkout._id + ecomCheckout: { checkoutId: cart._id }, // the cart's _id IS the checkout id callbacks: { postFlowUrl: `${origin}/`, thankYouPageUrl: `${origin}/` }, }); window.location.href = session.redirectSession.fullUrl; // the hosted-checkout URL ``` -**⚠️ Return shapes are in the cheat-sheet** — `createCheckoutFromCurrentCart` gives **`checkout.checkoutId`** (a string), not `checkout._id`. Reading `checkout._id` (over-applying the `_id` rule) throws *"Cannot read properties of undefined (reading '_id')"* — the silent checkout crash. +**⚠️ 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')"*. **⚠️ 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://` 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: . +### Formatting cart prices + +**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): + +```js +function formatCartMoney(money, cart) { + const value = money?.convertedAmount ?? money?.amount; + const currency = cart?.customerInfo?.currencyCode ?? cart?.businessInfo?.currencyCode ?? 'USD'; + return value == null ? '' : new Intl.NumberFormat(undefined, { style: 'currency', currency }).format(Number(value)); +} +// e.g. line item: formatCartMoney(item.pricing.totalPrice, cart) +// subtotal: formatCartMoney(estimate.summary.priceSummary.subtotal, cart) +``` + +Never hardcode `$` or assume USD — stores run in EUR/GBP too. + ### Showing stock state 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. @@ -208,7 +227,7 @@ function imgSrc(mediaMain, w = 600, h = 600) { **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 ``. Doc: -**This applies to cart line-item images too, not just product reads.** A cart `lineItem.image` is the same `wix:image://` identifier — run it through the same `imgSrc()` helper before ``. (If you build the cart over an API route, resolve there and return a ready URL so the component never sees a `wix:image://`.) +**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 ``. (If you build the cart over an API route, resolve there and return a ready URL so the component never sees a `wix:image://`.) ### Rendering product descriptions @@ -230,7 +249,7 @@ Optional: render a `Product` schema.org JSON-LD `