@base44/app-plugin-commerce 0.3.2 → 0.3.4

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/README.md CHANGED
@@ -11,8 +11,8 @@ It provides a full-featured **commerce data model and behavior** (variant-driven
11
11
  - **Payments: manual methods work out of the box; online cards are opt-in** — the seed enables the manual `offline` method (bank transfer, cash on delivery, pickup: on-hold + instructions, no code) and leaves the `card` gateway **disabled**. The order side of card payments *is* premade — checkout routing, payment links for unpaid orders, two idempotent confirmation paths (customer return + webhook) and refund records — so a store that opts in wires a provider by implementing **four functions in one file**, `base44/shared/commerce/card-payment.ts`. **For Stripe there is nothing to write**: `base44/shared/commerce/card-payment.stripe.ts` is a complete implementation used as-is — copy it over the stub and enable the gateway. Any other provider (PayPal, Adyen, a local PSP) follows the same shape. Enable the gateway only with a provider behind it, or checkout answers `503 no_card_payment_provider`. The rule and timing: [`skills/commerce/install/03-data.md`](./skills/commerce/install/03-data.md); provider mechanics: [`skills/commerce/references/online-payments.md`](./skills/commerce/references/online-payments.md). The admin can add more manual methods in Settings → Payments.
12
12
  - **Shared commerce engine** (`base44/shared/commerce/`) — totals, tax, shipping, coupons, stock, order lifecycle, webhook dispatch (HMAC-signed), emails, card-payment plumbing, plus static country/currency/continent data.
13
13
  - **Admin UI** (`src/commerce/admin/`) — a React/Tailwind/shadcn admin with a familiar store back-office information architecture: dashboard, orders, products, coupons, customers, reports, and full settings including webhooks. Admin-role gated.
14
- - **Storefront helpers** (`src/commerce/utils/`) — framework-free, dependency-free modules for the shopfront you build: `storefront.js` is the API client (`createStorefront(base44)` — cart-token lifecycle, cached store-info, catalog/cart/checkout/reviews/return-page calls); `variants.js` maps an attribute selection (Size, Color) onto a `ProductVariation` and back, plus per-option availability and price ranges; `price.js` encodes the from-price and price-range rules; `totals.js` projects a cart *or* an order into one summary shape; `address-spec.js` is the checkout address form as data; `images.js` normalizes catalog images; `shipping-promos.js` reads the store's real free-shipping configuration so "Free shipping over €150" states a configured rule rather than an invented number.
15
- - **Storefront React layer** (`src/commerce/storefront/`) — **headless: the logic is premade, the UI never is.** Nothing in the layer renders markup or carries CSS; every element, class and word of copy in the storefront you build is yours, so a brief like "make it feel like <site>" applies to the whole store, checkout included. **It ships no customer-facing copy either**: where a state needs words you get the *state* — `buy.state`, a picker's `hint.code`, the checkout's `blockers`, a review's `status` — and write the sentence. What ships is every piece of logic that is the same in all stores: `StorefrontProvider` (+ `useStorefront`/`useStoreInfo`/`useFormatMoney`/`useCountries`), `useProductList`/`useCategories`/`useRibbons`, `useProduct`/`useAddToCart`, `useCart`/`useCartLine` (+ `useCartUI`/`CartUIProvider` for a drawer), `useCheckout`/`CheckoutProvider`/`useCheckoutContext`, `useOrderReturn`/`orderReceivedUrl` — plus three render-prop components that stay just as headless (`ShippingMethodPicker`/`PaymentMethodPicker` for the two checkout choices that are store data, `CartLine` for per-row cart bindings), and the framework-free view-model helpers re-exported so one import line covers a page (`variantAxes`, `productPrice`, `productImages`, `productSpecs`, `attributesLabel`, `cartTotalsLines`/`orderTotalsLines`, `addressFieldSpec`). Each hook's doc comment states the render rules that keep a store correct (an unbuyable variant option renders disabled, not hidden; a receipt page must render `paymentInstructions`; …). Needs React and nothing else.
14
+ - **Storefront helpers** (`src/commerce/utils/`) — framework-free, dependency-free modules for the shopfront you build: `storefront.js` is the API client (`createStorefront(base44)` — cart-token lifecycle, cached store-info, catalog/cart/checkout/reviews/return-page calls); `variants.js` maps an attribute selection (Size, Color) onto a `ProductVariation` and back, plus per-option availability and price ranges; `price.js` encodes the from-price and price-range rules; `totals.js` projects a cart *or* an order into one summary shape; `address-spec.js` is the checkout address form as data; `images.js` and `ribbons.js` normalize the two catalog fields that are arrays of objects (`{src, name, alt}` images, `{id, name}` ribbons) rather than strings; `types.js` writes the catalog shapes down as JSDoc typedefs (`StorefrontProduct` and the rest), so what a field holds is answerable from the frontend; `shipping-promos.js` reads the store's real free-shipping configuration so "Free shipping over €150" states a configured rule rather than an invented number.
15
+ - **Storefront React layer** (`src/commerce/storefront/`) — **headless: the logic is premade, the UI never is.** Nothing in the layer renders markup or carries CSS; every element, class and word of copy in the storefront you build is yours, so a brief like "make it feel like <site>" applies to the whole store, checkout included. **It ships no customer-facing copy either**: where a state needs words you get the *state* — `buy.state`, a picker's `hint.code`, the checkout's `blockers`, a review's `status` — and write the sentence. What ships is every piece of logic that is the same in all stores: `StorefrontProvider` (+ `useStorefront`/`useStoreInfo`/`useFormatMoney`/`useCountries`), `useProductList`/`useCategories`/`useRibbons`, `useProduct`/`useAddToCart`, `useCart`/`useCartLine` (+ `useCartUI`/`CartUIProvider` for a drawer), `useCheckout`/`CheckoutProvider`/`useCheckoutContext`, `useOrderReturn`/`orderReceivedUrl` — plus three render-prop components that stay just as headless (`ShippingMethodPicker`/`PaymentMethodPicker` for the two checkout choices that are store data, `CartLine` for per-row cart bindings), and the framework-free view-model helpers re-exported so one import line covers a page (`variantAxes`, `productPrice`, `productImages`, `productRibbons`, `productSpecs`, `attributesLabel`, `cartTotalsLines`/`orderTotalsLines`, `addressFieldSpec`). Each hook's doc comment states the render rules that keep a store correct (an unbuyable variant option renders disabled, not hidden; a receipt page must render `paymentInstructions`; …) and names its return type from `storefront/types.js`, so a page reads a field's shape off the hook instead of off a backend function. Needs React and nothing else.
16
16
  - **StoreAdmin agent + bot** — an AI copilot (`base44/agents/commerce/StoreAdmin.jsonc`, registered as `commerce/StoreAdmin`) with the `commerce/*` functions attached directly as tools (calls run as the chatting user → `requireAdmin()` still applies), variant-aware order editing, plus a chat panel in the admin sidebar with GFM markdown-table rendering.
17
17
  - **Docs** — this README plus the commerce skill folder [`skills/commerce/`](./skills/commerce/): [`SKILL.md`](./skills/commerce/SKILL.md) is the map every agent starts from (and the only path the platform needs to know); [`install/`](./skills/commerce/install/) holds the three stage files that are the whole install (`01-install` → `02-storefront` → `03-data`, each read at the moment its work starts and dropped when its checklist passes); [`references/`](./skills/commerce/references/) holds per-topic guides opened only on demand; [`docs/`](./skills/commerce/docs/) holds the data-model map ([`entities.md`](./skills/commerce/docs/entities.md)) and the two API references. The whole folder is installed into the app at `.agents/skills/commerce/` so agents pick it up natively.
18
18
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@base44/app-plugin-commerce",
3
- "version": "0.3.2",
3
+ "version": "0.3.4",
4
4
  "description": "Base44 Commerce plugin — entities, backend functions, shared commerce engine, admin UI and the commerce skill, shipped as copyable source",
5
5
  "keywords": [
6
6
  "base44",
@@ -112,7 +112,7 @@ Open a file when its work starts — not while planning.
112
112
  | [`install/02-storefront.md`](./install/02-storefront.md) | building storefront pages | 33K |
113
113
  | [`install/03-data.md`](./install/03-data.md) | seeding catalog, shipping rates/zones, payments; re-callable per slice | 11K |
114
114
  | [`docs/entities.md`](./docs/entities.md) | any direct entity read/write ("which entity holds X") | 11K |
115
- | [`references/catalog-rendering.md`](./references/catalog-rendering.md) | which fields each catalog call returns, variant edge cases | 13K |
115
+ | [`references/catalog-rendering.md`](./references/catalog-rendering.md) | field shapes each catalog call returns, variant edge cases | 16K |
116
116
  | [`references/shipping-and-tax.md`](./references/shipping-and-tax.md) | zones beyond 03's recipe, taxes, day-2 edits | 8K |
117
117
  | [`references/online-payments.md`](./references/online-payments.md) | enabling card payments, or wiring the provider — at install or any time later | 8K |
118
118
  | [`references/storefront-verification.md`](./references/storefront-verification.md) | driving the storefront from a browser script | 3K |
@@ -59,7 +59,7 @@ Only `status: "publish"` products are returned — the admin's single **Visible*
59
59
 
60
60
  **Response:** `{ "products": [Product...], "page": 1, "per_page": 12, "has_next": true }`
61
61
 
62
- Each row is the product record (minus paywalled fields) **plus a resolved `ribbons` array** (`[{ id, name }]`), so cards show ribbons without a second call. `categories` are **not** resolved — `category_ids` only. What a row can and can't show, and how to add a `get-product`-only field to this call instead of fetching per card: [`../references/catalog-rendering.md`](../references/catalog-rendering.md).
62
+ Each row is the product record (minus paywalled fields) **plus a resolved `ribbons` array** (`[{ id, name }]` — objects, and the key is omitted entirely when no row on the page carries a ribbon), so cards show ribbons without a second call. `categories` are **not** resolved — `category_ids` only. Every field's exact shape, what a row can and can't show, and how to add a `get-product`-only field to this call instead of fetching per card: [`../references/catalog-rendering.md`](../references/catalog-rendering.md).
63
63
 
64
64
  **Filters stack** — all of them are ANDed, so "Dresses + gift + on sale" is one request. Build the controls from [`list-categories`](#list-categories), [`list-ribbons`](#list-ribbons) (its `count` gives you "Gift (12)") and [`list-attributes`](#list-attributes), and mirror active filters into the URL so a filtered listing is shareable and survives reload.
65
65
 
@@ -84,7 +84,7 @@ await cat({ category_id, sort: "popularity", per_page: 4 }); // top in catego
84
84
  "product": { Product },
85
85
  "variations": [ ProductVariation... ], // publishable only; [] when the product has no attributes
86
86
  "categories": [ ProductCategory... ],
87
- "ribbons": [ ProductRibbon... ],
87
+ "ribbons": [ ProductRibbon... ], // { id, name, count } records, beside the product — not on it
88
88
  "reviews": { "items": [ { "id", "reviewer", "review", "rating", "verified", "created_date" } ],
89
89
  "page": 1, "per_page": 10, "has_next": false,
90
90
  "average_rating": 4.5, "rating_count": 12 },
@@ -94,6 +94,8 @@ await cat({ category_id, sort: "popularity", per_page: 4 }); // top in catego
94
94
  ```
95
95
  **Errors:** `404 not_found` (missing / not published / hidden).
96
96
 
97
+ In React, `useProduct` normalizes those ribbons to a listing row's `{ id, name }` and attaches them to `product`, so one card component can render a row *or* this product (`productRibbons(product)`); ribbon counts for a filter come from [`list-ribbons`](#list-ribbons).
98
+
97
99
  > **`variations[]` is not a list of choices to show** — variant rule 1 applies, and variant prices come from `variations[]`, never `product.price` (a rolled-up from-price). In React the product page's hooks in `@/commerce/storefront` resolve this for you. The non-React resolver sample (`resolveSelection` from `@/commerce/utils`) and the variant deep-dive: [`../references/catalog-rendering.md`](../references/catalog-rendering.md).
98
100
 
99
101
  ### `list-categories`
@@ -16,13 +16,13 @@ carry_forward:
16
16
 
17
17
  # 02 — Storefront
18
18
 
19
- One split decides everything here: **the logic is premade, the UI never is.** The hooks own checkout repricing, variant resolution, cart state, order-return verification — hand-writing any of it is where storefront bugs cluster, so **never re-implement what a hook does**. Every element, class, layout and **word** is yours; nothing in `@/commerce/storefront` renders markup, carries CSS, or contains a sentence a customer will read. **Decide how the store looks as if this kit did not exist**, then encode it **once** as design classes ([below](#design-language--once-before-any-page)) — the snippets here are wiring reference, never design input.
19
+ One split decides everything here: **the logic is premade, the UI never is.** The hooks own checkout repricing, variant resolution, cart state, order-return verification — hand-writing any of it is where storefront bugs cluster, so **never re-implement what a hook does**. Every element, class, layout and **word** is yours; nothing in `@/commerce/storefront` renders markup or carries CSS. **Decide how the store looks as if this kit did not exist**, then encode it **once** as design classes ([below](#design-language--once-before-any-page)) — the snippets here are wiring reference, never design input.
20
20
 
21
21
  **States and codes, not copy.** Where a state needs words you get the *state* (`buy.state`, `hint.code`, `blockers`) and write the words. So: never re-derive a state you were handed (a ternary chain over `adding`/`purchasable` re-implements `buy.state`, wrong), and never leave one unworded (a button with no text for `sold_out` renders empty).
22
22
 
23
23
  **This file is the whole job.** Every shape you need is in ["What each hook resolves to"](#what-each-hook-resolves-to) — don't open the hook files while building; that is the most expensive way to answer a question this page already answers. Rules marked ⚑ must survive whatever design you build.
24
24
 
25
- **One import path: `@/commerce/storefront`.** Each section opens with its page's exact import line — copy it verbatim, then delete unused names. Everything a page needs is re-exported there, so a React page never imports `@/commerce/utils` directly. `useStoreInfo` is the name most often left out — it is the only source of store name and currency.
25
+ **One import path: `@/commerce/storefront`.** Each section opens with its page's exact import line — copy it verbatim, then delete unused names. Everything a page needs is re-exported there, so a React page never imports `@/commerce/utils` directly. `useStoreInfo` is the name most often left out.
26
26
 
27
27
  ## Setup — once
28
28
 
@@ -70,7 +70,7 @@ function StoreLayout() {
70
70
 
71
71
  The cost driver of a generated storefront is not wiring — it is decoration repeated inline. Encode identity **once**: in `index.css`, set the palette and type scale, then define the store's recurring surfaces as **10–15 composable classes** in Tailwind's components layer, named in *this* store's language (`.panel`, `.btn-cta`, `.label-mono`, `.field`, `.choice-row`, a heading scale, a price style — whatever *this* store repeats). Pages then carry short class names plus a couple of layout utilities. ⚑ **A utility run that appears twice becomes a class.**
72
72
 
73
- The store's words work the same way: the states these hooks hand you recur across pages (an empty bag, an unbuyable product, an undeliverable address), so write that copy once in the store's voice — a small map per surface, as the sections below show. It is the half of a store's identity a kit cannot ship: "Add to bag" in every store built from this plugin is how they end up reading alike.
73
+ The store's words work the same way: the states these hooks hand you recur across pages (an empty bag, an unbuyable product, an undeliverable address), so write that copy once in the store's voice — a small map per surface, as the sections below show. It is the half of a store's identity a kit cannot ship.
74
74
 
75
75
  **Concentrate identity; don't diffuse it.** The classes carry the look everywhere; on top of them, spend bespoke markup on **one or two signature moments per page** — the hero, the one product-page module that shows what these products are judged on — and render everything else as conventions in the classes. **The product page stays the storefront's richest surface**, and that richness is semantic: what the controls and rows *show*, which costs words rather than chrome. One navigation affordance per control (thumbnails *or* arrows, never both plus dots); checkout, bag and order-received are convention surfaces. Keep components small (~2–4K chars) — faster to emit, review and fix than one long page file.
76
76
 
@@ -90,6 +90,7 @@ Everything below is already unwrapped — no `.data`, no envelope. `formatMoney`
90
90
  | `variantAxes(view, pick)` | `[{ key, name, selectedOption, options: [{ value, selected, disabled, outOfStock, pick }] }]`. |
91
91
  | `productPrice(rowOrView, { formatMoney })` | `{ label, compareAtLabel, onSale, isFrom, isRange, min, max }` — `label` is what to render. |
92
92
  | `productImages(product)` | `[{ src, name, alt }]`, de-duplicated. `[]` is legitimate → render your placeholder. |
93
+ | `productRibbons(product)` | `[{ id, name }]` — **objects**, and the field can be absent; takes a listing row or `useProduct().product`. |
93
94
  | `productSpecs(product)` | `[{ key, label, titleLabel, value }]` from `meta_data`; `findSpec(rows, key)` looks one up ignoring case/spaces/`_`/`-`. Never match on `label` — meta keys are free text. |
94
95
  | `useCart()` | `{ status, cart, itemCount, isEmpty, loading, error, mutationError, refresh, addItem, updateItem, removeItem, applyCoupon, removeCoupon }` — `status`: `"loading" \| "ready" \| "empty"`. |
95
96
  | `cart.items[n]` | `{ item_key, product_id, variation_id, name, quantity, price, subtotal, total, image, attributes, sold_individually, purchasable }` — `attributes` is an **array** of `{name, option}`; `purchasable` is a **result object** `{ok, code, error}`, not a boolean. |
@@ -103,23 +104,23 @@ Everything below is already unwrapped — no `.data`, no envelope. `formatMoney`
103
104
  ## Product list / collection
104
105
 
105
106
  ```jsx
106
- import { useProductList, useCategories, useStoreInfo, useFormatMoney, productPrice, productImages } from "@/commerce/storefront";
107
+ import { useProductList, useCategories, useStoreInfo, useFormatMoney, productPrice, productImages, productRibbons } from "@/commerce/storefront";
107
108
  ```
108
109
 
109
110
  (Drop `useCategories` with no filter bar; add `useRibbons` for a ribbon filter.) `const list = useProductList({ per_page: 24 })`, then guards on `list.status` before any markup — ⚑ branch on `status`, so a failed request renders as a failure instead of an empty grid, with a retry calling `list.reload`.
110
111
 
111
112
  ⚑ **Render paging whenever `hasNext` is true** — `{list.hasNext && <button type="button" onClick={list.next} disabled={list.busy}>…</button>}` (append mode: `list.loadMore`); a page that renders nothing for paging ships a catalog silently capped at `per_page`. Drive filters from `useCategories()`/`useRibbons()` data via `setParams`, never from hardcoded names — a renamed ribbon must not strand a dead button.
112
113
 
113
- A card can render `name`, `productImages(row)[0]`, `productPrice(row, { formatMoney }).label` (already "From €19.99" when the product sells variants — there is no product `type` flag, and `product.price` on its own is a rolled-up from-price), `on_sale`, `short_description`, `stock_status`, `average_rating`/`rating_count`, `ribbons`, `productSpecs(row)`. ⚑ **Images are objects and the array may be empty** — render your placeholder, never a broken `<img>`. Full field matrix: [`../references/catalog-rendering.md`](../references/catalog-rendering.md). That list is an inventory, not a card design: lead with the one or two fields *these* products are judged on rather than the default name/price/stars trio.
114
+ A card can render `name`, `productImages(row)[0]`, `productPrice(row, { formatMoney }).label` (already "From €19.99" when the product sells variants — there is no product `type` flag, and `product.price` alone is a rolled-up from-price), `on_sale`, `short_description`, `stock_status`, `average_rating`/`rating_count`, `productRibbons(row)`, `productSpecs(row)`. ⚑ **Images and ribbons are objects, either may be empty** — render your placeholder, never a broken `<img>` or a raw object. Field matrix: [`../references/catalog-rendering.md`](../references/catalog-rendering.md). That list is an inventory, not a card design: lead with the one or two fields *these* products are judged on rather than the default name/price/stars trio.
114
115
 
115
- ⚑ **Ribbons belong in both views** — grid and product page. They are the merchant's own merchandising ("Limited", "Last pieces"), the most-skipped part of catalog rendering, and each links to its filtered listing (`/collection?ribbon_id=<id>`). Never render a bare "Ribbons:" label with nothing after it.
116
+ ⚑ **Ribbons belong in both views** — grid and product page. They are the merchant's own merchandising ("Limited", "Last pieces"), and each links to its filtered listing (`/collection?ribbon_id=<id>`). `productRibbons(row)` hands you `{id, name}` **objects** — render `r.name`, key the link on `r.id`; the entry itself in JSX is React's "Objects are not valid as a React child". Never render a bare "Ribbons:" label with nothing after it.
116
117
 
117
118
  **Rails** (featured row, "new in") are the same hook with a filter (`{ featured: true, per_page: 4 }`) — `featured` is the merchant's own flag, so the rail stays curated store data instead of hardcoded slugs. ⚑ Any filter may legitimately match nothing — render *nothing* then, never a heading over an empty row.
118
119
 
119
120
  ## Product page
120
121
 
121
122
  ```jsx
122
- import { useProduct, useAddToCart, useStoreInfo, useFormatMoney, useStorefront, variantAxes, productImages, imageIndex, productSpecs, findSpec, storefrontErrorCode } from "@/commerce/storefront";
123
+ import { useProduct, useAddToCart, useStoreInfo, useFormatMoney, useStorefront, variantAxes, productImages, imageIndex, productRibbons, productSpecs, findSpec, storefrontErrorCode } from "@/commerce/storefront";
123
124
  ```
124
125
 
125
126
  `useProduct(slug)` (or `{ id }`) owns fetch + variant selection + quantity + price, race-safe, selection mirrored to the URL. ⚑ `status: "not_found"` is a 404 page, not a spinner. ⚑ **Call every hook above the status guards** — they all tolerate a null/loading product precisely so they can sit at the top; a hook after an early `return` crashes React with "Rendered more hooks than during the previous render" the moment the product resolves.
@@ -172,8 +173,8 @@ Build your layout from — all optional, **not one component style**:
172
173
 
173
174
  ⚑ **Text for every state, and the gate from the hook.** `buy.state` resolves the precedence — never re-derive `disabled` from your own ternary chain, and never leave a state unworded (the button renders empty). ⚑ `buy.showQuantity: false` means no stepper. With `<CartUIProvider>` mounted, a successful add opens the drawer by itself.
174
175
  - **Description** — `product.description` is HTML; render as rich text, `short_description` above it.
175
- - **Specs** — `productSpecs(product)` rows from the admin's *Modifiers*. ⚑ **Look a particular spec up with `findSpec(rows, "care")`**, which ignores case, spaces, `_` and `-`. Meta keys are free text typed by whoever set the product up (`care`, `Care`, `Care Instructions`), so `rows.find(s => s.label === "Care")` silently never matches and the feature renders its fallback forever. **This is the product page's signature-moment candidate**: pick the two or three keys that carry *this* catalog's meaning and render each as what it is (a weight as a figure, a composition as bars, a provenance beside its place), then let the rest fall through to plain rows in your classes. Not everything into one uniform grey table; not a bespoke widget per row. `[]` means no section at all.
176
- - **Breadcrumbs** — from `categories` (`/collection?category_id=${c.id}`); skip on a flat catalog. Ribbons are labels, not breadcrumbs.
176
+ - **Specs** — `productSpecs(product)` rows from the admin's *Modifiers*. ⚑ **Look a particular spec up with `findSpec(rows, "care")`**, which ignores case, spaces, `_` and `-`. Meta keys are free text (`care`, `Care`, `Care Instructions`), so `rows.find(s => s.label === "Care")` silently never matches and renders the fallback forever. **This is the product page's signature-moment candidate**: pick the two or three keys that carry *this* catalog's meaning and render each as what it is (a weight as a figure, a composition as bars, a provenance beside its place), then let the rest fall through to plain rows in your classes. Not one uniform grey table; not a bespoke widget per row. `[]` means no section at all.
177
+ - **Breadcrumbs** — from `categories` (`/collection?category_id=${c.id}`); skip on a flat catalog. Ribbons (`productRibbons(product)`) are labels, not breadcrumbs.
177
178
  - **Reviews, only if the store wants them** — no review UI is a complete outcome (then no star ratings on cards either: an average of nothing is `0`). `p.reviews` arrives with the product as `{ items, page, per_page, has_next }`; submitting is `submitReview` off `useStorefront()`, open to guests. ⚑ Derive the confirmation from the response's `status` (`"approved"` vs `"hold"`) — a hardcoded "awaiting approval" lies to every store that auto-approves — and refresh the list after, or the review doesn't appear. Field codes, policies and moderation: [`../references/reviews.md`](../references/reviews.md).
178
179
  - **Title** — give each page type its own `<title>` and description; a store whose every page shares one static title is invisible to search. Nothing here emits structured data either — if the store wants rich results, emit your own `Product`/`Offer` JSON-LD from `product` and `view.display` (price, currency, availability).
179
180
 
@@ -217,13 +218,13 @@ const formatMoney = useFormatMoney();
217
218
  ))}
218
219
  ```
219
220
 
220
- No shipping estimator here — checkout reprices shipping and tax from the address. An upsell beside the lines needs no query: `p.upsells` / `p.crossSells` from `useProduct` are rows you can render and add with `addItem`, matching "already in the bag" by product id, never by display name (a rename must not break the match). ⚑ **A one-click Add only works on a product with no attributes**: one that sells variants answers `400 variation_required`, so link those tiles to the product page instead.
221
+ No shipping estimator here — checkout reprices shipping and tax from the address. An upsell beside the lines needs no query: `p.upsells` / `p.crossSells` from `useProduct` are rows you can render and add with `addItem`, matching "already in the bag" by product id, never by display name. ⚑ **A one-click Add only works on a product with no attributes**: one that sells variants answers `400 variation_required`, so link those tiles to the product page instead.
221
222
 
222
223
  ### If the cart is a drawer
223
224
 
224
225
  Same hooks, same rows. The drawer's *state* — open/close, Esc, close-on-route-change, open-on-add — is `useCartUI()`; the markup is yours: a trigger in the header (`onClick={ui.toggleCart}`, `aria-expanded={ui.open}`), then `{ui.open && …}` rendering a click-away overlay plus your panel (`role="dialog" aria-modal="true"`, a named close button inside it).
225
226
 
226
- ⚑ **Render the drawer conditionally — `{ui.open && …}`.** The classic drawer bug is a panel translated off-screen but still mounted: its buttons stay clickable, tab-able and visible to screen readers. If you keep it mounted to animate the slide, you own that concern — set the `inert` attribute while closed. The overlay is a click-away surface, not the close control.
227
+ ⚑ **Render the drawer conditionally — `{ui.open && …}`.** The classic drawer bug is a panel translated off-screen but still mounted: its buttons stay clickable, tab-able and visible to screen readers. If you keep it mounted to animate the slide, set the `inert` attribute while closed. The overlay is a click-away surface, not the close control.
227
228
 
228
229
  ## Checkout
229
230
 
@@ -351,6 +352,6 @@ The cart is optimistic and debounced, so a script that acts faster than it settl
351
352
  - [ ] Address form includes the state/province field and every `autoComplete` token.
352
353
  - [ ] Cart rows scope busy state to the row; repeated controls have unique accessible names; a drawer uses `useCartUI` and is rendered conditionally on `ui.open`.
353
354
  - [ ] Checkout guards `stage === "submitted"` above its empty-cart branch.
354
- - [ ] The storefront carries the design you settled on before reading this file — design classes plus one or two signature moments per page; on the product page, axes and specs are rendered by what they are (not one uniform table, not one identical chip row); convention surfaces (checkout, bag, order-received) carry the classes and nothing bespoke.
355
+ - [ ] The storefront carries the design you settled on before reading this file — design classes plus one or two signature moments per page; axes and specs rendered by what they are (not one uniform table, not one identical chip row); convention surfaces carry the classes and nothing bespoke.
355
356
 
356
357
  Then copy this file's `carry_forward` lines (in its front matter) into your working notes, and do not re-read this file.
@@ -22,7 +22,7 @@ A fresh install has **no settings and no catalog**. One admin-only, idempotent c
22
22
 
23
23
  **`store_name` is required on a first seed** — the app's name as the platform shows it (`base44/config.jsonc` → `name` can be stale; ask if unsure). **`currency`** is an ISO code (`"EUR"`); formatting follows the viewer's locale, nothing else to set. Explicit values always win, first seed and re-runs alike.
24
24
 
25
- The working call — `name` is the only required product key; give each product the keys its own catalog entry actually has and leave the rest out. The **full key list** (sale windows, downloads, tax, backorders, dimensions…) lives in `api-admin.md` — open it only if the catalog needs one:
25
+ The working call — `name` is the only required product key; give each product the keys its own catalog entry actually has and leave the rest out. The **full key list** (sale windows, downloads, tax, backorders, upsells…) lives in `api-admin.md` — open it only if the catalog needs one:
26
26
 
27
27
  ```js
28
28
  try {
@@ -45,10 +45,14 @@ try {
45
45
  categories: ["Shoes"], // get-or-created by display name
46
46
  ribbons: ["Best Seller"], // flat labels, not a hierarchy
47
47
 
48
+ weight: 0.248, // real fields, in the store's units —
49
+ dimensions: { length: 31, width: 12, height: 11 }, // never a meta_data row
50
+
48
51
  // Descriptive spec rows (productSpecs). NOT axes, NOT ribbons; `_` hides.
52
+ // Qualities only — a weight or a size here is a string nothing can read.
49
53
  meta_data: [
50
54
  { key: "Material", value: "Recycled knit upper" },
51
- { key: "Weight", value: "248 g" },
55
+ { key: "Care", value: "Machine wash cold" },
52
56
  ],
53
57
 
54
58
  attributes: [ // the axes → one selector each
@@ -57,8 +61,9 @@ try {
57
61
  ],
58
62
  default_options: { Size: "42", Color: "Black" },
59
63
  variations: [ // omit entirely → all combos auto-generated
60
- { options: { Size: "41", Color: "Black" }, stock_quantity: 4 },
64
+ { options: { Size: "41", Color: "Black" }, stock_quantity: 4, weight: 0.242 },
61
65
  { options: { Size: "42", Color: "White" }, regular_price: 94, stock_quantity: 3,
66
+ weight: 0.254, // per variant, never inherited from the parent
62
67
  image: "https://…/sneaker-white.jpg" }, // per-variation image for a visual axis
63
68
  ],
64
69
  },
@@ -136,9 +141,4 @@ Online card payments are **off by default**: the seeded store takes the manual `
136
141
  - [ ] `coupons` seeded only if a coupon field exists ([`./02-storefront.md`](./02-storefront.md)).
137
142
  - [ ] Cards off, or on with the provider file copied whole.
138
143
 
139
- Record these lines in your working notes; do not re-read this file.
140
-
141
- - Product slugs come from the seed response's `catalog.products[]` — link pages by slug, never by a client-side map.
142
- - Payments: report at handover where they landed (default = offline on, card off) — the owner must never learn it from a customer.
143
- - Turning card payments on or off later is one more seed call: `{ payment_methods: ["offline", "card"] }`.
144
- - Seed-time `locations` is THE shipping path; patching `commerce.ShippingTaxLocation` is the day-2 route.
144
+ Then copy this file's `carry_forward` lines (in its front matter) into your working notes, and do not re-read this file.
@@ -7,6 +7,7 @@ carry_forward:
7
7
  - "There is no product `type` field: a non-empty `attributes[]` is what makes a product sell variants, and such a product is only sellable via a `variation_id`."
8
8
  - "A variant parent's `price` is a FROM price — render it through productPrice(row, { formatMoney }), never as the price."
9
9
  - "Product images are objects `{src, name, alt}` and the array can be empty — go through productImages/normalizeImage and render a placeholder."
10
+ - "Ribbons are objects `{id, name}` and the field can be absent — go through productRibbons(product), which works on a listing row and on useProduct's product alike."
10
11
  ---
11
12
 
12
13
  # Rendering the catalog: listing and product page
@@ -32,13 +33,43 @@ A listing **row** is the product record (minus paywalled fields) plus resolved `
32
33
  | `sku`, `stock_status`, `stock_quantity`, `manage_stock`, `backorders` | ✅ | ✅ | |
33
34
  | `average_rating`, `rating_count`, `total_sales` | ✅ | ✅ | Enough for stars on a card; the reviews themselves aren't |
34
35
  | `downloadable`, `virtual`, `weight`, `dimensions`, `attributes[]`, `meta_data` | ✅ | ✅ | `attributes[]` is the variant axes; `meta_data` the descriptive **modifiers** (`productSpecs(product)` → spec rows) |
35
- | **`ribbons`** (resolved), `ribbon_ids`, `category_ids` | ✅ | ✅ | Rows carry `{id, name}` ribbons; `get-product` returns the full records |
36
+ | **`ribbons`** (resolved), `ribbon_ids`, `category_ids` | ✅ | ✅ | `{id, name}` objects on a row; `get-product` returns the records beside the product, which `useProduct` normalizes and attaches |
36
37
  | **`categories`** (resolved) | ❌ *ids only* | ✅ | §6 to add them to rows |
37
38
  | **`variations[]`** (per-variant price/stock/image/attributes) | ❌ | ✅ | Why a product with variants can't be fully priced from a row |
38
39
  | **`reviews`** (paged items + aggregates) | ❌ | ✅ | Rows still carry the aggregate numbers |
39
40
  | **`upsells`, `cross_sells`** (summaries) | ❌ | ✅ | `{id, name, slug, price, on_sale, image}` |
40
41
  | `downloads[]`, `download_limit`, `download_expiry` | ❌ | ❌ | **Never** public — only via `commerce/storefront-account` `get-download` |
41
42
 
43
+ ### What each field actually holds
44
+
45
+ Most of the interesting ones are **arrays of objects** where a card tends to
46
+ assume strings — `{p.ribbons[0]}` in JSX is React's *"Objects are not valid as a
47
+ React child"*, `<img src={p.images[0]}>` a broken image on every product at
48
+ once. The whole list, so none of it has to be read out of the backend:
49
+
50
+ | Field | Shape | Render it through |
51
+ |---|---|---|
52
+ | `images[]` | `{src, name, alt}` — **objects**; `[]` is legitimate | `productImages(product)` |
53
+ | `ribbons[]` | `{id, name}` — **objects**; the field is **absent**, not `[]`, when no row on the page carries one | `productRibbons(product)` |
54
+ | `meta_data[]` | `{key, value}` — keys are free text | `productSpecs(product)` + `findSpec` |
55
+ | `attributes[]` | `{attribute_id, name, position, options: string[]}` — one entry per **axis** | `variantAxes(view, pick)` |
56
+ | `default_attributes[]` | `{attribute_id, name, option}` — the merchant's pre-selection | `defaultSelection` (automatic in `useProduct`) |
57
+ | `dimensions` | `{length, width, height}` in the store's unit | — |
58
+ | `category_ids[]`, `ribbon_ids[]` | id **strings** | `category_id` / `ribbon_id` filters |
59
+ | `categories[]` *(get-product)* | full records — `{id, name, slug, parent_id, image, menu_order, count}` | breadcrumb links on `c.id` |
60
+ | `variations[]` *(get-product)* | `{id, product_id, attributes: [{attribute_id, name, option}], price, sale_price, on_sale, sku, image, stock_status, stock_quantity, …}` | `view`, never the list itself (§5) |
61
+ | `reviews` *(get-product)* | `{items: [{id, reviewer, review, rating, verified, created_date}], page, per_page, has_next, average_rating, rating_count}` | — |
62
+ | `upsells[]`, `cross_sells[]` *(get-product)* | `{id, name, slug, price, on_sale, image, stock_status}` — here `image` **is** a plain URL string | — |
63
+
64
+ The React layer carries all of this as types the hooks name on their `@returns`
65
+ — `StorefrontProduct` and the rest in `src/commerce/utils/types.js`, each hook's
66
+ result in `src/commerce/storefront/types.js` — so an editor answers "what is in
67
+ this field?" on hover. And `useProduct` attaches its resolved `ribbons` to
68
+ `product` in the listing row's `{id, name}` shape, which is why one card
69
+ component can render a row *or* the product page's product. (Ribbon `count` is
70
+ not in that shape: filter counts come from `useRibbons()` / `list-ribbons`,
71
+ which counts only the products the catalog would list.)
72
+
42
73
  ## 2. Two shapes, and three rules the exports own
43
74
 
44
75
  There is **no product `type` field**. `attributes[]` tells the shapes apart:
@@ -70,7 +101,7 @@ Both lists are inventories of what the data supports — **not a layout and not
70
101
 
71
102
  Ribbons are flat, cross-cutting labels ("Best Seller", "New", "Gift"); categories are the hierarchical spine. Generated storefronts routinely omit ribbons entirely. Don't.
72
103
 
73
- - **Listing rows carry resolved `ribbons`** a card renders them with no extra call; the product page renders its top-level `ribbons` near the metadata, lighter than the breadcrumb. One or two per card is useful, more is noise, and `[]` means render nothing — never a dangling "Ribbons:" label.
104
+ - **Both views carry resolved ribbons**, so a card needs no extra call: `productRibbons(product)` reads a listing row and `useProduct`'s product alike (the page can also take `p.ribbons`), and always answers `[{id, name}]` — including when the row has no `ribbons` field at all. Render `r.name`, never the entry itself; the product page's row sits near the metadata, lighter than the breadcrumb. One or two per card is useful, more is noise, and `[]` means render nothing — never a dangling "Ribbons:" label.
74
105
  - **Every ribbon links to a filtered listing** — `list-products` with `ribbon_id`, never a dead label. A ribbon has no slug, so key the URL on its id (`/shop?ribbon=<id>`) to stay shareable across reloads.
75
106
  - **Offer ribbons as a filter** from `useRibbons()` / `list-ribbons`, which hides ribbons no published product carries and gives a `count` for labels like "Gift (12)". `ribbon_id` stacks with `category_id`, price, `on_sale`, `featured`, `in_stock_only`.
76
107
  - Ribbons are not breadcrumbs and never variant options — a size or colour is an `attribute`.
@@ -55,6 +55,8 @@ A kit update re-copies `shared/commerce/` and restores the stub — re-run the c
55
55
 
56
56
  Checkout (`place-order`, `card` gateway) creates the order `pending`, runs `createCardPayment`, stores its `reference` on the order and redirects to `url`; return URLs carry `order_id`/`order_key`/`payment=`. Confirmation is **two idempotent paths** (the second a no-op): the customer return (`/order-received` → `commerce/payments` `complete-return` → `checkCardPaymentPaid` → `processing` with all side-effects; `useOrderReturn` is that page in one hook) and the webhook (covers the closed tab). `create-link` mints a fresh page for any unpaid order the same way; `admin-refunds` `refund_payment: true` calls `refundCardPayment` **before** writing locally (unimplemented → `501`; record without the flag instead). Gateway enabled with no provider behind it → `503 no_card_payment_provider`; every other payment option is **manual** (on-hold, the option's description as payment instructions) and needs no code.
57
57
 
58
+ ⚑ **Card payment cannot complete in the preview pane.** Providers serve their payment page `frame-ancestors 'none'`, so the redirect dead-ends in the frame. `useCheckout`'s `placeOrder` refuses up front — `{ ok: false, error: { code: "card_payment_in_preview" } }`, nothing created — because `place-order` takes the order and its stock hold *before* minting the payment page, and an attempt that dies at the redirect would strand an unpayable order. Manual gateways are unaffected. **Test card checkout in the published store**; a card flow that appears to do nothing in preview and works published is this, not broken provider wiring.
59
+
58
60
  ## Implementation rules
59
61
 
60
62
  For a **custom** provider (the shipped files already obey all of these):
@@ -41,6 +41,8 @@
41
41
  * `productPrice` (the from-price and incomplete-selection range rules),
42
42
  * `productImages` + `imageIndex` (an image's position in that list — how a
43
43
  * gallery follows the variant selection without owning a second copy of it),
44
+ * `productRibbons` (ribbons are `{id, name}` objects, not strings — rendering
45
+ * one straight into JSX is React's "Objects are not valid as a React child"),
44
46
  * `productSpecs` + `findSpec` (meta keys are free text, so featuring a
45
47
  * particular spec needs a tolerant lookup, not an equality test),
46
48
  * `attributesLabel`, `cartTotalsLines` / `orderTotalsLines`,
@@ -49,6 +51,13 @@
49
51
  * `storefrontErrorCode` / `storefrontErrorMessage` for the calls you make
50
52
  * yourself — every rejection from the client carries a code worth branching on.
51
53
  *
54
+ * **What a field holds** — every catalog shape a page renders from is written
55
+ * down in `./types.js` (`StorefrontProduct`, `ProductRibbon`, `ProductImage`,
56
+ * `ProductCategory`, `ProductSummary`, and each hook's result), and the hooks
57
+ * carry those types on their `@returns`. Read that instead of a backend
58
+ * function's source; the render rules that go with the shapes stay in the doc
59
+ * comments here and in the skill's references/catalog-rendering.md.
60
+ *
52
61
  * The catalog and product surfaces are where the design freedom lives: these
53
62
  * hooks hand you resolved data, and the rendering is entirely yours.
54
63
  */
@@ -85,6 +94,7 @@ export {
85
94
  productPrice,
86
95
  productImages,
87
96
  imageIndex,
97
+ productRibbons,
88
98
  productSpecs,
89
99
  findSpec,
90
100
  attributesLabel,
@@ -0,0 +1,120 @@
1
+ /**
2
+ * What the catalog hooks resolve to — the return shapes of `useProductList`,
3
+ * `useProduct`, `useCategories` and `useRibbons`, so a page never has to open a
4
+ * hook (or a backend function) to learn what a field holds.
5
+ *
6
+ * **Types only** — JSDoc `@typedef`s, no runtime code. The product shapes
7
+ * themselves live one layer down in `@/commerce/utils` (framework-free) and are
8
+ * re-declared here so this file is the only one a React page needs:
9
+ * `StorefrontProduct`, `ProductRibbon`, `ProductImage`, `ProductCategory`,
10
+ * `StorefrontVariation`, `ProductSummary`, `ProductReviews`.
11
+ *
12
+ * The rendering rules that go with these shapes are in each hook's doc comment
13
+ * and in the commerce skill's references/catalog-rendering.md — a type says a
14
+ * ribbon is `{id, name}`, not that an unbuyable variant option renders disabled
15
+ * rather than hidden.
16
+ */
17
+
18
+ /** @typedef {import("../utils/types.js").StorefrontProduct} StorefrontProduct */
19
+ /** @typedef {import("../utils/types.js").StorefrontVariation} StorefrontVariation */
20
+ /** @typedef {import("../utils/types.js").ProductImage} ProductImage */
21
+ /** @typedef {import("../utils/types.js").ProductRibbon} ProductRibbon */
22
+ /** @typedef {import("../utils/types.js").ProductCategory} ProductCategory */
23
+ /** @typedef {import("../utils/types.js").ProductSummary} ProductSummary */
24
+ /** @typedef {import("../utils/types.js").ProductReview} ProductReview */
25
+ /** @typedef {import("../utils/types.js").ProductReviews} ProductReviews */
26
+ /** @typedef {import("../utils/types.js").ProductMeta} ProductMeta */
27
+ /** @typedef {import("../utils/types.js").ProductAttributeAxis} ProductAttributeAxis */
28
+
29
+ /**
30
+ * A failed storefront call, as the hooks expose it. `code` is the backend's
31
+ * stable code (`not_found`, `out_of_stock`, …) — branch on it, render `message`.
32
+ *
33
+ * @typedef {object} StorefrontError
34
+ * @property {string|null} code
35
+ * @property {string} message
36
+ */
37
+
38
+ /**
39
+ * `useProductList(params, options)` — a catalog listing with its state solved.
40
+ *
41
+ * @typedef {object} UseProductListResult
42
+ * @property {StorefrontProduct[]} products — the current page (accumulated, in `mode: "append"`)
43
+ * @property {"loading"|"ready"|"empty"|"error"} status — what the page renders from
44
+ * @property {number} page
45
+ * @property {number} perPage
46
+ * @property {boolean} hasNext — true ⇒ render a paging control, or the catalog is capped at one page
47
+ * @property {number} totalLoaded
48
+ * @property {boolean} loading
49
+ * @property {boolean} refreshing
50
+ * @property {boolean} busy — `loading || refreshing`; what a paging button disables on
51
+ * @property {StorefrontError|null} error
52
+ * @property {boolean} isEmpty — never true while loading
53
+ * @property {object} params — the live `list-products` params
54
+ * @property {(patch: object) => void} setParams — any change but `page` returns to page 1
55
+ * @property {() => void} resetParams
56
+ * @property {() => void} next
57
+ * @property {() => void} prev
58
+ * @property {(page: number) => void} goToPage
59
+ * @property {() => void} loadMore — the paging call in `mode: "append"`
60
+ * @property {() => void} reload
61
+ * @property {() => void} reloadQuiet
62
+ */
63
+
64
+ /**
65
+ * `useProduct(ref, options)` — the product page's data and selection lifecycle.
66
+ * `view` is `resolveSelection`'s render model (`axes`, `display`, `purchasable`,
67
+ * `addToCart`, `priceRange`, `complete`, `missingAxes` — bind controls to it,
68
+ * never to `product.*`), and `price` is `productPrice`'s from-price/range rules.
69
+ *
70
+ * @typedef {object} UseProductResult
71
+ * @property {"loading"|"ready"|"not_found"|"error"} status — `"not_found"` is a 404 page, not a spinner
72
+ * @property {boolean} loading
73
+ * @property {StorefrontError|null} error — null when the product simply isn't there
74
+ * @property {boolean} notFound
75
+ * @property {() => void} reload
76
+ * @property {StorefrontProduct|null} product — carries the resolved `ribbons`, like a listing row
77
+ * @property {StorefrontVariation[]} variations — publishable only; `[]` without attributes
78
+ * @property {ProductCategory[]} categories — resolved records, for a breadcrumb
79
+ * @property {ProductRibbon[]} ribbons — `{id, name}`, same array as `product.ribbons`
80
+ * @property {ProductSummary[]} upsells
81
+ * @property {ProductSummary[]} crossSells
82
+ * @property {ProductReviews|null} reviews
83
+ * @property {object|null} view — the resolved selection view
84
+ * @property {object} selection — `{ [axisKey]: option }`
85
+ * @property {(axisKey: string, option: string) => void} pick
86
+ * @property {(selection: object) => void} setSelection
87
+ * @property {() => void} resetSelection
88
+ * @property {number} quantity
89
+ * @property {(n: number) => void} setQuantity
90
+ * @property {() => void} incQuantity
91
+ * @property {() => void} decQuantity
92
+ * @property {number} maxQuantity — respects `sold_individually` and tracked stock
93
+ * @property {boolean} canIncrease
94
+ * @property {{label: string, compareAtLabel: string|null, onSale: boolean,
95
+ * isFrom: boolean, isRange: boolean, min: number|null, max: number|null}} price
96
+ */
97
+
98
+ /**
99
+ * `useCategories()` — the category tree, roots with `children` nested.
100
+ *
101
+ * @typedef {object} UseCategoriesResult
102
+ * @property {ProductCategory[]} items — always an array
103
+ * @property {boolean} loading
104
+ * @property {StorefrontError|null} error
105
+ * @property {() => void} reload
106
+ */
107
+
108
+ /**
109
+ * `useRibbons()` — the ribbon list for a filter or a "shop by ribbon" nav.
110
+ * `count` is tallied from the products the catalog would actually list, so
111
+ * "Gift (12)" and the ribbon's listing agree.
112
+ *
113
+ * @typedef {object} UseRibbonsResult
114
+ * @property {Array<ProductRibbon & {count: number}>} items — always an array
115
+ * @property {boolean} loading
116
+ * @property {StorefrontError|null} error
117
+ * @property {() => void} reload
118
+ */
119
+
120
+ export {};
@@ -9,6 +9,22 @@ import {
9
9
  shippingSlice,
10
10
  } from "./address";
11
11
 
12
+ /**
13
+ * Card payment cannot complete in the app builder's preview pane. The
14
+ * provider's payment page is a full-page redirect and providers serve theirs
15
+ * `frame-ancestors 'none'`, so the redirect navigates the pane, the provider
16
+ * refuses to render in it, and checkout looks like it silently did nothing. A
17
+ * storefront runs framed in exactly one place, so "framed" is read as "preview".
18
+ */
19
+ function inPreview() {
20
+ if (typeof window === "undefined") return false;
21
+ try {
22
+ return window.self !== window.top;
23
+ } catch {
24
+ return true;
25
+ }
26
+ }
27
+
12
28
  const EMPTY_ADDRESS = Object.freeze({
13
29
  first_name: "",
14
30
  last_name: "",
@@ -70,6 +86,12 @@ function resolvePaymentMethod(gateways, picked) {
70
86
  * handle the result yourself (a manual result carries
71
87
  * `result.payment_instructions`).
72
88
  *
89
+ * **A card order will not place from the preview pane** — providers refuse to
90
+ * be framed, so the redirect can only dead-end there. `placeOrder` resolves
91
+ * `{ ok: false, error: { code: "card_payment_in_preview" } }` without
92
+ * creating anything; manual gateways are unaffected. Test card checkout in
93
+ * the published store.
94
+ *
73
95
  * Both navigations are **full page loads** (`window.location.assign`): the
74
96
  * provider hop leaves the app, and the receipt boots from the URL alone
75
97
  * (`order_id` + `order_key`), so a reload there is correct and shareable.
@@ -215,6 +237,18 @@ export function useCheckout(options = {}) {
215
237
  const placeOrder = useCallback(
216
238
  async (extra = {}) => {
217
239
  if (placing) return { ok: false, error: { code: "placing", message: "Order already being placed." } };
240
+ // Refuse a card checkout from the preview pane before the network, not
241
+ // after it: `place-order` creates the order and takes its stock hold
242
+ // *before* the payment page exists, so an attempt that can only die at
243
+ // the redirect would strand a real pending order nobody can pay.
244
+ if (paymentMethod === "card" && inPreview()) {
245
+ const error = {
246
+ code: "card_payment_in_preview",
247
+ message: "Card payment can't be completed in preview. Open the published store to pay.",
248
+ };
249
+ setOrderError(error);
250
+ return { ok: false, error };
251
+ }
218
252
  // Safe as `onClick={checkout.placeOrder}`: a DOM/React event is not
219
253
  // `extra`. Spreading one into the order payload would send a circular
220
254
  // SyntheticEvent as order fields — an explicit object still forwards.
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useState } from "react";
2
2
  import {
3
3
  defaultSelection,
4
4
  productPrice,
5
+ productRibbons,
5
6
  resolveSelection,
6
7
  selectOption,
7
8
  selectionFromParams,
@@ -32,9 +33,15 @@ import { useAsyncData } from "./internal/useAsyncData";
32
33
  * flight can never paint over a newer one; and the selection is mirrored to the
33
34
  * URL (`?color=Ivory`) so a chosen variant is linkable and survives a reload.
34
35
  *
36
+ * `product` is a `StorefrontProduct` (`src/commerce/utils/types.js`) carrying
37
+ * the resolved `ribbons` exactly as a listing row does, so one card component
38
+ * can render from either — `productImages(product)` and
39
+ * `productRibbons(product)` both hand back objects, never strings.
40
+ *
35
41
  * @param {string|{id: string}} ref slug or `{ id }`
36
42
  * @param {{syncSelectionToUrl?: boolean, reviewsPerPage?: number,
37
43
  * initialSelection?: object}} [options]
44
+ * @returns {import("./types.js").UseProductResult}
38
45
  */
39
46
  export function useProduct(ref, options = {}) {
40
47
  const { syncSelectionToUrl = true, reviewsPerPage, initialSelection } = options;
@@ -47,7 +54,16 @@ export function useProduct(ref, options = {}) {
47
54
  { keepPreviousData: false },
48
55
  );
49
56
 
50
- const product = data?.product ?? null;
57
+ // `get-product` returns the ribbons *beside* the product; a listing row
58
+ // carries them ON the row. Attach them here so both surfaces read one field
59
+ // in one shape — `productRibbons(product)` works on a card and on this page —
60
+ // and normalize to the row's `{id, name}`: the full record's `count` counts
61
+ // drafts and drifts, so filter counts come from `useRibbons()` instead.
62
+ const ribbons = useMemo(() => productRibbons(data?.ribbons ?? []), [data]);
63
+ const product = useMemo(
64
+ () => (data?.product ? { ...data.product, ribbons } : null),
65
+ [data, ribbons],
66
+ );
51
67
  const variations = data?.variations ?? [];
52
68
 
53
69
  // ── selection ──────────────────────────────────────────────────────────────
@@ -141,7 +157,7 @@ export function useProduct(ref, options = {}) {
141
157
  product,
142
158
  variations,
143
159
  categories: data?.categories ?? [],
144
- ribbons: data?.ribbons ?? [],
160
+ ribbons,
145
161
  upsells: data?.upsells ?? [],
146
162
  crossSells: data?.cross_sells ?? [],
147
163
  reviews: data?.reviews ?? null,
@@ -24,10 +24,17 @@ import { useAsyncData } from "./internal/useAsyncData";
24
24
  * legitimately match nothing — render from `isEmpty`, never on the assumption
25
25
  * that rows came back.
26
26
  *
27
+ * Each row is a `StorefrontProduct` — the whole published product record, with
28
+ * `images` as `{src, name, alt}` objects and the resolved `ribbons` as
29
+ * `{id, name}` objects (both are objects, both may be missing; go through
30
+ * `productImages` / `productRibbons`). The field list is
31
+ * `src/commerce/utils/types.js`.
32
+ *
27
33
  * @param {object} [initialParams] `list-products` params (page/per_page and any filter)
28
34
  * @param {{mode?: "pages"|"append", perPage?: number, keepPreviousData?: boolean}} [options]
29
35
  * `mode: "append"` accumulates pages for a "load more" / infinite-scroll
30
36
  * catalog; `loadMore()` is then the paging call.
37
+ * @returns {import("./types.js").UseProductListResult}
31
38
  */
32
39
  export function useProductList(initialParams = {}, options = {}) {
33
40
  const { mode = "pages", perPage: perPageOption, keepPreviousData = true } = options;
@@ -131,7 +138,7 @@ export function useProductList(initialParams = {}, options = {}) {
131
138
  * under `children`. One cached call; use it for navigation and for the
132
139
  * `category_id` filter on `useProductList`.
133
140
  *
134
- * @returns {{items: Array<object>, loading: boolean, error: object|null, reload: () => void}}
141
+ * @returns {import("./types.js").UseCategoriesResult}
135
142
  */
136
143
  export function useCategories() {
137
144
  const store = useStorefront();
@@ -140,10 +147,12 @@ export function useCategories() {
140
147
  }
141
148
 
142
149
  /**
143
- * Ribbons — an ARRAY of `{ id, name, count }`. Labels like "Best Seller" for
144
- * cards and the product page, and the `ribbon_id` filter's option list.
150
+ * Ribbons — an ARRAY of `{ id, name, count }` **objects**, for the `ribbon_id`
151
+ * filter's option list and a "shop by ribbon" nav. A product's own ribbons ride
152
+ * on the product (`productRibbons(product)`); this is the store's whole set,
153
+ * with the counts a filter labels itself with ("Gift (12)").
145
154
  *
146
- * @returns {{items: Array<object>, loading: boolean, error: object|null, reload: () => void}}
155
+ * @returns {import("./types.js").UseRibbonsResult}
147
156
  */
148
157
  export function useRibbons() {
149
158
  const store = useStorefront();
@@ -25,16 +25,21 @@
25
25
  * - `address-spec.js` — `addressFieldSpec`: the checkout address form as data,
26
26
  * with country/state options that are always arrays.
27
27
  * - `images.js` — `productImages`: images normalized to `{src, name, alt}`.
28
+ * - `ribbons.js` — `productRibbons`: ribbons normalized to `{id, name}` — they
29
+ * are objects, and the field is absent on a listing page that carries none.
28
30
  * - `specs.js` — `productSpecs`: `meta_data` → descriptive rows (`key`, `label`,
29
31
  * `titleLabel`, `value`). Match rows by `key`, never by `label`.
32
+ * - `types.js` — types only: `StorefrontProduct` and the rest of the catalog
33
+ * shapes as JSDoc typedefs, so what a field holds is readable from the
34
+ * frontend instead of from the backend function's source.
30
35
  *
31
36
  * Building the storefront in React? Import from **`@/commerce/storefront`** and
32
37
  * nothing else — it adds the headless hooks and re-exports the helpers a page
33
38
  * actually needs (`variantAxes`, `productPrice`, `productImages`,
34
- * `productSpecs`, `attributesLabel`, `cartTotalsLines`, `orderTotalsLines`), so
35
- * one import line covers a page. Neither layer ships any UI: all markup,
36
- * styling and copy belong to the storefront you build. Use this module directly
37
- * for non-React code and inside your own custom logic.
39
+ * `productRibbons`, `productSpecs`, `attributesLabel`, `cartTotalsLines`,
40
+ * `orderTotalsLines`), so one import line covers a page. Neither layer ships
41
+ * any UI: all markup, styling and copy belong to the storefront you build. Use
42
+ * this module directly for non-React code and inside your own custom logic.
38
43
  */
39
44
  export * from "./storefront.js";
40
45
  export * from "./variants.js";
@@ -43,4 +48,5 @@ export * from "./price.js";
43
48
  export * from "./totals.js";
44
49
  export * from "./address-spec.js";
45
50
  export * from "./images.js";
51
+ export * from "./ribbons.js";
46
52
  export * from "./specs.js";
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Product ribbons, normalized — the merchant's own merchandising labels
3
+ * ("Best Seller", "Last pieces"), rendered on cards and on the product page.
4
+ *
5
+ * Every ribbon is an **object** — `{ id, name }` — never a string, and
6
+ * `{product.ribbons[0]}` in JSX is React's "Objects are not valid as a React
7
+ * child" on the first product that carries one. Two more facts a hand-written
8
+ * `product.ribbons.map(...)` gets wrong: the field is **absent**, not `[]`, on a
9
+ * listing page where no row carries a ribbon (`list-products` only decorates
10
+ * when there is something to decorate), and the product page's ribbons arrive
11
+ * beside the product rather than on it. `productRibbons` takes any of those —
12
+ * a listing row, `useProduct().product`, the raw `get-product` payload, or the
13
+ * array itself — and always answers with the same clean array.
14
+ *
15
+ * ```jsx
16
+ * {productRibbons(product).map((r) => (
17
+ * <a key={r.id} href={`/collection?ribbon_id=${r.id}`}>{r.name}</a>
18
+ * ))}
19
+ * ```
20
+ *
21
+ * `[]` means render nothing — never a dangling "Ribbons:" label. A ribbon has
22
+ * no slug, so a link keys on `r.id`; counts for a filter's labels ("Gift (12)")
23
+ * come from `useRibbons()` / `list-ribbons`, not from here.
24
+ */
25
+
26
+ /**
27
+ * A product's ribbons as renderable `{ id, name }` entries, de-duplicated.
28
+ *
29
+ * @param {object|Array<object|string>|null|undefined} source a listing row, a
30
+ * product, the `get-product` payload — anything carrying `ribbons` — or the
31
+ * ribbon array itself
32
+ * @returns {Array<{id: string, name: string}>} may be empty; `name` is the
33
+ * merchant's own text, unchanged
34
+ */
35
+ export function productRibbons(source) {
36
+ const raw = Array.isArray(source) ? source : (source?.ribbons ?? []);
37
+ const seen = new Set();
38
+ const out = [];
39
+ for (const entry of raw) {
40
+ // A bare string is accepted because seed-store takes `ribbons: ["Gift"]`,
41
+ // so that shape turns up in code that moves catalog data around.
42
+ const name = String((typeof entry === "string" ? entry : entry?.name) ?? "").trim();
43
+ if (!name) continue;
44
+ const id = String((typeof entry === "string" ? "" : entry?.id) ?? "");
45
+ const key = id || `name:${name.toLowerCase()}`;
46
+ if (seen.has(key)) continue;
47
+ seen.add(key);
48
+ out.push({ id, name });
49
+ }
50
+ return out;
51
+ }
@@ -0,0 +1,193 @@
1
+ /**
2
+ * The catalog shapes, written down once — what `list-products` and
3
+ * `get-product` actually hand a storefront, so the answer to "what is in
4
+ * `ribbons[0]`?" is here rather than in the backend function's source.
5
+ *
6
+ * **This module is types only** — JSDoc `@typedef`s, no runtime code. Editors
7
+ * resolve them through the `@returns` annotations on the storefront hooks and
8
+ * helpers (`useProductList().products` is a `StorefrontProduct[]`), and they
9
+ * read on their own as the field list a card or a product page can render.
10
+ *
11
+ * Three shapes are objects where a storefront tends to assume strings — the
12
+ * whole reason this file exists:
13
+ *
14
+ * - `images[]` → `{ src, name, alt }`, and the array may be empty
15
+ * (`productImages(product)`)
16
+ * - `ribbons[]` → `{ id, name }`, and the field may be absent
17
+ * (`productRibbons(product)`)
18
+ * - `meta_data[]` → `{ key, value }` with free-text keys (`productSpecs(product)`)
19
+ *
20
+ * …while `ProductSummary.image` (upsells and cross-sells) *is* a plain URL
21
+ * string. Go through the helpers and the difference stops mattering.
22
+ *
23
+ * Field-by-field notes on what each view can render:
24
+ * the commerce skill's references/catalog-rendering.md.
25
+ */
26
+
27
+ /**
28
+ * A product image. Stored on the product; `alt` falls back to `name`.
29
+ *
30
+ * @typedef {object} ProductImage
31
+ * @property {string} src
32
+ * @property {string} name
33
+ * @property {string} alt
34
+ * @property {number} [position]
35
+ */
36
+
37
+ /**
38
+ * A ribbon as a storefront sees it — a flat merchandising label. The full
39
+ * `commerce.ProductRibbon` record is admin-only; a storefront gets `{id, name}`
40
+ * on catalog rows and `{id, name, count}` from `list-ribbons` / `useRibbons()`.
41
+ *
42
+ * @typedef {object} ProductRibbon
43
+ * @property {string} id — the `list-products` `ribbon_id` filter value; ribbons have no slug
44
+ * @property {string} name
45
+ */
46
+
47
+ /**
48
+ * A category. `get-product` returns the full records; `list-categories` /
49
+ * `useCategories()` nests the tree under `children`.
50
+ *
51
+ * @typedef {object} ProductCategory
52
+ * @property {string} id
53
+ * @property {string} name
54
+ * @property {string} slug
55
+ * @property {string} [parent_id] — empty at top level
56
+ * @property {string} [description]
57
+ * @property {{src: string, alt: string}} [image]
58
+ * @property {number} [menu_order]
59
+ * @property {number} [count] — derived, admin-maintained; may drift
60
+ * @property {ProductCategory[]} [children] — `list-categories` only
61
+ */
62
+
63
+ /**
64
+ * One variant **axis** on a product (Size, Color). A non-empty `attributes[]`
65
+ * is what makes a product sell variants — there is no `type` field.
66
+ *
67
+ * @typedef {object} ProductAttributeAxis
68
+ * @property {string} attribute_id
69
+ * @property {string} name
70
+ * @property {number} [position] — the order axes are presented in
71
+ * @property {string[]} options — the values this product comes in
72
+ */
73
+
74
+ /**
75
+ * A descriptive property (the admin's *Modifiers*) — never a selector.
76
+ * Keys are free text: look rows up with `findSpec`, never by `label`.
77
+ *
78
+ * @typedef {object} ProductMeta
79
+ * @property {string} key
80
+ * @property {string} value
81
+ */
82
+
83
+ /**
84
+ * A published catalog product, as returned by `list-products` (one row) and
85
+ * `get-product` (`product`). It is the `commerce.Product` record minus the
86
+ * paywalled download fields, plus the resolved `ribbons`.
87
+ *
88
+ * @typedef {object} StorefrontProduct
89
+ * @property {string} id
90
+ * @property {string} name
91
+ * @property {string} slug
92
+ * @property {"publish"} status — only published products are ever returned
93
+ * @property {boolean} [featured] — the merchant's own flag, for curated rails
94
+ * @property {string} [description] — HTML; render as rich text
95
+ * @property {string} [short_description]
96
+ * @property {string} [sku]
97
+ * @property {number|null} [price] — with variants this is a **from** price; render via `productPrice`
98
+ * @property {number|null} [regular_price]
99
+ * @property {number|null} [sale_price]
100
+ * @property {boolean} [on_sale]
101
+ * @property {string} [date_on_sale_from]
102
+ * @property {string} [date_on_sale_to]
103
+ * @property {ProductImage[]} [images] — **objects**, first is primary, may be empty
104
+ * @property {ProductRibbon[]} [ribbons] — resolved `{id, name}`; **absent** when the page carries none
105
+ * @property {string[]} [ribbon_ids]
106
+ * @property {string[]} [category_ids] — rows carry ids only; `get-product` resolves the records
107
+ * @property {ProductAttributeAxis[]} [attributes] — non-empty ⇒ sells variants
108
+ * @property {Array<{attribute_id: string, name: string, option: string}>} [default_attributes]
109
+ * @property {ProductMeta[]} [meta_data] — descriptive rows; use `productSpecs`
110
+ * @property {"instock"|"outofstock"|"onbackorder"} [stock_status]
111
+ * @property {boolean} [manage_stock]
112
+ * @property {number|null} [stock_quantity] — null when stock isn't tracked
113
+ * @property {"no"|"notify"|"yes"} [backorders]
114
+ * @property {boolean} [sold_individually] — caps quantity at 1
115
+ * @property {boolean} [virtual]
116
+ * @property {boolean} [downloadable] — the files themselves are never public
117
+ * @property {number} [average_rating]
118
+ * @property {number} [rating_count]
119
+ * @property {number} [total_sales]
120
+ * @property {number|null} [weight]
121
+ * @property {{length: number, width: number, height: number}} [dimensions]
122
+ * @property {string[]} [upsell_ids]
123
+ * @property {string[]} [cross_sell_ids]
124
+ * @property {string} [created_date]
125
+ * @property {string} [updated_date]
126
+ */
127
+
128
+ /**
129
+ * One stocked combination of a product's axes. `get-product` only, publishable
130
+ * only — a product with attributes is sellable **only** through a variation id.
131
+ * Bind the UI to `resolveSelection`'s view, not to this list.
132
+ *
133
+ * @typedef {object} StorefrontVariation
134
+ * @property {string} id
135
+ * @property {string} product_id
136
+ * @property {Array<{attribute_id: string, name: string, option: string}>} attributes
137
+ * @property {"publish"|"private"|"draft"} [status]
138
+ * @property {string} [sku]
139
+ * @property {number|null} [price]
140
+ * @property {number|null} [regular_price]
141
+ * @property {number|null} [sale_price]
142
+ * @property {boolean} [on_sale]
143
+ * @property {ProductImage|string|null} [image] — variation-owned, usually **not** in `product.images`
144
+ * @property {"instock"|"outofstock"|"onbackorder"} [stock_status]
145
+ * @property {"yes"|"no"|"parent"} [manage_stock]
146
+ * @property {number|null} [stock_quantity]
147
+ * @property {"no"|"notify"|"yes"} [backorders]
148
+ * @property {number|null} [weight]
149
+ * @property {{length: number, width: number, height: number}} [dimensions]
150
+ * @property {string} [description]
151
+ * @property {boolean} [virtual]
152
+ * @property {boolean} [downloadable]
153
+ */
154
+
155
+ /**
156
+ * An upsell / cross-sell tile. Deliberately thin — and note `image` is a plain
157
+ * URL **string** here, not the `{src, name, alt}` object `images[]` carries.
158
+ *
159
+ * @typedef {object} ProductSummary
160
+ * @property {string} id
161
+ * @property {string} name
162
+ * @property {string} slug
163
+ * @property {number|null} price
164
+ * @property {boolean} on_sale
165
+ * @property {string} image — URL, `""` when the product has no image
166
+ * @property {"instock"|"outofstock"|"onbackorder"} stock_status
167
+ */
168
+
169
+ /**
170
+ * One approved review. Reviewer emails are never public.
171
+ *
172
+ * @typedef {object} ProductReview
173
+ * @property {string} id
174
+ * @property {string} reviewer — display name
175
+ * @property {string} review
176
+ * @property {number} rating — 0–5
177
+ * @property {boolean} verified — the reviewer bought it
178
+ * @property {string} created_date
179
+ */
180
+
181
+ /**
182
+ * The paged review block that rides along with `get-product`.
183
+ *
184
+ * @typedef {object} ProductReviews
185
+ * @property {ProductReview[]} items
186
+ * @property {number} page
187
+ * @property {number} per_page
188
+ * @property {boolean} has_next
189
+ * @property {number} average_rating
190
+ * @property {number} rating_count
191
+ */
192
+
193
+ export {};