@base44/app-plugin-commerce 0.1.3 → 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. package/README.md +11 -11
  2. package/base44/agents/commerce/StoreAdmin.jsonc +7 -7
  3. package/base44/entities/commerce.Order.jsonc +1 -1
  4. package/base44/entities/commerce.PaymentGateway.jsonc +3 -3
  5. package/base44/entities/commerce.Product.jsonc +9 -45
  6. package/base44/entities/commerce.ProductAttribute.jsonc +7 -19
  7. package/base44/entities/commerce.ProductAttributeTerm.jsonc +4 -10
  8. package/base44/entities/commerce.ProductTag.jsonc +1 -8
  9. package/base44/entities/commerce.ProductVariation.jsonc +1 -1
  10. package/base44/entities/commerce.StoreSettings.jsonc +1 -1
  11. package/base44/functions/commerce/admin-products/entry.ts +73 -121
  12. package/base44/functions/commerce/admin-refunds/entry.ts +1 -1
  13. package/base44/functions/commerce/admin-reports/entry.ts +1 -1
  14. package/base44/functions/commerce/admin-tools/entry.ts +4 -3
  15. package/base44/functions/commerce/payments/entry.ts +0 -2
  16. package/base44/functions/commerce/seed-store/defaults.ts +13 -39
  17. package/base44/functions/commerce/seed-store/entry.ts +50 -166
  18. package/base44/functions/commerce/seed-store/sample-data.ts +28 -66
  19. package/base44/functions/commerce/seed-store/seed-catalog.ts +717 -0
  20. package/base44/functions/commerce/storefront-cart/entry.ts +0 -7
  21. package/base44/functions/commerce/storefront-catalog/entry.ts +12 -21
  22. package/base44/functions/commerce/storefront-checkout/entry.ts +5 -12
  23. package/base44/shared/commerce/catalog.ts +134 -0
  24. package/base44/shared/commerce/coupons.ts +4 -7
  25. package/base44/shared/commerce/email-templates.ts +15 -13
  26. package/base44/shared/commerce/emails.ts +4 -3
  27. package/base44/shared/commerce/payments.ts +8 -20
  28. package/base44/shared/commerce/products.ts +24 -0
  29. package/base44/shared/commerce/settings.ts +2 -3
  30. package/base44/shared/commerce/stock.ts +2 -4
  31. package/base44/shared/commerce/totals.ts +37 -42
  32. package/package.json +1 -1
  33. package/scripts/install.js +23 -7
  34. package/skills/commerce/SKILL.md +9 -7
  35. package/skills/commerce/docs/api-admin.md +20 -14
  36. package/skills/commerce/docs/api-storefront.md +39 -35
  37. package/skills/commerce/installation-guidelines.md +8 -7
  38. package/skills/commerce/post-installation.md +205 -84
  39. package/skills/commerce/references/admin-product-form.md +56 -0
  40. package/skills/commerce/references/emails.md +4 -3
  41. package/skills/commerce/references/guest-access-security.md +2 -2
  42. package/skills/commerce/references/limits-and-performance.md +1 -1
  43. package/skills/commerce/references/online-payments.md +8 -8
  44. package/skills/commerce/references/product-render.md +25 -23
  45. package/skills/commerce/references/reviews.md +20 -0
  46. package/skills/commerce/references/storefront-product-page.md +9 -9
  47. package/skills/commerce/references/webhooks.md +3 -1
  48. package/src/commerce/admin/README.md +4 -4
  49. package/src/commerce/admin/context/BasePathContext.jsx +5 -5
  50. package/src/commerce/admin/context/SettingsContext.jsx +4 -4
  51. package/src/commerce/admin/index.jsx +3 -3
  52. package/src/commerce/admin/layout/Sidebar.jsx +1 -9
  53. package/src/commerce/admin/lib/constants.js +0 -7
  54. package/src/commerce/admin/lib/paths.js +6 -6
  55. package/src/commerce/admin/lib/product-utils.js +18 -15
  56. package/src/commerce/admin/pages/orders/components/AddProductDialog.jsx +8 -8
  57. package/src/commerce/admin/pages/products/ProductEditor.jsx +10 -12
  58. package/src/commerce/admin/pages/products/ProductsList.jsx +3 -15
  59. package/src/commerce/admin/pages/products/components/AttributesSection.jsx +426 -0
  60. package/src/commerce/admin/pages/products/components/ProductDataPanel.jsx +33 -69
  61. package/src/commerce/admin/pages/products/components/TaxonomyPanel.jsx +1 -1
  62. package/src/commerce/admin/pages/products/components/tabs/LinkedTab.jsx +1 -14
  63. package/src/commerce/admin/pages/products/components/tabs/ModifiersTab.jsx +19 -0
  64. package/src/commerce/admin/pages/products/components/tabs/PriceInventoryTab.jsx +728 -0
  65. package/src/commerce/admin/pages/reports/Reports.jsx +3 -3
  66. package/src/commerce/admin/pages/settings/EmailsSettings.jsx +12 -14
  67. package/src/commerce/admin/pages/settings/GeneralSettings.jsx +6 -116
  68. package/src/commerce/admin/pages/settings/PaymentsSettings.jsx +101 -62
  69. package/src/commerce/admin/pages/settings/SettingsLayout.jsx +2 -6
  70. package/src/commerce/admin/pages/settings/TaxSettings.jsx +0 -1
  71. package/src/commerce/admin/pages/status/WebhookEditor.jsx +3 -3
  72. package/src/commerce/admin/pages/status/Webhooks.jsx +2 -2
  73. package/src/commerce/admin/routes.jsx +10 -19
  74. package/src/commerce/utils/index.js +2 -2
  75. package/src/commerce/utils/shipping-promos.js +9 -6
  76. package/src/commerce/utils/variants.js +21 -21
  77. package/src/commerce/admin/pages/products/AttributeTerms.jsx +0 -180
  78. package/src/commerce/admin/pages/products/Attributes.jsx +0 -183
  79. package/src/commerce/admin/pages/products/Tags.jsx +0 -150
  80. package/src/commerce/admin/pages/products/components/tabs/AdvancedTab.jsx +0 -48
  81. package/src/commerce/admin/pages/products/components/tabs/AttributesTab.jsx +0 -208
  82. package/src/commerce/admin/pages/products/components/tabs/ExternalTab.jsx +0 -41
  83. package/src/commerce/admin/pages/products/components/tabs/GeneralTab.jsx +0 -103
  84. package/src/commerce/admin/pages/products/components/tabs/InventoryTab.jsx +0 -93
  85. package/src/commerce/admin/pages/products/components/tabs/ShippingTab.jsx +0 -86
  86. package/src/commerce/admin/pages/products/components/tabs/VariationsTab.jsx +0 -377
@@ -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 gateways that work with no provider at all (`cod`, `bacs`, `cheque`) are **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.**
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 store's **Storefront URL** + **Payment return path** (both in general settings: `store_url`, `order_received_path`), or the origin the request came from + that path.
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 use the store's configured path.
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):**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 Cash on delivery)…`). 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.
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 rules like `cod`'s `enable_for_methods` / `enable_for_virtual`. Send the chosen `slug` and treat `400 invalid_payment_method` from `place-order` as the authoritative answer.
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 manual flows (`bacs`/`cheque`/`cod`) rather than hardcoding bank details in the UI.
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, grouped_products }` |
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`, `type`, `status` | ✅ | ✅ | |
23
- | `price`, `regular_price`, `sale_price`, `on_sale` | ✅ | ✅ | For `variable`, the parent `price` is a starting point, not the truth — see §3 |
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 raw axes/spec array |
28
- | **`tags`** (resolved `{id, name, slug}`) | ✅ | ✅ | `get-product` returns the full tag records; rows carry the short form |
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-variation price/stock/image/attributes) | ❌ | ✅ | The reason a variable product can't be fully priced from a row |
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
- | **`grouped_products`** (children of a grouped product) | ❌ | ✅ | |
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, slug}`), 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.
42
- - **On the product page**, render `product.tags` (the top-level `tags` array, with `description` and `count` available) as chips near the metadata, visually lighter than the category breadcrumb.
43
- - **Every chip links to a filtered listing** — `list-products` with `tag_id` — never a dead label. Keep the tag in the URL (`/shop?tag=<slug>` or `/tag/<slug>`) so the page is shareable and survives reload; resolve slug → id with `list-tags`.
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 and `description` as intro copy when present.
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 types in each view
48
+ ## 3. Product shapes in each view
49
49
 
50
- | `product.type` | Card | Product page |
51
- |---|---|---|
52
- | `simple` | 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` |
53
- | `variable` | price **range or `From €19`** (see below), never selectors — link through | one selector per attribute: [`storefront-product-page.md`](./storefront-product-page.md) |
54
- | `grouped` | link through, no cart action | not purchasable itself: render `grouped_products` as children with their own quantity inputs, each added as its own cart line |
55
- | `external` | link through | `product.button_text` linking to `product.external_url`; no cart interaction |
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 `simple`/`variable` with `downloadable: true` — see [`media-and-downloads.md`](./media-and-downloads.md).
59
+ Downloadable/virtual products are either shape with `downloadable: true` — see [`media-and-downloads.md`](./media-and-downloads.md).
58
60
 
59
- ### A variable product's parent `price` is not its price
61
+ ### A variant parent's `price` is its *from* price
60
62
 
61
- The backend rolls **stock** up from variations to the parent, but **not price** — a variable parent's `price` is only whatever was set on the parent record, often nothing. A row has no `variations`, so a card cannot compute the real range; show `From {product.price}` or a range, never the parent price presented as *the* price, and never `€0`.
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
- This is a **data** problem before it is a UI one: `list-products` sorts and filters on `product.price ?? 0`, so a variable product with no parent price **sorts as free and drops out of every price-range filter**. Fix it in the catalog — set the parent's `regular_price` to the lowest variation price (purchasing is unaffected: a variable line always prices from its variation). Then cards, sorting and filters all agree.
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
 
@@ -0,0 +1,20 @@
1
+ # Reviews
2
+
3
+ **Unless you are sure the store doesn't want reviews, implement them: the backend already exists.** Skipping them is the common miss, and it costs the store the social proof most shoppers look for before buying.
4
+
5
+ What ships, with nothing to write on the backend:
6
+
7
+ - `commerce/storefront-catalog` `get-product` returns **paginated `reviews`** plus `average_rating` and `rating_count`, and a `verified` flag per review;
8
+ - `list-products` rows carry `average_rating`/`rating_count`, so **stars on cards cost no extra call**;
9
+ - `commerce/storefront-catalog` `submit-review` accepts `{ product_id, reviewer?, review, rating }` from a **signed-in** customer — the email comes from the session, never the payload;
10
+ - `commerce/storefront-account` `my-reviews` lists a customer's own;
11
+ - moderation is already in the admin (Products → Reviews), and `commerce/admin-reviews` recalculates the product's rating on every status change.
12
+
13
+ So the work is UI: stars on cards and the product page, a reviews list, and a submit form. Behaviors to respect rather than fight:
14
+
15
+ - anonymous callers get **`401 login_required`** — put the form behind a login prompt instead of hiding reviews from guests;
16
+ - a new review is **`hold`** unless `auto_approve_reviews`, so tell the submitter it's awaiting approval rather than showing it as live;
17
+ - `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`**);
18
+ - the store can switch reviews off store-wide (`products.enable_reviews`) — read it and hide the UI when false.
19
+
20
+ Shapes and error codes: [`docs/api-storefront.md`](../docs/api-storefront.md#submit-review--auth). Where ratings belong per view: [`references/product-render.md`](./product-render.md).
@@ -1,6 +1,6 @@
1
1
  # Variant selection on the product page
2
2
 
3
- The one interaction agents reliably get wrong: turning a variable product 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).
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[]` entries with `variation: true` are the **axes**, in `position` order; each `variations[]` record is one combination. `visible: true` + `variation: false` attributes are descriptive — a spec table, **never** a selector.
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 `simple` product `resolveSelection` returns no axes, `complete: true` and a ready `addToCart` — the same code path; it branches on `product.type`, so `variations` can be `[]`.
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 **variable product with no usable variations** — `type: "variable"` but nothing in `variations[]`, the state an attribute record alone 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 `variation: true` attributes at all, `missingAxes` is empty and `view.missingAxes[0]?.name` is `undefined`.
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 variable product is not its price ([`product-render.md` §3](./product-render.md#3-product-types-in-each-view)). Keep **Add to cart** disabled with a hint at what's missing.
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 for type "variable"
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 variable product — 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.
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
- - [ ] Non-variation visible attributes render as specs, not selectors.
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 variable product with empty `axes[].options` shows as unavailable — no empty selector groups, no "Select a undefined".
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
- `webhooks.ts` `dispatch()` fires on `order.*`, `product.*`, `customer.*`, `coupon.*` (created/updated/deleted, plus order/product `restored`). 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).
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 Admin (`src/commerce/admin/`)
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, tooltip
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);
@@ -155,8 +155,8 @@ function SetupScreen({ onDone }) {
155
155
  onChange={(e) => setStoreName(e.target.value)}
156
156
  />
157
157
  <p className="text-xs text-muted-foreground">
158
- Shown to customers and used as the sender name on every transactional email.
159
- Editable later in Settings → General.
158
+ Used in the subject and as the sender name on every transactional email.
159
+ Editable later in Settings → Emails.
160
160
  </p>
161
161
  </div>
162
162
  {hasProducts === false && (
@@ -171,8 +171,8 @@ function SetupScreen({ onDone }) {
171
171
  Include sample data
172
172
  </Label>
173
173
  <p className="text-xs text-muted-foreground">
174
- Adds demo categories, products (simple, variable, downloadable,
175
- external, grouped) and coupons so you can explore the admin.
174
+ Adds demo categories, 10 products (three with size/colour variants, one
175
+ downloadable) and coupons so you can explore the admin.
176
176
  </p>
177
177
  </div>
178
178
  </div>
@@ -10,15 +10,15 @@ import { BasePathProvider } from "./context/BasePathContext";
10
10
  * The store admin application.
11
11
  *
12
12
  * Mount inside your app's router:
13
- * <Route path="/admin/*" element={<AdminApp />} />
13
+ * <Route path="/store-admin/*" element={<AdminApp />} />
14
14
  *
15
- * If mounted somewhere other than /admin, pass the prefix:
15
+ * If mounted somewhere other than /store-admin, pass the prefix:
16
16
  * <Route path="/backoffice/*" element={<AdminApp basePath="/backoffice" />} />
17
17
  *
18
18
  * Requires an authenticated user with role "admin" (enforced by AuthGuard,
19
19
  * and independently by entity RLS + requireAdmin() in backend functions).
20
20
  */
21
- export default function AdminApp({ basePath = "/admin" }) {
21
+ export default function AdminApp({ basePath = "/store-admin" }) {
22
22
  return (
23
23
  <BasePathProvider value={basePath}>
24
24
  <Toaster richColors position="top-right" />
@@ -10,7 +10,6 @@ import {
10
10
  ShoppingCart,
11
11
  Sparkles,
12
12
  Users,
13
- Webhook,
14
13
  } from "lucide-react";
15
14
  import { call } from "../lib/api";
16
15
  import { useAdminHref } from "../context/BasePathContext";
@@ -30,8 +29,6 @@ const NAV = [
30
29
  items: [
31
30
  { label: "All Products", path: "products", end: true },
32
31
  { label: "Categories", path: "products/categories" },
33
- { label: "Tags", path: "products/tags" },
34
- { label: "Attributes", path: "products/attributes" },
35
32
  { label: "Reviews", path: "products/reviews" },
36
33
  ],
37
34
  },
@@ -43,11 +40,6 @@ const NAV = [
43
40
  {
44
41
  items: [{ label: "Settings", path: "settings", icon: Settings }],
45
42
  },
46
- {
47
- title: "Status",
48
- icon: Webhook,
49
- items: [{ label: "Webhooks", path: "webhooks" }],
50
- },
51
43
  ];
52
44
 
53
45
  /** Left navigation. `onNavigate` closes the mobile sheet; `onOpenBot` opens the StoreAdmin bot panel. */
@@ -78,7 +70,7 @@ export default function Sidebar({ onNavigate, onOpenBot }) {
78
70
  <div className="flex h-full flex-col">
79
71
  <div className="flex h-14 items-center gap-2 border-b px-4">
80
72
  <Package className="h-5 w-5 text-primary" />
81
- <span className="font-semibold">Store Admin</span>
73
+ <span className="font-semibold">Store Management</span>
82
74
  </div>
83
75
  <nav className="flex-1 space-y-4 overflow-y-auto p-3">
84
76
  {NAV.map((group, gi) => (
@@ -17,13 +17,6 @@ export const PRODUCT_STATUSES = [
17
17
  { value: "publish", label: "Published", color: "bg-green-100 text-green-800 border-green-200" },
18
18
  ];
19
19
 
20
- export const PRODUCT_TYPES = [
21
- { value: "simple", label: "Simple product" },
22
- { value: "grouped", label: "Grouped product" },
23
- { value: "external", label: "External/Affiliate product" },
24
- { value: "variable", label: "Variable product" },
25
- ];
26
-
27
20
  export const CATALOG_VISIBILITIES = [
28
21
  { value: "visible", label: "Shop and search results" },
29
22
  { value: "catalog", label: "Shop only" },
@@ -1,16 +1,16 @@
1
1
  /**
2
2
  * Mount-path handling for the admin app.
3
3
  *
4
- * The admin is mounted with a splat route — `<Route path="/admin/*">` — and both
4
+ * The admin is mounted with a splat route — `<Route path="/store-admin/*">` — and both
5
5
  * halves of that pattern leak into places they shouldn't: the `basePath` prop
6
- * gets the pattern pasted in verbatim, and the literal URL `/admin/*` gets
6
+ * gets the pattern pasted in verbatim, and the literal URL `/store-admin/*` gets
7
7
  * opened (pattern copied into the address bar, or a nav link built from the
8
8
  * route table). Neither is a real page, so both are normalized here rather than
9
9
  * dead-ending the admin.
10
10
  */
11
11
 
12
- /** Strip a trailing route splat and slashes: "/admin/*" → "/admin". */
13
- export function normalizeBasePath(value, fallback = "/admin") {
12
+ /** Strip a trailing route splat and slashes: "/store-admin/*" → "/store-admin". */
13
+ export function normalizeBasePath(value, fallback = "/store-admin") {
14
14
  const clean = String(value ?? "")
15
15
  .replace(/\/+\*+$/, "")
16
16
  .replace(/\/+$/, "");
@@ -21,8 +21,8 @@ export function normalizeBasePath(value, fallback = "/admin") {
21
21
  * Is this leftover path (the `*` param of the catch-all route) nothing but
22
22
  * route-*pattern* segments — `*`, its percent-encoded form, or `:param`?
23
23
  *
24
- * `/admin/*` → true (send them to the dashboard)
25
- * `/admin/ordrs` → false (a genuine 404)
24
+ * `/store-admin/*` → true (send them to the dashboard)
25
+ * `/store-admin/ordrs` → false (a genuine 404)
26
26
  */
27
27
  export function isMountPatternPath(rest) {
28
28
  const segments = String(rest ?? "")
@@ -9,20 +9,20 @@ export function slugify(name) {
9
9
  .replace(/^-+|-+$/g, "");
10
10
  }
11
11
 
12
- export const isSimple = (p) => p?.type === "simple";
13
- export const isGrouped = (p) => p?.type === "grouped";
14
- export const isExternal = (p) => p?.type === "external";
15
- export const isVariable = (p) => p?.type === "variable";
12
+ /**
13
+ * A product sells variants because it carries attributes — every entry in
14
+ * `attributes[]` is a variant axis, so attaching one is the whole declaration.
15
+ * Descriptive properties are `meta_data` (modifiers), not attributes.
16
+ */
17
+ export const isVariable = (p) => (p?.attributes ?? []).length > 0;
16
18
 
17
19
  /**
18
- * Cartesian product of all attributes flagged `variation: true`.
20
+ * Cartesian product of every attribute that has options.
19
21
  * Returns [{attributes: [{attribute_id, name, option}]}] — one entry per combo.
20
- * Returns [] when no variation attributes with options exist.
22
+ * Returns [] when no attribute has options yet.
21
23
  */
22
24
  export function generateVariationCombos(attributes) {
23
- const varAttrs = (attributes || []).filter(
24
- (a) => a.variation && (a.options || []).length > 0
25
- );
25
+ const varAttrs = (attributes || []).filter((a) => (a.options || []).length > 0);
26
26
  if (!varAttrs.length) return [];
27
27
  const combos = varAttrs.reduce(
28
28
  (acc, attr) =>
@@ -44,12 +44,15 @@ export function variationLabel(variation) {
44
44
  .join(" / ") || "Any";
45
45
  }
46
46
 
47
+ /** Order-independent identity of a variation's attribute combination. */
48
+ export function comboKey(variation) {
49
+ return (variation?.attributes || [])
50
+ .map((x) => `${(x.name || "").toLowerCase()}=${(x.option || "").toLowerCase()}`)
51
+ .sort()
52
+ .join("|");
53
+ }
54
+
47
55
  /** True when two variations have the same attribute combo. */
48
56
  export function sameCombo(a, b) {
49
- const key = (v) =>
50
- (v?.attributes || [])
51
- .map((x) => `${(x.name || "").toLowerCase()}=${(x.option || "").toLowerCase()}`)
52
- .sort()
53
- .join("|");
54
- return key(a) === key(b);
57
+ return comboKey(a) === comboKey(b);
55
58
  }
@@ -20,11 +20,11 @@ import { Loader2 } from "lucide-react";
20
20
 
21
21
  import { call, base44 } from "../../../lib/api";
22
22
  import SearchSelect from "../../../components/SearchSelect";
23
- import { variationLabel } from "../../../lib/product-utils";
23
+ import { isVariable, variationLabel } from "../../../lib/product-utils";
24
24
 
25
25
  /**
26
26
  * Two-step product picker for the order editor:
27
- * product search → (variable only) variation select → quantity.
27
+ * product search → (products with variants only) variant select → quantity.
28
28
  *
29
29
  * Props: { open, onOpenChange, onAdd({product, variation|null, quantity}) }
30
30
  */
@@ -50,7 +50,7 @@ export default function AddProductDialog({ open, onOpenChange, onAdd }) {
50
50
  return rows.map((p) => ({
51
51
  value: p.id,
52
52
  label: p.name,
53
- meta: [p.sku && `SKU: ${p.sku}`, p.type].filter(Boolean).join(" · "),
53
+ meta: p.sku ? `SKU: ${p.sku}` : undefined,
54
54
  product: p,
55
55
  }));
56
56
  };
@@ -59,7 +59,7 @@ export default function AddProductDialog({ open, onOpenChange, onAdd }) {
59
59
  setPicked(opt);
60
60
  setVariations(null);
61
61
  setVariationId("");
62
- if (opt?.product?.type === "variable") {
62
+ if (isVariable(opt?.product)) {
63
63
  setLoadingVariations(true);
64
64
  try {
65
65
  const rows = await base44.entities["commerce.ProductVariation"].filter({ product_id: opt.value }, "menu_order", 200);
@@ -70,11 +70,11 @@ export default function AddProductDialog({ open, onOpenChange, onAdd }) {
70
70
  }
71
71
  };
72
72
 
73
- const isVariable = picked?.product?.type === "variable";
74
- const canAdd = picked && quantity > 0 && (!isVariable || variationId);
73
+ const hasVariants = isVariable(picked?.product);
74
+ const canAdd = picked && quantity > 0 && (!hasVariants || variationId);
75
75
 
76
76
  const submit = () => {
77
- const variation = isVariable ? (variations || []).find((v) => v.id === variationId) : null;
77
+ const variation = hasVariants ? (variations || []).find((v) => v.id === variationId) : null;
78
78
  onAdd({ product: picked.product, variation, quantity });
79
79
  onOpenChange(false);
80
80
  };
@@ -97,7 +97,7 @@ export default function AddProductDialog({ open, onOpenChange, onAdd }) {
97
97
  />
98
98
  </div>
99
99
 
100
- {isVariable && (
100
+ {hasVariants && (
101
101
  <div className="grid gap-1.5">
102
102
  <Label>Variation</Label>
103
103
  {loadingVariations ? (