@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
|
@@ -14,10 +14,9 @@ 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.
|
|
17
|
+
| commerce.ProductCategory, commerce.ProductRibbon | direct | **direct CRUD**, or `commerce/admin-products` `save-term`/`delete-term`/`list-terms` (the API/agent path) | category slug uniqueness; ribbon get-or-create by name |
|
|
18
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
|
-
| commerce.
|
|
20
|
-
| commerce.TaxClass, commerce.TaxRate, commerce.ShippingZone, commerce.ShippingZoneMethod, commerce.PaymentGateway | direct | **direct CRUD** | config; consumed by the pricing engine at read time |
|
|
19
|
+
| commerce.ShippingTaxLocation, commerce.PaymentGateway | direct | **direct CRUD** | config; consumed by the pricing engine at read time |
|
|
21
20
|
| commerce.StoreSettings | direct | **direct CRUD** (one record per `group_id`) | grouped config |
|
|
22
21
|
| commerce.Webhook | direct | **direct CRUD** (+ `commerce/admin-webhooks` for test/redeliver) | definition is data; dispatch is engine |
|
|
23
22
|
| commerce.WebhookDelivery, commerce.EmailLog | direct (read-only logs) | written by the engine | audit logs |
|
|
@@ -45,26 +44,26 @@ Success: `{ success: true, data }`. Failure: `{ success: false, error, code }` w
|
|
|
45
44
|
Actions: `save` · `delete` · `batch` · `duplicate` · `set-stock` · `search` · `save-term` · `delete-term` · `list-terms`
|
|
46
45
|
|
|
47
46
|
- **`save`** — `{ product, variations? }`. Upserts the product (create if no `id`); when `variations` is provided, diffs them (create/update/delete-missing).
|
|
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/
|
|
47
|
+
> **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/ribbon `count`; rolls parent stock **and price** up when the product has attributes; fires `product.created`/`product.updated`. → `{ product, variations }`.
|
|
49
48
|
- **`delete`** — `{ id }`. Cascades variations, decrements counts, fires `product.deleted`.
|
|
50
49
|
- **`batch`** — `{ create?: [], update?: [], delete?: [] }` (≤100 total) → per-item results.
|
|
51
50
|
- **`duplicate`** — `{ id }` → new draft copy (name "(Copy)", suffixed SKU, reset sales/ratings) incl. variations.
|
|
52
51
|
- **`set-stock`** — `{ id, variation_id?, quantity }`. Sets quantity, re-derives status, sends low/out-of-stock admin emails on threshold crossings.
|
|
53
52
|
- **`search`** — `{ q?, category_id?, stock_status?, status?, sort?, limit?, skip? }` → `{ rows, has_next }`. `category_id` includes descendant categories.
|
|
54
|
-
- **`save-term`** — `{ taxonomy, term }` → the term. Upserts one taxonomy record. `taxonomy` is `"category"` | `"
|
|
53
|
+
- **`save-term`** — `{ taxonomy, term }` → the term. Upserts one taxonomy record. `taxonomy` is `"category"` | `"ribbon"` | `"attribute"` | `"attribute-term"`, and `term` takes the fields for that one:
|
|
55
54
|
|
|
56
55
|
| taxonomy | entity | `term` fields |
|
|
57
56
|
|---|---|---|
|
|
58
57
|
| `category` | commerce.ProductCategory | `id?, name, slug?, description?, parent_id?, image?, menu_order?` |
|
|
59
|
-
| `
|
|
58
|
+
| `ribbon` | commerce.ProductRibbon | `id?, name` |
|
|
60
59
|
| `attribute` | commerce.ProductAttribute | `id?, name, code?, order?` |
|
|
61
60
|
| `attribute-term` | commerce.ProductAttributeTerm | `id?, attribute_id` (**required**, must exist)`, name, order?` |
|
|
62
61
|
|
|
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 **
|
|
62
|
+
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 **ribbon** is get-or-create: a name that already exists case-insensitively returns the existing record instead of splitting the ribbon 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
63
|
|
|
65
|
-
This is how a non-UI caller (notably the StoreAdmin agent) creates the records that `category_ids`/`
|
|
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
|
|
67
|
-
- **`list-terms`** — `{ taxonomy, q?, attribute_id?, limit?, skip? }` → `{ rows, has_next }`. Categories sort by `menu_order`, attributes and attribute values by `order`,
|
|
64
|
+
This is how a non-UI caller (notably the StoreAdmin agent) creates the records that `category_ids`/`ribbon_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.)
|
|
65
|
+
- **`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 ribbon, 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).
|
|
66
|
+
- **`list-terms`** — `{ taxonomy, q?, attribute_id?, limit?, skip? }` → `{ rows, has_next }`. Categories sort by `menu_order`, attributes and attribute values by `order`, ribbons by `name`; `attribute_id` filters values to one attribute. Use it to reuse an existing record instead of creating a duplicate.
|
|
68
67
|
|
|
69
68
|
## commerce/admin-orders
|
|
70
69
|
|
|
@@ -109,14 +108,14 @@ Applied by the transition engine; each effect is flag-guarded so a re-entered st
|
|
|
109
108
|
|
|
110
109
|
Actions: `create` · `delete`
|
|
111
110
|
|
|
112
|
-
- **`create`** — `{ order_id, amount, reason?, line_items?, restock_items?, refund_payment? }`. `line_items` specs: `{ line_id, quantity, refund_total, refund_tax? }`. Validates `amount` ≤ remaining refundable (`400 amount_exceeds_refundable`, `400 invalid_amount`). Restocks per line when `restock_items`. Updates `order.total_refunded`; a full refund transitions the order to `refunded`, otherwise sends `partial_refund` + `order.updated`. With **`refund_payment: true`** the money goes back through the payment provider **first** — a failed provider refund writes nothing locally, so there is never a phantom refund — and the response carries `gateway_refund: {
|
|
111
|
+
- **`create`** — `{ order_id, amount, reason?, line_items?, restock_items?, refund_payment? }`. `line_items` specs: `{ line_id, quantity, refund_total, refund_tax? }`. Validates `amount` ≤ remaining refundable (`400 amount_exceeds_refundable`, `400 invalid_amount`). Restocks per line when `restock_items`. Updates `order.total_refunded`; a full refund transitions the order to `refunded`, otherwise sends `partial_refund` + `order.updated`. With **`refund_payment: true`** the money goes back through the payment provider **first** — a failed provider refund writes nothing locally, so there is never a phantom refund — and the response carries `gateway_refund: { refund_id }` with the record's `refunded_payment: true`. An order that wasn't paid by card rejects it with `400 no_card_payment`; while the store's `card-payment.ts` refund stub is unimplemented it answers `501 card_refund_not_implemented` (record the refund without `refund_payment` instead). → `{ refund, order, gateway_refund? }`.
|
|
113
112
|
- **`delete`** — `{ refund_id }`. Reverses `total_refunded`; adds a note warning that restocked items are **not** auto-un-restocked.
|
|
114
113
|
|
|
115
114
|
## commerce/admin-coupons
|
|
116
115
|
|
|
117
116
|
Actions: `save` · `delete` · `batch` · `search`
|
|
118
117
|
|
|
119
|
-
- **`save`** — `{ coupon }`. Lowercases + enforces unique `code` (`duplicate_code`); percent amount ≤ 100. Fires `coupon.created`/`updated`.
|
|
118
|
+
- **`save`** — `{ coupon }`. Lowercases + enforces unique `code` (`duplicate_code`); percent amount ≤ 100. `enabled: false` disables the coupon — it can't be applied and carts holding it drop it on the next re-price (validation code `disabled`). Fires `coupon.created`/`updated`.
|
|
120
119
|
- **`delete`** — `{ id }` (fires `coupon.deleted`) · **`batch`** — `{ create?, update?, delete? }` · **`search`** — `{ q?, limit?, skip? }` → `{ rows, has_next }` (matches code/description).
|
|
121
120
|
|
|
122
121
|
## commerce/admin-customers
|
|
@@ -142,7 +141,7 @@ Actions: `test` · `redeliver` (webhook definitions themselves are direct `comme
|
|
|
142
141
|
|
|
143
142
|
## commerce/admin-reports
|
|
144
143
|
|
|
145
|
-
All actions scan orders on demand (counted = `date_paid` set, or status `processing`/`completed`). See the commerce skill reference
|
|
144
|
+
All actions scan orders on demand (counted = `date_paid` set, or status `processing`/`completed`). See the commerce skill reference `.agents/skills/commerce/references/limits-and-performance.md` for scaling.
|
|
146
145
|
|
|
147
146
|
| Action | Payload | Returns |
|
|
148
147
|
|---|---|---|
|
|
@@ -155,17 +154,16 @@ All actions scan orders on demand (counted = `date_paid` set, or status `process
|
|
|
155
154
|
| `customers-totals` | — | `{ total, guests, registered, paying }` |
|
|
156
155
|
| `coupons-totals` | — | `{ total, by: { [discount_type]: count } }` |
|
|
157
156
|
| `reviews-totals` | — | `{ total, by: { [status]: count } }` |
|
|
158
|
-
| `categories-totals` / `
|
|
157
|
+
| `categories-totals` / `ribbons-totals` | — | `{ total, terms: [{ id, name, count }] }` |
|
|
159
158
|
| `attributes-totals` | — | `{ total, attributes: [{ id, name, terms }] }` |
|
|
160
159
|
|
|
161
160
|
## commerce/admin-tools
|
|
162
161
|
|
|
163
|
-
Actions: `status` · `
|
|
162
|
+
Actions: `status` · `admin-email-recipients` · `recount-terms` · `recount-coupon-usage` · `recalculate-customer-stats-all` · `prune-webhook-deliveries` · `clear-abandoned-carts` · `regenerate-download-permissions`
|
|
164
163
|
|
|
165
|
-
- **`status`** — `{ template_version, seeded, settings_groups, counts: { "commerce.Product": n | "1000+", ... }, checks: { has_payment_gateways,
|
|
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.)
|
|
164
|
+
- **`status`** — `{ template_version, seeded, settings_groups, counts: { "commerce.Product": n | "1000+", ... }, checks: { has_payment_gateways, has_default_location } }` — `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.
|
|
167
165
|
- **`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.
|
|
168
|
-
- **`recount-terms`** — repairs category/
|
|
166
|
+
- **`recount-terms`** — repairs category/ribbon/term `count`.
|
|
169
167
|
- **`recount-coupon-usage`** — repairs `usage_count`/`used_by`.
|
|
170
168
|
- **`recalculate-customer-stats-all`** — repairs all customers' `orders_count`/`total_spent`.
|
|
171
169
|
- **`prune-webhook-deliveries`** — `{ keep_days }`.
|
|
@@ -174,19 +172,18 @@ Actions: `status` · `payment-connector-status` · `admin-email-recipients` · `
|
|
|
174
172
|
|
|
175
173
|
## commerce/payments
|
|
176
174
|
|
|
177
|
-
Actions: `
|
|
175
|
+
Actions: `create-link` · `complete-return` · `verify` — the admin side of online payments (the same function serves customers, who authorize with `order_key` instead; see [`api-storefront.md`](./api-storefront.md)).
|
|
178
176
|
|
|
179
|
-
- **`
|
|
180
|
-
- **`create-link`** — `{ order_id }` → `{ provider, session_id, url, expires_at? }`: a provider-hosted payment page for an unpaid order, i.e. the **payment link** to send a customer. Accepts an order with **any** payment method (including none, as admin-created orders start) and switches it onto the online gateway, logging the change — refunds key off `payment_method`, so this keeps the order honest about how it was paid. `409 already_paid`, `400 online_payments_disabled`, `503 payment_provider_unavailable`.
|
|
177
|
+
- **`create-link`** — `{ order_id }` → `{ url, reference }`: a provider-hosted payment page for an unpaid order, i.e. the **payment link** to send a customer. Accepts an order with **any** payment method (including none, as admin-created orders start) and switches it onto the card gateway, logging the change — refunds key off `payment_method`, so this keeps the order honest about how it was paid. `409 already_paid`, `400 card_payments_disabled`, `503 no_card_payment_provider`.
|
|
181
178
|
- **`verify`** — `{ order_id }` → `{ paid, already_confirmed, status, order }`: re-asks the provider and moves the order to `processing` when the money is there. Idempotent.
|
|
182
|
-
- **`complete-return`** — `{ order_id, order_key, payment?, return_url? }` → `{ state, paid, already_confirmed, status, order, payment_link }`: what the storefront's **mandatory** `/order-received` page calls — confirm, progress the order, and hand back a render-ready result plus a fresh payment link while unpaid.
|
|
179
|
+
- **`complete-return`** — `{ order_id, order_key, payment?, return_url? }` → `{ state, paid, already_confirmed, status, order, payment_link, payment_instructions }`: what the storefront's **mandatory** `/order-received` page calls — confirm, progress the order, and hand back a render-ready result plus a fresh payment link while unpaid (card) or the gateway's payment instructions (manual).
|
|
183
180
|
|
|
184
|
-
`commerce/payment-webhook` is the provider's
|
|
181
|
+
`commerce/payment-webhook` is the provider's server-to-server callback (raw body, no `action` envelope). Its `parseWebhook` is one of the two files to implement when wiring a provider; until then it answers `400 webhook_not_implemented`. See [`../references/online-payments.md`](../references/online-payments.md).
|
|
185
182
|
|
|
186
183
|
## commerce/seed-store
|
|
187
184
|
|
|
188
|
-
Not action-routed. Body `{ store_name?, with_sample_data?, products?, coupons?,
|
|
185
|
+
Not action-routed. Body `{ store_name?, currency?, weight_unit?, dimension_unit?, with_sample_data?, products?, coupons?, locations? }`. **`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. **`currency`** (ISO code, validated against the shared currencies table) and **`weight_unit`/`dimension_unit`** set the `general` group; unknown values fail as **400** `invalid_payload`. Unlike `store_name` they **always win** — there is no blank state to distinguish a merchant's choice from the seeded default, so passing one on a re-run updates the store. Prices are formatted with `Intl.NumberFormat` — the currency is a value, there are no format settings.
|
|
189
186
|
|
|
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/
|
|
187
|
+
**`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/ribbons/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/ribbon 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/locations. `coupons` is a thin passthrough (code lowercased, skip-if-exists); **`locations`** creates Shipping & Tax Locations (`{ name, countries?: ["IL"], shipping_rates: [{ name, cost, free_over? }], tax_groups?: [{ name, rates: [{ name, rate }] }], shipping_tax?: { type: "percent"|"fixed", value } }` — the default `Products` tax group is added when missing; skip-if-exists by name). Passing any `locations` suppresses the seeded "Rest of the world" fallback — yours become the store's only locations; without them the free-shipping fallback is seeded. `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
188
|
|
|
192
|
-
→ `{ seeded: { settings_groups, gateways,
|
|
189
|
+
→ `{ seeded: { settings_groups, gateways, locations }, sample_data: {...} | false, catalog: { categories|ribbons|attributes|terms: { created, reused }, products_created, products_skipped, variations_created, coupons, locations, 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" }, currency: { value, action: "created" | "updated" | "unchanged" } | null }`.
|
|
@@ -12,7 +12,7 @@ Read this list before writing any of it. Each item is a hard requirement enforce
|
|
|
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 |
|
|
15
|
-
| 5 | **Only advertise offers the store is configured for** (free-shipping thresholds come from
|
|
15
|
+
| 5 | **Only advertise offers the store is configured for** (free-shipping thresholds come from a location's shipping rates) | nothing — this one is on you |
|
|
16
16
|
|
|
17
17
|
Four public functions: **`commerce/storefront-catalog`**, **`commerce/storefront-cart`**, **`commerce/storefront-checkout`**, **`commerce/storefront-account`**. All are invoked the same way and return the same envelope.
|
|
18
18
|
|
|
@@ -22,21 +22,21 @@ 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),
|
|
26
|
-
| **Rich product pages** | galleries, per-variant price/stock/image, categories,
|
|
27
|
-
| **Taxonomy navigation** | category tree, **
|
|
28
|
-
| **Customer reviews** | paginated reviews per product with **average rating + rating count**, `verified` owner flag, and **review submission by
|
|
25
|
+
| **Product discovery** | full-text `search`, filter by category (incl. descendants), ribbon, 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, ribbons, **upsells**, **cross-sells** | [`get-product`](#get-product) |
|
|
27
|
+
| **Taxonomy navigation** | category tree, **ribbon list with counts**, attributes + terms for filter UIs | [`list-categories`](#list-categories), [`list-ribbons`](#list-ribbons), [`list-attributes`](#list-attributes) |
|
|
28
|
+
| **Customer reviews** | paginated reviews per product with **average rating + rating count**, `verified` owner flag, and **review submission by anyone with an email address** (no login); 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) |
|
|
30
30
|
| **Coupons** | apply/remove by code with full server-side validation (eligibility, limits, per-user usage) | [`apply-coupon`](#commercestorefront-cart) |
|
|
31
|
-
| **Shipping** | address → matched
|
|
32
|
-
| **Tax** | inclusive/exclusive pricing, per-
|
|
31
|
+
| **Shipping** | address → matched location → **selectable shipping rates with live costs**, free-over thresholds | [`set-shipping-address`](#commercestorefront-cart) |
|
|
32
|
+
| **Tax** | inclusive/exclusive pricing, per-location tax groups, itemized display — all resolved server-side | [`get-store-info`](#get-store-info) |
|
|
33
33
|
| **Stock states** | in stock / out of stock / **on backorder**, low-stock signalling, configurable out-of-stock hiding | [`list-products`](#list-products) |
|
|
34
34
|
| **Checkout** | guest or authenticated, billing/shipping, per-gateway routing, **payment instructions** for manual methods, order cancel, post-payment confirm | [`commerce/storefront-checkout`](#commercestorefront-checkout) |
|
|
35
35
|
| **Accounts** | order history, saved billing/shipping addresses, and **guest order tracking by `order_key`** with customer-visible order notes | [`commerce/storefront-account`](#commercestorefront-account) |
|
|
36
36
|
| **Digital products** | entitlement-checked downloads with remaining-count, expiry, and signed URLs for private files | [`get-download`](#commercestorefront-account) |
|
|
37
|
-
| **Store config** | currency
|
|
37
|
+
| **Store config** | currency (format it with `Intl.NumberFormat`), units, catalog/cart price display — read it and honour it instead of hardcoding | [`get-store-info`](#get-store-info) |
|
|
38
38
|
|
|
39
|
-
Two things that are **not** in the backend and are yours to build: the visitor UI itself, and
|
|
39
|
+
Two things that are **not** in the backend and are yours to build: the visitor UI itself, and the payment provider integration — two files to implement (see [`references/online-payments.md`](../references/online-payments.md)). Payment methods and the currency are admin-owned data — always render them from `get-store-info` rather than a hardcoded list, and format prices with `Intl.NumberFormat(undefined, { style: "currency", currency })`.
|
|
40
40
|
|
|
41
41
|
## Conventions
|
|
42
42
|
|
|
@@ -62,31 +62,32 @@ Bootstrap data for a storefront. No payload.
|
|
|
62
62
|
**Response:**
|
|
63
63
|
```json
|
|
64
64
|
{
|
|
65
|
-
"settings": { "store_name": "Acme", "currency": "USD",
|
|
66
|
-
"thousand_sep": ",", "decimal_sep": ".", "num_decimals": 2,
|
|
65
|
+
"settings": { "store_name": "Acme", "currency": "USD",
|
|
67
66
|
"weight_unit": "kg", "dimension_unit": "cm",
|
|
68
67
|
"prices_include_tax": false, "display_prices_shop": "excl", "display_prices_cart": "excl",
|
|
69
|
-
"
|
|
68
|
+
"hide_out_of_stock": false },
|
|
70
69
|
"payment_gateways": [ { "slug": "offline", "title": "Offline payment", "description": "...", "online": false },
|
|
71
|
-
{ "slug": "
|
|
70
|
+
{ "slug": "card", "title": "Credit card", "description": "...", "online": true } ],
|
|
72
71
|
"countries": [ { "code": "US", "name": "United States", "states": [ { "code": "CA", "name": "California" } ] } ],
|
|
73
72
|
"currencies": [ { "code": "USD", "name": "US Dollar", "symbol": "$", "decimals": 2 } ]
|
|
74
73
|
}
|
|
75
74
|
```
|
|
76
75
|
`settings` is a safe projection — only display/behavior keys, never admin config.
|
|
77
76
|
|
|
77
|
+
`payment_gateways` is **every gateway the admin has enabled** (sorted by their `order`) — the Payments settings switch is the single source of truth. `online: true` marks the card option (`place-order` answers with a payment page to redirect to; `503 no_card_payment_provider` while the store has no provider implemented); everything else settles manually.
|
|
78
|
+
|
|
78
79
|
### `list-products`
|
|
79
|
-
**Payload** (all optional): `search`, `category_id` (includes descendants), `
|
|
80
|
+
**Payload** (all optional): `search`, `category_id` (includes descendants), `ribbon_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).
|
|
80
81
|
|
|
81
|
-
Only `status: "publish"` products are returned
|
|
82
|
+
Only `status: "publish"` products are returned — the admin's single **Visible** toggle. `inventory.hide_out_of_stock` (or `in_stock_only`) drops out-of-stock products.
|
|
82
83
|
|
|
83
84
|
**Response:** `{ "products": [Product...], "page": 1, "per_page": 12, "has_next": true }`
|
|
84
85
|
|
|
85
|
-
Each row is the product record (minus paywalled fields) **plus a resolved `
|
|
86
|
+
Each row is the product record (minus paywalled fields) **plus a resolved `ribbons` array** (`[{ id, name }]`), so cards can show their ribbons without a second call. `categories` are **not** resolved on rows — `category_ids` only.
|
|
86
87
|
|
|
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,
|
|
88
|
+
> **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, ribbons 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.
|
|
88
89
|
|
|
89
|
-
**Filters stack.** `category_id`, `
|
|
90
|
+
**Filters stack.** `category_id`, `ribbon_id`, `attribute_id` + `attribute_term`, `min_price`/`max_price`, `on_sale`, `featured` and `in_stock_only` are ANDed, so "Dresses + gift + on sale" is one request. Build the controls from [`list-categories`](#list-categories), [`list-ribbons`](#list-ribbons) (its `count` gives you "Gift (12)") and [`list-attributes`](#list-attributes), and mirror active filters into the URL so a filtered listing is shareable and survives reload.
|
|
90
91
|
|
|
91
92
|
### `get-product`
|
|
92
93
|
**Payload:** `{ id }` **or** `{ slug }`; optional `reviews_page` (1), `reviews_per_page` (10, max 50).
|
|
@@ -97,7 +98,7 @@ Each row is the product record (minus paywalled fields) **plus a resolved `tags`
|
|
|
97
98
|
"product": { Product },
|
|
98
99
|
"variations": [ ProductVariation... ], // publishable only; [] when the product has no attributes
|
|
99
100
|
"categories": [ ProductCategory... ],
|
|
100
|
-
"
|
|
101
|
+
"ribbons": [ ProductRibbon... ],
|
|
101
102
|
"reviews": { "items": [ { "id", "reviewer", "review", "rating", "verified", "created_date" } ],
|
|
102
103
|
"page": 1, "per_page": 10, "has_next": false,
|
|
103
104
|
"average_rating": 4.5, "rating_count": 12 },
|
|
@@ -124,27 +125,27 @@ Each row is the product record (minus paywalled fields) **plus a resolved `tags`
|
|
|
124
125
|
### `list-categories`
|
|
125
126
|
No payload. Returns a nested tree: `{ "categories": [ { ...category, "children": [...] } ] }` sorted by `menu_order` then name.
|
|
126
127
|
|
|
127
|
-
### `list-
|
|
128
|
+
### `list-ribbons`
|
|
128
129
|
**Payload** (optional): `{ with_products_only?: boolean }` (default `true`).
|
|
129
130
|
|
|
130
|
-
Returns `{ "
|
|
131
|
+
Returns `{ "ribbons": [ { id, name, count } ] }` sorted by name. By default it omits ribbons no published product carries, so a ribbon nav never links to an empty listing; pass `with_products_only: false` for the full set (e.g. an admin-facing picker).
|
|
131
132
|
|
|
132
|
-
`count` is tallied from the products this API would actually list — published
|
|
133
|
+
`count` is tallied from the products this API would actually list — published and honoring `inventory.hide_out_of_stock` — **not** from `ProductRibbon.count`, which also counts drafts and drifts until `commerce/admin-tools` `recount-terms` runs. So `Gift (12)` and the ribbon's listing agree.
|
|
133
134
|
|
|
134
|
-
This is the **only** way a storefront can enumerate
|
|
135
|
+
This is the **only** way a storefront can enumerate ribbons — `commerce.ProductRibbon` is admin-only RLS — and it's what makes `list-products` `ribbon_id` usable, since that filter needs an id. Ribbons are flat, cross-cutting labels ("Best Seller", "New", "Gift"); categories are the hierarchical spine. Show both: see [*Ribbons*](../references/product-render.md#2-ribbons--render-them-in-both-views) for where they belong in each view.
|
|
135
136
|
|
|
136
137
|
### `list-attributes`
|
|
137
138
|
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.
|
|
138
139
|
|
|
139
|
-
### `submit-review`
|
|
140
|
+
### `submit-review`
|
|
140
141
|
**Payload:** `{ product_id, reviewer?, review, rating }`.
|
|
141
142
|
|
|
142
|
-
**
|
|
143
|
+
**Public by default: anyone can review with an email address — no login.** A guest passes `email` in the payload; for a signed-in caller the session email always wins (the payload cannot impersonate). `reviewer` is the display name only — it defaults to the account's `full_name`, then the email's local part. `rating` is optional (0–5). `verified` is derived from the email's order history (`processing`/`completed` order containing the product). Status is `hold` unless `products.auto_approve_reviews` — the one server-side review switch.
|
|
143
144
|
|
|
144
|
-
|
|
145
|
+
Any stricter policy is the **storefront's** to enforce in its UI — login-gated forms, verified-buyers-only, a required rating: see [`../references/reviews.md`](../references/reviews.md) for the patterns.
|
|
145
146
|
|
|
146
147
|
**Response:** `{ "review_id", "status": "hold"|"approved", "verified": true }`
|
|
147
|
-
**Errors:** `
|
|
148
|
+
**Errors:** `404 not_found`, `400 email_required|review_incomplete|invalid_rating`.
|
|
148
149
|
|
|
149
150
|
---
|
|
150
151
|
|
|
@@ -169,9 +170,9 @@ Token-scoped cart (guest + member). Every action **except `create`** takes `cart
|
|
|
169
170
|
"coupon_notices": [ { "code": "old", "error": "Coupon has expired.", "error_code": "expired" } ],
|
|
170
171
|
"removed_items": [ { "item_key": "...", "product_id": "...", "reason": "...", "code": "unavailable" } ],
|
|
171
172
|
"shipping_address": { "country": "US", "state": "CA", "postcode": "90210", "city": "LA" },
|
|
172
|
-
"chosen_shipping_method": "<
|
|
173
|
-
"available_shipping_methods": [ { "id": "<
|
|
174
|
-
"shipping_status": "chosen", // not_needed | chosen | auto_selected | choice_required | none_available
|
|
173
|
+
"chosen_shipping_method": "<shippingRateId>",
|
|
174
|
+
"available_shipping_methods": [ { "id": "<shippingRateId>", "title": "Standard delivery", "cost": 5 } ],
|
|
175
|
+
"shipping_status": "chosen", // not_needed | chosen | auto_selected | choice_required | missing_address | none_available
|
|
175
176
|
"totals": { "subtotal": 40, "discount_total": 4, "discount_tax": 0, "shipping_total": 5, "shipping_tax": 0,
|
|
176
177
|
"cart_tax": 3.6, "total_tax": 3.6, "total": 44.6, "prices_include_tax": false,
|
|
177
178
|
"tax_lines": [ { "rate_id": "...", "label": "CA Tax", "tax_total": 3.6 } ] },
|
|
@@ -186,15 +187,17 @@ Stored coupons that stop validating are **auto-removed** and reported in `coupon
|
|
|
186
187
|
| `create` | `{ items?: [{product_id, variation_id?, quantity, attributes?}] }` | Mints and returns a new `cart_token`. Initial items go through add validation. |
|
|
187
188
|
| `get` | `{ cart_token }` | Priced view (also re-prices + merges). |
|
|
188
189
|
| `totals` | `{ cart_token }` | Alias of `get`. |
|
|
189
|
-
| `add-item` | `{ cart_token
|
|
190
|
+
| `add-item` | `{ cart_token?, product_id, variation_id?, quantity?, attributes? }` | **Self-healing:** a missing, unknown or expired `cart_token` starts a fresh cart and adds the item to it — the response carries the new `cart_token`, so **always persist the token from the response** (see the note below the table). 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`. |
|
|
190
191
|
| `update-item` | `{ cart_token, item_key, quantity }` | qty ≤ 0 removes the line. `404 item_not_found`, `400 <stock code>`. |
|
|
191
192
|
| `remove-item` | `{ cart_token, item_key }` | |
|
|
192
193
|
| `apply-coupon` | `{ cart_token, code }` | Full validation. `400 code_required|already_applied|<coupon code>`. |
|
|
193
194
|
| `remove-coupon` | `{ cart_token, code }` | |
|
|
194
|
-
| `set-shipping-address` | `{ cart_token, address: {country, state?, postcode?, city?} }` | `400 country_required`. Returns the matched
|
|
195
|
-
| `choose-shipping-method` | `{ cart_token, method_id }` | `method_id` = a `
|
|
195
|
+
| `set-shipping-address` | `{ cart_token, address: {country, state?, postcode?, city?} }` | `400 country_required`. Returns the matched location's `available_shipping_methods` and a `shipping_status`. **Fails immediately with `400 shipping_not_available`** when the store doesn't ship to the address (the error body carries the priced `cart`) — the caller learns on address entry, not at place-order. |
|
|
196
|
+
| `choose-shipping-method` | `{ cart_token, method_id }` | `method_id` = a shipping rate `id` from `available_shipping_methods`. `400 invalid_shipping_method` (the error body carries `available_shipping_methods`). |
|
|
197
|
+
|
|
198
|
+
Common: `400 cart_token_required`, `404 cart_not_found`, `404 cart_expired` — except `add-item`, which never fails on the token: it starts a fresh cart instead.
|
|
196
199
|
|
|
197
|
-
|
|
200
|
+
> **Handle your cart_token cache.** Cart tokens expire (48h rolling TTL) and carts are consumed at checkout, so a token cached in `localStorage` can go stale. `add-item` heals this by starting a fresh cart — but only if the storefront **reads `cart_token` from every cart response and re-persists it** instead of assuming the stored one survived. Do that after every cart call, and treat `404 cart_not_found|cart_expired` from the other actions as "clear the cached token and start over" rather than an error to show.
|
|
198
201
|
|
|
199
202
|
### Shipping is not optional — read this before building checkout
|
|
200
203
|
<a id="shipping-is-not-optional--read-this-before-building-checkout"></a>
|
|
@@ -209,33 +212,36 @@ rather than assuming:
|
|
|
209
212
|
| `auto_selected` | exactly **one** method is offered, so the cart already applied it | show it as the (only) delivery option — **don't** make the customer pick from a list of one |
|
|
210
213
|
| `chosen` | the customer's pick is still offered | show it, allow changing |
|
|
211
214
|
| `choice_required` | **several** methods are offered and none is chosen | render `available_shipping_methods` and call `choose-shipping-method`; checkout will refuse until then |
|
|
212
|
-
| `
|
|
215
|
+
| `missing_address` | the options can't be determined until an address is set (several locations defined, none yet matchable) | collect the address and call `set-shipping-address` — shipping cost is not calculated yet |
|
|
216
|
+
| `none_available` | nothing ships to this address | say so, and don't let checkout proceed (`set-shipping-address` already failed `shipping_not_available` when this address was set) |
|
|
213
217
|
|
|
214
218
|
Rules the cart enforces on every price, so you get them for free:
|
|
215
219
|
|
|
216
220
|
- **A single available method is auto-selected.** One option is not a choice; requiring a round trip for it is exactly how a storefront ends up never sending a method at all.
|
|
221
|
+
- **A single location answers before the address.** A store with exactly **one** Shipping & Tax Location gets that location's rates (and, via auto-select, a shipping cost) on the cart with no address set — its options are the same everywhere. With several locations the cart reports `missing_address` until `set-shipping-address` resolves one.
|
|
222
|
+
- **An unsupported address fails on entry.** `set-shipping-address` returns `400 shipping_not_available` the moment a validated address matches no location (e.g. the store's one location doesn't cover it) — surface it on the address form instead of letting the customer reach payment.
|
|
217
223
|
- **A stale choice is dropped.** If the address or cart changes so the chosen method is no longer offered, it is cleared (and re-auto-selected when only one remains) instead of silently pricing zero shipping.
|
|
218
|
-
- **Costs change with the cart.** `available_shipping_methods` is recomputed per price — free-shipping thresholds and coupon-gated methods appear and disappear — so re-read
|
|
224
|
+
- **Costs change with the cart.** `available_shipping_methods` is recomputed per price — free-shipping thresholds and coupon-gated methods appear and disappear — so **call `set-shipping-address` as soon as the customer provides an address and re-read the returned cart after every mutation**; never carry an options list you fetched earlier.
|
|
219
225
|
|
|
220
226
|
### Never advertise what the store isn't configured to do
|
|
221
227
|
|
|
222
228
|
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.
|
|
223
229
|
|
|
224
|
-
**
|
|
230
|
+
**Locations are admin-only data.** `commerce.ShippingTaxLocation` is `read: {role: "admin"}` like every entity, so a visitor cannot list them — do not try. Two honest sources:
|
|
225
231
|
|
|
226
232
|
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
|
|
233
|
+
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):
|
|
228
234
|
|
|
229
235
|
```js
|
|
230
236
|
import { freeShippingRules, freeShippingThreshold, freeShippingProgress } from "@/commerce/utils";
|
|
231
237
|
|
|
232
|
-
//
|
|
233
|
-
const threshold = freeShippingThreshold(freeShippingRules(
|
|
238
|
+
// locations come from your own storefront action — NOT from a client entity read
|
|
239
|
+
const threshold = freeShippingThreshold(freeShippingRules(locations));
|
|
234
240
|
// null → the store has no free-shipping rule: show no banner, no progress bar.
|
|
235
|
-
const progress = freeShippingProgress(threshold, cart.totals.subtotal);
|
|
241
|
+
const progress = freeShippingProgress(threshold, cart.totals.subtotal - cart.totals.discount_total);
|
|
236
242
|
```
|
|
237
243
|
|
|
238
|
-
A free-shipping rule is a
|
|
244
|
+
A free-shipping rule is a shipping rate that is free (`cost: 0`) or carries a `free_over` threshold (thresholds are **per location** — a rule in one location must not become a site-wide banner). The same applies to any other promise: a discount claim needs a real `commerce.Coupon` (admin-read only — only advertise a code the operator gave you), and delivery-time or returns claims the template cannot enforce need the operator's confirmation.
|
|
239
245
|
|
|
240
246
|
---
|
|
241
247
|
|
|
@@ -247,21 +253,22 @@ Converts a cart into an order. **Payload:**
|
|
|
247
253
|
{ "cart_token": "uuid", "payment_method": "offline",
|
|
248
254
|
"billing": { "first_name", "last_name", "address_1", "address_2?", "city", "state?", "postcode?", "country", "email", "phone?" },
|
|
249
255
|
"shipping": { ...address without email },
|
|
250
|
-
"chosen_shipping_method?": "<
|
|
256
|
+
"chosen_shipping_method?": "<shippingRateId>",
|
|
251
257
|
"customer_note?": "...", "create_account?": false }
|
|
252
258
|
```
|
|
253
259
|
**Mandatory fields:**
|
|
254
260
|
- **Billing:** `first_name, last_name, address_1, city, country, email`.
|
|
255
261
|
- **Shipping address** and a **shipping method** — mandatory whenever the cart has any non-virtual (physical) line and shipping is enabled. Your checkout UI has to *offer* the options; there is no default. The whole contract:
|
|
256
262
|
```js
|
|
263
|
+
// the moment the customer provides an address — this recalculates the options and cost;
|
|
264
|
+
// an unsupported address fails right here with 400 shipping_not_available
|
|
257
265
|
cart = await inv("commerce/storefront-cart", { action: "set-shipping-address", cart_token, address });
|
|
258
266
|
if (cart.shipping_status === "choice_required") {
|
|
259
267
|
// MUST render cart.available_shipping_methods and let the customer pick
|
|
260
268
|
cart = await inv("commerce/storefront-cart", { action: "choose-shipping-method", cart_token, method_id: picked.id });
|
|
261
|
-
} else if (cart.shipping_status === "none_available") {
|
|
262
|
-
// the store doesn't ship there — stop, don't try to place the order
|
|
263
269
|
}
|
|
264
270
|
// auto_selected / chosen: nothing to do, one option was applied for you
|
|
271
|
+
// (a single-location store may show auto_selected before any address is set)
|
|
265
272
|
```
|
|
266
273
|
Skipping this is the most common storefront bug: `place-order` answers `400 shipping_method_required` with the methods attached, and no order is created. Set the address on the cart first (`set-shipping-address`); the method can come either from the cart (`choose-shipping-method`) or straight from this call via `chosen_shipping_method`, which wins if both are present. What happens then:
|
|
267
274
|
- **one method offered** → applied automatically, nothing to send;
|
|
@@ -271,9 +278,9 @@ Converts a cart into an order. **Payload:**
|
|
|
271
278
|
|
|
272
279
|
The order is priced with the resolved method, so its `shipping_total` always matches what the customer was shown.
|
|
273
280
|
|
|
274
|
-
> **Payment.** `payment_method` is a `commerce.PaymentGateway` slug from `get-store-info`.
|
|
281
|
+
> **Payment.** `payment_method` is a `commerce.PaymentGateway` slug from `get-store-info`. Choosing the **card** gateway leaves the order `pending` and returns `payment: { status: "requires_payment", checkout_url, reference }` — send the customer to `checkout_url` (the provider's hosted page), then confirm on their return with `commerce/payments` `complete-return`. Never treat a return URL as proof of payment. While the store has no payment provider implemented the card gateway fails with `503 no_card_payment_provider` — tell the customer card payment is unavailable and offer the other methods. **Every other gateway is manual** (offline, and any option the admin added in Settings → Payments): the order goes on-hold and `payment_instructions` carries the gateway's description and bank accounts. Optional `success_url` / `cancel_url` / `return_url` in the payload override the default return URLs. See [`../references/online-payments.md`](../references/online-payments.md).
|
|
275
282
|
|
|
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: **
|
|
283
|
+
**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: **card → stays pending** until the hosted page is paid, **everything else → on-hold** (with `payment_instructions`).
|
|
277
284
|
|
|
278
285
|
> **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.
|
|
279
286
|
|
|
@@ -284,15 +291,14 @@ Converts a cart into an order. **Payload:**
|
|
|
284
291
|
"status": "on-hold", "currency": "USD",
|
|
285
292
|
"payment_method": "offline", "payment_method_title": "Offline payment",
|
|
286
293
|
"payment_instructions": { "type": "offline", "description": "...", "account_details": [...] },
|
|
287
|
-
"payment": null, //
|
|
288
|
-
// { "status": "requires_payment", "
|
|
289
|
-
// For a custom gateway: { "status": "pending_external" }
|
|
294
|
+
"payment": null, // manual gateways. For the card gateway:
|
|
295
|
+
// { "status": "requires_payment", "checkout_url", "reference" }
|
|
290
296
|
"notices": [], // e.g. ["account_creation_requires_login"]
|
|
291
297
|
"totals": { "subtotal", "discount_total", "shipping_total", "shipping_tax", "cart_tax", "total_tax", "total" },
|
|
292
298
|
"order": { ...customer-safe order (internal flags/ip stripped) }
|
|
293
299
|
}
|
|
294
300
|
```
|
|
295
|
-
**Errors:** `401 login_required` (guest checkout disabled), `400 billing_incomplete`, `400 empty_cart`, `409 items_unavailable`, `409 coupon_invalid`, `400 shipping_method_required` / `400 invalid_shipping_method` / `400 no_shipping_available` (each carrying `available_shipping_methods`; these replace the earlier catch-all `shipping_required`), `400 invalid_payment_method
|
|
301
|
+
**Errors:** `401 login_required` (guest checkout disabled), `400 billing_incomplete`, `400 empty_cart`, `409 items_unavailable`, `409 coupon_invalid`, `400 shipping_method_required` / `400 invalid_shipping_method` / `400 no_shipping_available` (each carrying `available_shipping_methods`; these replace the earlier catch-all `shipping_required`), `400 invalid_payment_method`, `503 no_card_payment_provider` (card gateway chosen but no provider is implemented — the order exists as `pending` and its stock hold expires on its own; offer another method).
|
|
296
302
|
|
|
297
303
|
### `commerce/payments` — online payment for an order
|
|
298
304
|
|
|
@@ -300,23 +306,23 @@ Separate function, same guest-bearer rule (`order_id` + `order_key`; an admin ma
|
|
|
300
306
|
|
|
301
307
|
| Action | Payload | Returns |
|
|
302
308
|
|---|---|---|
|
|
303
|
-
| `create-link` | `{ order_id, order_key, success_url?, cancel_url?, return_url? }` | `{
|
|
304
|
-
| `complete-return` | `{ order_id, order_key, payment?, return_url? }` | `{ state: "paid"\|"cancelled"\|"unpaid", paid, already_confirmed, status, order, payment_link }` — the whole return flow in one call for your `/order-received` page: confirms with the provider, progresses the order,
|
|
305
|
-
| `verify` | `{ order_id, order_key
|
|
309
|
+
| `create-link` | `{ order_id, order_key, success_url?, cancel_url?, return_url? }` | `{ url, reference }` — a fresh hosted payment page for an **unpaid** order, whatever its current payment method (the order is switched onto the card gateway and the change is logged). This is how a storefront offers "pay now" on an order-received page or an emailed link, and what the admin's payment-link button calls. `409 already_paid`, `400 card_payments_disabled` (the store turned card payment off), `503 no_card_payment_provider` |
|
|
310
|
+
| `complete-return` | `{ order_id, order_key, payment?, return_url? }` | `{ state: "paid"\|"cancelled"\|"unpaid", paid, already_confirmed, status, order, payment_link, payment_instructions }` — the whole return flow in one call for your `/order-received` page: confirms with the provider, progresses the order, includes a fresh `payment_link: { url, reference }` while unpaid (card orders), and re-supplies `payment_instructions: { type, description, account_details }` for unpaid **manual** orders so the page can render bank details on every visit. `payment` is only a hint; a hand-edited `?payment=success` can never yield `paid`. **Note:** `order` carries flat totals (`order.total`, `order.shipping_total`) — there is no `order.totals` object; that nested shape belongs to the cart view |
|
|
311
|
+
| `verify` | `{ order_id, order_key }` | `{ paid, already_confirmed, status, order }` — the same confirmation without the render-ready extras. **Idempotent**: call it on every return, and as often as you like. Whether money arrived is asked of the provider about the payment reference stored on the order |
|
|
306
312
|
|
|
307
313
|
> **You must build the `/order-received` page** — it is mandatory for payment links to work. No storefront UI ships with the template, and without that route a paying customer lands on a 404 *and* the order never gets marked paid. It only needs to call `complete-return` and render its three states: see [`../references/online-payments.md`](../references/online-payments.md).
|
|
308
314
|
|
|
309
|
-
Provider callbacks land on `commerce/payment-webhook` (
|
|
315
|
+
Provider callbacks land on `commerce/payment-webhook` (server-to-server) — the second confirmation path, for buyers who pay and close the tab. Whichever path runs second is a no-op. Wiring a payment provider means implementing two files; see [`../references/online-payments.md`](../references/online-payments.md).
|
|
310
316
|
|
|
311
317
|
### `confirm-payment`
|
|
312
|
-
Post-payment hook for an order on the
|
|
318
|
+
Post-payment hook for an order on the card gateway. **Payload:** `{ order_id, order_key }`.
|
|
313
319
|
|
|
314
|
-
**The request cannot make an order paid.** The `order_key` says who is asking; the payment itself is confirmed against the provider
|
|
320
|
+
**The request cannot make an order paid.** The `order_key` says who is asking; the payment itself is confirmed against the provider using the payment reference stored on the order. Only then does the order move `pending`/`on-hold` → `processing`, set `date_paid` and bump customer stats. There is no caller-supplied `transaction_id` — it comes from the provider. Idempotent.
|
|
315
321
|
|
|
316
322
|
`commerce/payments` `complete-return` is the richer version of this and what an `/order-received` page should call; use `confirm-payment` when you only need the transition.
|
|
317
323
|
|
|
318
324
|
**Response:** `{ "order": { ...customer-safe order }, "paid": true, "already_confirmed": false }`
|
|
319
|
-
**Errors:** `400 order_key_required|
|
|
325
|
+
**Errors:** `400 order_key_required|not_a_card_payment`, `404 order_not_found`, `409 invalid_status|payment_not_confirmed`.
|
|
320
326
|
|
|
321
327
|
### `cancel-order`
|
|
322
328
|
Customer-initiated cancel. **Payload:** `{ order_id, order_key }`. Only `pending`/`on-hold` (restores stock). **Response:** `{ "order": { ...customer-safe order } }` · **Errors:** `400 order_key_required`, `404 order_not_found`, `409 invalid_status`.
|
|
@@ -364,7 +370,9 @@ if ((detail.product.attributes ?? []).length) {
|
|
|
364
370
|
// 4. Coupon (optional)
|
|
365
371
|
cart = await inv("commerce/storefront-cart", { action: "apply-coupon", cart_token: token, code: "welcome10" });
|
|
366
372
|
|
|
367
|
-
// 5. Shipping address → method.
|
|
373
|
+
// 5. Shipping address → method. Call this as soon as the address is known — it
|
|
374
|
+
// recalculates the options and cost; an unsupported address fails right here
|
|
375
|
+
// (400 shipping_not_available). A single option is already applied for you;
|
|
368
376
|
// several mean the customer has to choose or checkout will refuse.
|
|
369
377
|
cart = await inv("commerce/storefront-cart", { action: "set-shipping-address",
|
|
370
378
|
cart_token: token, address: { country: "US", state: "CA", postcode: "90210", city: "Los Angeles" } });
|
|
@@ -372,8 +380,6 @@ if (cart.shipping_status === "choice_required") {
|
|
|
372
380
|
const picked = await askCustomer(cart.available_shipping_methods); // your UI
|
|
373
381
|
cart = await inv("commerce/storefront-cart", { action: "choose-shipping-method",
|
|
374
382
|
cart_token: token, method_id: picked.id });
|
|
375
|
-
} else if (cart.shipping_status === "none_available") {
|
|
376
|
-
throw new Error("We don't ship to this address");
|
|
377
383
|
}
|
|
378
384
|
|
|
379
385
|
// 6. Place the order (offline → on-hold; no money has arrived yet)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Installation Guidelines
|
|
2
2
|
|
|
3
|
-
How to install the Base44 Commerce Template into an existing Base44 app. This file lives inside the **commerce skill** folder and is installed into the app at
|
|
3
|
+
How to install the Base44 Commerce Template into an existing Base44 app. This file lives inside the **commerce skill** folder and is installed into the app at `.agents/skills/commerce/installation-guidelines.md`. Once the files are in place, continue with [`post-installation.md`](./post-installation.md) (embedding the admin pages, seeding the store's data, the storefront quick start); day-2 guidance lives in [`.agents/skills/commerce/SKILL.md`](./SKILL.md), alongside the API references in [`.agents/skills/commerce/docs/`](./docs/).
|
|
4
4
|
|
|
5
5
|
> **If you are a Base44 agent working inside the runtime, read this first:**
|
|
6
6
|
> - **Skip the `npx base44` commands.** The runtime deploys functions and pushes entities automatically the moment you write the files — writing a resource file *is* the deploy.
|
|
@@ -27,21 +27,21 @@ Relative to the script's own folder (`examples/commerce/scripts/`), it copies:
|
|
|
27
27
|
| `../base44/agents/commerce/` | `../../../base44/agents/commerce/` |
|
|
28
28
|
| `../src/commerce/admin/` | `../../../src/commerce/admin/` |
|
|
29
29
|
| `../src/commerce/utils/` | `../../../src/commerce/utils/` |
|
|
30
|
-
| `../skills/commerce/` |
|
|
30
|
+
| `../skills/commerce/` | `../../../.agents/skills/commerce/` |
|
|
31
31
|
|
|
32
|
-
Directories are merged: files owned by the template are overwritten (re-running after a template update is safe); everything else in your app is left untouched. Files the template has since **renamed or retired** are deleted on install (it reports each one) — otherwise stale guidance would sit in
|
|
32
|
+
Directories are merged: files owned by the template are overwritten (re-running after a template update is safe); everything else in your app is left untouched. Files the template has since **renamed or retired** are deleted on install (it reports each one) — otherwise stale guidance would sit in `.agents/skills/` forever, and agents read whatever is there. The skill folder carries all the documentation — `SKILL.md`, this file, `post-installation.md`, the topic references in `references/` and the API docs in `docs/` — so the installed app gets it at `.agents/skills/commerce/` where agents pick it up natively; the template repo itself also stays under `examples/commerce/` for reference.
|
|
33
33
|
|
|
34
34
|
**Manual.** Equivalently, copy by hand:
|
|
35
35
|
|
|
36
36
|
1. Copy `base44/entities/commerce.*`, `base44/functions/commerce/*`, `base44/shared/commerce/*` and `base44/agents/commerce/*` into your app's `base44/` dir (merge, don't overwrite unrelated files). `shared/` is bundled into every function at deploy time.
|
|
37
37
|
2. Copy `src/commerce/admin/` → `src/commerce/admin/` and `src/commerce/utils/` → `src/commerce/utils/` (the storefront variant-selection helpers; framework-free, no deps).
|
|
38
|
-
3. Copy `skills/commerce/` →
|
|
38
|
+
3. Copy `skills/commerce/` → `.agents/skills/commerce/` (the commerce skill — `SKILL.md`, this file, `post-installation.md`, the `references/` topic guides and the `docs/` API references — for agents working on the app).
|
|
39
39
|
|
|
40
40
|
Confirm your `base44/config.jsonc` `entitiesDir`/`functionsDir` point at these folders (the defaults do).
|
|
41
41
|
|
|
42
42
|
## 2. Deploy and wire up
|
|
43
43
|
|
|
44
|
-
1. `npx base44 entities push` — creates/updates the
|
|
44
|
+
1. `npx base44 entities push` — creates/updates the 20 entity schemas. *(CLI path only — the Base44 runtime deploys on write.)*
|
|
45
45
|
2. `npx base44 functions deploy` — deploys the 16 functions. *(CLI path only.)*
|
|
46
46
|
3. `npx base44 agents push` — registers the `commerce/StoreAdmin` agent (§3). *(CLI path only.)*
|
|
47
47
|
4. Check the app's `package.json` for `sonner`, `recharts` and `react-markdown`, and run `npm i` **only** for the ones actually absent — all three ship with the default Base44 template, so the normal outcome is no install at all. Do not re-install a package that is already a dependency. Nothing else is needed. Verify the shadcn primitives listed in [`src/commerce/admin/README.md`](../../src/commerce/admin/README.md) exist in your app.
|
|
@@ -54,7 +54,7 @@ Check the install at any time:
|
|
|
54
54
|
```js
|
|
55
55
|
const { data } = (await base44.functions.invoke("commerce/admin-tools", { action: "status" })).data;
|
|
56
56
|
// → { template_version, seeded, settings_groups, counts: { "commerce.Product": n, ... },
|
|
57
|
-
// checks: { has_payment_gateways,
|
|
57
|
+
// checks: { has_payment_gateways, has_default_location } }
|
|
58
58
|
```
|
|
59
59
|
|
|
60
60
|
`commerce/seed-store` is **idempotent** and starts with a **canary schema check**: it probe-writes one record per entity it will touch and deletes it. If you've modified an entity schema incompatibly, it aborts with HTTP 422 `schema_incompatible` and writes nothing:
|
|
@@ -77,7 +77,7 @@ The template ships an AI copilot for store operators:
|
|
|
77
77
|
- **No `model` field, on purpose.** The platform's default-model path accepts slash-namespaced tool names; explicitly setting a `model` currently rejects them (LLM tool names must match `^[a-zA-Z0-9_-]{1,128}$`). If you set a model, the bot fails at message time with a `tools.0.custom.name` error.
|
|
78
78
|
- **Variant safety** — the agent is instructed to never auto-pick a variation: for a product carrying attributes it fetches `{product, variations}` via `commerce/storefront-catalog get-product`, presents the variants as a table, and asks the operator which `variation_id` to use before touching an order, stock, or download grant.
|
|
79
79
|
- **Bot UI** — `src/commerce/admin/bot/` (chat panel; "StoreAdmin bot" launcher at the bottom of the admin sidebar). Responses render as markdown via `react-markdown`, with the agent's GFM tables (`| col |` with `|---|` separators) rendered by the template's own `bot/pipe-tables.js` — no markdown plugin dependency. The panel lives behind the same `AuthGuard` as the rest of the admin.
|
|
80
|
-
- **Config it can't change, it links to.** Store settings,
|
|
80
|
+
- **Config it can't change, it links to.** Store settings, Shipping & Tax Locations, gateways and webhook definitions have no function tool, so the agent is instructed to name the screen and emit an `admin:`-scheme link (`[Settings → Shipping & Tax](admin:settings/shipping-tax)`) rather than telling the operator to "do it manually". `bot/Markdown.jsx` resolves `admin:` through `useAdminHref()`, so links follow your actual mount point (`basePath`) and navigate in-app instead of opening a tab. If you add screens, add the path to the table in the agent's instructions.
|
|
81
81
|
|
|
82
82
|
**Do not weaken the tool set.** The agent's power comes only from the admin functions' own `requireAdmin()` layer — don't add entity tools or service-role calls to the agent config, and keep `commerce/storefront-*` tools limited to the read-only catalog.
|
|
83
83
|
|
|
@@ -89,4 +89,4 @@ If your work includes a customer-facing shopfront, read **[`SKILL.md` → *If yo
|
|
|
89
89
|
|
|
90
90
|
## 5. Next steps
|
|
91
91
|
|
|
92
|
-
Continue with [`post-installation.md`](./post-installation.md): embedding the admin pages (router mount, admin-role enforcement), seeding the store's data, and the logic-only storefront quick start. After that, [
|
|
92
|
+
Continue with [`post-installation.md`](./post-installation.md): embedding the admin pages (router mount, admin-role enforcement), seeding the store's data, and the logic-only storefront quick start. After that, [`.agents/skills/commerce/SKILL.md`](./SKILL.md) is the map for all day-2 work.
|