@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.
- package/README.md +6 -6
- package/base44/agents/commerce/StoreAdmin.jsonc +1 -1
- package/base44/entities/commerce.OrderRefund.jsonc +1 -1
- package/base44/entities/commerce.PaymentGateway.jsonc +1 -1
- package/base44/entities/commerce.Webhook.jsonc +1 -1
- package/base44/functions/commerce/admin-products/entry.ts +1 -1
- package/base44/functions/commerce/admin-reports/entry.ts +1 -1
- package/base44/functions/commerce/payments/entry.ts +2 -2
- package/base44/functions/commerce/seed-store/defaults.ts +1 -1
- package/base44/functions/commerce/storefront-catalog/entry.ts +1 -1
- package/base44/functions/commerce/storefront-checkout/entry.ts +1 -1
- package/base44/shared/commerce/card-payment.stripe.ts +29 -9
- package/base44/shared/commerce/card-payment.ts +1 -1
- package/base44/shared/commerce/payments.ts +2 -2
- package/base44/shared/commerce/scan.ts +1 -1
- package/base44/shared/commerce/sequence.ts +2 -2
- package/package.json +1 -1
- package/scripts/install.js +1 -1
- package/skills/commerce/SKILL.md +36 -26
- package/skills/commerce/docs/api-storefront.md +6 -6
- package/skills/commerce/install/01-install.md +2 -2
- package/skills/commerce/install/02-storefront.md +355 -99
- package/skills/commerce/install/03-data.md +5 -5
- package/skills/commerce/references/catalog-rendering.md +6 -6
- package/skills/commerce/references/online-payments.md +5 -6
- package/skills/commerce/references/reviews.md +5 -5
- package/src/commerce/admin/README.md +6 -3
- package/src/commerce/admin/layout/AuthGuard.jsx +1 -1
- package/src/commerce/admin/pages/products/Reviews.jsx +1 -1
- package/src/commerce/admin/pages/settings/InventorySettings.jsx +1 -1
- package/src/commerce/admin/pages/settings/PaymentsSettings.jsx +1 -1
- package/src/commerce/storefront/index.js +45 -33
- package/src/commerce/storefront/useCartLine.js +37 -0
- package/src/commerce/storefront/useCheckout.jsx +18 -6
- package/src/commerce/storefront/useOrderReturn.js +36 -10
- package/src/commerce/storefront/useProduct.js +68 -0
- package/src/commerce/utils/index.js +9 -6
- package/src/commerce/utils/shipping-promos.js +2 -2
- package/src/commerce/utils/specs.js +26 -0
- package/src/commerce/utils/variants.js +49 -2
- package/src/commerce/storefront/blocks/AddToCartBlock.jsx +0 -86
- package/src/commerce/storefront/blocks/AddressFieldsBlock.jsx +0 -96
- package/src/commerce/storefront/blocks/BreadcrumbsBlock.jsx +0 -52
- package/src/commerce/storefront/blocks/CartLinesBlock.jsx +0 -98
- package/src/commerce/storefront/blocks/CheckoutBlock.jsx +0 -247
- package/src/commerce/storefront/blocks/CouponFieldBlock.jsx +0 -84
- package/src/commerce/storefront/blocks/OrderReceivedBlock.jsx +0 -129
- package/src/commerce/storefront/blocks/ProductGalleryBlock.jsx +0 -66
- package/src/commerce/storefront/blocks/ProductSpecsBlock.jsx +0 -33
- package/src/commerce/storefront/blocks/ProductStripBlock.jsx +0 -55
- package/src/commerce/storefront/blocks/QuantityStepper.jsx +0 -62
- package/src/commerce/storefront/blocks/ReviewsBlock.jsx +0 -191
- package/src/commerce/storefront/blocks/TotalsBlock.jsx +0 -42
- package/src/commerce/storefront/blocks/VariantSelectorBlock.jsx +0 -81
- 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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
4
|
-
skip_when: "
|
|
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
|
|
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
|
|
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
|
|
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
|
-
>
|
|
8
|
-
>
|
|
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
|
-
|
|
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
|
|
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
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
|
3
|
-
*
|
|
4
|
-
*
|
|
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
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* checkout
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
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` / `
|
|
35
|
-
* the product page: fetch + variant selection +
|
|
36
|
-
* race-safe, with `status: "not_found"` and
|
|
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
|
|
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
|
|
48
|
-
* country options never null), one totals
|
|
49
|
-
* and blocker codes turned into copy.
|
|
50
|
-
* - `
|
|
51
|
-
*
|
|
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
|
-
// ──
|
|
90
|
-
export
|
|
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
|
|
66
|
-
* redirects to the provider's payment page
|
|
67
|
-
*
|
|
68
|
-
*
|
|
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,
|
|
11
|
-
* useOrderReturn();
|
|
12
|
-
* // "loading" →
|
|
13
|
-
* // "paid" → thank-you + order summary (the order is now
|
|
14
|
-
* // "unpaid" → card order:
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
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
|
-
*
|
|
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
|
+
}
|