@base44/app-plugin-commerce 0.1.20 → 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/useAddressForm.js +96 -0
- package/src/commerce/storefront/useCartLine.js +184 -0
- 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 -496
- 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,10 +1,23 @@
|
|
|
1
1
|
# Storefront API Reference
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
The raw HTTP/SDK surface behind a customer-facing shopfront. **A React storefront should not call most of it directly** — `src/commerce/storefront/` ships the logic layer, and this file is the reference for what lies beyond it, for non-React and headless clients, and for the exact payloads and error codes.
|
|
4
|
+
|
|
5
|
+
**Two tiers.** The *identity* surfaces — home, the collection grid, the product page's layout, the product card, the theme — are yours to design; build them on the hooks (`useProductList`, `useProduct`, `useProductPrice`, `useStorefrontSeo`). The *commodity* surfaces — checkout, cart, order-received, reviews, and the product page's internals — ship as **blocks** that inherit your theme (`CheckoutBlock`, `CartLinesBlock`, `TotalsBlock`, `CouponFieldBlock`, `OrderReceivedBlock`, `ReviewsBlock`, `VariantSelectorBlock`, …). Restyle or replace them; never hand-roll their logic. Framework-free helpers (API client, variant resolution, free-shipping rules) live in `src/commerce/utils/`.
|
|
6
|
+
|
|
7
|
+
## Contents
|
|
8
|
+
|
|
9
|
+
- [Required behaviors](#required-behaviors--a-storefront-that-skips-these-cannot-sell) (the four hard rules) · [capabilities](#what-the-api-already-gives-you-read-before-designing-the-ui) · [conventions](#conventions) (envelope, auth, money)
|
|
10
|
+
- [`storefront-catalog`](#commercestorefront-catalog): [`get-store-info`](#get-store-info) · [`list-products`](#list-products) · [`get-product`](#get-product) · [`list-categories`](#list-categories) · [`list-ribbons`](#list-ribbons) · [`list-attributes`](#list-attributes) · [`submit-review`](#submit-review)
|
|
11
|
+
- [`storefront-cart`](#commercestorefront-cart): [priced view](#the-priced-cart-view-returned-by-every-action) · [actions](#actions) · [`cart_token`](#cart_token--the-storefronts-one-job) · [**shipping rules**](#shipping-is-not-optional--read-this-before-building-checkout) · [never advertise unconfigured offers](#never-advertise-what-the-store-isnt-configured-to-do)
|
|
12
|
+
- [`storefront-checkout`](#commercestorefront-checkout): [`place-order`](#place-order) · [`confirm-payment`](#confirm-payment) · [`cancel-order`](#cancel-order)
|
|
13
|
+
- [`payments`](#commercepayments--online-payment-for-an-order): payment links, `complete-return` for `/order-received`
|
|
14
|
+
- [`storefront-account`](#commercestorefront-account): orders, notes, downloads, addresses, my reviews
|
|
15
|
+
- [Walkthrough A — guest checkout](#walkthrough-a--guest-checkout) · [B — member](#walkthrough-b--member-experience) (non-React clients)
|
|
16
|
+
- Entities and direct CRUD → [`entities.md`](./entities.md) · admin surface → [`api-admin.md`](./api-admin.md)
|
|
4
17
|
|
|
5
18
|
## Required behaviors — a storefront that skips these cannot sell
|
|
6
19
|
|
|
7
|
-
|
|
20
|
+
The canonical statement. Each is enforced by the API, not a nicety:
|
|
8
21
|
|
|
9
22
|
| # | Requirement | Enforced by |
|
|
10
23
|
|---|---|---|
|
|
@@ -14,29 +27,29 @@ Read this list before writing any of it. Each item is a hard requirement enforce
|
|
|
14
27
|
| 4 | **Implement the return page** (`/order-received`, or your own route set in Settings → General → *Payment return path*) and call `commerce/payments` `complete-return` there | without it a paid order is never confirmed, and a wrong path is a 404 |
|
|
15
28
|
| 5 | **Only advertise offers the store is configured for** (free-shipping thresholds come from a location's shipping rates) | nothing — this one is on you |
|
|
16
29
|
|
|
17
|
-
Four public functions
|
|
30
|
+
Four public functions — **`storefront-catalog`**, **`storefront-cart`**, **`storefront-checkout`**, **`storefront-account`** — all invoked the same way, all returning the same envelope.
|
|
18
31
|
|
|
19
32
|
## What the API already gives you (read before designing the UI)
|
|
20
33
|
|
|
21
|
-
|
|
34
|
+
**Build to this surface, not to the basics** — everything below already exists server-side, so leaving it out ships a storefront weaker than the store behind it.
|
|
22
35
|
|
|
23
36
|
| Capability | Already supported | Where |
|
|
24
37
|
|---|---|---|
|
|
25
|
-
| **Product discovery** | full-text `search
|
|
38
|
+
| **Product discovery** | full-text `search`; filters: category (incl. descendants), ribbon, attribute+term, price range, `featured`, `on_sale`, `in_stock_only`; sort by name / price / **newest** (default) / **popularity** / **rating**; paging | [`list-products`](#list-products) |
|
|
26
39
|
| **Rich product pages** | galleries, per-variant price/stock/image, categories, ribbons, **upsells**, **cross-sells** | [`get-product`](#get-product) |
|
|
27
|
-
| **Taxonomy navigation** | category tree, **
|
|
28
|
-
| **Customer reviews** | paginated
|
|
29
|
-
| **Cart** | guest carts via `cart_token`, add/update/remove, quantity merging, `sold_individually` caps, live re-pricing, stock revalidation | [`
|
|
30
|
-
| **Coupons** | apply/remove by code
|
|
31
|
-
| **Shipping** | address → matched location → **selectable
|
|
32
|
-
| **Tax** | inclusive/exclusive pricing, per-location tax groups, itemized display —
|
|
33
|
-
| **Stock states** | in stock / out of stock / **on backorder**, low-stock signalling,
|
|
34
|
-
| **Checkout** | guest or authenticated, billing/shipping, per-gateway routing, **payment instructions** for manual methods,
|
|
35
|
-
| **Accounts** | order history, saved
|
|
36
|
-
| **Digital products** | entitlement-checked downloads with remaining-count, expiry,
|
|
37
|
-
| **Store config** | currency
|
|
38
|
-
|
|
39
|
-
|
|
40
|
+
| **Taxonomy navigation** | category tree, **ribbons with counts**, attributes + terms for filter UIs | [`list-categories`](#list-categories) · [`list-ribbons`](#list-ribbons) · [`list-attributes`](#list-attributes) |
|
|
41
|
+
| **Customer reviews** | paginated per product with **average rating + count**, `verified` flag, **submission by anyone with an email** (no login), "my reviews" | [`submit-review`](#submit-review) |
|
|
42
|
+
| **Cart** | guest carts via `cart_token`, add/update/remove, quantity merging, `sold_individually` caps, live re-pricing, stock revalidation | [`storefront-cart`](#commercestorefront-cart) |
|
|
43
|
+
| **Coupons** | apply/remove by code, fully validated server-side (eligibility, limits, per-user usage). Codes are admin-only data, so a store with coupons needs a **code field** in the buy path or they can never be redeemed | [`apply-coupon`](#actions) |
|
|
44
|
+
| **Shipping** | address → matched location → **selectable rates with live costs**, free-over thresholds | [`set-shipping-address`](#actions) |
|
|
45
|
+
| **Tax** | inclusive/exclusive pricing, per-location tax groups, itemized display — resolved server-side | [`get-store-info`](#get-store-info) |
|
|
46
|
+
| **Stock states** | in stock / out of stock / **on backorder**, low-stock signalling, out-of-stock hiding | [`list-products`](#list-products) |
|
|
47
|
+
| **Checkout** | guest or authenticated, billing/shipping, per-gateway routing, **payment instructions** for manual methods, cancel, post-payment confirm | [`storefront-checkout`](#commercestorefront-checkout) |
|
|
48
|
+
| **Accounts** | order history, saved addresses, **guest order tracking by `order_key`** with customer-visible notes | [`storefront-account`](#commercestorefront-account) |
|
|
49
|
+
| **Digital products** | entitlement-checked downloads with remaining-count, expiry, signed URLs for private files | [`get-download`](#commercestorefront-account) |
|
|
50
|
+
| **Store config** | currency, units, catalog/cart price display — honour it instead of hardcoding | [`get-store-info`](#get-store-info) |
|
|
51
|
+
|
|
52
|
+
Not in the backend: the visitor UI (identity tier — yours; commodity tier — the blocks), and the card-payment provider integration, which only a store that opts into cards needs — cards are off by default, the decision and its timing live in [`../install/03-data.md`](../install/03-data.md), the provider code in [`../references/online-payments.md`](../references/online-payments.md). Payment methods and the currency are admin-owned data — always render them from `get-store-info`, never a hardcoded list, and format prices with `Intl.NumberFormat(undefined, { style: "currency", currency })`.
|
|
40
53
|
|
|
41
54
|
## Conventions
|
|
42
55
|
|
|
@@ -45,8 +58,8 @@ Two things that are **not** in the backend and are yours to build: the visitor U
|
|
|
45
58
|
const res = await base44.functions.invoke("commerce/storefront-catalog", { action: "get-store-info" });
|
|
46
59
|
const info = res.data.data; // envelope → payload
|
|
47
60
|
```
|
|
48
|
-
- **Auth modes:** most actions are anonymous. Some require an authenticated Base44 session (marked **auth**).
|
|
49
|
-
- **Identity is never a payload field.** Anything attributed to a person — a review's author, a saved profile —
|
|
61
|
+
- **Auth modes:** most actions are anonymous. Some require an authenticated Base44 session (marked **auth**). Two bearer credentials (HTTPS only): `order_key` for guest order access, `cart_token` for the cart — whose lifecycle rules are [in the cart section](#cart_token--the-storefronts-one-job).
|
|
62
|
+
- **Identity is never a payload field.** Anything attributed to a person — a review's author, a saved profile — comes from the session. An email in the body does not make you that customer, and no request can assert that an order was paid. A feature built on client-supplied identity will be ignored.
|
|
50
63
|
- **Money:** numbers, 2-dp. **Dates:** ISO strings.
|
|
51
64
|
- **Errors:** every failure has a stable `code` (listed per action) plus a human `error` message.
|
|
52
65
|
|
|
@@ -72,9 +85,9 @@ Bootstrap data for a storefront. No payload.
|
|
|
72
85
|
"currencies": [ { "code": "USD", "name": "US Dollar", "symbol": "$", "decimals": 2 } ]
|
|
73
86
|
}
|
|
74
87
|
```
|
|
75
|
-
`settings` is a safe projection —
|
|
88
|
+
`settings` is a safe projection — display/behavior keys only, never admin config.
|
|
76
89
|
|
|
77
|
-
`payment_gateways` is **every gateway the admin has enabled** (sorted by
|
|
90
|
+
`payment_gateways` is **every gateway the admin has enabled** (sorted by `order`) and this call is its **only** source — `cart.payment_gateways` does not exist, and a hardcoded list is a broken checkout. A **default-seeded store reports `offline` alone** (cards are off by default — [`../install/03-data.md`](../install/03-data.md)), so expect the one-method case. `online: true` marks the card option (`place-order` answers with a payment page; `503 no_card_payment_provider` if it was enabled with no provider behind it); everything else settles manually. In React: `useStoreInfo()` + `PaymentMethodPicker`.
|
|
78
91
|
|
|
79
92
|
### `list-products`
|
|
80
93
|
**Payload** (all optional): `search`, `category_id` (includes descendants), `ribbon_id`, `attribute_id` + `attribute_term`, `min_price`, `max_price`, `featured` (bool), `on_sale` (bool), `in_stock_only` (bool), `sort` (`-created_date`|`name`|`price`|`-price`|`popularity`|`rating`, default `-created_date`), `page` (default 1), `per_page` (default 12, max 100).
|
|
@@ -83,25 +96,21 @@ Only `status: "publish"` products are returned — the admin's single **Visible*
|
|
|
83
96
|
|
|
84
97
|
**Response:** `{ "products": [Product...], "page": 1, "per_page": 12, "has_next": true }`
|
|
85
98
|
|
|
86
|
-
Each row is the product record (minus paywalled fields) **plus a resolved `ribbons` array** (`[{ id, name }]`), so cards
|
|
99
|
+
Each row is the product record (minus paywalled fields) **plus a resolved `ribbons` array** (`[{ id, name }]`), so cards show ribbons without a second call. `categories` are **not** resolved — `category_ids` only. What a row can and can't show, and how to add a `get-product`-only field to this call instead of fetching per card: [`../references/catalog-rendering.md`](../references/catalog-rendering.md).
|
|
87
100
|
|
|
88
|
-
|
|
101
|
+
**Filters stack** — all of them are ANDed, so "Dresses + gift + on sale" is one request. Build the controls from [`list-categories`](#list-categories), [`list-ribbons`](#list-ribbons) (its `count` gives you "Gift (12)") and [`list-attributes`](#list-attributes), and mirror active filters into the URL so a filtered listing is shareable and survives reload.
|
|
89
102
|
|
|
90
|
-
**
|
|
91
|
-
|
|
92
|
-
**The same call also serves a short strip anywhere else** — a homepage hero or banner, a "featured" row, a "new in" rail, related picks beside an article. `per_page` bounds the result and the filters/sort choose what lands in it, so a small curated slice is one request; there is no separate endpoint, and no reason to fetch a big page and slice it client-side. Available if a page wants one:
|
|
103
|
+
**The same call serves every short strip** — homepage hero, "featured" row, "new in" rail, related picks. `per_page` bounds it and the filters/sort choose what lands in it, so a curated slice is one request; there is no separate endpoint and no reason to fetch a big page and slice client-side:
|
|
93
104
|
|
|
94
105
|
```js
|
|
95
|
-
const inv = (fn, payload) => base44.functions.invoke(fn, payload).then(r => r.data.data);
|
|
96
106
|
const cat = (payload) => inv("commerce/storefront-catalog", { action: "list-products", ...payload });
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
const { products: topInCat } = await cat({ category_id, sort: "popularity", per_page: 4 });
|
|
107
|
+
await cat({ featured: true, per_page: 4 }); // curated: the admin's own star flag
|
|
108
|
+
await cat({ sort: "-created_date", per_page: 6 }); // new in
|
|
109
|
+
await cat({ on_sale: true, in_stock_only: true, per_page: 4 }); // deals
|
|
110
|
+
await cat({ category_id, sort: "popularity", per_page: 4 }); // top in category
|
|
102
111
|
```
|
|
103
112
|
|
|
104
|
-
`featured` is the admin's
|
|
113
|
+
`featured` is the admin's per-product flag (`commerce/seed-store` accepts `featured: true`), so a featured strip is store data, not slugs pinned in the UI. **A filter may legitimately match nothing** (nothing starred, nothing discounted) — drive the block off the returned array's length, hiding it or falling back to another slice, rather than assuming rows came back; `has_next` says whether a "see all" has more behind it. In React this is `useProductList`.
|
|
105
114
|
|
|
106
115
|
### `get-product`
|
|
107
116
|
**Payload:** `{ id }` **or** `{ slug }`; optional `reviews_page` (1), `reviews_per_page` (10, max 50).
|
|
@@ -122,19 +131,15 @@ const { products: topInCat } = await cat({ category_id, sort: "popularity", per_
|
|
|
122
131
|
```
|
|
123
132
|
**Errors:** `404 not_found` (missing / not published / hidden).
|
|
124
133
|
|
|
125
|
-
>
|
|
134
|
+
> **`variations[]` is not a list of choices to show.** Build **one control per `product.attributes[]` entry** (Size, Color, …) — every attribute is an axis — and resolve the combination to a variation client-side; `add-item` needs that `variation_id`. Variant prices come from `variations[]`, never from `product.price` (the parent's price is a rolled-up from-price). A React page gets all of this from `useProduct` + `useAddToCart` (render `view.axes`, gate on `view.purchasable`) or `<VariantSelectorBlock/>`; a non-React client uses the framework-free resolver:
|
|
126
135
|
> ```js
|
|
127
136
|
> import { resolveSelection, defaultSelection, selectOption } from "@/commerce/utils";
|
|
128
|
-
> const
|
|
129
|
-
>
|
|
130
|
-
> // view.
|
|
131
|
-
> // view.availability[axis.key][option] → "available" | "out_of_stock" | "unavailable" (disable, don't hide)
|
|
132
|
-
> // view.display → image / price / sku / stock for the resolved variation
|
|
137
|
+
> const view = resolveSelection(product, variations, selection); // selection from defaultSelection(), updated via selectOption()
|
|
138
|
+
> // view.axes → one control per axis view.display → image/price/sku/stock of the resolved variation
|
|
139
|
+
> // view.availability[axis.key][option] → "available" | "out_of_stock" | "unavailable" (disable, don't hide)
|
|
133
140
|
> // view.purchasable + view.addToCart → { product_id, variation_id } for add-item
|
|
134
|
-
> const pick = (axisKey, option) => setSelection((s) => selectOption(product, variations, s, axisKey, option));
|
|
135
141
|
> ```
|
|
136
|
-
>
|
|
137
|
-
> What to render on the page vs. a card, and which fields exist in only one of the two calls: [`../references/product-render.md`](../references/product-render.md).
|
|
142
|
+
> Field availability per view, ribbons, from-price rules and the variant deep-dive (availability states, incomplete-selection pricing, URL round-trip): [`../references/catalog-rendering.md`](../references/catalog-rendering.md).
|
|
138
143
|
|
|
139
144
|
### `list-categories`
|
|
140
145
|
No payload. Returns a nested tree: `{ "categories": [ { ...category, "children": [...] } ] }` sorted by `menu_order` then name.
|
|
@@ -144,19 +149,19 @@ No payload. Returns a nested tree: `{ "categories": [ { ...category, "children":
|
|
|
144
149
|
|
|
145
150
|
Returns `{ "ribbons": [ { id, name, count } ] }` sorted by name. By default it omits ribbons no published product carries, so a ribbon nav never links to an empty listing; pass `with_products_only: false` for the full set (e.g. an admin-facing picker).
|
|
146
151
|
|
|
147
|
-
`count` is tallied from the products this API would actually list
|
|
152
|
+
`count` is tallied from the products this API would actually list (published, honoring `inventory.hide_out_of_stock`) — **not** from `ProductRibbon.count`, which also counts drafts and drifts until `admin-tools` `recount-terms` runs. So `Gift (12)` and the ribbon's listing agree.
|
|
148
153
|
|
|
149
|
-
This is the **only** way a storefront can enumerate ribbons
|
|
154
|
+
This is the **only** way a storefront can enumerate ribbons (the entity is admin-only) and what makes `list-products` `ribbon_id` usable, since that filter needs an id. Ribbons are flat, cross-cutting labels; categories are the hierarchical spine — show both ([`../references/catalog-rendering.md`](../references/catalog-rendering.md)). In React: `useRibbons` / `useCategories`.
|
|
150
155
|
|
|
151
156
|
### `list-attributes`
|
|
152
|
-
No payload. Returns `{ "attributes": [ { ...attribute, "terms": [ ...values ] } ] }` —
|
|
157
|
+
No payload. Returns `{ "attributes": [ { ...attribute, "terms": [ ...values ] } ] }` — each attribute (`id, name, code, order`) with its values (`id, attribute_id, name, order, count`), both sorted by `order`; for filter UIs. Filter with `list-products` `attribute_id` (id or attribute **name**) + `attribute_term` (the value name); `code` is the stable key for a URL.
|
|
153
158
|
|
|
154
159
|
### `submit-review`
|
|
155
160
|
**Payload:** `{ product_id, reviewer?, review, rating }`.
|
|
156
161
|
|
|
157
|
-
|
|
162
|
+
> A React storefront gets the whole reviews surface — this call, the paginated list from `get-product`, refresh, and the store's moderation policy — from **`useProductReviews`** / **`<ReviewsBlock/>`**; don't hand-roll it. Read on for the raw contract.
|
|
158
163
|
|
|
159
|
-
|
|
164
|
+
**Public by default: anyone can review with an email address — no login.** A guest passes `email`; for a signed-in caller the session email always wins (the payload cannot impersonate). `reviewer` is the display name only, defaulting to the account's `full_name` then the email's local part. `rating` is optional (0–5). `verified` is derived from the email's order history. Status is `hold` unless `products.auto_approve_reviews` — the one server-side switch, so a hardcoded "awaiting approval" message is wrong when it is on. Stricter policies (login-gated, verified buyers only, rating required) are a `policy` option on the hook, not code you write: [`../references/reviews.md`](../references/reviews.md).
|
|
160
165
|
|
|
161
166
|
**Response:** `{ "review_id", "status": "hold"|"approved", "verified": true }`
|
|
162
167
|
**Errors:** `404 not_found`, `400 email_required|review_incomplete|invalid_rating`.
|
|
@@ -165,7 +170,7 @@ Any stricter policy is the **storefront's** to enforce in its UI — login-gated
|
|
|
165
170
|
|
|
166
171
|
## commerce/storefront-cart
|
|
167
172
|
|
|
168
|
-
Token-scoped cart (guest + member). Every action **except `create`** takes `cart_token
|
|
173
|
+
Token-scoped cart (guest + member). Every action **except `create`** takes `cart_token`, and **every mutating action returns the full priced cart view**. Rolling 48h TTL, refreshed on each touch. An authenticated caller's other active carts are merged in (quantities summed, `sold_individually` capped, coupon codes unioned; source carts marked abandoned).
|
|
169
174
|
|
|
170
175
|
### The priced cart view (returned by every action)
|
|
171
176
|
```json
|
|
@@ -193,7 +198,7 @@ Token-scoped cart (guest + member). Every action **except `create`** takes `cart
|
|
|
193
198
|
"expires_at": "2025-..."
|
|
194
199
|
}
|
|
195
200
|
```
|
|
196
|
-
|
|
201
|
+
Coupons that stop validating are **auto-removed** and reported in `coupon_notices`; items whose product vanished or was unpublished appear in `removed_items`. In React `useCart` exposes this view as `lines` + `notices`.
|
|
197
202
|
|
|
198
203
|
### Actions
|
|
199
204
|
| Action | Payload | Notes / errors |
|
|
@@ -201,17 +206,22 @@ Stored coupons that stop validating are **auto-removed** and reported in `coupon
|
|
|
201
206
|
| `create` | `{ items?: [{product_id, variation_id?, quantity, attributes?}] }` | Mints and returns a new `cart_token`. Initial items go through add validation. |
|
|
202
207
|
| `get` | `{ cart_token }` | Priced view (also re-prices + merges). |
|
|
203
208
|
| `totals` | `{ cart_token }` | Alias of `get`. |
|
|
204
|
-
| `add-item` | `{ cart_token?, product_id, variation_id?, quantity?, attributes? }` | **Self-healing:** a missing, unknown or expired `cart_token` starts a fresh cart and adds the item to it
|
|
209
|
+
| `add-item` | `{ cart_token?, product_id, variation_id?, quantity?, attributes? }` | **Self-healing:** a missing, unknown or expired `cart_token` starts a fresh cart and adds the item to it ([token rules](#cart_token--the-storefronts-one-job)). A product carrying attributes requires `variation_id` (`400 variation_required`); `sold_individually` caps qty at 1; merges same product+variation. `400 <stock code>`, `404 product_not_found|variation_not_found`. |
|
|
205
210
|
| `update-item` | `{ cart_token, item_key, quantity }` | qty ≤ 0 removes the line. `404 item_not_found`, `400 <stock code>`. |
|
|
206
211
|
| `remove-item` | `{ cart_token, item_key }` | |
|
|
207
212
|
| `apply-coupon` | `{ cart_token, code }` | Full validation. `400 code_required|already_applied|<coupon code>`. |
|
|
208
213
|
| `remove-coupon` | `{ cart_token, code }` | |
|
|
209
|
-
| `set-shipping-address` | `{ cart_token, address: {country, state?, postcode?, city?} }` |
|
|
210
|
-
| `choose-shipping-method` | `{ cart_token, method_id }` | `method_id` = a shipping rate `id` from `available_shipping_methods`. `400 invalid_shipping_method` (
|
|
214
|
+
| `set-shipping-address` | `{ cart_token, address: {country, state?, postcode?, city?} }` | Returns the matched location's `available_shipping_methods` + `shipping_status`. `400 country_required`; `400 shipping_not_available` when the store doesn't ship there (error body carries the priced `cart`). [Rules ↓](#shipping-is-not-optional--read-this-before-building-checkout) |
|
|
215
|
+
| `choose-shipping-method` | `{ cart_token, method_id }` | `method_id` = a shipping rate `id` from `available_shipping_methods`. `400 invalid_shipping_method` (error body carries `available_shipping_methods`). |
|
|
211
216
|
|
|
212
217
|
Common: `400 cart_token_required`, `404 cart_not_found`, `404 cart_expired` — except `add-item`, which never fails on the token: it starts a fresh cart instead.
|
|
213
218
|
|
|
214
|
-
|
|
219
|
+
### `cart_token` — the storefront's one job
|
|
220
|
+
<a id="cart_token--the-storefronts-one-job"></a>
|
|
221
|
+
|
|
222
|
+
The token is the cart's bearer credential (treat it as a secret) and the **only** cart state a client keeps. Tokens expire (48h rolling TTL) and carts are consumed at checkout, so one cached in `localStorage` can go stale. `add-item` heals that by starting a fresh cart — but only if the client **reads `cart_token` from every cart response and re-persists it** instead of assuming the stored one survived. Do that after every cart call, and treat `404 cart_not_found|cart_expired` from the other actions as "clear the cached token and start over", not as an error to show.
|
|
223
|
+
|
|
224
|
+
**In React none of this is yours:** `StorefrontProvider` owns the token and `useCart` exposes the priced view — never read, store or send the token yourself.
|
|
215
225
|
|
|
216
226
|
### Shipping is not optional — read this before building checkout
|
|
217
227
|
<a id="shipping-is-not-optional--read-this-before-building-checkout"></a>
|
|
@@ -222,40 +232,42 @@ rather than assuming:
|
|
|
222
232
|
|
|
223
233
|
| `shipping_status` | Meaning | What the storefront must do |
|
|
224
234
|
|---|---|---|
|
|
225
|
-
| `not_needed` | every line is virtual, or shipping is
|
|
226
|
-
| `auto_selected` | exactly **one** method
|
|
235
|
+
| `not_needed` | every line is virtual, or shipping is off store-wide | show no shipping step |
|
|
236
|
+
| `auto_selected` | exactly **one** method offered, already applied | show it as the only delivery option — **don't** make the customer pick from a list of one |
|
|
227
237
|
| `chosen` | the customer's pick is still offered | show it, allow changing |
|
|
228
|
-
| `choice_required` | **several**
|
|
229
|
-
| `missing_address` |
|
|
230
|
-
| `none_available` | nothing ships to this address | say so
|
|
238
|
+
| `choice_required` | **several** offered, none chosen | render `available_shipping_methods`, call `choose-shipping-method` — checkout refuses until then |
|
|
239
|
+
| `missing_address` | options undeterminable until an address is set (several locations, none matchable yet) | collect the address, call `set-shipping-address`; cost is not calculated yet |
|
|
240
|
+
| `none_available` | nothing ships to this address | say so and block checkout (`set-shipping-address` already failed `shipping_not_available`) |
|
|
231
241
|
|
|
232
242
|
Rules the cart enforces on every price, so you get them for free:
|
|
233
243
|
|
|
234
|
-
- **A single available method is auto-selected.** One option is not a choice
|
|
235
|
-
- **A single location answers before the address.** A store with exactly **one**
|
|
236
|
-
- **An unsupported address fails on entry
|
|
237
|
-
- **A stale choice is dropped.** If the address or cart changes so the chosen method is no longer offered, it is cleared (and re-auto-selected when
|
|
238
|
-
- **Costs change with the cart
|
|
244
|
+
- **A single available method is auto-selected.** One option is not a choice, and requiring a round trip for it is how a storefront ends up never sending a method at all.
|
|
245
|
+
- **A single location answers before the address.** A store with exactly **one** location gets its rates (and, via auto-select, a cost) with no address set — the options are the same everywhere. With several, the cart reports `missing_address` until `set-shipping-address` resolves one.
|
|
246
|
+
- **An unsupported address fails on entry**, with `400 shipping_not_available` — surface it on the address form instead of letting the customer reach payment.
|
|
247
|
+
- **A stale choice is dropped.** If the address or cart changes so the chosen method is no longer offered, it is cleared (and re-auto-selected when one remains) rather than silently pricing zero shipping.
|
|
248
|
+
- **Costs change with the cart** — free-shipping thresholds and coupon-gated methods appear and disappear, so **call `set-shipping-address` as soon as an address exists and re-read the cart after every mutation**; never carry an options list you fetched earlier.
|
|
249
|
+
|
|
250
|
+
What `place-order` does with all this: a single offered method is applied for you; several offered with none chosen is `400 shipping_method_required` (with `available_shipping_methods` in the error body); a `chosen_shipping_method` not offered for the address is `400 invalid_shipping_method`, never silently swapped; nothing offered is `400 no_shipping_available`. No order is created in any of those cases, and the order that is created prices the resolved method, so its `shipping_total` always matches what the customer was shown.
|
|
251
|
+
|
|
252
|
+
**In React this is `useCheckout`**: address state debounces into `set-shipping-address`, `ShippingMethodPicker` renders the options, and `canPlaceOrder` blocks with a named blocker until a method resolves — the whole table above is the hook's behavior, not code you write. Location/zone/tax configuration: [`../references/shipping-and-tax.md`](../references/shipping-and-tax.md).
|
|
239
253
|
|
|
240
254
|
### Never advertise what the store isn't configured to do
|
|
241
255
|
|
|
242
|
-
Copy like *"Free shipping on orders over €150"* is a **claim about store configuration**, and
|
|
256
|
+
Copy like *"Free shipping on orders over €150"* is a **claim about store configuration**, and checkout honours only what is configured. Don't write such a line unless a matching rule exists — configure it (with the operator's agreement) or leave the copy out. The same goes for a discount claim (it needs a real `commerce.Coupon`, and only a code the operator gave you) and for delivery-time or returns promises the template cannot enforce.
|
|
243
257
|
|
|
244
|
-
**Locations are admin-only data
|
|
258
|
+
**Locations are admin-only data** like every entity, so a visitor cannot list them — do not try. Two honest sources:
|
|
245
259
|
|
|
246
|
-
1. **The cart
|
|
247
|
-
2. **A projection you add**,
|
|
260
|
+
1. **The cart** — the live answer for a real address: after `set-shipping-address`, `available_shipping_methods` reflects every rule checkout will honour, free shipping included. Prefer this.
|
|
261
|
+
2. **A projection you add**, for a threshold banner *before* an address exists: widen `get-store-info` (or add an action) to return the free-shipping rules, then normalize them:
|
|
248
262
|
|
|
249
263
|
```js
|
|
250
264
|
import { freeShippingRules, freeShippingThreshold, freeShippingProgress } from "@/commerce/utils";
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
const threshold = freeShippingThreshold(freeShippingRules(locations));
|
|
254
|
-
// null → the store has no free-shipping rule: show no banner, no progress bar.
|
|
265
|
+
// locations come from your own storefront action — NOT a client entity read
|
|
266
|
+
const threshold = freeShippingThreshold(freeShippingRules(locations)); // null → no rule: no banner
|
|
255
267
|
const progress = freeShippingProgress(threshold, cart.totals.subtotal - cart.totals.discount_total);
|
|
256
268
|
```
|
|
257
269
|
|
|
258
|
-
A free-shipping rule is a
|
|
270
|
+
A free-shipping rule is a rate that is free (`cost: 0`) or carries a `free_over` threshold. Thresholds are **per location** — a rule in one location must not become a site-wide banner.
|
|
259
271
|
|
|
260
272
|
---
|
|
261
273
|
|
|
@@ -272,31 +284,13 @@ Converts a cart into an order. **Payload:**
|
|
|
272
284
|
```
|
|
273
285
|
**Mandatory fields:**
|
|
274
286
|
- **Billing:** `first_name, last_name, address_1, city, country, email`.
|
|
275
|
-
- **Shipping address** and a **shipping method**
|
|
276
|
-
```js
|
|
277
|
-
// the moment the customer provides an address — this recalculates the options and cost;
|
|
278
|
-
// an unsupported address fails right here with 400 shipping_not_available
|
|
279
|
-
cart = await inv("commerce/storefront-cart", { action: "set-shipping-address", cart_token, address });
|
|
280
|
-
if (cart.shipping_status === "choice_required") {
|
|
281
|
-
// MUST render cart.available_shipping_methods and let the customer pick
|
|
282
|
-
cart = await inv("commerce/storefront-cart", { action: "choose-shipping-method", cart_token, method_id: picked.id });
|
|
283
|
-
}
|
|
284
|
-
// auto_selected / chosen: nothing to do, one option was applied for you
|
|
285
|
-
// (a single-location store may show auto_selected before any address is set)
|
|
286
|
-
```
|
|
287
|
-
Skipping this is the most common storefront bug: `place-order` answers `400 shipping_method_required` with the methods attached, and no order is created. Set the address on the cart first (`set-shipping-address`); the method can come either from the cart (`choose-shipping-method`) or straight from this call via `chosen_shipping_method`, which wins if both are present. What happens then:
|
|
288
|
-
- **one method offered** → applied automatically, nothing to send;
|
|
289
|
-
- **several offered, none chosen** → `400 shipping_method_required`, with `available_shipping_methods` in the error body so you can prompt;
|
|
290
|
-
- **`chosen_shipping_method` not offered** for this address → `400 invalid_shipping_method` (never silently swapped for another);
|
|
291
|
-
- **nothing offered** → `400 no_shipping_available`.
|
|
292
|
-
|
|
293
|
-
The order is priced with the resolved method, so its `shipping_total` always matches what the customer was shown.
|
|
287
|
+
- **Shipping address** and a **shipping method** whenever the cart has any non-virtual line and shipping is enabled. Set the address on the cart first (`set-shipping-address`); the method comes either from the cart (`choose-shipping-method`) or from this call's `chosen_shipping_method`, which wins if both are present. Skipping it is the most common storefront bug — the rules, the `shipping_status` branches and the four error codes are in [**Shipping is not optional**](#shipping-is-not-optional--read-this-before-building-checkout).
|
|
294
288
|
|
|
295
|
-
> **Payment.** `payment_method` is a
|
|
289
|
+
> **Payment.** `payment_method` is a gateway slug from [`get-store-info`](#get-store-info). The **card** gateway leaves the order `pending` and returns `payment: { status: "requires_payment", checkout_url, reference }` — redirect to `checkout_url`, then confirm on the return with [`complete-return`](#commercepayments--online-payment-for-an-order); a return URL is never proof of payment. **Every other gateway is manual** (offline, plus anything the admin added): the order goes on-hold and `payment_instructions` carries the gateway's description and bank accounts. `success_url`/`cancel_url`/`return_url` override the default return URLs.
|
|
296
290
|
|
|
297
|
-
**Steps:**
|
|
291
|
+
**Steps:** release expired holds → revalidate stock & coupons → authoritative totals → find-or-create the Customer by billing email → create a `pending` order (`order_key`, `hold_expires_at`) → reduce stock, fire `new_order` email + `order.created` webhook → mark the cart `converted` → route by gateway: **card stays pending** until the hosted page is paid, **everything else goes on-hold** with `payment_instructions`.
|
|
298
292
|
|
|
299
|
-
> **Saved profiles are only written by their owner.** The order always stores the `billing`/`shipping` it was placed with
|
|
293
|
+
> **Saved profiles are only written by their owner.** The order always stores the `billing`/`shipping` it was placed with, but the `commerce.Customer` record a storefront prefills from is refreshed only when the caller is signed in *as* the billing email. A guest checkout quoting an existing customer's address still attaches the order to them (it shows up in their `my-orders`) without changing their saved details — so no "edit my details at checkout" for guests; use `update-my-addresses` behind a login.
|
|
300
294
|
|
|
301
295
|
**Response:**
|
|
302
296
|
```json
|
|
@@ -312,31 +306,26 @@ Converts a cart into an order. **Payload:**
|
|
|
312
306
|
"order": { ...customer-safe order (internal flags/ip stripped) }
|
|
313
307
|
}
|
|
314
308
|
```
|
|
315
|
-
**Errors:** `401 login_required` (guest checkout disabled), `400 billing_incomplete`, `400 empty_cart`, `409 items_unavailable`, `409 coupon_invalid`,
|
|
309
|
+
**Errors:** `401 login_required` (guest checkout disabled), `400 billing_incomplete`, `400 empty_cart`, `409 items_unavailable`, `409 coupon_invalid`, the three shipping codes above (each carrying `available_shipping_methods`), `400 invalid_payment_method`, `503 no_card_payment_provider` (card chosen, no provider implemented — the order exists as `pending`, its stock hold expires on its own; offer another method).
|
|
316
310
|
|
|
317
311
|
### `commerce/payments` — online payment for an order
|
|
318
312
|
|
|
319
|
-
Separate function, same guest-bearer rule
|
|
313
|
+
Separate function, same guest-bearer rule: `order_id` + `order_key` (an admin may act without a key). The admin-side view of these actions is in [`api-admin.md`](./api-admin.md#commercepayments).
|
|
320
314
|
|
|
321
315
|
| Action | Payload | Returns |
|
|
322
316
|
|---|---|---|
|
|
323
|
-
| `create-link` | `{ order_id, order_key, success_url?, cancel_url?, return_url? }` | `{ url, reference }` — a fresh hosted payment page for an **unpaid** order
|
|
324
|
-
| `complete-return` | `{ order_id, order_key, payment?, return_url? }` | `{ state: "paid"\|"cancelled"\|"unpaid", paid, already_confirmed, status, order, payment_link, payment_instructions }` — the whole return flow in one call
|
|
325
|
-
| `verify` | `{ order_id, order_key }` | `{ paid, already_confirmed, status, order }` — the same confirmation without the render-ready extras. **Idempotent
|
|
317
|
+
| `create-link` | `{ order_id, order_key, success_url?, cancel_url?, return_url? }` | `{ url, reference }` — a fresh hosted payment page for an **unpaid** order whatever its current method (it is switched onto the card gateway, logged): "pay now" on an order-received page or an emailed link. `409 already_paid`, `400 card_payments_disabled`, `503 no_card_payment_provider` |
|
|
318
|
+
| `complete-return` | `{ order_id, order_key, payment?, return_url? }` | `{ state: "paid"\|"cancelled"\|"unpaid", paid, already_confirmed, status, order, payment_link, payment_instructions }` — the whole return flow in one call: confirms with the provider, progresses the order, adds a fresh `payment_link: { url, reference }` while a card order is unpaid, and re-supplies `payment_instructions: { type, description, account_details }` for unpaid **manual** orders so bank details render on every visit. `payment` is only a hint — a hand-edited `?payment=success` can never yield `paid`. **`order` carries flat totals** (`order.total`, `order.shipping_total`); there is no `order.totals` — that shape belongs to the cart view |
|
|
319
|
+
| `verify` | `{ order_id, order_key }` | `{ paid, already_confirmed, status, order }` — the same confirmation without the render-ready extras. **Idempotent**; money is asked of the provider about the reference stored on the order |
|
|
326
320
|
|
|
327
|
-
> **
|
|
321
|
+
> **The `/order-received` route is mandatory** (requirement 4): without it a paying customer lands on a 404 *and* the order is never marked paid. It only has to call `complete-return` and render its three states — `<OrderReceivedBlock/>` / `useOrderReturn` do exactly that.
|
|
328
322
|
|
|
329
|
-
Provider callbacks land on `commerce/payment-webhook` (server-to-server) — the second confirmation path, for buyers who pay and close the tab
|
|
323
|
+
Provider callbacks land on `commerce/payment-webhook` (server-to-server) — the second confirmation path, for buyers who pay and close the tab; whichever path runs second is a no-op. That function is premade (it verifies through the provider's API, so no signing secret). Wiring a provider: [`../references/online-payments.md`](../references/online-payments.md).
|
|
330
324
|
|
|
331
325
|
### `confirm-payment`
|
|
332
|
-
|
|
326
|
+
The bare transition for a card order — `complete-return` is the richer version and what `/order-received` should call. **Payload:** `{ order_id, order_key }` → `{ order, paid, already_confirmed }`.
|
|
333
327
|
|
|
334
|
-
**The request cannot make an order paid.** The `order_key` says who is asking;
|
|
335
|
-
|
|
336
|
-
`commerce/payments` `complete-return` is the richer version of this and what an `/order-received` page should call; use `confirm-payment` when you only need the transition.
|
|
337
|
-
|
|
338
|
-
**Response:** `{ "order": { ...customer-safe order }, "paid": true, "already_confirmed": false }`
|
|
339
|
-
**Errors:** `400 order_key_required|not_a_card_payment`, `404 order_not_found`, `409 invalid_status|payment_not_confirmed`.
|
|
328
|
+
**The request cannot make an order paid.** The `order_key` says who is asking; payment is confirmed against the provider using the reference stored on the order. Only then does it move `pending`/`on-hold` → `processing`, set `date_paid` and bump customer stats. No caller-supplied `transaction_id`. Idempotent. **Errors:** `400 order_key_required|not_a_card_payment`, `404 order_not_found`, `409 invalid_status|payment_not_confirmed`.
|
|
340
329
|
|
|
341
330
|
### `cancel-order`
|
|
342
331
|
Customer-initiated cancel. **Payload:** `{ order_id, order_key }`. Only `pending`/`on-hold` (restores stock). **Response:** `{ "order": { ...customer-safe order } }` · **Errors:** `400 order_key_required`, `404 order_not_found`, `409 invalid_status`.
|
|
@@ -353,28 +342,30 @@ Two access modes: **auth** (Base44 session) or **`order_key` bearer** (guest tra
|
|
|
353
342
|
| `get-order` | order_key | `{ order_id, order_key }` | `{ order: customer-safe }` |
|
|
354
343
|
| `order-notes` | order_key | `{ order_id, order_key }` | `{ notes: [{ id, note, created_date }] }` (customer notes only) |
|
|
355
344
|
| `my-downloads` | auth | — | `{ downloads: [{ permission_id, order_id, product_id, download_name, downloads_remaining, access_expires, download_count }] }` |
|
|
356
|
-
| `get-download` | auth **or** order_key | `{ permission_id, order_key? }` | `{ url, download_name, downloads_remaining }` — decrements remaining
|
|
345
|
+
| `get-download` | auth **or** order_key | `{ permission_id, order_key? }` | `{ url, download_name, downloads_remaining }` — decrements remaining, enforces `access_expires` |
|
|
357
346
|
| `update-my-addresses` | auth | `{ billing?, shipping? }` | `{ customer: { email, first_name, last_name, billing, shipping } }` |
|
|
358
347
|
| `my-reviews` | auth | — | `{ reviews: [{ id, product_id, review, rating, status, verified, created_date }] }` |
|
|
359
348
|
|
|
360
349
|
**Errors:** `401 login_required`, `400 order_key_required|permission_required|nothing_to_update`, `404 order_not_found|download_not_found`, `403 forbidden|download_limit_reached|download_expired`.
|
|
361
350
|
|
|
351
|
+
> **Public vs. private files.** A download's `file_url` is whatever the admin stored: `Core.UploadFile` gives a public URL, handed back as-is. For files that must not be guessable, upload with **`Core.UploadPrivateFile`** — it stores a `file_uri` rather than an `http` URL, and `get-download` detects the non-`http` scheme and returns a short-lived **signed URL** (`Core.CreateFileSignedUrl`, 1h) instead. Either way the entitlement check, the `downloads_remaining` decrement and `access_expires` are enforced here — never link a customer straight to a stored URL.
|
|
352
|
+
|
|
362
353
|
---
|
|
363
354
|
|
|
364
355
|
## Walkthrough A — guest checkout
|
|
365
356
|
|
|
357
|
+
Raw-call sequence for a non-React client (a React app gets this from `useCart` + `CheckoutBlock`).
|
|
358
|
+
|
|
366
359
|
```js
|
|
367
360
|
const inv = (fn, payload) => base44.functions.invoke(fn, payload).then(r => r.data.data);
|
|
368
361
|
|
|
369
|
-
// 1. Browse
|
|
362
|
+
// 1. Browse, then 2. open a cart with one item
|
|
370
363
|
const { products } = await inv("commerce/storefront-catalog", { action: "list-products", per_page: 12 });
|
|
371
|
-
|
|
372
|
-
// 2. New cart with one item
|
|
373
364
|
let cart = await inv("commerce/storefront-cart", { action: "create",
|
|
374
365
|
items: [{ product_id: products[0].id, quantity: 1 }] });
|
|
375
|
-
const token = cart.cart_token;
|
|
366
|
+
const token = cart.cart_token; // re-read it from every response
|
|
376
367
|
|
|
377
|
-
// 3. (
|
|
368
|
+
// 3. (variants) fetch the options, then add the chosen variation
|
|
378
369
|
const detail = await inv("commerce/storefront-catalog", { action: "get-product", id: products[0].id });
|
|
379
370
|
if ((detail.product.attributes ?? []).length) {
|
|
380
371
|
cart = await inv("commerce/storefront-cart", { action: "add-item",
|
|
@@ -384,10 +375,7 @@ if ((detail.product.attributes ?? []).length) {
|
|
|
384
375
|
// 4. Coupon (optional)
|
|
385
376
|
cart = await inv("commerce/storefront-cart", { action: "apply-coupon", cart_token: token, code: "welcome10" });
|
|
386
377
|
|
|
387
|
-
// 5. Shipping address
|
|
388
|
-
// recalculates the options and cost; an unsupported address fails right here
|
|
389
|
-
// (400 shipping_not_available). A single option is already applied for you;
|
|
390
|
-
// several mean the customer has to choose or checkout will refuse.
|
|
378
|
+
// 5. Shipping: address as soon as it's known, then a method if asked for one
|
|
391
379
|
cart = await inv("commerce/storefront-cart", { action: "set-shipping-address",
|
|
392
380
|
cart_token: token, address: { country: "US", state: "CA", postcode: "90210", city: "Los Angeles" } });
|
|
393
381
|
if (cart.shipping_status === "choice_required") {
|
|
@@ -396,24 +384,23 @@ if (cart.shipping_status === "choice_required") {
|
|
|
396
384
|
cart_token: token, method_id: picked.id });
|
|
397
385
|
}
|
|
398
386
|
|
|
399
|
-
// 6. Place
|
|
387
|
+
// 6. Place it (offline → on-hold; no money has arrived yet)
|
|
400
388
|
const order = await inv("commerce/storefront-checkout", { action: "place-order",
|
|
401
389
|
cart_token: token, payment_method: "offline",
|
|
402
390
|
billing: { first_name: "Ada", last_name: "Lovelace", address_1: "1 St",
|
|
403
391
|
city: "Los Angeles", state: "CA", postcode: "90210", country: "US", email: "ada@example.com" } });
|
|
404
392
|
|
|
405
|
-
// 7. Track it later without an account
|
|
393
|
+
// 7. Track it later without an account, using order_key from step 6
|
|
406
394
|
const tracked = await inv("commerce/storefront-account", { action: "get-order",
|
|
407
395
|
order_id: order.order_id, order_key: order.order_key });
|
|
408
396
|
```
|
|
409
397
|
|
|
410
398
|
## Walkthrough B — member experience
|
|
411
399
|
|
|
412
|
-
|
|
413
|
-
const inv = (fn, payload) => base44.functions.invoke(fn, payload).then(r => r.data.data);
|
|
414
|
-
// (caller is authenticated via base44 auth — the SDK sends the session automatically)
|
|
400
|
+
Same, for an authenticated caller (the SDK sends the session automatically).
|
|
415
401
|
|
|
416
|
-
|
|
402
|
+
```js
|
|
403
|
+
// Carts made while logged out merge into this one automatically on `get`/`create`.
|
|
417
404
|
let cart = await inv("commerce/storefront-cart", { action: "create", items: [{ product_id, quantity: 2 }] });
|
|
418
405
|
|
|
419
406
|
// Saved addresses speed up checkout
|
|
@@ -425,7 +412,7 @@ const order = await inv("commerce/storefront-checkout", { action: "place-order",
|
|
|
425
412
|
cart_token: cart.cart_token, payment_method: "offline", billing: { /* ... */ } });
|
|
426
413
|
// offline → on-hold; show order.payment_instructions.account_details
|
|
427
414
|
|
|
428
|
-
// Order history, downloads, reviews
|
|
415
|
+
// Order history, downloads, reviews — all auth, no order_key needed
|
|
429
416
|
const { orders } = await inv("commerce/storefront-account", { action: "my-orders", per_page: 10 });
|
|
430
417
|
const { downloads } = await inv("commerce/storefront-account", { action: "my-downloads" });
|
|
431
418
|
const file = await inv("commerce/storefront-account", { action: "get-download", permission_id: downloads[0]?.permission_id });
|