@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.
@@ -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 (`general`, `products`, `inventory`, `tax`, `shipping`, `emails`), 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 and pick the response up when you need the slugs.
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 | Products created |
18
- |---|---|---|
19
- | **Real catalog** | `{ store_name, products: [...] }` (+ optional `coupons`, `locations`) | yours — categories, ribbons, attributes, variants, all in this one call |
20
- | **Demo data** | `{ store_name, with_sample_data: true }` | 10 generic demo products (skipped if any product exists) |
21
- | **No products** | `{ store_name }` | none — defaults only |
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** (**400** `store_name_required`) — the app's name as the platform shows it (`base44/config.jsonc` → `name` can be stale; ask if unsure). It becomes the email sender name and the public shop name. **`currency`** is an ISO code (`"EUR"`); prices are *formatted* per the viewer's locale, so there is nothing else to set. Explicit values always win, first seed and re-runs alike.
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
- Full field contractevery payload key, every response field, all error codes: [`../docs/api-admin.md`](../docs/api-admin.md#commerceseed-store). Here is the working call.
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
- // ── 1. Minimal. `name` is the only required key; a real store wants a
36
- // price too, and everything else below is opt-in.
37
- { name: "Linen Scarf", regular_price: 45 },
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
- regular_price: 89, // inherited by variations that don't override
49
- sale_price: 79, // sets on_sale; the storefront strikes through regular_price
50
- date_on_sale_from: "2026-03-01T00:00:00Z", // optional sale window (omit → sale is open-ended)
51
- date_on_sale_to: "2026-03-31T23:59:59Z",
52
-
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
- manage_stock: true, // only needed to force tracking with no quantity
55
- low_stock_amount: 3, // overrides the store's threshold
56
- backorders: "no", // no | notify | yes
57
- sold_individually: false, // true → max 1 per order (kills the qty stepper)
58
-
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>", // HTML, rendered as rich text
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 table (`productSpecs(product)`).
67
- // NOT variant axes and NOT ribbons: they describe the product, they
68
- // don't select anything. Values are strings; a leading `_` hides a row.
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: "Care", value: "Machine wash cold" },
52
+ { key: "Weight", value: "248 g" }, // "<number> <unit>" → a numeric row
72
53
  ],
73
54
 
74
- attributes: [ // the axes → one selector each in the storefront
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 4 combos auto-generated
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 — the next section; passing any makes them the store's ONLY ones
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.** `invoke` resolves to the raw HTTP response, which carries circular request/response objects `return res` (or stringifying a thrown error whole) fails with `Converting circular structure to JSON` *even when the seed succeeded*, and a thrown error needs `e.response?.data` for the same reason.
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** (categories, ribbons, attributes, options) — existing records are matched case-insensitively and reused. The seeder derives slugs, checks SKU uniqueness, prices variations, and **rolls the parent's `price`/`regular_price`/`on_sale` up from the cheapest publishable variant** — never set a variant parent's price yourself. Unknown keys are rejected, so typos surface instead of vanishing.
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": { "products_created": 2, "variations_created": 2,
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 the shipping section
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 time the merchant edits a product).
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
- **Seed-time `locations` is THE way to configure shipping.** Each location is a scope plus its rates and taxes, locations match in `order` ascending, and `order` defaults to the payload position — so the array reads as the priority. "€20 in Europe, €100 everywhere else" is two locations:
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-code country list. The seven codes are `AF` `AN` `AS` `EU` `NA` `OC` `SA`, and `EU` is the *continent* Europe, not the European Union. An unknown code fails `400 invalid_payload` with the known list.
143
- - **`rest_of_world: true`** is the catch-all — the location that matches every address no other location claims. It cannot also carry `countries`/`continents`/`regions`, and there is **no country code that means "everywhere"**: `countries: ["*"]`/`["ALL"]`/`["ROW"]`, and alpha-3 codes like `["USA"]`, are rejected **400** (every scope code is validated against the matcher's own country/continent/state data).
144
- - Other scopes: `countries: ["IL", "DE"]`, or explicit `regions: [{ type: "state", code: "US:CA" }]`. Matching is **country + state only** no postcode or city rules exist.
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
- **Tiered rates are worth one assertion**, because a wrong zone looks exactly like a right one until a customer in the wrong country pays. The cart prices from an address, so ask it directly no UI needed:
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
- ```js
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
- Every product needs at least one, and **the URL you seed is the URL the store serves** — there are no placeholders to swap later. Resolve each to its **final, permanent URL before seeding**: the app's image generation, `base44.integrations.Core.UploadFile({ file })` → public URL, or stable public stock URLs (`seed-store/sample-data.ts` shows a working pattern). Match the image to the product.
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
- **The rule, in full.** Online card payments are **off by default**: `commerce/seed-store` enables the manual `offline` method and leaves the `card` gateway disabled. Offline-only is a complete, payable store — the order goes on-hold with the gateway's description rendered as payment instructions on `/order-received`, which needs no code and no credentials. **Enable `card` only if a payment provider is wired, or will be in the same stretch of work:** enabled means offered, and an enabled card option with nothing behind it answers **`503 no_card_payment_provider`** the moment a customer picks it. Enabling and wiring are two halves of one step; neither half is useful alone.
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
- **`payment_methods` is the on/off switch.** Pass gateway slugs and the listed gateways are enabled while **every other row is disabled** — `["offline"]`, `["card"]` (card-only) or both, with no `commerce.PaymentGateway` reads or writes of your own. Omit it and the store stays offline-only. It is idempotent and needs no catalog, so `{ payment_methods: ["offline", "card"] }` alone is the later on/off switch. Unknown slugs fail `400 invalid_payload` with the known list.
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
- | What the request says | Decision |
120
+ | The request says | Decision |
178
121
  |---|---|
179
- | A provider is named ("use Stripe") | copy the provider file, enable `card` — whenever convenient; it blocks nothing |
180
- | Selling online implied, no provider named | cards are a reasonable read of *what* to offer, never of *when*: raise the provider **after** the store works |
181
- | Paid another way (transfer, COD, invoice, pickup, quotes) | nothing to do — the default already is exactly this |
182
- | Payments not mentioned at all | leave the default and **mention it at handover** |
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
- *Only for a store that opted into cards:* **if it is Stripe, the code is already written** — `base44/shared/commerce/card-payment.stripe.ts` is a complete implementation, used **as-is**. Copy it over `base44/shared/commerce/card-payment.ts` (`fs.copyFileSync`, whole file — never a partial edit, which leaves duplicate exports and breaks every commerce function's deploy) and enable the gateway with `payment_methods: ["offline", "card"]`. Nothing in it needs filling in and no key belongs in the code; it reads the credential the app's Stripe connection publishes. Any other provider means implementing four functions in that one file — [`../references/online-payments.md`](../references/online-payments.md).
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
- - [ ] `commerce/seed-store` returned success and reported the catalog — real products, final permanent image URLs.
193
- - [ ] `warnings` in the response is empty, or every warning is deliberate and stated to the user.
194
- - [ ] Shipping is expressed in `locations` (with a catch-all if the store ships worldwide), not patched into entities afterwards.
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 are either off, or on with the provider file copied whole and the `card` gateway enabled.
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-table rows) |
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 used to be prose here and are now enforced by exports — use them and they can't drift between views:
53
+ Three rules are enforced by exports — use them and they can't drift between views:
54
54
 
55
- - **From-price.** `admin-products` (and the seeder) roll a parent's `regular_price`/`price`/`on_sale` up from the cheapest publishable variant on every save, so the parent price is real, sortable and filterable but it is the **lowest** price, not *the* price. `productPrice(rowOrView, {formatMoney})` / `useProductPrice(rowOrView)` accept **either** a listing row or a `resolveSelection` view and return `{label, compareAtLabel, onSale, isFrom, isRange, min, max}`: "From €19.99" on a card, a range on an unresolved page, the exact price once resolved.
56
- - **Images are objects, and may be absent.** Every stored image is `{src, name, alt}`, never a URL string, and `images` can legitimately be empty. `productImages(product)` / `normalizeImage(entry)` return clean entries (non-empty `src`, defaulted `alt`), and an empty array is the *render your placeholder* signal — `useProductGallery` builds on them (`hasImages`). Passing the object itself to an `<img src>` fails the load and shows the placeholder for every product in the store.
57
- - **Modifiers are not attributes.** `meta_data` (Material, Care, GTIN) is descriptive — never a selector, never a ribbon. How each modifier *renders* is a design decision (§3); what it can never become is a control.
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**. Read them for availability, then design the surface; a store that renders exactly these fields in exactly this sequence is the generic storefront every generated catalog produces.
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`) that says more about *this* catalog than a star average does. Link the whole card to the product page; the card, the grid's rhythm and whether every card is even the same size are yours.
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.** `variantAxes` exposes `axis.key`/`axis.name` and `productSpecs` exposes `key`/`label` precisely so a page can branch on *which* one it is: colour as swatches, size as chips next to a size guide, "Composition" as bars, "Provenance" as a located line, "Certification" as a seal. One uniform chip row for every axis and one grey label/value table for every modifier is a default, not a requirement pick the two or three that carry the product's meaning, give them real treatment, and let the remainder fall back to a plain row. The rules in §5 govern selector *behaviour* (one control per axis, unbuyable disabled), never its form, and they hold whatever the control looks like.
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
- ## How the premade flow works
44
+ ## The premade flow
45
45
 
46
- 1. **Checkout** (`place-order` with the `card` gateway): the order is created `pending`, `createCardPayment` runs, the returned `reference` is stored on the order (`_payment_reference` meta), and the customer is redirected to `url`. The return URLs already carry `order_id`, `order_key` and `payment=success|cancel`.
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` in depth
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
- Everything after `parseWebhook` — order lookup, the `order_key` match, idempotent confirmation, order progression — is premade either way.
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) it wraps <Routes>, it is NOT a <Route>:
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
- * Inside <Routes> it throws ("is not a <Route> component"), since React Router
28
- * allows only <Route>/<Fragment> children there. To cover just some routes,
29
- * use a pathless layout route: element={<StorefrontProvider …><Outlet/></…>}.
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 around your <Routes> (it wraps the router; it is not a <Route>, and inside <Routes> React Router rejects it).",
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, `purchasable` as a boolean with
199
- * `unavailableReason` beside it, and the quantity bounds.
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(