@base44/app-plugin-commerce 0.10.0 → 0.10.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@base44/app-plugin-commerce",
3
- "version": "0.10.0",
3
+ "version": "0.10.2",
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",
@@ -12,6 +12,7 @@ carry_forward:
12
12
  - "Entities are dotted + bracket-syntax only (`base44.entities[\"commerce.X\"]`); the map is ../docs/entities.md — never scan base44/entities/."
13
13
  - "Payment gateways, currency and countries come from useStoreInfo() only — never off a cart (cart.payment_gateways is always undefined)."
14
14
  - "Cart page, drawer, checkout and order-received are SHIPPED components (@/commerce/storefront-ui): mount them, set the .sfui theme block in pairs + brand props, and build or edit nothing inside — day-2 changes go through ../references/storefront-ui.md, never file edits (kit updates re-copy the directory)."
15
+ - "A prompt naming a cart or checkout is describing the store, not ordering a custom one — mount the shipped component."
15
16
  - "<MiniCart /> mounts once in the layout, never on a route; the shipped coupon field appears by itself exactly when the store has coupons (sections.coupon \"auto\") — never wire your own."
16
17
  - "Branch list/product UI on `status`, never on `isEmpty`/nullable data — `isEmpty` is false while loading by design."
17
18
  - "Variant options that aren't buyable render disabled, never hidden; one control per axis, never a list of variations."
@@ -131,6 +132,8 @@ Record this file's `carry_forward` lines (front matter) in your working notes, t
131
132
 
132
133
  One split decides everything here — **who owns which surface**. The catalog — the list/collection and the product page — is yours end to end: every element, class, layout and **word**; its logic is premade as headless hooks (`@/commerce/storefront`) that render nothing. The **conversion surfaces — cart page, cart drawer, checkout, order received — ship finished** (`@/commerce/storefront-ui`, [below](#cart-drawer-checkout-order-received--shipped)): you mount them, set a theme block and brand wording, and build nothing inside. **Decide how the store looks as if this kit did not exist**, then encode it **once** as design classes ([below](#design-language--once-before-any-page)) — the snippets here are wiring reference, never design input.
133
134
 
135
+ ⚑ **Nearly every store prompt describes a cart and a checkout** — "customers add to a cart and pay at checkout" states what a store *is*; it is not an instruction to build those screens, and it is the sentence most likely to talk you out of mounting them. Only an ask to *change* a conversion surface is a request, and props or tokens answer it — never a hand-built page.
136
+
134
137
  **On the pages you build, states and codes — not copy.** Where a state needs words you get the *state* (`buy.state`) and write the words. So: never re-derive a state you were handed (a ternary chain over `adding`/`purchasable` re-implements `buy.state`, wrong), and never leave one unworded (a button with no text for `sold_out` renders empty). The hooks own variant resolution, cart state, pricing — **never re-implement what a hook does**; that is where storefront bugs cluster.
135
138
 
136
139
  **This file is the whole job.** Every shape you need is in ["What each hook resolves to"](#what-each-hook-resolves-to) — don't open the hook files while building; that is the most expensive way to answer a question this page already answers. Rules marked ⚑ must survive whatever design you build.
@@ -210,7 +213,7 @@ Everything below is already unwrapped — no `.data`, no envelope. `formatMoney`
210
213
  | `useProduct(slug)` | `{ status, product, view, price, selection, pick, quantity, setQuantity, incQuantity, decQuantity, maxQuantity, canIncrease, categories, ribbons, upsells, crossSells, reviews, reload }` — `status`: `"loading" \| "ready" \| "not_found" \| "error"`; `reviews` is `{ items, page, per_page, has_next }`. |
211
214
  | `useAddToCart(p)` | `{ state, disabled, addToCart, adding, error, soldOut, needsSelection, quantity, increase, decrease, canIncrease, canDecrease, showQuantity, reset }` — `state`: `"ready" \| "adding" \| "sold_out" \| "needs_selection"`. |
212
215
  | `variantAxes(view, pick)` | `[{ key, name, selectedOption, options: [{ value, selected, disabled, outOfStock, pick }] }]`. |
213
- | `productPrice(rowOrView, { formatMoney })` | `{ label, compareAtLabel, onSale, isFrom, isRange, min, max }` — `label` is what to render. |
216
+ | `productPrice(rowOrView, { formatMoney, fromLabel })` | `{ label, compareAtLabel, onSale, isFrom, isRange, min, max }` — `label` is what to render. ⚑ `fromLabel` defaults to English "From": a store in another language passes its own word once, or every variant card ships one English word. |
214
217
  | `productImages(product)` | `[{ src, name, alt }]`, de-duplicated. `[]` is legitimate → render your placeholder. |
215
218
  | `productRibbons(product)` | `[{ id, name }]` — **objects**, and the field can be absent; takes a listing row or `useProduct().product`. |
216
219
  | `productSpecs(product)` | `[{ key, label, titleLabel, value, type, number, unit, items }]` from `meta_data` — `type` is `"numeric" \| "duration" \| "location" \| "list" \| "text"`, inferred, with `number`/`unit` split out for the first two and `items` for a list. `findSpec(rows, key)` looks one up ignoring case/spaces/`_`/`-`. Never match on `label` — meta keys are free text. |
@@ -227,7 +230,7 @@ import { useProductList, useCategories, useStoreInfo, useFormatMoney, productPri
227
230
 
228
231
  ⚑ **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.
229
232
 
230
- 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)`.
233
+ 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 a non-English store pass `fromLabel` with the store's word), `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)`.
231
234
 
232
235
  ⚑ **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.
233
236
 
@@ -336,6 +339,7 @@ The components style themselves; with no block at all they follow the app's shad
336
339
  --sfui-border: #e5e0d5;
337
340
  --sfui-radius: 0.5rem; /* panels; controls derive from it */
338
341
  --sfui-font-heading: "Fraunces", serif; /* body text inherits the site's font by itself */
342
+ /* optional: --sfui-font-label — only if the site's labels/microcopy use a second face */
339
343
  }
340
344
  ```
341
345
 
@@ -37,6 +37,7 @@ then to safe literals — an app with no block at all is already correct.
37
37
  | `--sfui-border` | hairlines, control borders | `--border` |
38
38
  | `--sfui-radius` | panels/cards (controls derive ×0.6, or set `--sfui-radius-control`) | `--radius` → `0.75rem` |
39
39
  | `--sfui-font-heading` | headings only — body text always inherits the site font | `inherit` |
40
+ | `--sfui-font-label` | section/field labels and microcopy — set only when the site gives labels their own face (e.g. a mono label idiom) | `inherit` |
40
41
 
41
42
  **Contrast guard**: on mount each active pair is measured; a pair under 3:1 is reset
42
43
  to its default (both sides) and a `console.warn` names it. So a "my colors aren't
@@ -130,6 +130,23 @@ function CheckoutBody({ brand, layout, sections, continueHref, onPlaced }) {
130
130
 
131
131
  const disabled = !canPlaceOrder || placing || (termsCheckbox && !termsAccepted);
132
132
 
133
+ // AddressFields' built-in labels are English; the shipped checkout localizes
134
+ // them like every other label on the page.
135
+ const addressLabels = {
136
+ first_name: tt("checkout.address.first_name"),
137
+ last_name: tt("checkout.address.last_name"),
138
+ email: tt("checkout.address.email"),
139
+ company: tt("checkout.address.company"),
140
+ address_1: tt("checkout.address.address_1"),
141
+ address_2: tt("checkout.address.address_2"),
142
+ country: tt("checkout.address.country"),
143
+ city: tt("checkout.address.city"),
144
+ state: tt("checkout.address.state"),
145
+ postcode: tt("checkout.address.postcode"),
146
+ phone: tt(phone === "required" ? "checkout.address.phone" : "checkout.address.phoneOptional"),
147
+ };
148
+ const selectPlaceholder = tt("checkout.address.select");
149
+
133
150
  return (
134
151
  <SfuiRoot className="sfui-checkout" aria-labelledby="sfui-checkout-title">
135
152
  <div className="sfui-checkout-inner" data-layout={layout}>
@@ -140,7 +157,7 @@ function CheckoutBody({ brand, layout, sections, continueHref, onPlaced }) {
140
157
 
141
158
  <section className="sfui-section sfui-address" aria-label={tt("checkout.contactTitle")}>
142
159
  <h2 className="sfui-label">{tt("checkout.contactTitle")}</h2>
143
- <AddressFields which="billing" includePhone={phone !== "hidden"} />
160
+ <AddressFields which="billing" includePhone={phone !== "hidden"} labels={addressLabels} selectPlaceholder={selectPlaceholder} />
144
161
  {allowsShipToDifferent(shipToDifferent) && (
145
162
  <label className="sfui-checkbox">
146
163
  <input
@@ -154,7 +171,7 @@ function CheckoutBody({ brand, layout, sections, continueHref, onPlaced }) {
154
171
  {checkout.shipToDifferent && (
155
172
  <>
156
173
  <h2 className="sfui-label">{tt("checkout.shippingAddressTitle")}</h2>
157
- <AddressFields which="shipping" includePhone={false} />
174
+ <AddressFields which="shipping" includePhone={false} labels={addressLabels} selectPlaceholder={selectPlaceholder} />
158
175
  </>
159
176
  )}
160
177
  </section>
@@ -60,6 +60,19 @@ export default {
60
60
  "checkout.hint.none_available": "An diese Adresse können wir leider noch nicht liefern.",
61
61
  "checkout.hint.syncing": "Lieferoptionen werden aktualisiert…",
62
62
  "checkout.hint.payment_none": "Die Kasse ist vorübergehend nicht verfügbar — keine Zahlungsart aktiviert.",
63
+ "checkout.address.first_name": "Vorname",
64
+ "checkout.address.last_name": "Nachname",
65
+ "checkout.address.email": "E-Mail",
66
+ "checkout.address.company": "Firma",
67
+ "checkout.address.address_1": "Adresse",
68
+ "checkout.address.address_2": "Adresszusatz (optional)",
69
+ "checkout.address.country": "Land",
70
+ "checkout.address.city": "Stadt",
71
+ "checkout.address.state": "Bundesland / Region",
72
+ "checkout.address.postcode": "Postleitzahl",
73
+ "checkout.address.phone": "Telefon",
74
+ "checkout.address.phoneOptional": "Telefon (optional)",
75
+ "checkout.address.select": "Bitte wählen…",
63
76
  "checkout.blocker.cart_loading": "Ihr Warenkorb wird geladen…",
64
77
  "checkout.blocker.empty_cart": "Ihr Warenkorb ist leer.",
65
78
  "checkout.blocker.billing_incomplete": "Füllen Sie die erforderlichen Kontaktfelder aus.",
@@ -70,6 +70,19 @@ export default {
70
70
  "checkout.hint.none_available": "We can't deliver to that address yet.",
71
71
  "checkout.hint.syncing": "Updating delivery options…",
72
72
  "checkout.hint.payment_none": "Checkout is temporarily unavailable — no payment method is enabled.",
73
+ "checkout.address.first_name": "First name",
74
+ "checkout.address.last_name": "Last name",
75
+ "checkout.address.email": "Email",
76
+ "checkout.address.company": "Company",
77
+ "checkout.address.address_1": "Address",
78
+ "checkout.address.address_2": "Apartment, suite (optional)",
79
+ "checkout.address.country": "Country",
80
+ "checkout.address.city": "City",
81
+ "checkout.address.state": "State / Region",
82
+ "checkout.address.postcode": "Postal code",
83
+ "checkout.address.phone": "Phone",
84
+ "checkout.address.phoneOptional": "Phone (optional)",
85
+ "checkout.address.select": "Select…",
73
86
  "checkout.blocker.cart_loading": "Loading your cart…",
74
87
  "checkout.blocker.empty_cart": "Your cart is empty.",
75
88
  "checkout.blocker.billing_incomplete": "Fill in the required contact fields.",
@@ -60,6 +60,19 @@ export default {
60
60
  "checkout.hint.none_available": "Aún no podemos hacer entregas en esa dirección.",
61
61
  "checkout.hint.syncing": "Actualizando opciones de entrega…",
62
62
  "checkout.hint.payment_none": "El pago no está disponible por el momento — no hay ningún método de pago habilitado.",
63
+ "checkout.address.first_name": "Nombre",
64
+ "checkout.address.last_name": "Apellido",
65
+ "checkout.address.email": "Correo electrónico",
66
+ "checkout.address.company": "Empresa",
67
+ "checkout.address.address_1": "Dirección",
68
+ "checkout.address.address_2": "Departamento, interior (opcional)",
69
+ "checkout.address.country": "País",
70
+ "checkout.address.city": "Ciudad",
71
+ "checkout.address.state": "Estado / Región",
72
+ "checkout.address.postcode": "Código postal",
73
+ "checkout.address.phone": "Teléfono",
74
+ "checkout.address.phoneOptional": "Teléfono (opcional)",
75
+ "checkout.address.select": "Selecciona…",
63
76
  "checkout.blocker.cart_loading": "Cargando tu carrito…",
64
77
  "checkout.blocker.empty_cart": "Tu carrito está vacío.",
65
78
  "checkout.blocker.billing_incomplete": "Completa los campos de contacto obligatorios.",
@@ -60,6 +60,19 @@ export default {
60
60
  "checkout.hint.none_available": "Nous ne livrons pas encore à cette adresse.",
61
61
  "checkout.hint.syncing": "Mise à jour des options de livraison…",
62
62
  "checkout.hint.payment_none": "Le paiement est temporairement indisponible — aucun moyen de paiement n'est activé.",
63
+ "checkout.address.first_name": "Prénom",
64
+ "checkout.address.last_name": "Nom",
65
+ "checkout.address.email": "E-mail",
66
+ "checkout.address.company": "Société",
67
+ "checkout.address.address_1": "Adresse",
68
+ "checkout.address.address_2": "Appartement, bâtiment (facultatif)",
69
+ "checkout.address.country": "Pays",
70
+ "checkout.address.city": "Ville",
71
+ "checkout.address.state": "État / Région",
72
+ "checkout.address.postcode": "Code postal",
73
+ "checkout.address.phone": "Téléphone",
74
+ "checkout.address.phoneOptional": "Téléphone (facultatif)",
75
+ "checkout.address.select": "Sélectionnez…",
63
76
  "checkout.blocker.cart_loading": "Chargement de votre panier…",
64
77
  "checkout.blocker.empty_cart": "Votre panier est vide.",
65
78
  "checkout.blocker.billing_incomplete": "Renseignez les champs de contact obligatoires.",
@@ -60,6 +60,19 @@ export default {
60
60
  "checkout.hint.none_available": "申し訳ありませんが、その住所への配送はまだ承っておりません。",
61
61
  "checkout.hint.syncing": "配送方法を更新しています…",
62
62
  "checkout.hint.payment_none": "現在ご購入手続きをご利用いただけません — 有効なお支払い方法がありません。",
63
+ "checkout.address.first_name": "名",
64
+ "checkout.address.last_name": "姓",
65
+ "checkout.address.email": "メールアドレス",
66
+ "checkout.address.company": "会社名",
67
+ "checkout.address.address_1": "住所",
68
+ "checkout.address.address_2": "建物名・部屋番号(任意)",
69
+ "checkout.address.country": "国",
70
+ "checkout.address.city": "市区町村",
71
+ "checkout.address.state": "都道府県 / 地域",
72
+ "checkout.address.postcode": "郵便番号",
73
+ "checkout.address.phone": "電話番号",
74
+ "checkout.address.phoneOptional": "電話番号(任意)",
75
+ "checkout.address.select": "選択してください",
63
76
  "checkout.blocker.cart_loading": "カートを読み込んでいます…",
64
77
  "checkout.blocker.empty_cart": "カートは空です。",
65
78
  "checkout.blocker.billing_incomplete": "必須の連絡先項目をご入力ください。",
@@ -60,6 +60,19 @@ export default {
60
60
  "checkout.hint.none_available": "Ainda não entregamos nesse endereço.",
61
61
  "checkout.hint.syncing": "Atualizando opções de entrega…",
62
62
  "checkout.hint.payment_none": "A finalização está temporariamente indisponível — nenhuma forma de pagamento habilitada.",
63
+ "checkout.address.first_name": "Nome",
64
+ "checkout.address.last_name": "Sobrenome",
65
+ "checkout.address.email": "E-mail",
66
+ "checkout.address.company": "Empresa",
67
+ "checkout.address.address_1": "Endereço",
68
+ "checkout.address.address_2": "Complemento (opcional)",
69
+ "checkout.address.country": "País",
70
+ "checkout.address.city": "Cidade",
71
+ "checkout.address.state": "Estado / Região",
72
+ "checkout.address.postcode": "Código postal",
73
+ "checkout.address.phone": "Telefone",
74
+ "checkout.address.phoneOptional": "Telefone (opcional)",
75
+ "checkout.address.select": "Selecione…",
63
76
  "checkout.blocker.cart_loading": "Carregando seu carrinho…",
64
77
  "checkout.blocker.empty_cart": "Seu carrinho está vazio.",
65
78
  "checkout.blocker.billing_incomplete": "Preencha os campos de contato obrigatórios.",
@@ -27,6 +27,7 @@
27
27
  --_radius: var(--sfui-radius, var(--radius, 0.75rem));
28
28
  --_radius-control: var(--sfui-radius-control, calc(var(--_radius) * 0.6));
29
29
  --_font-heading: var(--sfui-font-heading, inherit);
30
+ --_font-label: var(--sfui-font-label, inherit);
30
31
 
31
32
  background: transparent;
32
33
  color: var(--_on-surface);
@@ -47,6 +48,7 @@
47
48
 
48
49
  .sfui-label {
49
50
  display: block;
51
+ font-family: var(--_font-label);
50
52
  font-size: 0.6875rem;
51
53
  font-weight: 600;
52
54
  letter-spacing: 0.08em;
@@ -379,6 +381,7 @@
379
381
  }
380
382
  .sfui-address [data-part="label"] {
381
383
  display: block;
384
+ font-family: var(--_font-label);
382
385
  font-size: 0.8125rem;
383
386
  font-weight: 500;
384
387
  margin-block-end: 0.375rem;
@@ -400,8 +403,9 @@
400
403
  .sfui-choice[data-selected="true"] { border-color: var(--_accent); box-shadow: inset 0 0 0 1px var(--_accent); }
401
404
  .sfui-choice input { inline-size: 1rem; block-size: 1rem; min-height: 0; margin: 0; accent-color: var(--_accent); flex-shrink: 0; }
402
405
  .sfui-choice-main { flex: 1; min-inline-size: 0; }
403
- .sfui-choice-title { font-size: 0.9375rem; font-weight: 500; }
404
- .sfui-choice-desc { font-size: 0.8125rem; color: var(--_on-subtle); margin-block-start: 0.125rem; }
406
+ /* block, not inline: a gateway's description must land under its title */
407
+ .sfui-choice-title { display: block; font-size: 0.9375rem; font-weight: 500; }
408
+ .sfui-choice-desc { display: block; font-size: 0.8125rem; color: var(--_on-subtle); margin-block-start: 0.125rem; }
405
409
  .sfui-choice-cost { font-size: 0.9375rem; font-weight: 600; white-space: nowrap; }
406
410
  .sfui-chosen-single {
407
411
  display: flex;
@@ -34,13 +34,15 @@ const money = (fn, n) => (typeof fn === "function" ? fn(n) : String(n ?? ""));
34
34
  *
35
35
  * @param {object} rowOrView a `list-products`/`get-product` product, or a
36
36
  * `resolveSelection` view
37
- * @param {{formatMoney?: (n: number) => string}} [opts]
37
+ * @param {{formatMoney?: (n: number) => string, fromLabel?: string}} [opts]
38
+ * `fromLabel` is the from-price prefix — "From" by default, so a store in
39
+ * another language MUST pass its own word or ship one English word per card.
38
40
  * @returns {{label: string, compareAtLabel: string|null, onSale: boolean,
39
41
  * isFrom: boolean, isRange: boolean, min: number|null, max: number|null}}
40
42
  * `label` is what to render as the price; `compareAtLabel` is the
41
43
  * struck-through original, or null when the product is not on sale.
42
44
  */
43
- export function productPrice(rowOrView, { formatMoney } = {}) {
45
+ export function productPrice(rowOrView, { formatMoney, fromLabel = "From" } = {}) {
44
46
  const fmt = (n) => money(formatMoney, n);
45
47
  const blank = {
46
48
  label: "", compareAtLabel: null, onSale: false, isFrom: false,
@@ -85,7 +87,7 @@ export function productPrice(rowOrView, { formatMoney } = {}) {
85
87
  const price = row.price ?? null;
86
88
  const onSale = Boolean(row.on_sale);
87
89
  return {
88
- label: price == null ? "" : isFrom ? `From ${fmt(price)}` : fmt(price),
90
+ label: price == null ? "" : isFrom ? `${fromLabel} ${fmt(price)}` : fmt(price),
89
91
  compareAtLabel: onSale && row.regular_price != null ? fmt(row.regular_price) : null,
90
92
  onSale,
91
93
  isFrom,