@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.
- package/README.md +11 -11
- package/base44/agents/commerce/StoreAdmin.jsonc +2 -2
- package/base44/entities/commerce.Cart.jsonc +1 -1
- package/base44/entities/commerce.Coupon.jsonc +5 -0
- package/base44/entities/commerce.Order.jsonc +6 -7
- package/base44/entities/commerce.OrderRefund.jsonc +1 -1
- package/base44/entities/commerce.PaymentGateway.jsonc +1 -1
- package/base44/entities/commerce.Product.jsonc +6 -16
- package/base44/entities/{commerce.ProductTag.jsonc → commerce.ProductRibbon.jsonc} +2 -2
- package/base44/entities/commerce.ProductVariation.jsonc +1 -9
- package/base44/entities/commerce.ShippingTaxLocation.jsonc +85 -0
- package/base44/entities/commerce.Webhook.jsonc +1 -1
- package/base44/functions/commerce/admin-orders/helpers.ts +7 -13
- package/base44/functions/commerce/admin-products/entry.ts +11 -17
- package/base44/functions/commerce/admin-refunds/entry.ts +10 -9
- package/base44/functions/commerce/admin-reports/entry.ts +3 -3
- package/base44/functions/commerce/admin-tools/entry.ts +9 -36
- package/base44/functions/commerce/payment-webhook/entry.ts +50 -89
- package/base44/functions/commerce/payments/entry.ts +46 -42
- package/base44/functions/commerce/seed-store/defaults.ts +35 -42
- package/base44/functions/commerce/seed-store/entry.ts +84 -31
- package/base44/functions/commerce/seed-store/sample-data.ts +2 -15
- package/base44/functions/commerce/seed-store/seed-catalog.ts +105 -55
- package/base44/functions/commerce/storefront-cart/cart-pricing.ts +6 -11
- package/base44/functions/commerce/storefront-cart/entry.ts +36 -2
- package/base44/functions/commerce/storefront-catalog/entry.ts +55 -72
- package/base44/functions/commerce/storefront-checkout/cart-pricing.ts +6 -11
- package/base44/functions/commerce/storefront-checkout/entry.ts +43 -56
- package/base44/shared/commerce/card-payment.ts +80 -0
- package/base44/shared/commerce/coupons.ts +16 -10
- package/base44/shared/commerce/emails.ts +30 -18
- package/base44/shared/commerce/money.ts +11 -24
- package/base44/shared/commerce/payments.ts +55 -286
- package/base44/shared/commerce/scan.ts +1 -1
- package/base44/shared/commerce/sequence.ts +1 -1
- package/base44/shared/commerce/settings.ts +4 -9
- package/base44/shared/commerce/shipping.ts +65 -133
- package/base44/shared/commerce/tax.ts +48 -96
- package/base44/shared/commerce/totals.ts +77 -91
- package/package.json +1 -1
- package/scripts/install.js +28 -7
- package/skills/commerce/SKILL.md +14 -14
- package/skills/commerce/docs/api-admin.md +23 -26
- package/skills/commerce/docs/api-storefront.md +67 -61
- package/skills/commerce/installation-guidelines.md +8 -8
- package/skills/commerce/post-installation.md +76 -41
- package/skills/commerce/references/admin-product-form.md +15 -12
- package/skills/commerce/references/emails.md +2 -2
- package/skills/commerce/references/guest-access-security.md +2 -2
- package/skills/commerce/references/online-payments.md +24 -191
- package/skills/commerce/references/product-render.md +18 -18
- package/skills/commerce/references/reviews.md +14 -8
- package/skills/commerce/references/storefront-product-page.md +1 -1
- package/src/commerce/admin/README.md +4 -5
- package/src/commerce/admin/bot/Markdown.jsx +1 -1
- package/src/commerce/admin/hooks/useMoney.js +13 -22
- package/src/commerce/admin/layout/AuthGuard.jsx +1 -1
- package/src/commerce/admin/lib/constants.js +2 -29
- package/src/commerce/admin/lib/order-utils.js +1 -1
- package/src/commerce/admin/pages/coupons/CouponEditor.jsx +131 -140
- package/src/commerce/admin/pages/coupons/CouponsList.jsx +14 -7
- package/src/commerce/admin/pages/orders/OrderEditor.jsx +3 -3
- package/src/commerce/admin/pages/orders/components/PaymentPanel.jsx +17 -28
- package/src/commerce/admin/pages/orders/components/RefundPanel.jsx +5 -11
- package/src/commerce/admin/pages/products/Categories.jsx +147 -177
- package/src/commerce/admin/pages/products/ProductEditor.jsx +23 -18
- package/src/commerce/admin/pages/products/Reviews.jsx +37 -1
- package/src/commerce/admin/pages/products/components/ProductDataPanel.jsx +33 -47
- package/src/commerce/admin/pages/products/components/PublishBox.jsx +13 -34
- package/src/commerce/admin/pages/products/components/TaxonomyPanel.jsx +29 -29
- package/src/commerce/admin/pages/products/components/tabs/PriceInventoryTab.jsx +14 -44
- package/src/commerce/admin/pages/reports/Reports.jsx +2 -2
- package/src/commerce/admin/pages/settings/EmailsSettings.jsx +101 -68
- package/src/commerce/admin/pages/settings/GeneralSettings.jsx +40 -38
- package/src/commerce/admin/pages/settings/InventorySettings.jsx +1 -41
- package/src/commerce/admin/pages/settings/LocationEditor.jsx +377 -0
- package/src/commerce/admin/pages/settings/PaymentsSettings.jsx +137 -119
- package/src/commerce/admin/pages/settings/SettingsLayout.jsx +2 -4
- package/src/commerce/admin/pages/settings/ShippingTaxSettings.jsx +191 -0
- package/src/commerce/admin/routes.jsx +6 -12
- package/src/commerce/utils/index.js +2 -2
- package/src/commerce/utils/shipping-promos.js +45 -49
- package/src/commerce/utils/variants.js +1 -1
- package/base44/entities/commerce.ShippingClass.jsonc +0 -30
- package/base44/entities/commerce.ShippingZone.jsonc +0 -41
- package/base44/entities/commerce.ShippingZoneMethod.jsonc +0 -84
- package/base44/entities/commerce.TaxClass.jsonc +0 -23
- package/base44/entities/commerce.TaxRate.jsonc +0 -68
- package/base44/shared/commerce/stripe.ts +0 -463
- package/src/commerce/admin/hooks/usePaymentProvider.js +0 -27
- package/src/commerce/admin/pages/settings/ProductsSettings.jsx +0 -118
- package/src/commerce/admin/pages/settings/ShippingSettings.jsx +0 -304
- package/src/commerce/admin/pages/settings/ShippingZoneEditor.jsx +0 -514
- package/src/commerce/admin/pages/settings/TaxRatesTable.jsx +0 -231
- 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
|
|
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)
|
|
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`, `
|
|
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
|
-
|
|
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
|
-
|
|
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/
|
|
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 }, "
|
|
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 }, "
|
|
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.
|
|
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` —
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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 `
|
|
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,
|
|
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 —
|
|
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
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
}
|
|
194
|
-
|
|
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":
|
|
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
|
|
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 "
|
|
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
|
-
|
|
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
|
|
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, //
|
|
250
|
-
// payment } //
|
|
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); //
|
|
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
|
|
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 — [
|
|
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
|
-
##
|
|
7
|
+
## Sections
|
|
8
8
|
|
|
9
|
-
`components/ProductDataPanel.jsx`
|
|
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
|
-
|
|
|
12
|
+
| Section | File | Holds |
|
|
12
13
|
|---|---|---|
|
|
13
|
-
| **Price & Inventory** | `components/tabs/PriceInventoryTab.jsx` | Tax status/
|
|
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
|
|
19
|
-
|
|
20
|
-
|
|
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
|
|
29
|
-
|
|
30
|
-
|
|
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
|
|
56
|
-
|
|
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 [
|
|
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
|
|
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
|
|
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 `
|
|
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.
|