@base44/app-plugin-commerce 0.1.3 → 0.1.5

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.
Files changed (86) hide show
  1. package/README.md +11 -11
  2. package/base44/agents/commerce/StoreAdmin.jsonc +7 -7
  3. package/base44/entities/commerce.Order.jsonc +1 -1
  4. package/base44/entities/commerce.PaymentGateway.jsonc +3 -3
  5. package/base44/entities/commerce.Product.jsonc +9 -45
  6. package/base44/entities/commerce.ProductAttribute.jsonc +7 -19
  7. package/base44/entities/commerce.ProductAttributeTerm.jsonc +4 -10
  8. package/base44/entities/commerce.ProductTag.jsonc +1 -8
  9. package/base44/entities/commerce.ProductVariation.jsonc +1 -1
  10. package/base44/entities/commerce.StoreSettings.jsonc +1 -1
  11. package/base44/functions/commerce/admin-products/entry.ts +73 -121
  12. package/base44/functions/commerce/admin-refunds/entry.ts +1 -1
  13. package/base44/functions/commerce/admin-reports/entry.ts +1 -1
  14. package/base44/functions/commerce/admin-tools/entry.ts +4 -3
  15. package/base44/functions/commerce/payments/entry.ts +0 -2
  16. package/base44/functions/commerce/seed-store/defaults.ts +13 -39
  17. package/base44/functions/commerce/seed-store/entry.ts +50 -166
  18. package/base44/functions/commerce/seed-store/sample-data.ts +28 -66
  19. package/base44/functions/commerce/seed-store/seed-catalog.ts +717 -0
  20. package/base44/functions/commerce/storefront-cart/entry.ts +0 -7
  21. package/base44/functions/commerce/storefront-catalog/entry.ts +12 -21
  22. package/base44/functions/commerce/storefront-checkout/entry.ts +5 -12
  23. package/base44/shared/commerce/catalog.ts +134 -0
  24. package/base44/shared/commerce/coupons.ts +4 -7
  25. package/base44/shared/commerce/email-templates.ts +15 -13
  26. package/base44/shared/commerce/emails.ts +4 -3
  27. package/base44/shared/commerce/payments.ts +8 -20
  28. package/base44/shared/commerce/products.ts +24 -0
  29. package/base44/shared/commerce/settings.ts +2 -3
  30. package/base44/shared/commerce/stock.ts +2 -4
  31. package/base44/shared/commerce/totals.ts +37 -42
  32. package/package.json +1 -1
  33. package/scripts/install.js +23 -7
  34. package/skills/commerce/SKILL.md +9 -7
  35. package/skills/commerce/docs/api-admin.md +20 -14
  36. package/skills/commerce/docs/api-storefront.md +39 -35
  37. package/skills/commerce/installation-guidelines.md +8 -7
  38. package/skills/commerce/post-installation.md +205 -84
  39. package/skills/commerce/references/admin-product-form.md +56 -0
  40. package/skills/commerce/references/emails.md +4 -3
  41. package/skills/commerce/references/guest-access-security.md +2 -2
  42. package/skills/commerce/references/limits-and-performance.md +1 -1
  43. package/skills/commerce/references/online-payments.md +8 -8
  44. package/skills/commerce/references/product-render.md +25 -23
  45. package/skills/commerce/references/reviews.md +20 -0
  46. package/skills/commerce/references/storefront-product-page.md +9 -9
  47. package/skills/commerce/references/webhooks.md +3 -1
  48. package/src/commerce/admin/README.md +4 -4
  49. package/src/commerce/admin/context/BasePathContext.jsx +5 -5
  50. package/src/commerce/admin/context/SettingsContext.jsx +4 -4
  51. package/src/commerce/admin/index.jsx +3 -3
  52. package/src/commerce/admin/layout/Sidebar.jsx +1 -9
  53. package/src/commerce/admin/lib/constants.js +0 -7
  54. package/src/commerce/admin/lib/paths.js +6 -6
  55. package/src/commerce/admin/lib/product-utils.js +18 -15
  56. package/src/commerce/admin/pages/orders/components/AddProductDialog.jsx +8 -8
  57. package/src/commerce/admin/pages/products/ProductEditor.jsx +10 -12
  58. package/src/commerce/admin/pages/products/ProductsList.jsx +3 -15
  59. package/src/commerce/admin/pages/products/components/AttributesSection.jsx +426 -0
  60. package/src/commerce/admin/pages/products/components/ProductDataPanel.jsx +33 -69
  61. package/src/commerce/admin/pages/products/components/TaxonomyPanel.jsx +1 -1
  62. package/src/commerce/admin/pages/products/components/tabs/LinkedTab.jsx +1 -14
  63. package/src/commerce/admin/pages/products/components/tabs/ModifiersTab.jsx +19 -0
  64. package/src/commerce/admin/pages/products/components/tabs/PriceInventoryTab.jsx +728 -0
  65. package/src/commerce/admin/pages/reports/Reports.jsx +3 -3
  66. package/src/commerce/admin/pages/settings/EmailsSettings.jsx +12 -14
  67. package/src/commerce/admin/pages/settings/GeneralSettings.jsx +6 -116
  68. package/src/commerce/admin/pages/settings/PaymentsSettings.jsx +101 -62
  69. package/src/commerce/admin/pages/settings/SettingsLayout.jsx +2 -6
  70. package/src/commerce/admin/pages/settings/TaxSettings.jsx +0 -1
  71. package/src/commerce/admin/pages/status/WebhookEditor.jsx +3 -3
  72. package/src/commerce/admin/pages/status/Webhooks.jsx +2 -2
  73. package/src/commerce/admin/routes.jsx +10 -19
  74. package/src/commerce/utils/index.js +2 -2
  75. package/src/commerce/utils/shipping-promos.js +9 -6
  76. package/src/commerce/utils/variants.js +21 -21
  77. package/src/commerce/admin/pages/products/AttributeTerms.jsx +0 -180
  78. package/src/commerce/admin/pages/products/Attributes.jsx +0 -183
  79. package/src/commerce/admin/pages/products/Tags.jsx +0 -150
  80. package/src/commerce/admin/pages/products/components/tabs/AdvancedTab.jsx +0 -48
  81. package/src/commerce/admin/pages/products/components/tabs/AttributesTab.jsx +0 -208
  82. package/src/commerce/admin/pages/products/components/tabs/ExternalTab.jsx +0 -41
  83. package/src/commerce/admin/pages/products/components/tabs/GeneralTab.jsx +0 -103
  84. package/src/commerce/admin/pages/products/components/tabs/InventoryTab.jsx +0 -93
  85. package/src/commerce/admin/pages/products/components/tabs/ShippingTab.jsx +0 -86
  86. package/src/commerce/admin/pages/products/components/tabs/VariationsTab.jsx +0 -377
@@ -73,15 +73,13 @@ function effectiveTax(product: any, variation?: any): { tax_class: string; tax_s
73
73
  function resolveTaxAddress(input: TotalsInput): any {
74
74
  const basedOn = getSetting(input.settings, "tax", "tax_based_on", "shipping");
75
75
  if (basedOn === "billing") return input.billing ?? input.shipping_address ?? {};
76
- if (basedOn === "base") return getSetting(input.settings, "general", "address", {});
77
76
  return input.shipping_address ?? input.billing ?? {}; // "shipping" default
78
77
  }
79
78
 
80
79
  /** Run the full totals pipeline. Pure aside from reading its inputs. */
81
80
  export function calculateTotals(input: TotalsInput): TotalsResult {
82
81
  const settings = input.settings ?? {};
83
- const taxesEnabled = !!getSetting(settings, "general", "enable_taxes", true);
84
- const pricesIncludeTax = taxesEnabled && !!getSetting(settings, "tax", "prices_include_tax", false);
82
+ const pricesIncludeTax = !!getSetting(settings, "tax", "prices_include_tax", false);
85
83
  const taxRates = input.taxRates ?? [];
86
84
  const taxAddress = resolveTaxAddress(input);
87
85
 
@@ -89,7 +87,7 @@ export function calculateTotals(input: TotalsInput): TotalsResult {
89
87
  const lines = (input.items || []).map((it) => {
90
88
  const src = it.variation ?? it.product;
91
89
  const { tax_class, tax_status } = effectiveTax(it.product, it.variation);
92
- const rates = taxesEnabled && tax_status === "taxable" ? matchTaxRates(taxRates, taxAddress, tax_class) : [];
90
+ const rates = tax_status === "taxable" ? matchTaxRates(taxRates, taxAddress, tax_class) : [];
93
91
  let unitPrice = Number(src.price ?? it.product?.price ?? 0);
94
92
  let subtotal = round2(unitPrice * it.quantity);
95
93
  if (pricesIncludeTax && rates.length) {
@@ -126,9 +124,8 @@ export function calculateTotals(input: TotalsInput): TotalsResult {
126
124
  const itemsSubtotal = round2(lines.reduce((a, l) => a + l.subtotal, 0));
127
125
 
128
126
  // ── 2. coupons ───────────────────────────────────────────────────────────
129
- const couponsEnabled = !!getSetting(settings, "general", "enable_coupons", true);
130
- const coupons = couponsEnabled ? input.coupons ?? [] : [];
131
- const couponLines = applyCoupons(lines, coupons, settings);
127
+ const coupons = input.coupons ?? [];
128
+ const couponLines = applyCoupons(lines, coupons);
132
129
  for (const l of lines) l.total = round2(l.subtotal - l.discount);
133
130
  const discountTotal = round2(lines.reduce((a, l) => a + l.discount, 0));
134
131
  const itemsAfterDiscount = round2(lines.reduce((a, l) => a + l.total, 0));
@@ -191,41 +188,39 @@ export function calculateTotals(input: TotalsInput): TotalsResult {
191
188
  agg.set(key, cur);
192
189
  };
193
190
 
194
- if (taxesEnabled) {
195
- for (const l of lines) {
196
- if (!l.rates.length) continue;
197
- const subApplied = applyRates(l.subtotal, l.rates);
198
- const totApplied = applyRates(l.total, l.rates);
199
- l.subtotal_tax = sumTax(subApplied);
200
- l.total_tax = sumTax(totApplied);
201
- l.taxes = totApplied.map((t, i) => ({
202
- rate_id: t.rate.id ?? "",
203
- total: t.amount,
204
- subtotal: subApplied[i]?.amount ?? 0,
205
- }));
206
- for (const t of totApplied) bump(t.rate, t.amount, 0);
207
- }
208
- for (const fee of feeLines) {
209
- if (fee.tax_status !== "taxable") continue;
210
- const rates = matchTaxRates(taxRates, taxAddress, fee.tax_class);
211
- const applied = applyRates(fee.total, rates);
212
- fee.total_tax = sumTax(applied);
213
- fee.taxes = applied.map((t) => ({ rate_id: t.rate.id ?? "", total: t.amount }));
214
- for (const t of applied) bump(t.rate, t.amount, 0);
215
- }
216
- if (shippingLines.length) {
217
- // shipping_tax_class "inherit" = first taxable line's class (intended behavior)
218
- const cfg = getSetting(settings, "tax", "shipping_tax_class", "inherit");
219
- const cls = cfg === "inherit"
220
- ? (lines.find((l) => l.tax_status === "taxable")?.tax_class ?? "standard")
221
- : cfg;
222
- const rates = matchTaxRates(taxRates, taxAddress, cls).filter((r) => r.shipping !== false);
223
- for (const sl of shippingLines) {
224
- const applied = applyRates(sl.total, rates);
225
- sl.total_tax = sumTax(applied);
226
- sl.taxes = applied.map((t) => ({ rate_id: t.rate.id ?? "", total: t.amount }));
227
- for (const t of applied) bump(t.rate, 0, t.amount);
228
- }
191
+ for (const l of lines) {
192
+ if (!l.rates.length) continue;
193
+ const subApplied = applyRates(l.subtotal, l.rates);
194
+ const totApplied = applyRates(l.total, l.rates);
195
+ l.subtotal_tax = sumTax(subApplied);
196
+ l.total_tax = sumTax(totApplied);
197
+ l.taxes = totApplied.map((t, i) => ({
198
+ rate_id: t.rate.id ?? "",
199
+ total: t.amount,
200
+ subtotal: subApplied[i]?.amount ?? 0,
201
+ }));
202
+ for (const t of totApplied) bump(t.rate, t.amount, 0);
203
+ }
204
+ for (const fee of feeLines) {
205
+ if (fee.tax_status !== "taxable") continue;
206
+ const rates = matchTaxRates(taxRates, taxAddress, fee.tax_class);
207
+ const applied = applyRates(fee.total, rates);
208
+ fee.total_tax = sumTax(applied);
209
+ fee.taxes = applied.map((t) => ({ rate_id: t.rate.id ?? "", total: t.amount }));
210
+ for (const t of applied) bump(t.rate, t.amount, 0);
211
+ }
212
+ if (shippingLines.length) {
213
+ // shipping_tax_class "inherit" = first taxable line's class (intended behavior)
214
+ const cfg = getSetting(settings, "tax", "shipping_tax_class", "inherit");
215
+ const cls = cfg === "inherit"
216
+ ? (lines.find((l) => l.tax_status === "taxable")?.tax_class ?? "standard")
217
+ : cfg;
218
+ const rates = matchTaxRates(taxRates, taxAddress, cls).filter((r) => r.shipping !== false);
219
+ for (const sl of shippingLines) {
220
+ const applied = applyRates(sl.total, rates);
221
+ sl.total_tax = sumTax(applied);
222
+ sl.taxes = applied.map((t) => ({ rate_id: t.rate.id ?? "", total: t.amount }));
223
+ for (const t of applied) bump(t.rate, 0, t.amount);
229
224
  }
230
225
  }
231
226
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@base44/app-plugin-commerce",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "description": "Base44 Commerce plugin — entities, backend functions, shared commerce engine, admin UI and the commerce skill, shipped as copyable source",
5
5
  "keywords": [
6
6
  "base44",
@@ -26,8 +26,8 @@
26
26
  *
27
27
  * This is only the static part of the install. The remaining steps live in
28
28
  * ../skills/commerce/installation-guidelines.md (deps, deploy, seeding) and
29
- * ../skills/commerce/post-installation.md (mounting the /admin route, admin
30
- * role, AGENTS.md registration); day-2 guidance starts at
29
+ * ../skills/commerce/post-installation.md (mounting the /store-admin route, admin
30
+ * role, seeding, the storefront quick start); day-2 guidance starts at
31
31
  * ../skills/commerce/SKILL.md. The skill folder carries all of this
32
32
  * documentation into the app.
33
33
  *
@@ -93,6 +93,22 @@
93
93
  // longer ships is listed here and removed on install.
94
94
  const RETIRED = [
95
95
  ["skills", "commerce", "references", "stripe-payments.md"], // → references/online-payments.md
96
+ // Attributes and their values are edited in the Attributes section of the
97
+ // Price & Inventory tab, tags in the product sidebar; the product type and the
98
+ // Advanced tab are gone entirely.
99
+ ["src", "commerce", "admin", "pages", "products", "Attributes.jsx"],
100
+ ["src", "commerce", "admin", "pages", "products", "AttributeTerms.jsx"],
101
+ ["src", "commerce", "admin", "pages", "products", "Tags.jsx"],
102
+ ["src", "commerce", "admin", "pages", "products", "components", "tabs", "ExternalTab.jsx"],
103
+ ["src", "commerce", "admin", "pages", "products", "components", "tabs", "AdvancedTab.jsx"],
104
+ // General + Inventory + Variations merged into PriceInventoryTab.jsx.
105
+ ["src", "commerce", "admin", "pages", "products", "components", "tabs", "GeneralTab.jsx"],
106
+ ["src", "commerce", "admin", "pages", "products", "components", "tabs", "InventoryTab.jsx"],
107
+ ["src", "commerce", "admin", "pages", "products", "components", "tabs", "VariationsTab.jsx"],
108
+ // Attributes moved into Price & Inventory; weight/dimensions/shipping class
109
+ // are per variant, so the product-level Shipping tab has nothing left.
110
+ ["src", "commerce", "admin", "pages", "products", "components", "tabs", "AttributesTab.jsx"],
111
+ ["src", "commerce", "admin", "pages", "products", "components", "tabs", "ShippingTab.jsx"],
96
112
  ];
97
113
  let retiredRemoved = 0;
98
114
  for (const parts of RETIRED) {
@@ -147,11 +163,11 @@
147
163
  " 1. No deps to add: sonner, recharts and react-markdown ship with the default\n" +
148
164
  " Base44 template — check package.json and npm i only what is truly missing;\n" +
149
165
  " do not re-install packages already listed as dependencies\n" +
150
- ' 2. Mount the admin router: <Route path="/admin/*" element={<AdminApp />} />\n' +
166
+ ' 2. Mount the admin router: <Route path="/store-admin/*" element={<AdminApp />} />\n' +
151
167
  " and implement the payment return page /order-received (post-installation.md)\n" +
152
- " 3. Grant your user the admin role, then settle the store's data — generate a catalog,\n" +
153
- " seed the demo data, or initialize defaults from /admin (post-installation.md §2)\n" +
154
- " 4. Register the template + skill in AGENTS.md (see skills/commerce/post-installation.md)\n" +
155
- " 5. CLI installs only: npx base44 agents push (the hosted runtime syncs agents on write)"
168
+ " 3. Grant your user the admin role, then seed the store's data — one commerce/seed-store\n" +
169
+ " call (store_name required) takes the whole catalog via products, or the demo data\n" +
170
+ " via with_sample_data, or defaults only (post-installation.md §2)\n" +
171
+ " 4. CLI installs only: npx base44 agents push (the hosted runtime syncs agents on write)"
156
172
  );
157
173
  })();
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: commerce
3
- description: Base44 Commerce template — 24 commerce.* entities, 16 commerce/* backend functions (storefront + admin APIs + online payments), the shared commerce engine under base44/shared/commerce/, the store admin UI mounted at /admin, and the commerce/StoreAdmin agent (admin copilot). Read before working on store features — the admin UI, storefront building, Stripe wiring, scheduled maintenance, emails, webhooks, downloads, or scaling limits.
3
+ description: Base44 Commerce template — 24 commerce.* entities, 16 commerce/* backend functions (storefront + admin APIs + online payments), the shared commerce engine under base44/shared/commerce/, the Store Management UI mounted at /store-admin, and the commerce/StoreAdmin agent (admin copilot). Read before working on store features — the admin UI, storefront building, Stripe wiring, scheduled maintenance, emails, webhooks, downloads, or scaling limits.
4
4
  ---
5
5
 
6
6
  # Commerce
@@ -13,30 +13,30 @@ Operational guidance for extending, operating and building on the Base44 Commerc
13
13
 
14
14
  ## IMPORTANT — first-time installation
15
15
 
16
- If the template was just installed (or you are installing it right now), read [`skills/commerce/post-installation.md`](./post-installation.md) **before anything else**: embedding the admin pages, the three-layer admin-role enforcement (do not weaken), **asking the user how the store's data should be created** (generate a real catalog / generic demo data / nothing — §2), and registering the template + skill in the app's `AGENTS.md`. The full install-from-scratch steps are in [`skills/commerce/installation-guidelines.md`](./installation-guidelines.md).
16
+ If the template was just installed (or you are installing it right now), read [`skills/commerce/post-installation.md`](./post-installation.md) **before anything else**: embedding the admin pages, the three-layer admin-role enforcement (do not weaken), seeding the store's data — **one `commerce/seed-store` call takes the whole catalog** (products with attributes; variants, categories and taxonomy created internally — §2) — and the **storefront quick start** (§3): logic-only chunks for product list → product page → cart → checkout that make reading the full API docs unnecessary for the happy path. The full install-from-scratch steps are in [`skills/commerce/installation-guidelines.md`](./installation-guidelines.md).
17
17
 
18
18
  ## Working on the UI
19
19
 
20
20
  - **Admin UI** (`src/commerce/admin/`) — a complete store back office ships with the template, and it is **yours to change**: restyle it, add or remove pages, rework flows, extend it however the app needs. To understand the backend it talks to, read [`skills/commerce/docs/api-admin.md`](./docs/api-admin.md) — every admin function/action plus the direct-entity-CRUD contract. The only invariant is the admin-role gating (see above).
21
- - **Storefront** — **no visitor UI ships**; the storefront *API* is complete (token-based cart), plus framework-free helpers in [`src/commerce/utils/`](../../src/commerce/utils/) (`variants.js`, `shipping-promos.js` — import from `@/commerce/utils`). Build against [`skills/commerce/docs/api-storefront.md`](./docs/api-storefront.md). Navigation comes from three actions — `list-categories` (tree), `list-tags` (flat, with counts) and `list-attributes` (filter UIs). What to render in the grid vs. the product page — and which fields only one of the two calls returns — is [`references/product-render.md`](./references/product-render.md); **tags are the most-skipped part of it and belong in both views**.
21
+ - **Storefront** — **no visitor UI ships**; the storefront *API* is complete (token-based cart), plus framework-free helpers in [`src/commerce/utils/`](../../src/commerce/utils/) (`variants.js`, `shipping-promos.js` — import from `@/commerce/utils`). Start from the logic-only quick start in [`post-installation.md` §3](./post-installation.md#3-storefront-quick-start--logic-only) — it covers the whole buy path; go to [`skills/commerce/docs/api-storefront.md`](./docs/api-storefront.md) for anything beyond it. Navigation comes from three actions — `list-categories` (tree), `list-tags` (flat, with counts) and `list-attributes` (filter UIs). What to render in the grid vs. the product page — and which fields only one of the two calls returns — is [`references/product-render.md`](./references/product-render.md); **tags are the most-skipped part of it and belong in both views**.
22
22
 
23
23
  ### If you build a storefront, these four are not optional
24
24
 
25
25
  Agents keep shipping storefronts that miss these, and each one breaks buying outright. Do them; the details are in [`docs/api-storefront.md`](./docs/api-storefront.md) and [`references/storefront-product-page.md`](./references/storefront-product-page.md).
26
26
 
27
- 1. **Variant products need one selector per attribute — and the selection must resolve to a variation.** `get-product` gives `product.attributes` (the axes: entries with `variation: true`) and `variations` (the combinations). Render a control per axis, never a list of combinations, then send the resolved `variation_id`:
27
+ 1. **Products with variants need one selector per attribute — and the selection must resolve to a variation.** `get-product` gives `product.attributes` (every entry is an axis — there is no product type) and `variations` (the combinations). Render a control per axis, never a list of combinations, then send the resolved `variation_id`:
28
28
  ```js
29
29
  import { resolveSelection, defaultSelection, selectOption } from "@/commerce/utils";
30
30
  const view = resolveSelection(product, variations, selection); // axes, availability, price, addToCart
31
31
  await inv("commerce/storefront-cart", { action: "add-item", cart_token, ...view.addToCart, quantity });
32
32
  ```
33
- `add-item` **rejects a variable product without `variation_id`** (`400 variation_required`), so a page that ignores this cannot sell anything.
33
+ `add-item` **rejects a product with attributes unless it gets a `variation_id`** (`400 variation_required`), so a page that ignores this cannot sell anything.
34
34
 
35
35
  2. **Checkout must present shipping options and send a choice.** After `set-shipping-address`, read `shipping_status` on the cart: `auto_selected` (one option, already applied) · `chosen` · `choice_required` → **you must show `available_shipping_methods` and call `choose-shipping-method`** · `none_available` → say so. `place-order` refuses with `400 shipping_method_required` until then — that is not a bug to work around.
36
36
 
37
37
  3. **Take card payments, and build `/order-received`.** (Ask the user to connect the provider *after* the store is set up — it's their step in the dashboard, and a catalog plus a shipping method is what makes a test payment provable.) Choosing the online gateway returns `payment.checkout_url` — redirect there. Every payment link comes back to `/order-received`, which **you must implement**: call `commerce/payments` `complete-return` with the query params and render its `state`. Without that page a customer pays into a 404 and the order is never marked paid.
38
38
 
39
- 4. **Never advertise what isn't configured.** "Free shipping over €150" must come from a real `free_shipping` zone method — `shipping-promos.js` reads it; no rule means no banner.
39
+ 4. **Never advertise what isn't configured.** "Free shipping over €150" must come from a real `free_shipping` zone method. Zones are admin-only data, so a storefront cannot read them: the live answer is the cart's `available_shipping_methods` after `set-shipping-address`, and `shipping-promos.js` normalizes the rules wherever the records *are* in hand. No rule means no banner.
40
40
 
41
41
  All functions return the envelope `{ success, data }` (or `{ success, error, code }`); with the SDK the body is on `res.data`:
42
42
 
@@ -51,8 +51,10 @@ Open the matching file under `skills/commerce/references/` only when a task touc
51
51
 
52
52
  | Topic | Read when the task involves | Reference |
53
53
  |---|---|---|
54
- | Product rendering (list + page) | what to show in a product grid vs. the product page, field availability across `list-products`/`get-product`, **tags in both views**, variable pricing on cards, adding a page-only field to the listing call | [`references/product-render.md`](./references/product-render.md) |
54
+ | Product rendering (list + page) | what to show in a product grid vs. the product page, field availability across `list-products`/`get-product`, **tags in both views**, variant pricing on cards, adding a page-only field to the listing call | [`references/product-render.md`](./references/product-render.md) |
55
55
  | Variant selection | attribute-level selectors, resolving a selection to a variation, availability states, incomplete-selection pricing, add-to-cart contract | [`references/storefront-product-page.md`](./references/storefront-product-page.md) |
56
+ | Reviews | stars on cards and the product page, the review list + submit form (backend ships complete), moderation statuses, verified-only and rating-required behaviors | [`references/reviews.md`](./references/reviews.md) |
57
+ | Admin product form | changing the product editor — its tabs are **Price & Inventory** (tax, then the attributes, then a row per variant, or a single *Base price* row when there are none), **Modifiers** (`meta_data`), **Downloads**, **Linked products**. Variants reconcile from the attribute values automatically: no generate step, no per-variant delete. Weight, dimensions and shipping class are per variant. | [`references/admin-product-form.md`](./references/admin-product-form.md) |
56
58
  | Online payments | **any storefront or checkout work** — card payments ship implemented (hosted page, payment links, signed webhook, refunds) behind a provider-neutral utility wired to Stripe; connect a provider to go live, or implement one adapter to use another | [`references/online-payments.md`](./references/online-payments.md) |
57
59
  | Scheduled work | recurring maintenance — stock-hold release, abandoned-cart cleanup, webhook-log pruning, counter-drift repair | [`references/scheduled-work.md`](./references/scheduled-work.md) |
58
60
  | Emails | transactional order emails, per-type overrides, deliverability, the email log | [`references/emails.md`](./references/emails.md) |
@@ -14,15 +14,15 @@ Two access styles. **Reads are direct** entity SDK calls; **mutations with side
14
14
  | commerce.Coupon | direct | **`commerce/admin-coupons`** | code normalization/uniqueness, webhooks |
15
15
  | commerce.Customer | direct | **`commerce/admin-customers`** | email uniqueness, invite/link, stats |
16
16
  | commerce.ProductReview | direct | **`commerce/admin-reviews`** | rating recalculation |
17
- | commerce.ProductCategory, commerce.ProductTag | direct | **direct CRUD**, or `commerce/admin-products` `save-term`/`delete-term`/`list-terms` (the API/agent path) | slug uniqueness per taxonomy |
18
- | commerce.ProductAttribute, commerce.ProductAttributeTerm | direct | **direct CRUD**, or `commerce/admin-products` `save-term`/`delete-term`/`list-terms` (the API/agent path) | slug uniqueness; attribute delete cascades its terms |
17
+ | commerce.ProductCategory, commerce.ProductTag | direct | **direct CRUD**, or `commerce/admin-products` `save-term`/`delete-term`/`list-terms` (the API/agent path) | category slug uniqueness; tag get-or-create by name |
18
+ | commerce.ProductAttribute, commerce.ProductAttributeTerm | direct | **direct CRUD**, or `commerce/admin-products` `save-term`/`delete-term`/`list-terms` (the API/agent path) | attribute `code` uniqueness; value rename rewrites products; attribute delete cascades its values |
19
19
  | commerce.ShippingClass | direct | **direct CRUD** | plain config; RLS enforces admin-only |
20
20
  | commerce.TaxClass, commerce.TaxRate, commerce.ShippingZone, commerce.ShippingZoneMethod, commerce.PaymentGateway | direct | **direct CRUD** | config; consumed by the pricing engine at read time |
21
21
  | commerce.StoreSettings | direct | **direct CRUD** (one record per `group_id`) | grouped config |
22
22
  | commerce.Webhook | direct | **direct CRUD** (+ `commerce/admin-webhooks` for test/redeliver) | definition is data; dispatch is engine |
23
23
  | commerce.WebhookDelivery, commerce.EmailLog | direct (read-only logs) | written by the engine | audit logs |
24
24
 
25
- All entities carry admin-only RLS on the operations they restrict (catalog entities are public-read, admin-write; transactional entities are admin-only on all ops). A direct write from a non-admin is rejected by the backend regardless of the UI.
25
+ **Every entity is admin-only on read *and* write** — including the catalog. Storefront access goes exclusively through the `commerce/storefront-*` functions, which run with the service role and project only what is safe; a direct entity read or write from a non-admin is rejected by the backend regardless of the UI. See [`references/guest-access-security.md`](../references/guest-access-security.md).
26
26
 
27
27
  ## Invocation & envelope
28
28
 
@@ -45,24 +45,26 @@ Success: `{ success: true, data }`. Failure: `{ success: false, error, code }` w
45
45
  Actions: `save` · `delete` · `batch` · `duplicate` · `set-stock` · `search` · `save-term` · `delete-term` · `list-terms`
46
46
 
47
47
  - **`save`** — `{ product, variations? }`. Upserts the product (create if no `id`); when `variations` is provided, diffs them (create/update/delete-missing).
48
- > **Creating a variable product takes all four of these**, and the last two must be in *this* call: `type: "variable"`; the attribute listed **on the product** as `attributes: [{ attribute_id, name, position, visible: true, variation: true, options: [...] }]`; a `variations` array with one entry per stocked combination (each `{ attributes: [{ attribute_id, name, option }], sku, regular_price, manage_stock: "yes", stock_quantity, status: "publish" }`); and `default_attributes` for the pre-selected combination. A `commerce.ProductAttribute` record on its own changes nothing — the product stays simple and cannot be added to a cart as a variant (`400 variation_required`). Set the parent's `regular_price` to the lowest variation price so catalog cards and price filters work. Enforces SKU + slug uniqueness across products *and* variations (auto-suffixes slug on collision; `duplicate_sku` on SKU clash). Derives `price`/`on_sale` from the sale window and `stock_status` when stock is managed; updates category/tag `count`; rolls parent stock up for variable products; fires `product.created`/`product.updated`. → `{ product, variations }`.
48
+ > **Selling variants takes both of these in *this* call**: the attribute listed **on the product** as `attributes: [{ attribute_id, name, position, options: [...] }]`, plus a `variations` array with one entry per stocked combination (each `{ attributes: [{ attribute_id, name, option }], sku, regular_price, manage_stock: "yes", stock_quantity, status: "publish" }`) and `default_attributes` for the pre-selected combination. There is **no `type` field**: carrying attributes is what makes a product sell variants, so a `commerce.ProductAttribute` record on its own changes nothing — and a product listing an attribute with no variations cannot be added to a cart at all (`400 variation_required`). Descriptive properties belong in `meta_data`, not `attributes`. **Don't set the parent's price** — `regular_price`, `price` and `on_sale` are derived from the cheapest publishable variant on every save, which is what makes catalog cards, price sorting and price filters agree. Enforces SKU + slug uniqueness across products *and* variations (auto-suffixes slug on collision; `duplicate_sku` on SKU clash). Derives `price`/`on_sale` from the sale window and `stock_status` when stock is managed; updates category/tag `count`; rolls parent stock **and price** up when the product has attributes; fires `product.created`/`product.updated`. → `{ product, variations }`.
49
49
  - **`delete`** — `{ id }`. Cascades variations, decrements counts, fires `product.deleted`.
50
50
  - **`batch`** — `{ create?: [], update?: [], delete?: [] }` (≤100 total) → per-item results.
51
51
  - **`duplicate`** — `{ id }` → new draft copy (name "(Copy)", suffixed SKU, reset sales/ratings) incl. variations.
52
52
  - **`set-stock`** — `{ id, variation_id?, quantity }`. Sets quantity, re-derives status, sends low/out-of-stock admin emails on threshold crossings.
53
- - **`search`** — `{ q?, category_id?, type?, stock_status?, status?, sort?, limit?, skip? }` → `{ rows, has_next }`. `category_id` includes descendant categories.
53
+ - **`search`** — `{ q?, category_id?, stock_status?, status?, sort?, limit?, skip? }` → `{ rows, has_next }`. `category_id` includes descendant categories.
54
54
  - **`save-term`** — `{ taxonomy, term }` → the term. Upserts one taxonomy record. `taxonomy` is `"category"` | `"tag"` | `"attribute"` | `"attribute-term"`, and `term` takes the fields for that one:
55
55
 
56
56
  | taxonomy | entity | `term` fields |
57
57
  |---|---|---|
58
58
  | `category` | commerce.ProductCategory | `id?, name, slug?, description?, parent_id?, image?, menu_order?` |
59
- | `tag` | commerce.ProductTag | `id?, name, slug?, description?` |
60
- | `attribute` | commerce.ProductAttribute | `id?, name, slug?, type?, order_by?, has_archives?` |
61
- | `attribute-term` | commerce.ProductAttributeTerm | `id?, attribute_id` (**required**, must exist)`, name, slug?, description?, menu_order?` |
59
+ | `tag` | commerce.ProductTag | `id?, name` |
60
+ | `attribute` | commerce.ProductAttribute | `id?, name, code?, order?` |
61
+ | `attribute-term` | commerce.ProductAttributeTerm | `id?, attribute_id` (**required**, must exist)`, name, order?` |
62
62
 
63
- Slug is derived from the name and made unique within that entity — for an attribute term, unique **within its attribute**. A category's `parent_id` pointing at itself is coerced to `""` (`categoryWithDescendants` would loop). This is how a non-UI caller (notably the StoreAdmin agent) creates the records that `category_ids`/`tag_ids`/`attributes[].attribute_id` reference — assigning an id is useless if the record can't be created. **A variable product needs its `commerce.ProductAttribute` and terms to exist first**, so create those, then `save` the product with `attributes[]`/`variations[]`.
64
- - **`delete-term`** — `{ taxonomy, id, detach? }` → `{ deleted, detached, terms_deleted }`. Deleting an **attribute** always deletes its terms (a term outliving its attribute is unreachable); `detach: true` additionally strips the attribute from every product's `attributes[]`. For a category or tag, products keep the id by default (the admin UI behaves the same, and the storefront skips ids that no longer resolve); `detach: true` strips it from every product first.
65
- - **`list-terms`** — `{ taxonomy, q?, attribute_id?, limit?, skip? }` → `{ rows, has_next }`. Categories and attribute terms sort by `menu_order`, tags and attributes by `name`; `attribute_id` filters attribute terms to one attribute. Use it to reuse an existing record instead of creating a duplicate.
63
+ Only a category has a slug, derived from its name and made unique; its `parent_id` pointing at itself is coerced to `""` (`categoryWithDescendants` would loop). An attribute's **`code`** is derived from the name and made unique — it is the key a storefront filter URL should carry. Creating a **tag** is get-or-create: a name that already exists case-insensitively returns the existing record instead of splitting the tag in two. Renaming an **attribute value** rewrites `attributes[].options` and `default_attributes` on every product using it, and the matching `option` on their variations — products store a value by name, so the rename would otherwise orphan them.
64
+
65
+ This is how a non-UI caller (notably the StoreAdmin agent) creates the records that `category_ids`/`tag_ids`/`attributes[].attribute_id` reference — assigning an id is useless if the record can't be created. **A product's `commerce.ProductAttribute` and its values must exist first**, so create those, then `save` the product with `attributes[]`/`variations[]`. (At install time, `commerce/seed-store` can do all of this in one call — it takes whole products by display name and get-or-creates the taxonomy internally; see below.)
66
+ - **`delete-term`** — `{ taxonomy, id, detach? }` → `{ deleted, detached, terms_deleted }`. Deleting an **attribute** always deletes its terms (a term outliving its attribute is unreachable); `detach: true` additionally strips the attribute from every product's `attributes[]`. For a category or tag, products keep the id by default (the storefront skips ids that no longer resolve); `detach: true` strips it from every product first. **For an attribute *value* `detach` is a no-op** — deleting a value leaves its name in every product's `attributes[].options` and leaves the variations that use it in place, so remove the value from the products first (or expect variants the storefront can no longer resolve).
67
+ - **`list-terms`** — `{ taxonomy, q?, attribute_id?, limit?, skip? }` → `{ rows, has_next }`. Categories sort by `menu_order`, attributes and attribute values by `order`, tags by `name`; `attribute_id` filters values to one attribute. Use it to reuse an existing record instead of creating a duplicate.
66
68
 
67
69
  ## commerce/admin-orders
68
70
 
@@ -149,7 +151,7 @@ All actions scan orders on demand (counted = `date_paid` set, or status `process
149
151
  | `top-sellers` | `{ date_min?, date_max?, limit? }` | `{ rows: [{ product_id, name, sku, quantity, net_revenue }] }` |
150
152
  | `stock` | — | `{ low_stock: [...], out_of_stock: [...] }` |
151
153
  | `orders-totals` | — | `{ [status]: count }` |
152
- | `products-totals` | — | `{ total, by: { [type]: count } }` |
154
+ | `products-totals` | — | `{ total, by: { [status]: count } }` |
153
155
  | `customers-totals` | — | `{ total, guests, registered, paying }` |
154
156
  | `coupons-totals` | — | `{ total, by: { [discount_type]: count } }` |
155
157
  | `reviews-totals` | — | `{ total, by: { [status]: count } }` |
@@ -160,7 +162,7 @@ All actions scan orders on demand (counted = `date_paid` set, or status `process
160
162
 
161
163
  Actions: `status` · `payment-connector-status` · `admin-email-recipients` · `recount-terms` · `recount-coupon-usage` · `recalculate-customer-stats-all` · `prune-webhook-deliveries` · `clear-abandoned-carts` · `regenerate-download-permissions`
162
164
 
163
- - **`status`** — `{ template_version, seeded, settings_groups, counts: { <Entity>: n | "1000+" }, checks: [...] }` — mini system-status; also the seeded/health check for install verification.
165
+ - **`status`** — `{ template_version, seeded, settings_groups, counts: { "commerce.Product": n | "1000+", ... }, checks: { has_payment_gateways, has_default_zone } }` — `counts` is keyed by the **namespaced** entity name, and `checks` is an object, not an array. — mini system-status; also the seeded/health check for install verification.
164
166
  - **`payment-connector-status`** — no payload → `{ provider, provider_label, gateway_slug, connected, error?, connector }`. Whether an online payment provider is usable **right now**, answered by the payment utility for whichever provider is wired — so UI derives payment readiness instead of hardcoding a "not set up" notice, and shows "No payment provider connected" rather than a brand. Connectors are service-role only, hence the round trip; any failure reports `connected: false`. A provider connected **after** the function's last deploy reads as `connected: false` until the functions are redeployed — env vars are injected at deploy time ([`references/online-payments.md`](../references/online-payments.md) §2). (`connector` repeats `provider` for callers written against the older shape.)
165
167
  - **`admin-email-recipients`** — no payload → `{ recipients: string[], source: "settings" | "admin_users", admin_users: string[] }`. Where admin notifications go **right now**: `recipients` is the configured `emails.admin_recipients`, or the app's admin users when that is empty (the runtime fallback), with `source` saying which. `admin_users` is returned either way, so Settings → Emails can show the fallback as the field's placeholder even while explicit recipients are set. A client can't resolve it itself — listing users needs service role.
166
168
  - **`recount-terms`** — repairs category/tag/term `count`.
@@ -183,4 +185,8 @@ Actions: `status` · `create-link` · `complete-return` · `verify` — the admi
183
185
 
184
186
  ## commerce/seed-store
185
187
 
186
- Not action-routed. Body `{ with_sample_data?: boolean, store_name?: string }`. **`store_name` is required** when the `general` group doesn't exist yet (**400** `store_name_required` otherwise) — pass the app's name **as the platform shows it** — ask the user or read it from the dashboard. `base44/config.jsonc` → `name` is *not* authoritative: it can still say `New App` for an app the platform calls `Canvas`. A backend function can't read either, its environment being only `BASE44_APP_ID`. It becomes the store name *and*, by inheritance, the "from" name on every transactional email; a nameless store renders subjects like `[]: New order #1002`, which is why seeding refuses one. Requires admin. Runs a **canary schema check** first — on any incompatibility returns **422** `{ success:false, code:"schema_incompatible", errors:[{ entity, error }] }` and writes nothing. Otherwise seeds defaults idempotently and (if `with_sample_data` and the store has no products) a sample catalog. On an already-seeded store a passed `store_name` fills a **blank** name and never overwrites one the merchant chose. → `{ seeded: { settings_groups, gateways, tax_classes, zones, zone_methods }, sample_data: {...counts} | false, store_name: { value, action: "created" | "filled" | "unchanged" | "kept_existing" } }`.
188
+ Not action-routed. Body `{ store_name?, with_sample_data?, products?, coupons?, tax_rates? }`. **`store_name` is required** when the `emails` group doesn't exist yet (**400** `store_name_required` otherwise) — pass the app's name **as the platform shows it** — ask the user or read it from the dashboard. `base44/config.jsonc` → `name` is *not* authoritative: it can still say `New App` for an app the platform calls `Canvas`. A backend function can't read either, its environment being only `BASE44_APP_ID`. It lands in `emails.store_name` — one setting serving as both the store name in email subjects and the sender name on every transactional email; a nameless store renders subjects like `[]: New order #1002`, which is why seeding refuses one. Requires admin. Runs a **canary schema check** first — on any incompatibility returns **422** `{ success:false, code:"schema_incompatible", errors:[{ entity, error }] }` and writes nothing. Otherwise seeds defaults idempotently, then the catalog. On an already-seeded store a passed `store_name` fills a **blank** name and never overwrites one the merchant chose.
189
+
190
+ **`products`** is the one-call catalog bootstrap — the worked example and full semantics are in [`../post-installation.md` §2.1](../post-installation.md#21-the-products-payload). Each entry references categories/tags/attributes by **display name** (taxonomy is get-or-created: slugs/codes derived, existing records matched case-insensitively and reused, with the stored casing canonicalized into the product). `attributes: [{ name, options }]` (or `{ <name>: [options] }`) declares the variant axes; `variations: [{ options: { <name>: <option> }, ...overrides }]` lists the stocked combinations — omit it to auto-generate **all** combinations, each inheriting the product-level price/sale fields. A variation with its own `stock_quantity` gets `manage_stock: "yes"`; without one it draws on the parent's pooled stock (`"parent"`). Variation SKUs are synthesized from the parent SKU when absent. Parent `price`/`regular_price`/`on_sale` are rolled up from the cheapest publishable variant, `stock_status` derived, category/tag counts maintained — same helpers as `admin-products` `save`, but **no `product.created` webhooks are dispatched** (bootstrap precedes webhook subscribers; use `admin-products` for webhook-visible creates). Payload problems fail before any write as **400** `invalid_payload` with `errors: [{ path, error }]`; an explicit variation SKU already in use is **409** `duplicate_sku`; a mid-write failure rolls back everything the call created (**500** `catalog_seed_failed`) without touching reused taxonomy. **Re-runs converge**: a product whose `sku` (or, without one, derived slug) already exists is skipped and reported, so retries never duplicate. Limits: ≤100 products, ≤50 variations per product, ≤500 variations per call, ≤50 coupons/tax rates. `coupons`/`tax_rates` are thin passthroughs (coupon `code` lowercased; both skip-if-exists). `with_sample_data: true` seeds the template's demo catalog instead (only when the store has zero products) and cannot be combined with the catalog keys.
191
+
192
+ → `{ seeded: { settings_groups, gateways, tax_classes, zones, zone_methods }, sample_data: {...} | false, catalog: { categories|tags|attributes|terms: { created, reused }, products_created, products_skipped, variations_created, coupons, tax_rates, products: [{ name, id, slug, sku, variation_count } | { name, skipped: true, reason: "sku_exists"|"slug_exists", existing_id }] } | null, store_name: { value, action: "created" | "filled" | "unchanged" | "kept_existing" } }`.
@@ -8,7 +8,7 @@ Read this list before writing any of it. Each item is a hard requirement enforce
8
8
 
9
9
  | # | Requirement | Enforced by |
10
10
  |---|---|---|
11
- | 1 | **Variable products: one selector per attribute**, resolved to a `variation_id` before adding to the cart — never a flat list of combinations | `add-item` → `400 variation_required` ([product page](#get-product)) |
11
+ | 1 | **Products with variants: one selector per attribute**, resolved to a `variation_id` before adding to the cart — never a flat list of combinations | `add-item` → `400 variation_required` ([product page](#get-product)) |
12
12
  | 2 | **Present shipping options and send a choice** — drive it off the cart's `shipping_status`; a single option is auto-applied, several mean you must ask | `place-order` → `400 shipping_method_required` ([shipping](#shipping-is-not-optional--read-this-before-building-checkout)) |
13
13
  | 3 | **Redirect to `payment.checkout_url`** when the customer pays by card | the order stays `pending` otherwise |
14
14
  | 4 | **Implement the return page** (`/order-received`, or your own route set in Settings → General → *Payment return path*) and call `commerce/payments` `complete-return` there | without it a paid order is never confirmed, and a wrong path is a 404 |
@@ -22,8 +22,8 @@ The backend is considerably richer than a minimal "grid → cart → pay" shop.
22
22
 
23
23
  | Capability | Already supported | Where |
24
24
  |---|---|---|
25
- | **Product discovery** | full-text `search`, filter by category (incl. descendants), tag, attribute+term, price range, `featured`, `on_sale`, `in_stock_only`; sort by menu order / price / newest / **popularity** / **rating**; paging | [`list-products`](#list-products) |
26
- | **Rich product pages** | galleries, variable products with per-variation price/stock/image, categories, tags, **upsells**, **cross-sells**, grouped-product children | [`get-product`](#get-product) |
25
+ | **Product discovery** | full-text `search`, filter by category (incl. descendants), tag, attribute+term, price range, `featured`, `on_sale`, `in_stock_only`; sort by name / price / **newest** (the default) / **popularity** / **rating**; paging | [`list-products`](#list-products) |
26
+ | **Rich product pages** | galleries, per-variant price/stock/image, categories, tags, **upsells**, **cross-sells** | [`get-product`](#get-product) |
27
27
  | **Taxonomy navigation** | category tree, **tag list with counts**, attributes + terms for filter UIs | [`list-categories`](#list-categories), [`list-tags`](#list-tags), [`list-attributes`](#list-attributes) |
28
28
  | **Customer reviews** | paginated reviews per product with **average rating + rating count**, `verified` owner flag, and **review submission by signed-in customers**; plus "my reviews" | [`get-product`](#get-product), [`submit-review`](#submit-review), [`my-reviews`](#commercestorefront-account) |
29
29
  | **Cart** | guest carts via `cart_token`, add/update/remove, quantity merging, `sold_individually` caps, live re-pricing, stock revalidation | [`commerce/storefront-cart`](#commercestorefront-cart) |
@@ -62,10 +62,13 @@ Bootstrap data for a storefront. No payload.
62
62
  **Response:**
63
63
  ```json
64
64
  {
65
- "settings": { "currency": "USD", "currency_position": "left", "thousand_sep": ",", "decimal_sep": ".",
66
- "num_decimals": 2, "weight_unit": "kg", "dimension_unit": "cm",
67
- "prices_include_tax": false, "enable_reviews": true, "review_rating_required": true },
68
- "payment_gateways": [ { "slug": "cod", "title": "Cash on delivery", "description": "..." } ],
65
+ "settings": { "store_name": "Acme", "currency": "USD", "currency_position": "left",
66
+ "thousand_sep": ",", "decimal_sep": ".", "num_decimals": 2,
67
+ "weight_unit": "kg", "dimension_unit": "cm",
68
+ "prices_include_tax": false, "display_prices_shop": "excl", "display_prices_cart": "excl",
69
+ "enable_reviews": true, "review_rating_required": true, "hide_out_of_stock": false },
70
+ "payment_gateways": [ { "slug": "offline", "title": "Offline payment", "description": "...", "online": false },
71
+ { "slug": "stripe", "title": "Credit card", "description": "...", "online": true } ],
69
72
  "countries": [ { "code": "US", "name": "United States", "states": [ { "code": "CA", "name": "California" } ] } ],
70
73
  "currencies": [ { "code": "USD", "name": "US Dollar", "symbol": "$", "decimals": 2 } ]
71
74
  }
@@ -73,13 +76,13 @@ Bootstrap data for a storefront. No payload.
73
76
  `settings` is a safe projection — only display/behavior keys, never admin config.
74
77
 
75
78
  ### `list-products`
76
- **Payload** (all optional): `search`, `category_id` (includes descendants), `tag_id`, `attribute_id` + `attribute_term`, `min_price`, `max_price`, `featured` (bool), `on_sale` (bool), `in_stock_only` (bool), `sort` (`menu_order`|`price`|`-price`|`-created_date`|`popularity`|`rating`, default `menu_order`), `page` (default 1), `per_page` (default 12, max 100).
79
+ **Payload** (all optional): `search`, `category_id` (includes descendants), `tag_id`, `attribute_id` + `attribute_term`, `min_price`, `max_price`, `featured` (bool), `on_sale` (bool), `in_stock_only` (bool), `sort` (`-created_date`|`name`|`price`|`-price`|`popularity`|`rating`, default `-created_date`), `page` (default 1), `per_page` (default 12, max 100).
77
80
 
78
81
  Only `status: "publish"` products are returned. Visibility: passing `search` uses **search context** (hides `catalog_visibility: hidden|catalog`); no `search` uses **browse context** (hides `hidden|search`). `inventory.hide_out_of_stock` (or `in_stock_only`) drops out-of-stock products.
79
82
 
80
83
  **Response:** `{ "products": [Product...], "page": 1, "per_page": 12, "has_next": true }`
81
84
 
82
- Each row is the product record (minus paywalled fields) **plus a resolved `tags` array** (`[{ id, name, slug }]`), so cards can show tags without a second call. `categories` are **not** resolved on rows — `category_ids` only.
85
+ Each row is the product record (minus paywalled fields) **plus a resolved `tags` array** (`[{ id, name }]`), so cards can show tags without a second call. `categories` are **not** resolved on rows — `category_ids` only.
83
86
 
84
87
  > **What to render, and what a row can't show:** [`../references/product-render.md`](../references/product-render.md) — field-availability table for list vs. product page, tags in both views, variable-product pricing on cards, and how to add a `get-product`-only field to the listing call instead of fetching per card.
85
88
 
@@ -92,7 +95,7 @@ Each row is the product record (minus paywalled fields) **plus a resolved `tags`
92
95
  ```json
93
96
  {
94
97
  "product": { Product },
95
- "variations": [ ProductVariation... ], // publishable only; [] for non-variable
98
+ "variations": [ ProductVariation... ], // publishable only; [] when the product has no attributes
96
99
  "categories": [ ProductCategory... ],
97
100
  "tags": [ ProductTag... ],
98
101
  "reviews": { "items": [ { "id", "reviewer", "review", "rating", "verified", "created_date" } ],
@@ -100,12 +103,11 @@ Each row is the product record (minus paywalled fields) **plus a resolved `tags`
100
103
  "average_rating": 4.5, "rating_count": 12 },
101
104
  "upsells": [ { "id", "name", "slug", "price", "on_sale", "image", "stock_status" } ],
102
105
  "cross_sells": [ ...summaries ],
103
- "grouped_products": [ ...summaries ]
104
106
  }
105
107
  ```
106
108
  **Errors:** `404 not_found` (missing / not published / hidden).
107
109
 
108
- > **Rendering this response as a product page — the rule, not just a link.** `variations[]` is **not** a list of choices to show. Build **one control per `product.attributes[]` entry with `variation: true`** (Size, Color, …), and resolve the combination to a variation client-side; `add-item` needs that `variation_id`. Variable-product prices come from `variations[]`, never from `product.price` (the backend rolls stock up to the parent, not price). The shipped helper does all of it:
110
+ > **Rendering this response as a product page — the rule, not just a link.** `variations[]` is **not** a list of choices to show. Build **one control per `product.attributes[]` entry** (Size, Color, …) — every attribute is an axis — and resolve the combination to a variation client-side; `add-item` needs that `variation_id`. Variant prices come from `variations[]`, never from `product.price` (the backend rolls stock up to the parent, not price). The shipped helper does all of it:
109
111
  > ```js
110
112
  > import { resolveSelection, defaultSelection, selectOption } from "@/commerce/utils";
111
113
  > const [selection, setSelection] = useState(() => defaultSelection(product, variations));
@@ -125,21 +127,21 @@ No payload. Returns a nested tree: `{ "categories": [ { ...category, "children":
125
127
  ### `list-tags`
126
128
  **Payload** (optional): `{ with_products_only?: boolean }` (default `true`).
127
129
 
128
- Returns `{ "tags": [ { id, name, slug, description, count } ] }` sorted by name. By default it omits tags no published product carries, so a tag nav never links to an empty listing; pass `with_products_only: false` for the full set (e.g. an admin-facing picker).
130
+ Returns `{ "tags": [ { id, name, count } ] }` sorted by name. By default it omits tags no published product carries, so a tag nav never links to an empty listing; pass `with_products_only: false` for the full set (e.g. an admin-facing picker).
129
131
 
130
132
  `count` is tallied from the products this API would actually list — published, browse-visible, and honoring `inventory.hide_out_of_stock` — **not** from `ProductTag.count`, which also counts drafts and hidden products and drifts until `commerce/admin-tools` `recount-terms` runs. So `Gift (12)` and the tag's listing agree.
131
133
 
132
134
  This is the **only** way a storefront can enumerate tags — `commerce.ProductTag` is admin-only RLS — and it's what makes `list-products` `tag_id` usable, since that filter needs an id. Tags are a flat, cross-cutting axis ("gift", "summer", "vegan"); categories are the hierarchical spine. Show both: see [*Tags*](../references/product-render.md#2-tags--render-them-in-both-views) for where they belong in each view.
133
135
 
134
136
  ### `list-attributes`
135
- No payload. Returns `{ "attributes": [ { ...attribute, "terms": [ ...terms ] } ] }` — for building filter UIs.
137
+ No payload. Returns `{ "attributes": [ { ...attribute, "terms": [ ...values ] } ] }` — the whole attribute record (`id, name, code, order`) plus its values (`id, attribute_id, name, order, count`), attributes sorted by `order` and each attribute's values by their own `order` — for building filter UIs. Filter with `list-products` `attribute_id` (the id, or the attribute **name**) + `attribute_term` (the value name); `code` is the stable key to put in a URL.
136
138
 
137
139
  ### `submit-review` — **auth**
138
140
  **Payload:** `{ product_id, reviewer?, review, rating }`.
139
141
 
140
142
  **Requires a signed-in customer.** The reviewer's email is taken from the session, never from the payload — a `reviewer_email` field is ignored. `reviewer` is the display name only, and defaults to the account's `full_name`. Build the review form behind a login prompt: anonymous callers get `401 login_required`.
141
143
 
142
- Requires `products.enable_reviews` and the product's `reviews_allowed`. Rating required when `review_rating_required`. If `only_verified_reviews`, the signed-in customer must have a `processing`/`completed` order containing the product. `verified` is derived from their own order history. Status is `hold` unless `auto_approve_reviews`.
144
+ Requires `products.enable_reviews` — reviews are a store-wide switch, not per product. Rating required when `review_rating_required`. If `only_verified_reviews`, the signed-in customer must have a `processing`/`completed` order containing the product. `verified` is derived from their own order history. Status is `hold` unless `auto_approve_reviews`.
143
145
 
144
146
  **Response:** `{ "review_id", "status": "hold"|"approved", "verified": true }`
145
147
  **Errors:** `401 login_required`, `403 reviews_disabled|verified_only|forbidden`, `404 not_found`, `400 review_incomplete|rating_required|invalid_rating`.
@@ -184,10 +186,10 @@ Stored coupons that stop validating are **auto-removed** and reported in `coupon
184
186
  | `create` | `{ items?: [{product_id, variation_id?, quantity, attributes?}] }` | Mints and returns a new `cart_token`. Initial items go through add validation. |
185
187
  | `get` | `{ cart_token }` | Priced view (also re-prices + merges). |
186
188
  | `totals` | `{ cart_token }` | Alias of `get`. |
187
- | `add-item` | `{ cart_token, product_id, variation_id?, quantity?, attributes? }` | Variable product requires `variation_id` (`400 variation_required`); `sold_individually` caps qty at 1; merges same product+variation. `400 <stock code>`, `404 product_not_found|variation_not_found`. |
189
+ | `add-item` | `{ cart_token, product_id, variation_id?, quantity?, attributes? }` | A product carrying attributes requires `variation_id` (`400 variation_required`); `sold_individually` caps qty at 1; merges same product+variation. `400 <stock code>`, `404 product_not_found|variation_not_found`. |
188
190
  | `update-item` | `{ cart_token, item_key, quantity }` | qty ≤ 0 removes the line. `404 item_not_found`, `400 <stock code>`. |
189
191
  | `remove-item` | `{ cart_token, item_key }` | |
190
- | `apply-coupon` | `{ cart_token, code }` | Full validation. `400 coupons_disabled|code_required|already_applied|<coupon code>`. |
192
+ | `apply-coupon` | `{ cart_token, code }` | Full validation. `400 code_required|already_applied|<coupon code>`. |
191
193
  | `remove-coupon` | `{ cart_token, code }` | |
192
194
  | `set-shipping-address` | `{ cart_token, address: {country, state?, postcode?, city?} }` | `400 country_required`. Returns the matched zone's `available_shipping_methods` and a `shipping_status`. |
193
195
  | `choose-shipping-method` | `{ cart_token, method_id }` | `method_id` = a `commerce.ShippingZoneMethod` id from `available_shipping_methods`. `400 invalid_shipping_method` (the error body carries `available_shipping_methods`). |
@@ -219,15 +221,15 @@ Rules the cart enforces on every price, so you get them for free:
219
221
 
220
222
  Copy like *"Free shipping on orders over €150"* is a **claim about store configuration**, and the checkout will only honour what's actually configured. Don't write such a line into a storefront unless a matching rule exists — either configure it (with the operator's agreement) or leave the copy out.
221
223
 
222
- `commerce.ShippingZone` and `commerce.ShippingZoneMethod` are **public-read**, so the real rule is available to the storefront, and [`src/commerce/utils/shipping-promos.js`](../../../src/commerce/utils/shipping-promos.js) reads it:
224
+ **Zones are admin-only data.** `commerce.ShippingZone` and `commerce.ShippingZoneMethod` are `read: {role: "admin"}` like every entity, so a visitor cannot list them — do not try. Two honest sources:
225
+
226
+ 1. **The cart itself**, which is the live answer for a real address: after `set-shipping-address`, `available_shipping_methods` already reflects every rule the checkout will honour, free shipping included. Prefer this.
227
+ 2. **A projection you add**, if you want a threshold banner *before* an address exists: widen `get-store-info` (or add an action) to return the `free_shipping` rules, then normalize them with [`src/commerce/utils/shipping-promos.js`](../../../src/commerce/utils/shipping-promos.js):
223
228
 
224
229
  ```js
225
230
  import { freeShippingRules, freeShippingThreshold, freeShippingProgress } from "@/commerce/utils";
226
231
 
227
- const [zones, methods] = await Promise.all([
228
- base44.entities["commerce.ShippingZone"].list("order", 100),
229
- base44.entities["commerce.ShippingZoneMethod"].list(undefined, 200),
230
- ]);
232
+ // zones/methods come from your own storefront action — NOT from a client entity read
231
233
  const threshold = freeShippingThreshold(freeShippingRules(zones, methods));
232
234
  // null → the store has no free-shipping rule: show no banner, no progress bar.
233
235
  const progress = freeShippingProgress(threshold, cart.totals.subtotal); // { threshold, qualifies, remaining }
@@ -242,7 +244,7 @@ A free-shipping rule is a `free_shipping` zone method whose `settings.requires`
242
244
  ### `place-order`
243
245
  Converts a cart into an order. **Payload:**
244
246
  ```json
245
- { "cart_token": "uuid", "payment_method": "cod",
247
+ { "cart_token": "uuid", "payment_method": "offline",
246
248
  "billing": { "first_name", "last_name", "address_1", "address_2?", "city", "state?", "postcode?", "country", "email", "phone?" },
247
249
  "shipping": { ...address without email },
248
250
  "chosen_shipping_method?": "<zoneMethodId>",
@@ -269,9 +271,9 @@ Converts a cart into an order. **Payload:**
269
271
 
270
272
  The order is priced with the resolved method, so its `shipping_total` always matches what the customer was shown.
271
273
 
272
- > **Payment.** `payment_method` is a `commerce.PaymentGateway` slug from `get-store-info`. Card payments are **implemented**: choosing the online gateway leaves the order `pending` and returns `payment: { status: "requires_payment", checkout_url, session_id }` — send the customer to `checkout_url` (the provider's hosted page), then confirm on their return with `commerce/payments` `verify`. Never treat a return URL as proof of payment. The manual gateways (`cod`, `bacs`, `cheque`) settle outside the store and return `payment_instructions` instead. An online gateway is only listed while a payment provider is connected, so anything `get-store-info` offers is payable. Optional `success_url` / `cancel_url` / `return_url` in the payload override the default return URLs. See [`../references/online-payments.md`](../references/online-payments.md).
274
+ > **Payment.** `payment_method` is a `commerce.PaymentGateway` slug from `get-store-info`. Card payments are **implemented**: choosing the online gateway leaves the order `pending` and returns `payment: { status: "requires_payment", checkout_url, session_id }` — send the customer to `checkout_url` (the provider's hosted page), then confirm on their return with `commerce/payments` `verify`. Never treat a return URL as proof of payment. The `offline` gateway settles outside the store and returns `payment_instructions` instead. An online gateway is only listed while a payment provider is connected, so anything `get-store-info` offers is payable. Optional `success_url` / `cancel_url` / `return_url` in the payload override the default return URLs. See [`../references/online-payments.md`](../references/online-payments.md).
273
275
 
274
- **Steps:** releases expired holds → revalidates stock & coupons → computes authoritative totals → finds-or-creates the Customer by billing email → creates a `pending` order (with `order_key`, `hold_expires_at`) → reduces stock, fires `new_order` email + `order.created` webhook → marks cart `converted` → routes by gateway: **cod → processing**, **bacs/cheque → on-hold** (with `payment_instructions`), **stripe → stays pending** with a `not_implemented` placeholder, **custom → pending_external**.
276
+ **Steps:** releases expired holds → revalidates stock & coupons → computes authoritative totals → finds-or-creates the Customer by billing email → creates a `pending` order (with `order_key`, `hold_expires_at`) → reduces stock, fires `new_order` email + `order.created` webhook → marks cart `converted` → routes by gateway: **offline → on-hold** (with `payment_instructions`), **stripe → stays pending** until the hosted page is paid, **custom → pending_external**.
275
277
 
276
278
  > **Saved profiles are only written by their owner.** The order always stores the `billing`/`shipping` it was placed with. The **`commerce.Customer`** record behind it — the one a storefront prefills from — is refreshed only when the caller is signed in *as* the billing email. A guest checkout quoting an existing customer's address still attaches the order to them (so it shows up in their `my-orders`), but cannot change their saved name or addresses. Don't build an "edit my details at checkout" flow for guests; use `update-my-addresses` behind a login instead.
277
279
 
@@ -279,10 +281,12 @@ Converts a cart into an order. **Payload:**
279
281
  ```json
280
282
  {
281
283
  "order_id": "...", "order_number": 1001, "order_key": "order_...",
282
- "status": "processing", "currency": "USD",
283
- "payment_method": "cod", "payment_method_title": "Cash on delivery",
284
- "payment_instructions": { "type": "cod", "description": "...", "account_details?": [...] },
285
- "payment": null, // or { "status": "not_implemented" | "pending_external", "note": "..." } for stripe/custom
284
+ "status": "on-hold", "currency": "USD",
285
+ "payment_method": "offline", "payment_method_title": "Offline payment",
286
+ "payment_instructions": { "type": "offline", "description": "...", "account_details": [...] },
287
+ "payment": null, // offline. For the online gateway:
288
+ // { "status": "requires_payment", "provider", "checkout_url", "session_id" }
289
+ // For a custom gateway: { "status": "pending_external" }
286
290
  "notices": [], // e.g. ["account_creation_requires_login"]
287
291
  "totals": { "subtotal", "discount_total", "shipping_total", "shipping_tax", "cart_tax", "total_tax", "total" },
288
292
  "order": { ...customer-safe order (internal flags/ip stripped) }
@@ -350,9 +354,9 @@ let cart = await inv("commerce/storefront-cart", { action: "create",
350
354
  items: [{ product_id: products[0].id, quantity: 1 }] });
351
355
  const token = cart.cart_token;
352
356
 
353
- // 3. (variable product) fetch options, then add the chosen variation
357
+ // 3. (product with variants) fetch options, then add the chosen variation
354
358
  const detail = await inv("commerce/storefront-catalog", { action: "get-product", id: products[0].id });
355
- if (detail.product.type === "variable") {
359
+ if ((detail.product.attributes ?? []).length) {
356
360
  cart = await inv("commerce/storefront-cart", { action: "add-item",
357
361
  cart_token: token, product_id: detail.product.id, variation_id: detail.variations[0].id });
358
362
  }
@@ -372,9 +376,9 @@ if (cart.shipping_status === "choice_required") {
372
376
  throw new Error("We don't ship to this address");
373
377
  }
374
378
 
375
- // 6. Place the order (COD → processing immediately)
379
+ // 6. Place the order (offline → on-hold; no money has arrived yet)
376
380
  const order = await inv("commerce/storefront-checkout", { action: "place-order",
377
- cart_token: token, payment_method: "cod",
381
+ cart_token: token, payment_method: "offline",
378
382
  billing: { first_name: "Ada", last_name: "Lovelace", address_1: "1 St",
379
383
  city: "Los Angeles", state: "CA", postcode: "90210", country: "US", email: "ada@example.com" } });
380
384
 
@@ -398,8 +402,8 @@ await inv("commerce/storefront-account", { action: "update-my-addresses",
398
402
  state: "CA", postcode: "90210", country: "US", email: "ada@example.com" } });
399
403
 
400
404
  const order = await inv("commerce/storefront-checkout", { action: "place-order",
401
- cart_token: cart.cart_token, payment_method: "bacs", billing: { /* ... */ } });
402
- // bacs → on-hold; show order.payment_instructions.account_details
405
+ cart_token: cart.cart_token, payment_method: "offline", billing: { /* ... */ } });
406
+ // offline → on-hold; show order.payment_instructions.account_details
403
407
 
404
408
  // Order history, downloads, reviews (all auth, no order_key needed)
405
409
  const { orders } = await inv("commerce/storefront-account", { action: "my-orders", per_page: 10 });