@base44/app-plugin-commerce 0.1.3 → 0.1.4
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 +8 -8
- package/base44/agents/commerce/StoreAdmin.jsonc +7 -7
- package/base44/entities/commerce.Order.jsonc +1 -1
- package/base44/entities/commerce.PaymentGateway.jsonc +3 -3
- package/base44/entities/commerce.Product.jsonc +9 -45
- package/base44/entities/commerce.ProductAttribute.jsonc +7 -19
- package/base44/entities/commerce.ProductAttributeTerm.jsonc +4 -10
- package/base44/entities/commerce.ProductTag.jsonc +1 -8
- package/base44/entities/commerce.ProductVariation.jsonc +1 -1
- package/base44/entities/commerce.StoreSettings.jsonc +1 -1
- package/base44/functions/commerce/admin-products/entry.ts +107 -35
- package/base44/functions/commerce/admin-refunds/entry.ts +1 -1
- package/base44/functions/commerce/admin-reports/entry.ts +1 -1
- package/base44/functions/commerce/admin-tools/entry.ts +4 -3
- package/base44/functions/commerce/payments/entry.ts +0 -2
- package/base44/functions/commerce/seed-store/defaults.ts +13 -39
- package/base44/functions/commerce/seed-store/entry.ts +47 -35
- package/base44/functions/commerce/seed-store/sample-data.ts +28 -66
- package/base44/functions/commerce/storefront-cart/entry.ts +0 -7
- package/base44/functions/commerce/storefront-catalog/entry.ts +12 -21
- package/base44/functions/commerce/storefront-checkout/entry.ts +5 -12
- package/base44/shared/commerce/coupons.ts +4 -7
- package/base44/shared/commerce/email-templates.ts +15 -13
- package/base44/shared/commerce/emails.ts +4 -3
- package/base44/shared/commerce/payments.ts +8 -20
- package/base44/shared/commerce/products.ts +24 -0
- package/base44/shared/commerce/settings.ts +2 -3
- package/base44/shared/commerce/stock.ts +2 -4
- package/base44/shared/commerce/totals.ts +37 -42
- package/package.json +1 -1
- package/scripts/install.js +21 -4
- package/skills/commerce/SKILL.md +6 -5
- package/skills/commerce/docs/api-admin.md +16 -14
- package/skills/commerce/docs/api-storefront.md +39 -35
- package/skills/commerce/installation-guidelines.md +4 -3
- package/skills/commerce/post-installation.md +22 -25
- package/skills/commerce/references/admin-product-form.md +56 -0
- package/skills/commerce/references/emails.md +4 -3
- package/skills/commerce/references/guest-access-security.md +2 -2
- package/skills/commerce/references/limits-and-performance.md +1 -1
- package/skills/commerce/references/online-payments.md +8 -8
- package/skills/commerce/references/product-render.md +25 -23
- package/skills/commerce/references/storefront-product-page.md +9 -9
- package/skills/commerce/references/webhooks.md +3 -1
- package/src/commerce/admin/README.md +4 -4
- package/src/commerce/admin/context/BasePathContext.jsx +5 -5
- package/src/commerce/admin/context/SettingsContext.jsx +4 -4
- package/src/commerce/admin/index.jsx +3 -3
- package/src/commerce/admin/layout/Sidebar.jsx +1 -9
- package/src/commerce/admin/lib/constants.js +0 -7
- package/src/commerce/admin/lib/paths.js +6 -6
- package/src/commerce/admin/lib/product-utils.js +18 -15
- package/src/commerce/admin/pages/orders/components/AddProductDialog.jsx +8 -8
- package/src/commerce/admin/pages/products/ProductEditor.jsx +10 -12
- package/src/commerce/admin/pages/products/ProductsList.jsx +3 -15
- package/src/commerce/admin/pages/products/components/AttributesSection.jsx +426 -0
- package/src/commerce/admin/pages/products/components/ProductDataPanel.jsx +33 -69
- package/src/commerce/admin/pages/products/components/TaxonomyPanel.jsx +1 -1
- package/src/commerce/admin/pages/products/components/tabs/LinkedTab.jsx +1 -14
- package/src/commerce/admin/pages/products/components/tabs/ModifiersTab.jsx +19 -0
- package/src/commerce/admin/pages/products/components/tabs/PriceInventoryTab.jsx +728 -0
- package/src/commerce/admin/pages/reports/Reports.jsx +3 -3
- package/src/commerce/admin/pages/settings/EmailsSettings.jsx +12 -14
- package/src/commerce/admin/pages/settings/GeneralSettings.jsx +6 -116
- package/src/commerce/admin/pages/settings/PaymentsSettings.jsx +101 -62
- package/src/commerce/admin/pages/settings/SettingsLayout.jsx +2 -6
- package/src/commerce/admin/pages/settings/TaxSettings.jsx +0 -1
- package/src/commerce/admin/pages/status/WebhookEditor.jsx +3 -3
- package/src/commerce/admin/pages/status/Webhooks.jsx +2 -2
- package/src/commerce/admin/routes.jsx +10 -19
- package/src/commerce/utils/index.js +2 -2
- package/src/commerce/utils/shipping-promos.js +9 -6
- package/src/commerce/utils/variants.js +21 -21
- package/src/commerce/admin/pages/products/AttributeTerms.jsx +0 -180
- package/src/commerce/admin/pages/products/Attributes.jsx +0 -183
- package/src/commerce/admin/pages/products/Tags.jsx +0 -150
- package/src/commerce/admin/pages/products/components/tabs/AdvancedTab.jsx +0 -48
- package/src/commerce/admin/pages/products/components/tabs/AttributesTab.jsx +0 -208
- package/src/commerce/admin/pages/products/components/tabs/ExternalTab.jsx +0 -41
- package/src/commerce/admin/pages/products/components/tabs/GeneralTab.jsx +0 -103
- package/src/commerce/admin/pages/products/components/tabs/InventoryTab.jsx +0 -93
- package/src/commerce/admin/pages/products/components/tabs/ShippingTab.jsx +0 -86
- package/src/commerce/admin/pages/products/components/tabs/VariationsTab.jsx +0 -377
|
@@ -53,14 +53,15 @@ Check the install at any time:
|
|
|
53
53
|
|
|
54
54
|
```js
|
|
55
55
|
const { data } = (await base44.functions.invoke("commerce/admin-tools", { action: "status" })).data;
|
|
56
|
-
// → { template_version, seeded, settings_groups, counts: {
|
|
56
|
+
// → { template_version, seeded, settings_groups, counts: { "commerce.Product": n, ... },
|
|
57
|
+
// checks: { has_payment_gateways, has_default_zone } }
|
|
57
58
|
```
|
|
58
59
|
|
|
59
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:
|
|
60
61
|
|
|
61
62
|
```json
|
|
62
63
|
{ "success": false, "code": "schema_incompatible",
|
|
63
|
-
"errors": [{ "entity": "Product", "error": "..." }] }
|
|
64
|
+
"errors": [{ "entity": "commerce.Product", "error": "..." }] }
|
|
64
65
|
```
|
|
65
66
|
|
|
66
67
|
The admin setup screen surfaces these errors verbatim. Sample catalog data is only created when `with_sample_data: true` **and** the store has zero products.
|
|
@@ -74,7 +75,7 @@ The template ships an AI copilot for store operators:
|
|
|
74
75
|
- **Agent definition** — [`base44/agents/commerce/StoreAdmin.jsonc`](../../base44/agents/commerce/StoreAdmin.jsonc), registered as **`commerce/StoreAdmin`** (the folder namespaces the agent, exactly like functions). The hosted runtime registers it when the file lands; on the CLI path run `npx base44 agents push`.
|
|
75
76
|
- **Tools** — the `commerce/*` backend functions attached directly (`commerce/admin-products`, `commerce/admin-orders`, …, `commerce/seed-store`, plus read-only `commerce/storefront-catalog` for enumerating product variations). Tool calls run **with the chatting user's credentials**, so `requireAdmin()` in every admin function still authorizes the actual user — the agent has no entity tools and no service-role shortcut; a non-admin chatting with it gets `401/403` from every admin operation.
|
|
76
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.
|
|
77
|
-
- **Variant safety** — the agent is instructed to never auto-pick a variation: for
|
|
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.
|
|
78
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.
|
|
79
80
|
- **Config it can't change, it links to.** Store settings, tax rates, shipping zones, gateways and webhook definitions have no function tool, so the agent is instructed to name the screen and emit an `admin:`-scheme link (`[Settings → Tax](admin:settings/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.
|
|
80
81
|
|
|
@@ -15,13 +15,13 @@ The admin UI is a self-contained React app under `src/commerce/admin/`. Its only
|
|
|
15
15
|
3. Mount the router:
|
|
16
16
|
```jsx
|
|
17
17
|
import AdminApp from "@/commerce/admin";
|
|
18
|
-
<Route path="/admin/*" element={<AdminApp />} />
|
|
18
|
+
<Route path="/store-admin/*" element={<AdminApp />} />
|
|
19
19
|
```
|
|
20
20
|
**You must also build a payment return page** (`/order-received` by default) — this is **mandatory for payment links to work at all**. If your route differs, set it in Settings → General → *Payment return path*, or payment links will send customers to a 404. Every link (checkout, the admin's payment link, emails) returns there; without the route a paying customer hits a 404, and since confirming is what marks an order paid, orders would stay unpaid. The page is a thin wrapper over one backend call, `commerce/payments` `complete-return`, which confirms the payment and returns `{ state, order, payment_link }` for the paid / unpaid / cancelled cases — the contract and rules are in [`references/online-payments.md`](./references/online-payments.md).
|
|
21
21
|
|
|
22
|
-
**Give the app root something too.** A blank Base44 app has no `/` route, so after mounting only `/admin/*` the app's own URL still renders its "page not found" screen — which reads exactly like a broken install. Until a storefront exists, redirect: `<Route path="/" element={<Navigate to="/admin" replace />} />`.
|
|
22
|
+
**Give the app root something too.** A blank Base44 app has no `/` route, so after mounting only `/store-admin/*` the app's own URL still renders its "page not found" screen — which reads exactly like a broken install. Until a storefront exists, redirect: `<Route path="/" element={<Navigate to="/store-admin" replace />} />`.
|
|
23
23
|
|
|
24
|
-
The **`/*` splat is required** — the admin renders its own nested routes, so a bare `path="/admin"` matches only the dashboard and every deeper link 404s. If you mount at a different base path, pass the prefix *without* the splat: `<AdminApp basePath="/
|
|
24
|
+
The **`/*` splat is required** — the admin renders its own nested routes, so a bare `path="/store-admin"` matches only the dashboard and every deeper link 404s. If you mount at a different base path, pass the prefix *without* the splat: `<AdminApp basePath="/backoffice" />` (a pattern passed by mistake, `basePath="/backoffice/*"`, is normalized rather than baked into every link). Opening the literal mount pattern `/store-admin/*` — pasted from these docs, or left in a hand-written nav link — lands on the dashboard instead of a "page not found" dead end; a genuinely wrong path like `/store-admin/ordrs` still 404s, with a link back.
|
|
25
25
|
|
|
26
26
|
### Admin-role enforcement (do not weaken)
|
|
27
27
|
|
|
@@ -51,45 +51,42 @@ A fresh install has **no settings and no catalog**. Don't decide this silently a
|
|
|
51
51
|
| Option | What you do | Result | Cost |
|
|
52
52
|
|---|---|---|---|
|
|
53
53
|
| **A — Generate the store's data** (best when you know the app's niche) | `commerce/seed-store` with `{ store_name, with_sample_data: false }`, then create real categories, attributes, products and images through the admin API (§2.1) | Store is **ready** — the admin opens straight onto a populated dashboard, no first-run screen | **Spends tokens**: you author every name, description and price, and image generation is billed per image |
|
|
54
|
-
| **B — Default demo data** | `commerce/seed-store` with `{ store_name, with_sample_data: true }` | Store is **ready**, populated with the template's
|
|
54
|
+
| **B — Default demo data** | `commerce/seed-store` with `{ store_name, with_sample_data: true }` | Store is **ready**, populated with the template's 10 generic demo products | **Near-zero**: one function call, static content and stock image URLs |
|
|
55
55
|
| **C — Nothing** | don't call `seed-store` at all | The admin shows its first-run **"Set up your store"** screen; the operator initializes defaults themselves | none |
|
|
56
56
|
|
|
57
57
|
Say the cost part out loud when you ask — it's the main trade-off between A and B, and the user is the one paying for it. A generated catalog of a dozen products with images is a real chunk of generation (text for every product plus one image call each); the static demo seed is a single backend call with no generation at all. If the user wants a populated store only to click around the admin, B is the better deal; A is worth it when the catalog is meant to survive into the real store.
|
|
58
58
|
|
|
59
|
-
**Both A and B must pass `store_name`** — the app's name as the platform shows it. It is required on a first seed (**400** `store_name_required` without it), because nothing server-side can read the app's name and an unnamed store sends subjects like `[]: New order #1002`. On an already-seeded store the call fills a blank name and never overwrites one the merchant chose; the response reports which happened as `store_name: { value, action }` (`created` · `filled` · `unchanged` · `kept_existing`).
|
|
59
|
+
**Both A and B must pass `store_name`** — the app's name as the platform shows it. It is required on a first seed (**400** `store_name_required` without it), because nothing server-side can read the app's name and an unnamed store sends subjects like `[]: New order #1002`. It lands in `emails.store_name` — that is the only place it lives, and it drives email subjects, headings and the sender name. It is also published to storefronts as `settings.store_name` by `get-store-info`, so it is the public shop name too. On an already-seeded store the call fills a blank name and never overwrites one the merchant chose; the response reports which happened as `store_name: { value, action }` (`created` · `filled` · `unchanged` · `kept_existing`). It is the sender name too — there is no separate from-name; leave `store_name` blank and Base44 sends as the app's name.
|
|
60
60
|
|
|
61
|
-
Both A and B call `seed-store`, which creates the business defaults — the
|
|
61
|
+
Both A and B call `seed-store`, which creates the business defaults — the six settings groups (`general`, `products`, `inventory`, `tax`, `shipping`, `emails`), payment gateways, tax classes and a fallback shipping zone. **Creating the `general` settings group is what marks the business as ready:** the admin's `SettingsProvider` (`src/commerce/admin/context/SettingsContext.jsx`) renders the first-run setup screen only while that group is missing. So under A and B the modal never appears — do **not** add a separate "ready" flag or suppress the screen in code; leave it working for option C, which is the only case it's meant for.
|
|
62
62
|
|
|
63
63
|
Confirm afterwards:
|
|
64
64
|
|
|
65
65
|
```js
|
|
66
66
|
const { data } = (await base44.functions.invoke("commerce/admin-tools", { action: "status" })).data;
|
|
67
67
|
// → seeded: true (i.e. settings_groups includes "general"),
|
|
68
|
-
// settings_groups: ["general", "products", ...], counts: { Product: n, ... }
|
|
68
|
+
// settings_groups: ["general", "products", ...], counts: { "commerce.Product": n, ... }
|
|
69
69
|
```
|
|
70
70
|
|
|
71
71
|
### 2.1 Generating the store's data (option A)
|
|
72
72
|
|
|
73
73
|
The goal is a catalog that looks like **this** business, not a demo. Order of operations:
|
|
74
74
|
|
|
75
|
-
1. **Defaults first** — `commerce/seed-store` with `{ with_sample_data: false, store_name: "<the app's name>" }`. `store_name` is **required** on a first seed (else **400** `store_name_required`) — use the app's name as the platform shows it (ask the user if unsure — `base44/config.jsonc` → `name` can be stale, e.g. `New App` for an app called `Canvas`), since the function cannot read it. It lands
|
|
76
|
-
2. **Business settings** — ask for, or infer from the app, the store name, currency
|
|
77
|
-
3. **Taxonomy** — create `commerce
|
|
78
|
-
4. **Products** — `commerce/admin-products` `save` (or `batch`, ≤100 items) per product. Write real merchandising copy: a distinct `name`, a one-line `short_description`, an HTML `description` (paragraph + `<ul>` of specifics), plus `sku`, `regular_price`, optional `sale_price`, `manage_stock: true` + `stock_quantity` where stock is tracked, `category_ids` and `images[]`. (`manage_stock` is a boolean on Product but `"yes"|"no"|"parent"` on ProductVariation.)
|
|
79
|
-
5. **
|
|
80
|
-
1.
|
|
81
|
-
2.
|
|
82
|
-
3. a `variations` array is sent with one entry per combination you actually stock;
|
|
83
|
-
4. `default_attributes` names the combination to pre-select.
|
|
75
|
+
1. **Defaults first** — `commerce/seed-store` with `{ with_sample_data: false, store_name: "<the app's name>" }`. `store_name` is **required** on a first seed (else **400** `store_name_required`) — use the app's name as the platform shows it (ask the user if unsure — `base44/config.jsonc` → `name` can be stale, e.g. `New App` for an app called `Canvas`), since the function cannot read it. It lands in `emails.store_name`, which is both the name in email subjects and the sender name. It's idempotent and runs a canary schema check; on `422 schema_incompatible` fix the reported entities before continuing. Never follow it with `with_sample_data: true` — you'd mix demo products into a real catalog (the sample seeder is skipped once any product exists, so this usually silently does nothing, which is worse).
|
|
76
|
+
2. **Business settings** — ask for, or infer from the app, the store name (in the `emails` group), currency and units, and write them into the relevant `commerce.StoreSettings` groups (direct CRUD, one record per `group_id`; patch `values`, don't replace groups you weren't asked about).
|
|
77
|
+
3. **Taxonomy** — create these through `commerce/admin-products` **`save-term`** (`taxonomy: "category" | "tag" | "attribute" | "attribute-term"`), not direct entity CRUD: `save-term` is what derives a category's unique slug and an attribute's unique `code` (the key a storefront filter URL carries), makes tag creation get-or-create by name, and initializes `count`. A direct create leaves an attribute with a blank `code`. Nest categories via `parent_id`; for variants create the attribute (`{name, code, order}`) then its values (`{attribute_id, name, order}`) for e.g. Size and Color. Attributes are shared: create Size once and reuse it across shoes and shirts, picking per product which of its values apply.
|
|
78
|
+
4. **Products** — `commerce/admin-products` `save` (or `batch`, ≤100 items) per product. Write real merchandising copy: a distinct `name`, a one-line `short_description`, an HTML `description` (paragraph + `<ul>` of specifics), plus `sku`, `regular_price`, optional `sale_price`, `manage_stock: true` + `stock_quantity` where stock is tracked, `category_ids` and `images[]`. **On a product that will carry attributes, price the variations and leave the parent's price alone** — `regular_price`, `price` and `on_sale` are rolled up from the cheapest publishable variant on every save, so anything you set there is discarded. (`manage_stock` is a boolean on Product but `"yes"|"no"|"parent"` on ProductVariation.)
|
|
79
|
+
5. **Variants — an attribute in the attributes list does NOTHING on its own.** This is the step agents get wrong: they create a shared `Size` attribute with values, and every product stays a single item. There is **no product type and no per-attribute flag** — a product sells variants because it carries attributes — so listing the attribute **on the product**, with the values that product comes in, is what declares it. Two things must be true, and both happen in the *same* `commerce/admin-products` `save` call:
|
|
80
|
+
1. the attribute is listed **on the product** with its `options` — not just in `commerce.ProductAttribute`;
|
|
81
|
+
2. a `variations` array is sent with one entry per combination you actually stock (plus `default_attributes` for the combination to pre-select).
|
|
84
82
|
|
|
85
83
|
```js
|
|
86
84
|
await call("admin-products", "save", {
|
|
87
85
|
product: {
|
|
88
|
-
name: "Runner Sneaker",
|
|
89
|
-
regular_price: 89, // lowest variation price, so cards/filters work
|
|
86
|
+
name: "Runner Sneaker", status: "publish", sku: "SNK",
|
|
90
87
|
category_ids: [shoesId], images: [{ src: "…" }],
|
|
91
88
|
attributes: [{ attribute_id: sizeAttrId, name: "Size", position: 0,
|
|
92
|
-
|
|
89
|
+
options: ["41", "42", "43"] }],
|
|
93
90
|
default_attributes: [{ attribute_id: sizeAttrId, name: "Size", option: "42" }],
|
|
94
91
|
},
|
|
95
92
|
variations: [
|
|
@@ -103,11 +100,11 @@ The goal is a catalog that looks like **this** business, not a demo. Order of op
|
|
|
103
100
|
});
|
|
104
101
|
```
|
|
105
102
|
|
|
106
|
-
Give a visual axis (Color) a per-variation `image`. `manage_stock` is `"yes"|"no"|"parent"` on a variation but a boolean on the product. **Then check your work**: re-read the product and assert `variations.length > 0` — a
|
|
103
|
+
Give a visual axis (Color) a per-variation `image`. `manage_stock` is `"yes"|"no"|"parent"` on a variation but a boolean on the product. **Then check your work**: re-read the product and assert `variations.length > 0` — a product with attributes and no variations cannot be added to a cart at all (`400 variation_required`), and the storefront has nothing to show. Descriptive properties (Material, Care) are **not** attributes: put them in `meta_data`, which the admin surfaces as *modifiers*.
|
|
107
104
|
6. **Images** — every product needs at least one. Use whatever image generation the app has available and store the returned URL, upload real assets with `base44.integrations.Core.UploadFile({ file })` → public URL, or fall back to stable public stock URLs (`base44/functions/commerce/seed-store/sample-data.ts` shows the `{ src, name, alt }` shape and a working Unsplash pattern). Match the image to the product — a generated catalog with mismatched or missing images reads as broken.
|
|
108
105
|
7. **Optional extras** — a launch coupon via `commerce/admin-coupons` `save`; tax rates via `commerce.TaxRate` direct CRUD if the user sells into taxed regions.
|
|
109
|
-
8. **Payments — once the store has something to sell.** Leave this until the catalog, shipping and settings are in place: connecting a payment provider is a step the *user* has to take in the platform dashboard, so asking for it first interrupts setup and leaves nothing to test the payment against. When the store is otherwise ready, raise it — don't wait to be asked
|
|
110
|
-
9. **Report back** — tell the user what you created (counts by category
|
|
106
|
+
8. **Payments — once the store has something to sell.** Leave this until the catalog, shipping and settings are in place: connecting a payment provider is a step the *user* has to take in the platform dashboard, so asking for it first interrupts setup and leaves nothing to test the payment against. When the store is otherwise ready, raise it — don't wait to be asked. Card payments are **already implemented** (hosted payment page, payment links, refunds); they only need a provider connected. The other gateway (`offline`) is **manual** — nobody pays online and someone reconciles by hand. So tell the user card payments are ready and ask them to set up the platform's **Stripe integration** for the app (app dashboard → Integrations → Stripe — *not* an OAuth connector; there is no Stripe connector in Base44's catalog), then **redeploy the backend functions** — the platform injects `STRIPE_SECRET_KEY` at deploy time, so a provider connected after the last deploy is invisible to already-deployed functions and the store still reports *no payment provider connected*. On a hosted app, edit any file under `base44/shared/commerce/` (bump the deploy marker at the top of `payments.ts`) to redeploy every `commerce/*` function; on the CLI, `npx base44 functions deploy`. Then confirm with `commerce/admin-tools` → `payment-connector-status`. Nothing to code. The card gateway is enabled by default and stays hidden from customers until a provider is connected, so enabling it early is safe. Declining is a legitimate choice — then switch that gateway off and make the manual flow explicit in the checkout copy. Details: [`references/online-payments.md`](./references/online-payments.md).
|
|
107
|
+
9. **Report back** — tell the user what you created (counts by category, currency, enabled gateways) and that the store is ready at `/store-admin`.
|
|
111
108
|
|
|
112
109
|
Keep the generated catalog small unless asked — roughly 8–15 products across 3–5 categories, with at least one variable product if the business plausibly has options. Cost scales with the catalog: every product is generated copy plus at least one generated image, so confirm before going past that range rather than quietly producing a 50-product store. Products are created as `status: "publish"` only if you set it; leave them `draft` if the user wants to review before going live.
|
|
113
110
|
|
|
@@ -124,7 +121,7 @@ So future agent sessions know the store exists and read the skill before touchin
|
|
|
124
121
|
|
|
125
122
|
## My Skills
|
|
126
123
|
|
|
127
|
-
- `skills/commerce/SKILL.md` — Base44 Commerce template: 24 `commerce.*` entities, 16 `commerce/*` backend functions (storefront + admin APIs + online payments), the shared commerce engine under `base44/shared/commerce/`, the
|
|
124
|
+
- `skills/commerce/SKILL.md` — Base44 Commerce template: 24 `commerce.*` entities, 16 `commerce/*` backend functions (storefront + admin APIs + online payments), the shared commerce engine under `base44/shared/commerce/`, the Store Management UI mounted at `/store-admin`, and the `commerce/StoreAdmin` agent (admin copilot bot in the admin sidebar). Read before working on store features — catalog, cart, checkout, orders, payments, emails, webhooks, or the admin UI.
|
|
128
125
|
```
|
|
129
126
|
|
|
130
127
|
---
|
|
@@ -146,9 +143,9 @@ So the work is UI: stars on cards and the product page, a reviews list, and a su
|
|
|
146
143
|
- anonymous callers get **`401 login_required`** — put the form behind a login prompt instead of hiding reviews from guests;
|
|
147
144
|
- a new review is **`hold`** unless `auto_approve_reviews`, so tell the submitter it's awaiting approval rather than showing it as live;
|
|
148
145
|
- `only_verified_reviews` requires the customer to have a `processing`/`completed` order with that product (**`403 verified_only`**), and `review_rating_required` makes the rating mandatory (**`400 rating_required`**);
|
|
149
|
-
- the store can switch reviews off (`products.enable_reviews`)
|
|
146
|
+
- the store can switch reviews off store-wide (`products.enable_reviews`) — read it and hide the UI when false.
|
|
150
147
|
|
|
151
|
-
Shapes and error codes: [`docs/api-storefront.md`](./docs/api-storefront.md#submit-review). Where ratings belong per view: [`references/product-render.md`](./references/product-render.md).
|
|
148
|
+
Shapes and error codes: [`docs/api-storefront.md`](./docs/api-storefront.md#submit-review--auth). Where ratings belong per view: [`references/product-render.md`](./references/product-render.md).
|
|
152
149
|
|
|
153
150
|
---
|
|
154
151
|
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# The admin product form
|
|
2
|
+
|
|
3
|
+
Where to change what, in `src/commerce/admin/pages/products/`. The shape follows one
|
|
4
|
+
rule: **a product sells variants because it carries attributes** — there is no product
|
|
5
|
+
type — so everything a customer actually buys is edited in one place.
|
|
6
|
+
|
|
7
|
+
## Tabs
|
|
8
|
+
|
|
9
|
+
`components/ProductDataPanel.jsx` builds the list. There are four, and two are conditional:
|
|
10
|
+
|
|
11
|
+
| Tab | File | Holds |
|
|
12
|
+
|---|---|---|
|
|
13
|
+
| **Price & Inventory** | `components/tabs/PriceInventoryTab.jsx` | Tax status/class and *limit to 1 per order* (product-wide) → the **Attributes** section → a row per purchasable thing |
|
|
14
|
+
| **Modifiers** | `components/tabs/ModifiersTab.jsx` | `meta_data` key/value pairs — descriptive properties (Material, Care, GTIN). They never affect price, stock or variants |
|
|
15
|
+
| **Downloads** | `components/tabs/DownloadsTab.jsx` | Only when `downloadable` |
|
|
16
|
+
| **Linked products** | `components/tabs/LinkedTab.jsx` | Upsells and cross-sells |
|
|
17
|
+
|
|
18
|
+
`Virtual` and `Downloadable` are checkboxes in the panel header, not tabs. There is no
|
|
19
|
+
General, Inventory, Variations, Attributes, Shipping, Advanced or External tab — all of
|
|
20
|
+
those were removed, and `scripts/install.js` retires the files on upgrade.
|
|
21
|
+
|
|
22
|
+
## Price & Inventory
|
|
23
|
+
|
|
24
|
+
- **Rows.** A product with no attributes shows one collapsed **Base price** row, which
|
|
25
|
+
edits the product's own fields. A product with attributes shows a row per variant.
|
|
26
|
+
Both go through the same `PriceFields` / inventory / `ShippingFields` / `DownloadsFields`
|
|
27
|
+
components, so there is one definition to change, not two.
|
|
28
|
+
- **Shipping is per row.** Weight, dimensions and shipping class live on each variant (a
|
|
29
|
+
large mug weighs more than a small one); the Base price row edits the product's own.
|
|
30
|
+
Hidden when the product is virtual.
|
|
31
|
+
- **The parent price is not editable** when variants exist — `commerce/admin-products`
|
|
32
|
+
derives `regular_price`, `price` and `on_sale` from the cheapest publishable variant on
|
|
33
|
+
save, and the seeder does the same. Don't add an input for it.
|
|
34
|
+
- **Copy from.** Pick a variant, tick Price / Inventory / Shipping, push it onto the rest.
|
|
35
|
+
`sku` and `image` are deliberately excluded — a copied SKU fails the save with
|
|
36
|
+
`duplicate_sku`.
|
|
37
|
+
|
|
38
|
+
## Attributes drive the variants
|
|
39
|
+
|
|
40
|
+
`components/AttributesSection.jsx` edits `product.attributes[]`; the variant list
|
|
41
|
+
reconciles itself in a `useEffect` in `PriceInventoryTab.jsx`:
|
|
42
|
+
|
|
43
|
+
- adding a value adds the missing combinations, **inheriting the first existing variant** —
|
|
44
|
+
or the product's own price when there is none yet, so adding Size to a €19.99 mug gives
|
|
45
|
+
priced variants;
|
|
46
|
+
- removing a value drops the combinations it made impossible.
|
|
47
|
+
|
|
48
|
+
So there is no *Generate variants* button and no per-variant delete: the attributes are the
|
|
49
|
+
control. Reconciliation is keyed on an attribute signature and **skipped on first render** —
|
|
50
|
+
a product whose stored variants don't match its attributes must not be rewritten before the
|
|
51
|
+
merchant touches anything. Keep that guard if you touch the effect.
|
|
52
|
+
|
|
53
|
+
Attribute and value *records* are shared between products, so the **Manage attributes**
|
|
54
|
+
dialog saves them immediately, unlike the rest of the form which batches until Save. There
|
|
55
|
+
are no Attributes or Tags pages in the side menu: attributes are reachable only from here,
|
|
56
|
+
tags from the `TaxonomyPanel` card in the product sidebar.
|
|
@@ -5,9 +5,10 @@ Transactional email is sent via `base44.integrations.Core.SendEmail` from the sh
|
|
|
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
7
|
- Stock notifications follow the same chain: `inventory.notification_recipient`, else the first `emails.admin_recipients` entry, else the first admin user.
|
|
8
|
-
- The **
|
|
9
|
-
-
|
|
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
|
+
- 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
|
+
- 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.
|
|
10
11
|
- Subjects and headings tolerate a **blank** store name: `{store_name}` substitution drops empty brackets and collapses the gap, so an unnamed store sends `New order #1002`, never `[]: New order #1002`.
|
|
11
12
|
- `emails_sent[]` on each order dedupes lifecycle emails so a re-entered status won't re-send.
|
|
12
|
-
- Deliverability (SPF/DKIM) and the sending address depend on your Base44 email configuration — the store only sets
|
|
13
|
+
- Deliverability (SPF/DKIM) and the sending address depend on your Base44 email configuration — the store only sets the sender *name*. Every send is recorded in the `commerce.EmailLog` entity.
|
|
13
14
|
- **Reading `commerce.EmailLog`:** one row per address per attempt. `target` is `admin` or `customer` — the same split the Recipient column shows in Settings → Emails (stock notifications count as `admin`). `order_id` is the id **users are shown**: the `order_number` from the admin Orders list and every email subject (`#1023` → `1023`). The internal Order record id is in `order_record_id`, which is what the admin routes on. Rows written before these fields existed still carry the record id in `order_id` and have no `target`; they are not backfilled.
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# Guest access & security
|
|
2
2
|
|
|
3
3
|
- Storefront functions are **public** (anonymous invocation); confirm your app allows unauthenticated function calls. Checkout accepts guests unconditionally — there is no store setting that forces login. A login-required store has to enforce that in the shopfront you build (and, if it must not be bypassable, by adding the check to `commerce/storefront-checkout` `place-order`).
|
|
4
|
-
- **All entities are admin-only RLS**, including the catalog (Product, ProductVariation, categories, tax/shipping config, payment gateways). Storefronts read the catalog through `commerce/storefront-catalog` (service role), never by querying entities directly with the client SDK — a direct read from a non-admin is rejected by the backend. This is deliberate: a world-readable catalog row exposes more than a shopper should see (`downloads[].file_url`,
|
|
5
|
-
- The storefront catalog function strips the paywalled fields (`downloads`, `download_limit`, `download_expiry
|
|
4
|
+
- **All entities are admin-only RLS**, including the catalog (Product, ProductVariation, categories, tax/shipping config, payment gateways). Storefronts read the catalog through `commerce/storefront-catalog` (service role), never by querying entities directly with the client SDK — a direct read from a non-admin is rejected by the backend. This is deliberate: a world-readable catalog row exposes more than a shopper should see (`downloads[].file_url`, draft/hidden products, stock and sales internals, gateway `settings` such as the offline gateway's bank details). Do **not** relax any catalog entity's read RLS to `true`; if a shopfront needs a field, surface it through the storefront function instead.
|
|
5
|
+
- The storefront catalog function strips the paywalled fields (`downloads`, `download_limit`, `download_expiry`) from every product/variation it returns. Downloadable files are reached only through `commerce/storefront-account` `get-download`, which enforces ownership, download limits, expiry and short-lived signed URLs — keep that the only path to a `file_url`.
|
|
6
6
|
- `cart_token` and `order_key` are **bearer credentials** — possession grants access to that cart/order. Always serve over HTTPS; don't log them; treat them like secrets.
|
|
7
7
|
- Carts and orders have admin-only RLS; customers never touch those entities directly — all access is mediated by `commerce/storefront-*` functions using the service role after verifying the caller.
|
|
8
8
|
- The admin side has three enforcement layers (UI guard, entity RLS, `requireAdmin()` function guard) — see [`post-installation.md`](../post-installation.md); never weaken them.
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
## Limits & concurrency
|
|
4
4
|
|
|
5
|
-
- **Pagination.** The SDK `filter`/`list` cap out at 5,000 records/page and there is **no total-count API**. Server-side scans use paged loops (`shared/scan.ts` `scanAll`, page size 500). Admin lists use limit+skip with a `limit+1` "has-next" probe — the UI shows *Page N ‹ ›*, never a total.
|
|
5
|
+
- **Pagination.** The SDK `filter`/`list` cap out at 5,000 records/page and there is **no total-count API**. Server-side scans use paged loops (`base44/shared/commerce/scan.ts` `scanAll`, page size 500). Admin lists use limit+skip with a `limit+1` "has-next" probe — the UI shows *Page N ‹ ›*, never a total.
|
|
6
6
|
- **Search** is server-side (`search` actions scan + JS-filter) because entity `filter` is exact-match only.
|
|
7
7
|
- **Reports** scan orders on demand — fine to ~10k orders per range. Beyond that, materialize an `OrderStats` entity updated on each order transition and aggregate from it (sketch: one record per day per status with summed totals; `commerce/admin-reports` reads the pre-aggregated rows instead of scanning `commerce.Order`).
|
|
8
8
|
- **No transactions.** A few consequences, all documented in code:
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
- `base44/shared/commerce/payments.ts` — the **provider-neutral payment utility** every caller uses, plus the one-line `ACTIVE_PROVIDER` binding.
|
|
6
6
|
- `base44/shared/commerce/stripe.ts` — the Stripe adapter (the only file that speaks Stripe).
|
|
7
|
-
- `base44/functions/commerce/payments/` — `status` · `create-link` · `verify`.
|
|
7
|
+
- `base44/functions/commerce/payments/` — `status` · `create-link` · `complete-return` · `verify`.
|
|
8
8
|
- `base44/functions/commerce/payment-webhook/` — the provider's signed callback.
|
|
9
9
|
- `commerce/storefront-checkout` `place-order` returns a `checkout_url`; `commerce/admin-refunds` refunds through the provider; the admin order page has a **Payment** panel.
|
|
10
10
|
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
|
|
13
13
|
## 1. What to do when building a store (strong recommendation, not a rule)
|
|
14
14
|
|
|
15
|
-
A store that can't take a card is usually not what the user wants: the
|
|
15
|
+
A store that can't take a card is usually not what the user wants: the gateway that works with no provider at all (`offline`) is **manual** — the customer leaves without paying and someone reconciles by hand. So **recommend online payment clearly** — but **at the right moment, which is not first.**
|
|
16
16
|
|
|
17
17
|
**Sequence it after the store exists.** Connecting a provider is a step only the *user* can take, in the platform dashboard: asking for it up front stops your work dead on someone else's action, and even once done there is nothing to verify against — no product to buy, no shipping method, no order. Set the store up first (settings → catalog → shipping), *then* raise payments, as the last thing before the store is usable or before you build checkout. The card gateway ships enabled and stays hidden from customers until a provider is connected, so nothing is broken while you wait, and nothing is lost by doing it late.
|
|
18
18
|
|
|
@@ -60,11 +60,11 @@ Card data never touches this app — the customer pays on the provider's page.
|
|
|
60
60
|
|
|
61
61
|
1. `success_url` / `cancel_url` — a full page URL per outcome;
|
|
62
62
|
2. `return_url` — **your return page**, used for both outcomes (`payment=success|cancel` distinguishes them). A bare origin (`https://shop`, no path) is instead treated as a base, since there's no page in it;
|
|
63
|
-
3. the
|
|
63
|
+
3. the origin the request came from (the storefront that called this) + the store's **Payment return path** (`settings.general.order_received_path`).
|
|
64
64
|
|
|
65
65
|
Every result carries `order_id`, `order_key` and `payment`, **including URLs you supplied** — a `success_url` without them can't verify anything. Existing query parameters survive. With nothing to build from, the call fails `400 return_url_required` rather than letting the provider answer "Not a valid URL".
|
|
66
66
|
|
|
67
|
-
**Set the return path to whatever route you built.** It defaults to `/order-received`, and a wrong value sends paying customers to a 404 — including the confusing `/<your-page>/<your-page>` you get if you treat `return_url` as a base and your page path as a suffix. The admin's payment links deliberately pass no URL at all: the route belongs to the storefront, so they
|
|
67
|
+
**Set the return path to whatever route you built.** It defaults to `/order-received`, and a wrong value sends paying customers to a 404 — including the confusing `/<your-page>/<your-page>` you get if you treat `return_url` as a base and your page path as a suffix. The admin's payment links deliberately pass no URL at all: the route belongs to the storefront, so they resolve to the app's own origin plus the store's configured path.
|
|
68
68
|
|
|
69
69
|
### MANDATORY: the payment return page (`/order-received`)
|
|
70
70
|
|
|
@@ -101,7 +101,7 @@ Rules for whatever you build:
|
|
|
101
101
|
- **Handle all three states**, not just the happy one — `unpaid` is common (bank delays, closed tabs) and needs a *Check again* plus *Pay now*, not an error.
|
|
102
102
|
- **Don't gate it behind login.** Guests pay too; `order_key` in the link is the credential.
|
|
103
103
|
|
|
104
|
-
**Storefront wiring** (the only integration work)
|
|
104
|
+
**Storefront wiring** (the only integration work):
|
|
105
105
|
|
|
106
106
|
```js
|
|
107
107
|
const res = await inv("commerce/storefront-checkout", {
|
|
@@ -128,7 +128,7 @@ The order page's **Payment** panel shows the method, total, paid state and payme
|
|
|
128
128
|
- **Create payment link** — the provider-hosted page for exactly that order's total; copy it to the customer.
|
|
129
129
|
- **Check payment** — re-asks the provider and moves the order on if the money arrived.
|
|
130
130
|
|
|
131
|
-
**The order's current payment method doesn't matter.** Asking for a link *is* the decision to collect online, and an admin-created order starts with no method at all, so `create-link` accepts any unpaid order and switches it onto the online gateway, recording the change in the order log (`Payment method set to Credit card (was
|
|
131
|
+
**The order's current payment method doesn't matter.** Asking for a link *is* the decision to collect online, and an admin-created order starts with no method at all, so `create-link` accepts any unpaid order and switches it onto the online gateway, recording the change in the order log (`Payment method set to Credit card (was Offline payment)…`). That switch matters beyond tidiness: refunds key off `payment_method`, so an order paid by card must say so or it can't be refunded through the provider. The panel warns before it happens.
|
|
132
132
|
|
|
133
133
|
The one refusal is a store that has **turned online payment off** — `400 online_payments_disabled` — because that's the operator declining it, not a missing setup.
|
|
134
134
|
|
|
@@ -186,9 +186,9 @@ Rules for any checkout UI you build:
|
|
|
186
186
|
- **Fetch, don't assume.** `commerce/storefront-catalog` → `get-store-info` returns `payment_gateways`, filtered to `enabled: true`, sorted by the admin's `order`, and with any online gateway **dropped while no provider is connected** — so what you render is always payable. Each entry is `{ slug, title, description, online }`.
|
|
187
187
|
- **Use the admin's copy.** Show `title`/`description` as the customer-facing labels instead of your own strings, and key your logic off `slug` (`online: true` marks the one that redirects to a hosted page).
|
|
188
188
|
- **Render the zero-state honestly.** An empty `payment_gateways` means checkout cannot complete; say so rather than showing a dead button.
|
|
189
|
-
- **Let the server arbitrate.** Gateway `settings` are intentionally *not* exposed to the storefront, so the client cannot evaluate
|
|
189
|
+
- **Let the server arbitrate.** Gateway `settings` are intentionally *not* exposed to the storefront, so the client cannot evaluate a gateway's own rules. Send the chosen `slug` and treat `400 invalid_payment_method` from `place-order` as the authoritative answer.
|
|
190
190
|
- **Re-fetch rather than cache hard.** An admin can enable, disable or reorder gateways at any time; a long-lived cached list is how a storefront drifts out of sync with the store.
|
|
191
|
-
- **Show `payment_instructions` from the response** for
|
|
191
|
+
- **Show `payment_instructions` from the response** for the `offline` flow rather than hardcoding bank details in the UI.
|
|
192
192
|
|
|
193
193
|
## 8. Never hardcode payment availability
|
|
194
194
|
|
|
@@ -5,7 +5,7 @@ What to show for a product, and **which view can show it**. Both storefront surf
|
|
|
5
5
|
| View | Call | Returns |
|
|
6
6
|
|---|---|---|
|
|
7
7
|
| Listing / grid / search results / tag & category pages | `commerce/storefront-catalog` `list-products` | `{ products: [row...], page, per_page, has_next }` |
|
|
8
|
-
| Product page | `commerce/storefront-catalog` `get-product` | `{ product, variations, categories, tags, reviews, upsells, cross_sells
|
|
8
|
+
| Product page | `commerce/storefront-catalog` `get-product` | `{ product, variations, categories, tags, reviews, upsells, cross_sells }` |
|
|
9
9
|
|
|
10
10
|
Request/response shapes: [`../docs/api-storefront.md`](../docs/api-storefront.md). Variant selection mechanics (axes, resolving a selection to a variation, unavailable combinations): [`storefront-product-page.md`](./storefront-product-page.md).
|
|
11
11
|
|
|
@@ -19,48 +19,50 @@ A listing **row** is the product record itself (minus paywalled fields), plus re
|
|
|
19
19
|
|
|
20
20
|
| Data | `list-products` row | `get-product` | Notes |
|
|
21
21
|
|---|---|---|---|
|
|
22
|
-
| `id`, `name`, `slug`, `
|
|
23
|
-
| `price`, `regular_price`, `sale_price`, `on_sale` | ✅ | ✅ |
|
|
22
|
+
| `id`, `name`, `slug`, `status` | ✅ | ✅ | There is no `type` field — a product sells variants when `attributes[]` is non-empty |
|
|
23
|
+
| `price`, `regular_price`, `sale_price`, `on_sale` | ✅ | ✅ | With variants the parent `price` is a starting point, not the truth — see §3 |
|
|
24
24
|
| `images[]`, `featured`, `short_description`, `description` | ✅ | ✅ | Cards normally use `images[0]` + `short_description`. `images` can be **empty** — render a placeholder, don't leave a broken `img` (on the product page `resolveSelection`'s `display.image` is `null` in that same case) |
|
|
25
25
|
| `sku`, `stock_status`, `stock_quantity`, `manage_stock`, `backorders` | ✅ | ✅ | |
|
|
26
26
|
| `average_rating`, `rating_count`, `total_sales` | ✅ | ✅ | Enough for stars on a card; the reviews themselves are not in a row |
|
|
27
|
-
| `downloadable`, `virtual`, `weight`, `dimensions`, `attributes[]`, `meta_data` | ✅ | ✅ | `attributes[]` is the
|
|
28
|
-
| **`tags`** (resolved `{id, name
|
|
27
|
+
| `downloadable`, `virtual`, `weight`, `dimensions`, `attributes[]`, `meta_data` | ✅ | ✅ | `attributes[]` is the variant axes; `meta_data` is the descriptive properties the admin calls **modifiers** |
|
|
28
|
+
| **`tags`** (resolved `{id, name}`) | ✅ | ✅ | `get-product` returns the full tag records; rows carry the short form |
|
|
29
29
|
| `tag_ids`, `category_ids` | ✅ | ✅ | Raw ids |
|
|
30
30
|
| **`categories`** (resolved) | ❌ *ids only* | ✅ | See §5 to add |
|
|
31
|
-
| **`variations[]`** (per-
|
|
31
|
+
| **`variations[]`** (per-variant price/stock/image/attributes) | ❌ | ✅ | The reason a product with variants can't be fully priced from a row |
|
|
32
32
|
| **`reviews`** (paged items + `average_rating`/`rating_count`) | ❌ | ✅ | Row still has the aggregate numbers |
|
|
33
33
|
| **`upsells`, `cross_sells`** (summaries) | ❌ | ✅ | `{id, name, slug, price, on_sale, image}` |
|
|
34
|
-
|
|
|
35
|
-
| `downloads[]`, `download_limit`, `download_expiry`, `purchase_note` | ❌ | ❌ | **Never** exposed publicly — paywalled/post-purchase. Gated by `commerce/storefront-account` `get-download` |
|
|
34
|
+
| `downloads[]`, `download_limit`, `download_expiry` | ❌ | ❌ | **Never** exposed publicly — paywalled. Gated by `commerce/storefront-account` `get-download` |
|
|
36
35
|
|
|
37
36
|
## 2. Tags — render them in **both** views
|
|
38
37
|
|
|
39
38
|
Tags are a flat, cross-cutting axis ("gift", "summer", "vegan"); categories are the hierarchical spine. Generated storefronts routinely omit tags entirely. Don't.
|
|
40
39
|
|
|
41
|
-
- **Listing rows carry resolved `tags`** (`{id, name
|
|
42
|
-
- **On the product page**, render `product.tags` (the top-level `tags` array
|
|
43
|
-
- **Every chip links to a filtered listing** — `list-products` with `tag_id` — never a dead label.
|
|
40
|
+
- **Listing rows carry resolved `tags`** (`{id, name}`), so a card can render them with no extra call. One or two chips per card is useful ("New", "Bundle"); more is noise. Hide the block when the array is empty — never a dangling "Tags:" label.
|
|
41
|
+
- **On the product page**, render `product.tags` (the top-level `tags` array) as chips near the metadata, visually lighter than the category breadcrumb.
|
|
42
|
+
- **Every chip links to a filtered listing** — `list-products` with `tag_id` — never a dead label. A tag has no slug, so key the URL on its id (`/shop?tag=<tag_id>`) so the page is shareable and survives reload. Use `list-tags` for a name to label it with.
|
|
44
43
|
- **Offer tags as a listing filter** from `list-tags`, which hides tags no published product carries and gives a `count` for labels like "Gift (12)". `tag_id` stacks with `category_id`, price, `on_sale`, `featured`, `in_stock_only`.
|
|
45
|
-
- **Tag landing page:** `list-products` filtered by `tag_id`, with the tag's `name` as heading
|
|
44
|
+
- **Tag landing page:** `list-products` filtered by `tag_id`, with the tag's `name` as the heading.
|
|
46
45
|
- Don't put tags in the breadcrumb, and don't use them as variant options — a size or colour is an `attribute`, not a tag.
|
|
46
|
+
- Descriptive properties (Material, Care) are **modifiers** in `meta_data`, not tags and not attributes. Render them as a spec table on the product page.
|
|
47
47
|
|
|
48
|
-
## 3. Product
|
|
48
|
+
## 3. Product shapes in each view
|
|
49
49
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
|
53
|
-
|
|
54
|
-
| `
|
|
55
|
-
| `
|
|
50
|
+
There are only two shapes, and **`attributes[]` tells them apart** — there is no `type` field:
|
|
51
|
+
|
|
52
|
+
| Shape | Test | Card | Product page |
|
|
53
|
+
|---|---|---|---|
|
|
54
|
+
| **Single item** | `attributes` is empty | price, **Add to cart** straight from the card if you want one | single **Add to cart** — `resolveSelection` returns no axes, `complete: true`, a ready `addToCart` |
|
|
55
|
+
| **Has variants** | `attributes` is non-empty | price **range or `From €19`** (see below), never selectors — link through | one selector per attribute: [`storefront-product-page.md`](./storefront-product-page.md) |
|
|
56
|
+
|
|
57
|
+
A product with attributes is **only** sellable through a variant: `add-item` without a `variation_id` is `400 variation_required`, and there is no fall-back to the parent. So one with attributes but no variations is unsellable by design, not by accident.
|
|
56
58
|
|
|
57
|
-
Downloadable/virtual products are
|
|
59
|
+
Downloadable/virtual products are either shape with `downloadable: true` — see [`media-and-downloads.md`](./media-and-downloads.md).
|
|
58
60
|
|
|
59
|
-
### A
|
|
61
|
+
### A variant parent's `price` is its *from* price
|
|
60
62
|
|
|
61
|
-
|
|
63
|
+
`admin-products` rolls both **stock** and **price** up from the variations: a parent's `regular_price`, `price` and `on_sale` are derived from the cheapest publishable variant on every save. So the parent price is real and safe to sort and filter on — but it is the **lowest** price, not *the* price.
|
|
62
64
|
|
|
63
|
-
|
|
65
|
+
A listing row has no `variations`, so a card still cannot compute the full range from a row alone: render `From {product.price}`, never the parent price presented as *the* price. On the product page you have `variations`, so show the real range via `priceRange` from the variant helpers.
|
|
64
66
|
|
|
65
67
|
## 4. Sensible defaults per view
|
|
66
68
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Variant selection on the product page
|
|
2
2
|
|
|
3
|
-
The one interaction agents reliably get wrong: turning a
|
|
3
|
+
The one interaction agents reliably get wrong: turning a product with variants into controls a customer can buy from. Everything else about the page — what to show, and which fields each call returns — is [`product-render.md`](./product-render.md); request/response shapes are [`docs/api-storefront.md`](../docs/api-storefront.md).
|
|
4
4
|
|
|
5
5
|
The logic ships: **`src/commerce/utils/variants.js`** (framework-free, no I/O, full JSDoc on every export — read it for signatures). Import it and spend your effort on the markup.
|
|
6
6
|
|
|
@@ -8,7 +8,7 @@ The logic ships: **`src/commerce/utils/variants.js`** (framework-free, no I/O, f
|
|
|
8
8
|
|
|
9
9
|
## 1. The model, and the rule
|
|
10
10
|
|
|
11
|
-
`get-product` returns `{ product, variations }`. `product.attributes[]`
|
|
11
|
+
`get-product` returns `{ product, variations }`. **Every** `product.attributes[]` entry is an **axis**, in `position` order; each `variations[]` record is one combination. There is no product type and no per-attribute flag — carrying attributes is what makes a product sell variants. Descriptive properties are `meta_data` (the admin calls them modifiers) — a spec table, **never** a selector.
|
|
12
12
|
|
|
13
13
|
**One control per axis. Never one option per variation.** Do not flatten `variations[]` into `Red / S`, `Red / M`, `Blue / S`, … — that is `n × m` noise, it hides the product's structure, and it stops scaling immediately.
|
|
14
14
|
|
|
@@ -43,16 +43,16 @@ Bind the UI to `view` — not to `product.*` — so a selection actually changes
|
|
|
43
43
|
| `purchasable` | gate **Add to cart** on this (resolved *and* buyable) |
|
|
44
44
|
| `addToCart` | `{ product_id, variation_id }` to spread into the cart call, or `null` |
|
|
45
45
|
|
|
46
|
-
`view.variation` and `view.candidates` are there when you need the record itself or the variations still reachable. For a
|
|
46
|
+
`view.variation` and `view.candidates` are there when you need the record itself or the variations still reachable. For a product with no attributes `resolveSelection` returns no axes, `complete: true` and a ready `addToCart` — the same code path; it branches on whether there are axes, so `variations` can be `[]`.
|
|
47
47
|
|
|
48
|
-
A **
|
|
48
|
+
A **product with attributes but no usable variations** — the state attaching an attribute and stopping leaves you in — is deliberately *not* given a parent fallback, because `add-item` would reject it. You get axes whose `options` arrays are empty, `purchasable: false` and `addToCart: null`. Render it as unavailable rather than painting selector groups with no buttons, and guard the "Select a …" label: with no attributes at all, `missingAxes` is empty and `view.missingAxes[0]?.name` is `undefined`.
|
|
49
49
|
|
|
50
50
|
The other exports cover what the view model doesn't: `selectionForVariation` (variation → selection, to hydrate controls from a cart line or `?variation=` link), `selectionToParams`/`selectionFromParams` (URL round-trip), `priceRange`, and the individual pieces if you build your own view model. Signatures and edge cases are in the JSDoc; two behaviors are easy to miss when hand-rolling: non-publishable variations never leak an option into the UI, and an empty `option` on an axis means **"any"**.
|
|
51
51
|
|
|
52
52
|
## 3. The three UI decisions the helpers can't make for you
|
|
53
53
|
|
|
54
54
|
- **Unavailable vs out of stock.** `"unavailable"` (no such combination) → **disable, don't hide**; options that vanish and reappear as the customer clicks are disorienting. `"out_of_stock"` → keep it visible and label it. `onbackorder` counts as available and purchasable — label it ("Ships in 2–3 weeks"), don't disable it.
|
|
55
|
-
- **Incomplete selection.** Show `view.priceRange` (`$21.99 – $23.99`, or `From $21.99`) — never `$0`, and never the parent `price`, which for a
|
|
55
|
+
- **Incomplete selection.** Show `view.priceRange` (`$21.99 – $23.99`, or `From $21.99`) — never `$0`, and never the parent `price`, which for a product with variants is not its price ([`product-render.md` §3](./product-render.md#3-product-shapes-in-each-view)). Keep **Add to cart** disabled with a hint at what's missing.
|
|
56
56
|
- **Initial state and linkability.** Start from `defaultSelection` (honours the merchant's `default_attributes`, preselects single-option axes). Mirror the selection into the URL with `selectionToParams` and hydrate with `selectionFromParams`, so a chosen variant is shareable and survives a refresh.
|
|
57
57
|
|
|
58
58
|
Route every click through `selectOption` and dead ends take care of themselves: it keeps the just-picked option and clears only conflicting axes (Red/L → click Blue → `{ Color: Blue }`, size re-open) instead of leaving the customer on a combination that resolves to nothing.
|
|
@@ -63,21 +63,21 @@ Route every click through `selectOption` and dead ends take care of themselves:
|
|
|
63
63
|
await base44.functions.invoke("commerce/storefront-cart", {
|
|
64
64
|
action: "add-item",
|
|
65
65
|
cart_token,
|
|
66
|
-
...view.addToCart, // { product_id, variation_id } — variation_id is REQUIRED
|
|
66
|
+
...view.addToCart, // { product_id, variation_id } — variation_id is REQUIRED once a product has attributes
|
|
67
67
|
quantity,
|
|
68
68
|
});
|
|
69
69
|
```
|
|
70
70
|
|
|
71
|
-
Handle `400 variation_required` (empty `variation_id` on a
|
|
71
|
+
Handle `400 variation_required` (empty `variation_id` on a product with attributes — the page has a bug), the stock codes (`out_of_stock`, `insufficient_stock`, …), and `404 variation_not_found` (catalog changed underneath the page: refetch `get-product`). `product.sold_individually` caps the quantity control at 1.
|
|
72
72
|
|
|
73
73
|
## 5. Checklist
|
|
74
74
|
|
|
75
75
|
- [ ] One control per axis in `view.axes` — no combination list anywhere in the UI.
|
|
76
|
-
- [ ]
|
|
76
|
+
- [ ] `meta_data` modifiers render as specs, not selectors.
|
|
77
77
|
- [ ] Image, price, SKU and availability all come from `view.display`, with a placeholder for `display.image === null`.
|
|
78
78
|
- [ ] Incomplete selection shows `view.priceRange` — never `$0`, never the parent `price`.
|
|
79
79
|
- [ ] `"unavailable"` disabled, `"out_of_stock"` labelled; every click routed through `selectOption`.
|
|
80
80
|
- [ ] Initial state from `defaultSelection`; selection mirrored into the URL.
|
|
81
81
|
- [ ] **Add to cart** gated on `view.purchasable`, sending `view.addToCart`.
|
|
82
|
-
- [ ] A
|
|
82
|
+
- [ ] A product with empty `axes[].options` shows as unavailable — no empty selector groups, no "Select a undefined".
|
|
83
83
|
- [ ] Tags, ratings, upsells and the rest per [`product-render.md`](./product-render.md).
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
# Webhooks
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Managed in the admin at **Settings → Webhooks** (`settings/webhooks`; the source still lives under `pages/status/`).
|
|
4
|
+
|
|
5
|
+
`webhooks.ts` `dispatch()` fires on `order.*`, `product.*`, `customer.*`, `coupon.*` (created/updated/deleted, note that `restored` is selectable on a webhook but **never emitted** — nothing dispatches it). Each active `commerce.Webhook` matching the topic receives an HTTP POST with headers `X-Commerce-Webhook-Topic/-Resource/-Event/-ID/-Delivery-ID/-Signature`. The signature is **base64 HMAC-SHA256** of the body, keyed by the webhook's `secret` (Web Crypto).
|
|
4
6
|
|
|
5
7
|
- Every attempt is logged as a `commerce.WebhookDelivery` (request/response bodies truncated to 32 KB). Prune with `commerce/admin-tools` `prune-webhook-deliveries`.
|
|
6
8
|
- `failure_count` increments on non-2xx/timeout and resets on success; a webhook auto-disables after **5** consecutive failures.
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# Store
|
|
1
|
+
# Store Management (`src/commerce/admin/`)
|
|
2
2
|
|
|
3
3
|
React admin UI for the Base44 commerce template. Copy this
|
|
4
4
|
folder into a Base44 app built on the default template (Vite + React +
|
|
@@ -27,14 +27,14 @@ Tailwind + shadcn/ui + React Router) to get a full store back office.
|
|
|
27
27
|
```jsx
|
|
28
28
|
import AdminApp from "@/commerce/admin";
|
|
29
29
|
|
|
30
|
-
<Route path="/admin/*" element={<AdminApp />} />
|
|
30
|
+
<Route path="/store-admin/*" element={<AdminApp />} />
|
|
31
31
|
// mounted elsewhere? → <AdminApp basePath="/backoffice" />
|
|
32
32
|
```
|
|
33
33
|
|
|
34
34
|
5. Make sure your user has the **admin role** (Base44 dashboard → Users, or
|
|
35
35
|
`base44.users.inviteUser(email, "admin")`). Signed-in users without the
|
|
36
36
|
admin role get an access-denied screen — do not weaken this check.
|
|
37
|
-
6. Open `/admin`. If the store has never been seeded (no `general` StoreSettings
|
|
37
|
+
6. Open `/store-admin`. If the store has never been seeded (no `general` StoreSettings
|
|
38
38
|
group) the first-run screen appears and seeds store defaults, optionally with
|
|
39
39
|
sample data when the store has no products yet. If `commerce/seed-store` was
|
|
40
40
|
already run during installation — including when an agent generated a real
|
|
@@ -61,7 +61,7 @@ backend functions directly as its tools.
|
|
|
61
61
|
|
|
62
62
|
alert, alert-dialog, badge, button, calendar, card, checkbox, command, dialog,
|
|
63
63
|
dropdown-menu, input, label, popover, radio-group, scroll-area, select,
|
|
64
|
-
separator, sheet, skeleton, switch, table, tabs, textarea
|
|
64
|
+
separator, sheet, skeleton, switch, table, tabs, textarea
|
|
65
65
|
|
|
66
66
|
The default Base44 template ships all of these. If one is missing:
|
|
67
67
|
|
|
@@ -2,15 +2,15 @@ import React, { createContext, useContext } from "react";
|
|
|
2
2
|
import { normalizeBasePath } from "../lib/paths";
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
|
-
* The URL prefix the admin app is mounted under (default `/admin`).
|
|
5
|
+
* The URL prefix the admin app is mounted under (default `/store-admin`).
|
|
6
6
|
* All internal links are built from this, so the admin folder works no matter
|
|
7
7
|
* where the consumer mounts it: `<AdminApp basePath="/backoffice" />`.
|
|
8
8
|
*/
|
|
9
|
-
const BasePathContext = createContext("/admin");
|
|
9
|
+
const BasePathContext = createContext("/store-admin");
|
|
10
10
|
|
|
11
|
-
export function BasePathProvider({ value = "/admin", children }) {
|
|
11
|
+
export function BasePathProvider({ value = "/store-admin", children }) {
|
|
12
12
|
// normalizeBasePath also tolerates the route *pattern* being passed in
|
|
13
|
-
// (`basePath="/admin/*"`), an easy copy/paste slip from the mount — every
|
|
13
|
+
// (`basePath="/store-admin/*"`), an easy copy/paste slip from the mount — every
|
|
14
14
|
// internal link would otherwise carry a literal "*" segment.
|
|
15
15
|
return <BasePathContext.Provider value={normalizeBasePath(value)}>{children}</BasePathContext.Provider>;
|
|
16
16
|
}
|
|
@@ -19,7 +19,7 @@ export function useBasePath() {
|
|
|
19
19
|
return useContext(BasePathContext);
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
-
/** Returns a builder: href("orders/123") → "/admin/orders/123"; href() → "/admin". */
|
|
22
|
+
/** Returns a builder: href("orders/123") → "/store-admin/orders/123"; href() → "/store-admin". */
|
|
23
23
|
export function useAdminHref() {
|
|
24
24
|
const base = useBasePath();
|
|
25
25
|
return (path = "") => (path ? `${base}/${String(path).replace(/^\/+/, "")}` : base);
|