@base44/app-plugin-commerce 0.2.4 → 0.2.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@base44/app-plugin-commerce",
3
- "version": "0.2.4",
3
+ "version": "0.2.5",
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",
@@ -107,10 +107,10 @@ so you can answer "would that file help?" without paying for it.
107
107
  | Topic | Open when | Already covered without opening | Size |
108
108
  |---|---|---|---|
109
109
  | [`install/01-install.md`](./install/01-install.md) | installing — it routes you to 02 and 03 | — | 8K |
110
- | [`install/02-storefront.md`](./install/02-storefront.md) | building storefront pages | — | 14K |
111
- | [`install/03-data.md`](./install/03-data.md) | seeding the catalog, shipping, payments decision | — | 14K |
110
+ | [`install/02-storefront.md`](./install/02-storefront.md) | building storefront pages — cart-drawer behaviour and driving the storefront from a browser script are in here too, not in a separate reference | — | 38K |
111
+ | [`install/03-data.md`](./install/03-data.md) | seeding the catalog, shipping, payments decision | — | 18K |
112
112
  | [`docs/entities.md`](./docs/entities.md) | any direct entity read/write, or "which entity holds X" | function-mediated flows never need it; the addressing rule is above | 11K |
113
- | [`references/catalog-rendering.md`](./references/catalog-rendering.md) | which fields each catalog call returns, variant edge cases | the install's product list/page chunks already render correct cards, prices and selectors | 12K |
113
+ | [`references/catalog-rendering.md`](./references/catalog-rendering.md) | which fields each catalog call returns, variant edge cases | the install's product list/page chunks already render correct cards, prices and selectors | 13K |
114
114
  | [`references/shipping-and-tax.md`](./references/shipping-and-tax.md) | zones beyond the standard recipe, taxes, editing locations later | "€X in a region, €Y worldwide" is inline in `install/03-data.md` | 8K |
115
115
  | [`references/online-payments.md`](./references/online-payments.md) | the store opted into cards and you are wiring the provider **now** | the decision and its timing are in `install/03-data.md`; wiring Stripe is a one-file copy, not code to write | 9K |
116
116
  | [`references/reviews.md`](./references/reviews.md) | moderation, or a policy beyond the `policy` prop | `useProductReviews` covers list + form + policies | 4K |
@@ -54,49 +54,93 @@ layout — get hooks only, on purpose: reference markup there would make every
54
54
  store look the same, and their design is the work only you can do. Either way
55
55
  the hooks are high-level enough that a page is a handful of calls plus your
56
56
  markup — writing more code than the budgets at the bottom allow means you are
57
- re-deriving logic a hook already owns. Everything imports from
58
- `@/commerce/storefront`.
57
+ re-deriving logic a hook already owns.
58
+
59
+ **One import path: `@/commerce/storefront`.** Each section below opens with its
60
+ page's exact import line — **copy it verbatim** instead of assembling one from
61
+ memory, then delete any name you don't end up using. `variantAxes` and
62
+ `productSpecs` live in `@/commerce/utils` but are re-exported here, so a React
63
+ page never imports from `@/commerce/utils` directly; `useStoreInfo` is the name
64
+ most often left out, and it is the only source of store name and currency.
59
65
 
60
66
  ## Setup — once
61
67
 
68
+ Nearly every store has shared chrome (a nav with a cart badge, a footer), so
69
+ **start from the layout route** — a pathless `<Route>` whose element is your
70
+ layout, rendering `<Outlet/>` where the page goes. It also keeps the admin
71
+ outside the storefront's provider and chrome:
72
+
62
73
  ```jsx
74
+ import { Routes, Route, Outlet } from "react-router-dom";
63
75
  import { StorefrontProvider } from "@/commerce/storefront";
64
76
  import { base44 } from "@/api/base44Client";
77
+ import AdminApp from "@/commerce/admin";
78
+
79
+ // StoreLayout is YOURS: <Nav/> (its cart badge calls useCart) + <Outlet/> + <Footer/>.
80
+ function StoreLayout() { return <><Nav /><Outlet /><Footer /></>; }
65
81
 
66
82
  <BrowserRouter>
67
- <StorefrontProvider base44={base44}> {/* wraps <Routes> — never a child of it */}
68
- <Routes> {/* ONE <Routes> — merge new pages into the app's */}
83
+ <Routes> {/* ONE <Routes> — merge new pages into the app's */}
84
+ <Route element={<StorefrontProvider base44={base44}><StoreLayout /></StorefrontProvider>}>
69
85
  <Route path="/" element={<Home />} />
70
86
  <Route path="/product/:slug" element={<ProductPage />} />
71
87
  <Route path="/bag" element={<Bag />} />
72
88
  <Route path="/checkout" element={<Checkout />} />
73
89
  <Route path="/order-received" element={<OrderReceived />} />
74
- <Route path="/store-admin/*" element={<AdminApp />} />
75
- </Routes>
76
- </StorefrontProvider>
90
+ </Route>
91
+ <Route path="/store-admin/*" element={<AdminApp />} /> {/* own chrome, outside the provider */}
92
+ </Routes>
77
93
  </BrowserRouter>
78
94
  ```
79
95
 
96
+ ⚑ **The nesting is provider → layout → `<Outlet/>`, never the reverse.** A
97
+ layout that renders the provider *inside* itself leaves the nav above (or
98
+ outside) it, so the header badge and the cart page read different carts — and a
99
+ `useCart` in the nav throws outright. The provider goes on the layout route's
100
+ element, wrapping your layout component.
101
+
102
+ **No shared chrome** (each page draws its own header, or there is one page)?
103
+ Then wrap `<Routes>` directly and skip the layout route:
104
+
105
+ ```jsx
106
+ <StorefrontProvider base44={base44}> {/* wraps <Routes> — never a child of it */}
107
+ <Routes>…</Routes>
108
+ </StorefrontProvider>
109
+ ```
110
+
111
+ > ⚠ **`<Routes>` accepts only `<Route>` children.** Putting the provider inside
112
+ > it — the natural reading of "wrap the storefront routes" — throws at render:
113
+ > `Error: [StorefrontProvider] is not a <Route> component. All component children of <Routes> must be a <Route> or <React.Fragment>`.
114
+ > A pathless layout route (above) is the one place a wrapper is legal.
115
+
80
116
  The provider owns the shared client, the store-info cache and **one** shared
81
117
  cart, so a header badge, a drawer and the checkout render the same state. Never
82
118
  mount a second provider, and never touch the `cart_token` — the provider owns
83
119
  its whole lifecycle.
84
120
 
85
- > ⚠ **`<Routes>` accepts only `<Route>` children.** Nesting the provider inside
86
- > it — the natural reading of "wrap the storefront routes" — throws at render:
87
- > `Error: [StorefrontProvider] is not a <Route> component. All component children of <Routes> must be a <Route> or <React.Fragment>`.
88
- > To scope the provider to storefront routes only, use a pathless **layout
89
- > route**, the one place a wrapper is legal:
90
- > ```jsx
91
- > <Route element={<StorefrontProvider base44={base44}><Outlet /></StorefrontProvider>}>
92
- > <Route path="/" element={<Home />} />
93
- > <Route path="/checkout" element={<Checkout />} />
94
- > </Route>
95
- > <Route path="/store-admin/*" element={<AdminApp />} /> {/* outside the provider */}
96
- > ```
97
-
98
121
  ## Product list / collection
99
122
 
123
+ ```jsx
124
+ import { useProductList, useCategories, useProductPrice, useStoreInfo, useStorefrontSeo, collectionSeo, productSpecs } from "@/commerce/storefront";
125
+ ```
126
+
127
+ (Drop `useCategories` with no filter bar, `productSpecs` if the card shows no
128
+ modifiers. Add `useRibbons` for a ribbon filter.) The top of the component,
129
+ before any markup — every hook above the guards, and `products` derived once:
130
+
131
+ ```jsx
132
+ const list = useProductList({ per_page: 24 });
133
+ const { settings } = useStoreInfo();
134
+ useStorefrontSeo(collectionSeo({ title: "…", products: list.products, storeName: settings?.store_name }));
135
+
136
+ if (list.status === "loading") return /* your loading state */;
137
+ if (list.status === "error") return /* your failure state, with a retry calling list.reload() */;
138
+ const products = list.products; // always an array — never null, so no defensive `?? []`
139
+ ```
140
+
141
+ The same opening carries a home page's rails and a search results page — only
142
+ the params and your markup change.
143
+
100
144
  `useProductList(params)` → `{ status, products, hasNext, next, refreshing,
101
145
  setParams, reload }`. ⚑ `status` is `"loading" | "ready" | "empty" | "error"`
102
146
  — branch on it, so a failed request renders as a failure instead of an empty
@@ -130,6 +174,13 @@ beside a product need no query: `p.upsells` / `p.crossSells` from `useProduct`.
130
174
 
131
175
  ## Product page
132
176
 
177
+ ```jsx
178
+ import { useProduct, useProductGallery, useAddToCartButton, useStoreInfo, useStorefrontSeo, productSeo, variantAxes, productSpecs } from "@/commerce/storefront";
179
+ ```
180
+
181
+ (Add `useProductReviews` only if the store has reviews; drop `productSpecs` if
182
+ these products carry no modifiers.)
183
+
133
184
  `useProduct(slug)` (or `{ id }`) owns fetch + variant selection + quantity +
134
185
  price, race-safe, selection mirrored to the URL. ⚑ `status: "not_found"` is a
135
186
  404 page, not a spinner.
@@ -144,7 +195,8 @@ precisely so they can sit at the top:
144
195
  const p = useProduct(slug);
145
196
  const g = useProductGallery(p.product, p.view);
146
197
  const buy = useAddToCartButton(p, { onAdded: () => navigate("/bag") });
147
- useStorefrontSeo(productSeo(p.product, p.view, { storeName, currency }));
198
+ const { settings } = useStoreInfo(); // the ONLY source of store name + currency
199
+ useStorefrontSeo(productSeo(p.product, p.view, { storeName: settings?.store_name, currency: settings?.currency }));
148
200
 
149
201
  if (p.status === "loading") return /* your loading state */;
150
202
  if (p.status === "not_found") return /* your 404 */;
@@ -190,25 +242,42 @@ anywhere.) Build your layout from:
190
242
  are yours.
191
243
  - **Description** — `product.description` is HTML; render as rich text
192
244
  (`dangerouslySetInnerHTML`), `short_description` above it.
193
- - **Specs** — `productSpecs(product)` → `[{ key, label, value }]` from
194
- `meta_data` (Material, Care, Provenance). `[]` means no section at all.
245
+ - **Specs** — `productSpecs(product)` → `[{ key, label, value, type, number,
246
+ unit, items }]` from `meta_data` (Material, Care, Provenance, Weight). `[]`
247
+ means no section at all. ⚑ **Don't `.map()` it into one uniform label/value
248
+ table** — that is the most reliable tell of a generated product page. Every
249
+ row's `type` is inferred for you so the branch point is already there:
250
+ `"numeric"` (with `number` and `unit` split out), `"duration"`, `"location"`,
251
+ `"list"` (with `items`), `"text"`.
252
+
253
+ ```jsx
254
+ // ❌ what a generated store ships: one grey table, every store the same
255
+ <dl>{specs.map((s) => <div key={s.key}><dt>{s.label}</dt><dd>{s.value}</dd></div>)}</dl>
256
+
257
+ // ✅ the figures read as figures, the rest falls through to a plain row
258
+ {productSpecs(product).map((s) =>
259
+ s.type === "numeric" ? <Figure key={s.key} label={s.label} n={s.number} unit={s.unit} /> // 0.75 ct, set large
260
+ : s.type === "list" ? <Bars key={s.key} parts={s.items} /> // 70% wool / 30% cashmere
261
+ : s.type === "location" ? <Sourced key={s.key} place={s.value} /> // a located line, a pin
262
+ : <Row key={s.key} label={s.label} value={s.value} />)}
263
+ ```
264
+
265
+ Design the two or three that carry *this* product's meaning; branch on `s.key`
266
+ instead when one particular modifier deserves its own treatment. And they need
267
+ not sit in one block — a spec can go under the gallery, beside the price, or
268
+ inside the description.
195
269
  - **Breadcrumbs** — build from `categories`
196
270
  (`/collection?category_id=${c.id}`); skip on a flat catalog. Ribbons are
197
271
  labels, not breadcrumbs.
198
272
 
199
273
  All optional — include what this store's products actually have — and each is
200
- one hook, **not one component style**. The tell of a generated product page is
201
- that every axis is the same chip row and every modifier the same grey
202
- label/value line. Branch on what they are: `variantAxes` gives you `axis.key` /
203
- `axis.name`, `productSpecs` gives you `key` / `label`, so a colour axis can be
204
- swatches in the real colours, a size axis chips with a size guide beside them,
205
- a material axis a small sample image; a "Composition" modifier can be bars, a
206
- "Provenance" a map pin, a "Certification" a seal, a "Weight" a figure set in
207
- the display face. Design the two or three that carry this product's meaning,
208
- let the rest fall back to a plain row, and don't feel obliged to keep them in
209
- one block — a spec can sit under the gallery, beside the price, or inside the
210
- description. The ⚑ rules above (one control per axis, unbuyable options
211
- disabled) constrain the *behaviour* of a selector, never its form.
274
+ one hook, **not one component style**. The same rule the specs bullet states
275
+ applies to the axes: `variantAxes` gives you `axis.key` / `axis.name` so a
276
+ colour axis can be swatches in the real colours, a size axis chips with a size
277
+ guide beside them, a material axis a small sample image — every axis rendered as
278
+ the identical chip row is the other half of the generated-page tell. The rules
279
+ above (one control per axis, unbuyable options disabled) constrain the
280
+ *behaviour* of a selector, never its form.
212
281
 
213
282
  ### Reviews — optional
214
283
 
@@ -230,6 +299,10 @@ approval", so render `message`, never your own text. `policy` is
230
299
 
231
300
  ## Cart / bag
232
301
 
302
+ ```jsx
303
+ import { useCart, CartLine, useTotalsLines, useCoupon, useFormatMoney } from "@/commerce/storefront";
304
+ ```
305
+
233
306
  A cart *page* is optional: a store selling one made-to-order piece reads better
234
307
  as buy-now straight to checkout. The surface is four hooks: `useCart()`
235
308
  (`status`, `lines`, `notices`), `CartLine` (headless render-prop binding
@@ -245,6 +318,26 @@ the checkout): coupons are admin-only data, redeemable only through a field the
245
318
  customer types into — if no field exists anywhere, don't seed coupons and don't
246
319
  write "use WELCOME10" in the copy.
247
320
 
321
+ ⚑ **`pending` is one row's flag, and it stays up until that update finishes.**
322
+ The window is not the click: `increase`/`decrease` set the optimistic number and
323
+ start a 250ms debounce, `pending` goes true when the request leaves, and returns
324
+ to false **only after the new cart view has landed** — so `pending === false`
325
+ with no `error` means that row's quantity and the totals are settled, not just
326
+ that a request returned. (`remove()` skips the debounce, `pending` immediately.)
327
+ So **disable and mark only that row** — `disabled={l.pending}` on its own
328
+ controls, `aria-busy` on the row — since `pending` says nothing about the other
329
+ lines, and stalling the whole cart over one 250ms stepper reads as a broken page.
330
+ And note **`status` never returns to `"loading"` for a mutation**: it settles
331
+ once, on first load, then only moves between `"empty"` and `"ready"`. There is
332
+ deliberately no cart-wide busy flag — `status` is the page's shape, `pending` is
333
+ "did that change land".
334
+
335
+ ⚑ **Repeated controls need unique accessible names.** A three-line cart renders
336
+ three buttons named "Remove", and "+" or "×" alone names nothing at all — put the
337
+ line in the label (``aria-label={`Remove ${line.name}`}``, likewise ±, and a
338
+ drawer's close button). Identical or empty names are ambiguous to a screen reader
339
+ and to anything driving the page by name.
340
+
248
341
  **Reference implementation** — read once for the wiring, then write your own
249
342
  page: the structure below is correct, the presentation is deliberately absent.
250
343
  Restyle, rearrange, split into your own components; the ⚑ rules are the part
@@ -263,12 +356,15 @@ function Bag() {
263
356
  {lines.map((line) => (
264
357
  <CartLine key={line.item_key} line={line}>
265
358
  {(l) => ( /* line: name, attributesLabel, image, total — l: the controls */
266
- <li>
359
+ <li aria-busy={l.pending}> {/* busy scope is THIS row, never the cart */}
267
360
  {line.name} {line.attributesLabel}
268
- <button onClick={l.decrease} disabled={!l.canDecrease || l.pending}>−</button>
361
+ <button onClick={l.decrease} disabled={!l.canDecrease || l.pending}
362
+ aria-label={`Decrease quantity of ${line.name}`}>−</button>
269
363
  {l.quantity}
270
- <button onClick={l.increase} disabled={!l.canIncrease || l.pending}>+</button>
271
- <button onClick={l.remove}>Remove</button>
364
+ <button onClick={l.increase} disabled={!l.canIncrease || l.pending}
365
+ aria-label={`Increase quantity of ${line.name}`}>+</button>
366
+ <button onClick={l.remove} disabled={l.pending}
367
+ aria-label={`Remove ${line.name}`}>Remove</button>
272
368
  {formatMoney(line.total)}
273
369
  {l.error && <p role="alert">{l.error.message}</p>}
274
370
  </li>
@@ -288,8 +384,40 @@ function Bag() {
288
384
  No shipping estimator here — checkout reprices shipping and tax from the
289
385
  address.
290
386
 
387
+ ### If the cart is a drawer
388
+
389
+ Same hooks, same rows — a drawer only adds what a cart *page* gets for free:
390
+
391
+ **Close it on navigation.** A product link or "Checkout" inside the drawer
392
+ changes the route with the drawer still open, leaving it hanging over the new
393
+ page. One effect covers every link inside, so no link has to remember:
394
+
395
+ ```jsx
396
+ const { pathname } = useLocation();
397
+ useEffect(() => { setOpen(false); }, [pathname]);
398
+ ```
399
+
400
+ **A closed drawer must be inert, not just invisible.** Hidden with `opacity` or
401
+ `translate`, its buttons stay focusable and clickable — Tab walks into an
402
+ invisible cart, and a click lands on a "Remove" nobody can see. Either don't
403
+ render it (`{open && <Drawer/>}`) or, if it stays mounted for the transition, use
404
+ `hidden` / `visibility: hidden` / `inert`. ⚑ Never `aria-hidden` alone: that
405
+ hides it from a screen reader while leaving it fully clickable.
406
+
407
+ **The backdrop is not the close button.** Both close the drawer; only one is a
408
+ control. Name the visible button (`aria-label="Close cart"`) and leave the
409
+ backdrop an unnamed overlay — `onClick={close}`, `aria-hidden="true"`, no tab
410
+ stop. Two elements named "Close cart" are ambiguous to a screen reader and to
411
+ anything driving the page; the keyboard's way out is Esc and the button.
412
+
291
413
  ## Checkout
292
414
 
415
+ ```jsx
416
+ import { CheckoutProvider, useCheckoutContext, useCheckoutBlockers, useAddressForm, ShippingMethodPicker, PaymentMethodPicker, useCart, useTotalsLines, useCoupon, useFormatMoney } from "@/commerce/storefront";
417
+ ```
418
+
419
+ (`useCoupon` only if the coupon field lives here rather than in the cart.)
420
+
293
421
  The state machine is `useCheckout`, shared across the page's regions by
294
422
  `CheckoutProvider` + `useCheckoutContext()`. It reprices shipping/tax from the
295
423
  address automatically (debounced, never on a half-typed address), derives the
@@ -403,6 +531,10 @@ function AddressFields({ which }) {
403
531
 
404
532
  ## Order received
405
533
 
534
+ ```jsx
535
+ import { useOrderReturn, useTotalsLines, useFormatMoney } from "@/commerce/storefront";
536
+ ```
537
+
406
538
  **Mandatory route** — every payment link returns here, and confirming is what
407
539
  marks a card order paid. `useOrderReturn()` is the whole page's logic: it reads
408
540
  `order_id`/`order_key` from the URL, verifies with the provider (idempotent),
@@ -464,7 +596,7 @@ above the status guards.
464
596
  | Page | budget (chars) | rationale |
465
597
  |---|---|---|
466
598
  | Checkout | ≤ 5K | your markup over the reference above — the logic is all hook calls |
467
- | Cart / bag | ≤ 3K | `useCart` + `CartLine` rows + totals + coupon + empty state |
599
+ | Cart / bag | ≤ 3K | `useCart` + `CartLine` rows + totals + coupon + empty state — a drawer is its own component with its own 3K, not an extension of this one |
468
600
  | Order-received | ≤ 2.5K | five states + payment instructions + summary |
469
601
  | Product page | ≤ 5K | your layout and type around `useProduct`, `variantAxes`, `useAddToCartButton`, the gallery |
470
602
  | Collection | ≤ 3K | `useProductList` + custom card + pagination controls |
@@ -481,9 +613,33 @@ for.
481
613
 
482
614
  ## If you drive the storefront from a browser script
483
615
 
484
- Whatever you choose to check and however you check it, two things make a
485
- working storefront look broken under a script:
486
-
616
+ Whatever you choose to check and however you check it, these are what make a
617
+ working storefront look broken under a script. The common cause is acting
618
+ faster than the cart settles: the hooks are optimistic and debounced, so the DOM
619
+ is briefly right about the *intent* and wrong about the *state*.
620
+
621
+ - **Wait for the cart, then for each row.** Two waits, neither optional. Before
622
+ the first action, wait for the initial load to settle — `status` leaves
623
+ `"loading"` exactly once, so the signal is the loaded UI (a row, or the empty
624
+ state), never a fixed sleep. Then after every stepper click wait for **that
625
+ row**: the click starts a 250ms debounce before the request even leaves, so
626
+ reading the quantity or total straight after gives the optimistic number and
627
+ stale totals, and two quick clicks send **one** request for the final number.
628
+ Wait for the row's busy state to clear (`aria-busy`, re-enabled buttons) before
629
+ reading or clicking again.
630
+ - **Scope actions to the visible drawer.** With a drawer, the page can hold two
631
+ "Remove" buttons for one line — drawer and cart page behind it — and a
632
+ closed-but-mounted drawer keeps its copies clickable. Query inside the open
633
+ drawer's container, not the document. A click that seems to do nothing usually
634
+ hit the hidden copy.
635
+ - **Remove lines one at a time.** Clicking every "Remove" in one pass fails on
636
+ its own terms: cart calls are serialized, each removal re-renders the list, and
637
+ buttons collected up front are detached by the time the loop reaches them.
638
+ Remove one, wait for the row to disappear, then the next.
639
+ - **Verify the checkout navigation before cleaning up.** Confirm you are on
640
+ `/checkout` — URL plus a field of the form on screen — before emptying the cart
641
+ or moving on. Tearing the cart down while still on the cart page, or
642
+ mid-navigation, produces an empty checkout that reads as a routing bug.
487
643
  - **Filling the checkout.** Every field is a controlled React input, so writing
488
644
  `el.value` changes nothing React sees. Use the harness's own fill (it
489
645
  dispatches `input` + `change`) — never lift the native setter off
@@ -501,14 +657,18 @@ working storefront look broken under a script:
501
657
  ## Done — forget this file
502
658
 
503
659
  - [ ] Catalog UI exists in whatever form fits the store (list, product pages, or both), plus a checkout, plus `/order-received`.
504
- - [ ] **One** `<StorefrontProvider>` above every storefront route, wrapping `<Routes>` (or a layout route's `<Outlet/>`); one client, no hand-rolled `cart_token`.
660
+ - [ ] **One** `<StorefrontProvider>` above every storefront route on the layout route's element, wrapping the layout that renders `<Outlet/>` (or wrapping `<Routes>` if the store has no shared chrome); one client, no hand-rolled `cart_token`.
661
+ - [ ] Every page's imports came from its section's import line above: one path (`@/commerce/storefront`), nothing imported from `@/commerce/utils`, and no imported name — React's included — left unused.
505
662
  - [ ] Pages branch on `status`; no page maps a possibly-null list or shows an empty state while loading.
506
663
  - [ ] Gateways/currency/countries read from `useStoreInfo()` only.
507
664
  - [ ] If the store has coupons, a coupon field (`useCoupon`) exists in the cart or the checkout.
508
665
  - [ ] `/order-received` renders `useOrderReturn`'s states **including `paymentInstructions`**.
509
666
  - [ ] Variant options render one control per axis; unbuyable options are disabled, not hidden.
667
+ - [ ] Cart rows scope their busy state to the row (`l.pending` + `aria-busy`), and every repeated control (remove, ±, a drawer's close) has a unique accessible name.
668
+ - [ ] A cart drawer closes on route change, is **inert** when closed (not merely invisible), and its backdrop is not a second control named "Close cart".
510
669
  - [ ] No hook logic was re-implemented by hand (address fields, quantity clamps, totals rows, shipping/payment branching).
511
- - [ ] The storefront carries the design you settled on before reading this file — no page ships the reference snippets' bare structure or placeholder copy, and the attributes and modifiers that matter to these products are designed rather than poured into one uniform block.
670
+ - [ ] The storefront carries the design you settled on before reading this file — no page ships the reference snippets' bare structure or placeholder copy.
671
+ - [ ] Specs and axes are rendered by what they are (`productSpecs`' `type`/`key`, `axis.key`) — not one uniform label/value table and one identical chip row.
512
672
  - [ ] Every page is within its budget above.
513
673
 
514
674
  Record these lines in your working notes; do not re-read this file.
@@ -63,11 +63,14 @@ try {
63
63
  categories: ["Shoes"], // get-or-created by display name
64
64
  ribbons: ["Best Seller"], // flat labels, not a hierarchy
65
65
 
66
- // Descriptive properties → the spec table (`productSpecs(product)`).
67
- // NOT variant axes and NOT ribbons: they describe the product, they
68
- // don't select anything. Values are strings; a leading `_` hides a row.
66
+ // Descriptive properties → the product page's spec rows
67
+ // (`productSpecs(product)`, which types each one so a weight can render
68
+ // as a figure and a composition as bars). NOT variant axes and NOT
69
+ // ribbons: they describe the product, they don't select anything.
70
+ // Values are strings; a leading `_` hides a row.
69
71
  meta_data: [
70
72
  { key: "Material", value: "Recycled knit upper" },
73
+ { key: "Weight", value: "248 g" }, // "<number> <unit>" → a numeric row
71
74
  { key: "Care", value: "Machine wash cold" },
72
75
  ],
73
76
 
@@ -31,7 +31,7 @@ A listing **row** is the product record itself (minus paywalled fields) plus res
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
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 spec-table rows) |
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) |
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 |
@@ -64,7 +64,7 @@ Both lists below are field inventories — what the data supports — **not a la
64
64
 
65
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)`, `useAddToCartButton`, `productSpecs(product)`, `useProductReviews`, `p.upsells`/`p.crossSells`.
66
66
 
67
- **Attributes and modifiers are individually designable.** `variantAxes` exposes `axis.key`/`axis.name` and `productSpecs` exposes `key`/`label` precisely so a page can branch on *which* one it is: colour as swatches, size as chips next to a size guide, "Composition" as bars, "Provenance" as a located line, "Certification" as a seal. One uniform chip row for every axis and one grey label/value table for every modifier is a default, not a requirement — pick the two or three that carry the product's meaning, give them real treatment, and let the remainder fall back to a plain row. The rules in §5 govern selector *behaviour* (one control per axis, unbuyable disabled), never its form, and they hold whatever the control looks like.
67
+ **Attributes and modifiers are individually designable.** `variantAxes` exposes `axis.key`/`axis.name`, and `productSpecs` exposes `key`/`label` plus an inferred `type` — `numeric` (with `number`/`unit` split out), `duration`, `location`, `list` (with `items`), `text` — precisely so a page can branch on *which* one it is: colour as swatches, size as chips next to a size guide, a `list` "Composition" as bars, a `location` "Provenance" as a located line, a `numeric` "Weight" as a figure in the display face, "Certification" as a seal. One uniform chip row for every axis and one grey label/value table for every modifier is a default, not a requirement — pick the two or three that carry the product's meaning, give them real treatment, and let the remainder fall back to a plain row. The rules in §5 govern selector *behaviour* (one control per axis, unbuyable disabled), never its form, and they hold whatever the control looks like.
68
68
 
69
69
  ## 4. Ribbons — in **both** views
70
70
 
@@ -19,14 +19,24 @@ import {
19
19
  * StorefrontProvider — one client, one store-info cache, ONE shared cart.
20
20
  *
21
21
  * Mount it once, above every storefront page (product list, product page,
22
- * cart, checkout, order-received) it wraps <Routes>, it is NOT a <Route>:
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:
23
26
  *
24
27
  * import { base44 } from "@/api/base44Client";
28
+ * <Route element={<StorefrontProvider base44={base44}><StoreLayout /></StorefrontProvider>}>
29
+ * <Route path="/" element={<Home />} /> …
30
+ * </Route>
31
+ *
32
+ * With no shared layout it can wrap <Routes> directly instead:
33
+ *
25
34
  * <StorefrontProvider base44={base44}> <Routes>…</Routes> </StorefrontProvider>
26
35
  *
27
- * Inside <Routes> it throws ("is not a <Route> component"), since React Router
28
- * allows only <Route>/<Fragment> children there. To cover just some routes,
29
- * use a pathless layout route: element={<StorefrontProvider …><Outlet/></…>}.
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.
30
40
  *
31
41
  * or, if other modules also need the raw client, create it once and share it:
32
42
  *
@@ -128,7 +138,7 @@ export function useStorefrontState() {
128
138
  const ctx = useContext(StorefrontContext);
129
139
  if (!ctx) {
130
140
  throw new Error(
131
- "Storefront hooks need a <StorefrontProvider> above them — mount it once around your <Routes> (it wraps the router; it is not a <Route>, and inside <Routes> React Router rejects it).",
141
+ "Storefront hooks need a <StorefrontProvider> above them — mount it once on a pathless layout route, wrapping the layout component that renders <Outlet/> (or, with no shared layout, around your <Routes>). It is never a <Route> itself: as a child of <Routes>, React Router rejects it.",
132
142
  );
133
143
  }
134
144
  return ctx;
@@ -204,6 +214,28 @@ export function useFormatMoney() {
204
214
  * `useCartLine`/`useAddToCart` can handle failures locally.
205
215
  * - `applyCoupon(code)` resolves to `{ ok, cart }` or `{ ok: false, code,
206
216
  * message }` — an invalid code is expected flow, not an exception.
217
+ *
218
+ * ## When `status` settles — and what it does not cover
219
+ *
220
+ * `status` is `"loading"` for exactly one thing: the **first** `getCart()` of
221
+ * the session has not resolved yet (internally, `cart === undefined`). It
222
+ * settles once, to `"empty"` or `"ready"`, and after that:
223
+ *
224
+ * - **A mutation never returns it to `"loading"`.** Adding, updating, removing
225
+ * or couponing leaves `status` as it was, previous numbers on screen, until
226
+ * the new view lands. There is no cart-wide busy flag by design: a page-wide
227
+ * spinner for a 250ms quantity step is worse than the stale number, and the
228
+ * right busy scope is the row (`useCartLine`'s `pending`) or the control that
229
+ * started it.
230
+ * - `"empty"` therefore means *loaded, with no items* — including after
231
+ * checkout consumes the cart — never "still arriving".
232
+ * - It flips `"empty"` → `"ready"` when the first line lands, so a header badge
233
+ * and a drawer switch states off the same signal.
234
+ *
235
+ * So branch **`status`** for the page's loading/empty/ready shape, and watch
236
+ * **`useCartLine().pending`** (or your own flag around `addItem`) for "did that
237
+ * change land". Anything waiting on a mutation — a queued follow-up action, a
238
+ * script driving the page — waits on the second, never the first.
207
239
  */
208
240
  export function useCart() {
209
241
  const { client, cart, cartError, mutationError, runCart } = useStorefrontState();
@@ -16,12 +16,16 @@
16
16
  * store correct (e.g. an unbuyable variant option renders *disabled, not
17
17
  * hidden*; a receipt page must render `paymentInstructions`).
18
18
  *
19
- * Setup (once, above every storefront route — it wraps <Routes>; placed as a
20
- * child of <Routes> React Router throws "is not a <Route> component"):
19
+ * Setup (once, above every storefront route — on a pathless layout route,
20
+ * wrapping the layout that renders <Outlet/>; as a child of <Routes> React
21
+ * Router throws "is not a <Route> component"):
21
22
  *
22
23
  * import { StorefrontProvider } from "@/commerce/storefront";
23
24
  * import { base44 } from "@/api/base44Client";
24
- * <StorefrontProvider base44={base44}> <Routes>…</Routes> </StorefrontProvider>
25
+ * <Route element={<StorefrontProvider base44={base44}><StoreLayout /></StorefrontProvider>}>
26
+ * <Route path="/" element={<Home />} /> …
27
+ * </Route>
28
+ * // no shared layout? <StorefrontProvider …> <Routes>…</Routes> </StorefrontProvider>
25
29
  *
26
30
  * ## Hooks
27
31
  * - `useStorefront` / `useStoreInfo` / `useFormatMoney` / `useMoney` — the
@@ -63,8 +67,10 @@
63
67
  * ## Helpers re-exported from `@/commerce/utils`
64
68
  * - `variantAxes(view, pick)` — axes → options with selected/disabled/stock
65
69
  * state derived, for the variant selector you write.
66
- * - `productSpecs(product)` — `meta_data` → descriptive rows, keyed so each
67
- * can be rendered its own way.
70
+ * - `productSpecs(product)` — `meta_data` → descriptive rows carrying an
71
+ * inferred `type` (`numeric` with `number`/`unit` split out, `duration`,
72
+ * `location`, `list` with `items`, `text`), so a weight can be a figure and
73
+ * a composition bars instead of every modifier being one grey table row.
68
74
  */
69
75
  export {
70
76
  StorefrontProvider,
@@ -20,6 +20,37 @@ import { useCart } from "./StorefrontProvider";
20
20
  * request per settle instead of one per click; and `sold_individually` is
21
21
  * respected, so a one-per-customer product has no working "+".
22
22
  *
23
+ * ## When `pending` settles — the exact window
24
+ *
25
+ * `pending` is this line's own flag, not the cart's, and it is **not** true for
26
+ * the whole gesture:
27
+ *
28
+ * 1. **Click → `pending` stays `false`.** `increase`/`decrease` only set the
29
+ * optimistic `quantity` and (re)start a `debounceMs` (250ms) timer. Nothing
30
+ * is in flight yet, and a further click restarts the timer, so a burst of
31
+ * clicks sends **one** request for the final number.
32
+ * 2. **Debounce elapses → `pending` becomes `true`** and the request goes out.
33
+ * `remove()` skips this step: it cancels the timer and goes `pending`
34
+ * immediately.
35
+ * 3. **`pending` returns to `false` only after the server's new cart view has
36
+ * been published to the provider** — the awaited mutation resolves through
37
+ * the provider's serialized queue, which sets the shared cart state before
38
+ * the await returns. So `pending === false` with `error === null` means this
39
+ * row's quantity, the cart's totals and any dependent badge are settled, not
40
+ * merely that the request finished.
41
+ *
42
+ * Two consequences worth designing for. **Disable and mark only this row**
43
+ * (`disabled={!l.canIncrease || l.pending}`) — `pending` says nothing about the
44
+ * other lines, and greying the whole cart because one stepper is busy makes a
45
+ * 250ms update look like a page-wide stall. And **`pending` is the only
46
+ * mutation-settled signal**: `useCart().status` never returns to `"loading"`
47
+ * for a mutation (see its doc comment), so a caller that needs to know an
48
+ * update landed — a script driving the page, a queued follow-up action — waits
49
+ * on this flag, per row, and not on cart `status`.
50
+ *
51
+ * On failure `pending` returns to `false`, the optimistic quantity rolls back
52
+ * to what the server still holds, and `error` is `{ code, message }`.
53
+ *
23
54
  * @param {object} line a decorated line from `useCart().lines` (a raw
24
55
  * `cart.items[n]` works too — it just has no `maxQuantity` hint)
25
56
  * @param {{debounceMs?: number}} [options]
@@ -25,8 +25,9 @@
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
- * - `specs.js` — `productSpecs`: `meta_data` → descriptive rows, keyed so each
29
- * can be rendered its own way.
28
+ * - `specs.js` — `productSpecs`: `meta_data` → descriptive rows with an inferred
29
+ * `type` (numeric/duration/location/list/text), so each can be rendered as
30
+ * what it is rather than as another label/value row.
30
31
  *
31
32
  * Building the storefront in React? **Prefer `@/commerce/storefront`** — it
32
33
  * layers headless hooks on top of this module, and a hook that pre-composes
@@ -1,32 +1,115 @@
1
1
  /**
2
2
  * Product spec rows — the descriptive properties a product page shows
3
- * (Material, Care, Fit, Provenance, Composition).
3
+ * (Material, Care, Fit, Provenance, Composition, Weight).
4
4
  *
5
5
  * These live in `product.meta_data` (the admin's *Modifiers* section) and are
6
6
  * **not** attributes and not ribbons: they describe the product, they don't
7
7
  * select a variant. Hidden keys (leading `_`) and empty values are skipped.
8
- * You render the rows yourself, and `key` is there so you don't have to render
9
- * them all the same way — a uniform list is the fallback, not the target:
10
8
  *
11
- * const specs = productSpecs(product);
12
- * {specs.length > 0 && <dl>{specs.map(s =>
13
- * <div key={s.key}><dt>{s.label}</dt><dd>{s.value}</dd></div>)}</dl>}
9
+ * Each row carries a `type` — inferred from the value (and, for `"location"`,
10
+ * the key) so the rendering decision is already made for you. **A `.map()`
11
+ * into one uniform label/value table is the fallback, not the target:** the
12
+ * types exist because a carat weight and a care instruction are not the same
13
+ * kind of fact and should not look alike.
14
14
  *
15
- * Branch on `s.key` to give the ones that carry this product's meaning their
16
- * own treatment (a composition as bars, a provenance as a located line, a
17
- * weight set in the display face) and let the rest fall through to the row
18
- * above.
15
+ * ```jsx
16
+ * // every product in every store, identical: one grey table
17
+ * <dl>{productSpecs(product).map((s) => (
18
+ * <div key={s.key}><dt>{s.label}</dt><dd>{s.value}</dd></div>))}</dl>
19
+ *
20
+ * // ✅ branch on type — the figures read as figures, the rest stays a row
21
+ * {productSpecs(product).map((s) =>
22
+ * s.type === "numeric" ? <Figure key={s.key} label={s.label} n={s.number} unit={s.unit} />
23
+ * : s.type === "location" ? <Sourced key={s.key} place={s.value} /> // a located line, a pin
24
+ * : s.type === "list" ? <Bars key={s.key} parts={s.items} /> // composition, materials
25
+ * : s.type === "duration" ? <Lead key={s.key} label={s.label} value={s.value} />
26
+ * : <Row key={s.key} label={s.label} value={s.value} />)}
27
+ * ```
28
+ *
29
+ * Design the two or three that carry *this* product's meaning (a weight set in
30
+ * the display face, a provenance beside a map, a composition as bars) and let
31
+ * the remainder fall through to the plain row. `key` is still there too, for
32
+ * when one particular modifier of this catalog deserves its own treatment
33
+ * regardless of type. And the rows need not sit in one block — a spec can go
34
+ * under the gallery, beside the price, or inside the description.
19
35
  *
20
36
  * @param {object} product
21
- * @returns {Array<{key: string, label: string, value: string}>} `[]` when the
22
- * product has no visible meta_data — render nothing, not an empty section.
37
+ * @returns {Array<{key: string, label: string, value: string,
38
+ * type: "numeric"|"duration"|"location"|"list"|"text",
39
+ * number: number|null, unit: string|null, items: string[]}>}
40
+ * `[]` when the product has no visible meta_data — render nothing, not an
41
+ * empty section. `number`/`unit` are set for `numeric` and `duration`
42
+ * (`unit` is `""` for a bare number), `items` for `list`, and are
43
+ * `null`/`[]` otherwise. `value` is always the store's own text, unchanged —
44
+ * the extra fields are there to render *with*, never a replacement for it.
23
45
  */
24
46
  export function productSpecs(product) {
25
47
  return (product?.meta_data ?? [])
26
48
  .filter((m) => m?.key && !String(m.key).startsWith("_") && m.value != null && m.value !== "")
27
- .map((m) => ({
28
- key: String(m.key),
29
- label: String(m.key).replace(/_/g, " "),
30
- value: String(m.value),
31
- }));
49
+ .map((m) => {
50
+ const key = String(m.key);
51
+ const value = String(m.value);
52
+ return { key, label: key.replace(/_/g, " "), value, ...classify(key, value) };
53
+ });
54
+ }
55
+
56
+ const LOCATION_KEY =
57
+ /(origin|provenance|made[\s_-]?in|country|region|sourced|source|location|city|terroir|appellation|distillery|winery|atelier|workshop)/i;
58
+
59
+ const DURATION_UNIT =
60
+ /^(sec|secs|second|seconds|min|mins|minute|minutes|hr|hrs|hour|hours|day|days|week|weeks|month|months|year|years|yr|yrs)$/i;
61
+
62
+ /** Infer the render-relevant shape of one spec value. Never throws. */
63
+ function classify(key, raw) {
64
+ const value = raw.trim();
65
+ const plain = { type: "text", number: null, unit: null, items: [] };
66
+
67
+ if (LOCATION_KEY.test(key)) return { ...plain, type: "location" };
68
+
69
+ const qty = parseQuantity(value);
70
+ if (qty) {
71
+ const type = DURATION_UNIT.test(qty.unit) ? "duration" : "numeric";
72
+ return { ...plain, type, number: qty.number, unit: qty.unit };
73
+ }
74
+
75
+ const items = parseList(value);
76
+ if (items) return { ...plain, type: "list", items };
77
+
78
+ return plain;
79
+ }
80
+
81
+ /** "0.75 ct" → {number: 0.75, unit: "ct"}; "18" → {number: 18, unit: ""}. */
82
+ function parseQuantity(value) {
83
+ const m = /^([-+]?[\d.,]+)\s*(.*)$/.exec(value);
84
+ if (!m) return null;
85
+ const number = toNumber(m[1]);
86
+ if (number === null) return null;
87
+ const unit = m[2].trim();
88
+ // A unit is a word or two of symbols/letters. Anything longer is prose that
89
+ // happens to start with a number ("2 pieces, hand-cut in the studio").
90
+ if (unit && (!/^[\p{L}%°µ"'/²³.\- ]{1,12}$/u.test(unit) || unit.split(/\s+/).length > 2)) return null;
91
+ return { number, unit };
92
+ }
93
+
94
+ /** Grouped thousands are separators; a lone comma between digits is a decimal. */
95
+ function toNumber(raw) {
96
+ let s = raw.replace(/\s/g, "");
97
+ if (/^[-+]?\d{1,3}(,\d{3})+(\.\d+)?$/.test(s)) s = s.replace(/,/g, "");
98
+ else if (/^[-+]?\d+,\d+$/.test(s)) s = s.replace(",", ".");
99
+ else if (s.includes(",")) return null;
100
+ const n = Number(s);
101
+ return Number.isFinite(n) ? n : null;
102
+ }
103
+
104
+ /** "70% wool / 30% cashmere" → ["70% wool", "30% cashmere"]. */
105
+ function parseList(value) {
106
+ const parts = value
107
+ .split(/\s*[,;|·•/]\s*/)
108
+ .map((p) => p.trim())
109
+ .filter(Boolean);
110
+ if (parts.length < 2) return null;
111
+ // Short fragments with words in them — not a sentence that happens to have commas.
112
+ if (parts.some((p) => p.length > 24 || p.split(/\s+/).length > 3 || /[.!?]/.test(p))) return null;
113
+ if (!parts.some((p) => /\p{L}/u.test(p))) return null;
114
+ return parts;
32
115
  }