@base44/app-plugin-commerce 0.2.7 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -4
- package/package.json +2 -2
- package/scripts/install.js +15 -0
- package/skills/commerce/SKILL.md +26 -12
- package/skills/commerce/docs/api-admin.md +1 -1
- package/skills/commerce/docs/api-storefront.md +12 -12
- package/skills/commerce/install/01-install.md +5 -2
- package/skills/commerce/install/02-storefront.md +167 -275
- package/skills/commerce/install/03-data.md +1 -1
- package/skills/commerce/references/catalog-rendering.md +37 -43
- package/skills/commerce/references/reviews.md +21 -14
- package/skills/commerce/references/store-settings.md +1 -1
- package/skills/commerce/references/storefront-verification.md +21 -15
- package/src/commerce/storefront/StorefrontProvider.jsx +65 -128
- package/src/commerce/storefront/cartUI.jsx +11 -30
- package/src/commerce/storefront/index.js +61 -98
- package/src/commerce/storefront/pickers.jsx +50 -64
- package/src/commerce/storefront/useCartLine.js +23 -130
- package/src/commerce/storefront/useCheckout.jsx +50 -43
- package/src/commerce/storefront/useOrderReturn.js +17 -7
- package/src/commerce/storefront/useProduct.js +41 -97
- package/src/commerce/storefront/useProductList.js +14 -22
- package/src/commerce/utils/address-spec.js +1 -1
- package/src/commerce/utils/images.js +1 -1
- package/src/commerce/utils/index.js +9 -9
- package/src/commerce/utils/price.js +2 -1
- package/src/commerce/utils/specs.js +41 -91
- package/src/commerce/utils/totals.js +7 -4
- package/src/commerce/storefront/useAddressForm.js +0 -166
- package/src/commerce/storefront/usePlaceOrder.js +0 -63
- package/src/commerce/storefront/useProductGallery.js +0 -78
- package/src/commerce/storefront/useProductPrice.js +0 -58
- package/src/commerce/storefront/useProductReviews.js +0 -242
- package/src/commerce/storefront/useStorefrontSeo.js +0 -204
- package/src/commerce/storefront/useTotalsLines.js +0 -109
- package/src/commerce/storefront/useUpsell.js +0 -90
|
@@ -5,39 +5,20 @@ import { useLocation } from "react-router-dom";
|
|
|
5
5
|
* Cart drawer/panel state, headless: `open` plus the handlers to change it,
|
|
6
6
|
* and the two behaviors every drawer needs but a hand-written one forgets —
|
|
7
7
|
* it closes when the route changes (`closeOnNavigate`) and it opens when an
|
|
8
|
-
* item lands in the cart (`openOnAdd`, wired through `useAddToCart
|
|
9
|
-
*
|
|
10
|
-
*
|
|
8
|
+
* item lands in the cart (`openOnAdd`, wired through `useAddToCart`; pass false
|
|
9
|
+
* for a navigate-to-bag flow). Esc closes it. You own every element, class and
|
|
10
|
+
* attribute.
|
|
11
11
|
*
|
|
12
12
|
* Mount `<CartUIProvider>` once, inside `<StorefrontProvider>`, around the
|
|
13
|
-
* layout
|
|
13
|
+
* layout; the layout then renders the panel off `open`.
|
|
14
14
|
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
* {ui.open && (<>
|
|
23
|
-
* <div onClick={ui.closeCart} aria-hidden="true" className="…" />
|
|
24
|
-
* <aside role="dialog" aria-modal="true" aria-label="Cart" className="…">
|
|
25
|
-
* <button type="button" onClick={ui.closeCart} aria-label="Close cart">×</button>
|
|
26
|
-
* {…your cart rows: useCart + CartLine…}
|
|
27
|
-
* </aside>
|
|
28
|
-
* </>)}
|
|
29
|
-
* </>);
|
|
30
|
-
* }
|
|
31
|
-
*
|
|
32
|
-
* ⚑ **Render the drawer conditionally (`{ui.open && …}`), as above.** The
|
|
33
|
-
* classic drawer bug is a panel that is translated off-screen but still
|
|
34
|
-
* mounted: its buttons stay clickable, tab-able and visible to screen readers.
|
|
35
|
-
* Unmounting it when closed is the trivial fix. If you keep it mounted to
|
|
36
|
-
* animate the slide, that concern is yours again — set the `inert` attribute
|
|
37
|
-
* while closed.
|
|
38
|
-
*
|
|
39
|
-
* The overlay is a click-away surface (`aria-hidden`, no tab stop needed) —
|
|
40
|
-
* it is not the close control; a named close button inside the panel is.
|
|
15
|
+
* ⚑ **Render the drawer conditionally — `{ui.open && …}`.** The classic drawer
|
|
16
|
+
* bug is a panel translated off-screen but still mounted: its buttons stay
|
|
17
|
+
* clickable, tab-able and visible to screen readers. Unmounting when closed is
|
|
18
|
+
* the trivial fix; if you keep it mounted to animate the slide, that concern is
|
|
19
|
+
* yours again — set the `inert` attribute while closed. The overlay is a
|
|
20
|
+
* click-away surface (`aria-hidden`, no tab stop) — it is not the close control;
|
|
21
|
+
* a named close button inside the panel is.
|
|
41
22
|
*
|
|
42
23
|
* `useCartUI()` → `{ open, openCart, closeCart, toggleCart }`.
|
|
43
24
|
* `useCartUIOptional()` returns null instead of throwing (how `useAddToCart`
|
|
@@ -1,97 +1,56 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Storefront React layer — **headless**: hooks and render-prop components that
|
|
3
|
-
* own the store's logic and hand you the data
|
|
4
|
-
* no
|
|
5
|
-
* written by you, against these APIs. Ships with the Base44
|
|
6
|
-
* next to the framework-free `@/commerce/utils` (which it
|
|
7
|
-
* React and nothing else.
|
|
3
|
+
* own the store's logic and hand you the data. They render nothing, carry no
|
|
4
|
+
* styling, and ship no customer-facing copy — every element, class and word in
|
|
5
|
+
* the storefront is written by you, against these APIs. Ships with the Base44
|
|
6
|
+
* Commerce Plugin next to the framework-free `@/commerce/utils` (which it
|
|
7
|
+
* builds on); needs React and nothing else.
|
|
8
8
|
*
|
|
9
9
|
* The split: **logic is premade, UI never is.** Checkout repricing, variant
|
|
10
|
-
* resolution, cart state,
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
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`).
|
|
10
|
+
* resolution, cart state, order-return verification — done here, and
|
|
11
|
+
* hand-rolling any of it is where storefront bugs cluster. What a checkout or a
|
|
12
|
+
* product page *looks like* is the store's identity, and no two stores should
|
|
13
|
+
* share it — so nothing here emits markup, and every state arrives as a **code**
|
|
14
|
+
* (`state`, `status`, `hint.code`, `blockers`) that you write the words for.
|
|
18
15
|
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
16
|
+
* `StorefrontProvider` mounts once, above every storefront route (see its own
|
|
17
|
+
* doc comment — getting the mounting wrong is the one setup error worth
|
|
18
|
+
* knowing). Each hook's doc comment is its contract; the map:
|
|
22
19
|
*
|
|
23
|
-
*
|
|
24
|
-
* import { base44 } from "@/api/base44Client";
|
|
25
|
-
* <Route element={<StorefrontProvider base44={base44}><StoreLayout /></StorefrontProvider>}>
|
|
26
|
-
* <Route path="/" element={<Home />} /> …
|
|
27
|
-
* </Route>
|
|
28
|
-
* // no shared layout? <StorefrontProvider …> <Routes>…</Routes> </StorefrontProvider>
|
|
29
|
-
*
|
|
30
|
-
* Every hook hands back **plain states and handlers** — strings, booleans,
|
|
31
|
-
* arrays, callbacks (`buy.label`, `buy.disabled`, `buy.addToCart`, `f.value`,
|
|
32
|
-
* `f.set`) — never ready-made prop objects to spread. You write every element
|
|
33
|
-
* and every attribute; the hook guarantees the values are right.
|
|
34
|
-
*
|
|
35
|
-
* ## Hooks
|
|
36
|
-
* - `useStorefront` / `useStoreInfo` / `useFormatMoney` / `useMoney` — the
|
|
20
|
+
* - `useStorefront` / `useStoreInfo` / `useFormatMoney` / `useCountries` — the
|
|
37
21
|
* shared client, cached store info (the ONLY source of payment gateways,
|
|
38
|
-
* currency and countries), money in the store's currency
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
* - `useProduct` / `useAddToCart`
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
* a render-ready model for the selector you write; `useProductSpecs` adds
|
|
49
|
-
* normalized `pick`/`get` lookup over `productSpecs`.
|
|
50
|
-
* - `useProductReviews` — the review list and the submit form, with the store's
|
|
51
|
-
* policy as a prop and field errors matching the server's codes.
|
|
52
|
-
* - `useCart` / `useCartLine` / `useCoupon` — the shared cart (branch on
|
|
53
|
-
* `status`, render `lines` and `notices` — money pre-formatted per line),
|
|
54
|
-
* quantity steppers that clamp and recover, and the coupon field a store
|
|
55
|
-
* with coupons must have.
|
|
56
|
-
* - `useCartUI` + `<CartUIProvider>` — the cart drawer's non-visual machinery:
|
|
57
|
-
* `open` + open/close/toggle handlers, Esc, close-on-navigate, open-on-add;
|
|
58
|
-
* render the drawer conditionally off `open`.
|
|
22
|
+
* currency and countries), money in the store's currency, and the country
|
|
23
|
+
* list as an array that is never null.
|
|
24
|
+
* - `useProductList` / `useCategories` / `useRibbons` — a listing with paging,
|
|
25
|
+
* the whole server-side filter surface, and failure as a visible state.
|
|
26
|
+
* - `useProduct` / `useAddToCart` — the product page: fetch + variant selection
|
|
27
|
+
* + quantity, race-safe; the buy box as `state`/`disabled`/`addToCart`.
|
|
28
|
+
* - `useCart` / `useCartLine` / `CartLine` — the shared cart (branch on
|
|
29
|
+
* `status`, map `cart.items` into your own rows) plus quantity steppers that
|
|
30
|
+
* clamp, coalesce rapid clicks and roll back on rejection.
|
|
31
|
+
* - `useCartUI` + `<CartUIProvider>` — a cart drawer's non-visual state.
|
|
59
32
|
* - `useCheckout` / `CheckoutProvider` / `useCheckoutContext` — the guided
|
|
60
|
-
* checkout:
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
* `label`, `stage` (guards the just-placed-order frames), `blockers` in
|
|
66
|
-
* words.
|
|
67
|
-
* - `useAddressForm` / `useCountries` / `useTotalsLines` /
|
|
68
|
-
* `useCheckoutBlockers` — the address form as a field spec, each field
|
|
69
|
-
* self-contained (`id`/`value`/`set`/`error`/`autoComplete`, `state`
|
|
70
|
-
* included, country options never null), one totals projection for cart
|
|
71
|
-
* and order, and blocker codes turned into copy.
|
|
72
|
-
* - `useUpsell` — one product offered beside another surface: fetch-or-row,
|
|
73
|
-
* already-in-cart matched by id, one-click add.
|
|
74
|
-
* - `useOrderReturn` — the mandatory `/order-received` page in one hook:
|
|
75
|
-
* status, order, `lines`, `paymentLink`, `paymentInstructions`, noindex.
|
|
76
|
-
* - `useStorefrontSeo` + `productSeo` / `collectionSeo` / `orderSeo` — titles,
|
|
77
|
-
* meta and product structured data; receipts are `noindex`.
|
|
33
|
+
* checkout: automatic shipping/tax recalculation, shipping and payment
|
|
34
|
+
* choice, a `canPlaceOrder` gate with named blockers, `placeOrder`.
|
|
35
|
+
* - `useOrderReturn` — the mandatory `/order-received` page in one hook.
|
|
36
|
+
* - `ShippingMethodPicker` / `PaymentMethodPicker` — render-prop components for
|
|
37
|
+
* the two checkout choices that are store data, never hardcoded.
|
|
78
38
|
*
|
|
79
|
-
*
|
|
80
|
-
*
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
*
|
|
84
|
-
*
|
|
39
|
+
* Re-exported from `@/commerce/utils` so one import line covers a page:
|
|
40
|
+
* `variantAxes` (axes → options with selected/disabled/stock derived),
|
|
41
|
+
* `productPrice` (the from-price and incomplete-selection range rules),
|
|
42
|
+
* `productImages` + `imageIndex` (an image's position in that list — how a
|
|
43
|
+
* gallery follows the variant selection without owning a second copy of it),
|
|
44
|
+
* `productSpecs` + `findSpec` (meta keys are free text, so featuring a
|
|
45
|
+
* particular spec needs a tolerant lookup, not an equality test),
|
|
46
|
+
* `attributesLabel`, `cartTotalsLines` / `orderTotalsLines`,
|
|
47
|
+
* `addressFieldSpec` (the checkout's field list, including the state/province
|
|
48
|
+
* field that silently mis-prices US/CA/AU orders when it is left out), and
|
|
49
|
+
* `storefrontErrorCode` / `storefrontErrorMessage` for the calls you make
|
|
50
|
+
* yourself — every rejection from the client carries a code worth branching on.
|
|
85
51
|
*
|
|
86
|
-
*
|
|
87
|
-
*
|
|
88
|
-
* state derived, for the variant selector you write.
|
|
89
|
-
* - `productSpecs(product)` — `meta_data` → descriptive rows carrying an
|
|
90
|
-
* inferred `type` (`numeric` with `number`/`unit` split out, `duration`,
|
|
91
|
-
* `location`, `list` with `items`, `text`), so a weight can be a figure and
|
|
92
|
-
* a composition bars instead of every modifier being one grey table row.
|
|
93
|
-
* - `productImages(product)` — images normalized to `{src, name, alt}` and
|
|
94
|
-
* de-duplicated; `[]` means render your placeholder.
|
|
52
|
+
* The catalog and product surfaces are where the design freedom lives: these
|
|
53
|
+
* hooks hand you resolved data, and the rendering is entirely yours.
|
|
95
54
|
*/
|
|
96
55
|
export {
|
|
97
56
|
StorefrontProvider,
|
|
@@ -99,10 +58,10 @@ export {
|
|
|
99
58
|
useStorefront,
|
|
100
59
|
useStoreInfo,
|
|
101
60
|
useFormatMoney,
|
|
61
|
+
useCountries,
|
|
102
62
|
useCart,
|
|
103
63
|
} from "./StorefrontProvider";
|
|
104
64
|
export { useCheckout, CheckoutProvider, useCheckoutContext } from "./useCheckout";
|
|
105
|
-
export { usePlaceOrder } from "./usePlaceOrder";
|
|
106
65
|
export { useOrderReturn, orderReceivedUrl } from "./useOrderReturn";
|
|
107
66
|
export { ShippingMethodPicker, PaymentMethodPicker } from "./pickers";
|
|
108
67
|
export { CartUIProvider, useCartUI } from "./cartUI";
|
|
@@ -115,19 +74,23 @@ export {
|
|
|
115
74
|
|
|
116
75
|
// ── catalog ────────────────────────────────────────────────────────────────
|
|
117
76
|
export { useProductList, useCategories, useRibbons } from "./useProductList";
|
|
118
|
-
export { useProduct, useAddToCart
|
|
119
|
-
export { useUpsell } from "./useUpsell";
|
|
120
|
-
export { useProductPrice, useMoney } from "./useProductPrice";
|
|
121
|
-
export { useProductGallery } from "./useProductGallery";
|
|
122
|
-
export { useProductReviews } from "./useProductReviews";
|
|
77
|
+
export { useProduct, useAddToCart } from "./useProduct";
|
|
123
78
|
|
|
124
|
-
// ── cart
|
|
125
|
-
export { useCartLine,
|
|
126
|
-
export { useAddressForm, useCountries } from "./useAddressForm";
|
|
127
|
-
export { useTotalsLines, useCheckoutBlockers, blockerMessage } from "./useTotalsLines";
|
|
128
|
-
|
|
129
|
-
// ── SEO ────────────────────────────────────────────────────────────────────
|
|
130
|
-
export { useStorefrontSeo, productSeo, collectionSeo, orderSeo } from "./useStorefrontSeo";
|
|
79
|
+
// ── cart ───────────────────────────────────────────────────────────────────
|
|
80
|
+
export { useCartLine, CartLine } from "./useCartLine";
|
|
131
81
|
|
|
132
82
|
// ── view-model helpers (framework-free, from @/commerce/utils) ─────────────
|
|
133
|
-
export {
|
|
83
|
+
export {
|
|
84
|
+
variantAxes,
|
|
85
|
+
productPrice,
|
|
86
|
+
productImages,
|
|
87
|
+
imageIndex,
|
|
88
|
+
productSpecs,
|
|
89
|
+
findSpec,
|
|
90
|
+
attributesLabel,
|
|
91
|
+
cartTotalsLines,
|
|
92
|
+
orderTotalsLines,
|
|
93
|
+
addressFieldSpec,
|
|
94
|
+
storefrontErrorCode,
|
|
95
|
+
storefrontErrorMessage,
|
|
96
|
+
} from "@/commerce/utils";
|
|
@@ -9,10 +9,24 @@ import { useFormatMoney } from "./StorefrontProvider";
|
|
|
9
9
|
* can't skip a `shipping_status` state or invent a payment method.
|
|
10
10
|
*
|
|
11
11
|
* Every option comes decorated with plain states and one handler — `selected`
|
|
12
|
-
* (boolean), `select()` (pick it) and
|
|
13
|
-
* the child is markup + classes and nothing else.
|
|
14
|
-
*
|
|
15
|
-
*
|
|
12
|
+
* (boolean), `select()` (pick it) and money formatted in the store's currency
|
|
13
|
+
* (`costLabel`) — so the child is markup + classes and nothing else.
|
|
14
|
+
*
|
|
15
|
+
* `hint` is the one thing the picker needs said right now, as a **code**, never
|
|
16
|
+
* as copy: `{ code, severity: "info"|"error", serverMessage }`. The words are
|
|
17
|
+
* the store's — write one line per code (there are three) in the store's own
|
|
18
|
+
* voice. `serverMessage` is set only when the backend explained the situation
|
|
19
|
+
* itself (an undeliverable address); it is more specific than anything you can
|
|
20
|
+
* write, so prefer it when present:
|
|
21
|
+
*
|
|
22
|
+
* const SHIPPING_HINTS = { // your words, once, near the checkout
|
|
23
|
+
* missing_address: "Enter your address to see delivery options.",
|
|
24
|
+
* none_available: "We can't deliver to that address yet.",
|
|
25
|
+
* syncing: "Updating delivery options…",
|
|
26
|
+
* };
|
|
27
|
+
* {hint && <p role={hint.severity === "error" ? "alert" : "status"}>
|
|
28
|
+
* {hint.serverMessage ?? SHIPPING_HINTS[hint.code]}
|
|
29
|
+
* </p>}
|
|
16
30
|
*
|
|
17
31
|
* Both read the nearest <CheckoutProvider>, or take an explicit `checkout`
|
|
18
32
|
* prop when you called `useCheckout` yourself.
|
|
@@ -27,40 +41,25 @@ function resolveCheckout(name, prop, ctx) {
|
|
|
27
41
|
}
|
|
28
42
|
|
|
29
43
|
/**
|
|
30
|
-
* Shipping options. Renders null for a virtual cart (`not_needed`) and while
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
* {
|
|
34
|
-
* status, // "missing_address" | "choice_required" | "chosen"
|
|
35
|
-
* // | "auto_selected" | "none_available"
|
|
36
|
-
* methods, // [{ id, title, cost, costLabel, selected,
|
|
37
|
-
* // select }] — what this address is offered
|
|
38
|
-
* chosen, // the chosen/auto-selected entry (with costLabel), or null
|
|
39
|
-
* selected, // alias of `chosen` — the same name the payment picker uses
|
|
40
|
-
* choose, // (id) => Promise — call with a method's id on pick
|
|
41
|
-
* mustChoose, // status === "choice_required" → render methods as a picker
|
|
42
|
-
* single, // exactly one method offered — already chosen; skip the
|
|
43
|
-
* // picker but still show `chosen.title` and `chosen.costLabel`
|
|
44
|
-
* syncing, // an address edit is being repriced — show a subtle busy state
|
|
45
|
-
* hint, // { code, message, severity: "info"|"error" } | null — the
|
|
46
|
-
* // one notice to show now (address missing / not deliverable
|
|
47
|
-
* // / repricing); render `hint.message`, done
|
|
48
|
-
* addressError, // { code, message } | null — also surfaces on the address
|
|
49
|
-
* } // form's country field
|
|
44
|
+
* Shipping options. Renders null for a virtual cart (`not_needed`) and while the
|
|
45
|
+
* cart is loading; otherwise calls `children` with:
|
|
50
46
|
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
* {
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
* {
|
|
47
|
+
* status "missing_address" | "choice_required" | "chosen"
|
|
48
|
+
* | "auto_selected" | "none_available"
|
|
49
|
+
* methods [{ id, title, cost, costLabel, selected, select }]
|
|
50
|
+
* chosen the chosen/auto-selected entry, or null
|
|
51
|
+
* selected alias of `chosen` (the name the payment picker uses)
|
|
52
|
+
* choose (id) => Promise
|
|
53
|
+
* mustChoose status === "choice_required" → render methods as a picker
|
|
54
|
+
* single exactly one method offered — already chosen
|
|
55
|
+
* syncing an address edit is being repriced
|
|
56
|
+
* hint { code, severity, serverMessage } | null (see above)
|
|
57
|
+
* addressError { code, message } | null — the server's own words
|
|
61
58
|
*
|
|
62
59
|
* `single` and `mustChoose` are never both true, and `single` guarantees
|
|
63
|
-
* `chosen` — a single option still *shows* what it is
|
|
60
|
+
* `chosen` — a single option still *shows* what it is (title + `costLabel`),
|
|
61
|
+
* never a picker of one. Never render `cart.chosen_shipping_method` directly:
|
|
62
|
+
* it is the rate's id, which is why `chosen` is handed to you resolved.
|
|
64
63
|
*/
|
|
65
64
|
export function ShippingMethodPicker({ checkout: checkoutProp, children }) {
|
|
66
65
|
const checkout = resolveCheckout("ShippingMethodPicker", checkoutProp, useCheckoutContextOptional());
|
|
@@ -84,11 +83,11 @@ export function ShippingMethodPicker({ checkout: checkoutProp, children }) {
|
|
|
84
83
|
};
|
|
85
84
|
const hint =
|
|
86
85
|
status === "missing_address"
|
|
87
|
-
? { code: "missing_address", severity: "info",
|
|
86
|
+
? { code: "missing_address", severity: "info", serverMessage: null }
|
|
88
87
|
: status === "none_available" || addressError?.code === "shipping_not_available"
|
|
89
|
-
? { code: "none_available", severity: "error",
|
|
88
|
+
? { code: "none_available", severity: "error", serverMessage: addressError?.message ?? null }
|
|
90
89
|
: syncing
|
|
91
|
-
? { code: "syncing", severity: "info",
|
|
90
|
+
? { code: "syncing", severity: "info", serverMessage: null }
|
|
92
91
|
: null;
|
|
93
92
|
const decoratedChosen = decorate(chosen) ?? null;
|
|
94
93
|
return children({
|
|
@@ -106,35 +105,22 @@ export function ShippingMethodPicker({ checkout: checkoutProp, children }) {
|
|
|
106
105
|
}
|
|
107
106
|
|
|
108
107
|
/**
|
|
109
|
-
* Payment methods — every gateway the admin has ENABLED, from store info
|
|
110
|
-
*
|
|
111
|
-
*
|
|
112
|
-
*
|
|
113
|
-
* {
|
|
114
|
-
* gateways, // [{ slug, title, description, online, selected,
|
|
115
|
-
* // select }] — admin-owned data, decorated
|
|
116
|
-
* value, // the selected slug ("" while none)
|
|
117
|
-
* select, // (slug) => void
|
|
118
|
-
* selected, // the selected gateway entry, or null
|
|
119
|
-
* single, // exactly one gateway — already selected; skip the picker but
|
|
120
|
-
* // still show its title so the customer knows how they pay
|
|
121
|
-
* hint, // { code, message, severity } | null — set when there are no
|
|
122
|
-
* } // gateways: checkout is unavailable, say so
|
|
123
|
-
*
|
|
124
|
-
* The child's whole job:
|
|
108
|
+
* Payment methods — every gateway the admin has ENABLED, from store info (their
|
|
109
|
+
* only source). Renders null while store info loads; otherwise calls `children`
|
|
110
|
+
* with:
|
|
125
111
|
*
|
|
126
|
-
* {
|
|
127
|
-
*
|
|
128
|
-
*
|
|
129
|
-
*
|
|
130
|
-
*
|
|
131
|
-
*
|
|
132
|
-
*
|
|
133
|
-
* {single && selected && <p>{selected.title} — {selected.description}</p>}
|
|
112
|
+
* gateways [{ slug, title, description, online, selected, select }]
|
|
113
|
+
* value the selected slug ("" while none)
|
|
114
|
+
* select (slug) => void
|
|
115
|
+
* selected the selected gateway entry, or null
|
|
116
|
+
* single exactly one gateway — already selected
|
|
117
|
+
* hint { code: "none_available", severity, serverMessage } | null —
|
|
118
|
+
* no gateways at all: say checkout is unavailable, in your words
|
|
134
119
|
*
|
|
135
120
|
* `single` guarantees `value` and `selected` — never render the one-gateway
|
|
136
121
|
* branch as "nothing selected yet" — and both re-resolve when store info
|
|
137
|
-
* changes.
|
|
122
|
+
* changes. A default-seeded store offers `offline` only, so never hardcode a
|
|
123
|
+
* card option. Titles and descriptions are the admin's copy: render them, don't
|
|
138
124
|
* invent your own.
|
|
139
125
|
*/
|
|
140
126
|
export function PaymentMethodPicker({ checkout: checkoutProp, children }) {
|
|
@@ -153,7 +139,7 @@ export function PaymentMethodPicker({ checkout: checkoutProp, children }) {
|
|
|
153
139
|
}));
|
|
154
140
|
const hint =
|
|
155
141
|
gateways.length === 0
|
|
156
|
-
? { code: "none_available", severity: "error",
|
|
142
|
+
? { code: "none_available", severity: "error", serverMessage: null }
|
|
157
143
|
: null;
|
|
158
144
|
return children({
|
|
159
145
|
gateways: decorated,
|
|
@@ -20,39 +20,23 @@ 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
|
-
*
|
|
23
|
+
* **When `pending` settles.** It is this row's flag, not the cart's, and not
|
|
24
|
+
* true for the whole gesture: a click only sets the optimistic quantity and
|
|
25
|
+
* (re)starts the 250ms timer, so a burst of clicks sends **one** request;
|
|
26
|
+
* `pending` goes true when that request leaves (`remove()` skips the wait); it
|
|
27
|
+
* returns to false only after the server's new cart view has been published
|
|
28
|
+
* through the provider's queue. So `pending === false` with `error === null`
|
|
29
|
+
* means this row, the cart's totals and any badge are all settled — it is the
|
|
30
|
+
* only mutation-settled signal (`useCart().status` never returns to
|
|
31
|
+
* `"loading"` for a mutation), and what a script driving the page waits on.
|
|
32
|
+
* Scope the busy state to this row: greying the whole cart for a 250ms step
|
|
33
|
+
* reads as a page-wide stall. On failure the quantity rolls back to what the
|
|
34
|
+
* server still holds and `error` is `{ code, message }`.
|
|
24
35
|
*
|
|
25
|
-
*
|
|
26
|
-
* the
|
|
36
|
+
* It owns the quantity only. The row's own content — name, `attributesLabel`,
|
|
37
|
+
* image, money — you render from the `cart.items[n]` you passed in.
|
|
27
38
|
*
|
|
28
|
-
*
|
|
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
|
-
*
|
|
54
|
-
* @param {object} line a decorated line from `useCart().lines` (a raw
|
|
55
|
-
* `cart.items[n]` works too — it just has no `maxQuantity` hint)
|
|
39
|
+
* @param {object} line one `cart.items[n]` from `useCart()`
|
|
56
40
|
* @param {{debounceMs?: number}} [options]
|
|
57
41
|
*/
|
|
58
42
|
export function useCartLine(line, { debounceMs = 250 } = {}) {
|
|
@@ -133,10 +117,6 @@ export function useCartLine(line, { debounceMs = 250 } = {}) {
|
|
|
133
117
|
maxQuantity,
|
|
134
118
|
atMax: quantity >= maxQuantity,
|
|
135
119
|
atMin: quantity <= 1,
|
|
136
|
-
attributesLabel: line?.attributesLabel ?? "",
|
|
137
|
-
image: line?.image ?? null,
|
|
138
|
-
totalLabel: line?.totalLabel ?? "",
|
|
139
|
-
unitPriceLabel: line?.unitPriceLabel ?? "",
|
|
140
120
|
};
|
|
141
121
|
}
|
|
142
122
|
|
|
@@ -144,29 +124,18 @@ export function useCartLine(line, { debounceMs = 250 } = {}) {
|
|
|
144
124
|
* CartLine — headless per-line binding for the rows of a cart you render
|
|
145
125
|
* yourself. It renders **nothing**: the render function you pass as `children`
|
|
146
126
|
* receives the `useCartLine` controls for that line and returns your markup.
|
|
147
|
-
* It exists so
|
|
127
|
+
* It exists so an `items.map(...)` doesn't tempt a hook call inside a loop:
|
|
148
128
|
*
|
|
149
|
-
*
|
|
150
|
-
*
|
|
151
|
-
*
|
|
152
|
-
* {(l) => (
|
|
153
|
-
* <li aria-busy={l.pending}>
|
|
154
|
-
* {line.name} {line.attributesLabel}
|
|
155
|
-
* <button onClick={l.decrease} disabled={!l.canDecrease || l.pending}
|
|
156
|
-
* aria-label={`Decrease quantity of ${line.name}`}>−</button>
|
|
157
|
-
* {l.quantity}
|
|
158
|
-
* <button onClick={l.increase} disabled={!l.canIncrease || l.pending}
|
|
159
|
-
* aria-label={`Increase quantity of ${line.name}`}>+</button>
|
|
160
|
-
* <button onClick={l.remove} aria-label={`Remove ${line.name}`}>Remove</button>
|
|
161
|
-
* {l.totalLabel}
|
|
162
|
-
* {l.error && <p role="alert">{l.error.message}</p>}
|
|
163
|
-
* </li>
|
|
164
|
-
* )}
|
|
129
|
+
* {cart.items.map(item => (
|
|
130
|
+
* <CartLine key={item.item_key} line={item}>
|
|
131
|
+
* {(l) => <li aria-busy={l.pending}>…your row, using item and l…</li>}
|
|
165
132
|
* </CartLine>
|
|
166
133
|
* ))}
|
|
167
134
|
*
|
|
168
|
-
*
|
|
169
|
-
*
|
|
135
|
+
* Give each row's controls their own accessible name (`Remove ${item.name}`):
|
|
136
|
+
* three buttons all named "Remove" are ambiguous to a screen reader and to a
|
|
137
|
+
* script driving the page. Equivalent to extracting your own row component that
|
|
138
|
+
* calls `useCartLine` — use whichever reads better in your page.
|
|
170
139
|
*
|
|
171
140
|
* @param {{line: object, options?: {debounceMs?: number},
|
|
172
141
|
* children: (controls: object) => React.ReactNode}} props
|
|
@@ -179,79 +148,3 @@ export function CartLine({ line, options, children }) {
|
|
|
179
148
|
if (!line) return null;
|
|
180
149
|
return children(controls);
|
|
181
150
|
}
|
|
182
|
-
|
|
183
|
-
/**
|
|
184
|
-
* useCoupon — the coupon field. Small, and the difference between a store that
|
|
185
|
-
* can honour its own discounts and one that cannot.
|
|
186
|
-
*
|
|
187
|
-
* const c = useCoupon();
|
|
188
|
-
* <input value={c.code} onChange={(e) => c.setCode(e.target.value)} />
|
|
189
|
-
* <button onClick={c.apply} disabled={c.applying}>Apply</button>
|
|
190
|
-
* {c.error && <p role="alert">{c.error.message}</p>}
|
|
191
|
-
* {c.applied.map(a => <Chip key={a.code} onRemove={() => c.remove(a.code)} …/>)}
|
|
192
|
-
*
|
|
193
|
-
* Coupons are admin-only data — a storefront cannot list codes, so a seeded
|
|
194
|
-
* code is reachable **only** through a field the customer types it into. If the
|
|
195
|
-
* store has any coupons, this field must exist somewhere in the cart or the
|
|
196
|
-
* checkout, or those codes can never be redeemed.
|
|
197
|
-
*
|
|
198
|
-
* An invalid, expired or ineligible code is **expected flow**: `apply()`
|
|
199
|
-
* resolves `{ ok: false, error }` and never throws — render `error.message`
|
|
200
|
-
* inline next to the field.
|
|
201
|
-
*/
|
|
202
|
-
export function useCoupon() {
|
|
203
|
-
const { cart, applyCoupon, removeCoupon } = useCart();
|
|
204
|
-
const [code, setCode] = useState("");
|
|
205
|
-
const [applying, setApplying] = useState(false);
|
|
206
|
-
const [error, setError] = useState(null);
|
|
207
|
-
|
|
208
|
-
const apply = useCallback(
|
|
209
|
-
async (explicit) => {
|
|
210
|
-
const value = String(explicit ?? code ?? "").trim();
|
|
211
|
-
if (!value) return { ok: false, error: { code: "empty", message: "Enter a code." } };
|
|
212
|
-
setApplying(true);
|
|
213
|
-
setError(null);
|
|
214
|
-
const res = await applyCoupon(value);
|
|
215
|
-
setApplying(false);
|
|
216
|
-
if (res.ok) {
|
|
217
|
-
setCode("");
|
|
218
|
-
return { ok: true, cart: res.cart };
|
|
219
|
-
}
|
|
220
|
-
const err = { code: res.code ?? "coupon_invalid", message: res.message };
|
|
221
|
-
setError(err);
|
|
222
|
-
return { ok: false, error: err };
|
|
223
|
-
},
|
|
224
|
-
[code, applyCoupon],
|
|
225
|
-
);
|
|
226
|
-
|
|
227
|
-
const remove = useCallback(
|
|
228
|
-
async (codeOrEntry) => {
|
|
229
|
-
const value = typeof codeOrEntry === "string" ? codeOrEntry : codeOrEntry?.code;
|
|
230
|
-
setError(null);
|
|
231
|
-
try {
|
|
232
|
-
await removeCoupon(value);
|
|
233
|
-
return { ok: true };
|
|
234
|
-
} catch (e) {
|
|
235
|
-
const err = { code: storefrontErrorCode(e) ?? "error", message: storefrontErrorMessage(e) };
|
|
236
|
-
setError(err);
|
|
237
|
-
return { ok: false, error: err };
|
|
238
|
-
}
|
|
239
|
-
},
|
|
240
|
-
[removeCoupon],
|
|
241
|
-
);
|
|
242
|
-
|
|
243
|
-
return {
|
|
244
|
-
code,
|
|
245
|
-
setCode,
|
|
246
|
-
apply,
|
|
247
|
-
remove,
|
|
248
|
-
applied: (cart?.coupons ?? []).map((c) => ({
|
|
249
|
-
code: c.code,
|
|
250
|
-
discount: c.discount ?? 0,
|
|
251
|
-
freeShipping: Boolean(c.free_shipping),
|
|
252
|
-
})),
|
|
253
|
-
applying,
|
|
254
|
-
error,
|
|
255
|
-
discountTotal: cart?.totals?.discount_total ?? 0,
|
|
256
|
-
};
|
|
257
|
-
}
|