@base44/app-plugin-commerce 0.2.7 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/README.md +4 -4
  2. package/package.json +2 -2
  3. package/scripts/install.js +15 -0
  4. package/skills/commerce/SKILL.md +26 -12
  5. package/skills/commerce/docs/api-admin.md +1 -1
  6. package/skills/commerce/docs/api-storefront.md +12 -12
  7. package/skills/commerce/install/01-install.md +5 -2
  8. package/skills/commerce/install/02-storefront.md +167 -275
  9. package/skills/commerce/install/03-data.md +1 -1
  10. package/skills/commerce/references/catalog-rendering.md +37 -43
  11. package/skills/commerce/references/reviews.md +21 -14
  12. package/skills/commerce/references/store-settings.md +1 -1
  13. package/skills/commerce/references/storefront-verification.md +21 -15
  14. package/src/commerce/storefront/StorefrontProvider.jsx +65 -128
  15. package/src/commerce/storefront/cartUI.jsx +11 -30
  16. package/src/commerce/storefront/index.js +61 -98
  17. package/src/commerce/storefront/pickers.jsx +50 -64
  18. package/src/commerce/storefront/useCartLine.js +23 -130
  19. package/src/commerce/storefront/useCheckout.jsx +50 -43
  20. package/src/commerce/storefront/useOrderReturn.js +17 -7
  21. package/src/commerce/storefront/useProduct.js +41 -97
  22. package/src/commerce/storefront/useProductList.js +14 -22
  23. package/src/commerce/utils/address-spec.js +1 -1
  24. package/src/commerce/utils/images.js +1 -1
  25. package/src/commerce/utils/index.js +9 -9
  26. package/src/commerce/utils/price.js +2 -1
  27. package/src/commerce/utils/specs.js +41 -91
  28. package/src/commerce/utils/totals.js +7 -4
  29. package/src/commerce/storefront/useAddressForm.js +0 -166
  30. package/src/commerce/storefront/usePlaceOrder.js +0 -63
  31. package/src/commerce/storefront/useProductGallery.js +0 -78
  32. package/src/commerce/storefront/useProductPrice.js +0 -58
  33. package/src/commerce/storefront/useProductReviews.js +0 -242
  34. package/src/commerce/storefront/useStorefrontSeo.js +0 -204
  35. package/src/commerce/storefront/useTotalsLines.js +0 -109
  36. package/src/commerce/storefront/useUpsell.js +0 -90
@@ -109,7 +109,7 @@ Continent codes in full, state regions, tax binding, VAT-on-shipping, day-2 edit
109
109
 
110
110
  ## Images
111
111
 
112
- **The URL you seed is the URL the store serves.** Resolve each to its final, permanent URL before seeding: the app's image generation, or `base44.integrations.Core.UploadFile({ file })` → public URL. A temporary/**signed** URL (expiry params in the query string are the tell) must be re-hosted via `UploadFile` first — the failure is silent at seed time and visible only in the store. Seeding imageless and patching later is allowed but is open debt; close it before handover.
112
+ **The URL you seed is the URL the store serves.** Resolve each to its final, permanent URL before seeding: the app's image generation, or `base44.integrations.Core.UploadFile({ file })` → public URL. A temporary/**signed** URL (expiry params in the query string are the tell) must be re-hosted via `UploadFile` first — the failure is silent at seed time and visible only in the store. Cheap insurance before seeding: fetch one or two of the URLs and check they answer 200 with an image content type. Seeding imageless and patching later is allowed but is open debt; close it before handover.
113
113
 
114
114
  ## Payments — the decision
115
115
 
@@ -5,7 +5,7 @@ skip_when: "The listing and product page render correctly from useProductList /
5
5
  forget_when: "Cards and the product page render the fields you intended, variant selection resolves to a variation, and add-to-cart succeeds."
6
6
  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
- - "A variant parent's `price` is a FROM price — render it through productPrice/useProductPrice, never as the price."
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
10
  ---
11
11
 
@@ -13,16 +13,16 @@ carry_forward:
13
13
 
14
14
  Both storefront surfaces render the same catalog record from a different call:
15
15
 
16
- | View | Call | Hook | Returns |
16
+ | View | `commerce/storefront-catalog` action | Hook | Returns |
17
17
  |---|---|---|---|
18
- | Listing / grid / search / ribbon & category pages | `commerce/storefront-catalog` `list-products` | `useProductList` | `{ products: [row…], page, per_page, has_next }` |
19
- | Product page | `commerce/storefront-catalog` `get-product` | `useProduct` | `{ product, variations, categories, ribbons, reviews, upsells, cross_sells }` |
18
+ | Listing / grid / search / ribbon & category pages | `list-products` | `useProductList` | `{ products: [row…], page, per_page, has_next }` |
19
+ | Product page | `get-product` | `useProduct` | `{ product, variations, categories, ribbons, reviews, upsells, cross_sells }` |
20
20
 
21
- Request/response shapes: [`../docs/api-storefront.md`](../docs/api-storefront.md). You choose what belongs in each view — but you can only render what the call returns, and §1 is that boundary.
21
+ Shapes: [`../docs/api-storefront.md`](../docs/api-storefront.md). What belongs in each view is yours — you can only render what the call returns, and §1 is that boundary.
22
22
 
23
23
  ## 1. Field availability
24
24
 
25
- A listing **row** is the product record itself (minus paywalled fields) plus resolved `ribbons`. `get-product` adds everything that needs a second read.
25
+ A listing **row** is the product record (minus paywalled fields) plus resolved `ribbons`; `get-product` adds everything that needs a second read.
26
26
 
27
27
  | Data | `list-products` row | `get-product` | Notes |
28
28
  |---|---|---|---|
@@ -30,85 +30,79 @@ A listing **row** is the product record itself (minus paywalled fields) plus res
30
30
  | `price`, `regular_price`, `sale_price`, `on_sale` | ✅ | ✅ | With variants the parent `price` is a *from* price — §2 |
31
31
  | `images[]`, `featured`, `short_description`, `description` | ✅ | ✅ | Cards normally use `images[0]` + `short_description`; every entry is an **object** — §2 |
32
32
  | `sku`, `stock_status`, `stock_quantity`, `manage_stock`, `backorders` | ✅ | ✅ | |
33
- | `average_rating`, `rating_count`, `total_sales` | ✅ | ✅ | Enough for stars on a card; the reviews themselves are not in a row |
34
- | `downloadable`, `virtual`, `weight`, `dimensions`, `attributes[]`, `meta_data` | ✅ | ✅ | `attributes[]` is the variant axes; `meta_data` is the descriptive **modifiers** (`productSpecs(product)` turns them into typed spec rows) |
33
+ | `average_rating`, `rating_count`, `total_sales` | ✅ | ✅ | Enough for stars on a card; the reviews themselves aren't |
34
+ | `downloadable`, `virtual`, `weight`, `dimensions`, `attributes[]`, `meta_data` | ✅ | ✅ | `attributes[]` is the variant axes; `meta_data` the descriptive **modifiers** (`productSpecs(product)` spec rows) |
35
35
  | **`ribbons`** (resolved), `ribbon_ids`, `category_ids` | ✅ | ✅ | Rows carry `{id, name}` ribbons; `get-product` returns the full records |
36
36
  | **`categories`** (resolved) | ❌ *ids only* | ✅ | §6 to add them to rows |
37
37
  | **`variations[]`** (per-variant price/stock/image/attributes) | ❌ | ✅ | Why a product with variants can't be fully priced from a row |
38
38
  | **`reviews`** (paged items + aggregates) | ❌ | ✅ | Rows still carry the aggregate numbers |
39
39
  | **`upsells`, `cross_sells`** (summaries) | ❌ | ✅ | `{id, name, slug, price, on_sale, image}` |
40
- | `downloads[]`, `download_limit`, `download_expiry` | ❌ | ❌ | **Never** public — reached only through `commerce/storefront-account` `get-download` |
40
+ | `downloads[]`, `download_limit`, `download_expiry` | ❌ | ❌ | **Never** public — only via `commerce/storefront-account` `get-download` |
41
41
 
42
- ## 2. Two shapes, and the three rules the code now owns
42
+ ## 2. Two shapes, and three rules the exports own
43
43
 
44
44
  There is **no product `type` field**. `attributes[]` tells the shapes apart:
45
45
 
46
- | Shape | Test | Card | Product page |
47
- |---|---|---|---|
48
- | **Single item** | `attributes` is empty | price, and Add to cart straight from the card if you want one | one Add to cart — `resolveSelection` returns no axes, `complete: true`, a ready `addToCart` |
49
- | **Sells variants** | `attributes` is non-empty | price **range or "From €19"**, never selectors — link through | one control per axis §5 |
46
+ | Shape | Test | Renders as |
47
+ |---|---|---|
48
+ | **Single item** | `attributes` empty | one price; one Add to cart, from the card too if you want (`resolveSelection` gives no axes, `complete: true`, a ready `addToCart`) |
49
+ | **Sells variants** | `attributes` non-empty | card: price **range or "From €19"**, never selectors — link through. Page: one control per axis (§5) |
50
50
 
51
- A product with attributes is **only** sellable through a variant: `add-item` without a `variation_id` is `400 variation_required`, with no fall-back to the parent. So one with attributes but no variations is unsellable by design, not by accident (§5 covers rendering that state).
51
+ A product with attributes is **only** sellable through a variant `add-item` with no `variation_id` is `400 variation_required`, no parent fall-back so attributes with no variations is unsellable by design, not by accident (§5 renders it).
52
52
 
53
- Three rules are enforced by exports use them and they can't drift between views:
53
+ Three rules the exports enforce, so two views can't drift apart:
54
54
 
55
- - **From-price.** A parent's `regular_price`/`price`/`on_sale` are rolled up from the cheapest publishable variant on every save (by `admin-products` and the seeder) — real, sortable, filterable, but the **lowest** price, not *the* price. `productPrice(rowOrView, {formatMoney})` / `useProductPrice(rowOrView)` take a listing row **or** a `resolveSelection` view and return `{label, compareAtLabel, onSale, isFrom, isRange, min, max}`: "From €19.99" on a card, a range unresolved, the exact price resolved.
56
- - **Images are objects, and may be absent.** Every stored image is `{src, name, alt}`, never a URL string — passing the object itself to `<img src>` fails the load — and `images` can legitimately be empty. `productImages(product)` / `normalizeImage(entry)` return clean entries (non-empty `src`, defaulted `alt`); an empty array means *render your placeholder* (`useProductGallery` builds on them, `hasImages`).
57
- - **Modifiers are not attributes.** `meta_data` (Material, Care, GTIN) is descriptive — never a selector, never a ribbon; how it renders is a design decision (§3), a control is what it can never become.
55
+ - **From-price.** A parent's `regular_price`/`price`/`on_sale` are rolled up from the cheapest publishable variant on every save (`admin-products`, the seeder) — real, sortable, filterable, but the **lowest** price, not *the* price. `productPrice(rowOrView, { formatMoney })` (`formatMoney` from `useFormatMoney()`) takes a listing row **or** a `resolveSelection` view `{label, compareAtLabel, onSale, isFrom, isRange, min, max}`: "From €19.99" on a card, a range unresolved, the exact price once resolved.
56
+ - **Images are objects, and may be absent.** Every stored image is `{src, name, alt}`, never a URL string — passing the object to an `<img src>` fails the load, on every product at once — and `images` can legitimately be empty. `productImages(product)` / `normalizeImage(entry)` return clean, de-duplicated entries (non-empty `src`, defaulted `alt`); `[]` means *render your placeholder*. Same trap one level in: `view.display.image` is that normalized object or **`null`** — render `display.image?.src`/`?.alt`, and `null` (no variation *or* parent image) is the one placeholder case.
57
+ - **Modifiers are not attributes.** `meta_data` (Material, Care, GTIN) is descriptive — never a selector, never a ribbon; how it renders is a design decision (§3), a control is what it can never be.
58
58
 
59
59
  ## 3. What each view *can* render
60
60
 
61
- Both lists below are field inventories what the data supports — **not a layout and not an order**; a store rendering exactly these fields in exactly this sequence is the generic storefront every generated catalog produces.
61
+ Both lists are inventories of what the data supports — **not a layout and not an order**: rendering exactly these fields in this sequence is the generic storefront.
62
62
 
63
- **Card:** image, name, `price.label`, sale badge from `on_sale`, stars from `average_rating`/`rating_count`, out-of-stock state from `stock_status`, one or two ribbons — plus anything else on the row (`weight`, `dimensions`, `meta_data` via `productSpecs`). Link the whole card to the product page; the layout is yours.
63
+ **Card:** image, name, `price.label`, a sale badge from `on_sale`, stars from `average_rating`/`rating_count`, out-of-stock from `stock_status`, one or two ribbons — plus anything else on the row (`weight`, `dimensions`, `productSpecs`). Link the whole card through; the layout is yours.
64
64
 
65
- **Product page:** gallery, name, price, variant selectors, stock, `short_description` then `description`, SKU, categories breadcrumb, ribbons, reviews, then upsells/cross-sells. Everything except the markup has a hook or helper: `useProductGallery`, `variantAxes(view, pick)`, `useAddToCart`, `productSpecs(product)`, `useProductReviews`, `p.upsells`/`p.crossSells`.
65
+ **Product page:** gallery, name, price, variant selectors, stock, `short_description` then `description`, SKU, categories breadcrumb, ribbons, reviews, upsells/cross-sells. All but the markup is handed to you: `productImages(product)` + your own active index, `variantAxes(view, pick)`, `useAddToCart`, `productSpecs(product)`, `p.reviews` (+ `submitReview` off `useStorefront()`), and `p.upsells`/`p.crossSells` — added with `useCart().addItem`, matching "already in cart" by product id.
66
66
 
67
- **Attributes and modifiers are individually designable.** `productSpecs` rows carry `key`/`label`/`titleLabel` (display-cased) plus an inferred `type` (`numeric` with `number`/`unit` split out, `duration`, `location`, `list` with `items`, `text`); `useProductSpecs(product, { pick })` is the React wrapper, its get/pick case/underscore-insensitive. Branch on `type`/`key` instead of mapping everything into one uniform chip row per axis and one grey label/value table — the design guidance is [`../install/02-storefront.md`](../install/02-storefront.md)'s; §5's rules govern selector *behaviour*, never its form.
67
+ **Attributes and modifiers are individually designable.** `productSpecs(product)` rows are `{key, label, titleLabel, value}` and nothing more. ⚑ **Look a row up with `findSpec(rows, "care")`**, which ignores case, spaces, `_` and `-`: meta keys are free text typed per product (`care`, `Care`, `Care Instructions`), so an equality test on `label` — or on one spelling of `key` silently never fires and the feature renders its fallback forever; `titleLabel` is the display-cased form, for a heading. What a row *looks* like is a per-catalog design decision — not one uniform chip row per axis and one grey label/value table ([`../install/02-storefront.md`](../install/02-storefront.md)); §5's rules govern selector *behaviour*, never its form.
68
68
 
69
69
  ## 4. Ribbons — in **both** views
70
70
 
71
71
  Ribbons are flat, cross-cutting labels ("Best Seller", "New", "Gift"); categories are the hierarchical spine. Generated storefronts routinely omit ribbons entirely. Don't.
72
72
 
73
- - **Listing rows carry resolved `ribbons`**, so a card renders them with no extra call; the product page renders the top-level `ribbons` near the metadata, lighter than the breadcrumb. One or two per card is useful, more is noise, and an empty array means render nothing — never a dangling "Ribbons:" label.
74
- - **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 keep the page shareable across reloads.
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.
74
+ - **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
75
  - **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
76
  - Ribbons are not breadcrumbs and never variant options — a size or colour is an `attribute`.
77
77
 
78
78
  ## 5. Variant selection
79
79
 
80
- The one interaction agents reliably get wrong. `variantAxes(view, pick)` hands you the render-ready model that encodes the rule: **one control per axis, never a list of variations** (`Red / S`, `Red / M`, … is `n × m` noise that hides the product's structure), and an option that isn't buyable renders **disabled, not removed** (`o.disabled`; `o.outOfStock` stays visible, just marked). Map it to any control — buttons, swatches, dropdowns — and call `o.pick()` on select.
80
+ The one interaction agents reliably get wrong. `variantAxes(view, pick)` is the render-ready model, and it encodes the rule: **one control per axis, never a list of variations** (`Red / S`, `Red / M`, … is `n × m` noise hiding the product's structure); an unbuyable option renders **disabled, not removed** (`o.disabled`; `o.outOfStock` stays visible, just marked). Map it to any control — buttons, swatches, dropdowns — and call `o.pick()` on select.
81
81
 
82
- Underneath, `useProduct` composes the framework-free helpers in `src/commerce/utils/variants.js` `defaultSelection` → `selectOption` on a click → `resolveSelection` for the view. Every `product.attributes[]` entry is an **axis** in `position` order; every `variations[]` record is one combination. Bind the UI to `view`, not to `product.*`, or a selection changes nothing:
82
+ Underneath, `useProduct` composes the framework-free helpers in `src/commerce/utils/variants.js`: `defaultSelection` → `selectOption` per click → `resolveSelection`. That middle step keeps dead ends out — it holds the just-picked option and clears **only** conflicting axes (Red/L → click Blue → `{Color: Blue}`, size re-opens), never parking the customer on a combination that resolves to nothing. Every `product.attributes[]` entry is an **axis** in `position` order, every `variations[]` record one combination. Bind the UI to `view`, not `product.*`, or a selection changes nothing:
83
83
 
84
84
  | `view` field | Binds to |
85
85
  |---|---|
86
86
  | `axes` | the controls: `[{ key, name, attribute_id, options[] }]`, dead options already dropped |
87
87
  | `availability[axisKey][option]` | `"available"` / `"out_of_stock"` / `"unavailable"` (constants `OPTION_*`) |
88
- | `display` | image, price, `regular_price`, `on_sale`, SKU, stock, weight/dimensions, description — variation-first with parent fallback; `display.image` is normalized or `null` |
89
- | `priceRange` | `{min, max, on_sale, count}` while the selection is incomplete — render via `useProductPrice(view)` |
88
+ | `display` | image, price, `regular_price`, `on_sale`, SKU, stock, weight/dimensions, description — variation-first with parent fallback; `display.image` per §2 |
89
+ | `priceRange` | `{min, max, on_sale, count}` while the selection is incomplete — render via `productPrice(view, { formatMoney })` |
90
90
  | `complete` / `missingAxes` | the button label: `Select a ${view.missingAxes[0]?.name}` |
91
91
  | `purchasable` / `addToCart` | gate **Add to cart** on `purchasable`, send `addToCart` (`{product_id, variation_id}`, or `null`) |
92
- | `variation` / `candidates` / `isVariable` / `selection` | the record itself, the variations still reachable, and the state the selector renders from |
92
+ | `variation` / `candidates` / `isVariable` / `selection` | the record itself, the variations still reachable, the state the selector renders from |
93
93
 
94
94
  Three decisions the helpers can't make for you:
95
95
 
96
- - **Unavailable vs out of stock.** `"unavailable"` (no such combination) → **disable, don't hide**; options that vanish and reappear as the customer clicks are disorienting. `"out_of_stock"` → visible and labelled. `onbackorder` counts as available and purchasable — label it ("Ships in 2–3 weeks"), don't disable it.
97
- - **Incomplete selection.** Show the range from `useProductPrice(view)` — never `€0`, never the bare parent price — and keep Add to cart disabled with a hint at what's missing.
98
- - **Linkability.** `useProduct` mirrors the selection into the URL (`?color=Ivory`) and hydrates from it, so a variant is shareable and survives a reload; `selectionToParams`/`selectionFromParams` do a custom round-trip, and `selectionForVariation` hydrates controls from a cart line or an `?variation=` link.
96
+ - **Unavailable vs out of stock.** `"unavailable"` (no such combination) → **disable, don't hide**; options that vanish and reappear as the customer clicks are disorienting. `"out_of_stock"` → visible, labelled. `onbackorder` is available and purchasable — label it ("Ships in 2–3 weeks"), don't disable it.
97
+ - **Incomplete selection.** Show the range from `productPrice(view, { formatMoney })` — never `€0`, never the bare parent price — and keep Add to cart disabled with a hint at what's missing.
98
+ - **Linkability.** `useProduct` mirrors the selection into the URL (`?color=Ivory`) and hydrates from it, so a variant is shareable and survives a reload; `selectionToParams`/`selectionFromParams` are that round-trip by hand, `selectionForVariation` hydrates controls from a cart line or an `?variation=` link.
99
99
 
100
- **Attributes but no usable variations** — what attaching an attribute and stopping leaves behind deliberately gets no parent fallback, because `add-item` would reject it: empty `axes[].options`, `purchasable: false`, `addToCart: null`. Render it as unavailable rather than painting empty selector groups, and guard the label (with no attributes at all `missingAxes[0]?.name` is `undefined` → "Select a undefined").
100
+ **Attributes but no usable variations** — attaching an attribute and stopping — gets no parent fallback by design, since `add-item` would reject it: empty `axes[].options`, `purchasable: false`, `addToCart: null`. Render it as unavailable rather than painting empty selector groups, and guard the label: with no attributes at all `missingAxes[0]?.name` is `undefined` → "Select a undefined".
101
101
 
102
- **Add to cart** goes through `useAddToCart(product)`, whose `addToCart()` never throws and maps the codes: `variation_required` (a page bug empty `variation_id` on a product with attributes), `out_of_stock` / `insufficient_stock`, and `variation_not_found` (the catalog changed under the page → the hook reloads the product itself). `product.sold_individually` caps quantity at 1, already reflected in `useProduct().maxQuantity`. Two behaviors that matter only if you hand-roll the view model: non-publishable variations never leak an option into the UI, and an empty `option` on a variation axis means **"any"**.
102
+ **Add to cart** goes through `useAddToCart(product)`: `addToCart()` never throws and maps the codes `variation_required` (a page bug: empty `variation_id` on a product with attributes), `out_of_stock`/`insufficient_stock`, `variation_not_found` (the catalog changed under the page → the hook reloads it). `product.sold_individually` caps quantity at 1, reflected in `useProduct().maxQuantity`. Two behaviors only a hand-rolled view model gets wrong: non-publishable variations never leak an option into the UI, and an empty `option` on an axis means **"any"**.
103
103
 
104
104
  ## 6. Adding a `get-product`-only field to the listing
105
105
 
106
- If the store needs something a row doesn't carry resolved categories are the common one — resolve it **in the listing call**, never with `get-product` per card (N requests for one grid). `list-products` resolves its page slice through a helper in `base44/functions/commerce/storefront-catalog/entry.ts`:
107
-
108
- ```ts
109
- const pageItems = await withRibbons(sr, products.slice(start, start + perPage).map(publicProduct));
110
- ```
111
-
112
- `withRibbons` reads the taxonomy **once per request** and maps ids onto the rows — not once per row. Extend that pattern: read the entity for the whole page, build an id→record `Map`, attach the short shape each card needs. Keep it to fields the UI renders; a listing payload is served on every browse.
106
+ Something a row doesn't carry (resolved categories, typically) gets resolved **in the listing call**, never `get-product` per card (N requests for one grid). `list-products` resolves its page slice through one helper in `base44/functions/commerce/storefront-catalog/entry.ts` — `await withRibbons(sr, pageRows.map(publicProduct))` — which reads the taxonomy **once per request**, not once per row, and maps ids onto the rows. Extend that pattern: read the entity for the whole page, build an id→record `Map`, attach the short shape each card needs. Keep it to fields the UI renders — a listing payload is served on every browse.
113
107
 
114
- Don't add `variations` to every row — the heaviest read in the catalog, and a grid doesn't need it; if cards must show true ranges, precompute `price_min`/`price_max`. And don't relax entity RLS to read the catalog from the client — it is admin-only by design ([`guest-access-security.md`](./guest-access-security.md)); widen the function's response instead.
108
+ Don't add `variations` to every row — the heaviest read in the catalog, and a grid doesn't need it; if cards must show true ranges, precompute `price_min`/`price_max`. And don't relax entity RLS to read the catalog client-side — it is admin-only by design ([`guest-access-security.md`](./guest-access-security.md)); widen the function's response instead.
@@ -1,41 +1,48 @@
1
1
  ---
2
2
  stage: reference
3
- read_when: "You need review behaviour beyond useProductReviewsmoderation, auto-approval, a customer's own reviews, or a policy the three built-in ones don't cover."
4
- skip_when: "useProductReviews is on the product page that is list + submit + policy + the right confirmation copy already."
3
+ read_when: "You are building the review surfacethe list, the form, a policy (login-gated, verified buyers) or need moderation, auto-approval, or a customer's own reviews."
4
+ skip_when: "The product page already renders `p.reviews` and submits through `submitReview`, with the confirmation copy following the response's `status`."
5
5
  forget_when: "A review submits, appears (or is held) as the store's auto-approve setting dictates, and the aggregate rating renders."
6
6
  carry_forward:
7
- - "Reviews are part of the happy path: one hook. The confirmation copy must come from the submit response, never hardcoded."
7
+ - "Reviews are part of the happy path: the list arrives with the product, submitting is one client call. The confirmation copy must come from the submit response's `status`, never hardcoded."
8
8
  ---
9
9
 
10
10
  # Reviews
11
11
 
12
- Reviews are **part of the happy path**, not an extra: the backend always shipped complete, and the whole UI contract is one hook. Unless the store explicitly doesn't want them, put them on the product page: `useProductReviews(product, { policy, user, requireRating, perPage })` the list with paging (`items`, `hasNext`/`loadMore`), the aggregates (`averageRating`, `ratingCount`), the whole form contract (`form`/`setField`/`fieldErrors`/`valid`/`submit`/`message`) and the policy gate (`canReview`, `reviewBlockedReason`, `requiresEmail`); exact shapes in its JSDoc. The markup around it — stars, rows, the form is yours, like all storefront UI.
12
+ Reviews are **part of the happy path**, not an extra: the backend always shipped complete, and the storefront's half is two calls. Unless the store explicitly doesn't want them, put them on the product page `useProduct(slug)` already hands you `p.reviews` (the first page of approved reviews plus the aggregates, fetched with the product), and submitting is one method. There is no reviews hook and no review markup: the stars, the rows, the form and every word around them are yours, like all storefront UI.
13
+
14
+ ## The two client calls
15
+
16
+ Both live on the storefront client in `@/commerce/utils` — in React, `useStorefront()` is that client:
17
+
18
+ - **`getProductReviews(slugOrRef, { page, per_page })`** → `{ items, page, per_page, has_next, average_rating, rating_count }`. The same reviews `get-product` returns — page or refresh the list without re-fetching the page; `useProduct(slug, { reviewsPerPage })` sizes the first one.
19
+ - **`submitReview({ product_id, review, rating?, reviewer?, email? })`** → `{ review_id, status, verified }`. `review` is the body text and is required; `rating` is optional, **0–5**; `reviewer` is the display name. It **rejects** with `email_required` | `review_incomplete` | `invalid_rating` | `not_found` — catch it, read `storefrontErrorCode(e)`, and land each code on its own field, so a failed submit says what to fix instead of resolving into nothing. After an approved submission, refresh the list yourself so the review actually appears.
13
20
 
14
21
  ## What ships
15
22
 
16
23
  - `get-product` returns **paginated `reviews`** plus `average_rating`, `rating_count` and a `verified` flag per review; `list-products` rows carry the aggregates, so **stars on cards cost no extra call**;
17
- - `submit-review` accepts `{ product_id, email, reviewer?, review, rating? }` from **anyone — no login**. A signed-in caller's session email always wins (the payload cannot impersonate); a guest supplies `email`; `verified` comes from that email's order history. The three error codes (`400 email_required | review_incomplete | invalid_rating`) land on their fields via `fieldErrors` instead of rejecting into nothing;
24
+ - `submit-review` accepts `{ product_id, email, reviewer?, review, rating? }` from **anyone — no login**. A signed-in caller's session email always wins (the payload cannot impersonate); a guest supplies `email`; `verified` comes from that email's order history;
18
25
  - `storefront-account` `my-reviews` lists a signed-in customer's own — what a "My reviews" account tab renders from;
19
26
  - moderation is in the admin (Products → Reviews); `commerce/admin-reviews` recalculates the product's rating on every status change.
20
27
 
21
- ## The `policy` prop
28
+ ## The policy is the store's
22
29
 
23
- `policy` is the store's review rule as one option, replacing the patterns a storefront used to implement by hand. Pass your app's current user alongside it (`useProductReviews(product, { policy: "login", user })`) — the stricter policies need it:
30
+ Which visitors may submit is a gate **you** render, in your own words. The three patterns worth knowing:
24
31
 
25
- | `policy` | Who may submit | Blocked as |
32
+ | Policy | Who may submit | How you implement it |
26
33
  |---|---|---|
27
- | `"open"` (default) | anyone with a valid email the server's own rule | |
28
- | `"login"` | a signed-in visitor only | `reviewBlockedReason: "login_required"` |
29
- | `"verified_buyers"` | someone whose own orders include a `processing`/`completed` order for this product (checked via `my-orders`) | `"not_a_buyer"` |
34
+ | **Open** (the server's own rule) | anyone with a valid email | render the form for everyone; the email field is required for guests |
35
+ | **Login-gated** | a signed-in visitor only | render the form only when your app has a user; otherwise your "sign in to review" line |
36
+ | **Verified buyers** | someone whose own orders include a `processing`/`completed` order for this product | check `storefront-account` `my-orders` for the product, gate on the result |
30
37
 
31
- Policies are **UI-side by design**: the server accepts any valid email, so a stricter rule is exactly this gate. `requireRating: true` makes the stars mandatory; `requiresEmail` is `false` for a signed-in visitor, so the field is hidden (the session's email wins server-side anyway). A policy that must hold against handcrafted API calls too belongs in a backend function of your own wrapping `submit-review`. An honest middle ground for most stores: accept everything and render the `verified` flag as a "Verified purchase" badge.
38
+ Policies are **UI-side by design**: the server accepts any valid email, so a stricter rule is exactly this gate and a policy that must hold against handcrafted API calls too belongs in a backend function of your own wrapping `submit-review`. Either way: hide the email field for a signed-in visitor (the session's email wins server-side), and make the stars mandatory by validating before you call. An honest middle ground for most stores: accept everything and render the `verified` flag as a "Verified purchase" badge.
32
39
 
33
40
  ## Auto-approval and moderation
34
41
 
35
42
  `products.auto_approve_reviews` ([`store-settings.md`](./store-settings.md), toggle on the admin's Reviews screen) is the **only** server-side switch: off (the default) holds every review as `hold` for moderation; on publishes immediately.
36
43
 
37
- So **the confirmation copy must come from the response**: `submit()` resolves `{ ok, status, verified }` with `status` `"approved"` or `"hold"`, and `message` follows it. A hardcoded "awaiting approval" lies to every store with auto-approval on — and after an approved submission the hook refreshes the list so the review actually appears.
44
+ So **the confirmation copy must come from the response**: `submitReview` resolves `{ review_id, status, verified }` with `status` `"approved"` or `"hold"`, and your message follows it. A hardcoded "awaiting approval" lies to every store with auto-approval on — and only the approved case is worth refreshing the list for.
38
45
 
39
- Disabling reviews store-wide is simply building no review UI; there is no server switch to keep in sync.
46
+ Disabling reviews store-wide is simply building no review UI; there is no server switch to keep in sync (and then no star ratings on cards either — an average of nothing is `0`).
40
47
 
41
48
  Shapes and error details: [`../docs/api-storefront.md`](../docs/api-storefront.md#submit-review). Where ratings belong per view: [`catalog-rendering.md`](./catalog-rendering.md).
@@ -15,7 +15,7 @@ Six records, one per group: `general`, `products`, `inventory`, `tax`, `shipping
15
15
  |---|---|---|
16
16
  | **`general`** | | |
17
17
  | `order_received_path` | `"/order-received"` | The route a paying customer returns to; `shared/commerce/payments.ts` builds the provider's success/cancel URLs from it. A value that doesn't match your route is a 404 after payment and an order never confirmed. |
18
- | `currency` | `"USD"` | ISO code, **a value not a format**. Stamped on orders by `storefront-checkout`; published on `get-store-info` for `useMoney`/`useFormatMoney`. |
18
+ | `currency` | `"USD"` | ISO code, **a value not a format**. Stamped on orders by `storefront-checkout`; published on `get-store-info` for `useFormatMoney`. |
19
19
  | `weight_unit` / `dimension_unit` | `"kg"` / `"cm"` | Labels only — nothing converts. Admin product form, `get-store-info`. |
20
20
  | **`products`** | | |
21
21
  | `auto_approve_reviews` | `false` | The **only** server-enforced review rule: `submit-review` returns `status: "hold"` or `"approved"`. Confirmation copy comes from that response ([`reviews.md`](./reviews.md)). |
@@ -11,19 +11,24 @@ faster than the cart settles: the hooks are optimistic and debounced, so the
11
11
  DOM is briefly right about the *intent* and wrong about the *state*.
12
12
 
13
13
  - **Wait for the cart, then for each row.** Two waits, neither optional. Before
14
- the first action, wait for the initial load to settle — `status` leaves
15
- `"loading"` exactly once, so the signal is the loaded UI (a row, or the empty
16
- state), never a fixed sleep. Then after every stepper click wait for **that
17
- row**: the click starts a 250ms debounce before the request even leaves, so
18
- reading the quantity or total straight after gives the optimistic number and
19
- stale totals, and two quick clicks send **one** request for the final number.
20
- Wait for the row's busy state to clear (`aria-busy`, re-enabled buttons)
21
- before reading or clicking again.
14
+ the first action, wait for the initial load to settle — `useCart().status`
15
+ leaves `"loading"` exactly once and never returns there for a mutation, so the
16
+ signal is the loaded UI (a row, or the empty state), never a fixed sleep. Then
17
+ after every stepper click wait for **that row**: `useCartLine` starts a 250ms
18
+ debounce before the request even leaves, so reading the quantity or total
19
+ straight after gives the optimistic number and stale totals, and two quick
20
+ clicks send **one** request for the final number. The row's `pending` is the
21
+ only mutation-settled signal there is (`pending === false` with no `error`
22
+ means the row, the totals and any badge have all landed) — wait for the busy
23
+ state the page renders from it (`aria-busy`, re-enabled buttons) before
24
+ reading or clicking again.
22
25
  - **Scope actions to the visible drawer.** With a drawer, the page can hold two
23
- "Remove" buttons for one line — drawer and cart page behind it. A drawer built
24
- on `useCartUI` is unmounted while closed, so its copies can't be hit by
25
- mistake but still query inside the open drawer's container, not the
26
- document. A click that seems to do nothing usually hit a hidden copy.
26
+ "Remove" buttons for one line — drawer and cart page behind it. `useCartUI`
27
+ only hands the page an `open` flag: whether the closed drawer is unmounted
28
+ (`{ui.open && …}`) or merely translated off-screen is the store's choice, so
29
+ assume the duplicates exist and query inside the open drawer's container,
30
+ never the document. A click that seems to do nothing usually hit a hidden
31
+ copy.
27
32
  - **Remove lines one at a time.** Clicking every "Remove" in one pass fails on
28
33
  its own terms: cart calls are serialized, each removal re-renders the list,
29
34
  and buttons collected up front are detached by the time the loop reaches
@@ -42,6 +47,7 @@ DOM is briefly right about the *intent* and wrong about the *state*.
42
47
  so a script that placed an order loses its page context and can land back at
43
48
  `/` — while the order itself was created normally. That is the hard
44
49
  navigation, not a broken redirect. The confirmation is reachable at any time
45
- from a fresh navigation to `/order-received?order_id=…&order_key=…` (the ids
46
- come back in `placeOrder`'s result, and `commerce/admin-orders` `search` has
47
- the order either way).
50
+ from a fresh navigation to `orderReceivedUrl(result)`
51
+ (`/order-received?order_id=…&order_key=…` the ids come back in
52
+ `placeOrder`'s result, and `commerce/admin-orders` `search` has the order
53
+ either way).
@@ -8,8 +8,6 @@ import React, {
8
8
  useState,
9
9
  } from "react";
10
10
  import {
11
- attributesLabel,
12
- cartTotalsLines,
13
11
  createStorefront,
14
12
  storefrontErrorCode,
15
13
  storefrontErrorMessage,
@@ -18,43 +16,24 @@ import {
18
16
  /**
19
17
  * StorefrontProvider — one client, one store-info cache, ONE shared cart.
20
18
  *
21
- * Mount it once, above every storefront page (product list, product page,
22
- * cart, checkout, order-received). It is NOT a <Route>. A store with shared
23
- * chrome nearly all of them — mounts it on a pathless layout route, wrapping
24
- * the layout that renders <Outlet/>, which keeps the nav's cart badge and the
25
- * page on one cart and leaves the admin outside:
19
+ * Mount it **once**, above every storefront page, on a pathless layout route
20
+ * wrapping the layout that renders `<Outlet/>` (`<StorefrontProvider
21
+ * base44={base44}><StoreLayout /></StorefrontProvider>` as that route's
22
+ * `element`); with no shared layout, wrap `<Routes>` instead. Two ways to get
23
+ * this wrong, both with symptoms:
24
+ * - as a child of `<Routes>` React Router throws "is not a `<Route>` component";
25
+ * - a layout that renders the provider *inside* itself leaves the nav outside
26
+ * it, so the cart badge and the cart page read different carts.
26
27
  *
27
- * import { base44 } from "@/api/base44Client";
28
- * <Route element={<StorefrontProvider base44={base44}><StoreLayout /></StorefrontProvider>}>
29
- * <Route path="/" element={<Home />} /> …
30
- * </Route>
28
+ * Pass `base44`, or `store={createStorefront(base44)}` when other modules need
29
+ * the same client.
31
30
  *
32
- * With no shared layout it can wrap <Routes> directly instead:
33
- *
34
- * <StorefrontProvider base44={base44}> <Routes>…</Routes> </StorefrontProvider>
35
- *
36
- * As a child of <Routes> it throws ("is not a <Route> component"), since React
37
- * Router allows only <Route>/<Fragment> children there. Never the other way
38
- * round either: a layout that renders the provider inside itself puts the nav
39
- * outside it, so the badge and the cart page read different carts.
40
- *
41
- * or, if other modules also need the raw client, create it once and share it:
42
- *
43
- * // src/lib/storefront.js
44
- * import { createStorefront } from "@/commerce/utils";
45
- * import { base44 } from "@/api/base44Client";
46
- * export const store = createStorefront(base44);
47
- *
48
- * <StorefrontProvider store={store}> ... </StorefrontProvider>
49
- *
50
- * What lives here (and why it must not be duplicated per page):
51
- * - the `createStorefront` instance — it owns the cart_token lifecycle;
52
- * - store info — payment_gateways, currency, countries/currencies come from
53
- * `get-store-info` ONLY (never off a cart view), cached for the session;
54
- * - the cart view — every mutation from any component funnels through one
55
- * serialized queue, so a slow response can never clobber a newer cart, and
56
- * a header badge, a cart drawer and the checkout all re-render from the
57
- * same state.
31
+ * What lives here, and why it must not be duplicated per page: the client (it
32
+ * owns the cart_token lifecycle); store info, cached for the session —
33
+ * payment gateways, currency and countries come from `get-store-info` ONLY,
34
+ * never off a cart view; and the cart, whose every mutation funnels through one
35
+ * serialized queue, so a slow response cannot clobber a newer cart and the
36
+ * badge, the drawer and the checkout all re-render from one state.
58
37
  */
59
38
 
60
39
  const StorefrontContext = createContext(null);
@@ -167,6 +146,22 @@ export function useStoreInfo() {
167
146
  };
168
147
  }
169
148
 
149
+ /**
150
+ * The store's country list, **always as an array** — `{ countries, options,
151
+ * loading, error }`, `options` as `{value, label}` for a `<select>`.
152
+ *
153
+ * `useStoreInfo().countries` is `null` until store info resolves, so mapping it
154
+ * directly white-screens the checkout on a cold load — the most severe defect
155
+ * observed in a generated storefront. Here the list is empty-then-full, never
156
+ * null, and `loading` says which.
157
+ */
158
+ export function useCountries() {
159
+ const { countries, loading, error } = useStoreInfo();
160
+ const list = Array.isArray(countries) ? countries : [];
161
+ const options = useMemo(() => list.map((c) => ({ value: c.code, label: c.name })), [countries]); // eslint-disable-line react-hooks/exhaustive-deps
162
+ return { countries: list, options, loading, error };
163
+ }
164
+
170
165
  /**
171
166
  * `(amount) => "€19.99"` in the store's currency, per the viewer's locale.
172
167
  * Falls back to a plain number while store info is still loading.
@@ -189,55 +184,49 @@ export function useFormatMoney() {
189
184
  }
190
185
 
191
186
  /**
192
- * The shared cart — state, decorated lines, and every cart mutation. All
193
- * consumers see the same view; every action resolves to the fresh priced cart.
187
+ * The shared cart — state plus every cart mutation. All consumers see the same
188
+ * view; every action resolves to the fresh priced cart it published.
194
189
  *
195
- * const { status, lines, totalsLines, notices, updateItem, removeItem } = useCart();
196
- * if (status === "loading") return <Skeleton />;
197
- * if (status === "empty") return <EmptyBag />;
198
- * lines.map(line => …) // line.attributesLabel, line.image, line.canIncrease
190
+ * const { status, cart, updateItem, removeItem, applyCoupon } = useCart();
191
+ * if (status === "loading") return <YourSkeleton />;
192
+ * if (status === "empty") return <YourEmptyBag />;
193
+ * cart.items.map(item => …) // your rows; useCartLine(item) per row
199
194
  *
200
195
  * **Branch on `status`, not on `isEmpty`.** `isEmpty` is true while the cart is
201
196
  * still loading (there are no items *yet*), so a page that renders its empty
202
197
  * state from it flashes "your bag is empty" on every visit before the cart
203
198
  * arrives. `status` separates the two: `"loading"` → `"empty"` | `"ready"`.
204
199
  *
205
- * - `lines` are `cart.items` decorated with what a renderer needs and would
206
- * otherwise re-derive: `attributesLabel` ("Size: 42 · Color: Ivory" — the raw
207
- * `attributes` is an **array** of `{name, option}`, never a map),
208
- * `image` normalized to `{src, alt}` or null, money pre-formatted
209
- * (`totalLabel`, `subtotalLabel`, `unitPriceLabel` no `useFormatMoney`
210
- * needed in a row), `purchasable` as a boolean with `unavailableReason`
211
- * beside it, and the quantity bounds.
212
- * - `notices` normalizes `coupon_notices` + `removed_items` into one list —
213
- * render it, or a line that auto-dropped vanishes with no explanation.
214
- * - `mutationError` is the last failed mutation (insufficient stock, an expired
215
- * cart), cleared on the next success. Mutations still reject, so
216
- * `useCartLine`/`useAddToCart` can handle failures locally.
217
- * - `applyCoupon(code)` resolves to `{ ok, cart }` or `{ ok: false, code,
218
- * message }` — an invalid code is expected flow, not an exception.
219
- *
220
- * ## When `status` settles — and what it does not cover
200
+ * Rendering `cart.items` is yours; three shapes in there are easy to misread,
201
+ * and `@/commerce/utils` has the helper for each:
202
+ * - `item.attributes` is an **array** of `{name, option}`, never a map
203
+ * `attributesLabel(item.attributes)`.
204
+ * - `item.purchasable` is a **result object** `{ok, code, error}`, not a
205
+ * boolean truthiness alone calls every line purchasable.
206
+ * - the cart's totals are **nested** (`cart.totals`), an order's are flat →
207
+ * `cartTotalsLines(cart, { formatMoney })` renders every line the store
208
+ * actually has, including the discount and tax rows a hand-written summary
209
+ * drops.
221
210
  *
222
- * `status` is `"loading"` for exactly one thing: the **first** `getCart()` of
223
- * the session has not resolved yet (internally, `cart === undefined`). It
224
- * settles once, to `"empty"` or `"ready"`, and after that:
211
+ * Also render `cart.coupon_notices` / `cart.removed_items` when present: they
212
+ * say what auto-dropped and why, in the server's words.
225
213
  *
226
- * - **A mutation never returns it to `"loading"`.** Adding, updating, removing
227
- * or couponing leaves `status` as it was, previous numbers on screen, until
228
- * the new view lands. There is no cart-wide busy flag by design: a page-wide
229
- * spinner for a 250ms quantity step is worse than the stale number, and the
230
- * right busy scope is the row (`useCartLine`'s `pending`) or the control that
231
- * started it.
232
- * - `"empty"` therefore means *loaded, with no items* including after
233
- * checkout consumes the cart — never "still arriving".
234
- * - It flips `"empty"` → `"ready"` when the first line lands, so a header badge
235
- * and a drawer switch states off the same signal.
214
+ * - `mutationError` is the last failed mutation (insufficient stock, an expired
215
+ * cart) as `{code, message}`, cleared on the next success. Mutations still
216
+ * reject, so a caller can handle failures locally too.
217
+ * - `applyCoupon(code)` resolves `{ ok, cart }` or `{ ok: false, code, message }`
218
+ * an invalid code is expected flow, not an exception. Coupons are admin-only
219
+ * data, so a seeded code is reachable **only** through a field the customer
220
+ * types it into: a store with coupons needs one, in the cart or the checkout.
236
221
  *
237
- * So branch **`status`** for the page's loading/empty/ready shape, and watch
238
- * **`useCartLine().pending`** (or your own flag around `addItem`) for "did that
239
- * change land". Anything waiting on a mutation a queued follow-up action, a
240
- * script driving the page waits on the second, never the first.
222
+ * `status` settles **once** `"loading"` means only that the session's first
223
+ * `getCart()` is outstanding. A mutation never returns it to `"loading"`
224
+ * (previous numbers stay on screen until the new view lands), so there is no
225
+ * cart-wide busy flag by design: the right busy scope is the row
226
+ * (`useCartLine().pending`) or the control that started it. `"empty"` therefore
227
+ * always means *loaded, with no items* — including after checkout consumes the
228
+ * cart. Anything waiting for a mutation to land waits on `pending`, never on
229
+ * `status`.
241
230
  */
242
231
  export function useCart() {
243
232
  const { client, cart, cartError, mutationError, runCart } = useStorefrontState();
@@ -270,58 +259,9 @@ export function useCart() {
270
259
  [client, runCart],
271
260
  );
272
261
 
273
- const formatMoney = useFormatMoney();
274
262
  const items = cart?.items ?? [];
275
263
  const loading = cart === undefined;
276
264
 
277
- const lines = useMemo(
278
- () =>
279
- items.map((item) => {
280
- // A cart line's `purchasable` is a RESULT object ({ ok, code, error }),
281
- // not a boolean — truthiness alone would call every line purchasable.
282
- const purchasable = item.purchasable?.ok ?? Boolean(item.purchasable);
283
- // `sold_individually` is the only per-line ceiling the cart view
284
- // carries; real stock limits surface as `insufficient_stock` from the
285
- // server when a customer tries to exceed them.
286
- const maxQuantity = item.sold_individually ? 1 : Infinity;
287
- return {
288
- ...item,
289
- attributesLabel: attributesLabel(item.attributes),
290
- image: item.image ? { src: item.image, alt: item.name ?? "" } : null,
291
- totalLabel: item.total != null ? formatMoney(item.total) : "",
292
- subtotalLabel: item.subtotal != null ? formatMoney(item.subtotal) : "",
293
- unitPriceLabel: item.price != null ? formatMoney(item.price) : "",
294
- maxQuantity,
295
- canIncrease: item.quantity < maxQuantity,
296
- canDecrease: item.quantity > 1,
297
- purchasable,
298
- unavailableReason: purchasable ? null : (item.purchasable?.error ?? null),
299
- };
300
- }),
301
- [items, formatMoney],
302
- );
303
-
304
- const notices = useMemo(
305
- () => [
306
- ...(cart?.coupon_notices ?? []).map((n) => ({
307
- kind: "coupon",
308
- code: n.error_code ?? n.code ?? "coupon_invalid",
309
- message: n.error ?? `Coupon ${n.code} is no longer valid.`,
310
- })),
311
- ...(cart?.removed_items ?? []).map((r) => ({
312
- kind: "removed_item",
313
- code: r.code ?? "unavailable",
314
- message: r.reason ?? "An item is no longer available and was removed.",
315
- })),
316
- ],
317
- [cart],
318
- );
319
-
320
- const totalsLines = useMemo(
321
- () => (cart ? cartTotalsLines(cart, { formatMoney }) : []),
322
- [cart, formatMoney],
323
- );
324
-
325
265
  return {
326
266
  cart: cart ?? null,
327
267
  loading,
@@ -330,9 +270,6 @@ export function useCart() {
330
270
  itemCount: items.reduce((n, i) => n + (i.quantity || 0), 0),
331
271
  isEmpty: !items.length,
332
272
  status: loading ? "loading" : items.length ? "ready" : "empty",
333
- lines,
334
- notices,
335
- totalsLines,
336
273
  refresh,
337
274
  addItem,
338
275
  updateItem,