@base44/app-plugin-commerce 0.1.4 → 0.1.5
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 +4 -4
- package/base44/agents/commerce/StoreAdmin.jsonc +1 -1
- package/base44/functions/commerce/admin-products/entry.ts +11 -131
- package/base44/functions/commerce/seed-store/entry.ts +39 -167
- package/base44/functions/commerce/seed-store/seed-catalog.ts +717 -0
- package/base44/shared/commerce/catalog.ts +134 -0
- package/package.json +1 -1
- package/scripts/install.js +5 -6
- package/skills/commerce/SKILL.md +3 -2
- package/skills/commerce/docs/api-admin.md +6 -2
- package/skills/commerce/installation-guidelines.md +4 -4
- package/skills/commerce/post-installation.md +202 -78
- package/skills/commerce/references/reviews.md +20 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Post-installation
|
|
2
2
|
|
|
3
|
-
What to do right after the static installation ([`installation-guidelines.md`](./installation-guidelines.md)): embed the admin pages into the app,
|
|
3
|
+
What to do right after the static installation ([`installation-guidelines.md`](./installation-guidelines.md)): embed the admin pages into the app, seed the store's data, and build the storefront from the quick start below. Installed into the app at `skills/commerce/post-installation.md`.
|
|
4
4
|
|
|
5
5
|
---
|
|
6
6
|
|
|
@@ -17,7 +17,7 @@ The admin UI is a self-contained React app under `src/commerce/admin/`. Its only
|
|
|
17
17
|
import AdminApp from "@/commerce/admin";
|
|
18
18
|
<Route path="/store-admin/*" element={<AdminApp />} />
|
|
19
19
|
```
|
|
20
|
-
**You must also build a payment return page** (`/order-received` by default) — this is **mandatory for payment links to work at all**. 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 a thin wrapper over one backend call
|
|
20
|
+
**You must also build a payment return page** (`/order-received` by default) — this is **mandatory for payment links to work at all**. 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 a thin wrapper over one backend call — step 4 of the [storefront quick start](#3-storefront-quick-start--logic-only) below covers it completely.
|
|
21
21
|
|
|
22
22
|
**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 />} />`.
|
|
23
23
|
|
|
@@ -42,113 +42,237 @@ Even if the client guard were bypassed, layers 2 and 3 keep the store data safe.
|
|
|
42
42
|
|
|
43
43
|
---
|
|
44
44
|
|
|
45
|
-
## 2. Store data —
|
|
45
|
+
## 2. Store data — seeding
|
|
46
46
|
|
|
47
|
-
A fresh install has **no settings and no catalog**.
|
|
47
|
+
A fresh install has **no settings and no catalog**. One call to `commerce/seed-store` (admin-only, idempotent) initializes both. It always creates the business defaults — the six settings groups (`general`, `products`, `inventory`, `tax`, `shipping`, `emails`; USD, kg/cm, taxes off prices), the `offline` and `stripe` payment gateways, three tax classes and a free "Rest of the world" fallback shipping zone — and, depending on the payload, the catalog:
|
|
48
48
|
|
|
49
|
-
|
|
49
|
+
| Mode | Body | Products created |
|
|
50
|
+
|---|---|---|
|
|
51
|
+
| **Real catalog** | `{ store_name, products: [...] }` (+ optional `coupons`, `tax_rates`) | Yours — categories, attributes, variants and all, in this one call |
|
|
52
|
+
| **Demo data** | `{ store_name, with_sample_data: true }` | The template's 10 generic demo products (skipped if any product exists) |
|
|
53
|
+
| **No products** | `{ store_name }` | None — defaults only |
|
|
50
54
|
|
|
51
|
-
|
|
52
|
-
|---|---|---|---|
|
|
53
|
-
| **A — Generate the store's data** (best when you know the app's niche) | `commerce/seed-store` with `{ store_name, with_sample_data: false }`, then create real categories, attributes, products and images through the admin API (§2.1) | Store is **ready** — the admin opens straight onto a populated dashboard, no first-run screen | **Spends tokens**: you author every name, description and price, and image generation is billed per image |
|
|
54
|
-
| **B — Default demo data** | `commerce/seed-store` with `{ store_name, with_sample_data: true }` | Store is **ready**, populated with the template's 10 generic demo products | **Near-zero**: one function call, static content and stock image URLs |
|
|
55
|
-
| **C — Nothing** | don't call `seed-store` at all | The admin shows its first-run **"Set up your store"** screen; the operator initializes defaults themselves | none |
|
|
55
|
+
`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.
|
|
56
56
|
|
|
57
|
-
|
|
57
|
+
**`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" }`.
|
|
58
58
|
|
|
59
|
-
|
|
59
|
+
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.
|
|
60
60
|
|
|
61
|
-
|
|
61
|
+
### 2.1 The `products` payload
|
|
62
62
|
|
|
63
|
-
|
|
63
|
+
Reference everything by **display name** — categories, tags, 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`.
|
|
64
64
|
|
|
65
65
|
```js
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
66
|
+
await base44.functions.invoke("commerce/seed-store", {
|
|
67
|
+
store_name: "Aurora Threads",
|
|
68
|
+
products: [
|
|
69
|
+
{ // simple product
|
|
70
|
+
name: "Classic T-Shirt",
|
|
71
|
+
sku: "TEE-CLASSIC", // optional, but makes re-runs idempotent
|
|
72
|
+
regular_price: 19.99,
|
|
73
|
+
stock_quantity: 50, // implies manage_stock: true
|
|
74
|
+
categories: ["Clothing"], // get-or-create by name
|
|
75
|
+
tags: ["bestseller"],
|
|
76
|
+
images: ["https://…/tee.jpg"], // URLs or { src, alt }
|
|
77
|
+
short_description: "A soft, breathable everyday tee.",
|
|
78
|
+
description: "<p>Cut from combed cotton…</p><ul><li>100% combed cotton</li><li>Pre-shrunk</li></ul>",
|
|
79
|
+
},
|
|
80
|
+
{ // variant product — attributes declare the axes, variations the stocked combos
|
|
81
|
+
name: "Runner Sneaker",
|
|
82
|
+
sku: "SNK-RUN",
|
|
83
|
+
regular_price: 89, // inherited by variations that don't override
|
|
84
|
+
categories: ["Shoes"],
|
|
85
|
+
images: [{ src: "https://…/sneaker.jpg", alt: "Runner Sneaker, side view" }],
|
|
86
|
+
attributes: [
|
|
87
|
+
{ name: "Size", options: ["41", "42", "43"] },
|
|
88
|
+
{ name: "Color", options: ["Black", "White"] },
|
|
89
|
+
],
|
|
90
|
+
default_options: { Size: "42", Color: "Black" }, // pre-selected combination
|
|
91
|
+
variations: [ // omit entirely → all 6 combos auto-generated
|
|
92
|
+
{ options: { Size: "41", Color: "Black" }, stock_quantity: 4 },
|
|
93
|
+
{ options: { Size: "42", Color: "Black" }, stock_quantity: 6 },
|
|
94
|
+
{ options: { Size: "43", Color: "Black" }, stock_quantity: 2 },
|
|
95
|
+
{ options: { Size: "42", Color: "White" }, regular_price: 94, stock_quantity: 3,
|
|
96
|
+
image: "https://…/sneaker-white.jpg" }, // give a visual axis per-variation images
|
|
97
|
+
],
|
|
98
|
+
},
|
|
99
|
+
],
|
|
100
|
+
coupons: [{ code: "WELCOME10", discount_type: "percent", amount: 10 }], // optional
|
|
101
|
+
});
|
|
69
102
|
```
|
|
70
103
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
default_attributes: [{ attribute_id: sizeAttrId, name: "Size", option: "42" }],
|
|
91
|
-
},
|
|
92
|
-
variations: [
|
|
93
|
-
{ attributes: [{ attribute_id: sizeAttrId, name: "Size", option: "41" }],
|
|
94
|
-
sku: "SNK-41", regular_price: 89, manage_stock: "yes", stock_quantity: 4, status: "publish" },
|
|
95
|
-
{ attributes: [{ attribute_id: sizeAttrId, name: "Size", option: "42" }],
|
|
96
|
-
sku: "SNK-42", regular_price: 89, manage_stock: "yes", stock_quantity: 6, status: "publish" },
|
|
97
|
-
{ attributes: [{ attribute_id: sizeAttrId, name: "Size", option: "43" }],
|
|
98
|
-
sku: "SNK-43", regular_price: 89, manage_stock: "yes", stock_quantity: 2, status: "publish" },
|
|
99
|
-
],
|
|
100
|
-
});
|
|
101
|
-
```
|
|
104
|
+
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/tag 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.
|
|
105
|
+
|
|
106
|
+
**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`).
|
|
107
|
+
|
|
108
|
+
The response reports everything:
|
|
109
|
+
|
|
110
|
+
```jsonc
|
|
111
|
+
{ "seeded": { "settings_groups": 6, "gateways": 2, ... },
|
|
112
|
+
"catalog": {
|
|
113
|
+
"categories": { "created": 2, "reused": 0 }, "tags": { ... }, "attributes": { ... }, "terms": { ... },
|
|
114
|
+
"products_created": 2, "products_skipped": 0, "variations_created": 4,
|
|
115
|
+
"coupons": { "created": 1, "skipped": 0 }, "tax_rates": { "created": 0, "skipped": 0 },
|
|
116
|
+
"products": [
|
|
117
|
+
{ "name": "Classic T-Shirt", "id": "…", "slug": "classic-t-shirt", "sku": "TEE-CLASSIC", "variation_count": 0 },
|
|
118
|
+
{ "name": "Runner Sneaker", "id": "…", "slug": "runner-sneaker", "sku": "SNK-RUN", "variation_count": 4 }
|
|
119
|
+
]
|
|
120
|
+
},
|
|
121
|
+
"store_name": { "value": "Aurora Threads", "action": "created" } }
|
|
122
|
+
```
|
|
102
123
|
|
|
103
|
-
|
|
104
|
-
6. **Images** — every product needs at least one. 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 the `{ src, name, alt }` shape and a working Unsplash pattern). Match the image to the product — a generated catalog with mismatched or missing images reads as broken.
|
|
105
|
-
7. **Optional extras** — a launch coupon via `commerce/admin-coupons` `save`; tax rates via `commerce.TaxRate` direct CRUD if the user sells into taxed regions.
|
|
106
|
-
8. **Payments — once the store has something to sell.** Leave this until the catalog, shipping and settings are in place: connecting a payment provider is a step the *user* has to take in the platform dashboard, so asking for it first interrupts setup and leaves nothing to test the payment against. When the store is otherwise ready, raise it — don't wait to be asked. Card payments are **already implemented** (hosted payment page, payment links, refunds); they only need a provider connected. The other gateway (`offline`) is **manual** — nobody pays online and someone reconciles by hand. So tell the user card payments are ready and ask them to set up the platform's **Stripe integration** for the app (app dashboard → Integrations → Stripe — *not* an OAuth connector; there is no Stripe connector in Base44's catalog), then **redeploy the backend functions** — the platform injects `STRIPE_SECRET_KEY` at deploy time, so a provider connected after the last deploy is invisible to already-deployed functions and the store still reports *no payment provider connected*. On a hosted app, edit any file under `base44/shared/commerce/` (bump the deploy marker at the top of `payments.ts`) to redeploy every `commerce/*` function; on the CLI, `npx base44 functions deploy`. Then confirm with `commerce/admin-tools` → `payment-connector-status`. Nothing to code. The card gateway is enabled by default and stays hidden from customers until a provider is connected, so enabling it early is safe. Declining is a legitimate choice — then switch that gateway off and make the manual flow explicit in the checkout copy. Details: [`references/online-payments.md`](./references/online-payments.md).
|
|
107
|
-
9. **Report back** — tell the user what you created (counts by category, currency, enabled gateways) and that the store is ready at `/store-admin`.
|
|
124
|
+
**Images**: every product needs at least one. 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.
|
|
108
125
|
|
|
109
|
-
|
|
126
|
+
A successful response means the data is in — the catalog and settings are live exactly as reported. Write any store-specific settings into `commerce.StoreSettings` (direct CRUD, one record per `group_id` — currency and units are the usual ones; patch `values`, don't replace groups you weren't asked about).
|
|
127
|
+
|
|
128
|
+
### 2.2 Payments
|
|
129
|
+
|
|
130
|
+
Card payments are **already implemented** (hosted payment page, payment links, refunds) — make sure a payment provider (i.e. the platform's **Stripe** integration) is configured for the app, and the store takes cards; the `offline` gateway (manual reconciliation) works with nothing to configure. Provider internals, webhooks and refunds are day-2 material: [`references/online-payments.md`](./references/online-payments.md).
|
|
110
131
|
|
|
111
132
|
---
|
|
112
133
|
|
|
113
|
-
## 3.
|
|
134
|
+
## 3. Storefront quick start — logic only
|
|
135
|
+
|
|
136
|
+
No visitor UI ships; the storefront **API** is complete. The four chunks below are the whole happy path — product list → product page → cart → checkout — showing what to call, what comes back, and what to carry into the next step. 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.
|
|
114
137
|
|
|
115
|
-
|
|
138
|
+
Every function returns the envelope `{ success, data }`; with the SDK the payload is `res.data.data`, so define one helper and use it everywhere:
|
|
116
139
|
|
|
117
|
-
```
|
|
118
|
-
|
|
140
|
+
```js
|
|
141
|
+
const inv = (fn, payload) => base44.functions.invoke(fn, payload).then((r) => r.data.data);
|
|
142
|
+
```
|
|
119
143
|
|
|
120
|
-
|
|
144
|
+
The cart is identified by a **`cart_token`** the backend mints — persist it in `localStorage` and send it with every cart/checkout call. Rolling 48 h expiry, refreshed on every touch.
|
|
121
145
|
|
|
122
|
-
|
|
146
|
+
### 3.1 Product list
|
|
123
147
|
|
|
124
|
-
|
|
148
|
+
```js
|
|
149
|
+
const info = await inv("commerce/storefront-catalog", { action: "get-store-info" });
|
|
150
|
+
// info.settings → { store_name, currency, currency_position, num_decimals, … } — format money with these
|
|
151
|
+
// info.payment_gateways → [{ slug, title, description, online }] — you'll need this at checkout
|
|
152
|
+
// info.countries / info.currencies → static tables for address forms and money display
|
|
153
|
+
|
|
154
|
+
const { products, page, per_page, has_next } = await inv("commerce/storefront-catalog", {
|
|
155
|
+
action: "list-products",
|
|
156
|
+
page: 1, per_page: 12, // optional: search, category_id, tag_id, featured, on_sale,
|
|
157
|
+
sort: "-created_date", // min_price, max_price, in_stock_only
|
|
158
|
+
}); // sort: -created_date | name | price | -price | popularity | rating
|
|
125
159
|
```
|
|
126
160
|
|
|
127
|
-
|
|
161
|
+
Each row is a full product record — for a card use `name`, `images[0]?.src`, `price`, `regular_price`, `on_sale`, `short_description`, `stock_status`, `average_rating`/`rating_count` (stars cost no extra call) and `tags` (`[{ id, name }]`, may be absent). **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 `{ action: "list-categories" }` (a tree via `parent_id`).
|
|
162
|
+
|
|
163
|
+
**Carry forward:** each card links to the product page by **`slug`**.
|
|
164
|
+
|
|
165
|
+
### 3.2 Product page — variant selection included
|
|
166
|
+
|
|
167
|
+
```js
|
|
168
|
+
const { product, variations, categories, tags, reviews } =
|
|
169
|
+
await inv("commerce/storefront-catalog", { action: "get-product", slug }); // or { id }
|
|
170
|
+
|
|
171
|
+
// One selector PER product.attributes[] entry — never a flat list of variations.
|
|
172
|
+
import { defaultSelection, selectOption, resolveSelection } from "@/commerce/utils";
|
|
128
173
|
|
|
129
|
-
|
|
174
|
+
let selection = defaultSelection(product, variations); // merchant defaults + single-option axes
|
|
175
|
+
// on user pick: selection = selectOption(product, variations, selection, axisKey, option);
|
|
130
176
|
|
|
131
|
-
|
|
177
|
+
const view = resolveSelection(product, variations, selection);
|
|
178
|
+
// view.axes → [{ key, name, options }] — render one control each
|
|
179
|
+
// view.availability → { [axisKey]: { [option]: "available" | "out_of_stock" | "unavailable" } }
|
|
180
|
+
// view.display → { price, regular_price, on_sale, sku, stock_status, image, … } for the selection
|
|
181
|
+
// view.purchasable → gate the Add-to-cart button on this
|
|
182
|
+
// view.addToCart → { product_id, variation_id } — null until the selection resolves
|
|
183
|
+
```
|
|
132
184
|
|
|
133
|
-
|
|
185
|
+
Add to cart — **create** mints the `cart_token` on first add; every later call reuses it:
|
|
134
186
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
187
|
+
```js
|
|
188
|
+
let cart_token = localStorage.getItem("cart_token");
|
|
189
|
+
if (!cart_token) {
|
|
190
|
+
const cart = await inv("commerce/storefront-cart", { action: "create" });
|
|
191
|
+
cart_token = cart.cart_token;
|
|
192
|
+
localStorage.setItem("cart_token", cart_token);
|
|
193
|
+
}
|
|
194
|
+
await inv("commerce/storefront-cart", { action: "add-item", cart_token, ...view.addToCart, quantity: 1 });
|
|
195
|
+
```
|
|
140
196
|
|
|
141
|
-
|
|
197
|
+
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. Show `reviews` (`{ items, has_next, average_rating, rating_count }`) and the `upsells`/`cross_sells` summaries the same response carries.
|
|
142
198
|
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
199
|
+
**Carry forward:** the **`cart_token`**.
|
|
200
|
+
|
|
201
|
+
### 3.3 Cart
|
|
202
|
+
|
|
203
|
+
**Every cart action returns the same full priced view**, so re-render from whatever the last call returned — no separate refresh:
|
|
204
|
+
|
|
205
|
+
```js
|
|
206
|
+
let cart = await inv("commerce/storefront-cart", { action: "get", cart_token });
|
|
207
|
+
// cart.items → [{ item_key, name, image, quantity, price, subtotal, total, attributes, purchasable }]
|
|
208
|
+
// cart.totals → { subtotal, discount_total, shipping_total, cart_tax, total_tax, total, … }
|
|
209
|
+
// cart.coupon_notices / cart.removed_items → tell the customer what auto-dropped and why
|
|
210
|
+
|
|
211
|
+
cart = await inv("commerce/storefront-cart", { action: "update-item", cart_token, item_key, quantity }); // ≤0 removes
|
|
212
|
+
cart = await inv("commerce/storefront-cart", { action: "remove-item", cart_token, item_key });
|
|
213
|
+
cart = await inv("commerce/storefront-cart", { action: "apply-coupon", cart_token, code });
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
Shipping is chosen **on the cart, before place-order** — this is the step storefronts most often skip, and `place-order` refuses without it (`400 shipping_method_required`):
|
|
217
|
+
|
|
218
|
+
```js
|
|
219
|
+
cart = await inv("commerce/storefront-cart", { action: "set-shipping-address", cart_token,
|
|
220
|
+
address: { country, state, postcode, city } });
|
|
221
|
+
switch (cart.shipping_status) {
|
|
222
|
+
case "auto_selected": // one option — already applied, nothing to do
|
|
223
|
+
case "chosen": break; // customer's earlier choice still valid
|
|
224
|
+
case "choice_required": // MUST render cart.available_shipping_methods [{ id, title, cost }]
|
|
225
|
+
cart = await inv("commerce/storefront-cart", { action: "choose-shipping-method", cart_token,
|
|
226
|
+
method_id: picked.id }); // the entry's id, not its method_id type
|
|
227
|
+
break;
|
|
228
|
+
case "none_available": /* the store doesn't ship there — say so, don't place the order */ break;
|
|
229
|
+
case "not_needed": break; // fully virtual cart
|
|
230
|
+
}
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
**Carry forward:** the **`cart_token`** (address and method choice live on the cart).
|
|
234
|
+
|
|
235
|
+
### 3.4 Checkout & order-received
|
|
236
|
+
|
|
237
|
+
Payment methods are `info.payment_gateways` from step 1 — render them as they come (`online: true` marks the card/redirect gateway; `offline` is manual reconciliation). Make sure a payment provider (i.e. Stripe) is configured for the app. Then one call places the order:
|
|
238
|
+
|
|
239
|
+
```js
|
|
240
|
+
// card payments ride the app's Stripe integration — setup & provider details:
|
|
241
|
+
// skills/commerce/references/online-payments.md
|
|
242
|
+
const res = await inv("commerce/storefront-checkout", {
|
|
243
|
+
action: "place-order", cart_token,
|
|
244
|
+
payment_method: gateway.slug, // from info.payment_gateways
|
|
245
|
+
billing: { first_name, last_name, address_1, city, country, email }, // the required set; phone, state, postcode optional
|
|
246
|
+
// shipping: { … } if it differs from billing; customer_note?; return_url: window.location.origin
|
|
247
|
+
});
|
|
248
|
+
// res → { order_id, order_number, order_key, status, totals, order,
|
|
249
|
+
// payment_instructions, // offline: { description, account_details } — render them
|
|
250
|
+
// payment } // online: { status: "requires_payment", checkout_url, … } | null
|
|
251
|
+
|
|
252
|
+
if (res.payment?.status === "requires_payment") window.location.href = res.payment.checkout_url;
|
|
253
|
+
else showConfirmation(res); // offline order placed — show payment_instructions
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
Every payment link returns to **`/order-received`** — the page from §1 step 3. It is one call, idempotent, safe on every visit:
|
|
257
|
+
|
|
258
|
+
```js
|
|
259
|
+
// GET /order-received?order_id=…&order_key=…&payment=success|cancel
|
|
260
|
+
const params = new URLSearchParams(window.location.search);
|
|
261
|
+
const { state, order, payment_link } = await inv("commerce/payments", {
|
|
262
|
+
action: "complete-return",
|
|
263
|
+
order_id: params.get("order_id"), order_key: params.get("order_key"),
|
|
264
|
+
payment: params.get("payment"), // only a hint — the server verifies with the provider
|
|
265
|
+
return_url: window.location.origin,
|
|
266
|
+
});
|
|
267
|
+
// state === "paid" → thank-you + order summary (order is now marked paid)
|
|
268
|
+
// state === "unpaid" → offer payment_link.url to try again
|
|
269
|
+
// state === "cancelled" → payment was cancelled — offer payment_link.url or support
|
|
270
|
+
```
|
|
147
271
|
|
|
148
|
-
|
|
272
|
+
**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).
|
|
149
273
|
|
|
150
274
|
---
|
|
151
275
|
|
|
152
|
-
##
|
|
276
|
+
## 4. Next
|
|
153
277
|
|
|
154
|
-
Continue with the commerce skill — [`skills/commerce/SKILL.md`](./SKILL.md) — for day-2 work: UI changes, storefront
|
|
278
|
+
Continue with the commerce skill — [`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), Stripe wiring, scheduled maintenance, emails, webhooks, and operational limits.
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# Reviews
|
|
2
|
+
|
|
3
|
+
**Unless you are sure the store doesn't want reviews, implement them: the backend already exists.** Skipping them is the common miss, and it costs the store the social proof most shoppers look for before buying.
|
|
4
|
+
|
|
5
|
+
What ships, with nothing to write on the backend:
|
|
6
|
+
|
|
7
|
+
- `commerce/storefront-catalog` `get-product` returns **paginated `reviews`** plus `average_rating` and `rating_count`, and a `verified` flag per review;
|
|
8
|
+
- `list-products` rows carry `average_rating`/`rating_count`, so **stars on cards cost no extra call**;
|
|
9
|
+
- `commerce/storefront-catalog` `submit-review` accepts `{ product_id, reviewer?, review, rating }` from a **signed-in** customer — the email comes from the session, never the payload;
|
|
10
|
+
- `commerce/storefront-account` `my-reviews` lists a customer's own;
|
|
11
|
+
- moderation is already in the admin (Products → Reviews), and `commerce/admin-reviews` recalculates the product's rating on every status change.
|
|
12
|
+
|
|
13
|
+
So the work is UI: stars on cards and the product page, a reviews list, and a submit form. Behaviors to respect rather than fight:
|
|
14
|
+
|
|
15
|
+
- anonymous callers get **`401 login_required`** — put the form behind a login prompt instead of hiding reviews from guests;
|
|
16
|
+
- a new review is **`hold`** unless `auto_approve_reviews`, so tell the submitter it's awaiting approval rather than showing it as live;
|
|
17
|
+
- `only_verified_reviews` requires the customer to have a `processing`/`completed` order with that product (**`403 verified_only`**), and `review_rating_required` makes the rating mandatory (**`400 rating_required`**);
|
|
18
|
+
- the store can switch reviews off store-wide (`products.enable_reviews`) — read it and hide the UI when false.
|
|
19
|
+
|
|
20
|
+
Shapes and error codes: [`docs/api-storefront.md`](../docs/api-storefront.md#submit-review--auth). Where ratings belong per view: [`references/product-render.md`](./product-render.md).
|