@base44/app-plugin-commerce 0.1.5 → 0.1.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. package/README.md +11 -11
  2. package/base44/agents/commerce/StoreAdmin.jsonc +2 -2
  3. package/base44/entities/commerce.Cart.jsonc +1 -1
  4. package/base44/entities/commerce.Coupon.jsonc +5 -0
  5. package/base44/entities/commerce.Order.jsonc +6 -7
  6. package/base44/entities/commerce.OrderRefund.jsonc +1 -1
  7. package/base44/entities/commerce.PaymentGateway.jsonc +1 -1
  8. package/base44/entities/commerce.Product.jsonc +6 -16
  9. package/base44/entities/{commerce.ProductTag.jsonc → commerce.ProductRibbon.jsonc} +2 -2
  10. package/base44/entities/commerce.ProductVariation.jsonc +1 -9
  11. package/base44/entities/commerce.ShippingTaxLocation.jsonc +85 -0
  12. package/base44/entities/commerce.Webhook.jsonc +1 -1
  13. package/base44/functions/commerce/admin-orders/helpers.ts +7 -13
  14. package/base44/functions/commerce/admin-products/entry.ts +11 -17
  15. package/base44/functions/commerce/admin-refunds/entry.ts +10 -9
  16. package/base44/functions/commerce/admin-reports/entry.ts +3 -3
  17. package/base44/functions/commerce/admin-tools/entry.ts +9 -36
  18. package/base44/functions/commerce/payment-webhook/entry.ts +50 -89
  19. package/base44/functions/commerce/payments/entry.ts +46 -42
  20. package/base44/functions/commerce/seed-store/defaults.ts +35 -42
  21. package/base44/functions/commerce/seed-store/entry.ts +84 -31
  22. package/base44/functions/commerce/seed-store/sample-data.ts +2 -15
  23. package/base44/functions/commerce/seed-store/seed-catalog.ts +105 -55
  24. package/base44/functions/commerce/storefront-cart/cart-pricing.ts +6 -11
  25. package/base44/functions/commerce/storefront-cart/entry.ts +36 -2
  26. package/base44/functions/commerce/storefront-catalog/entry.ts +55 -72
  27. package/base44/functions/commerce/storefront-checkout/cart-pricing.ts +6 -11
  28. package/base44/functions/commerce/storefront-checkout/entry.ts +43 -56
  29. package/base44/shared/commerce/card-payment.ts +80 -0
  30. package/base44/shared/commerce/coupons.ts +16 -10
  31. package/base44/shared/commerce/emails.ts +30 -18
  32. package/base44/shared/commerce/money.ts +11 -24
  33. package/base44/shared/commerce/payments.ts +55 -286
  34. package/base44/shared/commerce/scan.ts +1 -1
  35. package/base44/shared/commerce/sequence.ts +1 -1
  36. package/base44/shared/commerce/settings.ts +4 -9
  37. package/base44/shared/commerce/shipping.ts +65 -133
  38. package/base44/shared/commerce/tax.ts +48 -96
  39. package/base44/shared/commerce/totals.ts +77 -91
  40. package/package.json +1 -1
  41. package/scripts/install.js +28 -7
  42. package/skills/commerce/SKILL.md +14 -14
  43. package/skills/commerce/docs/api-admin.md +23 -26
  44. package/skills/commerce/docs/api-storefront.md +67 -61
  45. package/skills/commerce/installation-guidelines.md +8 -8
  46. package/skills/commerce/post-installation.md +76 -41
  47. package/skills/commerce/references/admin-product-form.md +15 -12
  48. package/skills/commerce/references/emails.md +2 -2
  49. package/skills/commerce/references/guest-access-security.md +2 -2
  50. package/skills/commerce/references/online-payments.md +24 -191
  51. package/skills/commerce/references/product-render.md +18 -18
  52. package/skills/commerce/references/reviews.md +14 -8
  53. package/skills/commerce/references/storefront-product-page.md +1 -1
  54. package/src/commerce/admin/README.md +4 -5
  55. package/src/commerce/admin/bot/Markdown.jsx +1 -1
  56. package/src/commerce/admin/hooks/useMoney.js +13 -22
  57. package/src/commerce/admin/layout/AuthGuard.jsx +1 -1
  58. package/src/commerce/admin/lib/constants.js +2 -29
  59. package/src/commerce/admin/lib/order-utils.js +1 -1
  60. package/src/commerce/admin/pages/coupons/CouponEditor.jsx +131 -140
  61. package/src/commerce/admin/pages/coupons/CouponsList.jsx +14 -7
  62. package/src/commerce/admin/pages/orders/OrderEditor.jsx +3 -3
  63. package/src/commerce/admin/pages/orders/components/PaymentPanel.jsx +17 -28
  64. package/src/commerce/admin/pages/orders/components/RefundPanel.jsx +5 -11
  65. package/src/commerce/admin/pages/products/Categories.jsx +147 -177
  66. package/src/commerce/admin/pages/products/ProductEditor.jsx +23 -18
  67. package/src/commerce/admin/pages/products/Reviews.jsx +37 -1
  68. package/src/commerce/admin/pages/products/components/ProductDataPanel.jsx +33 -47
  69. package/src/commerce/admin/pages/products/components/PublishBox.jsx +13 -34
  70. package/src/commerce/admin/pages/products/components/TaxonomyPanel.jsx +29 -29
  71. package/src/commerce/admin/pages/products/components/tabs/PriceInventoryTab.jsx +14 -44
  72. package/src/commerce/admin/pages/reports/Reports.jsx +2 -2
  73. package/src/commerce/admin/pages/settings/EmailsSettings.jsx +101 -68
  74. package/src/commerce/admin/pages/settings/GeneralSettings.jsx +40 -38
  75. package/src/commerce/admin/pages/settings/InventorySettings.jsx +1 -41
  76. package/src/commerce/admin/pages/settings/LocationEditor.jsx +377 -0
  77. package/src/commerce/admin/pages/settings/PaymentsSettings.jsx +137 -119
  78. package/src/commerce/admin/pages/settings/SettingsLayout.jsx +2 -4
  79. package/src/commerce/admin/pages/settings/ShippingTaxSettings.jsx +191 -0
  80. package/src/commerce/admin/routes.jsx +6 -12
  81. package/src/commerce/utils/index.js +2 -2
  82. package/src/commerce/utils/shipping-promos.js +45 -49
  83. package/src/commerce/utils/variants.js +1 -1
  84. package/base44/entities/commerce.ShippingClass.jsonc +0 -30
  85. package/base44/entities/commerce.ShippingZone.jsonc +0 -41
  86. package/base44/entities/commerce.ShippingZoneMethod.jsonc +0 -84
  87. package/base44/entities/commerce.TaxClass.jsonc +0 -23
  88. package/base44/entities/commerce.TaxRate.jsonc +0 -68
  89. package/base44/shared/commerce/stripe.ts +0 -463
  90. package/src/commerce/admin/hooks/usePaymentProvider.js +0 -27
  91. package/src/commerce/admin/pages/settings/ProductsSettings.jsx +0 -118
  92. package/src/commerce/admin/pages/settings/ShippingSettings.jsx +0 -304
  93. package/src/commerce/admin/pages/settings/ShippingZoneEditor.jsx +0 -514
  94. package/src/commerce/admin/pages/settings/TaxRatesTable.jsx +0 -231
  95. package/src/commerce/admin/pages/settings/TaxSettings.jsx +0 -280
@@ -1,6 +1,6 @@
1
1
  # Post-installation
2
2
 
3
- What to do right after the static installation ([`installation-guidelines.md`](./installation-guidelines.md)): embed the admin pages into the app, seed the store's data, and build the storefront from the quick start below. Installed into the app at `skills/commerce/post-installation.md`.
3
+ What to do right after the static installation ([`installation-guidelines.md`](./installation-guidelines.md)): embed the admin pages into the app, seed the store's data, and build the storefront from the quick start below. Installed into the app at `.agents/skills/commerce/post-installation.md`.
4
4
 
5
5
  ---
6
6
 
@@ -44,11 +44,11 @@ Even if the client guard were bypassed, layers 2 and 3 keep the store data safe.
44
44
 
45
45
  ## 2. Store data — seeding
46
46
 
47
- A fresh install has **no settings and no catalog**. One call to `commerce/seed-store` (admin-only, idempotent) initializes both. It always creates the business defaults — the six settings groups (`general`, `products`, `inventory`, `tax`, `shipping`, `emails`; USD, kg/cm, taxes off prices), the `offline` and `stripe` payment gateways, three tax classes and a free "Rest of the world" fallback shipping zone — and, depending on the payload, the catalog:
47
+ A fresh install has **no settings and no catalog**. One call to `commerce/seed-store` (admin-only, idempotent) initializes both. It always creates the business defaults — the six settings groups (`general`, `products`, `inventory`, `tax`, `shipping`, `emails`; USD, kg/cm, taxes off prices) and the `offline` and `card` payment gateways — and, depending on the payload, the catalog. A fallback "Rest of the world" **Shipping & Tax Location** (one free shipping rate, no tax) is seeded **only when the payload carries no `locations`** — locations you pass are the store's only shipping data, with no seeded fallback beside them. Pass **`currency`** (an ISO code, e.g. `"EUR"`) and/or **`weight_unit`**/**`dimension_unit`** to set the store's currency and measurement units instead of the defaults — explicit values always win, on a first seed and a re-run alike. (Prices are *formatted* with `Intl.NumberFormat` per the viewer's locale — the currency is a value; there are no format settings.)
48
48
 
49
49
  | Mode | Body | Products created |
50
50
  |---|---|---|
51
- | **Real catalog** | `{ store_name, products: [...] }` (+ optional `coupons`, `tax_rates`) | Yours — categories, attributes, variants and all, in this one call |
51
+ | **Real catalog** | `{ store_name, products: [...] }` (+ optional `coupons`, `locations`) | Yours — categories, ribbons, attributes, variants and all, in this one call |
52
52
  | **Demo data** | `{ store_name, with_sample_data: true }` | The template's 10 generic demo products (skipped if any product exists) |
53
53
  | **No products** | `{ store_name }` | None — defaults only |
54
54
 
@@ -60,11 +60,14 @@ The call runs a canary schema check first — on **422** `schema_incompatible` f
60
60
 
61
61
  ### 2.1 The `products` payload
62
62
 
63
- Reference everything by **display name** — categories, tags, attributes and their options are get-or-created (slugs and codes derived, existing records matched case-insensitively and reused). Variants come from `attributes`: list each axis with the options the product comes in, and either pass explicit `variations` (only the combinations you stock, with per-variation overrides) or omit them to auto-generate **every combination**. Prices and the sale window are inherited from the product spec unless a variation overrides them; a variation with its own `stock_quantity` tracks it, one without draws on the parent's pooled `stock_quantity`.
63
+ **This is the store's real data, not template data.** The call writes the actual `commerce.*` records the admin and the storefront API read from that moment on — nothing is post-processed, substituted or "filled in by the platform" later. Whatever you pass is exactly what `list-products`/`get-product` return, so pass final values (real copy, real prices, permanently-resolvable image URLs) and build the storefront on what the API returns — never on client-side constants mirroring the seed (e.g. a slug→image map): the database is the single source of truth, and such a mirror silently diverges the first time a product is edited in the admin.
64
+
65
+ Reference everything by **display name** — categories, ribbons, attributes and their options are get-or-created (slugs and codes derived, existing records matched case-insensitively and reused). Variants come from `attributes`: list each axis with the options the product comes in, and either pass explicit `variations` (only the combinations you stock, with per-variation overrides) or omit them to auto-generate **every combination**. Prices and the sale window are inherited from the product spec unless a variation overrides them; a variation with its own `stock_quantity` tracks it, one without draws on the parent's pooled `stock_quantity`.
64
66
 
65
67
  ```js
66
68
  await base44.functions.invoke("commerce/seed-store", {
67
69
  store_name: "Aurora Threads",
70
+ currency: "EUR", // optional — defaults to USD
68
71
  products: [
69
72
  { // simple product
70
73
  name: "Classic T-Shirt",
@@ -72,7 +75,7 @@ await base44.functions.invoke("commerce/seed-store", {
72
75
  regular_price: 19.99,
73
76
  stock_quantity: 50, // implies manage_stock: true
74
77
  categories: ["Clothing"], // get-or-create by name
75
- tags: ["bestseller"],
78
+ ribbons: ["Best Seller"],
76
79
  images: ["https://…/tee.jpg"], // URLs or { src, alt }
77
80
  short_description: "A soft, breathable everyday tee.",
78
81
  description: "<p>Cut from combed cotton…</p><ul><li>100% combed cotton</li><li>Pre-shrunk</li></ul>",
@@ -98,10 +101,16 @@ await base44.functions.invoke("commerce/seed-store", {
98
101
  },
99
102
  ],
100
103
  coupons: [{ code: "WELCOME10", discount_type: "percent", amount: 10 }], // optional
104
+ locations: [{ // optional — passing any makes these the store's ONLY locations (the free-shipping fallback is not seeded)
105
+ name: "Israel", countries: ["IL"],
106
+ shipping_rates: [{ name: "Standard", cost: 20, free_over: 150 }],
107
+ tax_groups: [{ name: "Products", rates: [{ name: "VAT", rate: 18 }] }],
108
+ shipping_tax: { type: "percent", value: 18 }, // or { type: "fixed", value: 5 }
109
+ }],
101
110
  });
102
111
  ```
103
112
 
104
- What the seeder does per product: derives a unique slug, checks SKU uniqueness, prices variations (`sale_price` + optional `date_on_sale_from/to` supported at both levels), rolls the parent's `price`/`regular_price`/`on_sale` up from the cheapest publishable variant (never set a variant parent's price yourself — it's derived), sets `stock_status`, and maintains category/tag counts. Products default to `status: "publish"`; pass `"draft"` to review first. Other `commerce.Product` fields (`weight`, `dimensions`, `virtual`, `downloadable`, `downloads`, `meta_data`, …) pass through; unknown keys are rejected so typos surface instead of vanishing.
113
+ What the seeder does per product: derives a unique slug, checks SKU uniqueness, prices variations (`sale_price` + optional `date_on_sale_from/to` supported at both levels), rolls the parent's `price`/`regular_price`/`on_sale` up from the cheapest publishable variant (never set a variant parent's price yourself — it's derived), sets `stock_status`, and maintains category/ribbon counts. Products default to `status: "publish"`; pass `"draft"` to review first. Other `commerce.Product` fields (`weight`, `dimensions`, `virtual`, `downloadable`, `downloads`, `meta_data`, …) pass through; unknown keys are rejected so typos surface instead of vanishing.
105
114
 
106
115
  **Re-runs converge instead of duplicating**: a product whose `sku` (or, without one, derived slug) already exists is skipped and reported — safe for retries after a timeout, and for seeding into a store that already has products. Limits: ≤100 products and ≤500 variations per call, ≤50 variations per product (an auto-generated cartesian above that is rejected — pass explicit `variations`).
107
116
 
@@ -110,24 +119,25 @@ The response reports everything:
110
119
  ```jsonc
111
120
  { "seeded": { "settings_groups": 6, "gateways": 2, ... },
112
121
  "catalog": {
113
- "categories": { "created": 2, "reused": 0 }, "tags": { ... }, "attributes": { ... }, "terms": { ... },
122
+ "categories": { "created": 2, "reused": 0 }, "ribbons": { ... }, "attributes": { ... }, "terms": { ... },
114
123
  "products_created": 2, "products_skipped": 0, "variations_created": 4,
115
- "coupons": { "created": 1, "skipped": 0 }, "tax_rates": { "created": 0, "skipped": 0 },
124
+ "coupons": { "created": 1, "skipped": 0 }, "locations": { "created": 1, "skipped": 0 },
116
125
  "products": [
117
126
  { "name": "Classic T-Shirt", "id": "…", "slug": "classic-t-shirt", "sku": "TEE-CLASSIC", "variation_count": 0 },
118
127
  { "name": "Runner Sneaker", "id": "…", "slug": "runner-sneaker", "sku": "SNK-RUN", "variation_count": 4 }
119
128
  ]
120
129
  },
121
- "store_name": { "value": "Aurora Threads", "action": "created" } }
130
+ "store_name": { "value": "Aurora Threads", "action": "created" },
131
+ "currency": { "value": "EUR", "action": "created" } } // "updated" | "unchanged" on re-runs; null when not passed
122
132
  ```
123
133
 
124
- **Images**: every product needs at least one. Use whatever image generation the app has available and store the returned URL, upload real assets with `base44.integrations.Core.UploadFile({ file })` → public URL, or fall back to stable public stock URLs (`base44/functions/commerce/seed-store/sample-data.ts` shows a working Unsplash pattern). Match the image to the product.
134
+ **Images**: every product needs at least one, and the URL you seed is the URL the store serves — there are no placeholders to swap later. So resolve each image to its **final URL before seeding**: use whatever image generation the app has available and store the returned URL, upload real assets with `base44.integrations.Core.UploadFile({ file })` → public URL, or fall back to stable public stock URLs (`base44/functions/commerce/seed-store/sample-data.ts` shows a working Unsplash pattern). Match the image to the product. If an image isn't ready at seed time, seed without it and set it afterwards through the admin API — don't seed a dead path and compensate in the frontend.
125
135
 
126
- A successful response means the data is in — the catalog and settings are live exactly as reported. Write any store-specific settings into `commerce.StoreSettings` (direct CRUD, one record per `group_id` — currency and units are the usual ones; patch `values`, don't replace groups you weren't asked about).
136
+ A successful response means the data is in — the catalog and settings are live exactly as reported. Write any remaining store-specific settings into `commerce.StoreSettings` (direct CRUD, one record per `group_id` — weight/dimension units are the usual ones; patch `values`, don't replace groups you weren't asked about).
127
137
 
128
138
  ### 2.2 Payments
129
139
 
130
- Card payments are **already implemented** (hosted payment page, payment links, refunds) — make sure a payment provider (i.e. the platform's **Stripe** integration) is configured for the app, and the store takes cards; the `offline` gateway (manual reconciliation) works with nothing to configure. Provider internals, webhooks and refunds are day-2 material: [`references/online-payments.md`](./references/online-payments.md).
140
+ The order side of payments is **already implemented** (checkout routing, confirmation, payment links, refund records). The `offline` gateway — and any option the admin adds in Settings → Payments — works with nothing to configure: the order goes on-hold with the option's description as instructions. The **Credit card** option needs a payment provider, which means implementing exactly two files (`shared/commerce/card-payment.ts` + the payment webhook); until then it answers `503 no_card_payment_provider` at checkout, or can simply be switched off. Details: [`references/online-payments.md`](./references/online-payments.md).
131
141
 
132
142
  ---
133
143
 
@@ -147,25 +157,26 @@ The cart is identified by a **`cart_token`** the backend mints — persist it in
147
157
 
148
158
  ```js
149
159
  const info = await inv("commerce/storefront-catalog", { action: "get-store-info" });
150
- // info.settings → { store_name, currency, currency_position, num_decimals, … } — format money with these
160
+ // info.settings → { store_name, currency, weight_unit, … } — format money with
161
+ // Intl.NumberFormat(undefined, { style: "currency", currency: info.settings.currency })
151
162
  // info.payment_gateways → [{ slug, title, description, online }] — you'll need this at checkout
152
163
  // info.countries / info.currencies → static tables for address forms and money display
153
164
 
154
165
  const { products, page, per_page, has_next } = await inv("commerce/storefront-catalog", {
155
166
  action: "list-products",
156
- page: 1, per_page: 12, // optional: search, category_id, tag_id, featured, on_sale,
167
+ page: 1, per_page: 12, // optional: search, category_id, ribbon_id, featured, on_sale,
157
168
  sort: "-created_date", // min_price, max_price, in_stock_only
158
169
  }); // sort: -created_date | name | price | -price | popularity | rating
159
170
  ```
160
171
 
161
- Each row is a full product record — for a card use `name`, `images[0]?.src`, `price`, `regular_price`, `on_sale`, `short_description`, `stock_status`, `average_rating`/`rating_count` (stars cost no extra call) and `tags` (`[{ id, name }]`, may be absent). **There is no product type flag**: `product.attributes?.length > 0` means the product sells variants and its `price` is a *from*-price rolled up from the cheapest variant — render it as "From …". Categories for the nav come from `{ action: "list-categories" }` (a tree via `parent_id`).
172
+ Each row is a full product record — for a card use `name`, `images[0]?.src`, `price`, `regular_price`, `on_sale`, `short_description`, `stock_status`, `average_rating`/`rating_count` (stars cost no extra call) and `ribbons` (`[{ id, name }]`, may be absent — labels like "Best Seller" for the card corner). **There is no product type flag**: `product.attributes?.length > 0` means the product sells variants and its `price` is a *from*-price rolled up from the cheapest variant — render it as "From …". Categories for the nav come from `{ action: "list-categories" }` (a tree via `parent_id`).
162
173
 
163
174
  **Carry forward:** each card links to the product page by **`slug`**.
164
175
 
165
176
  ### 3.2 Product page — variant selection included
166
177
 
167
178
  ```js
168
- const { product, variations, categories, tags, reviews } =
179
+ const { product, variations, categories, ribbons, reviews } =
169
180
  await inv("commerce/storefront-catalog", { action: "get-product", slug }); // or { id }
170
181
 
171
182
  // One selector PER product.attributes[] entry — never a flat list of variations.
@@ -182,21 +193,21 @@ const view = resolveSelection(product, variations, selection);
182
193
  // view.addToCart → { product_id, variation_id } — null until the selection resolves
183
194
  ```
184
195
 
185
- Add to cart — **create** mints the `cart_token` on first add; every later call reuses it:
196
+ Add to cart — `add-item` bootstraps the cart itself: a missing, stale or expired token starts a fresh cart, so the one rule is to **re-persist the `cart_token` from every response** (your cached token may have expired — 48h TTL — or been consumed by a checkout):
186
197
 
187
198
  ```js
188
- let cart_token = localStorage.getItem("cart_token");
189
- if (!cart_token) {
190
- const cart = await inv("commerce/storefront-cart", { action: "create" });
191
- cart_token = cart.cart_token;
192
- localStorage.setItem("cart_token", cart_token);
193
- }
194
- await inv("commerce/storefront-cart", { action: "add-item", cart_token, ...view.addToCart, quantity: 1 });
199
+ const cart = await inv("commerce/storefront-cart", {
200
+ action: "add-item",
201
+ cart_token: localStorage.getItem("cart_token") || undefined, // fine if absent/stale
202
+ ...view.addToCart,
203
+ quantity: 1,
204
+ });
205
+ localStorage.setItem("cart_token", cart.cart_token); // ALWAYS — the token may be a new cart's
195
206
  ```
196
207
 
197
- A product with attributes is **rejected without a `variation_id`** (`400 variation_required`) — that is why `view.addToCart` and not a bare `product_id` goes into the call. Show `reviews` (`{ items, has_next, average_rating, rating_count }`) and the `upsells`/`cross_sells` summaries the same response carries.
208
+ A product with attributes is **rejected without a `variation_id`** (`400 variation_required`) — that is why `view.addToCart` and not a bare `product_id` goes into the call. Show `reviews` (`{ items, has_next, average_rating, rating_count }`) and the `upsells`/`cross_sells` summaries the same response carries. On the other cart actions a `404 cart_not_found|cart_expired` means the cached token went stale — clear it and treat the cart as empty.
198
209
 
199
- **Carry forward:** the **`cart_token`**.
210
+ **Carry forward:** the **`cart_token`** — from the response, every time.
200
211
 
201
212
  ### 3.3 Cart
202
213
 
@@ -213,44 +224,65 @@ cart = await inv("commerce/storefront-cart", { action: "remove-item", cart_token
213
224
  cart = await inv("commerce/storefront-cart", { action: "apply-coupon", cart_token, code });
214
225
  ```
215
226
 
216
- Shipping is chosen **on the cart, before place-order** — this is the step storefronts most often skip, and `place-order` refuses without it (`400 shipping_method_required`):
227
+ Shipping is chosen **on the cart, before place-order** — this is the step storefronts most often skip, and `place-order` refuses without it (`400 shipping_method_required`). **Call `set-shipping-address` the moment the customer provides an address** — every shipping option and cost is recalculated by that call (never reuse a list fetched earlier), and an address the store doesn't ship to **fails right there** with `400 shipping_not_available`, so the address form is where you surface it:
217
228
 
218
229
  ```js
230
+ // as soon as the address is entered — this is what (re)calculates shipping options + cost
219
231
  cart = await inv("commerce/storefront-cart", { action: "set-shipping-address", cart_token,
220
- address: { country, state, postcode, city } });
232
+ address: { country, state, postcode, city } }); // 400 shipping_not_available → show it on the address form
233
+ // cart.chosen_shipping_method is the rate's ID (a string) — to display it, look
234
+ // it up: cart.available_shipping_methods.find(m => m.id === cart.chosen_shipping_method)
235
+ // and render that entry's title + cost. Never render the id itself.
221
236
  switch (cart.shipping_status) {
222
- case "auto_selected": // one option — already applied, nothing to do
237
+ case "auto_selected": break; // only ONE option existed — the backend already applied it;
238
+ // no picker needed, just display the looked-up title + cost
223
239
  case "chosen": break; // customer's earlier choice still valid
224
- case "choice_required": // MUST render cart.available_shipping_methods [{ id, title, cost }]
240
+ case "choice_required": // several options — MUST render cart.available_shipping_methods
241
+ // [{ id, title, cost }] as a picker, then send the customer's pick:
225
242
  cart = await inv("commerce/storefront-cart", { action: "choose-shipping-method", cart_token,
226
243
  method_id: picked.id }); // the entry's id, not its method_id type
227
244
  break;
228
- case "none_available": /* the store doesn't ship there — say so, don't place the order */ break;
245
+ case "missing_address": break; // several zones, no address yet — shipping cost is NOT
246
+ // calculated; collect the address and call set-shipping-address
229
247
  case "not_needed": break; // fully virtual cart
230
248
  }
231
249
  ```
232
250
 
251
+ A store with exactly **one shipping zone** shows its options (and, auto-selected, the cost) on the cart even before an address is set — the options are the same everywhere. Several zones report `missing_address` until the address resolves one.
252
+
233
253
  **Carry forward:** the **`cart_token`** (address and method choice live on the cart).
234
254
 
235
255
  ### 3.4 Checkout & order-received
236
256
 
237
- Payment methods are `info.payment_gateways` from step 1 — render them as they come (`online: true` marks the card/redirect gateway; `offline` is manual reconciliation). Make sure a payment provider (i.e. Stripe) is configured for the app. Then one call places the order:
257
+ The checkout page renders **two sets of options that are store data, never hardcoded**: the shipping methods (already resolved on the cart in step 3 — `place-order` refuses with `400 shipping_method_required` until `shipping_status` is `chosen`/`auto_selected`/`not_needed`) and the payment methods, which come from `info.payment_gateways` (step 1) — every gateway the admin has **enabled**:
258
+
259
+ ```js
260
+ // wiring a card provider = implementing two files — details:
261
+ // .agents/skills/commerce/references/online-payments.md
262
+ const gateways = info.payment_gateways; // [{ slug, title, description, online }] — admin-owned data
263
+ // several → render a picker using the admin's title/description as the labels
264
+ // exactly ONE → no picker: use it directly, but still show its title so the customer knows how they'll pay
265
+ // none → checkout cannot complete — say so instead of rendering a dead button
266
+ const payment_method = gateways.length === 1 ? gateways[0].slug : picked.slug; // never a hardcoded "card"
267
+ ```
268
+
269
+ While the store has no card provider implemented, picking the card gateway fails `place-order` with `503 no_card_payment_provider` — tell the customer card payment is temporarily unavailable and offer the other methods.
270
+
271
+ `online: true` marks the card/redirect gateway; every other gateway is manual reconciliation. Then one call places the order:
238
272
 
239
273
  ```js
240
- // card payments ride the app's Stripe integration — setup & provider details:
241
- // skills/commerce/references/online-payments.md
242
274
  const res = await inv("commerce/storefront-checkout", {
243
275
  action: "place-order", cart_token,
244
- payment_method: gateway.slug, // from info.payment_gateways
276
+ payment_method, // the slug chosen above
245
277
  billing: { first_name, last_name, address_1, city, country, email }, // the required set; phone, state, postcode optional
246
278
  // shipping: { … } if it differs from billing; customer_note?; return_url: window.location.origin
247
279
  });
248
280
  // res → { order_id, order_number, order_key, status, totals, order,
249
- // payment_instructions, // offline: { description, account_details } — render them
250
- // payment } // online: { status: "requires_payment", checkout_url, … } | null
281
+ // payment_instructions, // manual gateways: { description, account_details } — render them
282
+ // payment } // card: { status: "requires_payment", checkout_url, … } | null
251
283
 
252
284
  if (res.payment?.status === "requires_payment") window.location.href = res.payment.checkout_url;
253
- else showConfirmation(res); // offline order placed — show payment_instructions
285
+ else showConfirmation(res); // manual order placed — show payment_instructions
254
286
  ```
255
287
 
256
288
  Every payment link returns to **`/order-received`** — the page from §1 step 3. It is one call, idempotent, safe on every visit:
@@ -258,21 +290,24 @@ Every payment link returns to **`/order-received`** — the page from §1 step 3
258
290
  ```js
259
291
  // GET /order-received?order_id=…&order_key=…&payment=success|cancel
260
292
  const params = new URLSearchParams(window.location.search);
261
- const { state, order, payment_link } = await inv("commerce/payments", {
293
+ const { state, order, payment_link, payment_instructions } = await inv("commerce/payments", {
262
294
  action: "complete-return",
263
295
  order_id: params.get("order_id"), order_key: params.get("order_key"),
264
296
  payment: params.get("payment"), // only a hint — the server verifies with the provider
265
297
  return_url: window.location.origin,
266
298
  });
267
299
  // state === "paid" → thank-you + order summary (order is now marked paid)
268
- // state === "unpaid" → offer payment_link.url to try again
300
+ // state === "unpaid" → card order: offer payment_link.url to pay now;
301
+ // manual order: render payment_instructions ({ description, account_details })
269
302
  // state === "cancelled" → payment was cancelled — offer payment_link.url or support
270
303
  ```
271
304
 
305
+ Two shapes to get right when rendering: **`order` carries flat totals** — `order.total`, `order.shipping_total`, `order.total_tax` — there is **no `order.totals` object** (that nested shape belongs to the cart view and the place-order response's top-level `totals`); and `payment_link` is `{ url, reference } | null` (card orders only).
306
+
272
307
  **Carry forward:** `order_id` + `order_key` are the guest's proof of ownership — `commerce/storefront-account` `get-order` with both returns the order for a tracking page (signed-in customers get `my-orders` with no key).
273
308
 
274
309
  ---
275
310
 
276
311
  ## 4. Next
277
312
 
278
- Continue with the commerce skill — [`skills/commerce/SKILL.md`](./SKILL.md) — for day-2 work: UI changes, deeper storefront features ([`references/product-render.md`](./references/product-render.md) for what to render per view, [`references/storefront-product-page.md`](./references/storefront-product-page.md) for variant edge cases, [`references/reviews.md`](./references/reviews.md) for the ready-made reviews backend), Stripe wiring, scheduled maintenance, emails, webhooks, and operational limits.
313
+ Continue with the commerce skill — [`.agents/skills/commerce/SKILL.md`](./SKILL.md) — for day-2 work: UI changes, deeper storefront features ([`references/product-render.md`](./references/product-render.md) for what to render per view, [`references/storefront-product-page.md`](./references/storefront-product-page.md) for variant edge cases, [`references/reviews.md`](./references/reviews.md) for the ready-made reviews backend), payment provider wiring, scheduled maintenance, emails, webhooks, and operational limits.
@@ -4,20 +4,23 @@ Where to change what, in `src/commerce/admin/pages/products/`. The shape follows
4
4
  rule: **a product sells variants because it carries attributes** — there is no product
5
5
  type — so everything a customer actually buys is edited in one place.
6
6
 
7
- ## Tabs
7
+ ## Sections
8
8
 
9
- `components/ProductDataPanel.jsx` builds the list. There are four, and two are conditional:
9
+ `components/ProductDataPanel.jsx` stacks the sections one below the other — no side menu.
10
+ There are four, and one is conditional:
10
11
 
11
- | Tab | File | Holds |
12
+ | Section | File | Holds |
12
13
  |---|---|---|
13
- | **Price & Inventory** | `components/tabs/PriceInventoryTab.jsx` | Tax status/class and *limit to 1 per order* (product-wide) → the **Attributes** section → a row per purchasable thing |
14
+ | **Price & Inventory** | `components/tabs/PriceInventoryTab.jsx` | Tax status/group and *limit to 1 per order* (product-wide) → the **Attributes** section → a row per purchasable thing |
14
15
  | **Modifiers** | `components/tabs/ModifiersTab.jsx` | `meta_data` key/value pairs — descriptive properties (Material, Care, GTIN). They never affect price, stock or variants |
15
16
  | **Downloads** | `components/tabs/DownloadsTab.jsx` | Only when `downloadable` |
16
17
  | **Linked products** | `components/tabs/LinkedTab.jsx` | Upsells and cross-sells |
17
18
 
18
- `Virtual` and `Downloadable` are checkboxes in the panel header, not tabs. There is no
19
- General, Inventory, Variations, Attributes, Shipping, Advanced or External tab — all of
20
- those were removed, and `scripts/install.js` retires the files on upgrade.
19
+ `Virtual` and `Downloadable` are checkboxes in the panel header. The main column reads
20
+ Name → Description → Short description → Product data; the sidebar's **Publish** box has a
21
+ single **Visible** toggle driving `status` (publish/draft) — there is no catalog-visibility
22
+ field. The product's **Tax group** names a group defined per Shipping & Tax Location
23
+ (default `Products`).
21
24
 
22
25
  ## Price & Inventory
23
26
 
@@ -25,9 +28,9 @@ those were removed, and `scripts/install.js` retires the files on upgrade.
25
28
  edits the product's own fields. A product with attributes shows a row per variant.
26
29
  Both go through the same `PriceFields` / inventory / `ShippingFields` / `DownloadsFields`
27
30
  components, so there is one definition to change, not two.
28
- - **Shipping is per row.** Weight, dimensions and shipping class live on each variant (a
29
- large mug weighs more than a small one); the Base price row edits the product's own.
30
- Hidden when the product is virtual.
31
+ - **Shipping is per row.** Weight and dimensions live on each variant (a large mug weighs
32
+ more than a small one); the Base price row edits the product's own. Hidden when the
33
+ product is virtual.
31
34
  - **The parent price is not editable** when variants exist — `commerce/admin-products`
32
35
  derives `regular_price`, `price` and `on_sale` from the cheapest publishable variant on
33
36
  save, and the seeder does the same. Don't add an input for it.
@@ -52,5 +55,5 @@ merchant touches anything. Keep that guard if you touch the effect.
52
55
 
53
56
  Attribute and value *records* are shared between products, so the **Manage attributes**
54
57
  dialog saves them immediately, unlike the rest of the form which batches until Save. There
55
- are no Attributes or Tags pages in the side menu: attributes are reachable only from here,
56
- tags from the `TaxonomyPanel` card in the product sidebar.
58
+ are no Attributes or Ribbons pages in the side menu: attributes are reachable only from
59
+ here, ribbons from the `TaxonomyPanel` card in the product sidebar.
@@ -1,10 +1,10 @@
1
1
  # Emails
2
2
 
3
- Transactional email is sent via `base44.integrations.Core.SendEmail` from the shared `emails.ts`. Ten order emails are wired to the lifecycle (see the side-effect matrix in [`skills/commerce/docs/api-admin.md`](../docs/api-admin.md)): `new_order`, `cancelled_order`, `failed_order`, `on_hold_order`, `processing_order`, `completed_order`, `refunded_order`, `partial_refund`, `customer_invoice`, `customer_note`. `reset_password` and `new_account` are handled by **Base44 auth**, not this template.
3
+ Transactional email is sent via `base44.integrations.Core.SendEmail` from the shared `emails.ts`. Ten order emails are wired to the lifecycle (see the side-effect matrix in [`.agents/skills/commerce/docs/api-admin.md`](../docs/api-admin.md)): `new_order`, `cancelled_order`, `failed_order`, `on_hold_order`, `processing_order`, `completed_order`, `refunded_order`, `partial_refund`, `customer_invoice`, `customer_note`. `reset_password` and `new_account` are handled by **Base44 auth**, not this template.
4
4
 
5
5
  - Per-type enable/subject/heading/recipient/additional_content overrides live at the **top level** of the `emails` StoreSettings group, keyed by type id (`emails.new_order.enabled`) — that is the path `shared/commerce/emails.ts` reads. Editable in Settings → Emails; blank = built-in default. Don't nest them under a sub-object: the backend won't see them.
6
6
  - Admin notifications (`new_order`, `cancelled_order`, `failed_order`) resolve in three steps: the per-type `recipient` if it holds an address, else `emails.admin_recipients`, else — at send time — **the app's users with `role: "admin"`** (`sr.entities.User.filter({role:"admin"})`, memoized per isolate). Both settings accept a comma-separated list and drop blank entries, so a cleared per-type override falls back rather than sending to an empty address. Nothing is seeded: a fresh store notifies its admin users until someone sets explicit recipients, and promoting an admin is enough to add them. Only when there is also no admin user does the email go nowhere, logged in `commerce.EmailLog` with `success: false` and an `error` naming what was empty. Settings → Emails shows the addresses actually in effect (via `commerce/admin-tools` `admin-email-recipients`).
7
- - Stock notifications follow the same chain: `inventory.notification_recipient`, else the first `emails.admin_recipients` entry, else the first admin user.
7
+ - Stock notifications (`low_stock`, `out_of_stock`) sit in the same Settings → Emails list and are configured per type like the rest (`emails.low_stock.enabled`, `emails.low_stock.recipient`). Recipients resolve exactly like the admin order emails: per-type `recipient`, else `emails.admin_recipients`, else the app's admin users — comma-separated lists reach every address. Subject and body are generated (product name, SKU, remaining stock), so there are no subject/heading overrides for these two.
8
8
  - The **store name** is `emails.store_name` — one setting, used for both the `{store_name}` in subjects/headings and the sender name. It lives nowhere else, but it is not email-only: `storefrontSafeSettings()` publishes it as `settings.store_name` on `get-store-info`, so it is also the public shop name. `commerce/seed-store` fills it from the `store_name` it is passed, and Settings → Emails is where a merchant changes it.
9
9
  - It seeds **blank on purpose**: a blank name means `SendEmail` is called without `from_name`, in which case **Base44 sends as the app's name** (verified: an app named *Canvas* with it blank delivered mail from "Canvas"). So the sender is the app name unless the store overrides it — one place to rename, and no hardcoded default.
10
10
  - That fallback does **not** reach subjects or headings: those are rendered by this template, which has no access to the app name, so a store with no `emails.store_name` sends `New order #1002` rather than `[Canvas]: New order #1002`. Set the store name if you want it in the subject line.
@@ -12,7 +12,7 @@
12
12
  The storefront functions run as the service role, so RLS is not protecting the caller from itself — these guards are. Keep them when you extend a function, and follow them in a new one.
13
13
 
14
14
  - **Identity comes from the session, never from the body.** An email in the payload is a claim, not a credential. Use `getCallerUser(base44)` and `requireUser(user)` from `shared/commerce/auth.ts`, and `ownsEmail(user, email)` before writing to anything keyed on someone's address. This is why `submit-review` requires a signed-in customer (a `reviewer_email` field is ignored) and why a guest checkout attaches to an existing `commerce.Customer` without rewriting its saved name and addresses — otherwise knowing a customer's email would be enough to redirect where their next order ships, or to post a review in their name.
15
- - **Anything a person owns needs authentication, not just an email.** If you add reviews, wishlists, loyalty, saved payment details, subscriptions or support tickets, gate the write on `requireUser` and derive the owner from `user.email` / `user.id`. Guest access is only ever by bearer token (`cart_token`, `order_key`) for the *one* record that token names.
16
- - **Only the payment provider can say an order is paid.** Never transition an order to `processing` because a request said so — no `paid: true` flag, no `transaction_id` from a client, no `?payment=success` in a return URL. Go through `confirmOnlinePayment()`, which asks the provider and checks the session was opened for that order. Adding a gateway means writing an adapter (see [`online-payments.md`](./online-payments.md)), not a new "confirm" endpoint.
15
+ - **Anything a person owns needs authentication, not just an email.** If you add wishlists, loyalty, saved payment details, subscriptions or support tickets, gate the write on `requireUser` and derive the owner from `user.email` / `user.id`. Guest access is only ever by bearer token (`cart_token`, `order_key`) for the *one* record that token names. (Reviews are the deliberate exception — public by email, moderated instead of authenticated; a session email still always beats the payload.)
16
+ - **Only the payment provider can say an order is paid.** Never transition an order to `processing` because a request said so — no `paid: true` flag, no `transaction_id` from a client, no `?payment=success` in a return URL. Go through `confirmCardPayment()`, which asks the provider about the payment reference stored on the order. Wiring a provider means implementing the two payment files (see [`online-payments.md`](./online-payments.md)), never a new "confirm" endpoint.
17
17
  - **A bearer token authorizes one record.** `order_key` gets you *that* order; it is not a licence to name someone else's ids in the same request. Match every id in the payload back to the record the token opened.
18
18
  - **Don't return more than the caller asked about.** Serialize customer-facing orders through `serializeOrderForCustomer()`, and don't let a response reveal whether another person's email exists, bought something, or has an account — a boolean in an error body is an enumeration oracle.