@base44/app-plugin-commerce 0.7.1 → 0.8.0

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 (81) hide show
  1. package/README.md +4 -10
  2. package/base44/entities/commerce.ProductReview.jsonc +2 -2
  3. package/base44/functions/commerce/storefront-catalog/entry.ts +7 -3
  4. package/package.json +1 -1
  5. package/scripts/install.js +2 -4
  6. package/skills/commerce/SKILL.md +9 -3
  7. package/skills/commerce/docs/api-storefront.md +3 -3
  8. package/skills/commerce/install/01-install.md +8 -24
  9. package/skills/commerce/install/02-storefront.md +21 -9
  10. package/skills/commerce/references/admin-localization.md +95 -0
  11. package/skills/commerce/references/reviews.md +7 -6
  12. package/src/commerce/admin/README.md +36 -14
  13. package/src/commerce/admin/bot/StoreAdminBot.jsx +12 -11
  14. package/src/commerce/admin/components/AddressForm.jsx +13 -12
  15. package/src/commerce/admin/components/ConfirmDialog.jsx +5 -4
  16. package/src/commerce/admin/components/CountrySelect.jsx +12 -8
  17. package/src/commerce/admin/components/DataTable.jsx +3 -2
  18. package/src/commerce/admin/components/DateRangePicker.jsx +11 -10
  19. package/src/commerce/admin/components/EmptyState.jsx +2 -1
  20. package/src/commerce/admin/components/MediaUploader.jsx +3 -2
  21. package/src/commerce/admin/components/MetaDataEditor.jsx +4 -3
  22. package/src/commerce/admin/components/SearchSelect.jsx +4 -3
  23. package/src/commerce/admin/context/SettingsContext.jsx +12 -25
  24. package/src/commerce/admin/hooks/useMoney.js +3 -2
  25. package/src/commerce/admin/i18n/index.js +54 -0
  26. package/src/commerce/admin/i18n/locales/de.js +896 -0
  27. package/src/commerce/admin/i18n/locales/en.js +900 -0
  28. package/src/commerce/admin/i18n/locales/es.js +896 -0
  29. package/src/commerce/admin/i18n/locales/fr.js +896 -0
  30. package/src/commerce/admin/i18n/locales/ja.js +896 -0
  31. package/src/commerce/admin/i18n/locales/pt.js +896 -0
  32. package/src/commerce/admin/index.jsx +6 -30
  33. package/src/commerce/admin/layout/AccessDenied.jsx +8 -9
  34. package/src/commerce/admin/layout/AuthGuard.jsx +2 -1
  35. package/src/commerce/admin/layout/Sidebar.jsx +29 -18
  36. package/src/commerce/admin/layout/Topbar.jsx +4 -3
  37. package/src/commerce/admin/lib/api.js +3 -2
  38. package/src/commerce/admin/lib/constants.js +44 -43
  39. package/src/commerce/admin/lib/format.js +4 -3
  40. package/src/commerce/admin/lib/paths.js +2 -3
  41. package/src/commerce/admin/pages/Dashboard.jsx +25 -24
  42. package/src/commerce/admin/pages/coupons/CouponEditor.jsx +55 -57
  43. package/src/commerce/admin/pages/coupons/CouponsList.jsx +22 -21
  44. package/src/commerce/admin/pages/customers/CustomerEditor.jsx +31 -30
  45. package/src/commerce/admin/pages/customers/CustomersList.jsx +19 -18
  46. package/src/commerce/admin/pages/orders/OrderEditor.jsx +75 -71
  47. package/src/commerce/admin/pages/orders/OrdersList.jsx +16 -15
  48. package/src/commerce/admin/pages/orders/components/AddProductDialog.jsx +12 -11
  49. package/src/commerce/admin/pages/orders/components/DownloadPermissionsPanel.jsx +14 -13
  50. package/src/commerce/admin/pages/orders/components/LineItemsTable.jsx +12 -11
  51. package/src/commerce/admin/pages/orders/components/OrderNotesPanel.jsx +11 -10
  52. package/src/commerce/admin/pages/orders/components/PaymentPanel.jsx +22 -22
  53. package/src/commerce/admin/pages/orders/components/RefundPanel.jsx +18 -17
  54. package/src/commerce/admin/pages/orders/components/TotalsBox.jsx +18 -17
  55. package/src/commerce/admin/pages/products/Categories.jsx +14 -13
  56. package/src/commerce/admin/pages/products/ProductEditor.jsx +16 -15
  57. package/src/commerce/admin/pages/products/ProductsList.jsx +41 -36
  58. package/src/commerce/admin/pages/products/Reviews.jsx +54 -46
  59. package/src/commerce/admin/pages/products/components/AttributesSection.jsx +45 -39
  60. package/src/commerce/admin/pages/products/components/ProductDataPanel.jsx +8 -7
  61. package/src/commerce/admin/pages/products/components/PublishBox.jsx +10 -9
  62. package/src/commerce/admin/pages/products/components/TaxonomyPanel.jsx +18 -17
  63. package/src/commerce/admin/pages/products/components/tabs/DownloadsTab.jsx +11 -10
  64. package/src/commerce/admin/pages/products/components/tabs/LinkedTab.jsx +9 -10
  65. package/src/commerce/admin/pages/products/components/tabs/ModifiersTab.jsx +3 -9
  66. package/src/commerce/admin/pages/products/components/tabs/PriceInventoryTab.jsx +76 -78
  67. package/src/commerce/admin/pages/reports/Reports.jsx +33 -32
  68. package/src/commerce/admin/pages/settings/EmailsSettings.jsx +36 -39
  69. package/src/commerce/admin/pages/settings/GeneralSettings.jsx +13 -14
  70. package/src/commerce/admin/pages/settings/InventorySettings.jsx +12 -13
  71. package/src/commerce/admin/pages/settings/LocationEditor.jsx +48 -47
  72. package/src/commerce/admin/pages/settings/PaymentsSettings.jsx +32 -33
  73. package/src/commerce/admin/pages/settings/SettingsLayout.jsx +13 -12
  74. package/src/commerce/admin/pages/settings/ShippingTaxSettings.jsx +31 -31
  75. package/src/commerce/admin/pages/settings/useGroupForm.jsx +4 -3
  76. package/src/commerce/admin/pages/status/WebhookEditor.jsx +34 -31
  77. package/src/commerce/admin/pages/status/Webhooks.jsx +11 -10
  78. package/src/commerce/admin/routes.jsx +31 -43
  79. package/src/commerce/storefront/pickers.jsx +19 -5
  80. package/src/commerce/storefront/useCheckout.jsx +28 -3
  81. package/src/commerce/utils/storefront.js +49 -2
package/README.md CHANGED
@@ -10,7 +10,7 @@ It provides a full-featured **commerce data model and behavior** (variant-driven
10
10
  - **16 backend functions** — 9 admin (`commerce/admin-products`, `commerce/admin-orders`, `commerce/admin-refunds`, `commerce/admin-coupons`, `commerce/admin-customers`, `commerce/admin-reviews`, `commerce/admin-webhooks`, `commerce/admin-reports`, `commerce/admin-tools`), 4 storefront (`commerce/storefront-catalog`, `commerce/storefront-cart`, `commerce/storefront-checkout`, `commerce/storefront-account`), 2 payment (`commerce/payments`, `commerce/payment-webhook`), and an idempotent `commerce/seed-store` — one call seeds the business defaults **and the whole catalog** (products with attributes in, variants/categories/taxonomy created internally).
11
11
  - **Payments: manual methods work out of the box; online cards are opt-in** — the seed enables the manual `offline` method (bank transfer, cash on delivery, pickup: on-hold + instructions, no code) and leaves the `card` gateway **disabled**. The order side of card payments *is* premade — checkout routing, payment links for unpaid orders, two idempotent confirmation paths (customer return + webhook) and refund records — so a store that opts in wires a provider by implementing **four functions in one file**, `base44/shared/commerce/card-payment.ts`. **For Stripe there is nothing to write**: `base44/shared/commerce/card-payment.stripe.ts` is a complete implementation used as-is — copy it over the stub and enable the gateway. Any other provider (PayPal, Adyen, a local PSP) follows the same shape. Enable the gateway only with a provider behind it, or checkout answers `503 no_card_payment_provider`. The rule and timing: [`skills/commerce/install/03-data.md`](./skills/commerce/install/03-data.md); provider mechanics: [`skills/commerce/references/online-payments.md`](./skills/commerce/references/online-payments.md). The admin can add more manual methods in Settings → Payments.
12
12
  - **Shared commerce engine** (`base44/shared/commerce/`) — totals, tax, shipping, coupons, stock, order lifecycle, webhook dispatch (HMAC-signed), emails, card-payment plumbing, plus static country/currency/continent data.
13
- - **Admin UI** (`src/commerce/admin/`) — a React/Tailwind/shadcn admin with a familiar store back-office information architecture: dashboard, orders, products, coupons, customers, reports, and full settings including webhooks. Admin-role gated.
13
+ - **Admin UI** (`src/commerce/admin/`) — a React/Tailwind/shadcn admin with a familiar store back-office information architecture: dashboard, orders, products, coupons, customers, reports, and full settings including webhooks. Admin-role gated. Localized — ships in English (default), German, Spanish, French, Japanese and Portuguese, switched by one line of code, with a documented recipe for adding any other language (no i18n dependency; see [`src/commerce/admin/README.md`](./src/commerce/admin/README.md) and the skill's `references/admin-localization.md`).
14
14
  - **Storefront helpers** (`src/commerce/utils/`) — framework-free, dependency-free modules for the shopfront you build: `storefront.js` is the API client (`createStorefront(base44)` — cart-token lifecycle, cached store-info, catalog/cart/checkout/reviews/return-page calls); `variants.js` maps an attribute selection (Size, Color) onto a `ProductVariation` and back, plus per-option availability and price ranges; `price.js` encodes the from-price and price-range rules; `totals.js` projects a cart *or* an order into one summary shape; `address-spec.js` is the checkout address form as data; `images.js` and `ribbons.js` normalize the two catalog fields that are arrays of objects (`{src, name, alt}` images, `{id, name}` ribbons) rather than strings; `types.js` writes the catalog shapes down as JSDoc typedefs (`StorefrontProduct` and the rest), so what a field holds is answerable from the frontend; `shipping-promos.js` reads the store's real free-shipping configuration so "Free shipping over €150" states a configured rule rather than an invented number.
15
15
  - **Storefront React layer** (`src/commerce/storefront/`) — **headless: the logic is premade, the UI never is.** Nothing in the layer renders markup or carries CSS; every element, class and word of copy in the storefront you build is yours, so a brief like "make it feel like <site>" applies to the whole store, checkout included. **It ships no customer-facing copy either**: where a state needs words you get the *state* — `buy.state`, a picker's `hint.code`, the checkout's `blockers`, a review's `status` — and write the sentence. What ships is every piece of logic that is the same in all stores: `StorefrontProvider` (+ `useStorefront`/`useStoreInfo`/`useFormatMoney`/`useCountries`), `useProductList`/`useCategories`/`useRibbons`, `useProduct`/`useAddToCart`, `useCart`/`useCartLine` (+ `useCartUI`/`CartUIProvider` for a drawer), `useCheckout`/`CheckoutProvider`/`useCheckoutContext`, `useOrderReturn`/`orderReceivedUrl` — plus three render-prop components that stay just as headless (`ShippingMethodPicker`/`PaymentMethodPicker` for the two checkout choices that are store data, `CartLine` for per-row cart bindings), one deliberately rendered-but-unstyled component (`AddressFields` — the checkout address form, whose state/province and `autoComplete` mechanics are where hand-rolled forms break; it ships no CSS and styles via `data-part` selectors or class props), and the framework-free view-model helpers re-exported so one import line covers a page (`variantAxes`, `productPrice`, `productImages`, `productRibbons`, `productSpecs`, `attributesLabel`, `cartTotalsLines`/`orderTotalsLines`, `addressFieldSpec`). Each hook's doc comment states the render rules that keep a store correct (an unbuyable variant option renders disabled, not hidden; a receipt page must render `paymentInstructions`; …) and names its return type from `storefront/types.js`, so a page reads a field's shape off the hook instead of off a backend function. Needs React and nothing else.
16
16
  - **StoreAdmin agent + bot** — an AI copilot (`base44/agents/commerce/StoreAdmin.jsonc`, registered as `commerce/StoreAdmin`) with the `commerce/*` functions attached directly as tools (calls run as the chatting user → `requireAdmin()` still applies), variant-aware order editing, plus a chat panel in the admin sidebar with GFM markdown-table rendering.
@@ -85,18 +85,12 @@ From your existing Base44 app:
85
85
  npx npq install <only the missing names> # npq audits the package before npm installs it
86
86
  ```
87
87
  See [`src/commerce/admin/README.md`](./src/commerce/admin/README.md) for the exact shadcn component list.
88
- 5. **Mount the admin** as a layout route in your app's `src/App.jsx` — Base44 discovers an app's pages by reading the literal `<Route>` JSX in that file, so the screens that should be listed as pages are declared there and the rest run off a splat handled by the kit's own router:
88
+ 5. **Mount the admin router** in your app:
89
89
  ```jsx
90
- import AdminApp, { AdminRoutes } from "@/commerce/admin";
90
+ import AdminApp from "@/commerce/admin";
91
91
  // inside your <Routes>:
92
- <Route path="/store-admin" element={<AdminApp />}>
93
- <Route index element={<Dashboard />} />
94
- <Route path="orders" element={<OrdersList />} />
95
- {/* …products, customers, coupons, reports… */}
96
- <Route path="*" element={<AdminRoutes />} /> {/* editors, settings, webhooks */}
97
- </Route>
92
+ <Route path="/store-admin/*" element={<AdminApp />} />
98
93
  ```
99
- Name the section they group under in `base44/ui.jsonc` (app-owned — edit in place): `{ "version": 1, "sections": [{ "path": "/store-admin/*", "name": "Store Management" }] }`
100
94
  6. **Grant yourself the `admin` role** (Base44 dashboard → users, or `users.inviteUser(email, "admin")`). The admin UI refuses non-admins.
101
95
  7. **Seed the store.** Either open `/store-admin` and click **Initialize store defaults** on the first-run setup screen, or call `commerce/seed-store` directly — it creates the settings groups, the payment gateway rows (`offline` enabled, `card` off — enable it only with a provider wired) and — unless you pass your own `locations` — a fallback Shipping & Tax Location, plus the catalog: pass `products` (whole products with attributes — variants, categories, ribbons and taxonomy are created internally) or `with_sample_data: true` for the generic demo. Either way pass `store_name` (the app's name) — it is required on a first seed and becomes both the email subject prefix and the sender name. Once the `general` settings group exists the store counts as ready and the first-run screen stops appearing. Worked example: [`skills/commerce/install/03-data.md`](./skills/commerce/install/03-data.md); the full payload contract: [`skills/commerce/docs/api-admin.md`](./skills/commerce/docs/api-admin.md). Shipping zones are part of the same call — `locations` takes `continents: ["EU"]` and `rest_of_world: true`, so "€20 in Europe, €100 worldwide" is six lines.
102
96
 
@@ -24,7 +24,7 @@
24
24
  },
25
25
  "review": {
26
26
  "type": "string",
27
- "description": "Review content"
27
+ "description": "Review text. May be empty for a stars-only review (submit-review requires text or a rating, not both)."
28
28
  },
29
29
  "rating": {
30
30
  "type": "integer",
@@ -38,7 +38,7 @@
38
38
  "description": "Derived: reviewer purchased the product. Set by commerce/storefront-catalog submit-review."
39
39
  }
40
40
  },
41
- "required": ["product_id", "review"],
41
+ "required": ["product_id"],
42
42
  "rls": {
43
43
  "read": { "user_condition": { "role": "admin" } },
44
44
  "create": { "user_condition": { "role": "admin" } },
@@ -396,12 +396,16 @@ async function submitReview(sr: any, p: any, user: any): Promise<any> {
396
396
  const review = String(p.review ?? "").trim();
397
397
  const rating = p.rating == null ? null : Math.floor(Number(p.rating));
398
398
 
399
- if (!review) {
400
- throw new HttpError(400, "review is required.", "review_incomplete");
401
- }
402
399
  if (rating != null && (rating < 0 || rating > 5)) {
403
400
  throw new HttpError(400, "Rating must be between 0 and 5.", "invalid_rating");
404
401
  }
402
+ // Text or stars — either alone is a valid review. Requiring text rejected
403
+ // every stars-only submission from a form that treats the write-up as
404
+ // optional, with a 400 the customer can't act on. Rating 0 is "unrated"
405
+ // (the aggregates ignore it), so it can't stand in for the missing text.
406
+ if (!review && !(rating != null && rating >= 1)) {
407
+ throw new HttpError(400, "A review needs text or a star rating.", "review_incomplete");
408
+ }
405
409
 
406
410
  const verified = await hasPurchased(sr, reviewerEmail, product.id);
407
411
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@base44/app-plugin-commerce",
3
- "version": "0.7.1",
3
+ "version": "0.8.0",
4
4
  "description": "Base44 Commerce plugin — entities, backend functions, shared commerce engine, admin UI and the commerce skill, shipped as copyable source",
5
5
  "keywords": [
6
6
  "base44",
@@ -217,10 +217,8 @@
217
217
  "\n" +
218
218
  " 1. No deps to add: sonner, recharts and react-markdown ship with the default\n" +
219
219
  " Base44 template — check package.json and npm i only what is truly missing\n" +
220
- ' 2. Mount the admin in src/App.jsx: <Route path="/store-admin" element={<AdminApp />}>\n' +
221
- " with the main screens as literal child <Route>s (that file is what the platform\n" +
222
- ' discovers pages from) and <Route path="*" element={<AdminRoutes />} /> for the\n' +
223
- " rest, plus the mandatory /order-received route (useOrderReturn + your markup)\n" +
220
+ ' 2. Mount the admin router: <Route path="/store-admin/*" element={<AdminApp />} />\n' +
221
+ " plus the mandatory /order-received route (useOrderReturn + your markup)\n" +
224
222
  " 3. Seed the store — one commerce/seed-store call (store_name required) takes the\n" +
225
223
  " catalog, currency, shipping locations and payment methods\n" +
226
224
  " 4. CLI installs only: npx base44 agents push (the hosted runtime syncs agents on write)"
@@ -29,6 +29,11 @@ install, then the storefront UI, then the catalog. Every install runs all three,
29
29
  so fetching them one at a time defers nothing you will not read anyway and costs
30
30
  a model round trip per file. Read nothing *else* up front.
31
31
 
32
+ **Admin language.** English: nothing to do. de/es/fr/ja/pt: in
33
+ `src/commerce/admin/i18n/index.js` repoint `import active from "./locales/de.js"`
34
+ and stop — never edit admin screens. Other:
35
+ [`references/admin-localization.md`](./references/admin-localization.md).
36
+
32
37
  ## Four things to hold from the start
33
38
 
34
39
  - **Entity names are dotted; SDK access is bracket syntax only** —
@@ -117,18 +122,19 @@ batch (above).
117
122
 
118
123
  | Topic | Open when | Size |
119
124
  |---|---|---|
120
- | [`install/01-install.md`](./install/01-install.md) | installing — routes you to 02 and 03 | 8K |
121
- | [`install/02-storefront.md`](./install/02-storefront.md) | building storefront pages | 35K |
125
+ | [`install/01-install.md`](./install/01-install.md) | installing — routes you to 02 and 03 | 6K |
126
+ | [`install/02-storefront.md`](./install/02-storefront.md) | building storefront pages | 37K |
122
127
  | [`install/03-data.md`](./install/03-data.md) | seeding catalog, shipping rates/zones, payments; re-callable per slice | 11K |
123
128
  | [`docs/entities.md`](./docs/entities.md) | any direct entity read/write ("which entity holds X") | 11K |
124
129
  | [`references/catalog-rendering.md`](./references/catalog-rendering.md) | field shapes each catalog call returns, variant edge cases | 16K |
125
130
  | [`references/shipping-and-tax.md`](./references/shipping-and-tax.md) | zones beyond 03's recipe, taxes, day-2 edits | 8K |
126
131
  | [`references/online-payments.md`](./references/online-payments.md) | enabling card payments, or wiring the provider — at install or any time later | 8K |
127
132
  | [`references/storefront-verification.md`](./references/storefront-verification.md) | driving the storefront from a browser script | 3K |
128
- | [`references/reviews.md`](./references/reviews.md) | review policies (login-gated, verified buyers), moderation | 5K |
133
+ | [`references/reviews.md`](./references/reviews.md) | review policies (open by default; login-gated, verified buyers), moderation | 6K |
129
134
  | [`references/store-settings.md`](./references/store-settings.md) | changing store behavior through settings keys | 5K |
130
135
  | [`references/emails.md`](./references/emails.md) | order-email recipients, subjects, the log | 5K |
131
136
  | [`references/admin-product-form.md`](./references/admin-product-form.md) | editing the shipped product editor | 6K |
137
+ | [`references/admin-localization.md`](./references/admin-localization.md) | a language **outside** en/de/es/fr/ja/pt (the six ship; switching is one import) | 5K |
132
138
  | [`references/store-admin-agent.md`](./references/store-admin-agent.md) | changing the StoreAdmin copilot | 3K |
133
139
  | [`references/guest-access-security.md`](./references/guest-access-security.md) | **adding your own function or entity**; RLS/identity questions | 5K |
134
140
  | [`references/operations.md`](./references/operations.md) | scheduled maintenance, scaling limits, outbound webhooks | 7K |
@@ -114,14 +114,14 @@ This is the **only** way a storefront can enumerate ribbons (the entity is admin
114
114
  No payload. Returns `{ "attributes": [ { ...attribute, "terms": [ ...values ] } ] }` — each attribute (`id, name, code, order`) with its values (`id, attribute_id, name, order, count`), both sorted by `order`; for filter UIs. Filter with `list-products` `attribute_id` (id or attribute **name**) + `attribute_term` (the value name); `code` is the stable key for a URL.
115
115
 
116
116
  ### `submit-review`
117
- **Payload:** `{ product_id, email?, reviewer?, review, rating? }` — `email` is required for guests (`400 email_required`); a signed-in caller's session email always wins.
117
+ **Payload:** `{ product_id, email?, reviewer?, review?, rating? }` — at least one of `review` (text) or `rating` (1–5); `email` is required for guests (`400 email_required`); a signed-in caller's session email always wins.
118
118
 
119
119
  > A React storefront reaches this call as `submitReview` (and the paginated list as `getProductReviews`) on the client from `@/commerce/storefront`'s `useStorefront()`, with the list itself already riding along on the product; policies and moderation are [`../references/reviews.md`](../references/reviews.md). Read on for the raw contract.
120
120
 
121
- **Public by default: anyone can review with an email address — no login.** A guest passes `email`; for a signed-in caller the session email always wins (the payload cannot impersonate). `reviewer` is the display name only, defaulting to the account's `full_name` then the email's local part. `rating` is optional (0–5). `verified` is derived from the email's order history. Status is `hold` unless `products.auto_approve_reviews` — the one server-side switch, so a hardcoded "awaiting approval" message is wrong when it is on. Stricter policies (login-gated, verified buyers only, rating required) are the storefront's own gate around this call — the server accepts any valid email: [`../references/reviews.md`](../references/reviews.md).
121
+ **Public by default: anyone can review with an email address — no login.** A guest passes `email`; for a signed-in caller the session email always wins (the payload cannot impersonate). `reviewer` is the display name only, defaulting to the account's `full_name` then the email's local part. **Text or stars — either alone is valid**: `rating` is 0–5 (0 = unrated, so a stars-only submission needs 1–5); `review_incomplete` fires only when both are missing. `verified` is derived from the email's order history. Status is `hold` unless `products.auto_approve_reviews` — the one server-side switch, so a hardcoded "awaiting approval" message is wrong when it is on. Stricter policies (login-gated, verified buyers only, rating required) are the storefront's own gate around this call — the server accepts any valid email: [`../references/reviews.md`](../references/reviews.md).
122
122
 
123
123
  **Response:** `{ "review_id", "status": "hold"|"approved", "verified": true }`
124
- **Errors:** `404 not_found`, `400 email_required|review_incomplete|invalid_rating`.
124
+ **Errors:** `404 not_found`, `400 email_required|review_incomplete` (neither text nor stars)`|invalid_rating`.
125
125
 
126
126
  ---
127
127
 
@@ -1,10 +1,9 @@
1
1
  ---
2
2
  stage: install/01
3
3
  read_when: "The commerce kit's files were just copied into the app, or you are installing it now."
4
- skip_when: "src/App.jsx already declares the admin's screens as literal <Route> JSX under a /store-admin layout route, and / routes somewhere real."
4
+ skip_when: "The admin already mounts at /store-admin/* behind the shipped AuthGuard and / routes somewhere real."
5
5
  forget_when: "The checklist at the bottom of this file passes (admin mounts, / routes somewhere real, /order-received exists)."
6
6
  carry_forward:
7
- - "The admin's six picker-visible routes are literal <Route> JSX in src/App.jsx (the platform discovers pages by reading that file — an array or a .map() discovers nothing); the rest run off its <Route path=\"*\" element={<AdminRoutes />} />."
8
7
  - "Admin enforcement is three layers — AuthGuard (UI), admin-only entity RLS, requireAdmin() in every admin function. Never weaken any of them."
9
8
  - "/order-received must exist as a route: every payment link returns there, and confirming is what marks an order paid."
10
9
  - "The storefront header shows a visible \"Store manager\" link to /store-admin when the signed-in user's role is admin, and nothing for everyone else."
@@ -42,33 +41,18 @@ Image generation is the slowest step and nothing depends on it until seed time;
42
41
 
43
42
  The only dependency edges are *image URLs → seed payload* and *seed done → real products on the pages*.
44
43
 
45
- ## Mount the admin
46
-
47
- The admin mounts as a **layout route in the app's own `src/App.jsx`**: the six screens the store owner opens from the builder are declared there as literal `<Route>` JSX, and everything deeper goes to `<AdminRoutes />` on a splat. The platform discovers an app's pages by reading that file — a screen declared anywhere else is unreachable from the page picker.
44
+ ## Mount the admin router
48
45
 
49
46
  ```jsx
50
- import AdminApp, { AdminRoutes } from "@/commerce/admin";
51
- import Dashboard from "@/commerce/admin/pages/Dashboard"; // …and orders/OrdersList,
52
- // products/ProductsList, customers/CustomersList, coupons/CouponsList, reports/Reports
53
-
54
- {/* Literal JSX — the platform reads this file, it never runs it. Do not refactor into a map. */}
55
- <Route path="/store-admin" element={<AdminApp />}>
56
- <Route index element={<Dashboard />} />
57
- <Route path="orders" element={<OrdersList />} />
58
- <Route path="products" element={<ProductsList />} />
59
- <Route path="customers" element={<CustomersList />} />
60
- <Route path="coupons" element={<CouponsList />} />
61
- <Route path="reports" element={<Reports />} />
62
- <Route path="*" element={<AdminRoutes />} /> {/* editors, settings, webhooks */}
63
- </Route>
47
+ import AdminApp from "@/commerce/admin";
48
+ import { Navigate } from "react-router-dom";
49
+
50
+ <Route path="/store-admin/*" element={<AdminApp />} />
64
51
  <Route path="/" element={<Navigate to="/store-admin" replace />} /> {/* until a storefront exists */}
65
52
  <Route path="/order-received" element={<OrderReceived />} /> {/* mandatory — see below */}
66
53
  ```
67
54
 
68
- - **Those seven lines, as they are.** The splat is what keeps the app's listed pages to six instead of 26: `path="*"` is skipped, so the editors, settings tabs and webhook screens stay navigable without appearing there, and `<AdminRoutes />` still serves the admin's own 404.
69
- - **Don't add `settings` to the list** — it is a tabbed layout around a nested route, so it only renders correctly from the splat.
70
- - **Elsewhere than `/store-admin`**: change the layout route's path and pass the prefix — `<AdminApp basePath="/backoffice" />`; the children are unchanged.
71
- - **Name the group** in `base44/ui.jsonc` — app-owned, so edit it in place, keep any other keys, never recreate a deleted one: `{ "version": 1, "sections": [{ "path": "/store-admin/*", "name": "Store Management" }] }`
55
+ - **The `/*` splat is required** without it every nested admin link 404s. Mounting elsewhere: `<AdminApp basePath="/backoffice" />` (prefix without the splat).
72
56
  - **Give `/` something** — a blank app has no `/` route, and "page not found" at the app's own URL reads like a broken install.
73
57
  - **Link the admin from the storefront header** — otherwise the merchant has no way in but typing the URL. Resolve the signed-in user once (`base44.auth.me()`, rejection/no session = not an admin, never blocking the page) and render a plainly visible "Store manager" link to `/store-admin` in the header when `role === "admin"` — and nothing at all for everyone else.
74
58
  - **`/order-received` is mandatory**, even offline-only: every payment link returns there, and confirming is what marks an order paid — without it a paying customer hits a 404 and the order stays unpaid. The page is one hook, `useOrderReturn()` ([`./02-storefront.md`](./02-storefront.md)). A different path must be set in Settings → General (`general.order_received_path`).
@@ -85,7 +69,7 @@ Storefront functions are public on purpose (per-action verification, above). To
85
69
 
86
70
  ## Done — forget this file
87
71
 
88
- - [ ] The `/store-admin` layout route is in `src/App.jsx` with its six literal `<Route>` screens and the `path="*"` → `<AdminRoutes />` splat; `base44/ui.jsonc` names the section; the three enforcement layers untouched.
72
+ - [ ] `/store-admin/*` mounted with the splat, behind the shipped `AuthGuard`; the three enforcement layers untouched.
89
73
  - [ ] `/` routes somewhere real; `/order-received` is a route.
90
74
  - [ ] The storefront header shows a visible `/store-admin` link to signed-in admins, and to nobody else.
91
75
  - [ ] Anonymous function invocation is allowed in the app's settings.
@@ -48,7 +48,7 @@ import AdminApp from "@/commerce/admin";
48
48
  <Route path="/product/:slug" element={<ProductPage />} />
49
49
  {/* /bag, /checkout, and /order-received — which is mandatory */}
50
50
  </Route>
51
- <Route path="/store-admin" element={<AdminApp />}>…</Route> {/* own chrome, outside the provider */}
51
+ <Route path="/store-admin/*" element={<AdminApp />} /> {/* own chrome, outside the provider */}
52
52
  </Routes>
53
53
  </BrowserRouter>
54
54
 
@@ -116,7 +116,7 @@ import { useProductList, useCategories, useStoreInfo, useFormatMoney, productPri
116
116
 
117
117
  ⚑ **Render paging whenever `hasNext` is true** — `{list.hasNext && <button type="button" onClick={list.next} disabled={list.busy}>…</button>}` (append mode: `list.loadMore`); a page that renders nothing for paging ships a catalog silently capped at `per_page`. Drive filters from `useCategories()`/`useRibbons()` data via `setParams`, never from hardcoded names — a renamed ribbon must not strand a dead button.
118
118
 
119
- A card can render `name`, `productImages(row)[0]`, `productPrice(row, { formatMoney }).label` (already "From €19.99" when the product sells variants — there is no product `type` flag, and `product.price` alone is a rolled-up from-price), `on_sale`, `short_description`, `stock_status`, `average_rating`/`rating_count`, `productRibbons(row)`, `productSpecs(row)`. ⚑ **Images and ribbons are objects, either may be empty** — render your placeholder, never a broken `<img>` or a raw object. Field matrix: [`../references/catalog-rendering.md`](../references/catalog-rendering.md). That list is an inventory, not a card design and not an order to render in. An even grid of identical cards, each carrying the same name/price/stars trio, is where a generated store lands by default and almost never where this catalog belongs: give the grid a rhythm (a hero piece spanning two columns, an editorial break between rows, a denser tile for a large catalog), and lead each card with the one or two fields *these* products are judged on — carat weight, focal length, edition size, ABV — read off `productSpecs(row)`, not the fields every store shows.
119
+ A card can render `name`, `productImages(row)[0]`, `productPrice(row, { formatMoney }).label` (already "From €19.99" when the product sells variants — there is no product `type` flag, and `product.price` alone is a rolled-up from-price), `on_sale`, `short_description`, `stock_status`, `average_rating`/`rating_count`, `productRibbons(row)`, `productSpecs(row)`. ⚑ **Images and ribbons are objects, either may be empty** — render your placeholder, never a broken `<img>` or a raw object. Field matrix: [`../references/catalog-rendering.md`](../references/catalog-rendering.md). That list is an inventory, not a card design and not an order to render in. An even grid of identical cards, each carrying the same name/price/stars trio, is where a generated store lands by default and almost never where this catalog belongs: give the grid a rhythm (a hero piece spanning two columns, an editorial break between rows, a denser tile for a large catalog), and lead each card with the one or two fields *these* products are judged on — carat weight, focal length, ABV — read off `productSpecs(row)`.
120
120
 
121
121
  ⚑ **Ribbons belong in both views** — grid and product page. They are the merchant's own merchandising ("Limited", "Last pieces"), and each links to its filtered listing (`/collection?ribbon_id=<id>`). `productRibbons(row)` hands you `{id, name}` **objects** — render `r.name`, key the link on `r.id`; the entry itself in JSX is React's "Objects are not valid as a React child". Never render a bare "Ribbons:" label with nothing after it. ⚑ **A ribbon link inside a card that is itself a link nests `<a>` in `<a>`** — invalid, React warns. In the grid use plain labels, or link the image and title rather than the whole card; keep ribbon links on the product page.
122
122
 
@@ -156,9 +156,13 @@ Build your layout from — all optional, **not one component style**:
156
156
  - **Variant selector** — `variantAxes(view, p.pick)`, one entry per axis:
157
157
 
158
158
  ```jsx
159
+ const SWATCH = { Ivory: "#F2EDE4", "Obsidian Black": "#101014" }; // this catalog's colour names → CSS
159
160
  {variantAxes(view, p.pick).map((axis) => (
160
161
  <fieldset key={axis.key}>{/* label from axis.name / axis.selectedOption */}
161
- {axis.options.map((o) => (
162
+ {axis.options.map((o) => /colou?r/i.test(axis.name) ? (
163
+ <button key={o.value} disabled={o.disabled} aria-pressed={o.selected} onClick={o.pick}
164
+ className="swatch" style={{ background: SWATCH[o.value] }} title={o.value} aria-label={o.value} />
165
+ ) : (
162
166
  <button key={o.value} disabled={o.disabled} aria-pressed={o.selected} onClick={o.pick}>
163
167
  {o.value}{/* o.outOfStock → mark visibly */}
164
168
  </button>
@@ -167,7 +171,7 @@ Build your layout from — all optional, **not one component style**:
167
171
  ))}
168
172
  ```
169
173
 
170
- ⚑ **One control per axis, never a list of variations**, and ⚑ **an unbuyable option renders `disabled`, never hidden** (`outOfStock` stays visible, just marked). `view.missingAxes` names what's unpicked. **Render each axis by what it is** swatches for a colour axis, chips with a size guide beside a size axis; every axis as the identical chip row is a generated-page tell. That differentiation is semantic what the control *shows* built from your classes, not extra chrome around each row.
174
+ ⚑ **One control per axis, never a list of variations**, and ⚑ **an unbuyable option renders `disabled`, never hidden** (`outOfStock` stays visible, just marked). `view.missingAxes` names what's unpicked. **Pick the control per attribute, as the branch above does**: a colour axis as colour circles (the name stays reachable — `title`, `aria-label`, and the axis label showing `selectedOption`), size chips beside a size guide, a select for a long list; a specialty control where it genuinely fits, not on every axis — but every axis as the same bare chip row is the flattest page this kit produces. Any control keeps the contract: disabled, out-of-stock marked, selection visible.
171
175
  - **Buy box** — one button, and **you supply its four words**:
172
176
 
173
177
  ```jsx
@@ -190,7 +194,15 @@ Build your layout from — all optional, **not one component style**:
190
194
 
191
195
  ⚑ **Never `.map()` the whole list into one grey label/value table** — that is the single most reliable tell of a generated product page. Design the two or three rows that carry *this* catalog's meaning as what they are (a weight set in the display face, a composition as bars, a provenance beside its place); let the rest fall through to the plain row, and don't feel obliged to keep them in one block — a spec can sit under the gallery, beside the price, or inside the description. Branch on `s.key` too where one particular modifier deserves its own treatment regardless of type. ⚑ **Look a spec up with `findSpec(rows, "care")`** (ignores case, spaces, `_`, `-`): meta keys are free text (`care`, `Care`, `Care Instructions`), so `rows.find(s => s.label === "Care")` silently never matches and renders the fallback forever. `[]` means no section at all.
192
196
  - **Breadcrumbs** — from `categories` (`/collection?category_id=${c.id}`); skip on a flat catalog. Ribbons (`productRibbons(product)`) are labels, not breadcrumbs.
193
- - **Reviews, only if the store wants them** — no review UI is a complete outcome (then no star ratings on cards either: an average of nothing is `0`). `p.reviews` arrives with the product as `{ items, page, per_page, has_next }`; submitting is `submitReview` off `useStorefront()`, open to guests. Derive the confirmation from the response's `status` (`"approved"` vs `"hold"`) a hardcoded "awaiting approval" lies to every store that auto-approves — and refresh the list after, or the review doesn't appear. Field codes, policies and moderation: [`../references/reviews.md`](../references/reviews.md).
197
+ - **Reviews, only if the store wants them** — no review UI is a complete outcome (then no star ratings on cards either: an average of nothing is `0`). ⚑ **Both shapes are exact** entity-style names (`content`, `reviewer_name`) throw on submit and render blank in the list:
198
+
199
+ ```jsx
200
+ p.reviews // { items, page, per_page, has_next }
201
+ p.reviews.items[0] // { id, reviewer, review, rating, verified, created_date }
202
+ await submitReview({ product_id, review, rating, reviewer, email }) // NOT content/reviewer_name/reviewer_email
203
+ ```
204
+
205
+ Text or stars — either alone submits. ⚑ **The form renders for every visitor by default** (guests supply an email; hide it when signed in) — login-gate it only when the store asks. ⚑ Derive the confirmation from the response's `status` (`"approved"` vs `"hold"`) — a hardcoded "awaiting approval" lies to every auto-approving store — and refresh the list after, or the review doesn't appear. Codes, policies, moderation: [`../references/reviews.md`](../references/reviews.md).
194
206
  - **Title** — give each page type its own `<title>` and description; a store whose every page shares one static title is invisible to search. Nothing here emits structured data either — if the store wants rich results, emit your own `Product`/`Offer` JSON-LD from `product` and `view.display` (price, currency, availability).
195
207
 
196
208
  ## Cart / bag
@@ -249,9 +261,9 @@ import { CheckoutProvider, useCheckoutContext, AddressFields, ShippingMethodPick
249
261
 
250
262
  `useCheckout` reprices shipping/tax from the address automatically (debounced, never on a half-typed address), derives the shipping and payment choices, gates the button, and `placeOrder()` handles **both** navigations — online gateway → provider redirect, everything else → `/order-received` — as **full page loads** (`<CheckoutProvider options={{ orderReceivedPath: null }}>` for a router transition instead). `CheckoutProvider` shares it across the page's regions.
251
263
 
252
- ⚑ Rules: render each picker's `hint` and every branch; a single shipping or payment option still *shows* what it is — never a picker of one, never "nothing selected". Render `addressError` on the address fields. ⚑ Payment methods, currency and countries come from `useStoreInfo()`/`useCountries()` only — `cart.payment_gateways` is always `undefined`, and a default store offers `offline` only, so never hardcode a card option.
264
+ ⚑ Rules: render each picker's `hint` and every branch; a single shipping or payment option still *shows* what it is — never a picker of one, never "nothing selected". ⚑ Picks are instant: both pickers reflect a click immediately (shipping optimistically), and `mustChoose` stays true after a choice — the radios keep rendering, still changeable; never disable options while `syncing`/`choosing` (the hint covers it). Render `addressError` on the address fields. ⚑ Payment methods, currency and countries come from `useStoreInfo()`/`useCountries()` only — `cart.payment_gateways` is always `undefined`, and a default store offers `offline` only, so never hardcode a card option.
253
265
 
254
- ⚑ **A disabled place-order button must say why** — the silent disabled button is the most common checkout dead end. `blockers` is an array of codes; write one line per code, in the store's voice, anchored near the field that fixes it: `empty_cart` (bag is empty) · `billing_incomplete` (required address fields — `missingBillingFields` names them) · `shipping_address_incomplete` (the separate delivery address) · `shipping_address_required` (no address to price yet) · `shipping_method_required` (choose a delivery option) · `shipping_not_available` (this address can't be delivered to) · `payment_method_required` (choose how to pay) · `cart_loading` / `shipping_recalculating` (transient — a quiet "one moment", not an error).
266
+ ⚑ **A disabled place-order button must say why** — the silent disabled button is the most common checkout dead end. `blockers` is an array of codes; write one line per code, in the store's voice, anchored near the field that fixes it: `empty_cart` · `billing_incomplete` (required address fields — `missingBillingFields` names them) · `shipping_address_incomplete` (the separate delivery address) · `shipping_address_required` (no address to price yet) · `shipping_method_required` (choose a delivery option) · `shipping_not_available` (can't deliver there) · `payment_method_required` · `cart_loading` / `shipping_recalculating` (transient — a quiet "one moment", not an error).
255
267
 
256
268
  The pickers' `hint.code` works the same way (`missing_address`, `none_available`, `syncing` for shipping; `none_available` for payment): write those words once, and prefer `hint.serverMessage` when it is set — the backend's explanation is more specific than anything you can write.
257
269
 
@@ -282,7 +294,7 @@ function CheckoutForm() {
282
294
  {m.title} {m.costLabel}
283
295
  </label>
284
296
  ))}
285
- {!mustChoose && chosen && <p>{chosen.title} {chosen.costLabel}</p>}
297
+ {!mustChoose && chosen && <p>{chosen.title} {chosen.costLabel}</p>}{/* the lone option */}
286
298
  </fieldset>
287
299
  )}
288
300
  </ShippingMethodPicker>
@@ -304,7 +316,7 @@ function CheckoutForm() {
304
316
 
305
317
  **`<AddressFields>` is the one shipped component — use it, never hand-roll the address form.** It owns what hand-rolled forms get wrong: the state/province field appears with the right options once a country is picked (shipping rates and taxes match on country *plus* state, so a form without it mis-prices US/CA/AU orders with no error anywhere), every field keeps its `autoComplete` token (what makes browser autofill work), required marks arm on first blur, and the server's "we don't ship there" lands on the country field. `which="shipping"` renders null until `shipToDifferent` is on — the deliver-elsewhere checkbox itself is yours, wired to `c.shipToDifferent` / `c.setShipToDifferent`.
306
318
 
307
- It ships **no CSS** bar a `max-width:100%` cap on the selects (an unstyled checkout must not scroll sideways): every element carries `data-part` (`address-fields`, `field`, `label`, `control`, `required`, `error`) plus `data-key` (the field) and `data-span` (1 or 2 — the field's natural width in a two-column grid), so style it in your `index.css` via `[data-part]` selectors or pass `className`/`classes={{ field, label, control, error }}`. ⚑ **`data-part` sits on the element, not a wrapper** — `select[data-part="control"]`, never `[data-part="control"] input`: the descendant form matches nothing and ships the form unstyled. Props: `includeCompany` (default false), `includePhone` (default true), `omit={["…"]}`, `labels={{ postcode: "ZIP code" }}` (over `addressFieldSpec`'s plain-convention defaults), `selectPlaceholder`, and two escape hatches — `inputRender` swaps the control only (spread the handed `dom` props onto your input), `fieldRender` replaces the whole labeled block. `c.missingBillingFields` stays the live list of what is still missing, if you want your own per-field marks.
319
+ It ships **no CSS** bar a `max-width:100%` cap on the selects (an unstyled checkout must not scroll sideways): every element carries `data-part` (`address-fields`, `field`, `label`, `control`, `required`, `error`) plus `data-key` (the field) and `data-span` (1 or 2 — the field's natural width in a two-column grid), so style it in your `index.css` via `[data-part]` selectors or pass `className`/`classes={{ field, label, control, error }}`. ⚑ **`data-part` sits on the element, not a wrapper** — `select[data-part="control"]`, never `[data-part="control"] input`: the descendant form matches nothing and ships the form unstyled. Props: `includeCompany` (false), `includePhone` (true), `omit={["…"]}`, `labels={{ postcode: "ZIP code" }}`, `selectPlaceholder`, and two escape hatches — `inputRender` swaps the control only (spread the handed `dom` props onto your input), `fieldRender` replaces the whole labeled block. `c.missingBillingFields` stays the live list of what is missing, for your own per-field marks.
308
320
 
309
321
  ⚑ **The `stage === "submitted"` guard goes above the empty-cart branch** — placing an order clears the cart before the browser navigates, and without the guard the page flashes an empty bag over a just-placed order.
310
322
 
@@ -0,0 +1,95 @@
1
+ ---
2
+ stage: reference
3
+ read_when: "The admin must run in a language OUTSIDE the six that ship (en/de/es/fr/ja/pt), or you are adding new admin UI text that has to localize."
4
+ skip_when: "The admin stays in English (nothing to do) — or it runs in German, Spanish, French, Japanese or Portuguese, which is one import line in src/commerce/admin/i18n/index.js and needs nothing from this file. Also skip for storefront copy (the storefront ships none — write it directly in the store's language) and for transactional emails (backend templates — references/emails.md)."
5
+ forget_when: "The language is switched or the locale file is written, and the admin renders in it."
6
+ carry_forward:
7
+ - "Admin language = one import line in src/commerce/admin/i18n/index.js; no runtime picker, no npm package."
8
+ - "New locale file = copy of locales/en.js with every value translated and {placeholders} kept verbatim; missing keys fall back to English."
9
+ ---
10
+
11
+ # Admin localization
12
+
13
+ > **You probably don't need this file.** English is the default — nothing to
14
+ > do. German, Spanish, French, Japanese and Portuguese ship complete: repoint
15
+ > one import in `src/commerce/admin/i18n/index.js` and you are done. This file
16
+ > is for a **seventh** language, or for adding new admin text. In no case do
17
+ > you read or rewrite the admin's screens — the language never lives there.
18
+
19
+ The Store Management UI (`src/commerce/admin/`) renders every user-visible
20
+ string through `t()` from `src/commerce/admin/i18n/index.js`. The wording
21
+ lives in flat message catalogs at `src/commerce/admin/i18n/locales/<lang>.js`.
22
+ Shipped complete: `en` (default + fallback), `de`, `es`, `fr`, `ja`, `pt`.
23
+
24
+ It is all plain JavaScript inside the plugin — **no i18n npm package, nothing
25
+ to install**. Dates (`lib/format.js`), money (`hooks/useMoney.js`) and
26
+ country/currency display names (`Intl.DisplayNames`) format from the same
27
+ locale automatically; `lib/geo-data.js` keeps English state/province names
28
+ (proper nouns, mirrored from backend data) — do not translate that file.
29
+
30
+ ## Scope — what this does and does not cover
31
+
32
+ - **Covers:** every admin screen — navigation, tables, editors, settings,
33
+ dialogs, toasts, the first-run setup screen, enum labels
34
+ (`lib/constants.js` reads its labels from the catalog).
35
+ - **Does not cover:** the storefront (headless — it ships no copy; write your
36
+ storefront's copy directly in the store's language) and transactional
37
+ emails (backend templates — `references/emails.md`). The StoreAdmin bot's
38
+ UI chrome is localized; the bot's *answers* come from the agent and follow
39
+ the language the admin types in.
40
+
41
+ ## Switch the admin to a shipped language
42
+
43
+ Edit **one line** in `src/commerce/admin/i18n/index.js` — repoint the
44
+ `active` import:
45
+
46
+ ```js
47
+ import en from "./locales/en.js";
48
+ import active from "./locales/de.js"; // was "./locales/en.js" — admin now renders in German
49
+ ```
50
+
51
+ Leave the `en` import alone: it is the fallback for any key a locale misses.
52
+ There is deliberately no language picker in the UI — a store's back office
53
+ runs in one language, chosen in code. If the plugin is ever re-copied by an
54
+ update, re-apply this one-line edit.
55
+
56
+ ## Add a language that is not shipped
57
+
58
+ 1. Copy `src/commerce/admin/i18n/locales/en.js` to
59
+ `src/commerce/admin/i18n/locales/<code>.js` (e.g. `it.js`, `nl.js`,
60
+ `pt-BR.js`).
61
+ 2. Set its `$locale` to the language's BCP-47 tag (e.g. `"it"`, `"pt-BR"`) —
62
+ this drives `Intl` date/number/name formatting, not just messages.
63
+ 3. Translate **every value**; never change a key. Keep `{placeholders}`
64
+ verbatim — they are interpolated by name (`{count}`, `{email}`, `{page}`).
65
+ Keep the tone consistent with the shipped files (formal register: German
66
+ "Sie", Japanese です/ます).
67
+ 4. Point the `active` import in `i18n/index.js` at the new file (step above).
68
+
69
+ A partial file is safe: any key the file misses renders in English rather
70
+ than breaking, so translate-verify-iterate works. Your new locale file is not
71
+ part of the published package, so plugin updates won't overwrite it — only
72
+ the one-line import needs re-applying.
73
+
74
+ ## Adding new admin UI text
75
+
76
+ Never hard-code a user-visible string in an admin component. Render it as
77
+ `t("area.slug")` (import `{ t }` from the admin's `i18n/`, relative path) and
78
+ add the key to `locales/en.js`, namespaced by admin area
79
+ (`orders.`, `products.`, `settings.`, … — shared vocabulary lives under
80
+ `common.`). Then add the same key to every shipped locale file; if you skip
81
+ one, that language shows the English text for that key. Interpolation:
82
+ `t("orders.n_deleted", { count })` with catalog value `"{count} order(s)
83
+ deleted"`.
84
+
85
+ Keep the locale files structurally identical — same keys in every file. A
86
+ quick parity check:
87
+
88
+ ```bash
89
+ node --input-type=module -e '
90
+ import { readdirSync } from "node:fs";
91
+ const dir = new URL(`file://${process.cwd()}/src/commerce/admin/i18n/locales/`);
92
+ const keys = async (f) => Object.keys((await import(new URL(f, dir))).default).sort().join("\n");
93
+ const base = await keys("en.js");
94
+ for (const f of readdirSync(dir)) console.log(f, (await keys(f)) === base ? "ok" : "KEY MISMATCH");'
95
+ ```
@@ -5,6 +5,7 @@ skip_when: "The product page already renders `p.reviews` and submits through `su
5
5
  forget_when: "A review submits, appears (or is held) as the store's auto-approve setting dictates, and the aggregate rating renders."
6
6
  carry_forward:
7
7
  - "Reviews are part of the happy path: the list arrives with the product, submitting is one client call. The confirmation copy must come from the submit response's `status`, never hardcoded."
8
+ - "The review form renders for every visitor by default — guests included, with an email field. Login-gating is an explicit store request, implemented as one conditional around the form."
8
9
  ---
9
10
 
10
11
  # Reviews
@@ -16,7 +17,7 @@ Reviews are **part of the happy path**, not an extra: the backend always shipped
16
17
  Both live on the storefront client in `@/commerce/utils` — in React, `useStorefront()` is that client:
17
18
 
18
19
  - **`getProductReviews(slugOrRef, { page, per_page })`** → `{ items, page, per_page, has_next, average_rating, rating_count }`. The same reviews `get-product` returns — page or refresh the list without re-fetching the page; `useProduct(slug, { reviewsPerPage })` sizes the first one.
19
- - **`submitReview({ product_id, review, rating?, reviewer?, email? })`** → `{ review_id, status, verified }`. `review` is the body text and is required; `rating` is optional, **0–5**; `reviewer` is the display name. It **rejects** with `email_required` | `review_incomplete` | `invalid_rating` | `not_found` — catch it, read `storefrontErrorCode(e)`, and land each code on its own field, so a failed submit says what to fix instead of resolving into nothing. After an approved submission, refresh the list yourself so the review actually appears.
20
+ - **`submitReview({ product_id, review?, rating?, reviewer?, email? })`** → `{ review_id, status, verified }`. **Text or stars — at least one**: `review` is the body text, `rating` is **1–5 stars** (0 counts as unrated); stars-only and text-only are both valid, only both missing rejects. `reviewer` is the display name. ⚑ **One set of names, both directions** — the payload above, rows back as `{ id, reviewer, review, rating, verified, created_date }`. Not the entity's columns: a near-miss (`content`, `reviewer_name`, `reviewer_email`, …) throws client-side rather than being aliased, since the same wrong names would render the list blank too. It **rejects** with `email_required` | `review_incomplete` (neither text nor stars) | `invalid_rating` | `not_found` — catch it, read `storefrontErrorCode(e)`, and land each code on its own field, so a failed submit says what to fix instead of resolving into nothing. After an approved submission, refresh the list yourself so the review actually appears.
20
21
 
21
22
  ## What ships
22
23
 
@@ -25,17 +26,17 @@ Both live on the storefront client in `@/commerce/utils` — in React, `useStore
25
26
  - `storefront-account` `my-reviews` lists a signed-in customer's own — what a "My reviews" account tab renders from;
26
27
  - moderation is in the admin (Products → Reviews); `commerce/admin-reviews` recalculates the product's rating on every status change.
27
28
 
28
- ## The policy is the store's
29
+ ## The policy is the store's — and open is the default
29
30
 
30
- Which visitors may submit is a gate **you** render, in your own words. The three patterns worth knowing:
31
+ Which visitors may submit is a gate **you** render, in your own words — and **the default is no gate at all**. Build the form for every visitor, signed in or not (that is the server's own rule); a review form a guest cannot use is a policy the store has to ask for, never something to add on your own initiative. The two stricter patterns are each one small change away when the store does ask:
31
32
 
32
33
  | Policy | Who may submit | How you implement it |
33
34
  |---|---|---|
34
- | **Open** (the server's own rule) | anyone with a valid email | render the form for everyone; the email field is required for guests |
35
- | **Login-gated** | a signed-in visitor only | render the form only when your app has a user; otherwise your "sign in to review" line |
35
+ | **Open the default** | anyone with a valid email | render the form for everyone; the email field is required for guests, hidden when signed in |
36
+ | **Login-gated** | a signed-in visitor only | one conditional around the same form: render it when your app has a user, otherwise your "sign in to review" line |
36
37
  | **Verified buyers** | someone whose own orders include a `processing`/`completed` order for this product | check `storefront-account` `my-orders` for the product, gate on the result |
37
38
 
38
- Policies are **UI-side by design**: the server accepts any valid email, so a stricter rule is exactly this gate — and a policy that must hold against handcrafted API calls too belongs in a backend function of your own wrapping `submit-review`. Either way: hide the email field for a signed-in visitor (the session's email wins server-side), and make the stars mandatory by validating before you call. An honest middle ground for most stores: accept everything and render the `verified` flag as a "Verified purchase" badge.
39
+ Policies are **UI-side by design**: the server accepts any valid email, so a stricter rule is exactly this gate — and a policy that must hold against handcrafted API calls too belongs in a backend function of your own wrapping `submit-review`. Either way: hide the email field for a signed-in visitor (the session's email wins server-side). The server needs only one of text/stars — if your form makes either (or both) mandatory, validate before you call, so the customer meets your words rather than a raw 400. An honest middle ground for most stores: accept everything and render the `verified` flag as a "Verified purchase" badge.
39
40
 
40
41
  ## Auto-approval and moderation
41
42
 
@@ -25,20 +25,12 @@ Tailwind + shadcn/ui + React Router) to get a full store back office.
25
25
  npx npq install <only the missing names> # npq audits the package before npm installs it
26
26
  ```
27
27
 
28
- 4. Mount it as a layout route in the app's own `src/App.jsx` — that is the file
29
- Base44 discovers an app's pages from, so the screens that should be listed as
30
- pages are declared there literally, and the rest go to `<AdminRoutes />` on a
31
- splat. The full step is the skill's `install/01-install.md`.
28
+ 4. Mount the app in your router:
32
29
 
33
30
  ```jsx
34
- import AdminApp, { AdminRoutes } from "@/commerce/admin";
35
-
36
- <Route path="/store-admin" element={<AdminApp />}>
37
- <Route index element={<Dashboard />} />
38
- <Route path="orders" element={<OrdersList />} />
39
- {/* …products, customers, coupons, reports… */}
40
- <Route path="*" element={<AdminRoutes />} />
41
- </Route>
31
+ import AdminApp from "@/commerce/admin";
32
+
33
+ <Route path="/store-admin/*" element={<AdminApp />} />
42
34
  // mounted elsewhere? → <AdminApp basePath="/backoffice" />
43
35
  ```
44
36
 
@@ -83,17 +75,47 @@ npx shadcn@latest add <component>
83
75
  ## Layout of this folder
84
76
 
85
77
  ```
86
- index.jsx AdminApp: providers → auth guard → layout → <Outlet/>
87
- routes.jsx Route table + <AdminRoutes/> (the splat handler App.jsx delegates to)
78
+ index.jsx AdminApp: providers → auth guard → layout → routes
79
+ routes.jsx Route table + <AdminRoutes/>
88
80
  layout/ AdminLayout, Sidebar, Topbar, AuthGuard (admin-role gate), AccessDenied
89
81
  bot/ StoreAdminBot (chat panel over the commerce/StoreAdmin agent), Markdown (GFM renderer)
90
82
  context/ SettingsContext (store settings + first-run seeding), BasePathContext
91
83
  hooks/ useAsync, usePagedList, useRealtime (live updates), useMoney, useDebounce
84
+ i18n/ t() + locale; locales/ = en (default), de, es, fr, ja, pt
92
85
  lib/ api (function calls), constants, format, geo-data, order/product utils
93
86
  components/ DataTable, SearchSelect, MoneyInput, DateRangePicker, AddressForm, …
94
87
  pages/ All admin pages (orders, products, coupons, customers, reports, settings, webhooks)
95
88
  ```
96
89
 
90
+ ## Localization
91
+
92
+ Every user-visible string renders through `t()` from `i18n/`; the wording
93
+ lives in `i18n/locales/<lang>.js`. English (`en`) is the default and the
94
+ fallback; `de`, `es`, `fr`, `ja` and `pt` ship complete. Dates, money and
95
+ country/currency names follow the same locale via the browser's `Intl` —
96
+ no i18n package is involved anywhere.
97
+
98
+ **Switch language** — edit one line in `i18n/index.js`:
99
+
100
+ ```js
101
+ import active from "./locales/en.js"; // ← point at "./locales/de.js" for German
102
+ ```
103
+
104
+ There is deliberately no runtime picker: a store's back office runs in one
105
+ language. (Re-apply this one-line edit if a plugin update re-copies this
106
+ folder.)
107
+
108
+ **Add a language** — copy `i18n/locales/en.js` to `i18n/locales/<code>.js`,
109
+ set its `$locale` to the BCP-47 tag, translate every value keeping
110
+ `{placeholders}` verbatim, then point the `active` import at the new file.
111
+ Missing keys fall back to English, so a partial file renders mixed rather
112
+ than broken.
113
+
114
+ **Adding admin UI text?** Render it as `t("area.slug")`, add the key to
115
+ `i18n/locales/en.js`, and translate it in each shipped locale (or leave it —
116
+ it falls back to English). Full recipe for agents: the commerce skill's
117
+ `references/admin-localization.md`.
118
+
97
119
  ## Live-updating views
98
120
 
99
121
  The dashboard, the orders list and the reports tabs refresh themselves — a new