@base44/app-plugin-commerce 0.2.2 → 0.2.4
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/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/install/02-storefront.md +107 -30
- package/skills/commerce/install/03-data.md +57 -14
- package/skills/commerce/references/catalog-rendering.md +7 -3
- package/skills/commerce/references/online-payments.md +4 -3
- package/skills/commerce/references/shipping-and-tax.md +2 -0
- package/src/commerce/storefront/index.js +2 -1
- package/src/commerce/storefront/useAddressForm.js +41 -8
- package/src/commerce/storefront/useCheckout.jsx +11 -0
- package/src/commerce/storefront/useProduct.js +4 -0
- package/src/commerce/storefront/useProductGallery.js +4 -0
- package/src/commerce/utils/index.js +2 -1
- package/src/commerce/utils/specs.js +9 -3
- package/src/commerce/utils/variants.js +4 -2
|
@@ -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
|
|
@@ -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.4",
|
|
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
|
|
@@ -93,14 +105,24 @@ resets to page 1 and keeps the current rows on screen (`refreshing`) while the
|
|
|
93
105
|
page loads. `useCategories()` / `useRibbons()` → `{ items }` (arrays, children
|
|
94
106
|
nested).
|
|
95
107
|
|
|
96
|
-
Your card
|
|
108
|
+
Your card can render `name`, `images[0]?.src` (⚑ **images are `{src,name,alt}`
|
|
97
109
|
objects and the array may be empty — render a placeholder, never a broken
|
|
98
110
|
`<img>`**), `useProductPrice(row).label` (already "From €19.99" when the
|
|
99
111
|
product sells variants — there is no product `type` flag), `on_sale`,
|
|
100
112
|
`short_description`, `stock_status`, `average_rating`/`rating_count`,
|
|
101
|
-
`ribbons
|
|
113
|
+
`ribbons` — and a row carries the whole product record, so `weight`,
|
|
114
|
+
`dimensions`, `attributes[]` and `meta_data` are there too. Full field matrix:
|
|
102
115
|
[`../references/catalog-rendering.md`](../references/catalog-rendering.md).
|
|
103
116
|
|
|
117
|
+
That is an inventory of what you *can* show, not a card design and not a list
|
|
118
|
+
to render in order. An even grid of identical cards, each with the same
|
|
119
|
+
name/price/stars trio, is where a generated store lands by default and almost
|
|
120
|
+
never where this catalog belongs: give the grid a rhythm (a hero piece spanning
|
|
121
|
+
two columns, an editorial break between rows, a denser tile for a large
|
|
122
|
+
catalog), and lead each card with the one or two fields *these* products are
|
|
123
|
+
judged on — carat weight, focal length, edition size, ABV — read off
|
|
124
|
+
`meta_data` via `productSpecs(row)`, not the fields every store shows.
|
|
125
|
+
|
|
104
126
|
**Rails** (featured row, "new in") are the same hook with a filter
|
|
105
127
|
(`{ featured: true, per_page: 4 }`). ⚑ Any filter may legitimately match
|
|
106
128
|
nothing — render *nothing* then, never a heading over an empty row. Upsells
|
|
@@ -110,16 +132,34 @@ beside a product need no query: `p.upsells` / `p.crossSells` from `useProduct`.
|
|
|
110
132
|
|
|
111
133
|
`useProduct(slug)` (or `{ id }`) owns fetch + variant selection + quantity +
|
|
112
134
|
price, race-safe, selection mirrored to the URL. ⚑ `status: "not_found"` is a
|
|
113
|
-
404 page, not a spinner.
|
|
114
|
-
|
|
135
|
+
404 page, not a spinner.
|
|
136
|
+
|
|
137
|
+
⚑ **Call every hook above the status guards.** This page needs more than one,
|
|
138
|
+
and a hook placed after an early `return` runs on some renders but not others —
|
|
139
|
+
React then throws *"Rendered more hooks than during the previous render"* the
|
|
140
|
+
moment the product resolves. All of these tolerate a null/loading product
|
|
141
|
+
precisely so they can sit at the top:
|
|
142
|
+
|
|
143
|
+
```jsx
|
|
144
|
+
const p = useProduct(slug);
|
|
145
|
+
const g = useProductGallery(p.product, p.view);
|
|
146
|
+
const buy = useAddToCartButton(p, { onAdded: () => navigate("/bag") });
|
|
147
|
+
useStorefrontSeo(productSeo(p.product, p.view, { storeName, currency }));
|
|
148
|
+
|
|
149
|
+
if (p.status === "loading") return /* your loading state */;
|
|
150
|
+
if (p.status === "not_found") return /* your 404 */;
|
|
151
|
+
const { product, view, price, categories } = p;
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
(`variantAxes` and `productSpecs` are plain functions, not hooks — they can go
|
|
155
|
+
anywhere.) Build your layout from:
|
|
115
156
|
|
|
116
157
|
- **Price** — `price.label`, plus `price.compareAtLabel` (struck through) when
|
|
117
158
|
on sale. Never read `product.price` directly — the parent's price is a
|
|
118
159
|
rolled-up from-price.
|
|
119
|
-
- **Gallery** — `
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
placeholder.
|
|
160
|
+
- **Gallery** — `g` from above → `{ hasImages, images, active, activeIndex,
|
|
161
|
+
setActiveIndex, next, prev }`. The active image already follows the variant
|
|
162
|
+
selection; `hasImages: false` means render your placeholder.
|
|
123
163
|
- **Variant selector** — `variantAxes(view, p.pick)` → one entry per axis:
|
|
124
164
|
`{ key, name, selectedOption, options: [{ value, selected, disabled,
|
|
125
165
|
outOfStock, pick }] }`. Map it to any control — buttons, swatches, a dropdown.
|
|
@@ -141,22 +181,34 @@ and build your layout from:
|
|
|
141
181
|
</fieldset>
|
|
142
182
|
))}
|
|
143
183
|
```
|
|
144
|
-
- **Buy box** — `
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
184
|
+
- **Buy box** — `buy` from above → `{ add, adding, error, disabled, soldOut,
|
|
185
|
+
needsSelection, quantity, increase, decrease, canIncrease, canDecrease,
|
|
186
|
+
showQuantity }`. It gates on purchasability, recovers from every add failure
|
|
187
|
+
and clamps quantity to stock and `sold_individually`. ⚑ Render `error.message`
|
|
188
|
+
inline; ⚑ `showQuantity: false` means no stepper (only 1 can be bought); the
|
|
189
|
+
button label should reflect `adding`/`soldOut`/`needsSelection` — the words
|
|
190
|
+
are yours.
|
|
151
191
|
- **Description** — `product.description` is HTML; render as rich text
|
|
152
192
|
(`dangerouslySetInnerHTML`), `short_description` above it.
|
|
153
193
|
- **Specs** — `productSpecs(product)` → `[{ key, label, value }]` from
|
|
154
|
-
`meta_data` (Material, Care). `[]` means no section at all.
|
|
194
|
+
`meta_data` (Material, Care, Provenance). `[]` means no section at all.
|
|
155
195
|
- **Breadcrumbs** — build from `categories`
|
|
156
196
|
(`/collection?category_id=${c.id}`); skip on a flat catalog. Ribbons are
|
|
157
197
|
labels, not breadcrumbs.
|
|
158
198
|
|
|
159
|
-
All optional — include what this store's products actually have
|
|
199
|
+
All optional — include what this store's products actually have — and each is
|
|
200
|
+
one hook, **not one component style**. The tell of a generated product page is
|
|
201
|
+
that every axis is the same chip row and every modifier the same grey
|
|
202
|
+
label/value line. Branch on what they are: `variantAxes` gives you `axis.key` /
|
|
203
|
+
`axis.name`, `productSpecs` gives you `key` / `label`, so a colour axis can be
|
|
204
|
+
swatches in the real colours, a size axis chips with a size guide beside them,
|
|
205
|
+
a material axis a small sample image; a "Composition" modifier can be bars, a
|
|
206
|
+
"Provenance" a map pin, a "Certification" a seal, a "Weight" a figure set in
|
|
207
|
+
the display face. Design the two or three that carry this product's meaning,
|
|
208
|
+
let the rest fall back to a plain row, and don't feel obliged to keep them in
|
|
209
|
+
one block — a spec can sit under the gallery, beside the price, or inside the
|
|
210
|
+
description. The ⚑ rules above (one control per axis, unbuyable options
|
|
211
|
+
disabled) constrain the *behaviour* of a selector, never its form.
|
|
160
212
|
|
|
161
213
|
### Reviews — optional
|
|
162
214
|
|
|
@@ -244,7 +296,10 @@ address automatically (debounced, never on a half-typed address), derives the
|
|
|
244
296
|
shipping and payment choices, gates the button (`canPlaceOrder` +
|
|
245
297
|
`useCheckoutBlockers()` in words), and `placeOrder()` handles **both**
|
|
246
298
|
navigations — online gateway → provider redirect, everything else →
|
|
247
|
-
`/order-received`.
|
|
299
|
+
`/order-received`. Both are **full page loads** (`window.location.assign`),
|
|
300
|
+
which is why the order-received page boots from the URL alone; pass
|
|
301
|
+
`orderReceivedPath: null` and `navigate(orderReceivedUrl(result))` if you want
|
|
302
|
+
a router transition instead. The address form comes from `useAddressForm(which)` as a
|
|
248
303
|
field spec (`state` collected, country options never null); the two
|
|
249
304
|
store-data choices come through the headless `ShippingMethodPicker` /
|
|
250
305
|
`PaymentMethodPicker`, whose render props enumerate every branch.
|
|
@@ -327,18 +382,18 @@ function CheckoutForm() {
|
|
|
327
382
|
}
|
|
328
383
|
|
|
329
384
|
function AddressFields({ which }) {
|
|
330
|
-
const { fields,
|
|
331
|
-
return fields.map((f) => (
|
|
385
|
+
const { fields, countriesLoading } = useAddressForm(which);
|
|
386
|
+
return fields.map((f) => ( /* each field carries its own setter: f.set */
|
|
332
387
|
<label key={f.key}>
|
|
333
388
|
{f.label}{f.required && " *"}
|
|
334
389
|
{f.type === "select" ? (
|
|
335
|
-
<select value={f.value} onChange={(e) => set(
|
|
390
|
+
<select value={f.value} onChange={(e) => f.set(e.target.value)} autoComplete={f.autoComplete}>
|
|
336
391
|
<option value="">{f.key === "country" && countriesLoading ? "Loading…" : `Select ${f.label}`}</option>
|
|
337
392
|
{f.options.map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
|
|
338
393
|
</select>
|
|
339
394
|
) : (
|
|
340
395
|
<input type={f.type} value={f.value} required={f.required}
|
|
341
|
-
onChange={(e) => set(
|
|
396
|
+
onChange={(e) => f.set(e.target.value)} autoComplete={f.autoComplete} />
|
|
342
397
|
)}
|
|
343
398
|
{f.error && <span role="alert">{f.error}</span>}
|
|
344
399
|
</label>
|
|
@@ -401,8 +456,8 @@ useStorefrontSeo(collectionSeo({ title, products: list.products })); // col
|
|
|
401
456
|
// order-received is already noindex via useOrderReturn
|
|
402
457
|
```
|
|
403
458
|
|
|
404
|
-
|
|
405
|
-
|
|
459
|
+
The `*Seo` builders tolerate a null product, so this sits with the other hooks
|
|
460
|
+
above the status guards.
|
|
406
461
|
|
|
407
462
|
## Per-page output budgets
|
|
408
463
|
|
|
@@ -419,7 +474,29 @@ null product).
|
|
|
419
474
|
These budgets assume the hooks carry the logic and your markup carries only the
|
|
420
475
|
design. Over budget ⇒ you are re-implementing something a hook does — an
|
|
421
476
|
address spec, a quantity clamp, totals math, variant resolution, add-to-cart
|
|
422
|
-
error recovery. Go back to the hook and delete your version.
|
|
477
|
+
error recovery. Go back to the hook and delete your version. Design detail is
|
|
478
|
+
not what pushes a page over: giving a colour axis swatches or a composition
|
|
479
|
+
modifier bars costs a few hundred characters, and that is what the budget is
|
|
480
|
+
for.
|
|
481
|
+
|
|
482
|
+
## If you drive the storefront from a browser script
|
|
483
|
+
|
|
484
|
+
Whatever you choose to check and however you check it, two things make a
|
|
485
|
+
working storefront look broken under a script:
|
|
486
|
+
|
|
487
|
+
- **Filling the checkout.** Every field is a controlled React input, so writing
|
|
488
|
+
`el.value` changes nothing React sees. Use the harness's own fill (it
|
|
489
|
+
dispatches `input` + `change`) — never lift the native setter off
|
|
490
|
+
`HTMLInputElement.prototype` and call `descriptor.set(v)`: detached from the
|
|
491
|
+
element it throws `Illegal invocation`, and the workaround it is reaching for
|
|
492
|
+
is what the fill helper already does.
|
|
493
|
+
- **`placeOrder` ends the page.** It navigates with `window.location.assign`
|
|
494
|
+
(above), so a script that placed an order loses its page context and can land
|
|
495
|
+
back at `/` — while the order itself was created normally. That is the hard
|
|
496
|
+
navigation, not a broken redirect. The confirmation is reachable at any time
|
|
497
|
+
from a fresh navigation to `/order-received?order_id=…&order_key=…` (the ids
|
|
498
|
+
come back in `placeOrder`'s result, and `commerce/admin-orders` `search` has
|
|
499
|
+
the order either way).
|
|
423
500
|
|
|
424
501
|
## Done — forget this file
|
|
425
502
|
|
|
@@ -431,9 +508,8 @@ error recovery. Go back to the hook and delete your version.
|
|
|
431
508
|
- [ ] `/order-received` renders `useOrderReturn`'s states **including `paymentInstructions`**.
|
|
432
509
|
- [ ] Variant options render one control per axis; unbuyable options are disabled, not hidden.
|
|
433
510
|
- [ ] No hook logic was re-implemented by hand (address fields, quantity clamps, totals rows, shipping/payment branching).
|
|
434
|
-
- [ ] The storefront
|
|
511
|
+
- [ ] The storefront carries the design you settled on before reading this file — no page ships the reference snippets' bare structure or placeholder copy, and the attributes and modifiers that matter to these products are designed rather than poured into one uniform block.
|
|
435
512
|
- [ ] Every page is within its budget above.
|
|
436
|
-
- [ ] A real purchase completes in the preview — pick a variant, add it, check out, place an offline order, land on `/order-received`.
|
|
437
513
|
|
|
438
514
|
Record these lines in your working notes; do not re-read this file.
|
|
439
515
|
|
|
@@ -442,3 +518,4 @@ Record these lines in your working notes; do not re-read this file.
|
|
|
442
518
|
- `/order-received` is mandatory and renders `useOrderReturn`'s states, including `paymentInstructions` — how a normal (offline) customer learns how to pay.
|
|
443
519
|
- Branch cart/list/product UI on `status`, never on `isEmpty`/nullable data — `isEmpty` is false while loading by design.
|
|
444
520
|
- Variant options that aren't buyable render disabled, never hidden; one control per axis, never a list of variations.
|
|
521
|
+
- 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
|
|
|
@@ -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` precisely so a page can branch on *which* one it is: colour as swatches, size as chips next to a size guide, "Composition" as bars, "Provenance" as a located line, "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
|
|
|
@@ -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.
|
|
@@ -63,7 +63,8 @@
|
|
|
63
63
|
* ## Helpers re-exported from `@/commerce/utils`
|
|
64
64
|
* - `variantAxes(view, pick)` — axes → options with selected/disabled/stock
|
|
65
65
|
* state derived, for the variant selector you write.
|
|
66
|
-
* - `productSpecs(product)` — `meta_data` →
|
|
66
|
+
* - `productSpecs(product)` — `meta_data` → descriptive rows, keyed so each
|
|
67
|
+
* can be rendered its own way.
|
|
67
68
|
*/
|
|
68
69
|
export {
|
|
69
70
|
StorefrontProvider,
|
|
@@ -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,
|
|
@@ -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`,
|
|
@@ -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
|
|
@@ -25,7 +25,8 @@
|
|
|
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, keyed so each
|
|
29
|
+
* can be rendered its own way.
|
|
29
30
|
*
|
|
30
31
|
* Building the storefront in React? **Prefer `@/commerce/storefront`** — it
|
|
31
32
|
* layers headless hooks on top of this module, and a hook that pre-composes
|
|
@@ -1,16 +1,22 @@
|
|
|
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).
|
|
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
|
|
8
|
+
* You render the rows yourself, and `key` is there so you don't have to render
|
|
9
|
+
* them all the same way — a uniform list is the fallback, not the target:
|
|
9
10
|
*
|
|
10
11
|
* const specs = productSpecs(product);
|
|
11
12
|
* {specs.length > 0 && <dl>{specs.map(s =>
|
|
12
13
|
* <div key={s.key}><dt>{s.label}</dt><dd>{s.value}</dd></div>)}</dl>}
|
|
13
14
|
*
|
|
15
|
+
* Branch on `s.key` to give the ones that carry this product's meaning their
|
|
16
|
+
* own treatment (a composition as bars, a provenance as a located line, a
|
|
17
|
+
* weight set in the display face) and let the rest fall through to the row
|
|
18
|
+
* above.
|
|
19
|
+
*
|
|
14
20
|
* @param {object} product
|
|
15
21
|
* @returns {Array<{key: string, label: string, value: string}>} `[]` when the
|
|
16
22
|
* product has no visible meta_data — render nothing, not an empty section.
|
|
@@ -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
|