@base44/app-plugin-commerce 0.2.4 → 0.2.6
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/package.json +1 -1
- package/skills/commerce/SKILL.md +64 -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 +185 -319
- package/skills/commerce/install/03-data.md +43 -106
- package/skills/commerce/references/admin-product-form.md +26 -0
- package/skills/commerce/references/catalog-rendering.md +8 -8
- 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 +45 -8
- package/src/commerce/storefront/cartUI.jsx +190 -0
- package/src/commerce/storefront/index.js +50 -20
- package/src/commerce/storefront/pickers.jsx +98 -23
- package/src/commerce/storefront/useAddressForm.js +71 -25
- package/src/commerce/storefront/useCartLine.js +40 -4
- package/src/commerce/storefront/useCheckout.jsx +13 -0
- package/src/commerce/storefront/useOrderReturn.js +7 -5
- package/src/commerce/storefront/usePlaceOrder.js +55 -0
- package/src/commerce/storefront/useProduct.js +93 -13
- package/src/commerce/storefront/useProductList.js +12 -0
- package/src/commerce/storefront/useUpsell.js +90 -0
- package/src/commerce/utils/index.js +3 -2
- package/src/commerce/utils/specs.js +104 -17
- 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,75 +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 spec
|
|
67
|
-
// NOT variant axes
|
|
68
|
-
// don't select anything. 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.
|
|
69
50
|
meta_data: [
|
|
70
51
|
{ key: "Material", value: "Recycled knit upper" },
|
|
71
|
-
{ key: "
|
|
52
|
+
{ key: "Weight", value: "248 g" }, // "<number> <unit>" → a numeric row
|
|
72
53
|
],
|
|
73
54
|
|
|
74
|
-
attributes: [ // the axes → one selector each
|
|
55
|
+
attributes: [ // the axes → one selector each
|
|
75
56
|
{ name: "Size", options: ["41", "42"] },
|
|
76
57
|
{ name: "Color", options: ["Black", "White"] },
|
|
77
58
|
],
|
|
78
59
|
default_options: { Size: "42", Color: "Black" },
|
|
79
|
-
variations: [ // omit entirely → all
|
|
60
|
+
variations: [ // omit entirely → all combos auto-generated
|
|
80
61
|
{ options: { Size: "41", Color: "Black" }, stock_quantity: 4 },
|
|
81
62
|
{ options: { Size: "42", Color: "White" }, regular_price: 94, stock_quantity: 3,
|
|
82
63
|
image: "https://…/sneaker-white.jpg" }, // per-variation image for a visual axis
|
|
83
64
|
],
|
|
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: [],
|
|
99
65
|
},
|
|
100
66
|
],
|
|
101
67
|
coupons: [{ code: "WELCOME10", discount_type: "percent", amount: 10 }],
|
|
102
68
|
// ONLY with a coupon field in the cart or checkout (see ./02-storefront.md)
|
|
103
|
-
// locations: [ … ], // shipping —
|
|
69
|
+
// locations: [ … ], // shipping — next section; passing any makes them the store's ONLY ones
|
|
104
70
|
});
|
|
105
71
|
return res.data; // ← the { success, data } envelope: plain JSON
|
|
106
72
|
} catch (e) {
|
|
@@ -108,29 +74,23 @@ try {
|
|
|
108
74
|
}
|
|
109
75
|
```
|
|
110
76
|
|
|
111
|
-
**Running this through a code-execution tool? Return `res.data`, never the raw response
|
|
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.
|
|
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.
|
|
114
78
|
|
|
115
|
-
Reference taxonomy by **display name**
|
|
116
|
-
|
|
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.
|
|
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.
|
|
118
80
|
|
|
119
81
|
The response reports everything; these matter downstream:
|
|
120
82
|
|
|
121
83
|
```jsonc
|
|
122
|
-
{ "catalog": { "
|
|
123
|
-
"products": [{ "name": "Runner Sneaker", "id": "…", "slug": "runner-sneaker", "variation_count": 2 }] },
|
|
124
|
-
"store_name": { "value": "Aurora Threads", "action": "created" },
|
|
84
|
+
{ "catalog": { "products": [{ "name": "Runner Sneaker", "id": "…", "slug": "runner-sneaker", "variation_count": 2 }] },
|
|
125
85
|
"payment_methods": null, // null = the default (offline on, card off)
|
|
126
|
-
"warnings": [] } // always present; read it — see
|
|
86
|
+
"warnings": [] } // always present; read it — see shipping
|
|
127
87
|
```
|
|
128
88
|
|
|
129
|
-
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).
|
|
130
90
|
|
|
131
91
|
## Shipping — declare it in the seed call
|
|
132
92
|
|
|
133
|
-
|
|
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:
|
|
134
94
|
|
|
135
95
|
```js
|
|
136
96
|
locations: [
|
|
@@ -139,63 +99,40 @@ locations: [
|
|
|
139
99
|
]
|
|
140
100
|
```
|
|
141
101
|
|
|
142
|
-
- **`continents: ["EU"]`** spares you a 51-
|
|
143
|
-
- **`rest_of_world: true`** is the catch-all
|
|
144
|
-
-
|
|
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 }`.
|
|
146
|
-
|
|
147
|
-
**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.
|
|
148
|
-
|
|
149
|
-
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).
|
|
150
105
|
|
|
151
|
-
**
|
|
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).
|
|
152
107
|
|
|
153
|
-
|
|
154
|
-
// one item in the cart, then price it for each country the brief names
|
|
155
|
-
for (const country of ["DE", "AU"]) {
|
|
156
|
-
const { data } = await base44.functions.invoke("commerce/storefront-cart", {
|
|
157
|
-
action: "set-shipping-address", cart_token, address: { country, city: "x" },
|
|
158
|
-
});
|
|
159
|
-
console.log(country, data.data.available_shipping_methods.map((m) => m.cost));
|
|
160
|
-
}
|
|
161
|
-
// DE → [20], AU → [100] for the payload above. A [] means no location matched
|
|
162
|
-
// that address — the catch-all is missing.
|
|
163
|
-
```
|
|
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).
|
|
164
109
|
|
|
165
110
|
## Images
|
|
166
111
|
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
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.
|
|
170
113
|
|
|
171
114
|
## Payments — the decision
|
|
172
115
|
|
|
173
|
-
|
|
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.
|
|
174
117
|
|
|
175
|
-
|
|
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.
|
|
176
119
|
|
|
177
|
-
|
|
|
120
|
+
| The request says | Decision |
|
|
178
121
|
|---|---|
|
|
179
|
-
| A provider is named ("use Stripe") | copy the provider file, enable `card` — whenever convenient
|
|
180
|
-
| Selling online implied, no provider named |
|
|
181
|
-
| Paid another way (transfer, COD, invoice, pickup
|
|
182
|
-
| Payments not mentioned
|
|
183
|
-
|
|
184
|
-
**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.
|
|
185
|
-
|
|
186
|
-
**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** |
|
|
187
126
|
|
|
188
|
-
*
|
|
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).
|
|
189
128
|
|
|
190
129
|
## Done — forget this file
|
|
191
130
|
|
|
192
|
-
- [ ]
|
|
193
|
-
- [ ] `warnings`
|
|
194
|
-
- [ ] 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.
|
|
195
134
|
- [ ] `coupons` seeded only if a coupon field exists ([`./02-storefront.md`](./02-storefront.md)).
|
|
196
|
-
- [ ] Cards
|
|
197
|
-
- [ ] Product slugs from `catalog.products[]` recorded, and the storefront links by them.
|
|
198
|
-
- [ ] 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.
|
|
199
136
|
|
|
200
137
|
Record these lines in your working notes; do not re-read this file.
|
|
201
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).
|
|
@@ -31,7 +31,7 @@ A listing **row** is the product record itself (minus paywalled fields) plus res
|
|
|
31
31
|
| `images[]`, `featured`, `short_description`, `description` | ✅ | ✅ | Cards normally use `images[0]` + `short_description`; every entry is an **object** — §2 |
|
|
32
32
|
| `sku`, `stock_status`, `stock_quantity`, `manage_stock`, `backorders` | ✅ | ✅ | |
|
|
33
33
|
| `average_rating`, `rating_count`, `total_sales` | ✅ | ✅ | Enough for stars on a card; the reviews themselves are not in a row |
|
|
34
|
-
| `downloadable`, `virtual`, `weight`, `dimensions`, `attributes[]`, `meta_data` | ✅ | ✅ | `attributes[]` is the variant axes; `meta_data` is the descriptive **modifiers** (`productSpecs(product)` turns them into spec
|
|
34
|
+
| `downloadable`, `virtual`, `weight`, `dimensions`, `attributes[]`, `meta_data` | ✅ | ✅ | `attributes[]` is the variant axes; `meta_data` is the descriptive **modifiers** (`productSpecs(product)` turns them into typed spec rows) |
|
|
35
35
|
| **`ribbons`** (resolved), `ribbon_ids`, `category_ids` | ✅ | ✅ | Rows carry `{id, name}` ribbons; `get-product` returns the full records |
|
|
36
36
|
| **`categories`** (resolved) | ❌ *ids only* | ✅ | §6 to add them to rows |
|
|
37
37
|
| **`variations[]`** (per-variant price/stock/image/attributes) | ❌ | ✅ | Why a product with variants can't be fully priced from a row |
|
|
@@ -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
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`.
|
|
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
|
|
|
@@ -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 inert while closed, so its copies can't be hit by mistake —
|
|
25
|
+
but still query inside the open drawer's container, not the document. A click
|
|
26
|
+
that seems to do nothing usually hit the 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).
|
|
@@ -19,14 +19,24 @@ import {
|
|
|
19
19
|
* StorefrontProvider — one client, one store-info cache, ONE shared cart.
|
|
20
20
|
*
|
|
21
21
|
* Mount it once, above every storefront page (product list, product page,
|
|
22
|
-
* cart, checkout, order-received)
|
|
22
|
+
* cart, checkout, order-received). It is NOT a <Route>. A store with shared
|
|
23
|
+
* chrome — nearly all of them — mounts it on a pathless layout route, wrapping
|
|
24
|
+
* the layout that renders <Outlet/>, which keeps the nav's cart badge and the
|
|
25
|
+
* page on one cart and leaves the admin outside:
|
|
23
26
|
*
|
|
24
27
|
* import { base44 } from "@/api/base44Client";
|
|
28
|
+
* <Route element={<StorefrontProvider base44={base44}><StoreLayout /></StorefrontProvider>}>
|
|
29
|
+
* <Route path="/" element={<Home />} /> …
|
|
30
|
+
* </Route>
|
|
31
|
+
*
|
|
32
|
+
* With no shared layout it can wrap <Routes> directly instead:
|
|
33
|
+
*
|
|
25
34
|
* <StorefrontProvider base44={base44}> <Routes>…</Routes> </StorefrontProvider>
|
|
26
35
|
*
|
|
27
|
-
*
|
|
28
|
-
* allows only <Route>/<Fragment> children there.
|
|
29
|
-
*
|
|
36
|
+
* As a child of <Routes> it throws ("is not a <Route> component"), since React
|
|
37
|
+
* Router allows only <Route>/<Fragment> children there. Never the other way
|
|
38
|
+
* round either: a layout that renders the provider inside itself puts the nav
|
|
39
|
+
* outside it, so the badge and the cart page read different carts.
|
|
30
40
|
*
|
|
31
41
|
* or, if other modules also need the raw client, create it once and share it:
|
|
32
42
|
*
|
|
@@ -128,7 +138,7 @@ export function useStorefrontState() {
|
|
|
128
138
|
const ctx = useContext(StorefrontContext);
|
|
129
139
|
if (!ctx) {
|
|
130
140
|
throw new Error(
|
|
131
|
-
"Storefront hooks need a <StorefrontProvider> above them — mount it once
|
|
141
|
+
"Storefront hooks need a <StorefrontProvider> above them — mount it once on a pathless layout route, wrapping the layout component that renders <Outlet/> (or, with no shared layout, around your <Routes>). It is never a <Route> itself: as a child of <Routes>, React Router rejects it.",
|
|
132
142
|
);
|
|
133
143
|
}
|
|
134
144
|
return ctx;
|
|
@@ -195,8 +205,10 @@ export function useFormatMoney() {
|
|
|
195
205
|
* - `lines` are `cart.items` decorated with what a renderer needs and would
|
|
196
206
|
* otherwise re-derive: `attributesLabel` ("Size: 42 · Color: Ivory" — the raw
|
|
197
207
|
* `attributes` is an **array** of `{name, option}`, never a map),
|
|
198
|
-
* `image` normalized to `{src, alt}` or null,
|
|
199
|
-
* `
|
|
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.
|
|
200
212
|
* - `notices` normalizes `coupon_notices` + `removed_items` into one list —
|
|
201
213
|
* render it, or a line that auto-dropped vanishes with no explanation.
|
|
202
214
|
* - `mutationError` is the last failed mutation (insufficient stock, an expired
|
|
@@ -204,6 +216,28 @@ export function useFormatMoney() {
|
|
|
204
216
|
* `useCartLine`/`useAddToCart` can handle failures locally.
|
|
205
217
|
* - `applyCoupon(code)` resolves to `{ ok, cart }` or `{ ok: false, code,
|
|
206
218
|
* message }` — an invalid code is expected flow, not an exception.
|
|
219
|
+
*
|
|
220
|
+
* ## When `status` settles — and what it does not cover
|
|
221
|
+
*
|
|
222
|
+
* `status` is `"loading"` for exactly one thing: the **first** `getCart()` of
|
|
223
|
+
* the session has not resolved yet (internally, `cart === undefined`). It
|
|
224
|
+
* settles once, to `"empty"` or `"ready"`, and after that:
|
|
225
|
+
*
|
|
226
|
+
* - **A mutation never returns it to `"loading"`.** Adding, updating, removing
|
|
227
|
+
* or couponing leaves `status` as it was, previous numbers on screen, until
|
|
228
|
+
* the new view lands. There is no cart-wide busy flag by design: a page-wide
|
|
229
|
+
* spinner for a 250ms quantity step is worse than the stale number, and the
|
|
230
|
+
* right busy scope is the row (`useCartLine`'s `pending`) or the control that
|
|
231
|
+
* started it.
|
|
232
|
+
* - `"empty"` therefore means *loaded, with no items* — including after
|
|
233
|
+
* checkout consumes the cart — never "still arriving".
|
|
234
|
+
* - It flips `"empty"` → `"ready"` when the first line lands, so a header badge
|
|
235
|
+
* and a drawer switch states off the same signal.
|
|
236
|
+
*
|
|
237
|
+
* So branch **`status`** for the page's loading/empty/ready shape, and watch
|
|
238
|
+
* **`useCartLine().pending`** (or your own flag around `addItem`) for "did that
|
|
239
|
+
* change land". Anything waiting on a mutation — a queued follow-up action, a
|
|
240
|
+
* script driving the page — waits on the second, never the first.
|
|
207
241
|
*/
|
|
208
242
|
export function useCart() {
|
|
209
243
|
const { client, cart, cartError, mutationError, runCart } = useStorefrontState();
|
|
@@ -254,6 +288,9 @@ export function useCart() {
|
|
|
254
288
|
...item,
|
|
255
289
|
attributesLabel: attributesLabel(item.attributes),
|
|
256
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) : "",
|
|
257
294
|
maxQuantity,
|
|
258
295
|
canIncrease: item.quantity < maxQuantity,
|
|
259
296
|
canDecrease: item.quantity > 1,
|
|
@@ -261,7 +298,7 @@ export function useCart() {
|
|
|
261
298
|
unavailableReason: purchasable ? null : (item.purchasable?.error ?? null),
|
|
262
299
|
};
|
|
263
300
|
}),
|
|
264
|
-
[items],
|
|
301
|
+
[items, formatMoney],
|
|
265
302
|
);
|
|
266
303
|
|
|
267
304
|
const notices = useMemo(
|