@base44/app-plugin-commerce 0.1.19 → 0.2.1
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 +25 -22
- package/base44/functions/commerce/admin-reports/entry.ts +1 -1
- package/base44/functions/commerce/seed-store/entry.ts +34 -0
- package/base44/functions/commerce/seed-store/seed-catalog.ts +39 -5
- package/base44/shared/commerce/card-payment.stripe.ts +178 -0
- package/base44/shared/commerce/scan.ts +1 -1
- package/base44/shared/commerce/sequence.ts +1 -1
- package/package.json +1 -1
- package/scripts/install.js +24 -14
- package/skills/commerce/SKILL.md +107 -51
- package/skills/commerce/docs/api-admin.md +89 -28
- package/skills/commerce/docs/api-storefront.md +113 -126
- package/skills/commerce/docs/entities.md +137 -0
- package/skills/commerce/install/01-install.md +101 -0
- package/skills/commerce/install/02-storefront.md +188 -0
- package/skills/commerce/install/03-data.md +162 -0
- package/skills/commerce/references/admin-product-form.md +10 -0
- package/skills/commerce/references/catalog-rendering.md +110 -0
- package/skills/commerce/references/emails.md +49 -12
- package/skills/commerce/references/guest-access-security.md +18 -5
- package/skills/commerce/references/online-payments.md +50 -149
- package/skills/commerce/references/operations.md +52 -0
- package/skills/commerce/references/reviews.md +31 -16
- package/skills/commerce/references/shipping-and-tax.md +110 -0
- package/skills/commerce/references/store-admin-agent.md +21 -0
- package/skills/commerce/references/store-settings.md +49 -0
- package/src/commerce/admin/README.md +2 -2
- package/src/commerce/admin/layout/AuthGuard.jsx +1 -1
- package/src/commerce/admin/pages/settings/InventorySettings.jsx +1 -1
- package/src/commerce/storefront/StorefrontProvider.jsx +106 -20
- package/src/commerce/storefront/blocks/AddToCartBlock.jsx +86 -0
- package/src/commerce/storefront/blocks/AddressFieldsBlock.jsx +96 -0
- package/src/commerce/storefront/blocks/BreadcrumbsBlock.jsx +52 -0
- package/src/commerce/storefront/blocks/CartLinesBlock.jsx +98 -0
- package/src/commerce/storefront/blocks/CheckoutBlock.jsx +247 -0
- package/src/commerce/storefront/blocks/CouponFieldBlock.jsx +84 -0
- package/src/commerce/storefront/blocks/OrderReceivedBlock.jsx +129 -0
- package/src/commerce/storefront/blocks/ProductGalleryBlock.jsx +66 -0
- package/src/commerce/storefront/blocks/ProductSpecsBlock.jsx +33 -0
- package/src/commerce/storefront/blocks/ProductStripBlock.jsx +55 -0
- package/src/commerce/storefront/blocks/QuantityStepper.jsx +62 -0
- package/src/commerce/storefront/blocks/ReviewsBlock.jsx +191 -0
- package/src/commerce/storefront/blocks/TotalsBlock.jsx +42 -0
- package/src/commerce/storefront/blocks/VariantSelectorBlock.jsx +81 -0
- package/src/commerce/storefront/blocks/index.js +44 -0
- package/src/commerce/storefront/index.js +59 -21
- package/src/commerce/storefront/internal/useAsyncData.js +86 -0
- package/src/commerce/storefront/pickers.jsx +20 -5
- package/src/commerce/storefront/useAddressForm.js +96 -0
- package/src/commerce/storefront/useCartLine.js +184 -0
- package/src/commerce/storefront/useCheckout.jsx +38 -11
- package/src/commerce/storefront/useProduct.js +227 -0
- package/src/commerce/storefront/useProductGallery.js +74 -0
- package/src/commerce/storefront/useProductList.js +153 -0
- package/src/commerce/storefront/useProductPrice.js +58 -0
- package/src/commerce/storefront/useProductReviews.js +242 -0
- package/src/commerce/storefront/useStorefrontSeo.js +204 -0
- package/src/commerce/storefront/useTotalsLines.js +109 -0
- package/src/commerce/utils/address-spec.js +89 -0
- package/src/commerce/utils/images.js +45 -0
- package/src/commerce/utils/index.js +18 -6
- package/src/commerce/utils/price.js +95 -0
- package/src/commerce/utils/storefront.js +47 -3
- package/src/commerce/utils/totals.js +110 -0
- package/src/commerce/utils/variants.js +10 -2
- package/skills/commerce/installation-guidelines.md +0 -93
- package/skills/commerce/post-installation.md +0 -495
- package/skills/commerce/references/limits-and-performance.md +0 -16
- package/skills/commerce/references/media-and-downloads.md +0 -4
- package/skills/commerce/references/product-render.md +0 -89
- package/skills/commerce/references/scheduled-work.md +0 -19
- package/skills/commerce/references/storefront-product-page.md +0 -83
- package/skills/commerce/references/webhooks.md +0 -10
|
@@ -1,495 +0,0 @@
|
|
|
1
|
-
# Post-installation
|
|
2
|
-
|
|
3
|
-
What to do right after the static installation ([`installation-guidelines.md`](./installation-guidelines.md)): embed the admin pages into the app, build the storefront from the quick start below, and seed the store's data. Online card payments are **optional and off by default** — [§4](#4-payments--optional-cards-off-by-default) has the rule (enable them only with a provider wired) and the wiring; they are never the opening move. Installed into the app at `.agents/skills/commerce/post-installation.md`.
|
|
4
|
-
|
|
5
|
-
> **This file is intentionally the whole job.** Unless the user has a special requirement (wiring a card payment provider, signature-verified webhooks, filters/reviews/accounts beyond the happy path), do **not** open the `references/` guides or the `docs/` API references — everything needed to ship a working store (admin mount, the storefront — catalog pages, optional cart, checkout, order-received — seeding, and card payments for the stores that need them) is on this page, ending with the [Done when](#5-done-when) checklist. Reading more first just burns time.
|
|
6
|
-
|
|
7
|
-
> **Payments, in short** (full rules in [§4](#4-payments--optional-cards-off-by-default)): **online card payments are optional and off by default.** The seed enables the manual `offline` method, which needs no code or credentials, and leaves the `card` gateway disabled. **Enable cards only if you wire a payment provider — or are about to, in the same stretch of work** (§4.2: one file): an enabled card option with no provider behind it answers `503 no_card_payment_provider` at checkout. Choosing to add cards is fine and often right; just do it **late** — nothing else depends on it, so unless the user brought it up first, asking for a provider key before their store exists only stalls the build.
|
|
8
|
-
|
|
9
|
-
## 0. Sequencing — build the UI while the slow work runs
|
|
10
|
-
|
|
11
|
-
The sections below are in work order — admin mount, storefront, then the data call, and payments last — but the order is not a queue to wait in. The install's wall-clock is dominated by one thing that never needed to block: image generation, the slowest step of the whole install. **Nothing about the storefront waits on live data**: every request/response shape §2 builds against is documented on this page, so the components are written from the docs, and live data only matters once at the end.
|
|
12
|
-
|
|
13
|
-
So interleave — whenever a slow activity is in flight, write UI instead of waiting:
|
|
14
|
-
|
|
15
|
-
1. **Start image generation first** — kick off every product image before anything else (§3.1 "Images"), because it takes the longest and nothing depends on it until seed time.
|
|
16
|
-
2. **Mount the admin router (§1)** — minutes of work — and build the storefront (§2) while the images render.
|
|
17
|
-
3. **The moment the image URLs are back, fire `commerce/seed-store` (§3).** Its writes run in parallel, so the call usually takes a few seconds — but don't idle on it either; pick up its response (catalog report, slugs) when you next need it. (Payments need nothing from you here: the seed enables the manual `offline` method and leaves cards off — §4.1.)
|
|
18
|
-
4. **Converge**: with the seed done and pages written, open the storefront against the live catalog.
|
|
19
|
-
5. **Only then, card payments (§4) — if the store wants them at all.** Cards are off by default, so an offline-paid store is already done here. If you do enable them, wire the provider in the same stretch of work (§4.1's rule) — raise it, get the provider key, paste the one file, enable the gateway. Payments come last because nothing above depends on them and the ask only makes sense once there is a store to point at; the one exception is a user who named a provider or handed over a key up front, which you can wire whenever it's convenient during the build. Walk the [Done when](#5-done-when) checklist to close.
|
|
20
|
-
|
|
21
|
-
The only real dependency edges are: image URLs → seed payload, and seed done → seeing real products on the finished pages. Everything else overlaps. **Payments have no edge at all** — that is exactly why they go at the end rather than the beginning: the store is fully buildable, demonstrable, payable and reviewable before a single provider credential exists.
|
|
22
|
-
|
|
23
|
-
---
|
|
24
|
-
|
|
25
|
-
## 1. Embedding the admin pages
|
|
26
|
-
|
|
27
|
-
The admin UI is a self-contained React app under `src/commerce/admin/`. Its only external touchpoints are `@/components/ui/*` (shadcn) and `@/api/base44Client` (your app's SDK client). Two storefront-logic folders sit alongside it, used by the customer-facing UI you build, not by the admin: `src/commerce/utils/` (framework-free helpers — API client, variant selection, free-shipping rules; no dependencies) and `src/commerce/storefront/` (the React layer — shared-cart provider, guided-checkout hook, headless shipping/payment pickers, order-received hook; needs React and nothing else).
|
|
28
|
-
|
|
29
|
-
**Steps:**
|
|
30
|
-
|
|
31
|
-
1. Copy `src/commerce/admin/` → `src/commerce/admin/`, `src/commerce/utils/` → `src/commerce/utils/` and `src/commerce/storefront/` → `src/commerce/storefront/` (already done if you ran `scripts/install.js`).
|
|
32
|
-
2. Check the app's `package.json` for `sonner`, `recharts` and `react-markdown` (the StoreAdmin bot uses the last one), and run `npm i` **only** for the ones actually absent — all three ship with the default Base44 template, so the normal outcome is no install at all. Do not re-install a package that is already a dependency. The template needs **no other dependency**. Verify the shadcn primitives listed in `src/commerce/admin/README.md` are present (`npx shadcn@latest add <name>` for any missing).
|
|
33
|
-
3. Mount the router:
|
|
34
|
-
```jsx
|
|
35
|
-
import AdminApp from "@/commerce/admin";
|
|
36
|
-
<Route path="/store-admin/*" element={<AdminApp />} />
|
|
37
|
-
```
|
|
38
|
-
**You must also build a payment return page** (`/order-received` by default) — this is **mandatory for payment links to work at all**. Build it even for a store that starts offline-only (§4.1): it is one shipped hook, it renders a perfectly good order confirmation for manual orders too, and it is what makes turning cards on later a one-line switch instead of a second project. If your route differs, set it in Settings → General → *Payment return path*, or payment links will send customers to a 404. Every link (checkout, the admin's payment link, emails) returns there; without the route a paying customer hits a 404, and since confirming is what marks an order paid, orders would stay unpaid. The page is one shipped hook — `useOrderReturn` from `@/commerce/storefront` — plus your markup; step 4 of the [storefront quick start](#2-storefront-quick-start--logic-only) below covers it completely.
|
|
39
|
-
|
|
40
|
-
**Give the app root something too.** A blank Base44 app has no `/` route, so after mounting only `/store-admin/*` the app's own URL still renders its "page not found" screen — which reads exactly like a broken install. Until a storefront exists, redirect: `<Route path="/" element={<Navigate to="/store-admin" replace />} />`.
|
|
41
|
-
|
|
42
|
-
The **`/*` splat is required** — the admin renders its own nested routes, so a bare `path="/store-admin"` matches only the dashboard and every deeper link 404s. If you mount at a different base path, pass the prefix *without* the splat: `<AdminApp basePath="/backoffice" />` (a pattern passed by mistake, `basePath="/backoffice/*"`, is normalized rather than baked into every link). Opening the literal mount pattern `/store-admin/*` — pasted from these docs, or left in a hand-written nav link — lands on the dashboard instead of a "page not found" dead end; a genuinely wrong path like `/store-admin/ordrs` still 404s, with a link back.
|
|
43
|
-
|
|
44
|
-
### Admin-role enforcement (do not weaken)
|
|
45
|
-
|
|
46
|
-
The shipped `AuthGuard` requires an authenticated user **whose `role === "admin"`**:
|
|
47
|
-
|
|
48
|
-
- Not logged in → "Please sign in" screen.
|
|
49
|
-
- Logged in but **not** admin → "Admin access required" screen (a merely-authenticated customer cannot reach any admin page).
|
|
50
|
-
|
|
51
|
-
Grant the role via the Base44 dashboard (user management) or `base44.users.inviteUser(email, "admin")`.
|
|
52
|
-
|
|
53
|
-
**Do not relax this check.** It is the first of three enforcement layers:
|
|
54
|
-
|
|
55
|
-
1. **UI guard** — `AuthGuard` (client-side; convenience + UX).
|
|
56
|
-
2. **Entity RLS** — every admin-only entity (commerce.Order, commerce.Customer, commerce.Coupon, commerce.StoreSettings, …) has `"user_condition": { "role": "admin" }` on all operations, so direct SDK reads/writes from a non-admin are rejected by the backend.
|
|
57
|
-
3. **Function guard** — every `commerce/admin-*` function (and `commerce/seed-store`) calls `requireAdmin()`, returning **401** if unauthenticated and **403** if not an admin, before touching data via the service role.
|
|
58
|
-
|
|
59
|
-
Even if the client guard were bypassed, layers 2 and 3 keep the store data safe. Storefront functions are intentionally public and verify the caller per-action instead (auth session, `cart_token`, or `order_key`).
|
|
60
|
-
|
|
61
|
-
---
|
|
62
|
-
|
|
63
|
-
## 2. Storefront quick start — logic only
|
|
64
|
-
|
|
65
|
-
No visitor UI ships — and no visual component ships either: **every pixel of the shopfront stays yours to design**. What ships is the logic: the storefront **API**, the framework-free helpers in `@/commerce/utils`, and the React layer in `@/commerce/storefront` — hooks and headless pickers owning the contracts every store must get right. The catalog views (§2.1–2.2) are deliberately the thinnest, because that is where storefronts differ most; the cart and checkout (§2.3–2.4) are more guided, because shipping recalculation, payment methods and the place-order gate work the same in every store. **None of it waits on anything**: every shape you build against is documented right here, so seeding (§3) runs in parallel with building these pages (§0 has the schedule) — kick off image generation, write the storefront while it renders, seed when the URLs are back. Live data is only needed once, to see real products on the finished pages. **Payments are not a prerequisite for any of this** — the whole buy path down to `place-order` is built and reviewable before a provider exists (a card gateway with no provider simply answers `503 no_card_payment_provider`, and §2.4 shows the graceful fallback), which is why the payment decision comes after these pages work, not before (§4). The four chunks below are the whole happy path; open [`docs/api-storefront.md`](./docs/api-storefront.md) only for what's beyond them (attribute/price filters, reviews, customer accounts, refunds), and [`references/product-render.md`](./references/product-render.md) for which fields belong in which view.
|
|
66
|
-
|
|
67
|
-
**Set up once** — mount the provider **around `<Routes>`**, so it sits above every storefront route. It owns the shared API client, the store-info cache and ONE shared cart, so a header badge, a cart drawer and the checkout all render the same state:
|
|
68
|
-
|
|
69
|
-
```jsx
|
|
70
|
-
import { StorefrontProvider } from "@/commerce/storefront";
|
|
71
|
-
import { base44 } from "@/api/base44Client";
|
|
72
|
-
import AdminApp from "@/commerce/admin";
|
|
73
|
-
|
|
74
|
-
<BrowserRouter> {/* the app's existing router, wherever it lives */}
|
|
75
|
-
<StorefrontProvider base44={base44}> {/* wraps the router's <Routes>, not a child of it */}
|
|
76
|
-
<Routes> {/* ONE <Routes> — merge storefront routes into the app's */}
|
|
77
|
-
<Route path="/" element={<Home />} />
|
|
78
|
-
<Route path="/product/:slug" element={<ProductPage />} />
|
|
79
|
-
<Route path="/checkout" element={<Checkout />} />
|
|
80
|
-
<Route path="/order-received" element={<OrderReceived />} />
|
|
81
|
-
<Route path="/store-admin/*" element={<AdminApp />} />
|
|
82
|
-
</Routes>
|
|
83
|
-
</StorefrontProvider>
|
|
84
|
-
</BrowserRouter>
|
|
85
|
-
```
|
|
86
|
-
|
|
87
|
-
> ⚠ **`<Routes>` accepts only `<Route>` children — the provider goes outside it.** Nesting it inside (the natural reading of "wrap the storefront routes", and the usual mistake) throws at render: `Error: [StorefrontProvider] is not a <Route> component. All component children of <Routes> must be a <Route> or <React.Fragment>`. Wrapping the whole `<Routes>` as above is the simplest correct shape and fine even with the admin route inside — the admin doesn't use the storefront hooks. Same rule per file: an app that already has a `<Routes>` gets the new pages **added to it**, never a second `<Routes>` block. To scope the provider to storefront routes only, use a pathless **layout route** — the one place a wrapper component is legal — and render `<Outlet />` inside it:
|
|
88
|
-
> ```jsx
|
|
89
|
-
> <Route element={<StorefrontProvider base44={base44}><Outlet /></StorefrontProvider>}>
|
|
90
|
-
> <Route path="/" element={<Home />} /> {/* these share one cart */}
|
|
91
|
-
> <Route path="/checkout" element={<Checkout />} />
|
|
92
|
-
> </Route>
|
|
93
|
-
> <Route path="/store-admin/*" element={<AdminApp />} /> {/* outside the provider */}
|
|
94
|
-
> ```
|
|
95
|
-
|
|
96
|
-
The provider owns the two things hand-rolled storefronts keep getting wrong, so **don't reimplement either — and never mount a second provider or create a second client**: the **`cart_token` lifecycle** (sent with every cart/checkout call, re-persisted from every response — a stale token silently starts a fresh cart; rolling 48 h expiry; cleared when checkout consumes the cart) and the **store-info cache** (`payment_gateways`, currency, countries live **only** on `get-store-info` — the cart view never carries them). Inside the tree, `useStorefront()` returns the shared client for catalog calls, and `store.inv(fn, payload)` on it is the raw escape hatch (it unwraps the `{ success, data }` envelope). If non-React code also needs the client, create it once in `src/lib/storefront.js` (`createStorefront(base44)` from `@/commerce/utils`) and pass that same instance via `<StorefrontProvider store={store}>`.
|
|
97
|
-
|
|
98
|
-
**What each hook / client method resolves to** — every envelope is already unwrapped, so take these shapes at face value (no `.data`, and no `.categories` on a list result):
|
|
99
|
-
|
|
100
|
-
| Source | Resolves to |
|
|
101
|
-
|---|---|
|
|
102
|
-
| `useStoreInfo()` | `{ info, settings, paymentGateways, countries, currencies, loading, error }` — cached; the **only** source of payment gateways |
|
|
103
|
-
| `useFormatMoney()` | `(amount) => "€19.99"` — the store's currency, the viewer's locale |
|
|
104
|
-
| `useCart()` | `{ cart, loading, itemCount, isEmpty, addItem, updateItem, removeItem, applyCoupon, removeCoupon, refresh }` — ONE cart shared by every consumer; `cart.items[n].attributes` is an **array** of `{ name, option }`, never a map |
|
|
105
|
-
| `useCheckout()` / `CheckoutProvider` | the guided checkout — §2.4 |
|
|
106
|
-
| `useOrderReturn()` | the `/order-received` page — §2.4 |
|
|
107
|
-
| client `listProducts(params)` | `{ products, page, per_page, has_next }` — a page object; the array is `products` |
|
|
108
|
-
| client `getProduct(slug \| { id })` | `{ product, variations, categories, ribbons, reviews }` |
|
|
109
|
-
| client `listCategories()` | an **array** of root categories, subcategories nested under `children` |
|
|
110
|
-
| client `listRibbons()` | an **array** of `{ id, name, count }` |
|
|
111
|
-
|
|
112
|
-
Keep the UI in **small focused components** (~2–4K characters each — product card, gallery, cart panel, address step, payment step…), not monolithic page files: smaller files are faster to emit, review and fix.
|
|
113
|
-
|
|
114
|
-
### 2.1 Product list
|
|
115
|
-
|
|
116
|
-
The catalog is direct client calls plus your design — no premade structure to fight:
|
|
117
|
-
|
|
118
|
-
```js
|
|
119
|
-
const store = useStorefront(); // the shared client
|
|
120
|
-
const formatMoney = useFormatMoney(); // store currency, viewer's locale — use for every price
|
|
121
|
-
// useStoreInfo() → { settings, paymentGateways, countries, … } wherever store data is needed;
|
|
122
|
-
// it is the ONLY source of payment gateways (they are never on the cart view)
|
|
123
|
-
|
|
124
|
-
const { products, page, per_page, has_next } = await store.listProducts({
|
|
125
|
-
page: 1, per_page: 12, // optional: search, category_id, ribbon_id, featured, on_sale,
|
|
126
|
-
sort: "-created_date", // min_price, max_price, in_stock_only
|
|
127
|
-
}); // sort: -created_date | name | price | -price | popularity | rating
|
|
128
|
-
```
|
|
129
|
-
|
|
130
|
-
Each row is a full product record — for a card use `name`, `images[0]?.src` (**may be empty — render a placeholder, never a broken `<img>`**), `price`, `regular_price`, `on_sale` (sale badge), `short_description`, `stock_status`, `average_rating`/`rating_count` (stars cost no extra call) and `ribbons` (`[{ id, name }]`, may be absent — labels like "Best Seller" for the card corner). **There is no product type flag**: `product.attributes?.length > 0` means the product sells variants and its `price` is a *from*-price rolled up from the cheapest variant — render it as "From …". Categories for the nav come from `store.listCategories()` — an **array** of root categories with subcategories nested under `children` (map over it directly; there is no `{ categories }` wrapper on the result). That is the whole card — no other call or reference needed for the list view.
|
|
131
|
-
|
|
132
|
-
**A short strip of products is the same call with a small `per_page`.** Products are not confined to the grid — a homepage hero or banner, a "featured" row, a "new in" rail, a few picks beside an article are all one `listProducts` call: `per_page` bounds how many come back and the filters/sort decide which ones. No separate endpoint, and never fetch a big page to slice client-side. Use this if a page of yours wants such a strip; the catalog pages above are complete without one:
|
|
133
|
-
|
|
134
|
-
```js
|
|
135
|
-
const { products: featured } = await store.listProducts({ featured: true, per_page: 4 });
|
|
136
|
-
const { products: newest } = await store.listProducts({ sort: "-created_date", per_page: 6 });
|
|
137
|
-
const { products: deals } = await store.listProducts({ on_sale: true, per_page: 4 });
|
|
138
|
-
const { products: topInCat } = await store.listProducts({ category_id, sort: "popularity", per_page: 4 });
|
|
139
|
-
// filters stack (featured + category_id + in_stock_only …); sort: -created_date | name |
|
|
140
|
-
// price | -price | popularity | rating. Rows are the same shape the cards above render.
|
|
141
|
-
```
|
|
142
|
-
|
|
143
|
-
`featured` is the merchant's own flag — the star in the admin's product list and *Featured product* in the editor's Publish box — and `seed-store` accepts `featured: true` per product, so such a row stays curated store data instead of a hardcoded list of slugs. **Any filter can legitimately match nothing** (nobody has starred a product, nothing is discounted): render the block from the returned array's length — hide it, or fall back to another slice — rather than assuming rows came back.
|
|
144
|
-
|
|
145
|
-
**Carry forward:** each card links to the product page by **`slug`**.
|
|
146
|
-
|
|
147
|
-
### 2.2 Product page — variant selection included
|
|
148
|
-
|
|
149
|
-
```js
|
|
150
|
-
const { product, variations, categories, ribbons, reviews } =
|
|
151
|
-
await store.getProduct(slug); // or store.getProduct({ id })
|
|
152
|
-
|
|
153
|
-
// One selector PER product.attributes[] entry — never a flat list of variations.
|
|
154
|
-
import { defaultSelection, selectOption, resolveSelection } from "@/commerce/utils";
|
|
155
|
-
|
|
156
|
-
let selection = defaultSelection(product, variations); // merchant defaults + single-option axes
|
|
157
|
-
// on user pick: selection = selectOption(product, variations, selection, axisKey, option);
|
|
158
|
-
|
|
159
|
-
const view = resolveSelection(product, variations, selection);
|
|
160
|
-
// view.axes → [{ key, name, options }] — render one control each
|
|
161
|
-
// view.availability → { [axisKey]: { [option]: "available" | "out_of_stock" | "unavailable" } }
|
|
162
|
-
// view.display → { price, regular_price, on_sale, sku, stock_status, image, … } for the selection
|
|
163
|
-
// ⚠ display.image is an OBJECT — { src, name, alt } | null. Render
|
|
164
|
-
// <img src={view.display.image?.src} alt={view.display.image?.alt}>.
|
|
165
|
-
// Passing the object itself as src fails the load and your fallback
|
|
166
|
-
// shows a placeholder for every product — with the real image sitting
|
|
167
|
-
// one `.src` away. Same shape everywhere: product.images[n].src too.
|
|
168
|
-
// view.purchasable → gate the Add-to-cart button on this
|
|
169
|
-
// view.addToCart → { product_id, variation_id } — null until the selection resolves
|
|
170
|
-
```
|
|
171
|
-
|
|
172
|
-
Add to cart — through the shared cart, so every badge and drawer in the tree updates at once (the cart bootstraps itself; a missing, stale or expired token starts a fresh one, and the provider does all the token bookkeeping):
|
|
173
|
-
|
|
174
|
-
```js
|
|
175
|
-
const { addItem } = useCart();
|
|
176
|
-
await addItem(view.addToCart); // quantity 1 — addItem(view.addToCart, 3) for more
|
|
177
|
-
```
|
|
178
|
-
|
|
179
|
-
A product with attributes is **rejected without a `variation_id`** (`400 variation_required`) — that is why `view.addToCart` and not a bare `product_id` goes into the call.
|
|
180
|
-
|
|
181
|
-
**What the page renders — all from this one `get-product` call, no extra reads:** a gallery from `product.images` (`view.display.image` is the variant-selected one; placeholder **only** when the product truly has no images — every image is an `{ src, alt }` object, so render `img.src`/`img.alt`, never the object itself), name, price from `view.display` (`price`/`regular_price`/`on_sale` → sale badge), one selector per axis, stock state, `short_description` then `description` (**both HTML — render as rich text, don't escape or truncate away the markup**), SKU, `categories` as a breadcrumb, `ribbons` as light labels near the metadata, the `reviews` block (`{ items, has_next, average_rating, rating_count }`), and the `upsells`/`cross_sells` summaries. Descriptive properties (Material, Care…) live in `product.meta_data` — render them as a spec table; they are not attributes and not ribbons. That is the complete product page — [`references/storefront-product-page.md`](./references/storefront-product-page.md) and [`references/product-render.md`](./references/product-render.md) are only for edge cases and for adding fields to the *listing* call.
|
|
182
|
-
|
|
183
|
-
**Carry forward:** nothing — the client keeps the `cart_token`.
|
|
184
|
-
|
|
185
|
-
### 2.3 Cart
|
|
186
|
-
|
|
187
|
-
**A cart page is optional — decide from what the store sells.** Under the hood a cart always exists (checkout consumes it, and `addItem` is still how anything gets bought), but the *UI step* — a cart page or drawer where customers review and edit line items — is a choice, not a requirement. A store selling one service, made-to-order pieces, or typically-single-item purchases reads better as **buy-now**: `addItem(view.addToCart)` and navigate straight to checkout, no cart view anywhere. Multi-item catalogs where customers accumulate a basket want the full cart step. Either way the checkout (§2.4) is unchanged — it reads the same shared cart whether the customer curated it on a cart page or a buy-now button filled it a second ago.
|
|
188
|
-
|
|
189
|
-
When the store does want one, the cart is **shared state** — `useCart()` anywhere in the tree reads and mutates the same view, every action re-renders every consumer, and calls are serialized so rapid quantity clicks can never apply out of order:
|
|
190
|
-
|
|
191
|
-
```jsx
|
|
192
|
-
const { cart, loading, itemCount, isEmpty,
|
|
193
|
-
updateItem, removeItem, applyCoupon, removeCoupon } = useCart();
|
|
194
|
-
// cart === null → no cart yet (an expired token self-clears) — render an empty state
|
|
195
|
-
// cart.items → [{ item_key, name, image, quantity, price, subtotal, total, attributes, purchasable }]
|
|
196
|
-
// item.attributes is an ARRAY of { name, option } — render "Size: 42" by
|
|
197
|
-
// mapping it (attributes.map(a => `${a.name}: ${a.option}`)); it is never a map
|
|
198
|
-
// cart.totals → { subtotal, discount_total, shipping_total, cart_tax, total_tax, total, … }
|
|
199
|
-
// cart.coupon_notices / cart.removed_items → tell the customer what auto-dropped and why
|
|
200
|
-
//
|
|
201
|
-
// NOT in the cart view: payment_gateways (useStoreInfo() ONLY — cart.payment_gateways
|
|
202
|
-
// is always undefined), the product catalog (listProducts/getProduct), countries/currencies
|
|
203
|
-
// (useStoreInfo). Never dot into the cart for any of those.
|
|
204
|
-
|
|
205
|
-
await updateItem(item_key, quantity); // ≤0 removes
|
|
206
|
-
await removeItem(item_key);
|
|
207
|
-
const res = await applyCoupon(code); // { ok: true, cart } or { ok: false, code, message }
|
|
208
|
-
if (!res.ok) setCouponError(res.message); // an invalid code is expected flow — render it inline
|
|
209
|
-
```
|
|
210
|
-
|
|
211
|
-
**If the store has coupons, this is one of the two places a customer can enter one** — a code field plus `applyCoupon`, the discount shown from `cart.totals.discount_total`, applied codes from `cart.coupons` (with `removeCoupon`), and invalid codes rendered inline as above. **Skipping the cart page therefore means the field has to live in checkout (§2.4)** — a seeded coupon nobody can type is a discount the store advertises and cannot honor.
|
|
212
|
-
|
|
213
|
-
Shipping cost on the cart page: a store with exactly **one shipping location** shows its options (and, auto-selected, the cost) even before an address is known — render `cart.available_shipping_methods` and the totals as they come. Several locations report `shipping_status: "missing_address"` until an address resolves one; that address is collected in checkout, where `useCheckout` recalculates shipping and tax automatically the moment it is complete (§2.4) — **don't build a separate estimator or call `set-shipping-address` by hand on the cart page.**
|
|
214
|
-
|
|
215
|
-
**Carry forward:** nothing — the provider keeps the `cart_token`, and the address and method choice live on the shared cart.
|
|
216
|
-
|
|
217
|
-
### 2.4 Checkout & order-received
|
|
218
|
-
|
|
219
|
-
`useCheckout()` is the guided checkout — it owns everything that is the same in every store, and your page is markup around it:
|
|
220
|
-
|
|
221
|
-
- **address form state** (`billing`/`updateBilling`, an optional separate `shipping`/`updateShipping` behind `setShipToDifferent`), with `missingBillingFields` tracking what `place-order` would reject;
|
|
222
|
-
- **automatic shipping/tax recalculation**: the moment the address is complete enough to price (default: country + city), the hook debounces and calls `set-shipping-address`, repricing every shipping option, its cost and the taxes — half-typed addresses are never sent, an unchanged address is never re-sent, and an address the store doesn't ship to surfaces as `addressError` to show **on the address fields**;
|
|
223
|
-
- **the shipping choice** (`shippingStatus`, `shippingMethods`, `chosenShippingMethod`, `chooseShippingMethod`) and **the payment choice** (`paymentMethods` from store info — their only source; a store with exactly one enabled gateway gets it pre-selected);
|
|
224
|
-
- **the gate**: `canPlaceOrder` + named `blockers`, and `placeOrder` with the online-payment redirect handled.
|
|
225
|
-
|
|
226
|
-
Share one instance across the page's components with `CheckoutProvider` and build each step as your own markup:
|
|
227
|
-
|
|
228
|
-
```jsx
|
|
229
|
-
import { CheckoutProvider, useCheckoutContext, useCart, useStoreInfo,
|
|
230
|
-
ShippingMethodPicker, PaymentMethodPicker } from "@/commerce/storefront";
|
|
231
|
-
|
|
232
|
-
<CheckoutProvider> {/* one useCheckout shared by the whole page */}
|
|
233
|
-
<AddressFields /> {/* your components, your layout, your look */}
|
|
234
|
-
<ShippingStep />
|
|
235
|
-
<PaymentStep />
|
|
236
|
-
<OrderSummary /> {/* useCart().cart.totals re-renders as the address edits reprice it */}
|
|
237
|
-
<CouponField /> {/* if the store has coupons and no cart page has one — see below */}
|
|
238
|
-
<PlaceOrderButton />
|
|
239
|
-
</CheckoutProvider>
|
|
240
|
-
```
|
|
241
|
-
|
|
242
|
-
**Coupons belong on this page too** — `useCheckout` has no coupon API because it doesn't need one: the cart is shared, so `useCart()` works inside the `CheckoutProvider` tree and a code field is a few lines. **If the store has any coupons and there is no cart page carrying the field (§2.3), the field must be here** — otherwise the codes the store issued can never be redeemed:
|
|
243
|
-
|
|
244
|
-
```jsx
|
|
245
|
-
const { applyCoupon, removeCoupon, cart } = useCart(); // same shared cart the checkout prices
|
|
246
|
-
const res = await applyCoupon(code);
|
|
247
|
-
if (!res.ok) setCouponError(res.message); // invalid/expired/ineligible — expected flow, render inline
|
|
248
|
-
// cart.coupons → [{ code, discount, free_shipping }] with removeCoupon(code)
|
|
249
|
-
// cart.totals.discount_total → show the discount in the summary; every total already accounts for it,
|
|
250
|
-
// and applying one reprices shipping too (free-shipping and coupon-gated rates appear/disappear)
|
|
251
|
-
```
|
|
252
|
-
|
|
253
|
-
A coupon that stops validating between here and `place-order` surfaces as `orderError.code === "coupon_invalid"`; the hook re-reads the cart for you, so render the error and let the customer retry.
|
|
254
|
-
|
|
255
|
-
The address form binds inputs to the hook — editing is all it takes to trigger the recalculation (`useStoreInfo().countries` is the country table for the selector):
|
|
256
|
-
|
|
257
|
-
```jsx
|
|
258
|
-
const { billing, updateBilling, missingBillingFields,
|
|
259
|
-
addressError, shippingSyncing } = useCheckoutContext();
|
|
260
|
-
<input value={billing.city} onChange={(e) => updateBilling({ city: e.target.value })} />
|
|
261
|
-
{addressError && <p role="alert">{addressError.message}</p>} // "we don't ship there" lives HERE
|
|
262
|
-
```
|
|
263
|
-
|
|
264
|
-
The two store-data choices — **never hardcode either** — come pre-branched through the headless pickers (they render nothing themselves; the render prop is the whole UI):
|
|
265
|
-
|
|
266
|
-
```jsx
|
|
267
|
-
<ShippingMethodPicker>{({ status, methods, chosen, choose, mustChoose, syncing }) => (
|
|
268
|
-
// renders null for you on virtual carts (status "not_needed")
|
|
269
|
-
// status "missing_address" → say options appear once the address is entered
|
|
270
|
-
// mustChoose → render methods [{ id, title, cost }] as a picker → choose(m.id)
|
|
271
|
-
// otherwise → display chosen.title + chosen.cost (never a raw id); dim while syncing
|
|
272
|
-
)}</ShippingMethodPicker>
|
|
273
|
-
|
|
274
|
-
<PaymentMethodPicker>{({ gateways, value, select, selected, single }) => (
|
|
275
|
-
// several → picker labeled with the admin's title/description → select(g.slug)
|
|
276
|
-
// single → pre-selected; skip the picker but still show selected.title
|
|
277
|
-
// none → checkout cannot complete — say so instead of rendering a dead button
|
|
278
|
-
)}</PaymentMethodPicker>
|
|
279
|
-
```
|
|
280
|
-
|
|
281
|
-
A default-seeded store offers **`offline` only** — cards are off unless someone enables them (§4.1) — so `PaymentMethodPicker`'s `single` branch is the common case and your checkout must render it properly. Don't hardcode a Credit card option, and don't wait for a provider to build this step. Where the card gateway *is* enabled with no provider wired, picking it fails `place-order` with `503 no_card_payment_provider` — tell the customer card payment is temporarily unavailable and offer the other methods (the fix is §4.2: wire the provider, or switch the gateway back off). `online: true` marks the card/redirect gateway; every other gateway is manual reconciliation.
|
|
282
|
-
|
|
283
|
-
Placing the order — drive the button off the gate; the hook redirects to the provider's payment page when the gateway is online:
|
|
284
|
-
|
|
285
|
-
```jsx
|
|
286
|
-
const { canPlaceOrder, blockers, placing, placeOrder, orderError } = useCheckoutContext();
|
|
287
|
-
|
|
288
|
-
<button disabled={!canPlaceOrder || placing} onClick={async () => {
|
|
289
|
-
const res = await placeOrder({ customer_note });
|
|
290
|
-
if (res.ok && !res.result.payment) showConfirmation(res.result);
|
|
291
|
-
// manual gateway → res.result.payment_instructions: { description, account_details } — render them
|
|
292
|
-
// online gateway → the hook already redirected to res.result.payment.checkout_url
|
|
293
|
-
// res.ok === false → res.error rendered below; the hook re-read the cart if it changed underneath
|
|
294
|
-
}}>Place order</button>
|
|
295
|
-
{orderError && <p role="alert">{orderError.message}</p>}
|
|
296
|
-
```
|
|
297
|
-
|
|
298
|
-
`blockers` names exactly what still stands in the way — drive inline hints from it instead of re-deriving: `empty_cart`, `billing_incomplete` (pair with `missingBillingFields`), `shipping_address_incomplete`, `shipping_recalculating`, `shipping_address_required`, `shipping_method_required`, `shipping_not_available`, `payment_method_required`.
|
|
299
|
-
|
|
300
|
-
Every payment link returns to **`/order-received`** — the page from §1 step 3. One hook, idempotent, safe on every visit (it reads the URL params itself; `?payment=` is only a hint — the server verifies with the provider):
|
|
301
|
-
|
|
302
|
-
```jsx
|
|
303
|
-
const { status, order, paymentLink, paymentInstructions, error, reload } = useOrderReturn();
|
|
304
|
-
// "loading" → spinner
|
|
305
|
-
// "paid" → thank-you + order summary (the order is now marked paid)
|
|
306
|
-
// "unpaid" → card order: offer paymentLink.url to pay now;
|
|
307
|
-
// manual order: render paymentInstructions ({ description, account_details })
|
|
308
|
-
// "cancelled" → payment was cancelled — offer paymentLink.url or support
|
|
309
|
-
// "error" → render error.message with a retry via reload()
|
|
310
|
-
```
|
|
311
|
-
|
|
312
|
-
Two shapes to get right when rendering: **`order` carries flat totals** — `order.total`, `order.shipping_total`, `order.total_tax` — there is **no `order.totals` object** (that nested shape belongs to the cart view and the place-order result's top-level `totals`); and `paymentLink` is `{ url, reference } | null` (card orders only).
|
|
313
|
-
|
|
314
|
-
**Carry forward:** `order_id` + `order_key` are the guest's proof of ownership — `commerce/storefront-account` `get-order` with both returns the order for a tracking page (signed-in customers get `my-orders` with no key).
|
|
315
|
-
|
|
316
|
-
---
|
|
317
|
-
|
|
318
|
-
## 3. Store data — seeding
|
|
319
|
-
|
|
320
|
-
A fresh install has **no settings and no catalog**. One call to `commerce/seed-store` (admin-only, idempotent) initializes both. Its writes run in parallel, so the call usually takes a few seconds (large catalogs longer) — and nothing in §2 needs its response anyway, so fire it and keep building (§0). It always creates the business defaults — the six settings groups (`general`, `products`, `inventory`, `tax`, `shipping`, `emails`; USD, kg/cm, taxes off prices) and the two payment gateway rows, `offline` **enabled** and `card` **disabled** (enable cards only with a provider wired — §4.1) — and, depending on the payload, the catalog. A fallback "Rest of the world" **Shipping & Tax Location** (one free shipping rate, no tax) is seeded **only when the payload carries no `locations`** — locations you pass are the store's only shipping data, with no seeded fallback beside them. Pass **`currency`** (an ISO code, e.g. `"EUR"`) and/or **`weight_unit`**/**`dimension_unit`** to set the store's currency and measurement units instead of the defaults — explicit values always win, on a first seed and a re-run alike. (Prices are *formatted* with `Intl.NumberFormat` per the viewer's locale — the currency is a value; there are no format settings.)
|
|
321
|
-
|
|
322
|
-
Pass **`payment_methods`** (gateway slugs, e.g. `["offline", "card"]`) to say exactly how the store gets paid: the listed gateways are enabled and **every other gateway row is disabled** — "card-only", "offline-only" or both is part of the same seed call, with **no `commerce.PaymentGateway` reads or writes of your own**. **Omit it and the store offers `offline` only, with the `card` gateway switched off.** This argument is therefore how cards get turned **on** — and only enable them if a provider is wired or is about to be (§4.1), since an enabled card option with nothing behind it answers `503 no_card_payment_provider` at checkout. Explicit values win on re-runs too, so any of this can be changed later with one more `payment_methods` call. Unknown slugs fail as `400 invalid_payload` (the error lists the known ones). Should you ever need direct entity access, names are dotted — bracket syntax only: `base44.entities["commerce.PaymentGateway"]` (`commerce__PaymentGateway` / `PaymentGateway` don't exist).
|
|
323
|
-
|
|
324
|
-
| Mode | Body | Products created |
|
|
325
|
-
|---|---|---|
|
|
326
|
-
| **Real catalog** | `{ store_name, products: [...] }` (+ optional `coupons`, `locations`) | Yours — categories, ribbons, attributes, variants and all, in this one call |
|
|
327
|
-
| **Demo data** | `{ store_name, with_sample_data: true }` | The template's 10 generic demo products (skipped if any product exists) |
|
|
328
|
-
| **No products** | `{ store_name }` | None — defaults only |
|
|
329
|
-
|
|
330
|
-
**Seeding `coupons` commits you to a redemption path.** A `commerce.Coupon` is admin-only data — a storefront cannot list codes, so a seeded code is reachable *only* through a field the customer types it into: the cart (§2.3) or, when there's no cart page, the checkout (§2.4). Seed a coupon **only if that field exists** (or you are about to build it); otherwise skip `coupons` entirely, exactly as you would skip advertising a free-shipping threshold no rate backs. The same goes for copy: no "Use WELCOME10 for 10% off" banner without both the coupon record and the field.
|
|
331
|
-
|
|
332
|
-
`with_sample_data` cannot be combined with `products` (**400** `invalid_payload`). Not calling `seed-store` at all leaves the admin's first-run **"Set up your store"** screen for the operator — that screen keys off the `general` settings group, which the seed creates, so don't suppress it in code.
|
|
333
|
-
|
|
334
|
-
**`store_name` is required on a first seed** (**400** `store_name_required`) — pass the app's name as the platform shows it (`base44/config.jsonc` → `name` can be stale; ask the user if unsure). It lands in `emails.store_name` and is the email subject/sender name and the public shop name (`get-store-info` → `settings.store_name`). On a re-run it fills a blank name but never overwrites one the merchant chose; the response reports which happened as `store_name: { value, action: "created" | "filled" | "unchanged" | "kept_existing" }`.
|
|
335
|
-
|
|
336
|
-
The call runs a canary schema check first — on **422** `schema_incompatible` fix the reported entities before continuing. Bad catalog payloads fail as **400** `invalid_payload` with an `errors: [{ path, error }]` list before anything is written.
|
|
337
|
-
|
|
338
|
-
### 3.1 The `products` payload
|
|
339
|
-
|
|
340
|
-
**This is the store's real data, not template data.** The call writes the actual `commerce.*` records the admin and the storefront API read from that moment on — nothing is post-processed, substituted or "filled in by the platform" later. Whatever you pass is exactly what `list-products`/`get-product` return, so pass final values (real copy, real prices, permanently-resolvable image URLs) and build the storefront on what the API returns — never on client-side constants mirroring the seed (e.g. a slug→image map): the database is the single source of truth, and such a mirror silently diverges the first time a product is edited in the admin.
|
|
341
|
-
|
|
342
|
-
Reference everything by **display name** — categories, ribbons, attributes and their options are get-or-created (slugs and codes derived, existing records matched case-insensitively and reused). Variants come from `attributes`: list each axis with the options the product comes in, and either pass explicit `variations` (only the combinations you stock, with per-variation overrides) or omit them to auto-generate **every combination**. Prices and the sale window are inherited from the product spec unless a variation overrides them; a variation with its own `stock_quantity` tracks it, one without draws on the parent's pooled `stock_quantity`.
|
|
343
|
-
|
|
344
|
-
**Running this through a code-execution tool? Return only plain JSON.** The SDK's `invoke` resolves to the raw HTTP response, which carries circular request/response objects — `return result` (or stringifying a thrown error whole) fails with `Converting circular structure to JSON` *even when the seed itself succeeded*. Return `res.data` (the `{ success, data }` envelope) and, on error, `e.response?.data`:
|
|
345
|
-
|
|
346
|
-
```js
|
|
347
|
-
try {
|
|
348
|
-
const res = await base44.functions.invoke("commerce/seed-store", {
|
|
349
|
-
store_name: "Aurora Threads",
|
|
350
|
-
currency: "EUR", // optional — defaults to USD
|
|
351
|
-
// payment_methods: ["offline", "card"], // omitted = offline only, cards off (the default).
|
|
352
|
-
// // Only pass "card" with a provider wired (§4.1),
|
|
353
|
-
// // or checkout answers 503 no_card_payment_provider.
|
|
354
|
-
products: [
|
|
355
|
-
{ // simple product
|
|
356
|
-
name: "Classic T-Shirt",
|
|
357
|
-
sku: "TEE-CLASSIC", // optional, but makes re-runs idempotent
|
|
358
|
-
regular_price: 19.99,
|
|
359
|
-
stock_quantity: 50, // implies manage_stock: true
|
|
360
|
-
categories: ["Clothing"], // get-or-create by name
|
|
361
|
-
ribbons: ["Best Seller"],
|
|
362
|
-
images: ["https://…/tee.jpg"], // URLs or { src, alt }
|
|
363
|
-
short_description: "A soft, breathable everyday tee.",
|
|
364
|
-
description: "<p>Cut from combed cotton…</p><ul><li>100% combed cotton</li><li>Pre-shrunk</li></ul>",
|
|
365
|
-
},
|
|
366
|
-
{ // variant product — attributes declare the axes, variations the stocked combos
|
|
367
|
-
name: "Runner Sneaker",
|
|
368
|
-
sku: "SNK-RUN",
|
|
369
|
-
regular_price: 89, // inherited by variations that don't override
|
|
370
|
-
categories: ["Shoes"],
|
|
371
|
-
images: [{ src: "https://…/sneaker.jpg", alt: "Runner Sneaker, side view" }],
|
|
372
|
-
attributes: [
|
|
373
|
-
{ name: "Size", options: ["41", "42", "43"] },
|
|
374
|
-
{ name: "Color", options: ["Black", "White"] },
|
|
375
|
-
],
|
|
376
|
-
default_options: { Size: "42", Color: "Black" }, // pre-selected combination
|
|
377
|
-
variations: [ // omit entirely → all 6 combos auto-generated
|
|
378
|
-
{ options: { Size: "41", Color: "Black" }, stock_quantity: 4 },
|
|
379
|
-
{ options: { Size: "42", Color: "Black" }, stock_quantity: 6 },
|
|
380
|
-
{ options: { Size: "43", Color: "Black" }, stock_quantity: 2 },
|
|
381
|
-
{ options: { Size: "42", Color: "White" }, regular_price: 94, stock_quantity: 3,
|
|
382
|
-
image: "https://…/sneaker-white.jpg" }, // give a visual axis per-variation images
|
|
383
|
-
],
|
|
384
|
-
},
|
|
385
|
-
],
|
|
386
|
-
coupons: [{ code: "WELCOME10", discount_type: "percent", amount: 10 }], // optional — seed one
|
|
387
|
-
// ONLY if a code field exists in the cart or
|
|
388
|
-
// checkout to redeem it (§2.3 / §2.4)
|
|
389
|
-
locations: [{ // optional — passing any makes these the store's ONLY locations (the free-shipping fallback is not seeded)
|
|
390
|
-
name: "Israel", countries: ["IL"],
|
|
391
|
-
shipping_rates: [{ name: "Standard", cost: 20, free_over: 150 }],
|
|
392
|
-
tax_groups: [{ name: "Products", rates: [{ name: "VAT", rate: 18 }] }],
|
|
393
|
-
shipping_tax: { type: "percent", value: 18 }, // or { type: "fixed", value: 5 }
|
|
394
|
-
}],
|
|
395
|
-
});
|
|
396
|
-
return res.data; // the { success, data } envelope — plain JSON
|
|
397
|
-
} catch (e) {
|
|
398
|
-
// e itself is circular (it wraps the HTTP request) — return only its payload
|
|
399
|
-
return { success: false, status: e.response?.status, ...(e.response?.data ?? { error: e.message }) };
|
|
400
|
-
}
|
|
401
|
-
```
|
|
402
|
-
|
|
403
|
-
What the seeder does per product: derives a unique slug, checks SKU uniqueness, prices variations (`sale_price` + optional `date_on_sale_from/to` supported at both levels), rolls the parent's `price`/`regular_price`/`on_sale` up from the cheapest publishable variant (never set a variant parent's price yourself — it's derived), sets `stock_status`, and maintains category/ribbon counts. Products default to `status: "publish"`; pass `"draft"` to review first. Other `commerce.Product` fields (`weight`, `dimensions`, `virtual`, `downloadable`, `downloads`, `meta_data`, …) pass through; unknown keys are rejected so typos surface instead of vanishing.
|
|
404
|
-
|
|
405
|
-
**Re-runs converge instead of duplicating**: a product whose `sku` (or, without one, derived slug) already exists is skipped and reported — safe for retries after a timeout, and for seeding into a store that already has products. Limits: ≤100 products and ≤500 variations per call, ≤50 variations per product (an auto-generated cartesian above that is rejected — pass explicit `variations`).
|
|
406
|
-
|
|
407
|
-
The response reports everything:
|
|
408
|
-
|
|
409
|
-
```jsonc
|
|
410
|
-
{ "seeded": { "settings_groups": 6, "gateways": 2, ... },
|
|
411
|
-
"catalog": {
|
|
412
|
-
"categories": { "created": 2, "reused": 0 }, "ribbons": { ... }, "attributes": { ... }, "terms": { ... },
|
|
413
|
-
"products_created": 2, "products_skipped": 0, "variations_created": 4,
|
|
414
|
-
"coupons": { "created": 1, "skipped": 0 }, "locations": { "created": 1, "skipped": 0 },
|
|
415
|
-
"products": [
|
|
416
|
-
{ "name": "Classic T-Shirt", "id": "…", "slug": "classic-t-shirt", "sku": "TEE-CLASSIC", "variation_count": 0 },
|
|
417
|
-
{ "name": "Runner Sneaker", "id": "…", "slug": "runner-sneaker", "sku": "SNK-RUN", "variation_count": 4 }
|
|
418
|
-
]
|
|
419
|
-
},
|
|
420
|
-
"store_name": { "value": "Aurora Threads", "action": "created" },
|
|
421
|
-
"currency": { "value": "EUR", "action": "created" }, // "updated" | "unchanged" on re-runs; null when not passed
|
|
422
|
-
"payment_methods": { "enabled": ["offline", "card"], "disabled": [] } } // null when not passed (→ offline on, card off)
|
|
423
|
-
```
|
|
424
|
-
|
|
425
|
-
**Images**: every product needs at least one, and the URL you seed is the URL the store serves — there are no placeholders to swap later. So resolve each image to its **final URL before seeding**: use whatever image generation the app has available and store the returned URL, upload real assets with `base44.integrations.Core.UploadFile({ file })` → public URL, or fall back to stable public stock URLs (`base44/functions/commerce/seed-store/sample-data.ts` shows a working Unsplash pattern). Match the image to the product. **Final means permanent and resolving**, and both fail silently later rather than at seed time, so check them now: an image tool that returns a *temporary or signed* URL (expiry params in the query string are the tell) must be re-hosted — download and `UploadFile` it for a stable public URL — and before seeding, spot-check that the URLs actually resolve (fetch one or two: HTTP 200, image content type). A dead or expiring URL seeds fine and then renders as a broken image or an eternal placeholder in the store. Image generation is usually the **slowest step of the whole install** — this is dependency edge #1 of §0: kick all product images off first, do the rest (router mount, payment file, storefront pages) while they render, and seed the moment the URLs are back. If an image isn't ready at seed time you *may* seed without it and set it afterwards through the admin API (never seed a dead path and compensate in the frontend) — but that is **an open debt, not a resolution**: track every product seeded imageless and close it before handover.
|
|
426
|
-
|
|
427
|
-
A successful response means the data is in — the catalog and settings are live exactly as reported. Write any remaining store-specific settings into `commerce.StoreSettings` (direct CRUD, one record per `group_id` — weight/dimension units are the usual ones; patch `values`, don't replace groups you weren't asked about).
|
|
428
|
-
|
|
429
|
-
---
|
|
430
|
-
|
|
431
|
-
## 4. Payments — optional; cards off by default
|
|
432
|
-
|
|
433
|
-
The order side of payments is **already implemented** (checkout routing, confirmation, payment links, refund records). The `offline` gateway — and any option the admin adds in Settings → Payments — works with nothing to configure: the order goes on-hold with the option's description as instructions.
|
|
434
|
-
|
|
435
|
-
### 4.1 Off by default — enable only with a provider wired
|
|
436
|
-
|
|
437
|
-
**The rule, in full:** online card payments are **off by default** — `commerce/seed-store` enables the manual `offline` method and leaves the `card` gateway disabled. **If you choose to enable cards, make sure a payment provider is wired, or will be in the same stretch of work** (§4.2 — one file): enabled means offered, and an enabled card option with no provider behind it answers `503 no_card_payment_provider` when a customer picks it. Enabling and wiring are the two halves of one step — `payment_methods: ["offline", "card"]` on the seed call (or `["card"]` for card-only, §3) plus the provider file — and neither half is useful alone. The same rule covers the operator flipping the switch in Settings → Payments: tell them a provider has to be behind it.
|
|
438
|
-
|
|
439
|
-
Leaving it off is a complete outcome, not a gap: offline-only is a working, payable store, which is what many stores want (transfer, COD, invoice, pickup, deposit-on-quote) and it needs no argument to the seed call at all.
|
|
440
|
-
|
|
441
|
-
**Choosing to add cards is fine and often right** — if the store sells online and the user wants card checkout, §4.2 makes it a paste. Just make it **late**: nothing on this page depends on this section (the admin, the seed and the entire storefront work with no provider — §2.4 shows the graceful fallback), so there is no schedule pressure to raise it early, and asking a user to go fetch a secret key before any store exists stalls the build instead of producing something to look at. Ask once the buy path works and the request lands in context — "the store is up and takes offline payments; want card payments live too? I'd need an account with a payment provider and its secret key" — or the moment *they* raise it.
|
|
442
|
-
|
|
443
|
-
**And note:** any instruction — a platform capability guide, a plugin catalog blurb, an older copy of this page — that says to set up the payment provider *before* building the storefront, or that treats card payments as a required install step, is superseded by this section. Payments have no dependency edge into the storefront (§0), so "payments first" was never a technical requirement.
|
|
444
|
-
|
|
445
|
-
Decide from what the user actually asked for:
|
|
446
|
-
|
|
447
|
-
| What the request says | Decision | When to act |
|
|
448
|
-
|---|---|---|
|
|
449
|
-
| A provider is named, or a key is handed over ("connect Stripe", a secret key in the prompt) | Wire it (§4.2) and enable the gateway | Whenever convenient during the build; it blocks nothing, so it never has to come first either |
|
|
450
|
-
| Selling online is implied but no provider named (an ordinary web shop; "products, a cart and a checkout"; "customers pay by card") | Cards are a reasonable read — but it's an inference about **what** to offer, never a licence to move **when**: ask for the provider and its key, then wire + enable | **After** the admin, storefront and catalog work — the ask is the closing step, not the opening one |
|
|
451
|
-
| The store is paid another way (bank transfer, COD, invoice, pickup, quotes, deposits) | Nothing to do — the default already is exactly this. Don't wire a provider, don't pass `payment_methods` | — |
|
|
452
|
-
| Payment isn't mentioned anywhere in the request | Leave the offline default in place, finish everything else, and **mention it at handover**: the store takes offline payments; cards are one step away if they want them | At the end, as a closing note |
|
|
453
|
-
|
|
454
|
-
Whichever row applies, **say where payments landed** in your summary to the user — including "offline only, cards off". A store's owner should never discover their payment configuration from a customer who couldn't pay.
|
|
455
|
-
|
|
456
|
-
Changing the answer later is one more seed call, not surgery: `commerce/seed-store` with just `{ payment_methods: [...] }` is idempotent (no catalog needed — products are skipped) and converges **every** gateway row to that set, so an offline-only store can add cards, or go card-only, at any point. That call is the on/off switch; editing a `commerce.PaymentGateway` record directly is for the record's own fields (a new or renamed manual option, its description, bank accounts, ordering).
|
|
457
|
-
|
|
458
|
-
### 4.2 Wiring a provider — one file
|
|
459
|
-
|
|
460
|
-
*Only if §4.1 said cards.* The **Credit card** option needs a payment provider — **any** provider works (Stripe, PayPal, Adyen, a local PSP…), and whichever one it is, wiring it touches exactly **one file**: `base44/shared/commerce/card-payment.ts` — four functions, each backing a premade flow. `commerce/payment-webhook` (the function) is **premade — do not edit it**: it calls this file's `parseWebhook` to validate each event and **never trusts an event body on its own** — an unverified event only *names* an order, and whether money arrived is asked of the provider itself through `checkCardPaymentPaid`, so a forged webhook call can never mark an order paid and **no signing secret is needed**. The card gateway ships disabled, so an unimplemented file is normally invisible to customers — but a gateway enabled without it answers `503 no_card_payment_provider` at checkout, which is why this file and the `payment_methods` switch always go together.
|
|
461
|
-
|
|
462
|
-
| Function | Backs |
|
|
463
|
-
|---|---|
|
|
464
|
-
| `createCardPayment` | checkout + payment links (creates the hosted payment page) |
|
|
465
|
-
| `checkCardPaymentPaid` | confirmation — the return page, the webhook and the admin's "Check payment" all verify through it, against the provider's API |
|
|
466
|
-
| `refundCardPayment` | admin refunds with `refund_payment: true` |
|
|
467
|
-
| `parseWebhook` | webhook validation — names the order the event is about; `paid: true` only after signature verification, never from a raw body |
|
|
468
|
-
|
|
469
|
-
Four steps, whichever provider it is:
|
|
470
|
-
|
|
471
|
-
1. **Implement the four functions** in `base44/shared/commerce/card-payment.ts` against the provider's API. **No provider ships with the template** — the file arrives as stubs, and [`references/online-payments.md`](./references/online-payments.md) holds the per-provider rules plus a complete **Stripe** implementation to paste if Stripe is the provider the store chose. Write the file **whole, in one write. Never `find_replace` into the stubs**: a partial patch leaves the originals behind and breaks every commerce function's deploy with duplicate-export bundle errors (`Multiple exports with the same name "createCardPayment"` → rewrite the file whole).
|
|
472
|
-
2. **Store the provider's API credential as a backend app secret** (read with `Deno.env.get(...)` — never in code, never in an entity). Test credentials work end to end. This is the only step that needs the user, so it sets the timing of the whole section (§4.1): ask when the store is standing, not in the install's first message — and if the answer takes a while, keep the rest of the work moving rather than idling on it.
|
|
473
|
-
3. **Register the premade webhook URL** — `https://<app-domain>/functions/commerce/payment-webhook` — with the provider, for its "payment succeeded" event, so orders are confirmed even when the buyer pays and closes the tab. There is **no signing secret to store**: events are treated as nudges and verified against the provider's API. (Per-provider registration calls are in the reference; the user can also do it in the provider's dashboard.)
|
|
474
|
-
4. **Enable the gateway** — the `card` row is seeded **off** (§4.1), so the file alone changes nothing a customer sees. **Turning a method on or off is the seed call:** `commerce/seed-store` with `{ payment_methods: ["offline", "card"] }` (or `["card"]` for card-only) — safe on a seeded store (no catalog needed, products are skipped), idempotent, and it converges every gateway row to that set, so the same call also goes card-only or switches cards back off later. Reach for direct CRUD on `commerce.PaymentGateway` when the **record itself** needs changing — adding a new manual option, renaming one, editing its description or bank accounts, reordering them — which is what the admin's Settings → Payments screen does.
|
|
475
|
-
|
|
476
|
-
Skipping this step is the usual reason a freshly wired provider "doesn't show up at checkout"; doing it *without* steps 1–2 is what produces `503 no_card_payment_provider`.
|
|
477
|
-
|
|
478
|
-
That's it — checkout redirect, `/order-received` confirmation, the webhook, the admin's "Check payment" button, payment links and provider refunds all run through this one file.
|
|
479
|
-
|
|
480
|
-
---
|
|
481
|
-
|
|
482
|
-
## 5. Done when
|
|
483
|
-
|
|
484
|
-
Post-installation is complete when every line below holds. **Do not validate the admin part at all**: everything under `/store-admin` ships finished and already tested — there is nothing there for you to verify.
|
|
485
|
-
|
|
486
|
-
- [ ] `/store-admin/*` mounted behind the shipped `AuthGuard`; `/` routes somewhere real (storefront or a redirect).
|
|
487
|
-
- [ ] `commerce/seed-store` ran once and reported the catalog — real products, final image URLs; `payment_methods` passed only if the store's methods differ from the default (offline on, cards off).
|
|
488
|
-
- [ ] **Catalog pages and a checkout exist** — the catalog UI in whatever form fits the store (a product list, product pages, or both — one can be enough), a cart step only if the store wants one (§2.3), and the checkout built on `useCheckout` with `/order-received` rendering `useOrderReturn` (§2.4).
|
|
489
|
-
- [ ] **Every coupon the store has can actually be redeemed** — if `coupons` was seeded (or the operator has codes), a code field with `applyCoupon` exists in the cart or the checkout, showing `discount_total` and rendering invalid codes inline (§2.3/§2.4). No field → don't seed coupons and don't mention codes in the copy.
|
|
490
|
-
- [ ] **One `<StorefrontProvider>`** above every storefront route — wrapping `<Routes>` (or a layout route's `<Outlet />`), never placed as a child of `<Routes>`, which React Router rejects (§2) — no second client, no hand-rolled `cart_token` handling, payment gateways read from `useStoreInfo()` only (never off a cart), and cart state everywhere through `useCart()`.
|
|
491
|
-
- [ ] **Cards are either off, or on with a provider behind them** (§4.1) — the default (offline enabled, `card` off) is a complete state and needs nothing. If you enabled cards, the provider must be wired: `card-payment.ts` written whole, secret stored, webhook registered. Enabled without a wired provider means `503 no_card_payment_provider` at checkout; wired without enabling means customers never see the option. Say where payments landed when you hand over.
|
|
492
|
-
|
|
493
|
-
## 6. Next
|
|
494
|
-
|
|
495
|
-
Continue with the commerce skill — [`.agents/skills/commerce/SKILL.md`](./SKILL.md) — for day-2 work: UI changes, deeper storefront features ([`references/product-render.md`](./references/product-render.md) for what to render per view, [`references/storefront-product-page.md`](./references/storefront-product-page.md) for variant edge cases, [`references/reviews.md`](./references/reviews.md) for the ready-made reviews backend), payment provider wiring, scheduled maintenance, emails, webhooks, and operational limits.
|
|
@@ -1,16 +0,0 @@
|
|
|
1
|
-
# Limits, concurrency & reports performance
|
|
2
|
-
|
|
3
|
-
## Limits & concurrency
|
|
4
|
-
|
|
5
|
-
- **Pagination.** The SDK `filter`/`list` cap out at 5,000 records/page and there is **no total-count API**. Server-side scans use paged loops (`base44/shared/commerce/scan.ts` `scanAll`, page size 500). Admin lists use limit+skip with a `limit+1` "has-next" probe — the UI shows *Page N ‹ ›*, never a total.
|
|
6
|
-
- **Search** is server-side (`search` actions scan + JS-filter) because entity `filter` is exact-match only.
|
|
7
|
-
- **Reports** scan orders on demand — fine to ~10k orders per range. Beyond that, materialize an `OrderStats` entity updated on each order transition and aggregate from it (sketch: one record per day per status with summed totals; `commerce/admin-reports` reads the pre-aggregated rows instead of scanning `commerce.Order`).
|
|
8
|
-
- **No transactions.** A few consequences, all documented in code:
|
|
9
|
-
- `nextOrderNumber` is `max(order_number)+1` with a small retry; under heavy concurrent checkout two orders could theoretically collide — acceptable for typical volume, or front it with a dedicated counter entity if needed.
|
|
10
|
-
- Stock decrement is last-write-wins; oversell is possible under simultaneous checkouts of the last unit. Mitigate with the hold mechanism (already in place) or a stricter reserve step if your volume warrants.
|
|
11
|
-
- Denormalized counters (`usage_count`, `total_sales`, `orders_count`, term `count`) can drift; `commerce/admin-tools` recount actions repair them (schedule them via [`scheduled-work.md`](./scheduled-work.md)).
|
|
12
|
-
- **Record size.** Orders embed their line/shipping/tax/fee/coupon lines. Extremely large orders (hundreds of distinct line items) push against per-record size limits; split or paginate if you expect that.
|
|
13
|
-
|
|
14
|
-
## Reports performance
|
|
15
|
-
|
|
16
|
-
The `commerce/admin-reports` `summary`/`sales`/`top-sellers` actions scan `commerce.Order` (and `commerce.OrderRefund`) filtered to counted orders (`date_paid` set, or status `processing`/`completed`). Net sales = gross − refunds − tax − shipping (standard convention). For large catalogs, cache `summary` on the dashboard and consider the `OrderStats` materialization above.
|
|
@@ -1,4 +0,0 @@
|
|
|
1
|
-
# Images & downloadable files
|
|
2
|
-
|
|
3
|
-
- **Catalog images** — the admin `MediaUploader` uses `base44.integrations.Core.UploadFile({ file })` → public URL, stored in `product.images[]` / `variation.image`.
|
|
4
|
-
- **Downloadable products** — store files as the download's `file_url`. For private files, upload with `Core.UploadPrivateFile` (stores a `file_uri`, not an `http` URL). `commerce/storefront-account` `get-download` detects non-`http` URIs and returns a short-lived signed URL via `Core.CreateFileSignedUrl` (1-hour expiry), decrementing `downloads_remaining` and enforcing `access_expires`.
|