@base44/app-plugin-commerce 0.2.1 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/README.md +6 -6
  2. package/base44/agents/commerce/StoreAdmin.jsonc +1 -1
  3. package/base44/entities/commerce.OrderRefund.jsonc +1 -1
  4. package/base44/entities/commerce.PaymentGateway.jsonc +1 -1
  5. package/base44/entities/commerce.Webhook.jsonc +1 -1
  6. package/base44/functions/commerce/admin-products/entry.ts +1 -1
  7. package/base44/functions/commerce/admin-reports/entry.ts +1 -1
  8. package/base44/functions/commerce/payments/entry.ts +2 -2
  9. package/base44/functions/commerce/seed-store/defaults.ts +1 -1
  10. package/base44/functions/commerce/storefront-catalog/entry.ts +1 -1
  11. package/base44/functions/commerce/storefront-checkout/entry.ts +1 -1
  12. package/base44/shared/commerce/card-payment.stripe.ts +29 -9
  13. package/base44/shared/commerce/card-payment.ts +1 -1
  14. package/base44/shared/commerce/payments.ts +2 -2
  15. package/base44/shared/commerce/scan.ts +1 -1
  16. package/base44/shared/commerce/sequence.ts +2 -2
  17. package/package.json +1 -1
  18. package/scripts/install.js +1 -1
  19. package/skills/commerce/SKILL.md +36 -26
  20. package/skills/commerce/docs/api-storefront.md +6 -6
  21. package/skills/commerce/install/01-install.md +2 -2
  22. package/skills/commerce/install/02-storefront.md +355 -99
  23. package/skills/commerce/install/03-data.md +5 -5
  24. package/skills/commerce/references/catalog-rendering.md +6 -6
  25. package/skills/commerce/references/online-payments.md +5 -6
  26. package/skills/commerce/references/reviews.md +5 -5
  27. package/src/commerce/admin/README.md +6 -3
  28. package/src/commerce/admin/layout/AuthGuard.jsx +1 -1
  29. package/src/commerce/admin/pages/products/Reviews.jsx +1 -1
  30. package/src/commerce/admin/pages/settings/InventorySettings.jsx +1 -1
  31. package/src/commerce/admin/pages/settings/PaymentsSettings.jsx +1 -1
  32. package/src/commerce/storefront/index.js +45 -33
  33. package/src/commerce/storefront/useCartLine.js +37 -0
  34. package/src/commerce/storefront/useCheckout.jsx +18 -6
  35. package/src/commerce/storefront/useOrderReturn.js +36 -10
  36. package/src/commerce/storefront/useProduct.js +68 -0
  37. package/src/commerce/utils/index.js +9 -6
  38. package/src/commerce/utils/shipping-promos.js +2 -2
  39. package/src/commerce/utils/specs.js +26 -0
  40. package/src/commerce/utils/variants.js +49 -2
  41. package/src/commerce/storefront/blocks/AddToCartBlock.jsx +0 -86
  42. package/src/commerce/storefront/blocks/AddressFieldsBlock.jsx +0 -96
  43. package/src/commerce/storefront/blocks/BreadcrumbsBlock.jsx +0 -52
  44. package/src/commerce/storefront/blocks/CartLinesBlock.jsx +0 -98
  45. package/src/commerce/storefront/blocks/CheckoutBlock.jsx +0 -247
  46. package/src/commerce/storefront/blocks/CouponFieldBlock.jsx +0 -84
  47. package/src/commerce/storefront/blocks/OrderReceivedBlock.jsx +0 -129
  48. package/src/commerce/storefront/blocks/ProductGalleryBlock.jsx +0 -66
  49. package/src/commerce/storefront/blocks/ProductSpecsBlock.jsx +0 -33
  50. package/src/commerce/storefront/blocks/ProductStripBlock.jsx +0 -55
  51. package/src/commerce/storefront/blocks/QuantityStepper.jsx +0 -62
  52. package/src/commerce/storefront/blocks/ReviewsBlock.jsx +0 -191
  53. package/src/commerce/storefront/blocks/TotalsBlock.jsx +0 -42
  54. package/src/commerce/storefront/blocks/VariantSelectorBlock.jsx +0 -81
  55. package/src/commerce/storefront/blocks/index.js +0 -44
@@ -2,9 +2,10 @@
2
2
  stage: reference
3
3
  read_when: "The store opted into card payments and you are wiring the provider NOW."
4
4
  skip_when: "The store takes offline payments (the default) — nothing here applies; the decision itself lives in ../install/03-data.md."
5
- forget_when: "A test payment reaches /order-received as paid, or the provider file is copied, the secret stored, the webhook registered and the `card` gateway enabled."
5
+ forget_when: "A test payment reaches /order-received as paid, or the provider file is copied and the `card` gateway is enabled."
6
6
  carry_forward:
7
7
  - "Wiring a provider = replace shared/commerce/card-payment.ts WHOLE (copy the shipped card-payment.<provider>.ts over it) — a partial edit breaks every commerce function's deploy with duplicate exports."
8
+ - "Stripe: the shipped card-payment.stripe.ts is used as-is once the app is connected to Stripe — connecting is outside the kit. Enabling the `card` gateway is the seed call."
8
9
  - "Only the provider can say an order is paid: confirmation always goes through checkCardPaymentPaid against the provider's API."
9
10
  ---
10
11
 
@@ -34,11 +35,9 @@ fs.copyFileSync(
34
35
  );
35
36
  ```
36
37
 
37
- Then three steps, in order:
38
+ …and **enable the gateway**: `commerce/seed-store` with `{ payment_methods: ["offline", "card"] }`. The `card` row is seeded off, so that call is what makes card payment visible at checkout — the usual reason a wired provider "doesn't show up".
38
39
 
39
- 1. **Secret** store the merchant's Stripe **secret key** as the `STRIPE_SECRET_KEY` app secret (backend env; never in code, never in an entity) and redeploy the backend functions so they see it. Test keys (`sk_test_…`) work end to end.
40
- 2. **Webhook endpoint** — register `https://<app-domain>/functions/commerce/payment-webhook` with Stripe for `checkout.session.completed` (dashboard, or `POST https://api.stripe.com/v1/webhook_endpoints` with `url` + `enabled_events[]` using the same secret key). **There is no signing secret to store**: the shipped `parseWebhook` treats events as nudges verified against Stripe's API (see below).
41
- 3. **Enable the gateway** — `commerce/seed-store` with `{ payment_methods: ["offline", "card"] }`. It is seeded off, so none of the above is visible at checkout until this runs.
40
+ That is the whole of it. The file expects the app to be connected to Stripe and reads the secret key that connection publishes; **connecting the app is outside this kit and not described here.** Nothing in the file needs filling in, and no key belongs in the code. A missing key is not silent checkout answers `503 no_card_payment_provider`.
42
41
 
43
42
  A kit update re-copies `shared/commerce/` and restores the stub — re-run the copy after updating. A different provider ships the same way (`card-payment.<provider>.ts` beside the stub); until one does, implement the four functions against its API per the rules below, with the Stripe file as the worked model.
44
43
 
@@ -56,7 +55,7 @@ With the gateway enabled and no provider behind it, picking Credit card answers
56
55
  For a **custom** provider (the shipped files already obey all of these):
57
56
 
58
57
  - **Write `card-payment.ts` whole** — one write of the complete file, never a `find_replace` into the stub: a partial patch leaves the original stubs behind and breaks every commerce function's deploy with duplicate-export bundle errors ("Multiple exports with the same name …"). The fix is always the whole-file write — which is also why the shipped provider files exist.
59
- - **Credentials come from backend env** (`Deno.env.get(...)`) — never an entity, never the client — and are read **lazily inside the call**, so a store with the file but no secret yet answers a clean 503 instead of failing to boot every function that imports it. Env vars are injected at deploy time: after adding a secret, redeploy.
58
+ - **Credentials come from backend env** (`Deno.env.get(...)`) — never an entity, never the client — and are read **lazily inside the call**, so a store with the file but no credential yet answers a clean 503 instead of failing to boot every function that imports it.
60
59
  - **Only the provider can say an order is paid.** `checkCardPaymentPaid` must ask the provider's API about the stored `reference` **and** check the payment names this order (its metadata `order_id` vs `order.id`) — that stops a reference to some other, genuinely paid payment being replayed against a different order.
61
60
  - **Attach the metadata.** `createCardPayment` must put `order.id` and `order.order_key` on the payment's metadata (Stripe: `metadata` **and** `payment_intent_data.metadata`) — that echo is how `parseWebhook` names the order, and what the check above compares against.
62
61
  - **Amounts**: `order.total` is in display units (`12.34`) with `order.currency`; convert to the provider's minor units yourself, remembering the zero-decimal currencies.
@@ -1,15 +1,15 @@
1
1
  ---
2
2
  stage: reference
3
- read_when: "You need review behaviour beyond dropping in <ReviewsBlock/> — moderation, auto-approval, a customer's own reviews, or a policy the three built-in ones don't cover."
4
- skip_when: "<ReviewsBlock product={p}/> or useProductReviews is on the product page — that is list + submit + policy + the right confirmation copy already."
3
+ read_when: "You need review behaviour beyond useProductReviews — moderation, 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."
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 block. The confirmation copy must come from the submit response, never hardcoded."
7
+ - "Reviews are part of the happy path: one hook. The confirmation copy must come from the submit response, 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 now the UI does too. Unless the store explicitly doesn't want them, put them on the product page — it is one line: `<ReviewsBlock product={product} />` (plus `policy` / `user` below). For custom markup take the hook it composes, `useProductReviews(product, { policy, user, requireRating, perPage })` — the list, the whole form contract (`form`/`setField`/`fieldErrors`/`valid`/`submit`/`message`) and the policy gate (`canReview`, `reviewBlockedReason`, `requiresEmail`); exact shapes in its JSDoc.
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.
13
13
 
14
14
  ## What ships
15
15
 
@@ -20,7 +20,7 @@ Reviews are **part of the happy path**, not an extra: the backend always shipped
20
20
 
21
21
  ## The `policy` prop
22
22
 
23
- `policy` is the store's review rule as one prop, replacing the patterns a storefront used to implement by hand. Pass your app's current user alongside it (`<ReviewsBlock product={p} policy="login" user={user} />`) — the stricter policies need it:
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:
24
24
 
25
25
  | `policy` | Who may submit | Blocked as |
26
26
  |---|---|---|
@@ -4,8 +4,11 @@ React admin UI for the Base44 commerce template. Copy this
4
4
  folder into a Base44 app built on the default template (Vite + React +
5
5
  Tailwind + shadcn/ui + React Router) to get a full store back office.
6
6
 
7
- > Install, mounting & role setup: [`.agents/skills/commerce/install/01-install.md`](../../../.agents/skills/commerce/install/01-install.md) · architecture & operations: the commerce skill, [`.agents/skills/commerce/SKILL.md`](../../../.agents/skills/commerce/SKILL.md)
8
- > API references: [`.agents/skills/commerce/docs/api-admin.md`](../../../.agents/skills/commerce/docs/api-admin.md), [`.agents/skills/commerce/docs/api-storefront.md`](../../../.agents/skills/commerce/docs/api-storefront.md)
7
+ > Docs live in the commerce skill, and every path below is **relative to that
8
+ > skill folder** (`.agents/skills/commerce/` once installed): install, mounting
9
+ > and role setup are `install/01-install.md`; the map for everything else is
10
+ > `SKILL.md`; the API references are `docs/api-admin.md` and
11
+ > `docs/api-storefront.md`.
9
12
 
10
13
  ## Mounting
11
14
 
@@ -39,7 +42,7 @@ Tailwind + shadcn/ui + React Router) to get a full store back office.
39
42
  sample data when the store has no products yet. If `commerce/seed-store` was
40
43
  already run during installation — including when an agent generated a real
41
44
  catalog — the store counts as ready and this screen never shows; see
42
- `.agents/skills/commerce/install/03-data.md`.
45
+ the commerce skill's install/03-data.md.
43
46
 
44
47
  ## External touchpoints
45
48
 
@@ -24,7 +24,7 @@ const AUTH_CHECK_TIMEOUT_MS = 8000;
24
24
 
25
25
  /**
26
26
  * Blocks the admin UI unless the caller is an authenticated user with
27
- * role === "admin". Do NOT weaken this check — see .agents/skills/commerce/install/01-install.md.
27
+ * role === "admin". Do NOT weaken this check — see the commerce skill's install/01-install.md.
28
28
  * (Server-side RLS + requireAdmin() in functions enforce this independently.)
29
29
  *
30
30
  * Timing out resolves to **no user**, i.e. the sign-in screen — it never grants
@@ -263,7 +263,7 @@ export default function Reviews() {
263
263
  /**
264
264
  * The one server-enforced review setting: everything else (login-gating,
265
265
  * verified-buyers-only, required ratings) is storefront policy — see
266
- * .agents/skills/commerce/references/reviews.md.
266
+ * the commerce skill's references/reviews.md.
267
267
  */
268
268
  function AutoApproveToggle() {
269
269
  const settings = useSettings();
@@ -51,7 +51,7 @@ export default function InventorySettings() {
51
51
  <p className="text-xs text-muted-foreground">
52
52
  Hold stock for unpaid orders for this many minutes. When the limit is reached, the
53
53
  pending order is cancelled and its stock released. Release runs opportunistically —
54
- see .agents/skills/commerce/references/operations.md.
54
+ see the commerce skill's references/operations.md.
55
55
  </p>
56
56
  </div>
57
57
 
@@ -209,7 +209,7 @@ export default function PaymentsSettings() {
209
209
  wiring one means implementing the four functions in
210
210
  shared/commerce/card-payment.ts (rules, and a Stripe
211
211
  implementation to paste, in
212
- .agents/skills/commerce/references/online-payments.md; the
212
+ the commerce skill's references/online-payments.md; the
213
213
  payment webhook is premade). Enabled without that, picking it
214
214
  at checkout answers 503 no_card_payment_provider.
215
215
  Deliberately not shown to the store operator — it's developer
@@ -1,15 +1,20 @@
1
1
  /**
2
- * Storefront React layer — hooks and ready-made blocks for the shopfront you
3
- * build. Ships with the Base44 Commerce Template next to the framework-free
4
- * `@/commerce/utils` (which it builds on); needs React and nothing else.
2
+ * Storefront React layer — **headless**: hooks and render-prop components that
3
+ * own the store's logic and hand you the data; they render nothing and carry
4
+ * no styling. Every element, class and word of copy in the storefront is
5
+ * written by you, against these APIs. Ships with the Base44 Commerce Template
6
+ * next to the framework-free `@/commerce/utils` (which it builds on); needs
7
+ * React and nothing else.
5
8
  *
6
- * **Two tiers.** The *identity* surfaces are yours to design — home, the
7
- * collection grid, the product page's layout, the product card, the theme. The
8
- * *commodity* surfaces ship as working blocks that inherit your theme
9
- * checkout, cart lines, totals, coupon field, reviews, order-received, and the
10
- * product page's internals. Restyle or replace them; never hand-roll their
11
- * logic. Blocks are compositions of the hooks below, so outgrowing one means
12
- * rewriting a single region against an API you already know.
9
+ * The split: **logic is premade, UI never is.** Checkout repricing, variant
10
+ * resolution, cart state, review policies, order-return verification done
11
+ * here, and hand-rolling any of it is where storefront bugs cluster. What a
12
+ * checkout or a product page *looks like* is the store's identity, and no two
13
+ * stores should share it so nothing here emits markup. Each hook returns a
14
+ * complete view-model (statuses to branch on, ready-to-map arrays, handlers,
15
+ * error objects), and each doc comment states the render rules that keep the
16
+ * store correct (e.g. an unbuyable variant option renders *disabled, not
17
+ * hidden*; a receipt page must render `paymentInstructions`).
13
18
  *
14
19
  * Setup (once, above every storefront route — it wraps <Routes>; placed as a
15
20
  * child of <Routes> React Router throws "is not a <Route> component"):
@@ -18,40 +23,47 @@
18
23
  * import { base44 } from "@/api/base44Client";
19
24
  * <StorefrontProvider base44={base44}> <Routes>…</Routes> </StorefrontProvider>
20
25
  *
21
- * ## Blocks (drop in, then restyle)
22
- * `CheckoutBlock` · `CartLinesBlock` · `TotalsBlock` · `CouponFieldBlock` ·
23
- * `QuantityStepper` · `OrderReceivedBlock` · `ReviewsBlock` ·
24
- * `AddressFieldsBlock` · `VariantSelectorBlock` · `AddToCartBlock` ·
25
- * `ProductGalleryBlock` · `ProductSpecsBlock` · `BreadcrumbsBlock` ·
26
- * `ProductStripBlock` (needs your `renderCard` — no default card ships).
27
- *
28
26
  * ## Hooks
29
27
  * - `useStorefront` / `useStoreInfo` / `useFormatMoney` / `useMoney` — the
30
28
  * shared client, cached store info (the ONLY source of payment gateways,
31
29
  * currency and countries), money in the store's currency.
32
30
  * - `useProductList` / `useCategories` / `useRibbons` — a listing with paging,
33
31
  * filters, `refreshing`, and failure as a visible state.
34
- * - `useProduct` / `useAddToCart` / `useProductPrice` / `useProductGallery`
35
- * the product page: fetch + variant selection + quantity + price + gallery,
36
- * race-safe, with `status: "not_found"` and add-to-cart errors handled.
32
+ * - `useProduct` / `useAddToCart` / `useAddToCartButton` / `useProductPrice` /
33
+ * `useProductGallery` — the product page: fetch + variant selection +
34
+ * quantity + price + gallery, race-safe, with `status: "not_found"` and
35
+ * every add-to-cart failure handled. `variantAxes(view, pick)` (from
36
+ * `@/commerce/utils`, re-exported here) turns the resolved view into a
37
+ * render-ready model for the selector you write.
37
38
  * - `useProductReviews` — the review list and the submit form, with the store's
38
- * policy as a prop.
39
+ * policy as a prop and field errors matching the server's codes.
39
40
  * - `useCart` / `useCartLine` / `useCoupon` — the shared cart (branch on
40
41
  * `status`, render `lines` and `notices`), quantity steppers that clamp and
41
42
  * recover, and the coupon field a store with coupons must have.
42
43
  * - `useCheckout` / `CheckoutProvider` / `useCheckoutContext` — the guided
43
44
  * checkout: address state with automatic debounced shipping/tax
44
45
  * recalculation, shipping and payment choice, a `canPlaceOrder` gate with
45
- * named blockers, `placeOrder` with the online-payment redirect handled.
46
+ * named blockers, `placeOrder` with both navigations handled (online
47
+ * provider redirect, manual → `/order-received`).
46
48
  * - `useAddressForm` / `useCountries` / `useTotalsLines` /
47
- * `useCheckoutBlockers` — the address form as fields (state included,
48
- * country options never null), one totals projection for cart and order,
49
- * and blocker codes turned into copy.
50
- * - `ShippingMethodPicker` / `PaymentMethodPicker` headless (render-prop)
51
- * wrappers over the two choices that are store data, never hardcoded.
52
- * - `useOrderReturn` — the mandatory `/order-received` page in one hook.
49
+ * `useCheckoutBlockers` — the address form as a field spec you map to your
50
+ * own inputs (state included, country options never null), one totals
51
+ * projection for cart and order, and blocker codes turned into copy.
52
+ * - `useOrderReturn` the mandatory `/order-received` page in one hook:
53
+ * status, order, `lines`, `paymentLink`, `paymentInstructions`, noindex.
53
54
  * - `useStorefrontSeo` + `productSeo` / `collectionSeo` / `orderSeo` — titles,
54
55
  * meta and product structured data; receipts are `noindex`.
56
+ *
57
+ * ## Render-prop components (headless — children is a function, no markup ships)
58
+ * - `ShippingMethodPicker` / `PaymentMethodPicker` — the two checkout choices
59
+ * that are store data, never hardcoded, with their branching enumerated.
60
+ * - `CartLine` — per-line `useCartLine` binding for your cart rows, so a
61
+ * `lines.map(...)` never calls a hook in a loop.
62
+ *
63
+ * ## Helpers re-exported from `@/commerce/utils`
64
+ * - `variantAxes(view, pick)` — axes → options with selected/disabled/stock
65
+ * state derived, for the variant selector you write.
66
+ * - `productSpecs(product)` — `meta_data` → spec-table rows.
55
67
  */
56
68
  export {
57
69
  StorefrontProvider,
@@ -62,7 +74,7 @@ export {
62
74
  useCart,
63
75
  } from "./StorefrontProvider";
64
76
  export { useCheckout, CheckoutProvider, useCheckoutContext } from "./useCheckout";
65
- export { useOrderReturn } from "./useOrderReturn";
77
+ export { useOrderReturn, orderReceivedUrl } from "./useOrderReturn";
66
78
  export { ShippingMethodPicker, PaymentMethodPicker } from "./pickers";
67
79
  export {
68
80
  REQUIRED_BILLING_FIELDS,
@@ -73,18 +85,18 @@ export {
73
85
 
74
86
  // ── catalog ────────────────────────────────────────────────────────────────
75
87
  export { useProductList, useCategories, useRibbons } from "./useProductList";
76
- export { useProduct, useAddToCart } from "./useProduct";
88
+ export { useProduct, useAddToCart, useAddToCartButton } from "./useProduct";
77
89
  export { useProductPrice, useMoney } from "./useProductPrice";
78
90
  export { useProductGallery } from "./useProductGallery";
79
91
  export { useProductReviews } from "./useProductReviews";
80
92
 
81
93
  // ── cart & checkout ────────────────────────────────────────────────────────
82
- export { useCartLine, useCoupon } from "./useCartLine";
94
+ export { useCartLine, useCoupon, CartLine } from "./useCartLine";
83
95
  export { useAddressForm, useCountries } from "./useAddressForm";
84
96
  export { useTotalsLines, useCheckoutBlockers, blockerMessage } from "./useTotalsLines";
85
97
 
86
98
  // ── SEO ────────────────────────────────────────────────────────────────────
87
99
  export { useStorefrontSeo, productSeo, collectionSeo, orderSeo } from "./useStorefrontSeo";
88
100
 
89
- // ── blocks (default markup for the commodity surfaces) ─────────────────────
90
- export * from "./blocks";
101
+ // ── view-model helpers (framework-free, from @/commerce/utils) ─────────────
102
+ export { variantAxes, productSpecs } from "@/commerce/utils";
@@ -107,6 +107,43 @@ export function useCartLine(line, { debounceMs = 250 } = {}) {
107
107
  };
108
108
  }
109
109
 
110
+ /**
111
+ * CartLine — headless per-line binding for the rows of a cart you render
112
+ * yourself. It renders **nothing**: the render function you pass as `children`
113
+ * receives the `useCartLine` controls for that line and returns your markup.
114
+ * It exists so a `lines.map(...)` doesn't tempt a hook call inside a loop:
115
+ *
116
+ * const { lines } = useCart();
117
+ * {lines.map(line => (
118
+ * <CartLine key={line.item_key} line={line}>
119
+ * {(l) => (
120
+ * <li>
121
+ * {line.name} {line.attributesLabel}
122
+ * <button onClick={l.decrease} disabled={!l.canDecrease || l.pending}>−</button>
123
+ * {l.quantity}
124
+ * <button onClick={l.increase} disabled={!l.canIncrease || l.pending}>+</button>
125
+ * <button onClick={l.remove}>Remove</button>
126
+ * {l.error && <p role="alert">{l.error.message}</p>}
127
+ * </li>
128
+ * )}
129
+ * </CartLine>
130
+ * ))}
131
+ *
132
+ * Equivalent to extracting your own row component that calls `useCartLine` —
133
+ * use whichever reads better in your page.
134
+ *
135
+ * @param {{line: object, options?: {debounceMs?: number},
136
+ * children: (controls: object) => React.ReactNode}} props
137
+ */
138
+ export function CartLine({ line, options, children }) {
139
+ const controls = useCartLine(line, options);
140
+ if (typeof children !== "function") {
141
+ throw new Error("<CartLine> is headless: pass a render function as its only child.");
142
+ }
143
+ if (!line) return null;
144
+ return children(controls);
145
+ }
146
+
110
147
  /**
111
148
  * useCoupon — the coupon field. Small, and the difference between a store that
112
149
  * can honour its own discounts and one that cannot.
@@ -1,6 +1,7 @@
1
1
  import React, { createContext, useCallback, useContext, useEffect, useState } from "react";
2
2
  import { storefrontErrorCode, storefrontErrorMessage } from "@/commerce/utils";
3
3
  import { useStorefrontState } from "./StorefrontProvider";
4
+ import { orderReceivedUrl } from "./useOrderReturn";
4
5
  import {
5
6
  REQUIRED_BILLING_FIELDS,
6
7
  isShippingAddressComplete,
@@ -62,10 +63,15 @@ function resolvePaymentMethod(gateways, picked) {
62
63
  * - **The gate.** `canPlaceOrder` + `blockers` say exactly what still stands
63
64
  * between the customer and the order — drive the button's disabled state
64
65
  * and the "what's missing" hints from them instead of re-deriving.
65
- * - **placeOrder.** Sends the order, clears the shared cart, and (by default)
66
- * redirects to the provider's payment page when the gateway is online.
67
- * Resolves to `{ ok: true, result }` or `{ ok: false, error }`; a manual-
68
- * gateway result carries `result.payment_instructions` to render.
66
+ * - **placeOrder.** Sends the order, clears the shared cart, and navigates:
67
+ * an online gateway redirects to the provider's payment page
68
+ * (`redirectToPayment`), everything else lands on the order-received page
69
+ * (`orderReceivedPath`, default `/order-received`) which is where a manual
70
+ * order's payment instructions are rendered, so the offline default confirms
71
+ * properly with no extra wiring. Resolves to `{ ok: true, result }` or
72
+ * `{ ok: false, error }`. Pass `orderReceivedPath: null` to handle the
73
+ * result yourself (a manual-gateway result carries
74
+ * `result.payment_instructions`).
69
75
  *
70
76
  * Blocker codes, in the order checked: `cart_loading`, `empty_cart`,
71
77
  * `billing_incomplete`, `shipping_address_incomplete`, `shipping_recalculating`,
@@ -73,7 +79,8 @@ function resolvePaymentMethod(gateways, picked) {
73
79
  * `shipping_not_available`, `payment_method_required`.
74
80
  *
75
81
  * Options: `debounceMs` (600), `addressComplete` (predicate overriding the
76
- * country+city rule), `requiredBillingFields`, `redirectToPayment` (true).
82
+ * country+city rule), `requiredBillingFields`, `redirectToPayment` (true),
83
+ * `orderReceivedPath` ("/order-received"; null disables the navigation).
77
84
  */
78
85
  export function useCheckout(options = {}) {
79
86
  const {
@@ -81,6 +88,7 @@ export function useCheckout(options = {}) {
81
88
  addressComplete = isShippingAddressComplete,
82
89
  requiredBillingFields = REQUIRED_BILLING_FIELDS,
83
90
  redirectToPayment = true,
91
+ orderReceivedPath = "/order-received",
84
92
  } = options;
85
93
 
86
94
  const { client, info, cart, runCart, clearCart } = useStorefrontState();
@@ -204,6 +212,10 @@ export function useCheckout(options = {}) {
204
212
  typeof window !== "undefined"
205
213
  ) {
206
214
  window.location.assign(result.payment.checkout_url);
215
+ } else if (orderReceivedPath && !result.payment && typeof window !== "undefined") {
216
+ // A manual/offline order settles nothing online — land it on the
217
+ // order-received page, where its payment instructions are rendered.
218
+ window.location.assign(orderReceivedUrl(result, orderReceivedPath));
207
219
  }
208
220
  return { ok: true, result };
209
221
  } catch (e) {
@@ -224,7 +236,7 @@ export function useCheckout(options = {}) {
224
236
  setPlacing(false);
225
237
  }
226
238
  },
227
- [placing, client, paymentMethod, billing, shipToDifferent, shipping, clearCart, redirectToPayment, runCart],
239
+ [placing, client, paymentMethod, billing, shipToDifferent, shipping, clearCart, redirectToPayment, orderReceivedPath, runCart],
228
240
  );
229
241
 
230
242
  return {
@@ -1,29 +1,36 @@
1
1
  import { useCallback, useEffect, useState } from "react";
2
- import { storefrontErrorCode, storefrontErrorMessage } from "@/commerce/utils";
2
+ import { orderLines, storefrontErrorCode, storefrontErrorMessage } from "@/commerce/utils";
3
3
  import { useStorefrontState } from "./StorefrontProvider";
4
+ import { orderSeo, useStorefrontSeo } from "./useStorefrontSeo";
4
5
 
5
6
  /**
6
7
  * useOrderReturn — the whole `/order-received` page in one hook. Mount the
7
8
  * route (it is mandatory — every payment link returns here) and branch on
8
- * `status`:
9
+ * `status`; the markup for every state is yours to write:
9
10
  *
10
- * const { status, order, paymentLink, paymentInstructions, error, reload } =
11
- * useOrderReturn();
12
- * // "loading" → spinner
13
- * // "paid" → thank-you + order summary (the order is now marked paid)
14
- * // "unpaid" → card order: offer paymentLink.url to pay now;
11
+ * const { status, order, lines, paymentLink, paymentInstructions, error,
12
+ * reload } = useOrderReturn();
13
+ * // "loading" → confirming copy (never an empty page)
14
+ * // "paid" → thank-you + order number + summary (the order is now paid)
15
+ * // "unpaid" → card order: link paymentLink.url to pay now;
15
16
  * // manual order: render paymentInstructions
16
- * // ({ description, account_details })
17
+ * // ({ description, account_details }) — these ARE how the
18
+ * // customer pays; a page that drops them strands the order
17
19
  * // "cancelled" → payment was cancelled — offer paymentLink.url or support
18
20
  * // "error" → render error.message with a retry via reload()
19
21
  *
22
+ * `lines` are the order's items in the decorated cart-line shape, so the same
23
+ * row markup renders the bag and the confirmation; totals come from
24
+ * `useTotalsLines(order)`. The page is marked `noindex` automatically — a
25
+ * receipt carrying an order key must not rank (`seo: false` opts out).
26
+ *
20
27
  * It reads `order_id`/`order_key`/`payment` from the URL itself and verifies
21
28
  * with the payment provider server-side — safe and idempotent on every visit.
22
29
  *
23
30
  * `order` carries FLAT totals — `order.total`, `order.shipping_total`,
24
31
  * `order.total_tax`; there is no `order.totals` object on it.
25
32
  */
26
- export function useOrderReturn({ auto = true } = {}) {
33
+ export function useOrderReturn({ auto = true, seo = true } = {}) {
27
34
  const { client } = useStorefrontState();
28
35
  const [result, setResult] = useState({ status: auto ? "loading" : "idle" });
29
36
 
@@ -52,5 +59,24 @@ export function useOrderReturn({ auto = true } = {}) {
52
59
  if (auto) reload();
53
60
  }, [auto, reload]);
54
61
 
55
- return { ...result, reload };
62
+ useStorefrontSeo(seo ? orderSeo(result.order ?? null) : null);
63
+
64
+ return { ...result, lines: orderLines(result.order ?? null), reload };
65
+ }
66
+
67
+ /**
68
+ * The URL a just-placed manual/offline order should land on: the order-received
69
+ * page, carrying the id + key `useOrderReturn` reads back. `useCheckout` builds
70
+ * this itself by default (`orderReceivedPath`); use it directly only in a
71
+ * custom `placeOrder` flow.
72
+ *
73
+ * @param {{order_id: string, order_key: string}} result from `placeOrder`
74
+ * @param {string} [path]
75
+ */
76
+ export function orderReceivedUrl(result, path = "/order-received") {
77
+ const q = new URLSearchParams({
78
+ order_id: result?.order_id ?? "",
79
+ order_key: result?.order_key ?? "",
80
+ });
81
+ return `${path}?${q}`;
56
82
  }
@@ -225,3 +225,71 @@ export function useAddToCart() {
225
225
 
226
226
  return { add, adding, error, lastAdded, reset };
227
227
  }
228
+
229
+ /**
230
+ * useAddToCartButton — the buy button's whole state machine, ready to bind to
231
+ * markup you write. Pass the entire `useProduct` result:
232
+ *
233
+ * const p = useProduct(slug);
234
+ * const buy = useAddToCartButton(p, { onAdded: () => navigate("/bag") });
235
+ * <button disabled={buy.disabled} onClick={buy.add}>
236
+ * {buy.adding ? "Adding…" : buy.soldOut ? "Sold out"
237
+ * : buy.needsSelection ? "Select options" : "Add to bag"}
238
+ * </button>
239
+ * {buy.error && <p role="alert">{buy.error.message}</p>}
240
+ *
241
+ * What it wires so a hand-written buy box can't drop it: the button is gated on
242
+ * `view.purchasable`; a rejected add (sold out, stale variant) lands in `error`
243
+ * instead of leaving the button stuck on "Adding…"; a stale-variant rejection
244
+ * reloads the product; and the quantity controls respect `sold_individually`
245
+ * and tracked stock (`showQuantity` is false when only 1 can be bought — render
246
+ * no stepper then). Every label and every element is yours.
247
+ *
248
+ * @param {object} product the whole `useProduct` result
249
+ * @param {{onAdded?: (cart: object) => void}} [options]
250
+ * @returns {{add: () => Promise<object>, adding: boolean, error: object|null,
251
+ * reset: () => void, disabled: boolean, soldOut: boolean,
252
+ * needsSelection: boolean, purchasable: boolean,
253
+ * quantity: number, setQuantity: (n: number) => void, increase: () => void,
254
+ * decrease: () => void, canIncrease: boolean, canDecrease: boolean,
255
+ * maxQuantity: number, showQuantity: boolean}}
256
+ */
257
+ export function useAddToCartButton(product, { onAdded } = {}) {
258
+ const { add, adding, error, reset } = useAddToCart();
259
+ const view = product?.view ?? null;
260
+
261
+ const submit = useCallback(async () => {
262
+ if (!view) return { ok: false, error: { code: "no_product", message: "Product not loaded." } };
263
+ const res = await add(view.addToCart, product.quantity);
264
+ if (res.ok) onAdded?.(res.cart);
265
+ else if (res.error?.shouldReload) product.reload?.();
266
+ return res;
267
+ }, [add, view, product, onAdded]);
268
+
269
+ // A variable product with an incomplete selection isn't sold out — it needs
270
+ // a pick; only a resolved (or simple) unpurchasable view reads as sold out.
271
+ const soldOut = view
272
+ ? view.isVariable
273
+ ? view.complete && !view.purchasable
274
+ : !view.purchasable
275
+ : false;
276
+
277
+ return {
278
+ add: submit,
279
+ adding,
280
+ error,
281
+ reset,
282
+ disabled: !view?.purchasable || adding,
283
+ soldOut,
284
+ needsSelection: Boolean(view?.isVariable && !view.complete),
285
+ purchasable: Boolean(view?.purchasable),
286
+ quantity: product?.quantity ?? 1,
287
+ setQuantity: product?.setQuantity ?? (() => {}),
288
+ increase: product?.incQuantity ?? (() => {}),
289
+ decrease: product?.decQuantity ?? (() => {}),
290
+ canIncrease: Boolean(product?.canIncrease),
291
+ canDecrease: (product?.quantity ?? 1) > 1,
292
+ maxQuantity: product?.maxQuantity ?? 1,
293
+ showQuantity: (product?.maxQuantity ?? 1) > 1,
294
+ };
295
+ }
@@ -13,10 +13,10 @@
13
13
  * checkout/return-page calls. Create ONE instance and import it everywhere.
14
14
  * - `variants.js` — variant selection: map attribute selections (Size, Color)
15
15
  * to a `ProductVariation` and back, per-option availability, variant price
16
- * ranges. See `.agents/skills/commerce/references/catalog-rendering.md`.
16
+ * ranges. See the commerce skill's references/catalog-rendering.md.
17
17
  * - `shipping-promos.js` — read the store's real free-shipping configuration so
18
18
  * "Free shipping over €150" copy states a configured rule, not an invented
19
- * number. See `.agents/skills/commerce/docs/api-storefront.md`.
19
+ * number. See the commerce skill's docs/api-storefront.md.
20
20
  * - `price.js` — `productPrice`: the from-price and price-range rules, encoded
21
21
  * once so a card and a product page cannot disagree.
22
22
  * - `totals.js` — `cartTotalsLines` / `orderTotalsLines` / `orderLines` /
@@ -25,12 +25,14 @@
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` → spec-table rows.
28
29
  *
29
30
  * Building the storefront in React? **Prefer `@/commerce/storefront`** — it
30
- * layers hooks and ready-made UI blocks on top of this module, and a hook that
31
- * pre-composes these helpers is the difference between a rule that holds and a
32
- * rule you have to remember. Use this module directly for non-React code, and
33
- * inside your own custom logic.
31
+ * layers headless hooks on top of this module, and a hook that pre-composes
32
+ * these helpers is the difference between a rule that holds and a rule you
33
+ * have to remember. Neither layer ships any UI: all markup and styling belong
34
+ * to the storefront you build. Use this module directly for non-React code,
35
+ * and inside your own custom logic.
34
36
  */
35
37
  export * from "./storefront.js";
36
38
  export * from "./variants.js";
@@ -39,3 +41,4 @@ export * from "./price.js";
39
41
  export * from "./totals.js";
40
42
  export * from "./address-spec.js";
41
43
  export * from "./images.js";
44
+ export * from "./specs.js";
@@ -23,8 +23,8 @@
23
23
  * `available_shipping_methods` after `set-shipping-address` — that is computed
24
24
  * by the same engine and needs no extra exposure.
25
25
  *
26
- * Framework-free and dependency-free. See
27
- * `.agents/skills/commerce/docs/api-storefront.md` for the surrounding rules.
26
+ * Framework-free and dependency-free. See the commerce skill's
27
+ * docs/api-storefront.md for the surrounding rules.
28
28
  */
29
29
 
30
30
  /**
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Product spec rows — the descriptive properties a product page lists in a
3
+ * table (Material, Care, Fit).
4
+ *
5
+ * These live in `product.meta_data` (the admin's *Modifiers* section) and are
6
+ * **not** attributes and not ribbons: they describe the product, they don't
7
+ * select a variant. Hidden keys (leading `_`) and empty values are skipped.
8
+ * You render the rows yourself:
9
+ *
10
+ * const specs = productSpecs(product);
11
+ * {specs.length > 0 && <dl>{specs.map(s =>
12
+ * <div key={s.key}><dt>{s.label}</dt><dd>{s.value}</dd></div>)}</dl>}
13
+ *
14
+ * @param {object} product
15
+ * @returns {Array<{key: string, label: string, value: string}>} `[]` when the
16
+ * product has no visible meta_data — render nothing, not an empty section.
17
+ */
18
+ export function productSpecs(product) {
19
+ return (product?.meta_data ?? [])
20
+ .filter((m) => m?.key && !String(m.key).startsWith("_") && m.value != null && m.value !== "")
21
+ .map((m) => ({
22
+ key: String(m.key),
23
+ label: String(m.key).replace(/_/g, " "),
24
+ value: String(m.value),
25
+ }));
26
+ }