@base44/app-plugin-commerce 0.5.0 → 0.6.0
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 +2 -2
- package/package.json +1 -1
- package/skills/commerce/SKILL.md +56 -49
- package/skills/commerce/install/01-install.md +1 -1
- package/skills/commerce/install/02-storefront.md +178 -154
- package/skills/commerce/references/catalog-rendering.md +2 -2
- package/skills/commerce/references/storefront-verification.md +0 -4
- package/src/commerce/storefront/AddressFields.jsx +150 -0
- package/src/commerce/storefront/StorefrontProvider.jsx +3 -10
- package/src/commerce/storefront/cartUI.jsx +17 -63
- package/src/commerce/storefront/index.js +10 -32
- package/src/commerce/storefront/useCheckout.jsx +0 -27
- package/src/commerce/utils/index.js +4 -1
- package/src/commerce/utils/specs.js +95 -10
- package/skills/commerce/references/storefront-custom.md +0 -150
- package/skills/commerce/references/storefront-parts.md +0 -184
- package/src/commerce/storefront/parts/cart.jsx +0 -191
- package/src/commerce/storefront/parts/checkout.jsx +0 -540
- package/src/commerce/storefront/parts/drawer.jsx +0 -87
- package/src/commerce/storefront/parts/labels.jsx +0 -140
- package/src/commerce/storefront/parts/orderReceived.jsx +0 -200
- package/src/commerce/storefront/parts/parts.css +0 -215
- package/src/commerce/storefront/parts/shared.jsx +0 -137
- package/src/commerce/storefront/parts/visibility.js +0 -36
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import React, { useId, useState } from "react";
|
|
2
|
+
import { addressFieldSpec } from "@/commerce/utils";
|
|
3
|
+
import { useCheckoutContext } from "./useCheckout";
|
|
4
|
+
import { useCountries } from "./StorefrontProvider";
|
|
5
|
+
import { REQUIRED_BILLING_FIELDS } from "./address";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The checkout address form, headless — the one storefront section that ships
|
|
9
|
+
* as a component rather than as wiring reference, because hand-rolling it is
|
|
10
|
+
* where checkouts break: the state/province field must appear with the right
|
|
11
|
+
* options once a country is picked (shipping and taxes match on country *plus*
|
|
12
|
+
* state, so a form without it silently mis-prices US/CA/AU orders),
|
|
13
|
+
* `autoComplete` tokens are what make browser autofill work, and the server's
|
|
14
|
+
* "we don't ship there" belongs on the country field.
|
|
15
|
+
*
|
|
16
|
+
* It renders semantic fields and nothing else — no CSS ships with it. Every
|
|
17
|
+
* element carries `data-part` (`field`, `label`, `control`, `required`,
|
|
18
|
+
* `error`) plus `data-key`/`data-span`/`data-invalid`, so the store's own
|
|
19
|
+
* classes attach via `className`/`classes` props or `[data-part]` selectors.
|
|
20
|
+
* Field labels come from `addressFieldSpec` (plain conventions — rename any of
|
|
21
|
+
* them via `labels`). Required marks arm on first blur, never on load.
|
|
22
|
+
*
|
|
23
|
+
* Works inside `<CheckoutProvider>` (it reads `useCheckoutContext()`).
|
|
24
|
+
* `which="shipping"` renders null until `shipToDifferent` is on — the
|
|
25
|
+
* deliver-elsewhere checkbox itself stays yours, wired to
|
|
26
|
+
* `checkout.shipToDifferent` / `checkout.setShipToDifferent`.
|
|
27
|
+
*
|
|
28
|
+
* @param {object} props
|
|
29
|
+
* @param {"billing"|"shipping"} [props.which] which address this form edits.
|
|
30
|
+
* @param {boolean} [props.includeCompany] add the company field (default false).
|
|
31
|
+
* @param {boolean} [props.includePhone] keep the phone field (default true).
|
|
32
|
+
* @param {string[]} [props.omit] field keys to drop entirely.
|
|
33
|
+
* @param {Record<string, string>} [props.labels] label text per field key,
|
|
34
|
+
* merged over the spec's defaults (e.g. `{ postcode: "ZIP code" }`).
|
|
35
|
+
* @param {string} [props.selectPlaceholder] the selects' empty-option text.
|
|
36
|
+
* @param {Function} [props.inputRender] `({ field, options, invalid, ...dom })`
|
|
37
|
+
* — swaps the control only; spread `dom` onto your input.
|
|
38
|
+
* @param {Function} [props.fieldRender] `({ field, label, invalid, options, ...dom })`
|
|
39
|
+
* — replaces the whole labeled block.
|
|
40
|
+
* @param {string} [props.className] class for the wrapping element.
|
|
41
|
+
* @param {{field?: string, label?: string, control?: string, error?: string}} [props.classes]
|
|
42
|
+
*/
|
|
43
|
+
export function AddressFields({
|
|
44
|
+
which = "billing",
|
|
45
|
+
includeCompany = false,
|
|
46
|
+
includePhone = true,
|
|
47
|
+
omit,
|
|
48
|
+
labels,
|
|
49
|
+
selectPlaceholder = "Select…",
|
|
50
|
+
inputRender: InputRender,
|
|
51
|
+
fieldRender,
|
|
52
|
+
className,
|
|
53
|
+
classes,
|
|
54
|
+
}) {
|
|
55
|
+
const checkout = useCheckoutContext();
|
|
56
|
+
const { countries } = useCountries();
|
|
57
|
+
const idBase = useId();
|
|
58
|
+
const [touched, setTouched] = useState({});
|
|
59
|
+
|
|
60
|
+
const isBilling = which === "billing";
|
|
61
|
+
if (!isBilling && !checkout.shipToDifferent) return null;
|
|
62
|
+
|
|
63
|
+
const values = isBilling ? checkout.billing : checkout.shipping;
|
|
64
|
+
const set = isBilling ? checkout.updateBilling : checkout.updateShipping;
|
|
65
|
+
const omitted = new Set(omit ?? []);
|
|
66
|
+
const fields = addressFieldSpec({
|
|
67
|
+
countries,
|
|
68
|
+
country: values.country,
|
|
69
|
+
required: isBilling ? REQUIRED_BILLING_FIELDS : ["country", "city"],
|
|
70
|
+
includeEmail: isBilling, // one email per order, on billing
|
|
71
|
+
includeCompany,
|
|
72
|
+
includePhone,
|
|
73
|
+
}).filter((f) => !omitted.has(f.key));
|
|
74
|
+
|
|
75
|
+
return (
|
|
76
|
+
<div data-part="address-fields" data-which={which} className={className}>
|
|
77
|
+
{fields.map((f) => {
|
|
78
|
+
const id = `${idBase}-${which}-${f.key}`;
|
|
79
|
+
const value = values[f.key] ?? "";
|
|
80
|
+
const invalid = Boolean(touched[f.key] && f.required && !String(value).trim());
|
|
81
|
+
const dom = {
|
|
82
|
+
id,
|
|
83
|
+
value,
|
|
84
|
+
autoComplete: f.autoComplete,
|
|
85
|
+
onChange: (e) => set({ [f.key]: e && e.target ? e.target.value : e }),
|
|
86
|
+
onBlur: () => setTouched((t) => (t[f.key] ? t : { ...t, [f.key]: true })),
|
|
87
|
+
};
|
|
88
|
+
const labelText = labels?.[f.key] ?? f.label;
|
|
89
|
+
if (fieldRender) {
|
|
90
|
+
return (
|
|
91
|
+
<React.Fragment key={f.key}>
|
|
92
|
+
{fieldRender({ field: f, label: labelText, invalid, options: f.options, ...dom })}
|
|
93
|
+
</React.Fragment>
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
return (
|
|
97
|
+
<div
|
|
98
|
+
key={f.key}
|
|
99
|
+
data-part="field"
|
|
100
|
+
data-key={f.key}
|
|
101
|
+
data-span={f.colSpan}
|
|
102
|
+
data-invalid={invalid || undefined}
|
|
103
|
+
className={classes?.field}
|
|
104
|
+
>
|
|
105
|
+
<label data-part="label" className={classes?.label} htmlFor={id}>
|
|
106
|
+
{labelText}
|
|
107
|
+
{f.required && (
|
|
108
|
+
<span data-part="required" aria-hidden="true">
|
|
109
|
+
*
|
|
110
|
+
</span>
|
|
111
|
+
)}
|
|
112
|
+
</label>
|
|
113
|
+
{InputRender ? (
|
|
114
|
+
<InputRender field={f} options={f.options} invalid={invalid} {...dom} />
|
|
115
|
+
) : f.type === "select" ? (
|
|
116
|
+
<select
|
|
117
|
+
data-part="control"
|
|
118
|
+
className={classes?.control}
|
|
119
|
+
aria-invalid={invalid || undefined}
|
|
120
|
+
aria-required={f.required || undefined}
|
|
121
|
+
{...dom}
|
|
122
|
+
>
|
|
123
|
+
<option value="">{selectPlaceholder}</option>
|
|
124
|
+
{f.options.map((o) => (
|
|
125
|
+
<option key={o.value} value={o.value}>
|
|
126
|
+
{o.label}
|
|
127
|
+
</option>
|
|
128
|
+
))}
|
|
129
|
+
</select>
|
|
130
|
+
) : (
|
|
131
|
+
<input
|
|
132
|
+
data-part="control"
|
|
133
|
+
className={classes?.control}
|
|
134
|
+
type={f.type}
|
|
135
|
+
aria-invalid={invalid || undefined}
|
|
136
|
+
aria-required={f.required || undefined}
|
|
137
|
+
{...dom}
|
|
138
|
+
/>
|
|
139
|
+
)}
|
|
140
|
+
{f.key === "country" && checkout.addressError && (
|
|
141
|
+
<p data-part="error" role="alert" className={classes?.error}>
|
|
142
|
+
{checkout.addressError.message}
|
|
143
|
+
</p>
|
|
144
|
+
)}
|
|
145
|
+
</div>
|
|
146
|
+
);
|
|
147
|
+
})}
|
|
148
|
+
</div>
|
|
149
|
+
);
|
|
150
|
+
}
|
|
@@ -12,7 +12,6 @@ import {
|
|
|
12
12
|
storefrontErrorCode,
|
|
13
13
|
storefrontErrorMessage,
|
|
14
14
|
} from "@/commerce/utils";
|
|
15
|
-
import { LabelsScope } from "./parts/labels";
|
|
16
15
|
|
|
17
16
|
/**
|
|
18
17
|
* StorefrontProvider — one client, one store-info cache, ONE shared cart.
|
|
@@ -27,9 +26,7 @@ import { LabelsScope } from "./parts/labels";
|
|
|
27
26
|
* it, so the cart badge and the cart page read different carts.
|
|
28
27
|
*
|
|
29
28
|
* Pass `base44`, or `store={createStorefront(base44)}` when other modules need
|
|
30
|
-
* the same client.
|
|
31
|
-
* layer — pass it once here and every part resolves its words from it (the
|
|
32
|
-
* key list and example live in the skill's install/02-storefront.md).
|
|
29
|
+
* the same client.
|
|
33
30
|
*
|
|
34
31
|
* What lives here, and why it must not be duplicated per page: the client (it
|
|
35
32
|
* owns the cart_token lifecycle); store info, cached for the session —
|
|
@@ -41,7 +38,7 @@ import { LabelsScope } from "./parts/labels";
|
|
|
41
38
|
|
|
42
39
|
const StorefrontContext = createContext(null);
|
|
43
40
|
|
|
44
|
-
export function StorefrontProvider({ base44, store,
|
|
41
|
+
export function StorefrontProvider({ base44, store, children }) {
|
|
45
42
|
const client = useMemo(
|
|
46
43
|
() => store ?? createStorefront(base44),
|
|
47
44
|
[store, base44],
|
|
@@ -112,11 +109,7 @@ export function StorefrontProvider({ base44, store, labels, children }) {
|
|
|
112
109
|
() => ({ client, info, infoError, cart, cartError, mutationError, runCart, clearCart }),
|
|
113
110
|
[client, info, infoError, cart, cartError, mutationError, runCart, clearCart],
|
|
114
111
|
);
|
|
115
|
-
return
|
|
116
|
-
<StorefrontContext.Provider value={value}>
|
|
117
|
-
<LabelsScope labels={labels}>{children}</LabelsScope>
|
|
118
|
-
</StorefrontContext.Provider>
|
|
119
|
-
);
|
|
112
|
+
return <StorefrontContext.Provider value={value}>{children}</StorefrontContext.Provider>;
|
|
120
113
|
}
|
|
121
114
|
|
|
122
115
|
/** Advanced escape hatch: the raw provider state. Prefer the hooks below. */
|
|
@@ -1,45 +1,26 @@
|
|
|
1
|
-
import React, {
|
|
2
|
-
createContext,
|
|
3
|
-
useCallback,
|
|
4
|
-
useContext,
|
|
5
|
-
useEffect,
|
|
6
|
-
useId,
|
|
7
|
-
useLayoutEffect,
|
|
8
|
-
useMemo,
|
|
9
|
-
useRef,
|
|
10
|
-
useState,
|
|
11
|
-
} from "react";
|
|
1
|
+
import React, { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
|
|
12
2
|
import { useLocation } from "react-router-dom";
|
|
13
3
|
|
|
14
4
|
/**
|
|
15
|
-
* Cart drawer state
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
* - Esc closes; the route changing closes (`closeOnNavigate`); an item landing
|
|
22
|
-
* in the cart opens (`openOnAdd`, wired through `useAddToCart` — pass false
|
|
23
|
-
* for a navigate-to-bag flow).
|
|
24
|
-
* - **Focus.** Attach `panelRef` to your panel element: focus moves into it
|
|
25
|
-
* when the drawer opens and returns to whatever had it when the drawer
|
|
26
|
-
* closes (give the panel `tabIndex={-1}` so it can receive it).
|
|
27
|
-
* - **A closed-but-mounted panel is made `inert`** while `panelRef` is
|
|
28
|
-
* attached — the classic drawer bug is a panel translated off-screen whose
|
|
29
|
-
* buttons stay clickable, tab-able and readable to screen readers. Animate
|
|
30
|
-
* the slide freely; the closed panel stops being interactive by itself.
|
|
31
|
-
* - `panelId` — put it on your panel as `id`; the kit's `<CartDrawer.Trigger>`
|
|
32
|
-
* already points `aria-controls` at it.
|
|
5
|
+
* Cart drawer/panel state, headless: `open` plus the handlers to change it,
|
|
6
|
+
* and the two behaviors every drawer needs but a hand-written one forgets —
|
|
7
|
+
* it closes when the route changes (`closeOnNavigate`) and it opens when an
|
|
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.
|
|
33
11
|
*
|
|
34
12
|
* Mount `<CartUIProvider>` once, inside `<StorefrontProvider>`, around the
|
|
35
|
-
* layout; the layout renders the
|
|
13
|
+
* layout; the layout then renders the panel off `open`.
|
|
36
14
|
*
|
|
37
|
-
* ⚑
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
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
|
-
* `useCartUI()` → `{ open, openCart, closeCart, toggleCart
|
|
23
|
+
* `useCartUI()` → `{ open, openCart, closeCart, toggleCart }`.
|
|
43
24
|
* `useCartUIOptional()` returns null instead of throwing (how `useAddToCart`
|
|
44
25
|
* integrates without requiring the provider).
|
|
45
26
|
*
|
|
@@ -48,9 +29,6 @@ import { useLocation } from "react-router-dom";
|
|
|
48
29
|
|
|
49
30
|
const CartUIContext = createContext(null);
|
|
50
31
|
|
|
51
|
-
/** Layout effect where there is a DOM; plain effect on the server (no warning). */
|
|
52
|
-
const useDrawerEffect = typeof window === "undefined" ? useEffect : useLayoutEffect;
|
|
53
|
-
|
|
54
32
|
/** Internal: closes the drawer whenever the route changes (needs a Router above). */
|
|
55
33
|
function CloseOnNavigate({ close }) {
|
|
56
34
|
const { pathname } = useLocation();
|
|
@@ -74,9 +52,6 @@ export function CartUIProvider({ closeOnNavigate = true, openOnAdd = true, child
|
|
|
74
52
|
const openCart = useCallback(() => setOpen(true), []);
|
|
75
53
|
const closeCart = useCallback(() => setOpen(false), []);
|
|
76
54
|
const toggleCart = useCallback(() => setOpen((o) => !o), []);
|
|
77
|
-
const panelId = useId();
|
|
78
|
-
const panelRef = useRef(null);
|
|
79
|
-
const restoreRef = useRef(null);
|
|
80
55
|
|
|
81
56
|
// Esc closes — the keyboard's way out is Esc and the named close button.
|
|
82
57
|
useEffect(() => {
|
|
@@ -88,36 +63,15 @@ export function CartUIProvider({ closeOnNavigate = true, openOnAdd = true, child
|
|
|
88
63
|
return () => window.removeEventListener("keydown", onKey);
|
|
89
64
|
}, [open]);
|
|
90
65
|
|
|
91
|
-
// Focus into the store's panel on open, back where it came from on close, and
|
|
92
|
-
// a closed-but-mounted panel is inert — all on whatever element the store
|
|
93
|
-
// attached `panelRef` to, so the drawer's markup and animation stay theirs.
|
|
94
|
-
// Layout effect: before paint, so an animating panel is never briefly live.
|
|
95
|
-
useDrawerEffect(() => {
|
|
96
|
-
const node = panelRef.current;
|
|
97
|
-
if (open) {
|
|
98
|
-
restoreRef.current = typeof document !== "undefined" ? document.activeElement : null;
|
|
99
|
-
if (node) {
|
|
100
|
-
node.inert = false;
|
|
101
|
-
node.focus?.({ preventScroll: true });
|
|
102
|
-
}
|
|
103
|
-
} else {
|
|
104
|
-
if (node) node.inert = true;
|
|
105
|
-
restoreRef.current?.focus?.();
|
|
106
|
-
restoreRef.current = null;
|
|
107
|
-
}
|
|
108
|
-
}, [open]);
|
|
109
|
-
|
|
110
66
|
const value = useMemo(
|
|
111
67
|
() => ({
|
|
112
68
|
open,
|
|
113
69
|
openCart,
|
|
114
70
|
closeCart,
|
|
115
71
|
toggleCart,
|
|
116
|
-
panelRef,
|
|
117
|
-
panelId,
|
|
118
72
|
onItemAdded: openOnAdd ? openCart : null,
|
|
119
73
|
}),
|
|
120
|
-
[open, openCart, closeCart, toggleCart,
|
|
74
|
+
[open, openCart, closeCart, toggleCart, openOnAdd],
|
|
121
75
|
);
|
|
122
76
|
|
|
123
77
|
return (
|
|
@@ -35,6 +35,10 @@
|
|
|
35
35
|
* - `useOrderReturn` — the mandatory `/order-received` page in one hook.
|
|
36
36
|
* - `ShippingMethodPicker` / `PaymentMethodPicker` — render-prop components for
|
|
37
37
|
* the two checkout choices that are store data, never hardcoded.
|
|
38
|
+
* - `AddressFields` — the ONE rendered component in the layer: the checkout
|
|
39
|
+
* address form (state/province appears per country, `autoComplete` kept,
|
|
40
|
+
* required marks arm on blur). Unstyled; style it via `data-part`
|
|
41
|
+
* selectors or its `className`/`classes` props.
|
|
38
42
|
*
|
|
39
43
|
* Re-exported from `@/commerce/utils` so one import line covers a page:
|
|
40
44
|
* `variantAxes` (axes → options with selected/disabled/stock derived),
|
|
@@ -43,8 +47,10 @@
|
|
|
43
47
|
* gallery follows the variant selection without owning a second copy of it),
|
|
44
48
|
* `productRibbons` (ribbons are `{id, name}` objects, not strings — rendering
|
|
45
49
|
* one straight into JSX is React's "Objects are not valid as a React child"),
|
|
46
|
-
* `productSpecs` + `findSpec` (
|
|
47
|
-
*
|
|
50
|
+
* `productSpecs` + `findSpec` (spec rows carry an inferred render `type` so a
|
|
51
|
+
* weight reads as a figure and a composition as bars rather than every modifier
|
|
52
|
+
* as one grey table row; meta keys are free text, so featuring a particular
|
|
53
|
+
* spec needs a tolerant lookup, not an equality test),
|
|
48
54
|
* `attributesLabel`, `cartTotalsLines` / `orderTotalsLines`,
|
|
49
55
|
* `addressFieldSpec` (the checkout's field list, including the state/province
|
|
50
56
|
* field that silently mis-prices US/CA/AU orders when it is left out), and
|
|
@@ -60,23 +66,6 @@
|
|
|
60
66
|
*
|
|
61
67
|
* The catalog and product surfaces are where the design freedom lives: these
|
|
62
68
|
* hooks hand you resolved data, and the rendering is entirely yours.
|
|
63
|
-
*
|
|
64
|
-
* **Parts** — for the four commodity surfaces only (cart, drawer, checkout,
|
|
65
|
-
* order-received), `Checkout.*` / `Cart.*` / `CartDrawer.*` /
|
|
66
|
-
* `OrderReceived.*` render each section's correct semantic markup with zero
|
|
67
|
-
* copy and zero navigation: you place them in YOUR layout, and supply every
|
|
68
|
-
* word through `labels` (chain: part prop → page Root → `<StorefrontProvider
|
|
69
|
-
* labels={…}>`; a missing key renders a visible `⟨copy: …⟩` placeholder).
|
|
70
|
-
* They carry **layout geometry and nothing else** (`parts/parts.css`: field
|
|
71
|
-
* grids, row and thumbnail sizing, totals rows, the drawer panel — no color,
|
|
72
|
-
* type or border, every rule at zero specificity), so an unstyled store looks
|
|
73
|
-
* unfinished but never broken; the look arrives as your CSS on
|
|
74
|
-
* `data-part`/`data-state`, or via `className`/`classes`. Contract tables, the
|
|
75
|
-
* copy example and the `data-part` inventory: the commerce skill's
|
|
76
|
-
* install/02-storefront.md — use the parts from there, not from these files.
|
|
77
|
-
* They compose the hooks above, so dropping one section down to its hook is
|
|
78
|
-
* normal; they are also the app's own source, editable when a requirement
|
|
79
|
-
* outgrows their props.
|
|
80
69
|
*/
|
|
81
70
|
export {
|
|
82
71
|
StorefrontProvider,
|
|
@@ -87,9 +76,10 @@ export {
|
|
|
87
76
|
useCountries,
|
|
88
77
|
useCart,
|
|
89
78
|
} from "./StorefrontProvider";
|
|
90
|
-
export { useCheckout, CheckoutProvider, useCheckoutContext
|
|
79
|
+
export { useCheckout, CheckoutProvider, useCheckoutContext } from "./useCheckout";
|
|
91
80
|
export { useOrderReturn, orderReceivedUrl } from "./useOrderReturn";
|
|
92
81
|
export { ShippingMethodPicker, PaymentMethodPicker } from "./pickers";
|
|
82
|
+
export { AddressFields } from "./AddressFields";
|
|
93
83
|
export { CartUIProvider, useCartUI } from "./cartUI";
|
|
94
84
|
export {
|
|
95
85
|
REQUIRED_BILLING_FIELDS,
|
|
@@ -98,18 +88,6 @@ export {
|
|
|
98
88
|
isShippingAddressComplete,
|
|
99
89
|
} from "./address";
|
|
100
90
|
|
|
101
|
-
// ── parts: guided sections for the commodity surfaces ──────────────────────
|
|
102
|
-
// The parts' layout geometry (field grids, row/media sizing, totals rows, the
|
|
103
|
-
// drawer panel) — structure only, no look, every rule at zero specificity so
|
|
104
|
-
// the store's CSS wins. See parts/parts.css for the custom properties.
|
|
105
|
-
import "./parts/parts.css";
|
|
106
|
-
|
|
107
|
-
export { Checkout } from "./parts/checkout";
|
|
108
|
-
export { Cart } from "./parts/cart";
|
|
109
|
-
export { CartDrawer } from "./parts/drawer";
|
|
110
|
-
export { OrderReceived } from "./parts/orderReceived";
|
|
111
|
-
export { REQUIRED_LABEL_KEYS } from "./parts/labels";
|
|
112
|
-
|
|
113
91
|
// ── catalog ────────────────────────────────────────────────────────────────
|
|
114
92
|
export { useProductList, useCategories, useRibbons } from "./useProductList";
|
|
115
93
|
export { useProduct, useAddToCart } from "./useProduct";
|
|
@@ -381,30 +381,3 @@ export function useCheckoutContext() {
|
|
|
381
381
|
export function useCheckoutContextOptional() {
|
|
382
382
|
return useContext(CheckoutContext);
|
|
383
383
|
}
|
|
384
|
-
|
|
385
|
-
/**
|
|
386
|
-
* Is this component rendering inside a checkout (`Checkout.Root`, or your own
|
|
387
|
-
* `<CheckoutProvider>`)? For the one component a store writes once and shows in
|
|
388
|
-
* three places — the drawer, the cart page and the checkout's order summary —
|
|
389
|
-
* where a merchandising block belongs in the first two and is a distraction (or
|
|
390
|
-
* a way out of the funnel) in the third:
|
|
391
|
-
*
|
|
392
|
-
* function CartContents() {
|
|
393
|
-
* const inCheckout = useInCheckout();
|
|
394
|
-
* return (
|
|
395
|
-
* <>
|
|
396
|
-
* <Cart.Lines />
|
|
397
|
-
* {!inCheckout && <UpsellRail />} // your own upsell / related rail
|
|
398
|
-
* <Cart.Totals />
|
|
399
|
-
* </>
|
|
400
|
-
* );
|
|
401
|
-
* }
|
|
402
|
-
*
|
|
403
|
-
* A boolean, not a mode: nothing in the kit changes behavior from it. Prefer an
|
|
404
|
-
* explicit prop (`<CartContents showUpsell={false} />`) where the caller knows
|
|
405
|
-
* best; this is for the case where it doesn't — a shared component several
|
|
406
|
-
* pages deep.
|
|
407
|
-
*/
|
|
408
|
-
export function useInCheckout() {
|
|
409
|
-
return useContext(CheckoutContext) != null;
|
|
410
|
-
}
|
|
@@ -28,7 +28,10 @@
|
|
|
28
28
|
* - `ribbons.js` — `productRibbons`: ribbons normalized to `{id, name}` — they
|
|
29
29
|
* are objects, and the field is absent on a listing page that carries none.
|
|
30
30
|
* - `specs.js` — `productSpecs`: `meta_data` → descriptive rows (`key`, `label`,
|
|
31
|
-
* `titleLabel`, `value`)
|
|
31
|
+
* `titleLabel`, `value`) each carrying an inferred render `type` (numeric,
|
|
32
|
+
* duration, location, list, text), so a weight can read as a figure and a
|
|
33
|
+
* composition as bars instead of every modifier as one grey table row. Match
|
|
34
|
+
* rows by `key` via `findSpec`, never by `label`.
|
|
32
35
|
* - `types.js` — types only: `StorefrontProduct` and the rest of the catalog
|
|
33
36
|
* shapes as JSDoc typedefs, so what a field holds is readable from the
|
|
34
37
|
* frontend instead of from the backend function's source.
|
|
@@ -6,6 +6,32 @@
|
|
|
6
6
|
* **not** attributes and not ribbons: they describe the product, they don't
|
|
7
7
|
* select a variant. Hidden keys (leading `_`) and empty values are skipped.
|
|
8
8
|
*
|
|
9
|
+
* Each row carries a `type` — inferred from the value, and for `"location"`
|
|
10
|
+
* from the key — so the rendering decision is already made for you. **A
|
|
11
|
+
* `.map()` into one uniform label/value table is the fallback, not the
|
|
12
|
+
* target:** the types exist because a carat weight and a care instruction are
|
|
13
|
+
* not the same kind of fact and should not look alike.
|
|
14
|
+
*
|
|
15
|
+
* ```jsx
|
|
16
|
+
* // ❌ every product in every store, identical: one grey table
|
|
17
|
+
* <dl>{productSpecs(product).map((s) => (
|
|
18
|
+
* <div key={s.key}><dt>{s.titleLabel}</dt><dd>{s.value}</dd></div>))}</dl>
|
|
19
|
+
*
|
|
20
|
+
* // ✅ branch on type — the figures read as figures, the rest stays a row
|
|
21
|
+
* {productSpecs(product).map((s) =>
|
|
22
|
+
* s.type === "numeric" ? <Figure key={s.key} label={s.label} n={s.number} unit={s.unit} />
|
|
23
|
+
* : s.type === "location" ? <Sourced key={s.key} place={s.value} /> // a located line, a pin
|
|
24
|
+
* : s.type === "list" ? <Bars key={s.key} parts={s.items} /> // composition, materials
|
|
25
|
+
* : s.type === "duration" ? <Lead key={s.key} label={s.label} value={s.value} />
|
|
26
|
+
* : <Row key={s.key} label={s.titleLabel} value={s.value} />)}
|
|
27
|
+
* ```
|
|
28
|
+
*
|
|
29
|
+
* Design the two or three that carry *this* product's meaning (a weight set in
|
|
30
|
+
* the display face, a provenance beside a map, a composition as bars) and let
|
|
31
|
+
* the remainder fall through to the plain row. The rows need not sit in one
|
|
32
|
+
* block either: a spec can go under the gallery, beside the price, or inside
|
|
33
|
+
* the description.
|
|
34
|
+
*
|
|
9
35
|
* ```jsx
|
|
10
36
|
* const specs = productSpecs(product);
|
|
11
37
|
* const care = findSpec(specs, "care"); // not specs.find(s => s.label === "Care")
|
|
@@ -18,17 +44,15 @@
|
|
|
18
44
|
* fallback forever (observed in a live store). `titleLabel` is the display-cased
|
|
19
45
|
* form, for when you do want to print the key as a heading.
|
|
20
46
|
*
|
|
21
|
-
* A `.map()` into one uniform label/value table is the fallback, not the
|
|
22
|
-
* target: a carat weight and a care instruction are not the same kind of fact
|
|
23
|
-
* and need not look alike. Which two or three of *this* catalog's modifiers
|
|
24
|
-
* carry meaning — and how each is rendered — is a design decision about this
|
|
25
|
-
* store, made from its own data. The rows need not sit in one block either: a
|
|
26
|
-
* spec can go under the gallery, beside the price, or inside the description.
|
|
27
|
-
*
|
|
28
47
|
* @param {object} product
|
|
29
|
-
* @returns {Array<{key: string, label: string, titleLabel: string, value: string
|
|
48
|
+
* @returns {Array<{key: string, label: string, titleLabel: string, value: string,
|
|
49
|
+
* type: "numeric"|"duration"|"location"|"list"|"text",
|
|
50
|
+
* number: number|null, unit: string|null, items: string[]}>}
|
|
30
51
|
* `[]` when the product has no visible meta_data — render nothing, not an
|
|
31
|
-
* empty section. `
|
|
52
|
+
* empty section. `number`/`unit` are set for `numeric` and `duration`
|
|
53
|
+
* (`unit` is `""` for a bare number), `items` for `list`, and are
|
|
54
|
+
* `null`/`[]` otherwise. `value` is always the store's own text, unchanged —
|
|
55
|
+
* the extra fields are there to render *with*, never a replacement for it.
|
|
32
56
|
*/
|
|
33
57
|
export function productSpecs(product) {
|
|
34
58
|
return (product?.meta_data ?? [])
|
|
@@ -40,7 +64,7 @@ export function productSpecs(product) {
|
|
|
40
64
|
// `label` is the key with underscores opened up, in whatever case it was
|
|
41
65
|
// typed; `titleLabel` is the display-cased form, for printing as a <dt>.
|
|
42
66
|
const titleLabel = label.replace(/(^|\s)\p{Ll}/gu, (c) => c.toUpperCase());
|
|
43
|
-
return { key, label, titleLabel, value };
|
|
67
|
+
return { key, label, titleLabel, value, ...classify(key, value) };
|
|
44
68
|
});
|
|
45
69
|
}
|
|
46
70
|
|
|
@@ -67,3 +91,64 @@ const normalizeSpecKey = (k) =>
|
|
|
67
91
|
.toLowerCase()
|
|
68
92
|
.replace(/[\s_-]+/g, "")
|
|
69
93
|
.trim();
|
|
94
|
+
|
|
95
|
+
const LOCATION_KEY =
|
|
96
|
+
/(origin|provenance|made[\s_-]?in|country|region|sourced|source|location|city|terroir|appellation|distillery|winery|atelier|workshop)/i;
|
|
97
|
+
|
|
98
|
+
const DURATION_UNIT =
|
|
99
|
+
/^(sec|secs|second|seconds|min|mins|minute|minutes|hr|hrs|hour|hours|day|days|week|weeks|month|months|year|years|yr|yrs)$/i;
|
|
100
|
+
|
|
101
|
+
/** Infer the render-relevant shape of one spec value. Never throws. */
|
|
102
|
+
function classify(key, raw) {
|
|
103
|
+
const value = raw.trim();
|
|
104
|
+
const plain = { type: "text", number: null, unit: null, items: [] };
|
|
105
|
+
|
|
106
|
+
if (LOCATION_KEY.test(key)) return { ...plain, type: "location" };
|
|
107
|
+
|
|
108
|
+
const qty = parseQuantity(value);
|
|
109
|
+
if (qty) {
|
|
110
|
+
const type = DURATION_UNIT.test(qty.unit) ? "duration" : "numeric";
|
|
111
|
+
return { ...plain, type, number: qty.number, unit: qty.unit };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const items = parseList(value);
|
|
115
|
+
if (items) return { ...plain, type: "list", items };
|
|
116
|
+
|
|
117
|
+
return plain;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** "0.75 ct" → {number: 0.75, unit: "ct"}; "18" → {number: 18, unit: ""}. */
|
|
121
|
+
function parseQuantity(value) {
|
|
122
|
+
const m = /^([-+]?[\d.,]+)\s*(.*)$/.exec(value);
|
|
123
|
+
if (!m) return null;
|
|
124
|
+
const number = toNumber(m[1]);
|
|
125
|
+
if (number === null) return null;
|
|
126
|
+
const unit = m[2].trim();
|
|
127
|
+
// A unit is a word or two of symbols/letters. Anything longer is prose that
|
|
128
|
+
// happens to start with a number ("2 pieces, hand-cut in the studio").
|
|
129
|
+
if (unit && (!/^[\p{L}%°µ"'/²³.\- ]{1,12}$/u.test(unit) || unit.split(/\s+/).length > 2)) return null;
|
|
130
|
+
return { number, unit };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Grouped thousands are separators; a lone comma between digits is a decimal. */
|
|
134
|
+
function toNumber(raw) {
|
|
135
|
+
let s = raw.replace(/\s/g, "");
|
|
136
|
+
if (/^[-+]?\d{1,3}(,\d{3})+(\.\d+)?$/.test(s)) s = s.replace(/,/g, "");
|
|
137
|
+
else if (/^[-+]?\d+,\d+$/.test(s)) s = s.replace(",", ".");
|
|
138
|
+
else if (s.includes(",")) return null;
|
|
139
|
+
const n = Number(s);
|
|
140
|
+
return Number.isFinite(n) ? n : null;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** "70% wool / 30% cashmere" → ["70% wool", "30% cashmere"]. */
|
|
144
|
+
function parseList(value) {
|
|
145
|
+
const parts = value
|
|
146
|
+
.split(/\s*[,;|·•/]\s*/)
|
|
147
|
+
.map((p) => p.trim())
|
|
148
|
+
.filter(Boolean);
|
|
149
|
+
if (parts.length < 2) return null;
|
|
150
|
+
// Short fragments with words in them — not a sentence that happens to have commas.
|
|
151
|
+
if (parts.some((p) => p.length > 24 || p.split(/\s+/).length > 3 || /[.!?]/.test(p))) return null;
|
|
152
|
+
if (!parts.some((p) => /\p{L}/u.test(p))) return null;
|
|
153
|
+
return parts;
|
|
154
|
+
}
|