@base44/app-plugin-commerce 0.9.4 → 0.10.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.
- package/README.md +6 -3
- package/base44/functions/commerce/storefront-catalog/entry.ts +5 -0
- package/package.json +1 -1
- package/scripts/install.js +3 -0
- package/skills/commerce/SKILL.md +36 -39
- package/skills/commerce/docs/api-storefront.md +1 -1
- package/skills/commerce/installation/install.md +76 -165
- package/skills/commerce/references/admin-localization.md +6 -4
- package/skills/commerce/references/storefront-ui.md +154 -0
- package/src/commerce/storefront/index.js +6 -3
- package/src/commerce/storefront-ui/CartButton.jsx +33 -0
- package/src/commerce/storefront-ui/CartPage.jsx +140 -0
- package/src/commerce/storefront-ui/CheckoutPage.jsx +287 -0
- package/src/commerce/storefront-ui/MiniCart.jsx +128 -0
- package/src/commerce/storefront-ui/OrderReceivedPage.jsx +159 -0
- package/src/commerce/storefront-ui/i18n/index.js +51 -0
- package/src/commerce/storefront-ui/i18n/locales/de.js +89 -0
- package/src/commerce/storefront-ui/i18n/locales/en.js +100 -0
- package/src/commerce/storefront-ui/i18n/locales/es.js +89 -0
- package/src/commerce/storefront-ui/i18n/locales/fr.js +89 -0
- package/src/commerce/storefront-ui/i18n/locales/ja.js +89 -0
- package/src/commerce/storefront-ui/i18n/locales/pt.js +89 -0
- package/src/commerce/storefront-ui/index.js +16 -0
- package/src/commerce/storefront-ui/internal.jsx +218 -0
- package/src/commerce/storefront-ui/storefront-ui.css +448 -0
- package/src/commerce/storefront-ui/theme.js +76 -0
|
@@ -11,12 +11,12 @@ carry_forward:
|
|
|
11
11
|
- "Interleave: start image generation first → mount admin + build the storefront → seed with the image_url the generate_image result already returned (never poll, never write a function to fetch URLs) → payments last."
|
|
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
|
-
- "
|
|
15
|
-
- "
|
|
16
|
-
- "Branch
|
|
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
|
+
- "<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
|
+
- "Branch list/product UI on `status`, never on `isEmpty`/nullable data — `isEmpty` is false while loading by design."
|
|
17
17
|
- "Variant options that aren't buyable render disabled, never hidden; one control per axis, never a list of variations."
|
|
18
18
|
- "Every hook on a page goes above its status guards — a hook after an early return crashes with \"Rendered more hooks than during the previous render\"."
|
|
19
|
-
- "
|
|
19
|
+
- "On the pages you build the kit ships no copy: hooks hand you state codes (buy.state) and you write every word in the store's voice; the shipped surfaces carry their own localized copy, reworded only via brand props."
|
|
20
20
|
- "Identity is encoded once — design classes in index.css plus one or two signature moments per page; a utility run that appears twice becomes a class."
|
|
21
21
|
- "The product page is the richest surface and budgeted for it (~8K chars): productSpecs rows carry a type — branch on it, never .map() the list into one grey label/value table."
|
|
22
22
|
- "Product slugs come from the seed response's catalog.products[] — link pages by slug, never by a client-side map."
|
|
@@ -47,26 +47,31 @@ lines and treat that stage as done — but keep reading this file, not re-fetchi
|
|
|
47
47
|
|
|
48
48
|
**Dependencies.** `sonner`, `recharts`, `react-markdown` — all three ship with the default Base44 template, so check `package.json` and `npm i` only what is actually absent (the normal outcome is installing nothing).
|
|
49
49
|
|
|
50
|
-
##
|
|
50
|
+
## Work order — interleave, don't queue
|
|
51
51
|
|
|
52
|
-
Image generation is the slowest step and nothing depends on it until seed time; the storefront doesn't wait on live data either.
|
|
52
|
+
Image generation is the slowest step and nothing depends on it until seed time; the storefront doesn't wait on live data either.
|
|
53
53
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
54
|
+
1. **Start image generation first** — every product image, before anything else.
|
|
55
|
+
⚑ **The result already carries the real `image_url`** alongside a `placeholder_url`
|
|
56
|
+
— use `image_url` and move on. Nothing is pending, there is nothing to poll, and a
|
|
57
|
+
backend function calling `Core.GenerateImage` to "fetch the real URLs" is pure waste:
|
|
58
|
+
you already have them. If a `/__generating__/…` value does reach a file or a seed
|
|
59
|
+
payload, the platform swaps it for the real URL after the turn (in files *and* entity
|
|
60
|
+
records) — so a placeholder that renders broken in a mid-build preview is expected and
|
|
61
|
+
must not be "fixed".
|
|
62
|
+
2. **Mount the admin (below) and build the storefront** while images render. Every
|
|
63
|
+
request and response shape the pages build against is written out in
|
|
64
|
+
[stage 02 below](#02--storefront), so they are written from the docs,
|
|
65
|
+
not from live data.
|
|
66
|
+
3. **Seed the moment the image URLs are back** — one `commerce/seed-store` call ([stage 03 below](#03--store-data)). Its writes run in parallel, so the call usually takes a few seconds — but don't idle on it either.
|
|
67
|
+
4. **Converge**: open the finished pages against the live catalog.
|
|
68
|
+
5. **Payments last, if at all** — cards are off by default; [stage 03 below](#03--store-data) decides it.
|
|
64
69
|
|
|
65
|
-
|
|
70
|
+
The only dependency edges are *image URLs → seed payload* and *seed done → real products on the pages*.
|
|
66
71
|
|
|
67
72
|
## Pre-verified — take this file on faith
|
|
68
73
|
|
|
69
|
-
Everything this file states about the kit is exact for the version just installed: the admin import lines and route JSX below, every hook's return shape in stage 02's table, the
|
|
74
|
+
Everything this file states about the kit is exact for the version just installed: the admin import lines and route JSX below, every hook's return shape in stage 02's table, the shipped storefront-ui components with their props and theme tokens, the barrels' export lists. Never open kit source to confirm it, and never probe your own just-written usage against it — the checklists are recall checks, not an audit: fix what a tool result reported or what you know deviates, nothing else.
|
|
70
75
|
|
|
71
76
|
## Mount the admin
|
|
72
77
|
|
|
@@ -88,7 +93,7 @@ import Dashboard from "@/commerce/admin/pages/Dashboard"; // …and orders/Ord
|
|
|
88
93
|
<Route path="*" element={<AdminRoutes />} /> {/* editors, settings, webhooks */}
|
|
89
94
|
</Route>
|
|
90
95
|
<Route path="/" element={<Navigate to="/store-admin" replace />} /> {/* until a storefront exists */}
|
|
91
|
-
|
|
96
|
+
{/* /order-received is mandatory — mounted inside the storefront layout route, stage 02's snippet */}
|
|
92
97
|
```
|
|
93
98
|
|
|
94
99
|
- **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.
|
|
@@ -97,7 +102,7 @@ import Dashboard from "@/commerce/admin/pages/Dashboard"; // …and orders/Ord
|
|
|
97
102
|
- **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" }] }`
|
|
98
103
|
- **Give `/` something** — a blank app has no `/` route, and "page not found" at the app's own URL reads like a broken install.
|
|
99
104
|
- **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.
|
|
100
|
-
- **`/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
|
|
105
|
+
- **`/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 ships finished — mount `<OrderReceivedPage />` **inside the storefront's provider** (the layout route in [stage 02 below](#02--storefront), never up here beside the admin). A different path must be set in Settings → General (`general.order_received_path`).
|
|
101
106
|
|
|
102
107
|
## Admin-role enforcement — do not weaken
|
|
103
108
|
|
|
@@ -124,13 +129,13 @@ Record this file's `carry_forward` lines (front matter) in your working notes, t
|
|
|
124
129
|
|
|
125
130
|
# 02 — Storefront
|
|
126
131
|
|
|
127
|
-
One split decides everything here
|
|
132
|
+
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.
|
|
128
133
|
|
|
129
|
-
**
|
|
134
|
+
**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.
|
|
130
135
|
|
|
131
136
|
**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.
|
|
132
137
|
|
|
133
|
-
**One import path
|
|
138
|
+
**One import path per surface.** Pages you build import from `@/commerce/storefront` — each section opens with its page's exact import line; copy it verbatim, then delete unused names (a React page never imports `@/commerce/utils` directly; `useStoreInfo` is the name most often left out). The shipped surfaces import from `@/commerce/storefront-ui`.
|
|
134
139
|
|
|
135
140
|
⚑ **Only some of what lives there is importable.** Hooks and helpers (`useProduct`, `useCart`, `productImages`, …) are named exports; a few operations exist **only on the client** `useStorefront()` returns — `submitReview`, `getProductReviews`, `listProducts`, `getProduct`, `applyCoupon`, `chooseShippingMethod`, `completeReturn`. Importing one by name throws `does not provide an export named …` and blanks the whole app: `const c = useStorefront(); await c.submitReview(…)`.
|
|
136
141
|
|
|
@@ -141,6 +146,7 @@ Nearly every store has shared chrome, so **start from a pathless layout route**
|
|
|
141
146
|
```jsx
|
|
142
147
|
import { Routes, Route, Outlet } from "react-router-dom";
|
|
143
148
|
import { StorefrontProvider, CartUIProvider } from "@/commerce/storefront";
|
|
149
|
+
import { CartPage, MiniCart, CartButton, CheckoutPage, OrderReceivedPage } from "@/commerce/storefront-ui";
|
|
144
150
|
import { base44 } from "@/api/base44Client";
|
|
145
151
|
import AdminApp from "@/commerce/admin";
|
|
146
152
|
|
|
@@ -148,14 +154,16 @@ import AdminApp from "@/commerce/admin";
|
|
|
148
154
|
<Routes> {/* ONE <Routes> — merge new pages into the app's */}
|
|
149
155
|
<Route element={
|
|
150
156
|
<StorefrontProvider base44={base44}>
|
|
151
|
-
<CartUIProvider> {/*
|
|
152
|
-
<StoreLayout /> {/* YOURS: header + <Outlet/> + footer +
|
|
157
|
+
<CartUIProvider> {/* the drawer's state — keep it mounted */}
|
|
158
|
+
<StoreLayout /> {/* YOURS: header + <Outlet/> + footer + <MiniCart/> */}
|
|
153
159
|
</CartUIProvider>
|
|
154
160
|
</StorefrontProvider>
|
|
155
161
|
}>
|
|
156
162
|
<Route path="/" element={<Home />} />
|
|
157
163
|
<Route path="/product/:slug" element={<ProductPage />} />
|
|
158
|
-
|
|
164
|
+
<Route path="/cart" element={<CartPage />} />
|
|
165
|
+
<Route path="/checkout" element={<CheckoutPage />} />
|
|
166
|
+
<Route path="/order-received" element={<OrderReceivedPage />} /> {/* mandatory */}
|
|
159
167
|
</Route>
|
|
160
168
|
<Route path="/store-admin" element={<AdminApp />}>…</Route> {/* own chrome, outside the provider */}
|
|
161
169
|
</Routes>
|
|
@@ -163,12 +171,12 @@ import AdminApp from "@/commerce/admin";
|
|
|
163
171
|
|
|
164
172
|
// …and the layout that route renders. Yours to design; the shape is the point:
|
|
165
173
|
function StoreLayout() {
|
|
166
|
-
const { itemCount } = useCart(); // one cart, shared with every page
|
|
167
174
|
return (
|
|
168
175
|
<>
|
|
169
|
-
<header>{/* nav + your
|
|
176
|
+
<header>{/* nav + <CartButton /> (or your own trigger on useCartUI/useCart) */}</header>
|
|
170
177
|
<Outlet /> {/* the routed page lands here */}
|
|
171
|
-
<footer>…</footer>
|
|
178
|
+
<footer>…</footer>
|
|
179
|
+
<MiniCart cartHref="/cart" checkoutHref="/checkout" /> {/* once, here — never on a route */}
|
|
172
180
|
</>
|
|
173
181
|
);
|
|
174
182
|
}
|
|
@@ -184,9 +192,9 @@ The cost driver of a generated storefront is not wiring — it is decoration rep
|
|
|
184
192
|
|
|
185
193
|
The store's words work the same way: the states these hooks hand you recur across pages (an empty bag, an unbuyable product, an undeliverable address), so write that copy once in the store's voice — a small map per surface, as the sections below show. It is the half of a store's identity a kit cannot ship.
|
|
186
194
|
|
|
187
|
-
**Concentrate identity; don't diffuse it.** The classes carry the look everywhere; on top of them, spend bespoke markup on **one or two signature moments per page** — the hero, the one product-page module that shows what these products are judged on — and render everything else as conventions in the classes. **The product page stays the storefront's richest surface**, and that richness is semantic: what the controls and rows *show*, which costs words rather than chrome. One navigation affordance per control (thumbnails *or* arrows, never both plus dots)
|
|
195
|
+
**Concentrate identity; don't diffuse it.** The classes carry the look everywhere; on top of them, spend bespoke markup on **one or two signature moments per page** — the hero, the one product-page module that shows what these products are judged on — and render everything else as conventions in the classes. **The product page stays the storefront's richest surface**, and that richness is semantic: what the controls and rows *show*, which costs words rather than chrome. One navigation affordance per control (thumbnails *or* arrows, never both plus dots).
|
|
188
196
|
|
|
189
|
-
⚑ **Budget by surface, and spend the product page's.**
|
|
197
|
+
⚑ **Budget by surface, and spend the product page's.** **The product page gets ~8K chars and the collection ~5K**, because rendering axes and specs *by what they are* is exactly what those chars buy — a product page that came in at 3K is the generic one. Cart, drawer, checkout and order-received cost **zero** — they ship ([below](#cart-drawer-checkout-order-received--shipped)); authored code there is the budget's biggest leak. Over budget elsewhere means re-implemented hook logic (a quantity clamp, totals math, variant resolution), never too much design: find your version, delete it, call the hook.
|
|
190
198
|
|
|
191
199
|
## What each hook resolves to
|
|
192
200
|
|
|
@@ -206,14 +214,8 @@ Everything below is already unwrapped — no `.data`, no envelope. `formatMoney`
|
|
|
206
214
|
| `productImages(product)` | `[{ src, name, alt }]`, de-duplicated. `[]` is legitimate → render your placeholder. |
|
|
207
215
|
| `productRibbons(product)` | `[{ id, name }]` — **objects**, and the field can be absent; takes a listing row or `useProduct().product`. |
|
|
208
216
|
| `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. |
|
|
209
|
-
| `useCart()` | `{ status, cart, itemCount, isEmpty, loading, error, mutationError, refresh, addItem, updateItem, removeItem, applyCoupon, removeCoupon }` — `status`: `"loading" \| "ready" \| "empty"`. |
|
|
210
|
-
| `
|
|
211
|
-
| `attributesLabel(item.attributes)` | `"Size: 42 · Color: Ivory"` (`""` when the product has no attributes). |
|
|
212
|
-
| `cartTotalsLines(cart, { formatMoney })` | `[{ key, label, amount, formatted, hidden, emphasis }]` — every line the store has, incl. discount and tax. `orderTotalsLines(order, …)` is the same shape for a receipt. Pass `labels: {…}` to rename a row. |
|
|
213
|
-
| `useCartLine(item)` | `{ quantity, setQuantity, increase, decrease, remove, pending, error, canIncrease, canDecrease, maxQuantity, atMax, atMin }`. |
|
|
214
|
-
| `useCartUI()` | `{ open, openCart, closeCart, toggleCart }`. |
|
|
215
|
-
| `useCheckoutContext()` | the address (`billing`, `updateBilling`, `shipping`, `updateShipping`, `shipToDifferent`, `setShipToDifferent`, `missingBillingFields`, `addressError`), the shipping and payment state (the pickers read those for you), and the gate: `blockers`, `canPlaceOrder`, `placing`, `stage`, `orderError`, `placeOrder` — plus `cart`. The Checkout section wires all of it. |
|
|
216
|
-
| `useOrderReturn()` | `{ status, order, lines, paymentLink, paymentInstructions, error, reload }` — `status`: `"loading" \| "paid" \| "unpaid" \| "cancelled" \| "error"`. An order's totals are **flat** (`order.total`); there is no `order.totals`. |
|
|
217
|
+
| `useCart()` | `{ status, cart, itemCount, isEmpty, loading, error, mutationError, refresh, addItem, updateItem, removeItem, applyCoupon, removeCoupon }` — `status`: `"loading" \| "ready" \| "empty"`. Needed on *your* pages only for `itemCount` and upsell `addItem` — the shipped surfaces carry their own cart logic. |
|
|
218
|
+
| `useCartUI()` | `{ open, openCart, closeCart, toggleCart }` — for your own header trigger; `<CartButton />` is this pre-wired. |
|
|
217
219
|
|
|
218
220
|
## Product list / collection
|
|
219
221
|
|
|
@@ -229,7 +231,7 @@ A card can render `name`, `productImages(row)[0]`, `productPrice(row, { formatMo
|
|
|
229
231
|
|
|
230
232
|
⚑ **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.
|
|
231
233
|
|
|
232
|
-
**Rails** (featured row, "new in") are the same hook with a filter (`{ featured: true, per_page: 4 }`) — `featured` is the merchant's own flag, so the rail stays curated store data instead of hardcoded slugs. ⚑ Any filter may legitimately match nothing — render *nothing* then, never a heading over an empty row.
|
|
234
|
+
**Rails** (featured row, "new in") are the same hook with a filter (`{ featured: true, per_page: 4 }`) — `featured` is the merchant's own flag, so the rail stays curated store data instead of hardcoded slugs. On a product page, `p.upsells` / `p.crossSells` are ready rows for the same markup — add with `useCart().addItem`, ⚑ but **a one-click Add only works on a product with no attributes**: one that sells variants answers `400 variation_required`, so link those tiles to the product page instead. ⚑ Any filter may legitimately match nothing — render *nothing* then, never a heading over an empty row.
|
|
233
235
|
|
|
234
236
|
## Product page
|
|
235
237
|
|
|
@@ -315,132 +317,44 @@ Build your layout from — all optional, **not one component style**:
|
|
|
315
317
|
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).
|
|
316
318
|
- **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).
|
|
317
319
|
|
|
318
|
-
## Cart
|
|
320
|
+
## Cart, drawer, checkout, order received — shipped
|
|
319
321
|
|
|
320
|
-
|
|
321
|
-
import { useCart, useCartLine, CartLine, useCartUI, useFormatMoney, attributesLabel, cartTotalsLines } from "@/commerce/storefront";
|
|
322
|
-
```
|
|
323
|
-
|
|
324
|
-
A cart *page* is optional — decide from what the store sells (buy-now straight to checkout reads better for a single-piece store; a grocery basket needs a page).
|
|
325
|
-
|
|
326
|
-
⚑ Rules: branch on `status`, never on emptiness while loading. Render `cart.coupon_notices` (`[{ code, error, error_code }]` — a coupon that stopped validating) and `cart.removed_items` (`[{ item_key, product_id, reason, code }]` — a product that vanished or was unpublished): render `error`/`reason`, the server's own words, or a line disappears from the bag with no explanation. Render every non-`hidden` line from `cartTotalsLines` rather than hardcoding subtotal/total — a hand-written summary omits discount and tax, then stops adding up the day a coupon or tax rate exists. **A store with any coupons must have a coupon field** (here or in the checkout) — they are admin-only data, redeemable only through a field the customer types into. `applyCoupon(code)` resolves `{ok: false, message}` for a bad code rather than throwing, so render that inline. No field means no coupons: don't seed them, don't name a code in the copy.
|
|
322
|
+
These four surfaces are **finished components** in `@/commerce/storefront-ui` — the one part of the storefront you do not design or build. They render complete on their own: the cart page (line rows with quantity editing, full totals, coupons, a designed empty state), the drawer (`<MiniCart />` — a real dialog: portal, backdrop, focus trap, Esc, scroll-lock, opens by itself on add-to-cart under `<CartUIProvider>`), the checkout (contact + addresses, deliver-elsewhere, shipping and payment choice, blockers worded, order notes, coupon, place-order with the offline-instructions flow and the card redirect), and the order-received page (paid / unpaid with payment instructions / cancelled / error). Every internal state is handled and worded; functional labels are localized (en/de/es/fr/ja/pt ship).
|
|
327
323
|
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
**Reference wiring** for one row — the rest of the page (notices, coupon field, totals, checkout link) is ordinary markup around it:
|
|
331
|
-
|
|
332
|
-
```jsx
|
|
333
|
-
const { status, cart } = useCart();
|
|
334
|
-
const formatMoney = useFormatMoney();
|
|
335
|
-
// guards on status first, then:
|
|
336
|
-
{cart.items.map((item) => (
|
|
337
|
-
<CartLine key={item.item_key} line={item}>
|
|
338
|
-
{(l) => (
|
|
339
|
-
<li aria-busy={l.pending}> {/* busy scope is THIS row, never the cart */}
|
|
340
|
-
{item.name} {attributesLabel(item.attributes)} {formatMoney(item.total)}
|
|
341
|
-
<button onClick={l.decrease} disabled={!l.canDecrease || l.pending}
|
|
342
|
-
aria-label={`Decrease quantity of ${item.name}`}>−</button>
|
|
343
|
-
{l.quantity}
|
|
344
|
-
<button onClick={l.increase} disabled={!l.canIncrease || l.pending}
|
|
345
|
-
aria-label={`Increase quantity of ${item.name}`}>+</button>
|
|
346
|
-
<button onClick={l.remove} disabled={l.pending}
|
|
347
|
-
aria-label={`Remove ${item.name}`}>Remove</button>
|
|
348
|
-
{l.error && <p role="alert">{l.error.message}</p>}
|
|
349
|
-
</li>
|
|
350
|
-
)}
|
|
351
|
-
</CartLine>
|
|
352
|
-
))}
|
|
353
|
-
{cartTotalsLines(cart, { formatMoney }).filter((t) => !t.hidden).map((t) => (
|
|
354
|
-
<div key={t.key}>{t.label} {t.formatted}</div> /* t.emphasis → the total row */
|
|
355
|
-
))}
|
|
356
|
-
```
|
|
324
|
+
**They ship tested — treat them like the admin, with one difference in tone**: mount them (the [Setup](#setup--once) snippet is the whole wiring), set the theme block and brand props below, and glance at the preview as you would any page. Don't open their source, probe their internals, or rebuild any part by hand; if something needs to change beyond tokens/props, the user will ask — [`../references/storefront-ui.md`](../references/storefront-ui.md) covers day-2 recipes (slots, hiding sections, extra locales, restyling beyond tokens). Direct edits to `src/commerce/storefront-ui/` are unsupported: kit updates re-copy the directory.
|
|
357
325
|
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
### If the cart is a drawer
|
|
361
|
-
|
|
362
|
-
Same hooks, same rows. The drawer's *state* — open/close, Esc, close-on-route-change, open-on-add — is `useCartUI()`; the markup is yours: a trigger in the header (`onClick={ui.toggleCart}`, `aria-expanded={ui.open}`), then `{ui.open && …}` rendering a click-away overlay plus your panel (`role="dialog" aria-modal="true"`, a named close button inside it).
|
|
363
|
-
|
|
364
|
-
⚑ **Render the drawer conditionally — `{ui.open && …}`.** The classic drawer bug is a panel translated off-screen but still mounted: its buttons stay clickable, tab-able and visible to screen readers. If you keep it mounted to animate the slide, set the `inert` attribute while closed. The overlay is a click-away surface, not the close control.
|
|
365
|
-
|
|
366
|
-
## Checkout
|
|
367
|
-
|
|
368
|
-
```jsx
|
|
369
|
-
import { CheckoutProvider, useCheckoutContext, AddressFields, ShippingMethodPicker, PaymentMethodPicker, useCart, useFormatMoney, cartTotalsLines } from "@/commerce/storefront";
|
|
370
|
-
```
|
|
326
|
+
### Theme — one block, pairs only
|
|
371
327
|
|
|
372
|
-
|
|
328
|
+
The components style themselves; with no block at all they follow the app's shadcn variables and stay correct. To align them to *this* store's palette, set **`--sfui-*` tokens in `index.css`, always in pairs** — a surface never changes without its `on-` pair, which is what makes invisible text impossible:
|
|
373
329
|
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
```jsx
|
|
384
|
-
function CheckoutForm() {
|
|
385
|
-
const { status } = useCart();
|
|
386
|
-
const c = useCheckoutContext();
|
|
387
|
-
if (c.stage === "submitted") return /* "taking you to your receipt" screen */;
|
|
388
|
-
if (status === "loading") return /* your loading screen */;
|
|
389
|
-
if (status === "empty") return /* your empty-bag screen */;
|
|
390
|
-
return (
|
|
391
|
-
<>
|
|
392
|
-
<AddressFields which="billing" />
|
|
393
|
-
{/* a checkbox on c.shipToDifferent / c.setShipToDifferent, your wording */}
|
|
394
|
-
<AddressFields which="shipping" /> {/* renders null until shipToDifferent */}
|
|
395
|
-
|
|
396
|
-
<ShippingMethodPicker>
|
|
397
|
-
{({ hint, mustChoose, methods, chosen }) => (
|
|
398
|
-
<fieldset>{/* renders null for a virtual cart */}
|
|
399
|
-
{hint && <p role={hint.severity === "error" ? "alert" : "status"}>
|
|
400
|
-
{hint.serverMessage ?? SHIPPING_HINTS[hint.code]}</p>}
|
|
401
|
-
{mustChoose && methods.map((m) => (
|
|
402
|
-
<label key={m.id}>
|
|
403
|
-
<input type="radio" name="shipping-method" checked={m.selected} onChange={m.select} />
|
|
404
|
-
{m.title} {m.costLabel}
|
|
405
|
-
</label>
|
|
406
|
-
))}
|
|
407
|
-
{!mustChoose && chosen && <p>{chosen.title} {chosen.costLabel}</p>}{/* the lone option */}
|
|
408
|
-
</fieldset>
|
|
409
|
-
)}
|
|
410
|
-
</ShippingMethodPicker>
|
|
411
|
-
|
|
412
|
-
{/* PaymentMethodPicker: same shape — hint, then gateways.map radios unless
|
|
413
|
-
`single`, then `selected.title`. Titles/descriptions are the admin's copy. */}
|
|
414
|
-
|
|
415
|
-
{/* summary: coupon field (if not in the cart) + cartTotalsLines(c.cart, { formatMoney }) */}
|
|
416
|
-
|
|
417
|
-
<button type="button" onClick={c.placeOrder} disabled={!c.canPlaceOrder || c.placing}>
|
|
418
|
-
{c.placing ? PLACING_LABEL : PLACE_ORDER_LABEL}
|
|
419
|
-
</button>
|
|
420
|
-
{c.orderError && <p role="alert">{c.orderError.message}</p>}
|
|
421
|
-
{!c.canPlaceOrder && c.blockers.map((code) => <p key={code}>{BLOCKERS[code]}</p>)}
|
|
422
|
-
</>
|
|
423
|
-
);
|
|
330
|
+
```css
|
|
331
|
+
.sfui {
|
|
332
|
+
--sfui-surface: #fff; --sfui-on-surface: #101014; /* page + text */
|
|
333
|
+
--sfui-subtle: #f4f1ea; --sfui-on-subtle: #6b6459; /* panels + secondary text */
|
|
334
|
+
--sfui-accent: #1b3a2f; --sfui-on-accent: #fff; /* CTAs */
|
|
335
|
+
--sfui-danger: #a52a2a; --sfui-on-danger: #fff; /* errors */
|
|
336
|
+
--sfui-border: #e5e0d5;
|
|
337
|
+
--sfui-radius: 0.5rem; /* panels; controls derive from it */
|
|
338
|
+
--sfui-font-heading: "Fraunces", serif; /* body text inherits the site's font by itself */
|
|
424
339
|
}
|
|
425
340
|
```
|
|
426
341
|
|
|
427
|
-
|
|
342
|
+
That is the entire vocabulary — nothing else is a token. If a pair lands under 3:1 contrast the components fall back to the default pair for it and `console.warn` which one; the worst case is the default look, never an unreadable page.
|
|
428
343
|
|
|
429
|
-
|
|
344
|
+
### Brand wording — props, in the store's voice
|
|
430
345
|
|
|
431
|
-
|
|
346
|
+
Headings, CTAs and empty-state copy come in per component as `brand` (each overrides its label; precedence brand → locale → en). Everything not listed here is a functional label and already localized — don't reword those:
|
|
432
347
|
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
**Mandatory route** — every payment link returns here, and confirming is what marks a card order paid. `useOrderReturn()` reads `order_id`/`order_key` from the URL, verifies with the provider (idempotent on every visit), and marks the page noindex itself.
|
|
348
|
+
| component | brand keys |
|
|
349
|
+
|---|---|
|
|
350
|
+
| `<CartPage />` | `title`, `summaryTitle`, `checkoutCta`, `continueShopping`, `emptyTitle`, `emptyBody`, `emptyCta`, `note` |
|
|
351
|
+
| `<MiniCart />` | `title`, `checkoutLabel`, `viewCartLabel`, `emptyTitle`, `emptyCta` |
|
|
352
|
+
| `<CheckoutPage />` | `title`, `contactTitle`, `shippingTitle`, `paymentTitle`, `summaryTitle`, `submitLabel`, `termsLabel`, `emptyTitle`, `emptyCta` |
|
|
353
|
+
| `<OrderReceivedPage />` | `paidTitle`, `paidBody`, `unpaidTitle`, `unpaidBody`, `backToStore` |
|
|
440
354
|
|
|
441
|
-
|
|
355
|
+
Behavior options, all optional (defaults are the professional store): `CartPage`/`CheckoutPage` take `sections` — `coupon: "auto"` (field appears exactly when the store has coupons — never wire your own), `notes: false`, and on checkout `phone: "optional"|"required"|"hidden"`, `shipToDifferent: true`, `termsCheckbox: false`, `layout: "two-column"|"single"`. `hrefs` (`checkoutHref`, `cartHref`, `continueHref`, `homeHref`) default to `/checkout`, `/cart`, `/`; pass `productHref={(item) => …}` so line names link to your product route. Full props, slots and recipes: [`../references/storefront-ui.md`](../references/storefront-ui.md).
|
|
442
356
|
|
|
443
|
-
A
|
|
357
|
+
**A store language outside the six**: copy `src/commerce/storefront-ui/i18n/locales/en.js` to `<lang>.js`, translate the values (~2–3K chars), repoint the one `import active` line in `src/commerce/storefront-ui/i18n/index.js`. Same-language brand props alone don't need this.
|
|
444
358
|
|
|
445
359
|
## Driving the storefront from a browser script?
|
|
446
360
|
|
|
@@ -448,19 +362,17 @@ The cart is optimistic and debounced, so a script that acts faster than it settl
|
|
|
448
362
|
|
|
449
363
|
## Done — stage 02 complete
|
|
450
364
|
|
|
451
|
-
- [ ] Catalog UI in whatever form fits the store
|
|
452
|
-
- [ ] `index.css`
|
|
453
|
-
- [ ]
|
|
365
|
+
- [ ] Catalog UI (collection + product page) in whatever form fits the store; `/cart`, `/checkout` and `/order-received` are the **shipped components** mounted on routes, `<MiniCart />` mounted once in the layout — no hand-built version of any of the four, no edits under `src/commerce/storefront-ui/`.
|
|
366
|
+
- [ ] The `.sfui` theme block is set in `index.css` in **pairs** (or deliberately left to the defaults), and `brand` props carry the store's voice on all four surfaces.
|
|
367
|
+
- [ ] `index.css` defines the store's design classes; your pages carry class names, not repeated utility runs.
|
|
368
|
+
- [ ] **One** `<StorefrontProvider>` (+ `<CartUIProvider>`) above every storefront route (layout-route pattern); one client, no hand-rolled `cart_token`.
|
|
454
369
|
- [ ] Every page's imports came from its section's import line; no unused names.
|
|
455
|
-
- [ ]
|
|
456
|
-
- [ ] Every state
|
|
370
|
+
- [ ] Your pages branch on `status`; gateways/currency/countries read from `useStoreInfo()`/`useCountries()` only.
|
|
371
|
+
- [ ] Every state your pages expose has words: the buy button reads for all four `buy.state` values, and no state renders empty.
|
|
457
372
|
- [ ] No re-implemented hook logic (button state precedence, quantity clamps, totals math, drawer state).
|
|
458
|
-
- [ ]
|
|
373
|
+
- [ ] A paging control rendered whenever `hasNext` is true; ribbons rendered in both the grid and the product page.
|
|
459
374
|
- [ ] Variant options: one control per axis, unbuyable options disabled, not hidden.
|
|
460
|
-
- [ ] The
|
|
461
|
-
- [ ] Cart rows scope busy state to the row; repeated controls have unique accessible names; a drawer uses `useCartUI` and is rendered conditionally on `ui.open`.
|
|
462
|
-
- [ ] Checkout guards `stage === "submitted"` above its empty-cart branch.
|
|
463
|
-
- [ ] The storefront carries the design you settled on before reading this file — design classes plus one or two signature moments per page; convention surfaces carry the classes and nothing bespoke.
|
|
375
|
+
- [ ] The storefront carries the design you settled on before reading this file — design classes plus one or two signature moments per page.
|
|
464
376
|
- [ ] **Specs and axes render by what they are**: the product page branches on `productSpecs` `type`/`key` for the rows that carry this catalog's meaning, and the grid has a rhythm — no page ships one uniform grey label/value table or one identical chip row per axis.
|
|
465
377
|
|
|
466
378
|
Record this file's `carry_forward` lines (front matter) in your working notes, then move on to stage 03 below.
|
|
@@ -525,7 +437,7 @@ try {
|
|
|
525
437
|
},
|
|
526
438
|
],
|
|
527
439
|
coupons: [{ code: "WELCOME10", discount_type: "percent", amount: 10 }],
|
|
528
|
-
//
|
|
440
|
+
// the shipped cart/checkout surface the coupon field by themselves once any exist
|
|
529
441
|
// locations: [ … ], // shipping — next section; passing any makes them the store's ONLY ones
|
|
530
442
|
});
|
|
531
443
|
return res.data; // ← the { success, data } envelope: plain JSON
|
|
@@ -594,7 +506,6 @@ Online card payments are **off by default**: the seeded store takes the manual `
|
|
|
594
506
|
- [ ] Seed returned success — real products, final permanent image URLs; slugs recorded and pages link by them.
|
|
595
507
|
- [ ] `warnings` is empty, or every warning is deliberate and stated to the user.
|
|
596
508
|
- [ ] Shipping expressed in `locations` (catch-all present if the store ships worldwide); named tiers asserted.
|
|
597
|
-
- [ ] `coupons` seeded only if a coupon field exists ([stage 02 below](#02--storefront)).
|
|
598
509
|
- [ ] Cards off, or on with the provider file copied whole.
|
|
599
510
|
|
|
600
511
|
Record this file's `carry_forward` lines (front matter) in your working notes. The install is finished — treat this file as spent and do not re-read it.
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
stage: reference
|
|
3
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
|
|
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 copy on storefront pages you build (write it directly in the store's language), for the shipped storefront-ui surfaces (same six-locale pattern, own files — references/storefront-ui.md), and for transactional emails (backend templates — references/emails.md)."
|
|
5
5
|
forget_when: "The language is switched or the locale file is written, and the admin renders in it."
|
|
6
6
|
carry_forward:
|
|
7
7
|
- "Admin language = one import line in src/commerce/admin/i18n/index.js; no runtime picker, no npm package."
|
|
@@ -32,9 +32,11 @@ locale automatically; `lib/geo-data.js` keeps English state/province names
|
|
|
32
32
|
- **Covers:** every admin screen — navigation, tables, editors, settings,
|
|
33
33
|
dialogs, toasts, the first-run setup screen, enum labels
|
|
34
34
|
(`lib/constants.js` reads its labels from the catalog).
|
|
35
|
-
- **Does not cover:** the storefront
|
|
36
|
-
|
|
37
|
-
|
|
35
|
+
- **Does not cover:** the storefront pages you build (write their copy directly
|
|
36
|
+
in the store's language), the shipped storefront-ui surfaces (their own
|
|
37
|
+
locale files under `src/commerce/storefront-ui/i18n/` follow this same
|
|
38
|
+
pattern — `references/storefront-ui.md`), and transactional emails (backend
|
|
39
|
+
templates — `references/emails.md`). The StoreAdmin bot's
|
|
38
40
|
UI chrome is localized; the bot's *answers* come from the agent and follow
|
|
39
41
|
the language the admin types in.
|
|
40
42
|
|