@base44/app-plugin-commerce 0.1.20 → 0.2.2

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 (73) hide show
  1. package/README.md +25 -22
  2. package/base44/agents/commerce/StoreAdmin.jsonc +1 -1
  3. package/base44/entities/commerce.OrderRefund.jsonc +1 -1
  4. package/base44/entities/commerce.PaymentGateway.jsonc +1 -1
  5. package/base44/entities/commerce.Webhook.jsonc +1 -1
  6. package/base44/functions/commerce/admin-products/entry.ts +1 -1
  7. package/base44/functions/commerce/admin-reports/entry.ts +1 -1
  8. package/base44/functions/commerce/payments/entry.ts +2 -2
  9. package/base44/functions/commerce/seed-store/defaults.ts +1 -1
  10. package/base44/functions/commerce/seed-store/entry.ts +34 -0
  11. package/base44/functions/commerce/seed-store/seed-catalog.ts +39 -5
  12. package/base44/functions/commerce/storefront-catalog/entry.ts +1 -1
  13. package/base44/functions/commerce/storefront-checkout/entry.ts +1 -1
  14. package/base44/shared/commerce/card-payment.stripe.ts +198 -0
  15. package/base44/shared/commerce/card-payment.ts +1 -1
  16. package/base44/shared/commerce/payments.ts +2 -2
  17. package/base44/shared/commerce/scan.ts +1 -1
  18. package/base44/shared/commerce/sequence.ts +2 -2
  19. package/package.json +1 -1
  20. package/scripts/install.js +24 -14
  21. package/skills/commerce/SKILL.md +117 -51
  22. package/skills/commerce/docs/api-admin.md +89 -28
  23. package/skills/commerce/docs/api-storefront.md +113 -126
  24. package/skills/commerce/docs/entities.md +137 -0
  25. package/skills/commerce/install/01-install.md +101 -0
  26. package/skills/commerce/install/02-storefront.md +444 -0
  27. package/skills/commerce/install/03-data.md +162 -0
  28. package/skills/commerce/references/admin-product-form.md +10 -0
  29. package/skills/commerce/references/catalog-rendering.md +110 -0
  30. package/skills/commerce/references/emails.md +49 -12
  31. package/skills/commerce/references/guest-access-security.md +18 -5
  32. package/skills/commerce/references/online-payments.md +49 -149
  33. package/skills/commerce/references/operations.md +52 -0
  34. package/skills/commerce/references/reviews.md +31 -16
  35. package/skills/commerce/references/shipping-and-tax.md +110 -0
  36. package/skills/commerce/references/store-admin-agent.md +21 -0
  37. package/skills/commerce/references/store-settings.md +49 -0
  38. package/src/commerce/admin/README.md +6 -3
  39. package/src/commerce/admin/layout/AuthGuard.jsx +1 -1
  40. package/src/commerce/admin/pages/products/Reviews.jsx +1 -1
  41. package/src/commerce/admin/pages/settings/InventorySettings.jsx +1 -1
  42. package/src/commerce/admin/pages/settings/PaymentsSettings.jsx +1 -1
  43. package/src/commerce/storefront/StorefrontProvider.jsx +106 -20
  44. package/src/commerce/storefront/index.js +74 -24
  45. package/src/commerce/storefront/internal/useAsyncData.js +86 -0
  46. package/src/commerce/storefront/useAddressForm.js +96 -0
  47. package/src/commerce/storefront/useCartLine.js +221 -0
  48. package/src/commerce/storefront/useCheckout.jsx +18 -6
  49. package/src/commerce/storefront/useOrderReturn.js +36 -10
  50. package/src/commerce/storefront/useProduct.js +295 -0
  51. package/src/commerce/storefront/useProductGallery.js +74 -0
  52. package/src/commerce/storefront/useProductList.js +153 -0
  53. package/src/commerce/storefront/useProductPrice.js +58 -0
  54. package/src/commerce/storefront/useProductReviews.js +242 -0
  55. package/src/commerce/storefront/useStorefrontSeo.js +204 -0
  56. package/src/commerce/storefront/useTotalsLines.js +109 -0
  57. package/src/commerce/utils/address-spec.js +89 -0
  58. package/src/commerce/utils/images.js +45 -0
  59. package/src/commerce/utils/index.js +22 -7
  60. package/src/commerce/utils/price.js +95 -0
  61. package/src/commerce/utils/shipping-promos.js +2 -2
  62. package/src/commerce/utils/specs.js +26 -0
  63. package/src/commerce/utils/storefront.js +47 -3
  64. package/src/commerce/utils/totals.js +110 -0
  65. package/src/commerce/utils/variants.js +58 -3
  66. package/skills/commerce/installation-guidelines.md +0 -93
  67. package/skills/commerce/post-installation.md +0 -496
  68. package/skills/commerce/references/limits-and-performance.md +0 -16
  69. package/skills/commerce/references/media-and-downloads.md +0 -4
  70. package/skills/commerce/references/product-render.md +0 -89
  71. package/skills/commerce/references/scheduled-work.md +0 -19
  72. package/skills/commerce/references/storefront-product-page.md +0 -83
  73. package/skills/commerce/references/webhooks.md +0 -10
@@ -1,26 +1,41 @@
1
+ ---
2
+ stage: reference
3
+ read_when: "You need review behaviour beyond useProductReviews — moderation, auto-approval, a customer's own reviews, or a policy the three built-in ones don't cover."
4
+ skip_when: "useProductReviews is on the product page — that is list + submit + policy + the right confirmation copy already."
5
+ forget_when: "A review submits, appears (or is held) as the store's auto-approve setting dictates, and the aggregate rating renders."
6
+ carry_forward:
7
+ - "Reviews are part of the happy path: one hook. The confirmation copy must come from the submit response, never hardcoded."
8
+ ---
9
+
1
10
  # Reviews
2
11
 
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.
12
+ Reviews are **part of the happy path**, not an extra: the backend always shipped complete, and the whole UI contract is one hook. Unless the store explicitly doesn't want them, put them on the product page: `useProductReviews(product, { policy, user, requireRating, perPage })` — the list with paging (`items`, `hasNext`/`loadMore`), the aggregates (`averageRating`, `ratingCount`), the whole form contract (`form`/`setField`/`fieldErrors`/`valid`/`submit`/`message`) and the policy gate (`canReview`, `reviewBlockedReason`, `requiresEmail`); exact shapes in its JSDoc. The markup around it stars, rows, the form is yours, like all storefront UI.
13
+
14
+ ## What ships
15
+
16
+ - `get-product` returns **paginated `reviews`** plus `average_rating`, `rating_count` and a `verified` flag per review; `list-products` rows carry the aggregates, so **stars on cards cost no extra call**;
17
+ - `submit-review` accepts `{ product_id, email, reviewer?, review, rating? }` from **anyone — no login**. A signed-in caller's session email always wins (the payload cannot impersonate); a guest supplies `email`; `verified` comes from that email's order history. The three error codes (`400 email_required | review_incomplete | invalid_rating`) land on their fields via `fieldErrors` instead of rejecting into nothing;
18
+ - `storefront-account` `my-reviews` lists a signed-in customer's own — what a "My reviews" account tab renders from;
19
+ - moderation is in the admin (Products → Reviews); `commerce/admin-reviews` recalculates the product's rating on every status change.
20
+
21
+ ## The `policy` prop
4
22
 
5
- What ships, with nothing to write on the backend:
23
+ `policy` is the store's review rule as one option, replacing the patterns a storefront used to implement by hand. Pass your app's current user alongside it (`useProductReviews(product, { policy: "login", user })`) — the stricter policies need it:
6
24
 
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, email, reviewer?, review, rating? }` from **anyone no login**. For a signed-in caller the session email always wins (the payload cannot impersonate); a guest supplies `email`. `verified` is derived from that email's order history;
10
- - `commerce/storefront-account` `my-reviews` lists a signed-in 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.
25
+ | `policy` | Who may submit | Blocked as |
26
+ |---|---|---|
27
+ | `"open"` (default) | anyone with a valid email the server's own rule | |
28
+ | `"login"` | a signed-in visitor only | `reviewBlockedReason: "login_required"` |
29
+ | `"verified_buyers"` | someone whose own orders include a `processing`/`completed` order for this product (checked via `my-orders`) | `"not_a_buyer"` |
12
30
 
13
- **The one server-side switch is auto-approval** (`products.auto_approve_reviews`, the toggle on the admin's Reviews screen): off (the default) holds every new review as `hold` for moderation; on publishes immediately. Tell the submitter their review is awaiting approval rather than showing it as live.
31
+ Policies are **UI-side by design**: the server accepts any valid email, so a stricter rule is exactly this gate. `requireRating: true` makes the stars mandatory; `requiresEmail` is `false` for a signed-in visitor, so the field is hidden (the session's email wins server-side anyway). A policy that must hold against handcrafted API calls too belongs in a backend function of your own wrapping `submit-review`. An honest middle ground for most stores: accept everything and render the `verified` flag as a "Verified purchase" badge.
14
32
 
15
- ## Policies are yours to enforce in the UI
33
+ ## Auto-approval and moderation
16
34
 
17
- The server deliberately ships the **most open default anyone can review by email**. Anything stricter is storefront policy, implemented where you build the form:
35
+ `products.auto_approve_reviews` ([`store-settings.md`](./store-settings.md), toggle on the admin's Reviews screen) is the **only** server-side switch: off (the default) holds every review as `hold` for moderation; on publishes immediately.
18
36
 
19
- - **Login-gated reviews** render the form only for a signed-in session (`base44.auth.me()`), and omit `email` from the payload: the session email is used automatically.
20
- - **Verified buyers only** — before showing the form to a signed-in customer, check their history: `commerce/storefront-account` `my-orders` and look for a `processing`/`completed` order containing the product. (For guests there is nothing to check against — pair this policy with login-gating.) The `verified` flag on every stored review tells moderators and readers which reviews came from buyers either way — an honest middle ground is to accept all reviews and render a "Verified purchase" badge from it.
21
- - **Required rating** — `rating` is optional server-side (0–5 when present); make the stars mandatory in the form before submitting if the store wants no unrated reviews.
22
- - **Disabling reviews store-wide** — simply build no review UI. There is no server switch to keep in sync.
37
+ So **the confirmation copy must come from the response**: `submit()` resolves `{ ok, status, verified }` with `status` `"approved"` or `"hold"`, and `message` follows it. A hardcoded "awaiting approval" lies to every store with auto-approval on — and after an approved submission the hook refreshes the list so the review actually appears.
23
38
 
24
- If a policy must hold even against handcrafted API calls (not just your UI), enforce it in a backend function of your own that wraps `submit-review` — the same pattern as any custom storefront action.
39
+ Disabling reviews store-wide is simply building no review UI; there is no server switch to keep in sync.
25
40
 
26
- Shapes and error codes (`400 email_required|review_incomplete|invalid_rating`): [`docs/api-storefront.md`](../docs/api-storefront.md#submit-review). Where ratings belong per view: [`references/product-render.md`](./product-render.md).
41
+ Shapes and error details: [`../docs/api-storefront.md`](../docs/api-storefront.md#submit-review). Where ratings belong per view: [`catalog-rendering.md`](./catalog-rendering.md).
@@ -0,0 +1,110 @@
1
+ ---
2
+ stage: reference
3
+ read_when: "You need shipping or tax beyond the one recipe inline in ../install/03-data.md — several priced tiers, VAT, tax on shipping, or editing locations after the seed."
4
+ skip_when: "One flat rate is enough, or the '€X region / €Y worldwide' recipe in ../install/03-data.md already expresses the requirement."
5
+ forget_when: "The seed response came back with no `no_catchall_location` warning and checkout prices shipping for both a domestic and a foreign test address."
6
+ carry_forward:
7
+ - "One matched commerce.ShippingTaxLocation supplies BOTH the shipping rates and the tax for an address."
8
+ - "Locations match by `order` ascending; one with no regions (`rest_of_world: true`) is the catch-all — without it every unlisted address gets `shipping_not_available`."
9
+ - "Matching is country / state / continent only — no postcode or city rules exist."
10
+ ---
11
+
12
+ # Shipping & tax locations
13
+
14
+ One entity does both jobs. A **`commerce.ShippingTaxLocation`** is *a set of regions plus everything charged there*: the shipping options offered and the tax applied. There is no zone/method/class model and no separate tax-rate table.
15
+
16
+ Declaring `locations` in the `commerce/seed-store` call is **the** way to set shipping up ([`../install/03-data.md`](../install/03-data.md)); editing the entity afterwards is the day-2 route (last section).
17
+
18
+ ## The model
19
+
20
+ | Field | Meaning |
21
+ |---|---|
22
+ | `name` | Required; also the identity used for skip-if-exists when seeding. |
23
+ | `order` | Match priority, **ascending**. Defaults to the payload position (0, 1, 2…) at seed time, so the array reads as the priority; the seeded fallback sits at `999`. |
24
+ | `regions[]` | `{ type: "country" \| "state" \| "continent", code }` — `"US"`, `"US:CA"`, `"EU"`. **Empty = the catch-all.** |
25
+ | `shipping_rates[]` | `{ id, name, cost, free_over }` — each one a checkout choice. |
26
+ | `tax_groups[]` | `[{ name, rates: [{ name, rate }] }]`, `rate` a percentage (`18` = 18%). `Products` is the group every product uses unless its `tax_group` names another. |
27
+ | `shipping_tax` | `{ type: "percent" \| "fixed", value }`, or `null` for untaxed shipping. |
28
+
29
+ **The 7 continent codes** (`shared/commerce/data/continents.ts`): `AF` Africa, `AN` Antarctica, `AS` Asia, `EU` Europe, `NA` North America, `OC` Oceania, `SA` South America. `EU` is the **continent** — 51 countries including GB, CH, NO, RU, UA — *not* the European Union. Unknown codes are rejected at seed time with the known list.
30
+
31
+ ## Matching an address
32
+
33
+ `shared/commerce/shipping.ts` `matchLocation()`: sort by `order` ascending → the first location with non-empty `regions` where **any** region matches wins (regions OR together) → else the first location with **no** regions → else nothing, and the storefront gets `400 shipping_not_available` from `set-address`.
34
+
35
+ **Country and state only.** A `state` region matches as `COUNTRY:STATE` (`US:CA`) — which is why the address form must collect `state`. **No postcode or city matching exists**; the address carries them, nothing reads them. Per-postcode pricing means a rate per state, or your own logic.
36
+
37
+ One convenience: with no address yet, a store with exactly **one** location prices from it, so the cart can show shipping before the customer types anything. With two or more the cart reports `missing_address` until an address is set.
38
+
39
+ ## One location, both charges
40
+
41
+ Totals resolve the location **once** (`tax.tax_based_on` — `shipping` by default, or `billing` — picks which address), then its `tax_groups` tax the items, its `shipping_rates` are the options offered, and its `shipping_tax` taxes the chosen rate (`fixed` charged once per order). A product's `tax_group` picks the group **by name** (case-insensitive, default `Products`); a group's tax is the **sum of its rates** — no priorities, no compounding. So tax follows shipping coverage: a location with no matching group charges nothing, and a region you don't ship to is untaxed unless you give it a location with no rates.
42
+
43
+ **Rate ids are stable and referenced** — carts and orders store the chosen `id`, derived at seed time as `<location-slug>-<rate-slug>` (`europe-standard`) unless you pass one. `free_over` zeroes a rate once the **discounted items subtotal** reaches it (`null` = never); a free-shipping coupon zeroes every offered rate.
44
+
45
+ ## Recipes
46
+
47
+ Copy-pasteable `locations` values for `commerce/seed-store`.
48
+
49
+ **1. One country, flat rate**
50
+
51
+ ```js
52
+ locations: [{ name: "United States", countries: ["US"],
53
+ shipping_rates: [{ name: "Standard", cost: 6.95 }] }]
54
+ ```
55
+
56
+ **2. €20 in Europe, €100 worldwide** — a whole continent without a 51-code list:
57
+
58
+ ```js
59
+ locations: [
60
+ { name: "Europe", continents: ["EU"], shipping_rates: [{ name: "Standard", cost: 20 }] },
61
+ { name: "Worldwide", rest_of_world: true, shipping_rates: [{ name: "International", cost: 100 }] },
62
+ ]
63
+ ```
64
+
65
+ **3. Domestic, then priced international tiers** — explicit `order` when the payload order isn't the priority you want:
66
+
67
+ ```js
68
+ locations: [
69
+ { name: "Germany", countries: ["DE"], order: 0,
70
+ shipping_rates: [{ name: "DHL Paket", cost: 4.9 }, { name: "Express", cost: 12.9 }] },
71
+ { name: "EU", continents: ["EU"], order: 1, shipping_rates: [{ name: "Standard", cost: 9.9 }] },
72
+ { name: "Rest of the world", rest_of_world: true, order: 2,
73
+ shipping_rates: [{ name: "International", cost: 24.9 }] },
74
+ ]
75
+ ```
76
+
77
+ Two rates at one location = a real choice at checkout; a single rate is auto-selected (`resolveShippingSelection`).
78
+
79
+ **4. Free over a threshold** — one rate, not two:
80
+
81
+ ```js
82
+ locations: [{ name: "United Kingdom", countries: ["GB"],
83
+ shipping_rates: [{ id: "uk-standard", name: "Standard (free over £50)",
84
+ cost: 3.95, free_over: 50 }] }]
85
+ ```
86
+
87
+ **5. VAT, with tax on shipping too**
88
+
89
+ ```js
90
+ locations: [{
91
+ name: "Israel", countries: ["IL"],
92
+ shipping_rates: [{ name: "Standard", cost: 20, free_over: 150 }],
93
+ tax_groups: [{ name: "Products", rates: [{ name: "VAT", rate: 18 }] }, { name: "Books", rates: [] }],
94
+ shipping_tax: { type: "percent", value: 18 },
95
+ }]
96
+ ```
97
+
98
+ Products join the `Books` group by carrying `tax_group: "Books"`. Set `tax.prices_include_tax` if the prices you seed are **gross** — tax is then extracted from them rather than added ([`store-settings.md`](./store-settings.md)).
99
+
100
+ ## The catch-all trap
101
+
102
+ A payload carrying `locations` **suppresses** the seeded "Rest of the world" fallback: your list becomes the store's entire shipping story. Seed `{ name: "Europe", continents: ["EU"] }` alone and the store ships to Europe and **nowhere else** — every other address fails `set-address` with `400 shipping_not_available`, deliberately loudly rather than dead-ending at place-order. Ship worldwide ⇒ include a `rest_of_world: true` location; the seeder flags the omission in its response `warnings` as `no_catchall_location`.
103
+
104
+ `rest_of_world: true` is the catch-all *instead of* a scope — combining it with `countries`/`continents`/`regions` is a payload error, not a merge. (An empty `regions: []` still works and means the same.)
105
+
106
+ ## Day-2 edits
107
+
108
+ No admin function owns locations, so there are two routes: **direct CRUD** on `commerce.ShippingTaxLocation` (admin-only RLS, bracket syntax — [`../docs/entities.md`](../docs/entities.md)), where `shipping_rates` is written whole so you must **mint stable `id`s yourself and never renumber existing ones** (orders reference them); or **the merchant's screen**, admin → Settings → Shipping & Tax (`settings/shipping-tax`), which edits regions, rates, groups and shipping tax directly.
109
+
110
+ Re-seeding is **skip-if-exists by name**: a location of the same name is left untouched, never duplicated and never updated — to change one, edit the record. Limit **≤ 50 locations** per seed call. At runtime the storefront never picks a location: `set-address` prices the cart, the totals view carries `matched_location_id` and `available_shipping_methods`, and `useCheckout` surfaces failure as the `shipping_not_available` blocker.
@@ -0,0 +1,21 @@
1
+ ---
2
+ stage: reference
3
+ read_when: "You are changing the StoreAdmin copilot — its tools, its instructions, the chat panel — or debugging why it fails at message time."
4
+ skip_when: "You are not touching the agent; it registers itself when its file lands and needs no wiring."
5
+ forget_when: "The agent answers in the admin's chat panel and its tool calls succeed for an admin user."
6
+ carry_forward:
7
+ - "Never add entity tools or service-role calls to StoreAdmin: its only authorization layer is requireAdmin() inside the admin functions it calls."
8
+ ---
9
+
10
+ # The StoreAdmin agent (admin copilot)
11
+
12
+ The kit ships an AI copilot for store operators.
13
+
14
+ - **Agent definition** — `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`.
15
+ - **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.
16
+ - **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.
17
+ - **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.
18
+ - **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 kit's own `bot/pipe-tables.js` — no markdown plugin dependency. The panel lives behind the same `AuthGuard` as the rest of the admin.
19
+ - **Config it can't change, it links to.** Store settings, Shipping & Tax Locations, gateways and webhook definitions have no function tool, so the agent is instructed to name the screen and emit an `admin:`-scheme link (`[Settings → Shipping & Tax](admin:settings/shipping-tax)`) rather than telling the operator to "do it manually". `bot/Markdown.jsx` resolves `admin:` through `useAdminHref()`, so links follow your actual mount point (`basePath`) and navigate in-app instead of opening a tab. If you add screens, add the path to the table in the agent's instructions.
20
+
21
+ **Do not weaken the tool set.** The agent's power comes only from the admin functions' own `requireAdmin()` layer — don't add entity tools or service-role calls to the agent config, and keep `commerce/storefront-*` tools limited to the read-only catalog.
@@ -0,0 +1,49 @@
1
+ ---
2
+ stage: reference
3
+ read_when: "You need to change store behaviour through a settings key — stock holds, review auto-approval, tax display, the payment return path, the store name."
4
+ skip_when: "The seeded defaults are fine (they are, for a new store) — or the value is set by the seed call's own arguments (store_name, currency, weight_unit, dimension_unit)."
5
+ forget_when: "The setting is written and the behaviour it drives is observed once."
6
+ carry_forward:
7
+ - "commerce.StoreSettings is one record per group_id with a `values` object; `update` replaces `values` whole — read, spread, write back."
8
+ ---
9
+
10
+ # Store settings
11
+
12
+ Six records, one per group: `general`, `products`, `inventory`, `tax`, `shipping`, `emails`. Defaults are seeded by `commerce/seed-store` (`seed-store/defaults.ts` is the literal source). Backend reads: `getSettings(sr, ...groups)` / `getSetting(groups, group, key, fallback)` in `shared/commerce/settings.ts`. Admin: `useSettings().get(group, key, fallback)`. The storefront sees only the `storefrontSafeSettings()` projection on `get-store-info`.
13
+
14
+ | Key | Default | Effect / read by |
15
+ |---|---|---|
16
+ | **`general`** | | |
17
+ | `order_received_path` | `"/order-received"` | The route a paying customer returns to; `shared/commerce/payments.ts` builds the provider's success/cancel URLs from it. A value that doesn't match your route is a 404 after payment and an order never confirmed. |
18
+ | `currency` | `"USD"` | ISO code, **a value not a format**. Stamped on orders by `storefront-checkout`; published on `get-store-info` for `useMoney`/`useFormatMoney`. |
19
+ | `weight_unit` / `dimension_unit` | `"kg"` / `"cm"` | Labels only — nothing converts. Admin product form, `get-store-info`. |
20
+ | **`products`** | | |
21
+ | `auto_approve_reviews` | `false` | The **only** server-enforced review rule: `submit-review` returns `status: "hold"` or `"approved"`. Confirmation copy comes from that response ([`reviews.md`](./reviews.md)). |
22
+ | **`inventory`** | | |
23
+ | `manage_stock` | `true` | Stock-tracking default for new products. |
24
+ | `hold_stock_minutes` | `60` | How long an unpaid order holds stock: `place-order` sets `hold_expires_at`; `admin-orders release-expired-holds` frees it ([`operations.md`](./operations.md)). `0` disables holds. |
25
+ | `low_stock_threshold` / `out_of_stock_threshold` | `2` / `0` | Stock status and the low/out-of-stock emails (`shared/commerce/stock.ts`, `admin-products`, `admin-reports`); a product's own `low_stock_amount` wins. |
26
+ | `hide_out_of_stock` | `false` | `storefront-catalog` drops out-of-stock products from `list-products` **and** `get-product` — the catalog, not just the UI. |
27
+ | **`tax`** | | |
28
+ | `prices_include_tax` | `false` | `true` = stored prices are **gross** and `totals.ts` extracts tax instead of adding it. Flipping it after orders exist re-prices everything. |
29
+ | `tax_based_on` | `"shipping"` | Which address resolves the location — `shipping` or `billing` ([`shipping-and-tax.md`](./shipping-and-tax.md)). |
30
+ | `display_prices_shop` / `display_prices_cart` | `"excl"` | `incl` \| `excl`. **Nothing in the engine enforces these** — they ride on `get-store-info` as a hint for the storefront you build. Honour them or ignore them; no price changes by itself. |
31
+ | **`shipping`** | | |
32
+ | `enable_shipping` | `true` | `false` = no shipping line, no rates, no address requirement anywhere. The digital-only-store switch. |
33
+ | **`emails`** | | |
34
+ | `store_name` | `""` (set from the seed call) | Subject/heading `{store_name}`, the `SendEmail` sender name, **and** the public shop name on `get-store-info`. Blank = Base44 sends as the app's name; semantics in [`emails.md`](./emails.md). Required on a first seed ([`../install/03-data.md`](../install/03-data.md)). |
35
+ | `admin_recipients` | `[]` | Fallback notification addresses; empty falls back to the app's `admin`-role users. |
36
+ | `<type>.enabled` / `.subject` / `.heading` / `.recipient` | enabled, blanks | Per-type overrides at the **top level** of the group (`emails.new_order.enabled`) — [`emails.md`](./emails.md). |
37
+
38
+ ## Writing a setting
39
+
40
+ `values` is written **whole**: `update(id, { values })` replaces the object, so patch by spreading what is already there.
41
+
42
+ ```js
43
+ const [rec] = await base44.entities["commerce.StoreSettings"].filter({ group_id: "inventory" });
44
+ await base44.entities["commerce.StoreSettings"].update(rec.id, {
45
+ values: { ...rec.values, hold_stock_minutes: 30 },
46
+ });
47
+ ```
48
+
49
+ No admin function owns settings — it is direct CRUD (admin-only RLS) or the merchant's own Settings screens, where each group is one page. `commerce/seed-store` re-runs never clobber a group that exists, except for the arguments it is given explicitly (`store_name`, `currency`, `weight_unit`, `dimension_unit`), which always win.
@@ -4,8 +4,11 @@ React admin UI for the Base44 commerce template. Copy this
4
4
  folder into a Base44 app built on the default template (Vite + React +
5
5
  Tailwind + shadcn/ui + React Router) to get a full store back office.
6
6
 
7
- > Install docs: [`.agents/skills/commerce/installation-guidelines.md`](../../../.agents/skills/commerce/installation-guidelines.md) · mounting & role setup: [`.agents/skills/commerce/post-installation.md`](../../../.agents/skills/commerce/post-installation.md) · architecture & operations: the commerce skill, [`.agents/skills/commerce/SKILL.md`](../../../.agents/skills/commerce/SKILL.md)
8
- > API references: [`.agents/skills/commerce/docs/api-admin.md`](../../../.agents/skills/commerce/docs/api-admin.md), [`.agents/skills/commerce/docs/api-storefront.md`](../../../.agents/skills/commerce/docs/api-storefront.md)
7
+ > Docs live in the commerce skill, and every path below is **relative to that
8
+ > skill folder** (`.agents/skills/commerce/` once installed): install, mounting
9
+ > and role setup are `install/01-install.md`; the map for everything else is
10
+ > `SKILL.md`; the API references are `docs/api-admin.md` and
11
+ > `docs/api-storefront.md`.
9
12
 
10
13
  ## Mounting
11
14
 
@@ -39,7 +42,7 @@ Tailwind + shadcn/ui + React Router) to get a full store back office.
39
42
  sample data when the store has no products yet. If `commerce/seed-store` was
40
43
  already run during installation — including when an agent generated a real
41
44
  catalog — the store counts as ready and this screen never shows; see
42
- `.agents/skills/commerce/post-installation.md` §2.
45
+ the commerce skill's install/03-data.md.
43
46
 
44
47
  ## External touchpoints
45
48
 
@@ -24,7 +24,7 @@ const AUTH_CHECK_TIMEOUT_MS = 8000;
24
24
 
25
25
  /**
26
26
  * Blocks the admin UI unless the caller is an authenticated user with
27
- * role === "admin". Do NOT weaken this check — see .agents/skills/commerce/post-installation.md.
27
+ * role === "admin". Do NOT weaken this check — see the commerce skill's install/01-install.md.
28
28
  * (Server-side RLS + requireAdmin() in functions enforce this independently.)
29
29
  *
30
30
  * Timing out resolves to **no user**, i.e. the sign-in screen — it never grants
@@ -263,7 +263,7 @@ export default function Reviews() {
263
263
  /**
264
264
  * The one server-enforced review setting: everything else (login-gating,
265
265
  * verified-buyers-only, required ratings) is storefront policy — see
266
- * .agents/skills/commerce/references/reviews.md.
266
+ * the commerce skill's references/reviews.md.
267
267
  */
268
268
  function AutoApproveToggle() {
269
269
  const settings = useSettings();
@@ -51,7 +51,7 @@ export default function InventorySettings() {
51
51
  <p className="text-xs text-muted-foreground">
52
52
  Hold stock for unpaid orders for this many minutes. When the limit is reached, the
53
53
  pending order is cancelled and its stock released. Release runs opportunistically —
54
- see .agents/skills/commerce/references/scheduled-work.md.
54
+ see the commerce skill's references/operations.md.
55
55
  </p>
56
56
  </div>
57
57
 
@@ -209,7 +209,7 @@ export default function PaymentsSettings() {
209
209
  wiring one means implementing the four functions in
210
210
  shared/commerce/card-payment.ts (rules, and a Stripe
211
211
  implementation to paste, in
212
- .agents/skills/commerce/references/online-payments.md; the
212
+ the commerce skill's references/online-payments.md; the
213
213
  payment webhook is premade). Enabled without that, picking it
214
214
  at checkout answers 503 no_card_payment_provider.
215
215
  Deliberately not shown to the store operator — it's developer
@@ -7,7 +7,13 @@ import React, {
7
7
  useRef,
8
8
  useState,
9
9
  } from "react";
10
- import { createStorefront, storefrontErrorCode, storefrontErrorMessage } from "@/commerce/utils";
10
+ import {
11
+ attributesLabel,
12
+ cartTotalsLines,
13
+ createStorefront,
14
+ storefrontErrorCode,
15
+ storefrontErrorMessage,
16
+ } from "@/commerce/utils";
11
17
 
12
18
  /**
13
19
  * StorefrontProvider — one client, one store-info cache, ONE shared cart.
@@ -67,21 +73,37 @@ export function StorefrontProvider({ base44, store, children }) {
67
73
  // undefined = still loading, null = no cart yet, object = the priced view.
68
74
  const [cart, setCart] = useState(undefined);
69
75
  const [cartError, setCartError] = useState(null);
76
+ const [mutationError, setMutationError] = useState(null);
70
77
  const queue = useRef(Promise.resolve());
71
78
 
72
79
  /**
73
80
  * Run one cart call at a time, in order, and publish its returned view.
74
81
  * Serializing is what keeps rapid quantity clicks (or an address sync racing
75
82
  * an add-to-cart) from applying responses out of order.
83
+ *
84
+ * A failure is recorded in `mutationError` *and* re-thrown: the caller still
85
+ * gets its rejection, and a component that renders no error handling of its
86
+ * own (a bare quantity stepper) still has somewhere the message can show,
87
+ * instead of the failure vanishing into an unhandled rejection.
76
88
  */
77
89
  const runCart = useCallback((fn) => {
78
90
  const next = queue.current.catch(() => {}).then(fn);
79
91
  queue.current = next;
80
- return next.then((view) => {
81
- setCart(view ?? null);
82
- setCartError(null);
83
- return view ?? null;
84
- });
92
+ return next.then(
93
+ (view) => {
94
+ setCart(view ?? null);
95
+ setCartError(null);
96
+ setMutationError(null);
97
+ return view ?? null;
98
+ },
99
+ (e) => {
100
+ setMutationError({
101
+ code: storefrontErrorCode(e) ?? "error",
102
+ message: storefrontErrorMessage(e),
103
+ });
104
+ throw e;
105
+ },
106
+ );
85
107
  }, []);
86
108
 
87
109
  /** Forget the cart locally (used after checkout consumes it). */
@@ -95,8 +117,8 @@ export function StorefrontProvider({ base44, store, children }) {
95
117
  }, [client, runCart]);
96
118
 
97
119
  const value = useMemo(
98
- () => ({ client, info, infoError, cart, cartError, runCart, clearCart }),
99
- [client, info, infoError, cart, cartError, runCart, clearCart],
120
+ () => ({ client, info, infoError, cart, cartError, mutationError, runCart, clearCart }),
121
+ [client, info, infoError, cart, cartError, mutationError, runCart, clearCart],
100
122
  );
101
123
  return <StorefrontContext.Provider value={value}>{children}</StorefrontContext.Provider>;
102
124
  }
@@ -157,23 +179,34 @@ export function useFormatMoney() {
157
179
  }
158
180
 
159
181
  /**
160
- * The shared cart — state plus every cart mutation. All consumers see the
161
- * same view; every action resolves to the fresh priced cart it published.
182
+ * The shared cart — state, decorated lines, and every cart mutation. All
183
+ * consumers see the same view; every action resolves to the fresh priced cart.
184
+ *
185
+ * const { status, lines, totalsLines, notices, updateItem, removeItem } = useCart();
186
+ * if (status === "loading") return <Skeleton />;
187
+ * if (status === "empty") return <EmptyBag />;
188
+ * lines.map(line => …) // line.attributesLabel, line.image, line.canIncrease
162
189
  *
163
- * const { cart, loading, itemCount, addItem, updateItem, removeItem,
164
- * applyCoupon, removeCoupon, refresh } = useCart();
190
+ * **Branch on `status`, not on `isEmpty`.** `isEmpty` is true while the cart is
191
+ * still loading (there are no items *yet*), so a page that renders its empty
192
+ * state from it flashes "your bag is empty" on every visit before the cart
193
+ * arrives. `status` separates the two: `"loading"` → `"empty"` | `"ready"`.
165
194
  *
166
- * - `cart` is `null` until something is added (render an empty state).
167
- * - `addItem(view.addToCart, quantity)` pass the resolved `addToCart` from
168
- * `resolveSelection`; a product with attributes is rejected without its
169
- * `variation_id` (`400 variation_required`).
195
+ * - `lines` are `cart.items` decorated with what a renderer needs and would
196
+ * otherwise re-derive: `attributesLabel` ("Size: 42 · Color: Ivory" the raw
197
+ * `attributes` is an **array** of `{name, option}`, never a map),
198
+ * `image` normalized to `{src, alt}` or null, `purchasable` as a boolean with
199
+ * `unavailableReason` beside it, and the quantity bounds.
200
+ * - `notices` normalizes `coupon_notices` + `removed_items` into one list —
201
+ * render it, or a line that auto-dropped vanishes with no explanation.
202
+ * - `mutationError` is the last failed mutation (insufficient stock, an expired
203
+ * cart), cleared on the next success. Mutations still reject, so
204
+ * `useCartLine`/`useAddToCart` can handle failures locally.
170
205
  * - `applyCoupon(code)` resolves to `{ ok, cart }` or `{ ok: false, code,
171
206
  * message }` — an invalid code is expected flow, not an exception.
172
- * - Render `cart.coupon_notices` / `cart.removed_items` when present: they
173
- * say what auto-dropped and why.
174
207
  */
175
208
  export function useCart() {
176
- const { client, cart, cartError, runCart } = useStorefrontState();
209
+ const { client, cart, cartError, mutationError, runCart } = useStorefrontState();
177
210
 
178
211
  const refresh = useCallback(() => runCart(() => client.getCart()), [client, runCart]);
179
212
  const addItem = useCallback(
@@ -203,13 +236,66 @@ export function useCart() {
203
236
  [client, runCart],
204
237
  );
205
238
 
239
+ const formatMoney = useFormatMoney();
206
240
  const items = cart?.items ?? [];
241
+ const loading = cart === undefined;
242
+
243
+ const lines = useMemo(
244
+ () =>
245
+ items.map((item) => {
246
+ // A cart line's `purchasable` is a RESULT object ({ ok, code, error }),
247
+ // not a boolean — truthiness alone would call every line purchasable.
248
+ const purchasable = item.purchasable?.ok ?? Boolean(item.purchasable);
249
+ // `sold_individually` is the only per-line ceiling the cart view
250
+ // carries; real stock limits surface as `insufficient_stock` from the
251
+ // server when a customer tries to exceed them.
252
+ const maxQuantity = item.sold_individually ? 1 : Infinity;
253
+ return {
254
+ ...item,
255
+ attributesLabel: attributesLabel(item.attributes),
256
+ image: item.image ? { src: item.image, alt: item.name ?? "" } : null,
257
+ maxQuantity,
258
+ canIncrease: item.quantity < maxQuantity,
259
+ canDecrease: item.quantity > 1,
260
+ purchasable,
261
+ unavailableReason: purchasable ? null : (item.purchasable?.error ?? null),
262
+ };
263
+ }),
264
+ [items],
265
+ );
266
+
267
+ const notices = useMemo(
268
+ () => [
269
+ ...(cart?.coupon_notices ?? []).map((n) => ({
270
+ kind: "coupon",
271
+ code: n.error_code ?? n.code ?? "coupon_invalid",
272
+ message: n.error ?? `Coupon ${n.code} is no longer valid.`,
273
+ })),
274
+ ...(cart?.removed_items ?? []).map((r) => ({
275
+ kind: "removed_item",
276
+ code: r.code ?? "unavailable",
277
+ message: r.reason ?? "An item is no longer available and was removed.",
278
+ })),
279
+ ],
280
+ [cart],
281
+ );
282
+
283
+ const totalsLines = useMemo(
284
+ () => (cart ? cartTotalsLines(cart, { formatMoney }) : []),
285
+ [cart, formatMoney],
286
+ );
287
+
207
288
  return {
208
289
  cart: cart ?? null,
209
- loading: cart === undefined,
290
+ loading,
210
291
  error: cartError,
292
+ mutationError,
211
293
  itemCount: items.reduce((n, i) => n + (i.quantity || 0), 0),
212
294
  isEmpty: !items.length,
295
+ status: loading ? "loading" : items.length ? "ready" : "empty",
296
+ lines,
297
+ notices,
298
+ totalsLines,
213
299
  refresh,
214
300
  addItem,
215
301
  updateItem,