@base44/app-plugin-commerce 0.2.5 → 0.2.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/package.json +1 -1
- package/skills/commerce/SKILL.md +68 -76
- package/skills/commerce/docs/api-admin.md +11 -50
- package/skills/commerce/docs/api-storefront.md +25 -118
- package/skills/commerce/install/01-install.md +20 -49
- package/skills/commerce/install/02-storefront.md +204 -431
- package/skills/commerce/install/03-data.md +43 -109
- package/skills/commerce/references/admin-product-form.md +26 -0
- package/skills/commerce/references/catalog-rendering.md +9 -9
- package/skills/commerce/references/online-payments.md +4 -15
- package/skills/commerce/references/operations.md +19 -1
- package/skills/commerce/references/storefront-verification.md +47 -0
- package/src/commerce/storefront/StorefrontProvider.jsx +8 -3
- package/src/commerce/storefront/cartUI.jsx +118 -0
- package/src/commerce/storefront/index.js +39 -15
- package/src/commerce/storefront/pickers.jsx +84 -23
- package/src/commerce/storefront/useAddressForm.js +63 -26
- package/src/commerce/storefront/useCartLine.js +9 -4
- package/src/commerce/storefront/useCheckout.jsx +13 -0
- package/src/commerce/storefront/useOrderReturn.js +7 -5
- package/src/commerce/storefront/usePlaceOrder.js +63 -0
- package/src/commerce/storefront/useProduct.js +112 -41
- package/src/commerce/storefront/useProductList.js +7 -0
- package/src/commerce/storefront/useUpsell.js +90 -0
- package/src/commerce/utils/specs.js +6 -2
- package/src/commerce/utils/totals.js +23 -12
|
@@ -12,19 +12,17 @@ carry_forward:
|
|
|
12
12
|
|
|
13
13
|
# 03 — Store data
|
|
14
14
|
|
|
15
|
-
A fresh install has **no settings and no catalog**. One admin-only, idempotent call to `commerce/seed-store` creates both: the six settings groups
|
|
15
|
+
A fresh install has **no settings and no catalog**. One admin-only, idempotent call to `commerce/seed-store` creates both: the six settings groups, the two gateway rows (`offline` enabled, `card` disabled), and — from the payload — the catalog, coupons and shipping locations. Nothing in [`./02-storefront.md`](./02-storefront.md) waits on it: fire it when the image URLs are back.
|
|
16
16
|
|
|
17
|
-
| Mode | Body |
|
|
18
|
-
|
|
19
|
-
| **Real catalog** | `{ store_name, products: [...] }` (+ optional `coupons`, `locations`) |
|
|
20
|
-
| **Demo data** | `{ store_name, with_sample_data: true }`
|
|
21
|
-
| **No products** | `{ store_name }`
|
|
22
|
-
|
|
23
|
-
`with_sample_data` cannot be combined with `products` (**400** `invalid_payload`). Never calling the function leaves the operator the admin's first-run "Set up your store" screen.
|
|
17
|
+
| Mode | Body |
|
|
18
|
+
|---|---|
|
|
19
|
+
| **Real catalog** | `{ store_name, products: [...] }` (+ optional `coupons`, `locations`) |
|
|
20
|
+
| **Demo data** | `{ store_name, with_sample_data: true }` — 10 generic products; cannot combine with `products` (**400**) |
|
|
21
|
+
| **No products** | `{ store_name }` — defaults only |
|
|
24
22
|
|
|
25
|
-
**`store_name` is required on a first seed**
|
|
23
|
+
**`store_name` is required on a first seed** — the app's name as the platform shows it (`base44/config.jsonc` → `name` can be stale; ask if unsure). **`currency`** is an ISO code (`"EUR"`); formatting follows the viewer's locale, nothing else to set. Explicit values always win, first seed and re-runs alike.
|
|
26
24
|
|
|
27
|
-
|
|
25
|
+
The working call — `name` is the only required product key; give each product the keys its own catalog entry actually has and leave the rest out. The **full key list** (sale windows, downloads, tax, backorders, dimensions…) is in [`../docs/api-admin.md`](../docs/api-admin.md#commerceseed-store) — open it only if the catalog needs one of those:
|
|
28
26
|
|
|
29
27
|
```js
|
|
30
28
|
try {
|
|
@@ -32,78 +30,43 @@ try {
|
|
|
32
30
|
store_name: "Aurora Threads",
|
|
33
31
|
currency: "EUR",
|
|
34
32
|
products: [
|
|
35
|
-
|
|
36
|
-
// price too, and everything else below is opt-in.
|
|
37
|
-
{ name: "Linen Scarf", regular_price: 45 },
|
|
33
|
+
{ name: "Linen Scarf", regular_price: 45 }, // minimal is complete
|
|
38
34
|
|
|
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.
|
|
42
35
|
{ name: "Runner Sneaker",
|
|
43
36
|
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
37
|
featured: true, // → useProductList({ featured: true }) rails
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
|
|
38
|
+
regular_price: 89,
|
|
39
|
+
sale_price: 79, // sets on_sale; storefront strikes regular_price
|
|
53
40
|
stock_quantity: 12, // implies manage_stock: true
|
|
54
|
-
|
|
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
|
-
|
|
41
|
+
sold_individually: false, // true → max 1 per order (no qty stepper)
|
|
59
42
|
short_description: "Cushioned everyday runner.",
|
|
60
|
-
description: "<p>Cut from recycled knit…</p>",
|
|
43
|
+
description: "<p>Cut from recycled knit…</p>", // HTML, rendered as rich text
|
|
61
44
|
images: ["https://…/sneaker.jpg"], // URLs or { src, alt } — see Images below
|
|
62
|
-
|
|
63
45
|
categories: ["Shoes"], // get-or-created by display name
|
|
64
46
|
ribbons: ["Best Seller"], // flat labels, not a hierarchy
|
|
65
47
|
|
|
66
|
-
// Descriptive properties → the product page's spec rows
|
|
67
|
-
//
|
|
68
|
-
// as a figure and a composition as bars). NOT variant axes and NOT
|
|
69
|
-
// ribbons: they describe the product, they don't select anything.
|
|
70
|
-
// Values are strings; a leading `_` hides a row.
|
|
48
|
+
// Descriptive properties → the product page's spec rows (productSpecs
|
|
49
|
+
// types each one). NOT variant axes, NOT ribbons. Strings; `_` hides.
|
|
71
50
|
meta_data: [
|
|
72
51
|
{ key: "Material", value: "Recycled knit upper" },
|
|
73
|
-
{ key: "Weight", value: "248 g" },
|
|
74
|
-
{ key: "Care", value: "Machine wash cold" },
|
|
52
|
+
{ key: "Weight", value: "248 g" }, // "<number> <unit>" → a numeric row
|
|
75
53
|
],
|
|
76
54
|
|
|
77
|
-
attributes: [ // the axes → one selector each
|
|
55
|
+
attributes: [ // the axes → one selector each
|
|
78
56
|
{ name: "Size", options: ["41", "42"] },
|
|
79
57
|
{ name: "Color", options: ["Black", "White"] },
|
|
80
58
|
],
|
|
81
59
|
default_options: { Size: "42", Color: "Black" },
|
|
82
|
-
variations: [ // omit entirely → all
|
|
60
|
+
variations: [ // omit entirely → all combos auto-generated
|
|
83
61
|
{ options: { Size: "41", Color: "Black" }, stock_quantity: 4 },
|
|
84
62
|
{ options: { Size: "42", Color: "White" }, regular_price: 94, stock_quantity: 3,
|
|
85
63
|
image: "https://…/sneaker-white.jpg" }, // per-variation image for a visual axis
|
|
86
64
|
],
|
|
87
|
-
|
|
88
|
-
weight: 0.8, // store's weight/dimension units
|
|
89
|
-
dimensions: { length: 30, width: 20, height: 12 },
|
|
90
|
-
tax_status: "taxable", // taxable | none
|
|
91
|
-
tax_group: "Products", // a tax group from the matched location
|
|
92
|
-
|
|
93
|
-
virtual: false, // true → no shipping (a service, a booking)
|
|
94
|
-
downloadable: false, // ↓ the three download keys apply only when true
|
|
95
|
-
downloads: [{ name: "Care guide", file_url: "https://…/care.pdf" }],
|
|
96
|
-
download_limit: 3, // -1 / omit = unlimited
|
|
97
|
-
download_expiry: 30, // days after purchase
|
|
98
|
-
|
|
99
|
-
// Accepted, but they take Product *ids* — which only exist after this
|
|
100
|
-
// call. Cross-link in a later admin-products update, not here.
|
|
101
|
-
upsell_ids: [], cross_sell_ids: [],
|
|
102
65
|
},
|
|
103
66
|
],
|
|
104
67
|
coupons: [{ code: "WELCOME10", discount_type: "percent", amount: 10 }],
|
|
105
68
|
// ONLY with a coupon field in the cart or checkout (see ./02-storefront.md)
|
|
106
|
-
// locations: [ … ], // shipping —
|
|
69
|
+
// locations: [ … ], // shipping — next section; passing any makes them the store's ONLY ones
|
|
107
70
|
});
|
|
108
71
|
return res.data; // ← the { success, data } envelope: plain JSON
|
|
109
72
|
} catch (e) {
|
|
@@ -111,29 +74,23 @@ try {
|
|
|
111
74
|
}
|
|
112
75
|
```
|
|
113
76
|
|
|
114
|
-
**Running this through a code-execution tool? Return `res.data`, never the raw response
|
|
115
|
-
|
|
116
|
-
**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.
|
|
77
|
+
**Running this through a code-execution tool? Return `res.data`, never the raw response** — the raw response carries circular objects and fails `Converting circular structure to JSON` *even when the seed succeeded*; a thrown error needs `e.response?.data` for the same reason.
|
|
117
78
|
|
|
118
|
-
Reference taxonomy by **display name**
|
|
119
|
-
|
|
120
|
-
**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.
|
|
79
|
+
Reference taxonomy by **display name** — existing records are matched case-insensitively and reused. The seeder derives slugs, checks SKU uniqueness, prices variations, and **rolls the parent's price up from the cheapest publishable variant** — never set a variant parent's price yourself. Unknown keys are rejected, so typos surface. **Idempotency:** a product whose `sku` (or derived slug) exists is skipped and reported — safe to retry. **Limits:** ≤100 products, ≤500 variations, ≤50 locations per call. Bad payloads fail **400** `invalid_payload` with `errors: [{ path, error }]` before anything is written.
|
|
121
80
|
|
|
122
81
|
The response reports everything; these matter downstream:
|
|
123
82
|
|
|
124
83
|
```jsonc
|
|
125
|
-
{ "catalog": { "
|
|
126
|
-
"products": [{ "name": "Runner Sneaker", "id": "…", "slug": "runner-sneaker", "variation_count": 2 }] },
|
|
127
|
-
"store_name": { "value": "Aurora Threads", "action": "created" },
|
|
84
|
+
{ "catalog": { "products": [{ "name": "Runner Sneaker", "id": "…", "slug": "runner-sneaker", "variation_count": 2 }] },
|
|
128
85
|
"payment_methods": null, // null = the default (offline on, card off)
|
|
129
|
-
"warnings": [] } // always present; read it — see
|
|
86
|
+
"warnings": [] } // always present; read it — see shipping
|
|
130
87
|
```
|
|
131
88
|
|
|
132
|
-
Link pages by the **`slug`** from `catalog.products[]`; never mirror the seed in client-side constants (the database is the source of truth and a mirror diverges the first
|
|
89
|
+
Link pages by the **`slug`** from `catalog.products[]`; never mirror the seed in client-side constants (the database is the source of truth and a mirror diverges on the first merchant edit).
|
|
133
90
|
|
|
134
91
|
## Shipping — declare it in the seed call
|
|
135
92
|
|
|
136
|
-
|
|
93
|
+
Each location is a scope plus its rates and taxes; locations match in payload order. "€20 in Europe, €100 everywhere else" is two locations:
|
|
137
94
|
|
|
138
95
|
```js
|
|
139
96
|
locations: [
|
|
@@ -142,63 +99,40 @@ locations: [
|
|
|
142
99
|
]
|
|
143
100
|
```
|
|
144
101
|
|
|
145
|
-
- **`continents: ["EU"]`** spares you a 51-
|
|
146
|
-
- **`rest_of_world: true`** is the catch-all
|
|
147
|
-
-
|
|
148
|
-
- 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 }`.
|
|
149
|
-
|
|
150
|
-
**The catch-all trap.** Passing any `locations` suppresses the seeded "Rest of the world" fallback, so scoped locations with nothing behind them mean every other address gets `shipping_not_available` at checkout. That is plausibly intended, so it is not an error — it comes back in the response as `warnings: ["no_catchall_location: …"]`. Read `warnings` on every seed and either add a `rest_of_world` location or state the restriction to the user.
|
|
151
|
-
|
|
152
|
-
Patching `commerce.ShippingTaxLocation` records afterwards is the **harder, day-2 route** — you must mint stable rate `id`s yourself, and there is no admin function for it. Do shipping in the seed payload. For continent codes in full, state-level regions, tax-group binding, free-over thresholds, VAT-on-shipping and day-2 edits: [`../references/shipping-and-tax.md`](../references/shipping-and-tax.md).
|
|
102
|
+
- **`continents: ["EU"]`** spares you a 51-country list — and `EU` is the *continent* Europe, not the European Union.
|
|
103
|
+
- **`rest_of_world: true`** is the catch-all. There is **no country code meaning "everywhere"** (`["*"]`, `["ALL"]`, alpha-3 like `["USA"]` all fail **400**); other scopes are `countries: ["IL", "DE"]` or `regions: [{ type: "state", code: "US:CA" }]`. Matching is country + state only.
|
|
104
|
+
- A matched location supplies **both** shipping rates and tax groups (`tax_groups`, `shipping_tax`, `free_over` — see the reference below).
|
|
153
105
|
|
|
154
|
-
**
|
|
106
|
+
**The catch-all trap.** Passing any `locations` suppresses the seeded worldwide fallback, so scoped locations with nothing behind them mean every other address gets `shipping_not_available` at checkout. Plausibly intended, so it is a warning, not an error: **read `warnings` on every seed** and either add a `rest_of_world` location or state the restriction to the user. If the brief named tiered rates, assert them once by pricing a cart against one address per zone (`set-shipping-address` → `available_shipping_methods`; a `[]` means no location matched).
|
|
155
107
|
|
|
156
|
-
|
|
157
|
-
// one item in the cart, then price it for each country the brief names
|
|
158
|
-
for (const country of ["DE", "AU"]) {
|
|
159
|
-
const { data } = await base44.functions.invoke("commerce/storefront-cart", {
|
|
160
|
-
action: "set-shipping-address", cart_token, address: { country, city: "x" },
|
|
161
|
-
});
|
|
162
|
-
console.log(country, data.data.available_shipping_methods.map((m) => m.cost));
|
|
163
|
-
}
|
|
164
|
-
// DE → [20], AU → [100] for the payload above. A [] means no location matched
|
|
165
|
-
// that address — the catch-all is missing.
|
|
166
|
-
```
|
|
108
|
+
Continent codes in full, state regions, tax binding, VAT-on-shipping, day-2 edits: [`../references/shipping-and-tax.md`](../references/shipping-and-tax.md).
|
|
167
109
|
|
|
168
110
|
## Images
|
|
169
111
|
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
Both failure modes are silent at seed time and visible only in the store, so check now: a **temporary or signed** URL (expiry params in the query string are the tell) must be re-hosted via `UploadFile`, and spot-check that one or two URLs resolve (200, image content type). Seeding without an image and setting it later through the admin API is allowed, but it is an open debt — track every imageless product and close it before handover.
|
|
112
|
+
**The URL you seed is the URL the store serves.** Resolve each to its final, permanent URL before seeding: the app's image generation, or `base44.integrations.Core.UploadFile({ file })` → public URL. A temporary/**signed** URL (expiry params in the query string are the tell) must be re-hosted via `UploadFile` first — the failure is silent at seed time and visible only in the store. Seeding imageless and patching later is allowed but is open debt; close it before handover.
|
|
173
113
|
|
|
174
114
|
## Payments — the decision
|
|
175
115
|
|
|
176
|
-
|
|
116
|
+
Online card payments are **off by default**: the seeded store takes the manual `offline` method and is complete and payable — the order goes on-hold and `/order-received` renders the gateway's payment instructions, no code, no credentials. **Enable `card` only if a provider is wired in the same stretch of work**: an enabled card option with nothing behind it answers **`503 no_card_payment_provider`** the moment a customer picks it.
|
|
177
117
|
|
|
178
|
-
|
|
118
|
+
`payment_methods` is the on/off switch — the listed slugs are enabled, every other row disabled; it is idempotent and needs no catalog, so `{ payment_methods: ["offline", "card"] }` alone is the later switch.
|
|
179
119
|
|
|
180
|
-
|
|
|
120
|
+
| The request says | Decision |
|
|
181
121
|
|---|---|
|
|
182
|
-
| A provider is named ("use Stripe") | copy the provider file, enable `card` — whenever convenient
|
|
183
|
-
| Selling online implied, no provider named |
|
|
184
|
-
| Paid another way (transfer, COD, invoice, pickup
|
|
185
|
-
| Payments not mentioned
|
|
186
|
-
|
|
187
|
-
**Timing: late, never the opening move.** Payments have no dependency edge into anything — the admin, the seed and the entire buy path down to `place-order` work with no provider — so putting the provider connection before the store exists only stalls the build. Any instruction that treats a payment provider as a required install step is superseded by this section.
|
|
188
|
-
|
|
189
|
-
**Say where payments landed** in your handover summary, including "offline only, cards off". A store's owner should never discover their payment configuration from a customer who couldn't pay.
|
|
122
|
+
| A provider is named ("use Stripe") | copy the provider file, enable `card` — whenever convenient |
|
|
123
|
+
| Selling online implied, no provider named | raise the provider **after** the store works |
|
|
124
|
+
| Paid another way (transfer, COD, invoice, pickup) | nothing to do — the default is exactly this |
|
|
125
|
+
| Payments not mentioned | leave the default, **state it at handover** |
|
|
190
126
|
|
|
191
|
-
*
|
|
127
|
+
*Cards only:* **if it is Stripe the code is already written** — copy `base44/shared/commerce/card-payment.stripe.ts` over `base44/shared/commerce/card-payment.ts` **whole** (`fs.copyFileSync`; a partial edit breaks every function's deploy), then enable via `payment_methods`. Nothing needs filling in. Any other provider: [`../references/online-payments.md`](../references/online-payments.md).
|
|
192
128
|
|
|
193
129
|
## Done — forget this file
|
|
194
130
|
|
|
195
|
-
- [ ]
|
|
196
|
-
- [ ] `warnings`
|
|
197
|
-
- [ ] Shipping
|
|
131
|
+
- [ ] Seed returned success — real products, final permanent image URLs; slugs recorded and pages link by them.
|
|
132
|
+
- [ ] `warnings` is empty, or every warning is deliberate and stated to the user.
|
|
133
|
+
- [ ] Shipping expressed in `locations` (catch-all present if the store ships worldwide); named tiers asserted.
|
|
198
134
|
- [ ] `coupons` seeded only if a coupon field exists ([`./02-storefront.md`](./02-storefront.md)).
|
|
199
|
-
- [ ] Cards
|
|
200
|
-
- [ ] Product slugs from `catalog.products[]` recorded, and the storefront links by them.
|
|
201
|
-
- [ ] If the brief named tiered rates, each named region prices to its rate (the `set-shipping-address` check above).
|
|
135
|
+
- [ ] Cards off, or on with the provider file copied whole.
|
|
202
136
|
|
|
203
137
|
Record these lines in your working notes; do not re-read this file.
|
|
204
138
|
|
|
@@ -67,3 +67,29 @@ Attribute and value *records* are shared between products, so the **Manage attri
|
|
|
67
67
|
dialog saves them immediately, unlike the rest of the form which batches until Save. There
|
|
68
68
|
are no Attributes or Ribbons pages in the side menu: attributes are reachable only from
|
|
69
69
|
here, ribbons from the `TaxonomyPanel` card in the product sidebar.
|
|
70
|
+
|
|
71
|
+
## Taxonomy cascade semantics (`save-term` / `delete-term`)
|
|
72
|
+
|
|
73
|
+
What `commerce/admin-products` `save-term`/`delete-term` do beyond the plain upsert/delete
|
|
74
|
+
(signatures and the per-taxonomy field table: [`../docs/api-admin.md`](../docs/api-admin.md#commerceadmin-products)).
|
|
75
|
+
These rules apply whenever you build custom taxonomy tooling against those actions — the
|
|
76
|
+
shipped dialogs already obey them.
|
|
77
|
+
|
|
78
|
+
- Only a category has a slug, derived from its name and made unique; its `parent_id`
|
|
79
|
+
pointing at itself is coerced to `""` (`categoryWithDescendants` would loop). An
|
|
80
|
+
attribute's **`code`** is derived from the name and made unique — it is the key a
|
|
81
|
+
storefront filter URL should carry.
|
|
82
|
+
- Creating a **ribbon** is get-or-create: a name that already exists case-insensitively
|
|
83
|
+
returns the existing record instead of splitting the ribbon in two.
|
|
84
|
+
- Renaming an **attribute value** rewrites `attributes[].options` and `default_attributes`
|
|
85
|
+
on every product using it, and the matching `option` on their variations — products
|
|
86
|
+
store a value by name, so the rename would otherwise orphan them.
|
|
87
|
+
- Deleting an **attribute** always deletes its terms (a term outliving its attribute is
|
|
88
|
+
unreachable); `detach: true` additionally strips the attribute from every product's
|
|
89
|
+
`attributes[]`. For a category or ribbon, products keep the id by default (the
|
|
90
|
+
storefront skips ids that no longer resolve); `detach: true` strips it from every
|
|
91
|
+
product first.
|
|
92
|
+
- **For an attribute *value* `detach` is a no-op** — deleting a value leaves its name in
|
|
93
|
+
every product's `attributes[].options` and leaves the variations that use it in place,
|
|
94
|
+
so remove the value from the products first (or expect variants the storefront can no
|
|
95
|
+
longer resolve).
|
|
@@ -50,21 +50,21 @@ There is **no product `type` field**. `attributes[]` tells the shapes apart:
|
|
|
50
50
|
|
|
51
51
|
A product with attributes is **only** sellable through a variant: `add-item` without a `variation_id` is `400 variation_required`, with no fall-back to the parent. So one with attributes but no variations is unsellable by design, not by accident (§5 covers rendering that state).
|
|
52
52
|
|
|
53
|
-
Three rules
|
|
53
|
+
Three rules are enforced by exports — use them and they can't drift between views:
|
|
54
54
|
|
|
55
|
-
- **From-price.**
|
|
56
|
-
- **Images are objects, and may be absent.** Every stored image is `{src, name, alt}`, never a URL string
|
|
57
|
-
- **Modifiers are not attributes.** `meta_data` (Material, Care, GTIN) is descriptive — never a selector, never a ribbon
|
|
55
|
+
- **From-price.** A parent's `regular_price`/`price`/`on_sale` are rolled up from the cheapest publishable variant on every save (by `admin-products` and the seeder) — real, sortable, filterable, but the **lowest** price, not *the* price. `productPrice(rowOrView, {formatMoney})` / `useProductPrice(rowOrView)` take a listing row **or** a `resolveSelection` view and return `{label, compareAtLabel, onSale, isFrom, isRange, min, max}`: "From €19.99" on a card, a range unresolved, the exact price resolved.
|
|
56
|
+
- **Images are objects, and may be absent.** Every stored image is `{src, name, alt}`, never a URL string — passing the object itself to `<img src>` fails the load — and `images` can legitimately be empty. `productImages(product)` / `normalizeImage(entry)` return clean entries (non-empty `src`, defaulted `alt`); an empty array means *render your placeholder* (`useProductGallery` builds on them, `hasImages`).
|
|
57
|
+
- **Modifiers are not attributes.** `meta_data` (Material, Care, GTIN) is descriptive — never a selector, never a ribbon; how it renders is a design decision (§3), a control is what it can never become.
|
|
58
58
|
|
|
59
59
|
## 3. What each view *can* render
|
|
60
60
|
|
|
61
|
-
Both lists below are field inventories — what the data supports — **not a layout and not an order
|
|
61
|
+
Both lists below are field inventories — what the data supports — **not a layout and not an order**; a store rendering exactly these fields in exactly this sequence is the generic storefront every generated catalog produces.
|
|
62
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`)
|
|
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`). Link the whole card to the product page; the layout is yours.
|
|
64
64
|
|
|
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)`, `
|
|
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)`, `useAddToCart`, `productSpecs(product)`, `useProductReviews`, `p.upsells`/`p.crossSells`.
|
|
66
66
|
|
|
67
|
-
**Attributes and modifiers are individually designable.** `
|
|
67
|
+
**Attributes and modifiers are individually designable.** `productSpecs` rows carry `key`/`label`/`titleLabel` (display-cased) plus an inferred `type` (`numeric` with `number`/`unit` split out, `duration`, `location`, `list` with `items`, `text`); `useProductSpecs(product, { pick })` is the React wrapper, its get/pick case/underscore-insensitive. Branch on `type`/`key` instead of mapping everything into one uniform chip row per axis and one grey label/value table — the design guidance is [`../install/02-storefront.md`](../install/02-storefront.md)'s; §5's rules govern selector *behaviour*, never its form.
|
|
68
68
|
|
|
69
69
|
## 4. Ribbons — in **both** views
|
|
70
70
|
|
|
@@ -99,7 +99,7 @@ Three decisions the helpers can't make for you:
|
|
|
99
99
|
|
|
100
100
|
**Attributes but no usable variations** — what attaching an attribute and stopping leaves behind — deliberately gets no parent fallback, because `add-item` would reject it: empty `axes[].options`, `purchasable: false`, `addToCart: null`. Render it as unavailable rather than painting empty selector groups, and guard the label (with no attributes at all `missingAxes[0]?.name` is `undefined` → "Select a undefined").
|
|
101
101
|
|
|
102
|
-
**Add to cart** goes through `useAddToCart()`,
|
|
102
|
+
**Add to cart** goes through `useAddToCart(product)`, whose `addToCart()` never throws and maps the codes: `variation_required` (a page bug — empty `variation_id` on a product with attributes), `out_of_stock` / `insufficient_stock`, and `variation_not_found` (the catalog changed under the page → the hook reloads the product itself). `product.sold_individually` caps quantity at 1, already reflected in `useProduct().maxQuantity`. Two behaviors that matter only if you hand-roll the view model: non-publishable variations never leak an option into the UI, and an empty `option` on a variation axis means **"any"**.
|
|
103
103
|
|
|
104
104
|
## 6. Adding a `get-product`-only field to the listing
|
|
105
105
|
|
|
@@ -41,14 +41,9 @@ That is the whole of it. The file expects the app to be connected to Stripe and
|
|
|
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
|
|
|
44
|
-
##
|
|
44
|
+
## The premade flow
|
|
45
45
|
|
|
46
|
-
|
|
47
|
-
2. **Confirmation — two idempotent paths**, whichever runs second is a no-op: the **customer return** (`/order-received` calls `commerce/payments` `complete-return`, which runs `checkCardPaymentPaid` and on true moves the order to `processing`, firing the stock/email/webhook side-effects — `useOrderReturn` is that page in one hook), and the **webhook**, which covers the buyer who pays and closes the tab.
|
|
48
|
-
3. **Payment links**: `commerce/payments` `create-link` mints a fresh page for any unpaid order through the same `createCardPayment` — the admin's payment-link button and order-received's "Pay now".
|
|
49
|
-
4. **Refunds**: `commerce/admin-refunds` with `refund_payment: true` calls `refundCardPayment` **before** writing the local record (a failed provider refund writes nothing). While unimplemented it answers `501 card_refund_not_implemented` — record the refund without `refund_payment` and return the money from the provider's dashboard.
|
|
50
|
-
|
|
51
|
-
With the gateway enabled and no provider behind it, picking Credit card answers `503 no_card_payment_provider` — implement the file, or switch the option off in Settings → Payments. Every other payment option is **manual**: the order goes on-hold with the option's description as payment instructions and the operator moves it on when the money arrives. Those need no code, and the admin can add more of them.
|
|
46
|
+
Checkout (`place-order`, `card` gateway) creates the order `pending`, runs `createCardPayment`, stores its `reference` on the order and redirects to `url`; return URLs carry `order_id`/`order_key`/`payment=`. Confirmation is **two idempotent paths** (the second a no-op): the customer return (`/order-received` → `commerce/payments` `complete-return` → `checkCardPaymentPaid` → `processing` with all side-effects; `useOrderReturn` is that page in one hook) and the webhook (covers the closed tab). `create-link` mints a fresh page for any unpaid order the same way; `admin-refunds` `refund_payment: true` calls `refundCardPayment` **before** writing locally (unimplemented → `501`; record without the flag instead). Gateway enabled with no provider behind it → `503 no_card_payment_provider`; every other payment option is **manual** (on-hold, the option's description as payment instructions) and needs no code.
|
|
52
47
|
|
|
53
48
|
## Implementation rules
|
|
54
49
|
|
|
@@ -62,12 +57,6 @@ For a **custom** provider (the shipped files already obey all of these):
|
|
|
62
57
|
- **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.
|
|
63
58
|
- The helpers in `shared/commerce/payments.ts` (return-URL building, `confirmCardPayment`, reference bookkeeping) are premade — don't duplicate or bypass them.
|
|
64
59
|
|
|
65
|
-
## `parseWebhook
|
|
66
|
-
|
|
67
|
-
`parseWebhook(req, payload)` lives in `card-payment.ts` with the other three; `commerce/payment-webhook` is premade and calls it with the raw request and the raw body — the exact bytes, so signature schemes work. It returns `CardWebhookEvent | null`:
|
|
68
|
-
|
|
69
|
-
- **The simple, secure default — the nudge**: parse the event, read the `order_id`/`order_key` metadata `createCardPayment` attached, return `{ order_id, order_key, paid: false }`. No signing secret: the premade flow verifies through `checkCardPaymentPaid` against the provider's API, so forgery is impossible by construction — a forged call can at worst trigger a re-check, and the `order_key` match stops an event being aimed at another order. This is what the shipped Stripe file does.
|
|
70
|
-
- **The signature-verified fast path** (optional): verify the signature over the raw `payload` bytes (Stripe: `constructEventAsync` with a webhook signing secret) and return `paid: true` with the event's `reference`; the premade code then trusts it without the API round-trip. **`paid: true` from an unverified body is the one way to break this design.** Set `reference` only from a verified event; otherwise leave it unset and the flow uses the reference stored on the order at checkout.
|
|
71
|
-
- Return **`null`** for events that aren't about one of this store's payments; the function answers 200 so the provider doesn't retry.
|
|
60
|
+
## `parseWebhook`, briefly
|
|
72
61
|
|
|
73
|
-
|
|
62
|
+
Premade `commerce/payment-webhook` feeds `parseWebhook(req, payload)` the raw request and body (exact bytes, so signature schemes work); it returns `CardWebhookEvent | null` — `null` = not this store's payment, answered 200 (no retry). The secure default (what the shipped Stripe file does) is the **nudge**: read the `order_id`/`order_key` metadata `createCardPayment` attached, return `{ order_id, order_key, paid: false }` — no signing secret, since payment is verified through `checkCardPaymentPaid` against the provider's API and the `order_key` match stops an event aimed at another order. Optionally verify the signature over the raw `payload` bytes and return `paid: true` + the event's `reference` to skip that round-trip — **`paid: true` (or a `reference`) from an unverified body is the one way to break this design.** Everything after `parseWebhook` — lookup, `order_key` match, idempotent confirmation, progression — is premade either way.
|
|
@@ -32,13 +32,31 @@ Every action above is guarded by `requireAdmin()`, so each workflow must run wit
|
|
|
32
32
|
|
|
33
33
|
- **Pagination.** SDK `filter`/`list` cap at 5,000 records/page and there is **no total-count API**. Server-side scans use paged loops (`shared/commerce/scan.ts` `scanAll`, page size 500); admin lists and `useProductList` use limit+skip with a `limit+1` "has-next" probe — the UI shows *Page N ‹ ›*, never a total.
|
|
34
34
|
- **Search** is server-side (`search` actions scan + JS-filter) because entity `filter` is exact-match only.
|
|
35
|
-
- **Reports** scan on demand — fine to ~10k orders per range. `commerce/admin-reports` `summary`/`sales`/`top-sellers` scan `commerce.Order` (+ `commerce.OrderRefund`) filtered to counted orders (`date_paid` set, or status `processing`/`completed`); net sales = gross − refunds − tax − shipping. Beyond that, cache `summary` and materialize an `OrderStats` entity (one record per day per status, summed totals) updated on each order transition.
|
|
35
|
+
- **Reports** scan on demand — fine to ~10k orders per range. `commerce/admin-reports` `summary`/`sales`/`top-sellers` scan `commerce.Order` (+ `commerce.OrderRefund`) filtered to counted orders (`date_paid` set, or status `processing`/`completed`); net sales = gross − refunds − tax − shipping. Beyond that, cache `summary` and materialize an `OrderStats` entity (one record per day per status, summed totals) updated on each order transition. The full action table is below.
|
|
36
36
|
- **No transactions.** Four consequences, all documented in code:
|
|
37
37
|
- `nextOrderNumber` is `max(order_number)+1` with a small retry; two concurrent checkouts could theoretically collide — acceptable for typical volume, or front it with a counter entity.
|
|
38
38
|
- Stock decrement is last-write-wins; oversell is possible on simultaneous checkouts of the last unit. The hold mechanism (`hold_stock_minutes`) mitigates it; a stricter reserve step is yours to add.
|
|
39
39
|
- Denormalized counters (`usage_count`, `total_sales`, `orders_count`, term `count`) can drift; the recount actions above repair them.
|
|
40
40
|
- **Record size.** Orders embed their line/shipping/tax/fee/coupon lines, so orders with hundreds of distinct line items push against per-record size limits.
|
|
41
41
|
|
|
42
|
+
### `commerce/admin-reports` actions
|
|
43
|
+
|
|
44
|
+
The complete payload/return contract (invocation: [`../docs/api-admin.md`](../docs/api-admin.md#commerceadmin-reports)):
|
|
45
|
+
|
|
46
|
+
| Action | Payload | Returns |
|
|
47
|
+
|---|---|---|
|
|
48
|
+
| `summary` | — | `{ sales_today, sales_month, orders_by_status, low_stock_count, out_of_stock_count, top_seller }` where `sales_*` = `{ gross_sales, net_sales, orders, items, tax, shipping, discount, refunds }` |
|
|
49
|
+
| `sales` | `{ date_min?, date_max?, interval? }` (`day`\|`week`\|`month`) | `{ totals: <agg>, series: [{ period, ...agg }] }` (net = gross − refunds − tax − shipping) |
|
|
50
|
+
| `top-sellers` | `{ date_min?, date_max?, limit? }` | `{ rows: [{ product_id, name, sku, quantity, net_revenue }] }` |
|
|
51
|
+
| `stock` | — | `{ low_stock: [...], out_of_stock: [...] }` |
|
|
52
|
+
| `orders-totals` | — | `{ [status]: count }` |
|
|
53
|
+
| `products-totals` | — | `{ total, by: { [status]: count } }` |
|
|
54
|
+
| `customers-totals` | — | `{ total, guests, registered, paying }` |
|
|
55
|
+
| `coupons-totals` | — | `{ total, by: { [discount_type]: count } }` |
|
|
56
|
+
| `reviews-totals` | — | `{ total, by: { [status]: count } }` |
|
|
57
|
+
| `categories-totals` / `ribbons-totals` | — | `{ total, terms: [{ id, name, count }] }` |
|
|
58
|
+
| `attributes-totals` | — | `{ total, attributes: [{ id, name, terms }] }` |
|
|
59
|
+
|
|
42
60
|
## Webhooks (outbound)
|
|
43
61
|
|
|
44
62
|
Managed in the admin at **Settings → Webhooks** (`settings/webhooks`; the source still lives under `pages/status/`).
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
---
|
|
2
|
+
read_when: "You are about to drive the storefront from a browser script — adding to the cart, stepping quantities, filling the checkout, placing an order."
|
|
3
|
+
skip_when: "You are not scripting the storefront."
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Driving the storefront from a browser script
|
|
7
|
+
|
|
8
|
+
Whatever you choose to check and however you check it, these are what make a
|
|
9
|
+
working storefront look broken under a script. The common cause is acting
|
|
10
|
+
faster than the cart settles: the hooks are optimistic and debounced, so the
|
|
11
|
+
DOM is briefly right about the *intent* and wrong about the *state*.
|
|
12
|
+
|
|
13
|
+
- **Wait for the cart, then for each row.** Two waits, neither optional. Before
|
|
14
|
+
the first action, wait for the initial load to settle — `status` leaves
|
|
15
|
+
`"loading"` exactly once, so the signal is the loaded UI (a row, or the empty
|
|
16
|
+
state), never a fixed sleep. Then after every stepper click wait for **that
|
|
17
|
+
row**: the click starts a 250ms debounce before the request even leaves, so
|
|
18
|
+
reading the quantity or total straight after gives the optimistic number and
|
|
19
|
+
stale totals, and two quick clicks send **one** request for the final number.
|
|
20
|
+
Wait for the row's busy state to clear (`aria-busy`, re-enabled buttons)
|
|
21
|
+
before reading or clicking again.
|
|
22
|
+
- **Scope actions to the visible drawer.** With a drawer, the page can hold two
|
|
23
|
+
"Remove" buttons for one line — drawer and cart page behind it. A drawer built
|
|
24
|
+
on `useCartUI` is unmounted while closed, so its copies can't be hit by
|
|
25
|
+
mistake — but still query inside the open drawer's container, not the
|
|
26
|
+
document. A click that seems to do nothing usually hit a hidden copy.
|
|
27
|
+
- **Remove lines one at a time.** Clicking every "Remove" in one pass fails on
|
|
28
|
+
its own terms: cart calls are serialized, each removal re-renders the list,
|
|
29
|
+
and buttons collected up front are detached by the time the loop reaches
|
|
30
|
+
them. Remove one, wait for the row to disappear, then the next.
|
|
31
|
+
- **Verify the checkout navigation before cleaning up.** Confirm you are on
|
|
32
|
+
`/checkout` — URL plus a field of the form on screen — before emptying the
|
|
33
|
+
cart or moving on. Tearing the cart down while still on the cart page, or
|
|
34
|
+
mid-navigation, produces an empty checkout that reads as a routing bug.
|
|
35
|
+
- **Filling the checkout.** Every field is a controlled React input, so writing
|
|
36
|
+
`el.value` changes nothing React sees. Use the harness's own fill (it
|
|
37
|
+
dispatches `input` + `change`) — never lift the native setter off
|
|
38
|
+
`HTMLInputElement.prototype` and call `descriptor.set(v)`: detached from the
|
|
39
|
+
element it throws `Illegal invocation`, and the workaround it is reaching for
|
|
40
|
+
is what the fill helper already does.
|
|
41
|
+
- **`placeOrder` ends the page.** It navigates with `window.location.assign`,
|
|
42
|
+
so a script that placed an order loses its page context and can land back at
|
|
43
|
+
`/` — while the order itself was created normally. That is the hard
|
|
44
|
+
navigation, not a broken redirect. The confirmation is reachable at any time
|
|
45
|
+
from a fresh navigation to `/order-received?order_id=…&order_key=…` (the ids
|
|
46
|
+
come back in `placeOrder`'s result, and `commerce/admin-orders` `search` has
|
|
47
|
+
the order either way).
|
|
@@ -205,8 +205,10 @@ export function useFormatMoney() {
|
|
|
205
205
|
* - `lines` are `cart.items` decorated with what a renderer needs and would
|
|
206
206
|
* otherwise re-derive: `attributesLabel` ("Size: 42 · Color: Ivory" — the raw
|
|
207
207
|
* `attributes` is an **array** of `{name, option}`, never a map),
|
|
208
|
-
* `image` normalized to `{src, alt}` or null,
|
|
209
|
-
* `
|
|
208
|
+
* `image` normalized to `{src, alt}` or null, money pre-formatted
|
|
209
|
+
* (`totalLabel`, `subtotalLabel`, `unitPriceLabel` — no `useFormatMoney`
|
|
210
|
+
* needed in a row), `purchasable` as a boolean with `unavailableReason`
|
|
211
|
+
* beside it, and the quantity bounds.
|
|
210
212
|
* - `notices` normalizes `coupon_notices` + `removed_items` into one list —
|
|
211
213
|
* render it, or a line that auto-dropped vanishes with no explanation.
|
|
212
214
|
* - `mutationError` is the last failed mutation (insufficient stock, an expired
|
|
@@ -286,6 +288,9 @@ export function useCart() {
|
|
|
286
288
|
...item,
|
|
287
289
|
attributesLabel: attributesLabel(item.attributes),
|
|
288
290
|
image: item.image ? { src: item.image, alt: item.name ?? "" } : null,
|
|
291
|
+
totalLabel: item.total != null ? formatMoney(item.total) : "",
|
|
292
|
+
subtotalLabel: item.subtotal != null ? formatMoney(item.subtotal) : "",
|
|
293
|
+
unitPriceLabel: item.price != null ? formatMoney(item.price) : "",
|
|
289
294
|
maxQuantity,
|
|
290
295
|
canIncrease: item.quantity < maxQuantity,
|
|
291
296
|
canDecrease: item.quantity > 1,
|
|
@@ -293,7 +298,7 @@ export function useCart() {
|
|
|
293
298
|
unavailableReason: purchasable ? null : (item.purchasable?.error ?? null),
|
|
294
299
|
};
|
|
295
300
|
}),
|
|
296
|
-
[items],
|
|
301
|
+
[items, formatMoney],
|
|
297
302
|
);
|
|
298
303
|
|
|
299
304
|
const notices = useMemo(
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import React, { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
|
|
2
|
+
import { useLocation } from "react-router-dom";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Cart drawer/panel state, headless: `open` plus the handlers to change it,
|
|
6
|
+
* and the two behaviors every drawer needs but a hand-written one forgets —
|
|
7
|
+
* it closes when the route changes (`closeOnNavigate`) and it opens when an
|
|
8
|
+
* item lands in the cart (`openOnAdd`, wired through `useAddToCart` /
|
|
9
|
+
* `useUpsell`; pass false for a navigate-to-bag flow). Esc closes it. You own
|
|
10
|
+
* every element, class and attribute.
|
|
11
|
+
*
|
|
12
|
+
* Mount `<CartUIProvider>` once, inside `<StorefrontProvider>`, around the
|
|
13
|
+
* layout. Then the layout renders off `open`:
|
|
14
|
+
*
|
|
15
|
+
* function StoreLayout() {
|
|
16
|
+
* const ui = useCartUI();
|
|
17
|
+
* const { itemCount } = useCart();
|
|
18
|
+
* return (<>
|
|
19
|
+
* <header>… <button type="button" onClick={ui.toggleCart}
|
|
20
|
+
* aria-expanded={ui.open}>Bag ({itemCount})</button></header>
|
|
21
|
+
* <Outlet />
|
|
22
|
+
* {ui.open && (<>
|
|
23
|
+
* <div onClick={ui.closeCart} aria-hidden="true" className="…" />
|
|
24
|
+
* <aside role="dialog" aria-modal="true" aria-label="Cart" className="…">
|
|
25
|
+
* <button type="button" onClick={ui.closeCart} aria-label="Close cart">×</button>
|
|
26
|
+
* {…your cart rows: useCart + CartLine…}
|
|
27
|
+
* </aside>
|
|
28
|
+
* </>)}
|
|
29
|
+
* </>);
|
|
30
|
+
* }
|
|
31
|
+
*
|
|
32
|
+
* ⚑ **Render the drawer conditionally (`{ui.open && …}`), as above.** The
|
|
33
|
+
* classic drawer bug is a panel that is translated off-screen but still
|
|
34
|
+
* mounted: its buttons stay clickable, tab-able and visible to screen readers.
|
|
35
|
+
* Unmounting it when closed is the trivial fix. If you keep it mounted to
|
|
36
|
+
* animate the slide, that concern is yours again — set the `inert` attribute
|
|
37
|
+
* while closed.
|
|
38
|
+
*
|
|
39
|
+
* The overlay is a click-away surface (`aria-hidden`, no tab stop needed) —
|
|
40
|
+
* it is not the close control; a named close button inside the panel is.
|
|
41
|
+
*
|
|
42
|
+
* `useCartUI()` → `{ open, openCart, closeCart, toggleCart }`.
|
|
43
|
+
* `useCartUIOptional()` returns null instead of throwing (how `useAddToCart`
|
|
44
|
+
* integrates without requiring the provider).
|
|
45
|
+
*
|
|
46
|
+
* A store whose cart is a page, not a drawer, skips this provider entirely.
|
|
47
|
+
*/
|
|
48
|
+
|
|
49
|
+
const CartUIContext = createContext(null);
|
|
50
|
+
|
|
51
|
+
/** Internal: closes the drawer whenever the route changes (needs a Router above). */
|
|
52
|
+
function CloseOnNavigate({ close }) {
|
|
53
|
+
const { pathname } = useLocation();
|
|
54
|
+
const first = useRef(true);
|
|
55
|
+
useEffect(() => {
|
|
56
|
+
if (first.current) {
|
|
57
|
+
first.current = false;
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
close();
|
|
61
|
+
}, [pathname, close]);
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* @param {{closeOnNavigate?: boolean, openOnAdd?: boolean,
|
|
67
|
+
* children: React.ReactNode}} props
|
|
68
|
+
*/
|
|
69
|
+
export function CartUIProvider({ closeOnNavigate = true, openOnAdd = true, children }) {
|
|
70
|
+
const [open, setOpen] = useState(false);
|
|
71
|
+
const openCart = useCallback(() => setOpen(true), []);
|
|
72
|
+
const closeCart = useCallback(() => setOpen(false), []);
|
|
73
|
+
const toggleCart = useCallback(() => setOpen((o) => !o), []);
|
|
74
|
+
|
|
75
|
+
// Esc closes — the keyboard's way out is Esc and the named close button.
|
|
76
|
+
useEffect(() => {
|
|
77
|
+
if (!open) return;
|
|
78
|
+
const onKey = (e) => {
|
|
79
|
+
if (e.key === "Escape") setOpen(false);
|
|
80
|
+
};
|
|
81
|
+
window.addEventListener("keydown", onKey);
|
|
82
|
+
return () => window.removeEventListener("keydown", onKey);
|
|
83
|
+
}, [open]);
|
|
84
|
+
|
|
85
|
+
const value = useMemo(
|
|
86
|
+
() => ({
|
|
87
|
+
open,
|
|
88
|
+
openCart,
|
|
89
|
+
closeCart,
|
|
90
|
+
toggleCart,
|
|
91
|
+
onItemAdded: openOnAdd ? openCart : null,
|
|
92
|
+
}),
|
|
93
|
+
[open, openCart, closeCart, toggleCart, openOnAdd],
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
return (
|
|
97
|
+
<CartUIContext.Provider value={value}>
|
|
98
|
+
{closeOnNavigate && <CloseOnNavigate close={closeCart} />}
|
|
99
|
+
{children}
|
|
100
|
+
</CartUIContext.Provider>
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** The nearest <CartUIProvider>'s controls; throws without one. */
|
|
105
|
+
export function useCartUI() {
|
|
106
|
+
const ctx = useContext(CartUIContext);
|
|
107
|
+
if (!ctx) {
|
|
108
|
+
throw new Error(
|
|
109
|
+
"useCartUI needs a <CartUIProvider> above it — mount it once inside <StorefrontProvider>, around your layout.",
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
return ctx;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Internal-ish: null instead of throwing, for optional integrations. */
|
|
116
|
+
export function useCartUIOptional() {
|
|
117
|
+
return useContext(CartUIContext);
|
|
118
|
+
}
|