@base44/app-plugin-commerce 0.2.2 → 0.2.3
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/functions/commerce/seed-store/seed-catalog.ts +64 -6
- package/package.json +1 -1
- package/skills/commerce/install/02-storefront.md +54 -23
- package/skills/commerce/install/03-data.md +57 -14
- package/skills/commerce/references/shipping-and-tax.md +2 -0
- package/src/commerce/storefront/useAddressForm.js +41 -8
- package/src/commerce/storefront/useProduct.js +4 -0
- package/src/commerce/storefront/useProductGallery.js +4 -0
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
*/
|
|
20
20
|
import { HttpError } from "../../../shared/commerce/auth.ts";
|
|
21
21
|
import { CONTINENTS } from "../../../shared/commerce/data/continents.ts";
|
|
22
|
+
import { COUNTRIES } from "../../../shared/commerce/data/countries.ts";
|
|
22
23
|
import { getSettings } from "../../../shared/commerce/settings.ts";
|
|
23
24
|
import { scanAll } from "../../../shared/commerce/scan.ts";
|
|
24
25
|
import {
|
|
@@ -188,6 +189,56 @@ export function normalizeCatalogPayload(body: any): CatalogSpec | null {
|
|
|
188
189
|
};
|
|
189
190
|
}
|
|
190
191
|
|
|
192
|
+
const REGION_TYPES = ["country", "continent", "state"];
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Validate one region code against the static data the matcher uses, so a code
|
|
196
|
+
* that can never match is a 400 instead of a silently dead location.
|
|
197
|
+
*
|
|
198
|
+
* This exists because "everywhere else" has no country code, and a caller
|
|
199
|
+
* reaching for one anyway — `countries: ["*"]`, `["ALL"]`, `["ROW"]`, or an
|
|
200
|
+
* alpha-3 `["USA"]` — used to be accepted verbatim. The store then shipped with
|
|
201
|
+
* a location matching no address on earth, and the admin's country picker had
|
|
202
|
+
* nothing to select for it: an empty filter, no error anywhere. The catch-all
|
|
203
|
+
* is `rest_of_world: true`, and the error below says so.
|
|
204
|
+
*/
|
|
205
|
+
function checkRegionCode(
|
|
206
|
+
type: string,
|
|
207
|
+
code: string,
|
|
208
|
+
path: string,
|
|
209
|
+
err: (p: string, e: string) => void,
|
|
210
|
+
): boolean {
|
|
211
|
+
const catchAllHint =
|
|
212
|
+
` To cover every address no other location claims, pass rest_of_world: true instead of a placeholder code.`;
|
|
213
|
+
if (type === "continent") {
|
|
214
|
+
if (CONTINENTS.some((c) => c.code === code)) return true;
|
|
215
|
+
err(path, `unknown continent code: ${code} — known: ${CONTINENTS.map((c) => `${c.code} (${c.name})`).join(", ")}.${catchAllHint}`);
|
|
216
|
+
return false;
|
|
217
|
+
}
|
|
218
|
+
if (type === "country") {
|
|
219
|
+
if (COUNTRIES.some((c) => c.code === code)) return true;
|
|
220
|
+
err(path, `unknown country code: ${code} — must be an ISO 3166-1 alpha-2 code (US, IL, DE, not USA).${catchAllHint}`);
|
|
221
|
+
return false;
|
|
222
|
+
}
|
|
223
|
+
// state: "US:CA" — the country half must exist, and must declare states when
|
|
224
|
+
// the template ships them (US, CA, AU); elsewhere state is free text.
|
|
225
|
+
const [country, state] = code.split(":");
|
|
226
|
+
if (!state) {
|
|
227
|
+
err(path, `state region "${code}" must be COUNTRY:STATE — e.g. US:CA`);
|
|
228
|
+
return false;
|
|
229
|
+
}
|
|
230
|
+
const known = COUNTRIES.find((c) => c.code === country);
|
|
231
|
+
if (!known) {
|
|
232
|
+
err(path, `state region "${code}" names an unknown country: ${country} — must be an ISO 3166-1 alpha-2 code`);
|
|
233
|
+
return false;
|
|
234
|
+
}
|
|
235
|
+
if (known.states?.length && !known.states.some((s) => s.code === state)) {
|
|
236
|
+
err(path, `unknown ${known.name} state: ${state} — known: ${known.states.map((s) => s.code).join(", ")}`);
|
|
237
|
+
return false;
|
|
238
|
+
}
|
|
239
|
+
return true;
|
|
240
|
+
}
|
|
241
|
+
|
|
191
242
|
/**
|
|
192
243
|
* A caller location is Wix-shaped: a scope (countries as ISO codes, whole
|
|
193
244
|
* continents, explicit regions, or `rest_of_world` for the catch-all), shipping
|
|
@@ -219,19 +270,26 @@ function normalizeLocation(l: any, index: number, path: string, err: (p: string,
|
|
|
219
270
|
const regions: any[] = [];
|
|
220
271
|
for (const c of l.countries ?? []) {
|
|
221
272
|
const code = String(c ?? "").trim().toUpperCase();
|
|
222
|
-
if (code)
|
|
273
|
+
if (!code) continue;
|
|
274
|
+
if (!checkRegionCode("country", code, `${path}.countries`, err)) continue;
|
|
275
|
+
regions.push({ type: "country", code });
|
|
223
276
|
}
|
|
224
277
|
for (const c of l.continents ?? []) {
|
|
225
278
|
const code = String(c ?? "").trim().toUpperCase();
|
|
226
279
|
if (!code) continue;
|
|
227
|
-
if (!
|
|
228
|
-
err(`${path}.continents`, `unknown continent code: ${code} — known: ${CONTINENTS.map((continent) => `${continent.code} (${continent.name})`).join(", ")}`);
|
|
229
|
-
continue;
|
|
230
|
-
}
|
|
280
|
+
if (!checkRegionCode("continent", code, `${path}.continents`, err)) continue;
|
|
231
281
|
regions.push({ type: "continent", code });
|
|
232
282
|
}
|
|
233
283
|
for (const r of l.regions ?? []) {
|
|
234
|
-
if (r?.type
|
|
284
|
+
if (!r?.type || !r?.code) continue;
|
|
285
|
+
const type = String(r.type);
|
|
286
|
+
const code = String(r.code).toUpperCase();
|
|
287
|
+
if (!REGION_TYPES.includes(type)) {
|
|
288
|
+
err(`${path}.regions`, `unknown region type: ${type} — known: ${REGION_TYPES.join(", ")}`);
|
|
289
|
+
continue;
|
|
290
|
+
}
|
|
291
|
+
if (!checkRegionCode(type, code, `${path}.regions`, err)) continue;
|
|
292
|
+
regions.push({ type, code });
|
|
235
293
|
}
|
|
236
294
|
// The catch-all is defined by having NO regions, so a scoped "rest of the
|
|
237
295
|
// world" is a contradiction, not a merge: silently dropping either half would
|
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.3",
|
|
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",
|
|
@@ -9,6 +9,7 @@ carry_forward:
|
|
|
9
9
|
- "/order-received is mandatory and renders useOrderReturn's states, including paymentInstructions — how a normal (offline) customer learns how to pay."
|
|
10
10
|
- "Branch cart/list/product UI on `status`, never on `isEmpty`/nullable data — `isEmpty` is false while loading by design."
|
|
11
11
|
- "Variant options that aren't buyable render disabled, never hidden; one control per axis, never a list of variations."
|
|
12
|
+
- "Every hook on a page goes above its status guards — a hook after an early return crashes with \"Rendered more hooks than during the previous render\"."
|
|
12
13
|
---
|
|
13
14
|
|
|
14
15
|
# 02 — Storefront
|
|
@@ -22,9 +23,20 @@ One split decides everything here: **the logic is premade, the UI never is.**
|
|
|
22
23
|
a hook does.**
|
|
23
24
|
- **UI — yours, always.** Every element, class, layout and word of copy on
|
|
24
25
|
every page. Nothing in `@/commerce/storefront` renders markup or carries CSS,
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
26
|
+
so there is no component to drop in and nothing to restyle — the design is
|
|
27
|
+
the part of the storefront only you can do, and it should be designed, not
|
|
28
|
+
assembled.
|
|
29
|
+
|
|
30
|
+
## Before you begin
|
|
31
|
+
|
|
32
|
+
**Decide how the store looks as if this kit did not exist** — identity, type,
|
|
33
|
+
palette, spacing, the shape of a card, how a checkout is laid out — from the
|
|
34
|
+
brief and your own judgement. Then use this file for **how to wire it**:
|
|
35
|
+
everything below is implementation reference and **none of it is design input**.
|
|
36
|
+
The bare tags, flat structure and placeholder copy show where the data goes in
|
|
37
|
+
the fewest characters; they are not a look to adopt, keep or tweak. The finished
|
|
38
|
+
store should look like what you would have built with no kit at all — the kit's
|
|
39
|
+
only job is to make it cost far less code.
|
|
28
40
|
|
|
29
41
|
Each hook returns a complete view-model — a `status` to branch on,
|
|
30
42
|
ready-to-map arrays, handlers, error objects — and its **doc comment (JSDoc) is
|
|
@@ -110,16 +122,34 @@ beside a product need no query: `p.upsells` / `p.crossSells` from `useProduct`.
|
|
|
110
122
|
|
|
111
123
|
`useProduct(slug)` (or `{ id }`) owns fetch + variant selection + quantity +
|
|
112
124
|
price, race-safe, selection mirrored to the URL. ⚑ `status: "not_found"` is a
|
|
113
|
-
404 page, not a spinner.
|
|
114
|
-
|
|
125
|
+
404 page, not a spinner.
|
|
126
|
+
|
|
127
|
+
⚑ **Call every hook above the status guards.** This page needs more than one,
|
|
128
|
+
and a hook placed after an early `return` runs on some renders but not others —
|
|
129
|
+
React then throws *"Rendered more hooks than during the previous render"* the
|
|
130
|
+
moment the product resolves. All of these tolerate a null/loading product
|
|
131
|
+
precisely so they can sit at the top:
|
|
132
|
+
|
|
133
|
+
```jsx
|
|
134
|
+
const p = useProduct(slug);
|
|
135
|
+
const g = useProductGallery(p.product, p.view);
|
|
136
|
+
const buy = useAddToCartButton(p, { onAdded: () => navigate("/bag") });
|
|
137
|
+
useStorefrontSeo(productSeo(p.product, p.view, { storeName, currency }));
|
|
138
|
+
|
|
139
|
+
if (p.status === "loading") return /* your loading state */;
|
|
140
|
+
if (p.status === "not_found") return /* your 404 */;
|
|
141
|
+
const { product, view, price, categories } = p;
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
(`variantAxes` and `productSpecs` are plain functions, not hooks — they can go
|
|
145
|
+
anywhere.) Build your layout from:
|
|
115
146
|
|
|
116
147
|
- **Price** — `price.label`, plus `price.compareAtLabel` (struck through) when
|
|
117
148
|
on sale. Never read `product.price` directly — the parent's price is a
|
|
118
149
|
rolled-up from-price.
|
|
119
|
-
- **Gallery** — `
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
placeholder.
|
|
150
|
+
- **Gallery** — `g` from above → `{ hasImages, images, active, activeIndex,
|
|
151
|
+
setActiveIndex, next, prev }`. The active image already follows the variant
|
|
152
|
+
selection; `hasImages: false` means render your placeholder.
|
|
123
153
|
- **Variant selector** — `variantAxes(view, p.pick)` → one entry per axis:
|
|
124
154
|
`{ key, name, selectedOption, options: [{ value, selected, disabled,
|
|
125
155
|
outOfStock, pick }] }`. Map it to any control — buttons, swatches, a dropdown.
|
|
@@ -141,13 +171,13 @@ and build your layout from:
|
|
|
141
171
|
</fieldset>
|
|
142
172
|
))}
|
|
143
173
|
```
|
|
144
|
-
- **Buy box** — `
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
174
|
+
- **Buy box** — `buy` from above → `{ add, adding, error, disabled, soldOut,
|
|
175
|
+
needsSelection, quantity, increase, decrease, canIncrease, canDecrease,
|
|
176
|
+
showQuantity }`. It gates on purchasability, recovers from every add failure
|
|
177
|
+
and clamps quantity to stock and `sold_individually`. ⚑ Render `error.message`
|
|
178
|
+
inline; ⚑ `showQuantity: false` means no stepper (only 1 can be bought); the
|
|
179
|
+
button label should reflect `adding`/`soldOut`/`needsSelection` — the words
|
|
180
|
+
are yours.
|
|
151
181
|
- **Description** — `product.description` is HTML; render as rich text
|
|
152
182
|
(`dangerouslySetInnerHTML`), `short_description` above it.
|
|
153
183
|
- **Specs** — `productSpecs(product)` → `[{ key, label, value }]` from
|
|
@@ -327,18 +357,18 @@ function CheckoutForm() {
|
|
|
327
357
|
}
|
|
328
358
|
|
|
329
359
|
function AddressFields({ which }) {
|
|
330
|
-
const { fields,
|
|
331
|
-
return fields.map((f) => (
|
|
360
|
+
const { fields, countriesLoading } = useAddressForm(which);
|
|
361
|
+
return fields.map((f) => ( /* each field carries its own setter: f.set */
|
|
332
362
|
<label key={f.key}>
|
|
333
363
|
{f.label}{f.required && " *"}
|
|
334
364
|
{f.type === "select" ? (
|
|
335
|
-
<select value={f.value} onChange={(e) => set(
|
|
365
|
+
<select value={f.value} onChange={(e) => f.set(e.target.value)} autoComplete={f.autoComplete}>
|
|
336
366
|
<option value="">{f.key === "country" && countriesLoading ? "Loading…" : `Select ${f.label}`}</option>
|
|
337
367
|
{f.options.map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
|
|
338
368
|
</select>
|
|
339
369
|
) : (
|
|
340
370
|
<input type={f.type} value={f.value} required={f.required}
|
|
341
|
-
onChange={(e) => set(
|
|
371
|
+
onChange={(e) => f.set(e.target.value)} autoComplete={f.autoComplete} />
|
|
342
372
|
)}
|
|
343
373
|
{f.error && <span role="alert">{f.error}</span>}
|
|
344
374
|
</label>
|
|
@@ -401,8 +431,8 @@ useStorefrontSeo(collectionSeo({ title, products: list.products })); // col
|
|
|
401
431
|
// order-received is already noindex via useOrderReturn
|
|
402
432
|
```
|
|
403
433
|
|
|
404
|
-
|
|
405
|
-
|
|
434
|
+
The `*Seo` builders tolerate a null product, so this sits with the other hooks
|
|
435
|
+
above the status guards.
|
|
406
436
|
|
|
407
437
|
## Per-page output budgets
|
|
408
438
|
|
|
@@ -431,7 +461,7 @@ error recovery. Go back to the hook and delete your version.
|
|
|
431
461
|
- [ ] `/order-received` renders `useOrderReturn`'s states **including `paymentInstructions`**.
|
|
432
462
|
- [ ] Variant options render one control per axis; unbuyable options are disabled, not hidden.
|
|
433
463
|
- [ ] No hook logic was re-implemented by hand (address fields, quantity clamps, totals rows, shipping/payment branching).
|
|
434
|
-
- [ ] The storefront
|
|
464
|
+
- [ ] The storefront carries the design you settled on before reading this file — no page ships the reference snippets' bare structure or placeholder copy.
|
|
435
465
|
- [ ] Every page is within its budget above.
|
|
436
466
|
- [ ] A real purchase completes in the preview — pick a variant, add it, check out, place an offline order, land on `/order-received`.
|
|
437
467
|
|
|
@@ -442,3 +472,4 @@ Record these lines in your working notes; do not re-read this file.
|
|
|
442
472
|
- `/order-received` is mandatory and renders `useOrderReturn`'s states, including `paymentInstructions` — how a normal (offline) customer learns how to pay.
|
|
443
473
|
- Branch cart/list/product UI on `status`, never on `isEmpty`/nullable data — `isEmpty` is false while loading by design.
|
|
444
474
|
- Variant options that aren't buyable render disabled, never hidden; one control per axis, never a list of variations.
|
|
475
|
+
- Every hook on a page goes above its status guards — a hook after an early return crashes with "Rendered more hooks than during the previous render".
|
|
@@ -32,19 +32,45 @@ try {
|
|
|
32
32
|
store_name: "Aurora Threads",
|
|
33
33
|
currency: "EUR",
|
|
34
34
|
products: [
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
short_description: "A soft, breathable everyday tee.",
|
|
43
|
-
description: "<p>Cut from combed cotton…</p>", // HTML, rendered as rich text
|
|
44
|
-
},
|
|
35
|
+
// ── 1. Minimal. `name` is the only required key; a real store wants a
|
|
36
|
+
// price too, and everything else below is opt-in.
|
|
37
|
+
{ name: "Linen Scarf", regular_price: 45 },
|
|
38
|
+
|
|
39
|
+
// ── 2. Every product key the seeder accepts, on one product. Take the
|
|
40
|
+
// lines a product actually needs and drop the rest — there is no
|
|
41
|
+
// "complete" product to fill in.
|
|
45
42
|
{ name: "Runner Sneaker",
|
|
46
|
-
sku: "SNK-RUN",
|
|
43
|
+
sku: "SNK-RUN", // optional; makes re-runs idempotent
|
|
44
|
+
slug: "runner-sneaker", // derived from name when omitted
|
|
45
|
+
status: "publish", // draft | pending | private | publish (seeder defaults to publish)
|
|
46
|
+
featured: true, // → useProductList({ featured: true }) rails
|
|
47
|
+
|
|
47
48
|
regular_price: 89, // inherited by variations that don't override
|
|
49
|
+
sale_price: 79, // sets on_sale; the storefront strikes through regular_price
|
|
50
|
+
date_on_sale_from: "2026-03-01T00:00:00Z", // optional sale window (omit → sale is open-ended)
|
|
51
|
+
date_on_sale_to: "2026-03-31T23:59:59Z",
|
|
52
|
+
|
|
53
|
+
stock_quantity: 12, // implies manage_stock: true
|
|
54
|
+
manage_stock: true, // only needed to force tracking with no quantity
|
|
55
|
+
low_stock_amount: 3, // overrides the store's threshold
|
|
56
|
+
backorders: "no", // no | notify | yes
|
|
57
|
+
sold_individually: false, // true → max 1 per order (kills the qty stepper)
|
|
58
|
+
|
|
59
|
+
short_description: "Cushioned everyday runner.",
|
|
60
|
+
description: "<p>Cut from recycled knit…</p>", // HTML, rendered as rich text
|
|
61
|
+
images: ["https://…/sneaker.jpg"], // URLs or { src, alt } — see Images below
|
|
62
|
+
|
|
63
|
+
categories: ["Shoes"], // get-or-created by display name
|
|
64
|
+
ribbons: ["Best Seller"], // flat labels, not a hierarchy
|
|
65
|
+
|
|
66
|
+
// Descriptive properties → the spec table (`productSpecs(product)`).
|
|
67
|
+
// NOT variant axes and NOT ribbons: they describe the product, they
|
|
68
|
+
// don't select anything. Values are strings; a leading `_` hides a row.
|
|
69
|
+
meta_data: [
|
|
70
|
+
{ key: "Material", value: "Recycled knit upper" },
|
|
71
|
+
{ key: "Care", value: "Machine wash cold" },
|
|
72
|
+
],
|
|
73
|
+
|
|
48
74
|
attributes: [ // the axes → one selector each in the storefront
|
|
49
75
|
{ name: "Size", options: ["41", "42"] },
|
|
50
76
|
{ name: "Color", options: ["Black", "White"] },
|
|
@@ -55,6 +81,21 @@ try {
|
|
|
55
81
|
{ options: { Size: "42", Color: "White" }, regular_price: 94, stock_quantity: 3,
|
|
56
82
|
image: "https://…/sneaker-white.jpg" }, // per-variation image for a visual axis
|
|
57
83
|
],
|
|
84
|
+
|
|
85
|
+
weight: 0.8, // store's weight/dimension units
|
|
86
|
+
dimensions: { length: 30, width: 20, height: 12 },
|
|
87
|
+
tax_status: "taxable", // taxable | none
|
|
88
|
+
tax_group: "Products", // a tax group from the matched location
|
|
89
|
+
|
|
90
|
+
virtual: false, // true → no shipping (a service, a booking)
|
|
91
|
+
downloadable: false, // ↓ the three download keys apply only when true
|
|
92
|
+
downloads: [{ name: "Care guide", file_url: "https://…/care.pdf" }],
|
|
93
|
+
download_limit: 3, // -1 / omit = unlimited
|
|
94
|
+
download_expiry: 30, // days after purchase
|
|
95
|
+
|
|
96
|
+
// Accepted, but they take Product *ids* — which only exist after this
|
|
97
|
+
// call. Cross-link in a later admin-products update, not here.
|
|
98
|
+
upsell_ids: [], cross_sell_ids: [],
|
|
58
99
|
},
|
|
59
100
|
],
|
|
60
101
|
coupons: [{ code: "WELCOME10", discount_type: "percent", amount: 10 }],
|
|
@@ -69,6 +110,8 @@ try {
|
|
|
69
110
|
|
|
70
111
|
**Running this through a code-execution tool? Return `res.data`, never the raw response.** `invoke` resolves to the raw HTTP response, which carries circular request/response objects — `return res` (or stringifying a thrown error whole) fails with `Converting circular structure to JSON` *even when the seed succeeded*, and a thrown error needs `e.response?.data` for the same reason.
|
|
71
112
|
|
|
113
|
+
**The two products above are the range, not a template.** `name` is the only required key: every other line is opt-in, and each product in the array picks its own set independently — a plain product stays two keys long next to a fully specified one, and the fields it omits simply don't apply to it (no attributes ⇒ it sells no variants; no `meta_data` ⇒ no spec table; no `downloads` ⇒ nothing to deliver). Seed each product with the keys its own catalog entry actually has, and leave the rest out rather than padding with empty values.
|
|
114
|
+
|
|
72
115
|
Reference taxonomy by **display name** (categories, ribbons, attributes, options) — existing records are matched case-insensitively and reused. The seeder derives slugs, checks SKU uniqueness, prices variations, and **rolls the parent's `price`/`regular_price`/`on_sale` up from the cheapest publishable variant** — never set a variant parent's price yourself. Unknown keys are rejected, so typos surface instead of vanishing.
|
|
73
116
|
|
|
74
117
|
**Idempotency:** a product whose `sku` (or derived slug) exists is skipped and reported — safe to retry after a timeout, or to seed into a store that already has products. **Limits:** ≤100 products, ≤500 variations per call, ≤50 per product, ≤50 locations. Bad payloads fail **400** `invalid_payload` with `errors: [{ path, error }]`, a modified schema **422** `schema_incompatible` — both before anything is written.
|
|
@@ -76,8 +119,8 @@ Reference taxonomy by **display name** (categories, ribbons, attributes, options
|
|
|
76
119
|
The response reports everything; these matter downstream:
|
|
77
120
|
|
|
78
121
|
```jsonc
|
|
79
|
-
{ "catalog": { "products_created": 2, "variations_created":
|
|
80
|
-
"products": [{ "name": "Runner Sneaker", "id": "…", "slug": "runner-sneaker", "variation_count":
|
|
122
|
+
{ "catalog": { "products_created": 2, "variations_created": 2,
|
|
123
|
+
"products": [{ "name": "Runner Sneaker", "id": "…", "slug": "runner-sneaker", "variation_count": 2 }] },
|
|
81
124
|
"store_name": { "value": "Aurora Threads", "action": "created" },
|
|
82
125
|
"payment_methods": null, // null = the default (offline on, card off)
|
|
83
126
|
"warnings": [] } // always present; read it — see the shipping section
|
|
@@ -97,7 +140,7 @@ locations: [
|
|
|
97
140
|
```
|
|
98
141
|
|
|
99
142
|
- **`continents: ["EU"]`** spares you a 51-code country list. The seven codes are `AF` `AN` `AS` `EU` `NA` `OC` `SA`, and `EU` is the *continent* Europe, not the European Union. An unknown code fails `400 invalid_payload` with the known list.
|
|
100
|
-
- **`rest_of_world: true`** is the catch-all — the location that matches every address no other location claims. It cannot also carry `countries`/`continents`/`regions
|
|
143
|
+
- **`rest_of_world: true`** is the catch-all — the location that matches every address no other location claims. It cannot also carry `countries`/`continents`/`regions`, and there is **no country code that means "everywhere"**: `countries: ["*"]`/`["ALL"]`/`["ROW"]`, and alpha-3 codes like `["USA"]`, are rejected **400** (every scope code is validated against the matcher's own country/continent/state data).
|
|
101
144
|
- Other scopes: `countries: ["IL", "DE"]`, or explicit `regions: [{ type: "state", code: "US:CA" }]`. Matching is **country + state only** — no postcode or city rules exist.
|
|
102
145
|
- One matched location supplies **both** the shipping rates and the tax groups: `shipping_rates: [{ name, cost, free_over? }]`, `tax_groups: [{ name, rates: [{ name, rate }] }]`, `shipping_tax: { type: "percent"|"fixed", value }`.
|
|
103
146
|
|
|
@@ -103,6 +103,8 @@ A payload carrying `locations` **suppresses** the seeded "Rest of the world" fal
|
|
|
103
103
|
|
|
104
104
|
`rest_of_world: true` is the catch-all *instead of* a scope — combining it with `countries`/`continents`/`regions` is a payload error, not a merge. (An empty `regions: []` still works and means the same.)
|
|
105
105
|
|
|
106
|
+
**There is no country code for "everywhere".** `countries: ["*"]`, `["ALL"]`, `["ROW"]` and alpha-3 codes like `["USA"]` are all rejected **400** `invalid_payload` — every scope code is checked against the same static data the matcher uses (`US`/`IL`/`DE` for countries, the seven continent codes, `US:CA` for states), so a code that could never match a real address fails at seed time instead of creating a location that silently matches nothing.
|
|
107
|
+
|
|
106
108
|
## Day-2 edits
|
|
107
109
|
|
|
108
110
|
No admin function owns locations, so there are two routes: **direct CRUD** on `commerce.ShippingTaxLocation` (admin-only RLS, bracket syntax — [`../docs/entities.md`](../docs/entities.md)), where `shipping_rates` is written whole so you must **mint stable `id`s yourself and never renumber existing ones** (orders reference them); or **the merchant's screen**, admin → Settings → Shipping & Tax (`settings/shipping-tax`), which edits regions, rates, groups and shipping tax directly.
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useMemo } from "react";
|
|
1
|
+
import { useCallback, useMemo } from "react";
|
|
2
2
|
import { addressFieldSpec } from "@/commerce/utils";
|
|
3
3
|
import { useStoreInfo } from "./StorefrontProvider";
|
|
4
4
|
import { useCheckoutContext } from "./useCheckout";
|
|
@@ -22,24 +22,47 @@ export function useCountries() {
|
|
|
22
22
|
return { countries: list, options, loading, error };
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
+
/**
|
|
26
|
+
* Read the new value out of whatever a field's `set` was handed. All three
|
|
27
|
+
* forms an onChange is plausibly written as work, so a field setter can't be
|
|
28
|
+
* called "wrong":
|
|
29
|
+
*
|
|
30
|
+
* f.set(e.target.value) // the value
|
|
31
|
+
* f.set(e) // the change event (onChange={f.set})
|
|
32
|
+
* f.set(f.key, e.target.value) // key + value, mirroring the top-level set()
|
|
33
|
+
*/
|
|
34
|
+
function newValue(args) {
|
|
35
|
+
if (args.length >= 2) return args[1];
|
|
36
|
+
const first = args[0];
|
|
37
|
+
if (first && typeof first === "object" && "target" in first) return first.target?.value ?? "";
|
|
38
|
+
return first;
|
|
39
|
+
}
|
|
40
|
+
|
|
25
41
|
/**
|
|
26
42
|
* useAddressForm — the checkout address form as a field list bound to the
|
|
27
43
|
* guided checkout. Needs a `<CheckoutProvider>` above it.
|
|
28
44
|
*
|
|
29
|
-
*
|
|
45
|
+
* Every field is **self-contained** — it carries its own setter, so a `.map`
|
|
46
|
+
* never has to reach back out of the loop:
|
|
47
|
+
*
|
|
48
|
+
* const { fields } = useAddressForm("billing");
|
|
30
49
|
* {fields.map(f => (
|
|
31
50
|
* <label key={f.key}>
|
|
32
51
|
* {f.label}{f.required && " *"}
|
|
33
52
|
* {f.type === "select"
|
|
34
|
-
* ? <select value={f.value} onChange={e => set(
|
|
53
|
+
* ? <select value={f.value} onChange={e => f.set(e.target.value)}>
|
|
35
54
|
* {f.options.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
|
|
36
55
|
* </select>
|
|
37
56
|
* : <input type={f.type} value={f.value} autoComplete={f.autoComplete}
|
|
38
|
-
* onChange={e => set(
|
|
57
|
+
* onChange={e => f.set(e.target.value)} />}
|
|
39
58
|
* {f.error && <span role="alert">{f.error}</span>}
|
|
40
59
|
* </label>
|
|
41
60
|
* ))}
|
|
42
61
|
*
|
|
62
|
+
* `f.set` also accepts the raw event (`onChange={f.set}`) or a `(key, value)`
|
|
63
|
+
* pair; the hook's top-level `set(key, value)` is still there for code that
|
|
64
|
+
* writes a field outside the map.
|
|
65
|
+
*
|
|
43
66
|
* Editing a field is all it takes to trigger the shipping/tax recalculation —
|
|
44
67
|
* `useCheckout` debounces and calls `set-shipping-address` once the address is
|
|
45
68
|
* complete enough to price. Two things the field list gets right that a
|
|
@@ -49,6 +72,11 @@ export function useCountries() {
|
|
|
49
72
|
*
|
|
50
73
|
* @param {"billing"|"shipping"} [which]
|
|
51
74
|
* @param {{includeState?: boolean, includePhone?: boolean, includeCompany?: boolean}} [options]
|
|
75
|
+
* @returns {{fields: Array<{key: string, label: string, type: string,
|
|
76
|
+
* value: string, required: boolean, options: Array<object>, error: string|null,
|
|
77
|
+
* autoComplete: string, colSpan: number, set: (...args: any[]) => void}>,
|
|
78
|
+
* set: (key: string, value: any) => void, values: object, missing: Array<string>,
|
|
79
|
+
* complete: boolean, error: object|null, countriesLoading: boolean}}
|
|
52
80
|
*/
|
|
53
81
|
export function useAddressForm(which = "billing", options = {}) {
|
|
54
82
|
const checkout = useCheckoutContext();
|
|
@@ -56,9 +84,11 @@ export function useAddressForm(which = "billing", options = {}) {
|
|
|
56
84
|
|
|
57
85
|
const isBilling = which === "billing";
|
|
58
86
|
const values = isBilling ? checkout.billing : checkout.shipping;
|
|
59
|
-
const
|
|
60
|
-
|
|
61
|
-
|
|
87
|
+
const { updateBilling, updateShipping } = checkout;
|
|
88
|
+
const set = useCallback(
|
|
89
|
+
(key, value) => (isBilling ? updateBilling({ [key]: value }) : updateShipping({ [key]: value })),
|
|
90
|
+
[isBilling, updateBilling, updateShipping],
|
|
91
|
+
);
|
|
62
92
|
|
|
63
93
|
// `place-order` only enforces required fields on billing; a separate shipping
|
|
64
94
|
// address is priced, not validated field-by-field.
|
|
@@ -75,6 +105,9 @@ export function useAddressForm(which = "billing", options = {}) {
|
|
|
75
105
|
return spec.map((f) => ({
|
|
76
106
|
...f,
|
|
77
107
|
value: values?.[f.key] ?? "",
|
|
108
|
+
// Self-contained: the field knows its own key, so a .map never has to
|
|
109
|
+
// reach back out to the hook's set() (and can't pass the wrong key).
|
|
110
|
+
set: (...args) => set(f.key, newValue(args)),
|
|
78
111
|
// The address-level error ("we don't ship there") belongs on country.
|
|
79
112
|
error:
|
|
80
113
|
f.key === "country" && checkout.addressError?.code === "shipping_not_available"
|
|
@@ -82,7 +115,7 @@ export function useAddressForm(which = "billing", options = {}) {
|
|
|
82
115
|
: null,
|
|
83
116
|
}));
|
|
84
117
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
85
|
-
}, [countries, values, isBilling, checkout.addressError, JSON.stringify(options)]);
|
|
118
|
+
}, [countries, values, isBilling, set, checkout.addressError, JSON.stringify(options)]);
|
|
86
119
|
|
|
87
120
|
return {
|
|
88
121
|
fields,
|
|
@@ -245,6 +245,10 @@ export function useAddToCart() {
|
|
|
245
245
|
* and tracked stock (`showQuantity` is false when only 1 can be bought — render
|
|
246
246
|
* no stepper then). Every label and every element is yours.
|
|
247
247
|
*
|
|
248
|
+
* A not-yet-loaded product is fine (`disabled: true`), so call this next to
|
|
249
|
+
* `useProduct` **above** the page's `loading`/`not_found` guards — a hook below
|
|
250
|
+
* an early return breaks the hook order the next render.
|
|
251
|
+
*
|
|
248
252
|
* @param {object} product the whole `useProduct` result
|
|
249
253
|
* @param {{onAdded?: (cart: object) => void}} [options]
|
|
250
254
|
* @returns {{add: () => Promise<object>, adding: boolean, error: object|null,
|
|
@@ -19,6 +19,10 @@ import { imageIndex, productImages } from "@/commerce/utils";
|
|
|
19
19
|
* moves the active image to the variation's own picture while a manual pick
|
|
20
20
|
* still wins until the selection changes again** — highlight, not replace.
|
|
21
21
|
*
|
|
22
|
+
* A null/not-yet-loaded product is fine (`hasImages: false`), so call this with
|
|
23
|
+
* the other hooks **above** the page's `loading`/`not_found` guards — a hook
|
|
24
|
+
* below an early return breaks the hook order the next render.
|
|
25
|
+
*
|
|
22
26
|
* @param {object} product
|
|
23
27
|
* @param {object|null} [view] a `resolveSelection` view; its
|
|
24
28
|
* `display.image` is the variation's image
|