@base44/app-plugin-commerce 0.2.3 → 0.2.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/base44/shared/commerce/card-payment.stripe.ts +109 -56
- package/base44/shared/commerce/card-payment.ts +5 -2
- package/package.json +1 -1
- package/skills/commerce/SKILL.md +3 -3
- package/skills/commerce/install/02-storefront.md +241 -35
- package/skills/commerce/install/03-data.md +6 -3
- package/skills/commerce/references/catalog-rendering.md +8 -4
- package/skills/commerce/references/online-payments.md +4 -3
- package/src/commerce/storefront/StorefrontProvider.jsx +37 -5
- package/src/commerce/storefront/index.js +11 -4
- package/src/commerce/storefront/useCartLine.js +31 -0
- package/src/commerce/storefront/useCheckout.jsx +11 -0
- package/src/commerce/utils/index.js +3 -1
- package/src/commerce/utils/specs.js +102 -13
- package/src/commerce/utils/variants.js +4 -2
|
@@ -16,9 +16,9 @@
|
|
|
16
16
|
* makes card payment visible at checkout.
|
|
17
17
|
*
|
|
18
18
|
* That is the whole job. This file assumes the app is connected to Stripe and
|
|
19
|
-
* reads the secret key
|
|
20
|
-
* the app gets connected is not this
|
|
21
|
-
* filling in.
|
|
19
|
+
* reads the secret key that connection publishes from **Base44 secrets**
|
|
20
|
+
* (`secrets.get("STRIPE_SECRET_KEY")`); how the app gets connected is not this
|
|
21
|
+
* file's concern and nothing here needs filling in.
|
|
22
22
|
*
|
|
23
23
|
* NEVER patch this file (or the stub it replaces) with partial edits. A
|
|
24
24
|
* find_replace that leaves the originals behind gives every commerce function a
|
|
@@ -39,9 +39,14 @@
|
|
|
39
39
|
* `card-payment.<provider>.ts` next to the stub. `references/online-payments.md`
|
|
40
40
|
* has the rules; this file is the worked model.
|
|
41
41
|
*/
|
|
42
|
-
import
|
|
42
|
+
import { secrets } from "base44:runtime";
|
|
43
43
|
import { HttpError } from "./auth.ts";
|
|
44
44
|
|
|
45
|
+
/** Stripe's REST API, called directly — no SDK to bundle in the function. */
|
|
46
|
+
const STRIPE_API = "https://api.stripe.com/v1";
|
|
47
|
+
/** Pinned, so a Stripe API release can never change the shapes read below. */
|
|
48
|
+
const STRIPE_VERSION = "2025-10-29.clover";
|
|
49
|
+
|
|
45
50
|
/** A hosted payment page for one order. */
|
|
46
51
|
export interface CardPaymentPage {
|
|
47
52
|
/** Where the customer goes to pay. */
|
|
@@ -52,37 +57,75 @@ export interface CardPaymentPage {
|
|
|
52
57
|
}
|
|
53
58
|
|
|
54
59
|
/**
|
|
55
|
-
* Credentials come from
|
|
56
|
-
* lazily (per call, not at module load) so a
|
|
57
|
-
* the secret answers a clean 503 at
|
|
58
|
-
* commerce function that imports it.
|
|
60
|
+
* Credentials come from **Base44 secrets**, never an entity, never backend
|
|
61
|
+
* source, never the client. Read lazily (per call, not at module load) so a
|
|
62
|
+
* store that has the file but not yet the secret answers a clean 503 at
|
|
63
|
+
* checkout instead of failing to boot every commerce function that imports it.
|
|
59
64
|
*/
|
|
65
|
+
const secret = (name: string): string => {
|
|
66
|
+
try {
|
|
67
|
+
return String(secrets.get(name) ?? "");
|
|
68
|
+
} catch {
|
|
69
|
+
return "";
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
|
|
60
73
|
/**
|
|
61
|
-
* Stripe's secret key, as published to the
|
|
62
|
-
*
|
|
63
|
-
*
|
|
74
|
+
* Stripe's secret key, as published to the app's secrets by its Stripe
|
|
75
|
+
* connection. The names below are the conventional ones; if the key arrives
|
|
76
|
+
* under a different name, this list is the only thing to change.
|
|
64
77
|
*/
|
|
65
|
-
const
|
|
78
|
+
const STRIPE_KEY_SECRETS = ["STRIPE_SECRET_KEY", "STRIPE_API_KEY", "STRIPE_KEY"];
|
|
66
79
|
|
|
67
|
-
const
|
|
68
|
-
for (const name of
|
|
69
|
-
const value =
|
|
80
|
+
const stripeKey = (): string => {
|
|
81
|
+
for (const name of STRIPE_KEY_SECRETS) {
|
|
82
|
+
const value = secret(name);
|
|
70
83
|
if (value) return value;
|
|
71
84
|
}
|
|
72
|
-
|
|
85
|
+
// The client is told only that cards are unavailable — which secret is
|
|
86
|
+
// missing is backend configuration, and naming it to a storefront visitor
|
|
87
|
+
// maps out the app's secrets for them. The detail goes to the log instead.
|
|
88
|
+
console.error(
|
|
89
|
+
`Stripe is not configured — no secret key found (looked for ${STRIPE_KEY_SECRETS.join(", ")}).`,
|
|
90
|
+
);
|
|
91
|
+
throw new HttpError(
|
|
92
|
+
503,
|
|
93
|
+
"Card payments are not available right now.",
|
|
94
|
+
"no_card_payment_provider",
|
|
95
|
+
);
|
|
73
96
|
};
|
|
74
97
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
98
|
+
/**
|
|
99
|
+
* The Base44 app this payment belongs to. Stamped on every payment's metadata
|
|
100
|
+
* as `base44_app_id`, which is how the platform attributes a Stripe payment
|
|
101
|
+
* back to this app — send it on every call that creates money movement.
|
|
102
|
+
*/
|
|
103
|
+
const base44AppId = (): string => secret("BASE44_APP_ID") || String(Deno.env.get("BASE44_APP_ID") ?? "");
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* One Stripe REST call. A body makes it a POST (form-encoded, with an
|
|
107
|
+
* idempotency key); no body is a GET.
|
|
108
|
+
*
|
|
109
|
+
* Stripe's own error text stays in the log: it describes backend configuration
|
|
110
|
+
* (keys, account state, API parameters), so the caller gets a flat message.
|
|
111
|
+
*/
|
|
112
|
+
async function stripeCall(path: string, body?: URLSearchParams): Promise<any> {
|
|
113
|
+
const headers: Record<string, string> = {
|
|
114
|
+
"Authorization": `Bearer ${stripeKey()}`,
|
|
115
|
+
"Stripe-Version": STRIPE_VERSION,
|
|
116
|
+
};
|
|
117
|
+
if (body) {
|
|
118
|
+
headers["Content-Type"] = "application/x-www-form-urlencoded";
|
|
119
|
+
headers["Idempotency-Key"] = crypto.randomUUID();
|
|
83
120
|
}
|
|
84
|
-
|
|
85
|
-
|
|
121
|
+
const res = await fetch(`${STRIPE_API}${path}`, { method: body ? "POST" : "GET", headers, body });
|
|
122
|
+
const data = await res.json().catch(() => null);
|
|
123
|
+
if (!res.ok) {
|
|
124
|
+
console.error(`Stripe ${path} failed (${res.status}):`, JSON.stringify(data?.error ?? {}));
|
|
125
|
+
throw new HttpError(502, "The payment provider could not process this request.", "payment_provider_error");
|
|
126
|
+
}
|
|
127
|
+
return data;
|
|
128
|
+
}
|
|
86
129
|
|
|
87
130
|
// Stripe amounts are in minor units; these currencies have none, so ×100 would
|
|
88
131
|
// charge a hundred times the total.
|
|
@@ -102,27 +145,34 @@ export async function createCardPayment(
|
|
|
102
145
|
): Promise<CardPaymentPage> {
|
|
103
146
|
// order_id + order_key in the metadata is how the premade payment-webhook
|
|
104
147
|
// names the order when Stripe's event arrives, and what checkCardPaymentPaid
|
|
105
|
-
// compares against
|
|
106
|
-
// not copied to the
|
|
107
|
-
|
|
108
|
-
const
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
148
|
+
// compares against; base44_app_id attributes the payment to this app. Keep
|
|
149
|
+
// all of it on both objects (the session's own metadata is not copied to the
|
|
150
|
+
// payment intent).
|
|
151
|
+
const metadata: Record<string, string> = {
|
|
152
|
+
order_id: String(order.id),
|
|
153
|
+
order_key: String(order.order_key),
|
|
154
|
+
};
|
|
155
|
+
const appId = base44AppId();
|
|
156
|
+
if (appId) metadata.base44_app_id = appId;
|
|
157
|
+
|
|
158
|
+
const params = new URLSearchParams();
|
|
159
|
+
params.set("mode", "payment");
|
|
160
|
+
params.set("line_items[0][quantity]", "1");
|
|
161
|
+
params.set("line_items[0][price_data][currency]", String(order.currency || "USD").toLowerCase());
|
|
162
|
+
params.set("line_items[0][price_data][unit_amount]", String(minorUnits(order.total, order.currency)));
|
|
163
|
+
params.set("line_items[0][price_data][product_data][name]", `Order #${order.order_number}`);
|
|
164
|
+
if (opts.customerEmail) params.set("customer_email", opts.customerEmail);
|
|
165
|
+
params.set("client_reference_id", String(order.id));
|
|
166
|
+
params.set("success_url", opts.successUrl);
|
|
167
|
+
params.set("cancel_url", opts.cancelUrl);
|
|
168
|
+
for (const [key, value] of Object.entries(metadata)) {
|
|
169
|
+
params.set(`metadata[${key}]`, value);
|
|
170
|
+
params.set(`payment_intent_data[metadata][${key}]`, value);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const session = await stripeCall("/checkout/sessions", params);
|
|
174
|
+
if (!session?.url) throw new HttpError(502, "Stripe did not return a payment page URL.", "payment_session_failed");
|
|
175
|
+
return { url: session.url, reference: String(session.id) };
|
|
126
176
|
}
|
|
127
177
|
|
|
128
178
|
/**
|
|
@@ -131,10 +181,10 @@ export async function createCardPayment(
|
|
|
131
181
|
* the admin's "Check payment" button.
|
|
132
182
|
*/
|
|
133
183
|
export async function checkCardPaymentPaid(_sr: any, order: any, reference: string): Promise<boolean> {
|
|
134
|
-
const session = await
|
|
184
|
+
const session = await stripeCall(`/checkout/sessions/${encodeURIComponent(reference)}`);
|
|
135
185
|
// The payment must be for THIS order — stops a reference to some other
|
|
136
186
|
// (genuinely paid) session being replayed against a different order.
|
|
137
|
-
return session
|
|
187
|
+
return session?.payment_status === "paid" && session?.metadata?.order_id === String(order.id);
|
|
138
188
|
}
|
|
139
189
|
|
|
140
190
|
/**
|
|
@@ -149,15 +199,18 @@ export async function refundCardPayment(_sr: any, _order: any, opts: {
|
|
|
149
199
|
}): Promise<{ refund_id: string }> {
|
|
150
200
|
// The stored reference is the Checkout Session; the refundable object is the
|
|
151
201
|
// payment intent behind it, which only exists once the session was paid.
|
|
152
|
-
const session = await
|
|
153
|
-
if (!session
|
|
202
|
+
const session = await stripeCall(`/checkout/sessions/${encodeURIComponent(opts.reference)}`);
|
|
203
|
+
if (!session?.payment_intent) {
|
|
154
204
|
throw new HttpError(409, "This payment has no charge to refund at Stripe.", "no_charge_to_refund");
|
|
155
205
|
}
|
|
156
|
-
const
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
206
|
+
const params = new URLSearchParams();
|
|
207
|
+
params.set("payment_intent", String(session.payment_intent));
|
|
208
|
+
params.set("amount", String(minorUnits(opts.amount, opts.currency)));
|
|
209
|
+
const appId = base44AppId();
|
|
210
|
+
if (appId) params.set("metadata[base44_app_id]", appId);
|
|
211
|
+
|
|
212
|
+
const refund = await stripeCall("/refunds", params);
|
|
213
|
+
return { refund_id: String(refund.id) };
|
|
161
214
|
}
|
|
162
215
|
|
|
163
216
|
/** What parseWebhook distills an event into — the premade webhook's contract. */
|
|
@@ -34,8 +34,11 @@
|
|
|
34
34
|
* Until implemented, the Credit Card checkout option answers
|
|
35
35
|
* 503 `no_card_payment_provider`.
|
|
36
36
|
*
|
|
37
|
-
* Credentials belong in
|
|
38
|
-
* never in an entity
|
|
37
|
+
* Credentials belong in Base44 secrets (`secrets.get("...")` from
|
|
38
|
+
* `base44:runtime`) — never in an entity, never in the code, never from the
|
|
39
|
+
* client. When one is missing, log which one and answer the caller with the
|
|
40
|
+
* flat 503 below: the storefront must not learn the names of the app's
|
|
41
|
+
* secrets.
|
|
39
42
|
*/
|
|
40
43
|
import { HttpError } from "./auth.ts";
|
|
41
44
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@base44/app-plugin-commerce",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.5",
|
|
4
4
|
"description": "Base44 Commerce plugin — entities, backend functions, shared commerce engine, admin UI and the commerce skill, shipped as copyable source",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"base44",
|
package/skills/commerce/SKILL.md
CHANGED
|
@@ -107,10 +107,10 @@ so you can answer "would that file help?" without paying for it.
|
|
|
107
107
|
| Topic | Open when | Already covered without opening | Size |
|
|
108
108
|
|---|---|---|---|
|
|
109
109
|
| [`install/01-install.md`](./install/01-install.md) | installing — it routes you to 02 and 03 | — | 8K |
|
|
110
|
-
| [`install/02-storefront.md`](./install/02-storefront.md) | building storefront pages | — |
|
|
111
|
-
| [`install/03-data.md`](./install/03-data.md) | seeding the catalog, shipping, payments decision | — |
|
|
110
|
+
| [`install/02-storefront.md`](./install/02-storefront.md) | building storefront pages — cart-drawer behaviour and driving the storefront from a browser script are in here too, not in a separate reference | — | 38K |
|
|
111
|
+
| [`install/03-data.md`](./install/03-data.md) | seeding the catalog, shipping, payments decision | — | 18K |
|
|
112
112
|
| [`docs/entities.md`](./docs/entities.md) | any direct entity read/write, or "which entity holds X" | function-mediated flows never need it; the addressing rule is above | 11K |
|
|
113
|
-
| [`references/catalog-rendering.md`](./references/catalog-rendering.md) | which fields each catalog call returns, variant edge cases | the install's product list/page chunks already render correct cards, prices and selectors |
|
|
113
|
+
| [`references/catalog-rendering.md`](./references/catalog-rendering.md) | which fields each catalog call returns, variant edge cases | the install's product list/page chunks already render correct cards, prices and selectors | 13K |
|
|
114
114
|
| [`references/shipping-and-tax.md`](./references/shipping-and-tax.md) | zones beyond the standard recipe, taxes, editing locations later | "€X in a region, €Y worldwide" is inline in `install/03-data.md` | 8K |
|
|
115
115
|
| [`references/online-payments.md`](./references/online-payments.md) | the store opted into cards and you are wiring the provider **now** | the decision and its timing are in `install/03-data.md`; wiring Stripe is a one-file copy, not code to write | 9K |
|
|
116
116
|
| [`references/reviews.md`](./references/reviews.md) | moderation, or a policy beyond the `policy` prop | `useProductReviews` covers list + form + policies | 4K |
|
|
@@ -54,49 +54,93 @@ layout — get hooks only, on purpose: reference markup there would make every
|
|
|
54
54
|
store look the same, and their design is the work only you can do. Either way
|
|
55
55
|
the hooks are high-level enough that a page is a handful of calls plus your
|
|
56
56
|
markup — writing more code than the budgets at the bottom allow means you are
|
|
57
|
-
re-deriving logic a hook already owns.
|
|
58
|
-
|
|
57
|
+
re-deriving logic a hook already owns.
|
|
58
|
+
|
|
59
|
+
**One import path: `@/commerce/storefront`.** Each section below opens with its
|
|
60
|
+
page's exact import line — **copy it verbatim** instead of assembling one from
|
|
61
|
+
memory, then delete any name you don't end up using. `variantAxes` and
|
|
62
|
+
`productSpecs` live in `@/commerce/utils` but are re-exported here, so a React
|
|
63
|
+
page never imports from `@/commerce/utils` directly; `useStoreInfo` is the name
|
|
64
|
+
most often left out, and it is the only source of store name and currency.
|
|
59
65
|
|
|
60
66
|
## Setup — once
|
|
61
67
|
|
|
68
|
+
Nearly every store has shared chrome (a nav with a cart badge, a footer), so
|
|
69
|
+
**start from the layout route** — a pathless `<Route>` whose element is your
|
|
70
|
+
layout, rendering `<Outlet/>` where the page goes. It also keeps the admin
|
|
71
|
+
outside the storefront's provider and chrome:
|
|
72
|
+
|
|
62
73
|
```jsx
|
|
74
|
+
import { Routes, Route, Outlet } from "react-router-dom";
|
|
63
75
|
import { StorefrontProvider } from "@/commerce/storefront";
|
|
64
76
|
import { base44 } from "@/api/base44Client";
|
|
77
|
+
import AdminApp from "@/commerce/admin";
|
|
78
|
+
|
|
79
|
+
// StoreLayout is YOURS: <Nav/> (its cart badge calls useCart) + <Outlet/> + <Footer/>.
|
|
80
|
+
function StoreLayout() { return <><Nav /><Outlet /><Footer /></>; }
|
|
65
81
|
|
|
66
82
|
<BrowserRouter>
|
|
67
|
-
<
|
|
68
|
-
<
|
|
83
|
+
<Routes> {/* ONE <Routes> — merge new pages into the app's */}
|
|
84
|
+
<Route element={<StorefrontProvider base44={base44}><StoreLayout /></StorefrontProvider>}>
|
|
69
85
|
<Route path="/" element={<Home />} />
|
|
70
86
|
<Route path="/product/:slug" element={<ProductPage />} />
|
|
71
87
|
<Route path="/bag" element={<Bag />} />
|
|
72
88
|
<Route path="/checkout" element={<Checkout />} />
|
|
73
89
|
<Route path="/order-received" element={<OrderReceived />} />
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
</
|
|
90
|
+
</Route>
|
|
91
|
+
<Route path="/store-admin/*" element={<AdminApp />} /> {/* own chrome, outside the provider */}
|
|
92
|
+
</Routes>
|
|
77
93
|
</BrowserRouter>
|
|
78
94
|
```
|
|
79
95
|
|
|
96
|
+
⚑ **The nesting is provider → layout → `<Outlet/>`, never the reverse.** A
|
|
97
|
+
layout that renders the provider *inside* itself leaves the nav above (or
|
|
98
|
+
outside) it, so the header badge and the cart page read different carts — and a
|
|
99
|
+
`useCart` in the nav throws outright. The provider goes on the layout route's
|
|
100
|
+
element, wrapping your layout component.
|
|
101
|
+
|
|
102
|
+
**No shared chrome** (each page draws its own header, or there is one page)?
|
|
103
|
+
Then wrap `<Routes>` directly and skip the layout route:
|
|
104
|
+
|
|
105
|
+
```jsx
|
|
106
|
+
<StorefrontProvider base44={base44}> {/* wraps <Routes> — never a child of it */}
|
|
107
|
+
<Routes>…</Routes>
|
|
108
|
+
</StorefrontProvider>
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
> ⚠ **`<Routes>` accepts only `<Route>` children.** Putting the provider inside
|
|
112
|
+
> it — the natural reading of "wrap the storefront routes" — throws at render:
|
|
113
|
+
> `Error: [StorefrontProvider] is not a <Route> component. All component children of <Routes> must be a <Route> or <React.Fragment>`.
|
|
114
|
+
> A pathless layout route (above) is the one place a wrapper is legal.
|
|
115
|
+
|
|
80
116
|
The provider owns the shared client, the store-info cache and **one** shared
|
|
81
117
|
cart, so a header badge, a drawer and the checkout render the same state. Never
|
|
82
118
|
mount a second provider, and never touch the `cart_token` — the provider owns
|
|
83
119
|
its whole lifecycle.
|
|
84
120
|
|
|
85
|
-
> ⚠ **`<Routes>` accepts only `<Route>` children.** Nesting the provider inside
|
|
86
|
-
> it — the natural reading of "wrap the storefront routes" — throws at render:
|
|
87
|
-
> `Error: [StorefrontProvider] is not a <Route> component. All component children of <Routes> must be a <Route> or <React.Fragment>`.
|
|
88
|
-
> To scope the provider to storefront routes only, use a pathless **layout
|
|
89
|
-
> route**, the one place a wrapper is legal:
|
|
90
|
-
> ```jsx
|
|
91
|
-
> <Route element={<StorefrontProvider base44={base44}><Outlet /></StorefrontProvider>}>
|
|
92
|
-
> <Route path="/" element={<Home />} />
|
|
93
|
-
> <Route path="/checkout" element={<Checkout />} />
|
|
94
|
-
> </Route>
|
|
95
|
-
> <Route path="/store-admin/*" element={<AdminApp />} /> {/* outside the provider */}
|
|
96
|
-
> ```
|
|
97
|
-
|
|
98
121
|
## Product list / collection
|
|
99
122
|
|
|
123
|
+
```jsx
|
|
124
|
+
import { useProductList, useCategories, useProductPrice, useStoreInfo, useStorefrontSeo, collectionSeo, productSpecs } from "@/commerce/storefront";
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
(Drop `useCategories` with no filter bar, `productSpecs` if the card shows no
|
|
128
|
+
modifiers. Add `useRibbons` for a ribbon filter.) The top of the component,
|
|
129
|
+
before any markup — every hook above the guards, and `products` derived once:
|
|
130
|
+
|
|
131
|
+
```jsx
|
|
132
|
+
const list = useProductList({ per_page: 24 });
|
|
133
|
+
const { settings } = useStoreInfo();
|
|
134
|
+
useStorefrontSeo(collectionSeo({ title: "…", products: list.products, storeName: settings?.store_name }));
|
|
135
|
+
|
|
136
|
+
if (list.status === "loading") return /* your loading state */;
|
|
137
|
+
if (list.status === "error") return /* your failure state, with a retry calling list.reload() */;
|
|
138
|
+
const products = list.products; // always an array — never null, so no defensive `?? []`
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
The same opening carries a home page's rails and a search results page — only
|
|
142
|
+
the params and your markup change.
|
|
143
|
+
|
|
100
144
|
`useProductList(params)` → `{ status, products, hasNext, next, refreshing,
|
|
101
145
|
setParams, reload }`. ⚑ `status` is `"loading" | "ready" | "empty" | "error"`
|
|
102
146
|
— branch on it, so a failed request renders as a failure instead of an empty
|
|
@@ -105,14 +149,24 @@ resets to page 1 and keeps the current rows on screen (`refreshing`) while the
|
|
|
105
149
|
page loads. `useCategories()` / `useRibbons()` → `{ items }` (arrays, children
|
|
106
150
|
nested).
|
|
107
151
|
|
|
108
|
-
Your card
|
|
152
|
+
Your card can render `name`, `images[0]?.src` (⚑ **images are `{src,name,alt}`
|
|
109
153
|
objects and the array may be empty — render a placeholder, never a broken
|
|
110
154
|
`<img>`**), `useProductPrice(row).label` (already "From €19.99" when the
|
|
111
155
|
product sells variants — there is no product `type` flag), `on_sale`,
|
|
112
156
|
`short_description`, `stock_status`, `average_rating`/`rating_count`,
|
|
113
|
-
`ribbons
|
|
157
|
+
`ribbons` — and a row carries the whole product record, so `weight`,
|
|
158
|
+
`dimensions`, `attributes[]` and `meta_data` are there too. Full field matrix:
|
|
114
159
|
[`../references/catalog-rendering.md`](../references/catalog-rendering.md).
|
|
115
160
|
|
|
161
|
+
That is an inventory of what you *can* show, not a card design and not a list
|
|
162
|
+
to render in order. An even grid of identical cards, each with the same
|
|
163
|
+
name/price/stars trio, is where a generated store lands by default and almost
|
|
164
|
+
never where this catalog belongs: give the grid a rhythm (a hero piece spanning
|
|
165
|
+
two columns, an editorial break between rows, a denser tile for a large
|
|
166
|
+
catalog), and lead each card with the one or two fields *these* products are
|
|
167
|
+
judged on — carat weight, focal length, edition size, ABV — read off
|
|
168
|
+
`meta_data` via `productSpecs(row)`, not the fields every store shows.
|
|
169
|
+
|
|
116
170
|
**Rails** (featured row, "new in") are the same hook with a filter
|
|
117
171
|
(`{ featured: true, per_page: 4 }`). ⚑ Any filter may legitimately match
|
|
118
172
|
nothing — render *nothing* then, never a heading over an empty row. Upsells
|
|
@@ -120,6 +174,13 @@ beside a product need no query: `p.upsells` / `p.crossSells` from `useProduct`.
|
|
|
120
174
|
|
|
121
175
|
## Product page
|
|
122
176
|
|
|
177
|
+
```jsx
|
|
178
|
+
import { useProduct, useProductGallery, useAddToCartButton, useStoreInfo, useStorefrontSeo, productSeo, variantAxes, productSpecs } from "@/commerce/storefront";
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
(Add `useProductReviews` only if the store has reviews; drop `productSpecs` if
|
|
182
|
+
these products carry no modifiers.)
|
|
183
|
+
|
|
123
184
|
`useProduct(slug)` (or `{ id }`) owns fetch + variant selection + quantity +
|
|
124
185
|
price, race-safe, selection mirrored to the URL. ⚑ `status: "not_found"` is a
|
|
125
186
|
404 page, not a spinner.
|
|
@@ -134,7 +195,8 @@ precisely so they can sit at the top:
|
|
|
134
195
|
const p = useProduct(slug);
|
|
135
196
|
const g = useProductGallery(p.product, p.view);
|
|
136
197
|
const buy = useAddToCartButton(p, { onAdded: () => navigate("/bag") });
|
|
137
|
-
|
|
198
|
+
const { settings } = useStoreInfo(); // the ONLY source of store name + currency
|
|
199
|
+
useStorefrontSeo(productSeo(p.product, p.view, { storeName: settings?.store_name, currency: settings?.currency }));
|
|
138
200
|
|
|
139
201
|
if (p.status === "loading") return /* your loading state */;
|
|
140
202
|
if (p.status === "not_found") return /* your 404 */;
|
|
@@ -180,13 +242,42 @@ anywhere.) Build your layout from:
|
|
|
180
242
|
are yours.
|
|
181
243
|
- **Description** — `product.description` is HTML; render as rich text
|
|
182
244
|
(`dangerouslySetInnerHTML`), `short_description` above it.
|
|
183
|
-
- **Specs** — `productSpecs(product)` → `[{ key, label, value
|
|
184
|
-
`meta_data` (Material, Care). `[]`
|
|
245
|
+
- **Specs** — `productSpecs(product)` → `[{ key, label, value, type, number,
|
|
246
|
+
unit, items }]` from `meta_data` (Material, Care, Provenance, Weight). `[]`
|
|
247
|
+
means no section at all. ⚑ **Don't `.map()` it into one uniform label/value
|
|
248
|
+
table** — that is the most reliable tell of a generated product page. Every
|
|
249
|
+
row's `type` is inferred for you so the branch point is already there:
|
|
250
|
+
`"numeric"` (with `number` and `unit` split out), `"duration"`, `"location"`,
|
|
251
|
+
`"list"` (with `items`), `"text"`.
|
|
252
|
+
|
|
253
|
+
```jsx
|
|
254
|
+
// ❌ what a generated store ships: one grey table, every store the same
|
|
255
|
+
<dl>{specs.map((s) => <div key={s.key}><dt>{s.label}</dt><dd>{s.value}</dd></div>)}</dl>
|
|
256
|
+
|
|
257
|
+
// ✅ the figures read as figures, the rest falls through to a plain row
|
|
258
|
+
{productSpecs(product).map((s) =>
|
|
259
|
+
s.type === "numeric" ? <Figure key={s.key} label={s.label} n={s.number} unit={s.unit} /> // 0.75 ct, set large
|
|
260
|
+
: s.type === "list" ? <Bars key={s.key} parts={s.items} /> // 70% wool / 30% cashmere
|
|
261
|
+
: s.type === "location" ? <Sourced key={s.key} place={s.value} /> // a located line, a pin
|
|
262
|
+
: <Row key={s.key} label={s.label} value={s.value} />)}
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
Design the two or three that carry *this* product's meaning; branch on `s.key`
|
|
266
|
+
instead when one particular modifier deserves its own treatment. And they need
|
|
267
|
+
not sit in one block — a spec can go under the gallery, beside the price, or
|
|
268
|
+
inside the description.
|
|
185
269
|
- **Breadcrumbs** — build from `categories`
|
|
186
270
|
(`/collection?category_id=${c.id}`); skip on a flat catalog. Ribbons are
|
|
187
271
|
labels, not breadcrumbs.
|
|
188
272
|
|
|
189
|
-
All optional — include what this store's products actually have
|
|
273
|
+
All optional — include what this store's products actually have — and each is
|
|
274
|
+
one hook, **not one component style**. The same rule the specs bullet states
|
|
275
|
+
applies to the axes: `variantAxes` gives you `axis.key` / `axis.name` so a
|
|
276
|
+
colour axis can be swatches in the real colours, a size axis chips with a size
|
|
277
|
+
guide beside them, a material axis a small sample image — every axis rendered as
|
|
278
|
+
the identical chip row is the other half of the generated-page tell. The ⚑ rules
|
|
279
|
+
above (one control per axis, unbuyable options disabled) constrain the
|
|
280
|
+
*behaviour* of a selector, never its form.
|
|
190
281
|
|
|
191
282
|
### Reviews — optional
|
|
192
283
|
|
|
@@ -208,6 +299,10 @@ approval", so render `message`, never your own text. `policy` is
|
|
|
208
299
|
|
|
209
300
|
## Cart / bag
|
|
210
301
|
|
|
302
|
+
```jsx
|
|
303
|
+
import { useCart, CartLine, useTotalsLines, useCoupon, useFormatMoney } from "@/commerce/storefront";
|
|
304
|
+
```
|
|
305
|
+
|
|
211
306
|
A cart *page* is optional: a store selling one made-to-order piece reads better
|
|
212
307
|
as buy-now straight to checkout. The surface is four hooks: `useCart()`
|
|
213
308
|
(`status`, `lines`, `notices`), `CartLine` (headless render-prop binding
|
|
@@ -223,6 +318,26 @@ the checkout): coupons are admin-only data, redeemable only through a field the
|
|
|
223
318
|
customer types into — if no field exists anywhere, don't seed coupons and don't
|
|
224
319
|
write "use WELCOME10" in the copy.
|
|
225
320
|
|
|
321
|
+
⚑ **`pending` is one row's flag, and it stays up until that update finishes.**
|
|
322
|
+
The window is not the click: `increase`/`decrease` set the optimistic number and
|
|
323
|
+
start a 250ms debounce, `pending` goes true when the request leaves, and returns
|
|
324
|
+
to false **only after the new cart view has landed** — so `pending === false`
|
|
325
|
+
with no `error` means that row's quantity and the totals are settled, not just
|
|
326
|
+
that a request returned. (`remove()` skips the debounce, `pending` immediately.)
|
|
327
|
+
So **disable and mark only that row** — `disabled={l.pending}` on its own
|
|
328
|
+
controls, `aria-busy` on the row — since `pending` says nothing about the other
|
|
329
|
+
lines, and stalling the whole cart over one 250ms stepper reads as a broken page.
|
|
330
|
+
And note **`status` never returns to `"loading"` for a mutation**: it settles
|
|
331
|
+
once, on first load, then only moves between `"empty"` and `"ready"`. There is
|
|
332
|
+
deliberately no cart-wide busy flag — `status` is the page's shape, `pending` is
|
|
333
|
+
"did that change land".
|
|
334
|
+
|
|
335
|
+
⚑ **Repeated controls need unique accessible names.** A three-line cart renders
|
|
336
|
+
three buttons named "Remove", and "+" or "×" alone names nothing at all — put the
|
|
337
|
+
line in the label (``aria-label={`Remove ${line.name}`}``, likewise ±, and a
|
|
338
|
+
drawer's close button). Identical or empty names are ambiguous to a screen reader
|
|
339
|
+
and to anything driving the page by name.
|
|
340
|
+
|
|
226
341
|
**Reference implementation** — read once for the wiring, then write your own
|
|
227
342
|
page: the structure below is correct, the presentation is deliberately absent.
|
|
228
343
|
Restyle, rearrange, split into your own components; the ⚑ rules are the part
|
|
@@ -241,12 +356,15 @@ function Bag() {
|
|
|
241
356
|
{lines.map((line) => (
|
|
242
357
|
<CartLine key={line.item_key} line={line}>
|
|
243
358
|
{(l) => ( /* line: name, attributesLabel, image, total — l: the controls */
|
|
244
|
-
<li>
|
|
359
|
+
<li aria-busy={l.pending}> {/* busy scope is THIS row, never the cart */}
|
|
245
360
|
{line.name} {line.attributesLabel}
|
|
246
|
-
<button onClick={l.decrease} disabled={!l.canDecrease || l.pending}
|
|
361
|
+
<button onClick={l.decrease} disabled={!l.canDecrease || l.pending}
|
|
362
|
+
aria-label={`Decrease quantity of ${line.name}`}>−</button>
|
|
247
363
|
{l.quantity}
|
|
248
|
-
<button onClick={l.increase} disabled={!l.canIncrease || l.pending}
|
|
249
|
-
|
|
364
|
+
<button onClick={l.increase} disabled={!l.canIncrease || l.pending}
|
|
365
|
+
aria-label={`Increase quantity of ${line.name}`}>+</button>
|
|
366
|
+
<button onClick={l.remove} disabled={l.pending}
|
|
367
|
+
aria-label={`Remove ${line.name}`}>Remove</button>
|
|
250
368
|
{formatMoney(line.total)}
|
|
251
369
|
{l.error && <p role="alert">{l.error.message}</p>}
|
|
252
370
|
</li>
|
|
@@ -266,15 +384,50 @@ function Bag() {
|
|
|
266
384
|
No shipping estimator here — checkout reprices shipping and tax from the
|
|
267
385
|
address.
|
|
268
386
|
|
|
387
|
+
### If the cart is a drawer
|
|
388
|
+
|
|
389
|
+
Same hooks, same rows — a drawer only adds what a cart *page* gets for free:
|
|
390
|
+
|
|
391
|
+
**Close it on navigation.** A product link or "Checkout" inside the drawer
|
|
392
|
+
changes the route with the drawer still open, leaving it hanging over the new
|
|
393
|
+
page. One effect covers every link inside, so no link has to remember:
|
|
394
|
+
|
|
395
|
+
```jsx
|
|
396
|
+
const { pathname } = useLocation();
|
|
397
|
+
useEffect(() => { setOpen(false); }, [pathname]);
|
|
398
|
+
```
|
|
399
|
+
|
|
400
|
+
**A closed drawer must be inert, not just invisible.** Hidden with `opacity` or
|
|
401
|
+
`translate`, its buttons stay focusable and clickable — Tab walks into an
|
|
402
|
+
invisible cart, and a click lands on a "Remove" nobody can see. Either don't
|
|
403
|
+
render it (`{open && <Drawer/>}`) or, if it stays mounted for the transition, use
|
|
404
|
+
`hidden` / `visibility: hidden` / `inert`. ⚑ Never `aria-hidden` alone: that
|
|
405
|
+
hides it from a screen reader while leaving it fully clickable.
|
|
406
|
+
|
|
407
|
+
**The backdrop is not the close button.** Both close the drawer; only one is a
|
|
408
|
+
control. Name the visible button (`aria-label="Close cart"`) and leave the
|
|
409
|
+
backdrop an unnamed overlay — `onClick={close}`, `aria-hidden="true"`, no tab
|
|
410
|
+
stop. Two elements named "Close cart" are ambiguous to a screen reader and to
|
|
411
|
+
anything driving the page; the keyboard's way out is Esc and the button.
|
|
412
|
+
|
|
269
413
|
## Checkout
|
|
270
414
|
|
|
415
|
+
```jsx
|
|
416
|
+
import { CheckoutProvider, useCheckoutContext, useCheckoutBlockers, useAddressForm, ShippingMethodPicker, PaymentMethodPicker, useCart, useTotalsLines, useCoupon, useFormatMoney } from "@/commerce/storefront";
|
|
417
|
+
```
|
|
418
|
+
|
|
419
|
+
(`useCoupon` only if the coupon field lives here rather than in the cart.)
|
|
420
|
+
|
|
271
421
|
The state machine is `useCheckout`, shared across the page's regions by
|
|
272
422
|
`CheckoutProvider` + `useCheckoutContext()`. It reprices shipping/tax from the
|
|
273
423
|
address automatically (debounced, never on a half-typed address), derives the
|
|
274
424
|
shipping and payment choices, gates the button (`canPlaceOrder` +
|
|
275
425
|
`useCheckoutBlockers()` in words), and `placeOrder()` handles **both**
|
|
276
426
|
navigations — online gateway → provider redirect, everything else →
|
|
277
|
-
`/order-received`.
|
|
427
|
+
`/order-received`. Both are **full page loads** (`window.location.assign`),
|
|
428
|
+
which is why the order-received page boots from the URL alone; pass
|
|
429
|
+
`orderReceivedPath: null` and `navigate(orderReceivedUrl(result))` if you want
|
|
430
|
+
a router transition instead. The address form comes from `useAddressForm(which)` as a
|
|
278
431
|
field spec (`state` collected, country options never null); the two
|
|
279
432
|
store-data choices come through the headless `ShippingMethodPicker` /
|
|
280
433
|
`PaymentMethodPicker`, whose render props enumerate every branch.
|
|
@@ -378,6 +531,10 @@ function AddressFields({ which }) {
|
|
|
378
531
|
|
|
379
532
|
## Order received
|
|
380
533
|
|
|
534
|
+
```jsx
|
|
535
|
+
import { useOrderReturn, useTotalsLines, useFormatMoney } from "@/commerce/storefront";
|
|
536
|
+
```
|
|
537
|
+
|
|
381
538
|
**Mandatory route** — every payment link returns here, and confirming is what
|
|
382
539
|
marks a card order paid. `useOrderReturn()` is the whole page's logic: it reads
|
|
383
540
|
`order_id`/`order_key` from the URL, verifies with the provider (idempotent),
|
|
@@ -439,7 +596,7 @@ above the status guards.
|
|
|
439
596
|
| Page | budget (chars) | rationale |
|
|
440
597
|
|---|---|---|
|
|
441
598
|
| Checkout | ≤ 5K | your markup over the reference above — the logic is all hook calls |
|
|
442
|
-
| Cart / bag | ≤ 3K | `useCart` + `CartLine` rows + totals + coupon + empty state |
|
|
599
|
+
| Cart / bag | ≤ 3K | `useCart` + `CartLine` rows + totals + coupon + empty state — a drawer is its own component with its own 3K, not an extension of this one |
|
|
443
600
|
| Order-received | ≤ 2.5K | five states + payment instructions + summary |
|
|
444
601
|
| Product page | ≤ 5K | your layout and type around `useProduct`, `variantAxes`, `useAddToCartButton`, the gallery |
|
|
445
602
|
| Collection | ≤ 3K | `useProductList` + custom card + pagination controls |
|
|
@@ -449,21 +606,70 @@ above the status guards.
|
|
|
449
606
|
These budgets assume the hooks carry the logic and your markup carries only the
|
|
450
607
|
design. Over budget ⇒ you are re-implementing something a hook does — an
|
|
451
608
|
address spec, a quantity clamp, totals math, variant resolution, add-to-cart
|
|
452
|
-
error recovery. Go back to the hook and delete your version.
|
|
609
|
+
error recovery. Go back to the hook and delete your version. Design detail is
|
|
610
|
+
not what pushes a page over: giving a colour axis swatches or a composition
|
|
611
|
+
modifier bars costs a few hundred characters, and that is what the budget is
|
|
612
|
+
for.
|
|
613
|
+
|
|
614
|
+
## If you drive the storefront from a browser script
|
|
615
|
+
|
|
616
|
+
Whatever you choose to check and however you check it, these are what make a
|
|
617
|
+
working storefront look broken under a script. The common cause is acting
|
|
618
|
+
faster than the cart settles: the hooks are optimistic and debounced, so the DOM
|
|
619
|
+
is briefly right about the *intent* and wrong about the *state*.
|
|
620
|
+
|
|
621
|
+
- **Wait for the cart, then for each row.** Two waits, neither optional. Before
|
|
622
|
+
the first action, wait for the initial load to settle — `status` leaves
|
|
623
|
+
`"loading"` exactly once, so the signal is the loaded UI (a row, or the empty
|
|
624
|
+
state), never a fixed sleep. Then after every stepper click wait for **that
|
|
625
|
+
row**: the click starts a 250ms debounce before the request even leaves, so
|
|
626
|
+
reading the quantity or total straight after gives the optimistic number and
|
|
627
|
+
stale totals, and two quick clicks send **one** request for the final number.
|
|
628
|
+
Wait for the row's busy state to clear (`aria-busy`, re-enabled buttons) before
|
|
629
|
+
reading or clicking again.
|
|
630
|
+
- **Scope actions to the visible drawer.** With a drawer, the page can hold two
|
|
631
|
+
"Remove" buttons for one line — drawer and cart page behind it — and a
|
|
632
|
+
closed-but-mounted drawer keeps its copies clickable. Query inside the open
|
|
633
|
+
drawer's container, not the document. A click that seems to do nothing usually
|
|
634
|
+
hit the hidden copy.
|
|
635
|
+
- **Remove lines one at a time.** Clicking every "Remove" in one pass fails on
|
|
636
|
+
its own terms: cart calls are serialized, each removal re-renders the list, and
|
|
637
|
+
buttons collected up front are detached by the time the loop reaches them.
|
|
638
|
+
Remove one, wait for the row to disappear, then the next.
|
|
639
|
+
- **Verify the checkout navigation before cleaning up.** Confirm you are on
|
|
640
|
+
`/checkout` — URL plus a field of the form on screen — before emptying the cart
|
|
641
|
+
or moving on. Tearing the cart down while still on the cart page, or
|
|
642
|
+
mid-navigation, produces an empty checkout that reads as a routing bug.
|
|
643
|
+
- **Filling the checkout.** Every field is a controlled React input, so writing
|
|
644
|
+
`el.value` changes nothing React sees. Use the harness's own fill (it
|
|
645
|
+
dispatches `input` + `change`) — never lift the native setter off
|
|
646
|
+
`HTMLInputElement.prototype` and call `descriptor.set(v)`: detached from the
|
|
647
|
+
element it throws `Illegal invocation`, and the workaround it is reaching for
|
|
648
|
+
is what the fill helper already does.
|
|
649
|
+
- **`placeOrder` ends the page.** It navigates with `window.location.assign`
|
|
650
|
+
(above), so a script that placed an order loses its page context and can land
|
|
651
|
+
back at `/` — while the order itself was created normally. That is the hard
|
|
652
|
+
navigation, not a broken redirect. The confirmation is reachable at any time
|
|
653
|
+
from a fresh navigation to `/order-received?order_id=…&order_key=…` (the ids
|
|
654
|
+
come back in `placeOrder`'s result, and `commerce/admin-orders` `search` has
|
|
655
|
+
the order either way).
|
|
453
656
|
|
|
454
657
|
## Done — forget this file
|
|
455
658
|
|
|
456
659
|
- [ ] Catalog UI exists in whatever form fits the store (list, product pages, or both), plus a checkout, plus `/order-received`.
|
|
457
|
-
- [ ] **One** `<StorefrontProvider>` above every storefront route
|
|
660
|
+
- [ ] **One** `<StorefrontProvider>` above every storefront route — on the layout route's element, wrapping the layout that renders `<Outlet/>` (or wrapping `<Routes>` if the store has no shared chrome); one client, no hand-rolled `cart_token`.
|
|
661
|
+
- [ ] Every page's imports came from its section's import line above: one path (`@/commerce/storefront`), nothing imported from `@/commerce/utils`, and no imported name — React's included — left unused.
|
|
458
662
|
- [ ] Pages branch on `status`; no page maps a possibly-null list or shows an empty state while loading.
|
|
459
663
|
- [ ] Gateways/currency/countries read from `useStoreInfo()` only.
|
|
460
664
|
- [ ] If the store has coupons, a coupon field (`useCoupon`) exists in the cart or the checkout.
|
|
461
665
|
- [ ] `/order-received` renders `useOrderReturn`'s states **including `paymentInstructions`**.
|
|
462
666
|
- [ ] Variant options render one control per axis; unbuyable options are disabled, not hidden.
|
|
667
|
+
- [ ] Cart rows scope their busy state to the row (`l.pending` + `aria-busy`), and every repeated control (remove, ±, a drawer's close) has a unique accessible name.
|
|
668
|
+
- [ ] A cart drawer closes on route change, is **inert** when closed (not merely invisible), and its backdrop is not a second control named "Close cart".
|
|
463
669
|
- [ ] No hook logic was re-implemented by hand (address fields, quantity clamps, totals rows, shipping/payment branching).
|
|
464
670
|
- [ ] The storefront carries the design you settled on before reading this file — no page ships the reference snippets' bare structure or placeholder copy.
|
|
671
|
+
- [ ] Specs and axes are rendered by what they are (`productSpecs`' `type`/`key`, `axis.key`) — not one uniform label/value table and one identical chip row.
|
|
465
672
|
- [ ] Every page is within its budget above.
|
|
466
|
-
- [ ] A real purchase completes in the preview — pick a variant, add it, check out, place an offline order, land on `/order-received`.
|
|
467
673
|
|
|
468
674
|
Record these lines in your working notes; do not re-read this file.
|
|
469
675
|
|
|
@@ -63,11 +63,14 @@ try {
|
|
|
63
63
|
categories: ["Shoes"], // get-or-created by display name
|
|
64
64
|
ribbons: ["Best Seller"], // flat labels, not a hierarchy
|
|
65
65
|
|
|
66
|
-
// Descriptive properties → the spec
|
|
67
|
-
//
|
|
68
|
-
//
|
|
66
|
+
// Descriptive properties → the product page's spec rows
|
|
67
|
+
// (`productSpecs(product)`, which types each one so a weight can render
|
|
68
|
+
// as a figure and a composition as bars). NOT variant axes and NOT
|
|
69
|
+
// ribbons: they describe the product, they don't select anything.
|
|
70
|
+
// Values are strings; a leading `_` hides a row.
|
|
69
71
|
meta_data: [
|
|
70
72
|
{ key: "Material", value: "Recycled knit upper" },
|
|
73
|
+
{ key: "Weight", value: "248 g" }, // "<number> <unit>" → a numeric row
|
|
71
74
|
{ key: "Care", value: "Machine wash cold" },
|
|
72
75
|
],
|
|
73
76
|
|
|
@@ -31,7 +31,7 @@ A listing **row** is the product record itself (minus paywalled fields) plus res
|
|
|
31
31
|
| `images[]`, `featured`, `short_description`, `description` | ✅ | ✅ | Cards normally use `images[0]` + `short_description`; every entry is an **object** — §2 |
|
|
32
32
|
| `sku`, `stock_status`, `stock_quantity`, `manage_stock`, `backorders` | ✅ | ✅ | |
|
|
33
33
|
| `average_rating`, `rating_count`, `total_sales` | ✅ | ✅ | Enough for stars on a card; the reviews themselves are not in a row |
|
|
34
|
-
| `downloadable`, `virtual`, `weight`, `dimensions`, `attributes[]`, `meta_data` | ✅ | ✅ | `attributes[]` is the variant axes; `meta_data` is the descriptive **modifiers** (`productSpecs(product)` turns them into spec
|
|
34
|
+
| `downloadable`, `virtual`, `weight`, `dimensions`, `attributes[]`, `meta_data` | ✅ | ✅ | `attributes[]` is the variant axes; `meta_data` is the descriptive **modifiers** (`productSpecs(product)` turns them into typed spec rows) |
|
|
35
35
|
| **`ribbons`** (resolved), `ribbon_ids`, `category_ids` | ✅ | ✅ | Rows carry `{id, name}` ribbons; `get-product` returns the full records |
|
|
36
36
|
| **`categories`** (resolved) | ❌ *ids only* | ✅ | §6 to add them to rows |
|
|
37
37
|
| **`variations[]`** (per-variant price/stock/image/attributes) | ❌ | ✅ | Why a product with variants can't be fully priced from a row |
|
|
@@ -54,14 +54,18 @@ Three rules used to be prose here and are now enforced by exports — use them a
|
|
|
54
54
|
|
|
55
55
|
- **From-price.** `admin-products` (and the seeder) roll a parent's `regular_price`/`price`/`on_sale` up from the cheapest publishable variant on every save, so the parent price is real, sortable and filterable — but it is the **lowest** price, not *the* price. `productPrice(rowOrView, {formatMoney})` / `useProductPrice(rowOrView)` accept **either** a listing row or a `resolveSelection` view and return `{label, compareAtLabel, onSale, isFrom, isRange, min, max}`: "From €19.99" on a card, a range on an unresolved page, the exact price once resolved.
|
|
56
56
|
- **Images are objects, and may be absent.** Every stored image is `{src, name, alt}`, never a URL string, and `images` can legitimately be empty. `productImages(product)` / `normalizeImage(entry)` return clean entries (non-empty `src`, defaulted `alt`), and an empty array is the *render your placeholder* signal — `useProductGallery` builds on them (`hasImages`). Passing the object itself to an `<img src>` fails the load and shows the placeholder for every product in the store.
|
|
57
|
-
- **Modifiers are not attributes.** `meta_data` (Material, Care, GTIN) is
|
|
57
|
+
- **Modifiers are not attributes.** `meta_data` (Material, Care, GTIN) is descriptive — never a selector, never a ribbon. How each modifier *renders* is a design decision (§3); what it can never become is a control.
|
|
58
58
|
|
|
59
|
-
## 3. What each view
|
|
59
|
+
## 3. What each view *can* render
|
|
60
60
|
|
|
61
|
-
|
|
61
|
+
Both lists below are field inventories — what the data supports — **not a layout and not an order**. Read them for availability, then design the surface; a store that renders exactly these fields in exactly this sequence is the generic storefront every generated catalog produces.
|
|
62
|
+
|
|
63
|
+
**Card:** image, name, `price.label`, sale badge from `on_sale`, stars from `average_rating`/`rating_count`, out-of-stock state from `stock_status`, one or two ribbons — plus anything else on the row (`weight`, `dimensions`, `meta_data` via `productSpecs`) that says more about *this* catalog than a star average does. Link the whole card to the product page; the card, the grid's rhythm and whether every card is even the same size are yours.
|
|
62
64
|
|
|
63
65
|
**Product page:** gallery, name, price, variant selectors, stock, `short_description` then `description`, SKU, categories breadcrumb, ribbons, reviews, then upsells/cross-sells. Everything except the markup has a hook or helper: `useProductGallery`, `variantAxes(view, pick)`, `useAddToCartButton`, `productSpecs(product)`, `useProductReviews`, `p.upsells`/`p.crossSells`.
|
|
64
66
|
|
|
67
|
+
**Attributes and modifiers are individually designable.** `variantAxes` exposes `axis.key`/`axis.name`, and `productSpecs` exposes `key`/`label` plus an inferred `type` — `numeric` (with `number`/`unit` split out), `duration`, `location`, `list` (with `items`), `text` — precisely so a page can branch on *which* one it is: colour as swatches, size as chips next to a size guide, a `list` "Composition" as bars, a `location` "Provenance" as a located line, a `numeric` "Weight" as a figure in the display face, "Certification" as a seal. One uniform chip row for every axis and one grey label/value table for every modifier is a default, not a requirement — pick the two or three that carry the product's meaning, give them real treatment, and let the remainder fall back to a plain row. The rules in §5 govern selector *behaviour* (one control per axis, unbuyable disabled), never its form, and they hold whatever the control looks like.
|
|
68
|
+
|
|
65
69
|
## 4. Ribbons — in **both** views
|
|
66
70
|
|
|
67
71
|
Ribbons are flat, cross-cutting labels ("Best Seller", "New", "Gift"); categories are the hierarchical spine. Generated storefronts routinely omit ribbons entirely. Don't.
|
|
@@ -37,7 +37,7 @@ fs.copyFileSync(
|
|
|
37
37
|
|
|
38
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".
|
|
39
39
|
|
|
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
|
|
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 from the app's secrets (`secrets.get("STRIPE_SECRET_KEY")`); **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`, with the name of the missing secret in the function log only.
|
|
41
41
|
|
|
42
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.
|
|
43
43
|
|
|
@@ -55,9 +55,10 @@ With the gateway enabled and no provider behind it, picking Credit card answers
|
|
|
55
55
|
For a **custom** provider (the shipped files already obey all of these):
|
|
56
56
|
|
|
57
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.
|
|
58
|
-
- **Credentials come from
|
|
58
|
+
- **Credentials come from Base44 secrets** (`import { secrets } from "base44:runtime"` → `secrets.get("STRIPE_SECRET_KEY")`) — never an entity, never the code, 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.
|
|
59
|
+
- **Never tell the caller what is misconfigured.** Secret names, provider error text and account state go to `console.error`; the storefront gets a flat `503 no_card_payment_provider` / `502` with no detail. A checkout page that names the missing secret hands a visitor a map of the app's configuration.
|
|
59
60
|
- **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.
|
|
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.
|
|
61
|
+
- **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. Stamp the app id alongside them (`base44_app_id`, from `secrets.get("BASE44_APP_ID")`), which is how the platform attributes the payment back to this app.
|
|
61
62
|
- **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.
|
|
62
63
|
- The helpers in `shared/commerce/payments.ts` (return-URL building, `confirmCardPayment`, reference bookkeeping) are premade — don't duplicate or bypass them.
|
|
63
64
|
|
|
@@ -19,14 +19,24 @@ import {
|
|
|
19
19
|
* StorefrontProvider — one client, one store-info cache, ONE shared cart.
|
|
20
20
|
*
|
|
21
21
|
* Mount it once, above every storefront page (product list, product page,
|
|
22
|
-
* cart, checkout, order-received)
|
|
22
|
+
* cart, checkout, order-received). It is NOT a <Route>. A store with shared
|
|
23
|
+
* chrome — nearly all of them — mounts it on a pathless layout route, wrapping
|
|
24
|
+
* the layout that renders <Outlet/>, which keeps the nav's cart badge and the
|
|
25
|
+
* page on one cart and leaves the admin outside:
|
|
23
26
|
*
|
|
24
27
|
* import { base44 } from "@/api/base44Client";
|
|
28
|
+
* <Route element={<StorefrontProvider base44={base44}><StoreLayout /></StorefrontProvider>}>
|
|
29
|
+
* <Route path="/" element={<Home />} /> …
|
|
30
|
+
* </Route>
|
|
31
|
+
*
|
|
32
|
+
* With no shared layout it can wrap <Routes> directly instead:
|
|
33
|
+
*
|
|
25
34
|
* <StorefrontProvider base44={base44}> <Routes>…</Routes> </StorefrontProvider>
|
|
26
35
|
*
|
|
27
|
-
*
|
|
28
|
-
* allows only <Route>/<Fragment> children there.
|
|
29
|
-
*
|
|
36
|
+
* As a child of <Routes> it throws ("is not a <Route> component"), since React
|
|
37
|
+
* Router allows only <Route>/<Fragment> children there. Never the other way
|
|
38
|
+
* round either: a layout that renders the provider inside itself puts the nav
|
|
39
|
+
* outside it, so the badge and the cart page read different carts.
|
|
30
40
|
*
|
|
31
41
|
* or, if other modules also need the raw client, create it once and share it:
|
|
32
42
|
*
|
|
@@ -128,7 +138,7 @@ export function useStorefrontState() {
|
|
|
128
138
|
const ctx = useContext(StorefrontContext);
|
|
129
139
|
if (!ctx) {
|
|
130
140
|
throw new Error(
|
|
131
|
-
"Storefront hooks need a <StorefrontProvider> above them — mount it once
|
|
141
|
+
"Storefront hooks need a <StorefrontProvider> above them — mount it once on a pathless layout route, wrapping the layout component that renders <Outlet/> (or, with no shared layout, around your <Routes>). It is never a <Route> itself: as a child of <Routes>, React Router rejects it.",
|
|
132
142
|
);
|
|
133
143
|
}
|
|
134
144
|
return ctx;
|
|
@@ -204,6 +214,28 @@ export function useFormatMoney() {
|
|
|
204
214
|
* `useCartLine`/`useAddToCart` can handle failures locally.
|
|
205
215
|
* - `applyCoupon(code)` resolves to `{ ok, cart }` or `{ ok: false, code,
|
|
206
216
|
* message }` — an invalid code is expected flow, not an exception.
|
|
217
|
+
*
|
|
218
|
+
* ## When `status` settles — and what it does not cover
|
|
219
|
+
*
|
|
220
|
+
* `status` is `"loading"` for exactly one thing: the **first** `getCart()` of
|
|
221
|
+
* the session has not resolved yet (internally, `cart === undefined`). It
|
|
222
|
+
* settles once, to `"empty"` or `"ready"`, and after that:
|
|
223
|
+
*
|
|
224
|
+
* - **A mutation never returns it to `"loading"`.** Adding, updating, removing
|
|
225
|
+
* or couponing leaves `status` as it was, previous numbers on screen, until
|
|
226
|
+
* the new view lands. There is no cart-wide busy flag by design: a page-wide
|
|
227
|
+
* spinner for a 250ms quantity step is worse than the stale number, and the
|
|
228
|
+
* right busy scope is the row (`useCartLine`'s `pending`) or the control that
|
|
229
|
+
* started it.
|
|
230
|
+
* - `"empty"` therefore means *loaded, with no items* — including after
|
|
231
|
+
* checkout consumes the cart — never "still arriving".
|
|
232
|
+
* - It flips `"empty"` → `"ready"` when the first line lands, so a header badge
|
|
233
|
+
* and a drawer switch states off the same signal.
|
|
234
|
+
*
|
|
235
|
+
* So branch **`status`** for the page's loading/empty/ready shape, and watch
|
|
236
|
+
* **`useCartLine().pending`** (or your own flag around `addItem`) for "did that
|
|
237
|
+
* change land". Anything waiting on a mutation — a queued follow-up action, a
|
|
238
|
+
* script driving the page — waits on the second, never the first.
|
|
207
239
|
*/
|
|
208
240
|
export function useCart() {
|
|
209
241
|
const { client, cart, cartError, mutationError, runCart } = useStorefrontState();
|
|
@@ -16,12 +16,16 @@
|
|
|
16
16
|
* store correct (e.g. an unbuyable variant option renders *disabled, not
|
|
17
17
|
* hidden*; a receipt page must render `paymentInstructions`).
|
|
18
18
|
*
|
|
19
|
-
* Setup (once, above every storefront route —
|
|
20
|
-
*
|
|
19
|
+
* Setup (once, above every storefront route — on a pathless layout route,
|
|
20
|
+
* wrapping the layout that renders <Outlet/>; as a child of <Routes> React
|
|
21
|
+
* Router throws "is not a <Route> component"):
|
|
21
22
|
*
|
|
22
23
|
* import { StorefrontProvider } from "@/commerce/storefront";
|
|
23
24
|
* import { base44 } from "@/api/base44Client";
|
|
24
|
-
* <StorefrontProvider base44={base44}
|
|
25
|
+
* <Route element={<StorefrontProvider base44={base44}><StoreLayout /></StorefrontProvider>}>
|
|
26
|
+
* <Route path="/" element={<Home />} /> …
|
|
27
|
+
* </Route>
|
|
28
|
+
* // no shared layout? <StorefrontProvider …> <Routes>…</Routes> </StorefrontProvider>
|
|
25
29
|
*
|
|
26
30
|
* ## Hooks
|
|
27
31
|
* - `useStorefront` / `useStoreInfo` / `useFormatMoney` / `useMoney` — the
|
|
@@ -63,7 +67,10 @@
|
|
|
63
67
|
* ## Helpers re-exported from `@/commerce/utils`
|
|
64
68
|
* - `variantAxes(view, pick)` — axes → options with selected/disabled/stock
|
|
65
69
|
* state derived, for the variant selector you write.
|
|
66
|
-
* - `productSpecs(product)` — `meta_data` →
|
|
70
|
+
* - `productSpecs(product)` — `meta_data` → descriptive rows carrying an
|
|
71
|
+
* inferred `type` (`numeric` with `number`/`unit` split out, `duration`,
|
|
72
|
+
* `location`, `list` with `items`, `text`), so a weight can be a figure and
|
|
73
|
+
* a composition bars instead of every modifier being one grey table row.
|
|
67
74
|
*/
|
|
68
75
|
export {
|
|
69
76
|
StorefrontProvider,
|
|
@@ -20,6 +20,37 @@ import { useCart } from "./StorefrontProvider";
|
|
|
20
20
|
* request per settle instead of one per click; and `sold_individually` is
|
|
21
21
|
* respected, so a one-per-customer product has no working "+".
|
|
22
22
|
*
|
|
23
|
+
* ## When `pending` settles — the exact window
|
|
24
|
+
*
|
|
25
|
+
* `pending` is this line's own flag, not the cart's, and it is **not** true for
|
|
26
|
+
* the whole gesture:
|
|
27
|
+
*
|
|
28
|
+
* 1. **Click → `pending` stays `false`.** `increase`/`decrease` only set the
|
|
29
|
+
* optimistic `quantity` and (re)start a `debounceMs` (250ms) timer. Nothing
|
|
30
|
+
* is in flight yet, and a further click restarts the timer, so a burst of
|
|
31
|
+
* clicks sends **one** request for the final number.
|
|
32
|
+
* 2. **Debounce elapses → `pending` becomes `true`** and the request goes out.
|
|
33
|
+
* `remove()` skips this step: it cancels the timer and goes `pending`
|
|
34
|
+
* immediately.
|
|
35
|
+
* 3. **`pending` returns to `false` only after the server's new cart view has
|
|
36
|
+
* been published to the provider** — the awaited mutation resolves through
|
|
37
|
+
* the provider's serialized queue, which sets the shared cart state before
|
|
38
|
+
* the await returns. So `pending === false` with `error === null` means this
|
|
39
|
+
* row's quantity, the cart's totals and any dependent badge are settled, not
|
|
40
|
+
* merely that the request finished.
|
|
41
|
+
*
|
|
42
|
+
* Two consequences worth designing for. **Disable and mark only this row**
|
|
43
|
+
* (`disabled={!l.canIncrease || l.pending}`) — `pending` says nothing about the
|
|
44
|
+
* other lines, and greying the whole cart because one stepper is busy makes a
|
|
45
|
+
* 250ms update look like a page-wide stall. And **`pending` is the only
|
|
46
|
+
* mutation-settled signal**: `useCart().status` never returns to `"loading"`
|
|
47
|
+
* for a mutation (see its doc comment), so a caller that needs to know an
|
|
48
|
+
* update landed — a script driving the page, a queued follow-up action — waits
|
|
49
|
+
* on this flag, per row, and not on cart `status`.
|
|
50
|
+
*
|
|
51
|
+
* On failure `pending` returns to `false`, the optimistic quantity rolls back
|
|
52
|
+
* to what the server still holds, and `error` is `{ code, message }`.
|
|
53
|
+
*
|
|
23
54
|
* @param {object} line a decorated line from `useCart().lines` (a raw
|
|
24
55
|
* `cart.items[n]` works too — it just has no `maxQuantity` hint)
|
|
25
56
|
* @param {{debounceMs?: number}} [options]
|
|
@@ -73,6 +73,17 @@ function resolvePaymentMethod(gateways, picked) {
|
|
|
73
73
|
* result yourself (a manual-gateway result carries
|
|
74
74
|
* `result.payment_instructions`).
|
|
75
75
|
*
|
|
76
|
+
* Both navigations are **full page loads** (`window.location.assign`), not
|
|
77
|
+
* router transitions: the provider hop has to leave the app, and the
|
|
78
|
+
* order-received page is built to boot from the URL alone (`order_id` +
|
|
79
|
+
* `order_key`), so a reload there is correct and shareable. Consequences
|
|
80
|
+
* worth knowing: React state does not survive it, and a browser script
|
|
81
|
+
* driving checkout loses its page context at this point — the order is
|
|
82
|
+
* still placed, so verify by navigating fresh to
|
|
83
|
+
* `orderReceivedUrl(result)`. For a client-side transition instead, pass
|
|
84
|
+
* `orderReceivedPath: null` and `navigate(orderReceivedUrl(result))`
|
|
85
|
+
* yourself.
|
|
86
|
+
*
|
|
76
87
|
* Blocker codes, in the order checked: `cart_loading`, `empty_cart`,
|
|
77
88
|
* `billing_incomplete`, `shipping_address_incomplete`, `shipping_recalculating`,
|
|
78
89
|
* `shipping_address_required`, `shipping_method_required`,
|
|
@@ -25,7 +25,9 @@
|
|
|
25
25
|
* - `address-spec.js` — `addressFieldSpec`: the checkout address form as data,
|
|
26
26
|
* with country/state options that are always arrays.
|
|
27
27
|
* - `images.js` — `productImages`: images normalized to `{src, name, alt}`.
|
|
28
|
-
* - `specs.js` — `productSpecs`: `meta_data` →
|
|
28
|
+
* - `specs.js` — `productSpecs`: `meta_data` → descriptive rows with an inferred
|
|
29
|
+
* `type` (numeric/duration/location/list/text), so each can be rendered as
|
|
30
|
+
* what it is rather than as another label/value row.
|
|
29
31
|
*
|
|
30
32
|
* Building the storefront in React? **Prefer `@/commerce/storefront`** — it
|
|
31
33
|
* layers headless hooks on top of this module, and a hook that pre-composes
|
|
@@ -1,26 +1,115 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Product spec rows — the descriptive properties a product page
|
|
3
|
-
*
|
|
2
|
+
* Product spec rows — the descriptive properties a product page shows
|
|
3
|
+
* (Material, Care, Fit, Provenance, Composition, Weight).
|
|
4
4
|
*
|
|
5
5
|
* These live in `product.meta_data` (the admin's *Modifiers* section) and are
|
|
6
6
|
* **not** attributes and not ribbons: they describe the product, they don't
|
|
7
7
|
* select a variant. Hidden keys (leading `_`) and empty values are skipped.
|
|
8
|
-
* You render the rows yourself:
|
|
9
8
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
9
|
+
* Each row carries a `type` — inferred from the value (and, for `"location"`,
|
|
10
|
+
* the key) — so the rendering decision is already made for you. **A `.map()`
|
|
11
|
+
* into one uniform label/value table is the fallback, not the target:** the
|
|
12
|
+
* types exist because a carat weight and a care instruction are not the same
|
|
13
|
+
* kind of fact and should not look alike.
|
|
14
|
+
*
|
|
15
|
+
* ```jsx
|
|
16
|
+
* // ❌ every product in every store, identical: one grey table
|
|
17
|
+
* <dl>{productSpecs(product).map((s) => (
|
|
18
|
+
* <div key={s.key}><dt>{s.label}</dt><dd>{s.value}</dd></div>))}</dl>
|
|
19
|
+
*
|
|
20
|
+
* // ✅ branch on type — the figures read as figures, the rest stays a row
|
|
21
|
+
* {productSpecs(product).map((s) =>
|
|
22
|
+
* s.type === "numeric" ? <Figure key={s.key} label={s.label} n={s.number} unit={s.unit} />
|
|
23
|
+
* : s.type === "location" ? <Sourced key={s.key} place={s.value} /> // a located line, a pin
|
|
24
|
+
* : s.type === "list" ? <Bars key={s.key} parts={s.items} /> // composition, materials
|
|
25
|
+
* : s.type === "duration" ? <Lead key={s.key} label={s.label} value={s.value} />
|
|
26
|
+
* : <Row key={s.key} label={s.label} value={s.value} />)}
|
|
27
|
+
* ```
|
|
28
|
+
*
|
|
29
|
+
* Design the two or three that carry *this* product's meaning (a weight set in
|
|
30
|
+
* the display face, a provenance beside a map, a composition as bars) and let
|
|
31
|
+
* the remainder fall through to the plain row. `key` is still there too, for
|
|
32
|
+
* when one particular modifier of this catalog deserves its own treatment
|
|
33
|
+
* regardless of type. And the rows need not sit in one block — a spec can go
|
|
34
|
+
* under the gallery, beside the price, or inside the description.
|
|
13
35
|
*
|
|
14
36
|
* @param {object} product
|
|
15
|
-
* @returns {Array<{key: string, label: string, value: string
|
|
16
|
-
*
|
|
37
|
+
* @returns {Array<{key: string, label: string, value: string,
|
|
38
|
+
* type: "numeric"|"duration"|"location"|"list"|"text",
|
|
39
|
+
* number: number|null, unit: string|null, items: string[]}>}
|
|
40
|
+
* `[]` when the product has no visible meta_data — render nothing, not an
|
|
41
|
+
* empty section. `number`/`unit` are set for `numeric` and `duration`
|
|
42
|
+
* (`unit` is `""` for a bare number), `items` for `list`, and are
|
|
43
|
+
* `null`/`[]` otherwise. `value` is always the store's own text, unchanged —
|
|
44
|
+
* the extra fields are there to render *with*, never a replacement for it.
|
|
17
45
|
*/
|
|
18
46
|
export function productSpecs(product) {
|
|
19
47
|
return (product?.meta_data ?? [])
|
|
20
48
|
.filter((m) => m?.key && !String(m.key).startsWith("_") && m.value != null && m.value !== "")
|
|
21
|
-
.map((m) =>
|
|
22
|
-
key
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
})
|
|
49
|
+
.map((m) => {
|
|
50
|
+
const key = String(m.key);
|
|
51
|
+
const value = String(m.value);
|
|
52
|
+
return { key, label: key.replace(/_/g, " "), value, ...classify(key, value) };
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const LOCATION_KEY =
|
|
57
|
+
/(origin|provenance|made[\s_-]?in|country|region|sourced|source|location|city|terroir|appellation|distillery|winery|atelier|workshop)/i;
|
|
58
|
+
|
|
59
|
+
const DURATION_UNIT =
|
|
60
|
+
/^(sec|secs|second|seconds|min|mins|minute|minutes|hr|hrs|hour|hours|day|days|week|weeks|month|months|year|years|yr|yrs)$/i;
|
|
61
|
+
|
|
62
|
+
/** Infer the render-relevant shape of one spec value. Never throws. */
|
|
63
|
+
function classify(key, raw) {
|
|
64
|
+
const value = raw.trim();
|
|
65
|
+
const plain = { type: "text", number: null, unit: null, items: [] };
|
|
66
|
+
|
|
67
|
+
if (LOCATION_KEY.test(key)) return { ...plain, type: "location" };
|
|
68
|
+
|
|
69
|
+
const qty = parseQuantity(value);
|
|
70
|
+
if (qty) {
|
|
71
|
+
const type = DURATION_UNIT.test(qty.unit) ? "duration" : "numeric";
|
|
72
|
+
return { ...plain, type, number: qty.number, unit: qty.unit };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const items = parseList(value);
|
|
76
|
+
if (items) return { ...plain, type: "list", items };
|
|
77
|
+
|
|
78
|
+
return plain;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** "0.75 ct" → {number: 0.75, unit: "ct"}; "18" → {number: 18, unit: ""}. */
|
|
82
|
+
function parseQuantity(value) {
|
|
83
|
+
const m = /^([-+]?[\d.,]+)\s*(.*)$/.exec(value);
|
|
84
|
+
if (!m) return null;
|
|
85
|
+
const number = toNumber(m[1]);
|
|
86
|
+
if (number === null) return null;
|
|
87
|
+
const unit = m[2].trim();
|
|
88
|
+
// A unit is a word or two of symbols/letters. Anything longer is prose that
|
|
89
|
+
// happens to start with a number ("2 pieces, hand-cut in the studio").
|
|
90
|
+
if (unit && (!/^[\p{L}%°µ"'/²³.\- ]{1,12}$/u.test(unit) || unit.split(/\s+/).length > 2)) return null;
|
|
91
|
+
return { number, unit };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Grouped thousands are separators; a lone comma between digits is a decimal. */
|
|
95
|
+
function toNumber(raw) {
|
|
96
|
+
let s = raw.replace(/\s/g, "");
|
|
97
|
+
if (/^[-+]?\d{1,3}(,\d{3})+(\.\d+)?$/.test(s)) s = s.replace(/,/g, "");
|
|
98
|
+
else if (/^[-+]?\d+,\d+$/.test(s)) s = s.replace(",", ".");
|
|
99
|
+
else if (s.includes(",")) return null;
|
|
100
|
+
const n = Number(s);
|
|
101
|
+
return Number.isFinite(n) ? n : null;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** "70% wool / 30% cashmere" → ["70% wool", "30% cashmere"]. */
|
|
105
|
+
function parseList(value) {
|
|
106
|
+
const parts = value
|
|
107
|
+
.split(/\s*[,;|·•/]\s*/)
|
|
108
|
+
.map((p) => p.trim())
|
|
109
|
+
.filter(Boolean);
|
|
110
|
+
if (parts.length < 2) return null;
|
|
111
|
+
// Short fragments with words in them — not a sentence that happens to have commas.
|
|
112
|
+
if (parts.some((p) => p.length > 24 || p.split(/\s+/).length > 3 || /[.!?]/.test(p))) return null;
|
|
113
|
+
if (!parts.some((p) => /\p{L}/u.test(p))) return null;
|
|
114
|
+
return parts;
|
|
26
115
|
}
|
|
@@ -41,8 +41,10 @@ export function attributeKey(attribute) {
|
|
|
41
41
|
|
|
42
42
|
/**
|
|
43
43
|
* The product's variation axes: every `attributes` entry, ordered by `position`.
|
|
44
|
-
* These become the selectors on the product page
|
|
45
|
-
*
|
|
44
|
+
* These become the selectors on the product page — one control per axis, in
|
|
45
|
+
* whatever form suits the axis (swatches for a colour, chips for a size).
|
|
46
|
+
* Descriptive properties are not attributes: they are `meta_data` entries, read
|
|
47
|
+
* with `productSpecs`, and they never select anything.
|
|
46
48
|
*
|
|
47
49
|
* @param {object} product
|
|
48
50
|
* @returns {Array<object>} the `product.attributes` entries
|