@base44/app-plugin-commerce 0.1.4 → 0.1.6

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.
@@ -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, decide with the user how the store's data gets created, and register the template + skill in `AGENTS.md`. Installed into the app at `skills/commerce/post-installation.md`.
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, `commerce/payments` `complete-return`, which confirms the payment and returns `{ state, order, payment_link }` for the paid / unpaid / cancelled cases — the contract and rules are in [`references/online-payments.md`](./references/online-payments.md).
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,251 @@ 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 — ask the user before seeding anything
45
+ ## 2. Store data — seeding
46
46
 
47
- A fresh install has **no settings and no catalog**. Don't decide this silently and don't default to the demo catalog: **ask the user which of these three they want**, then do exactly that.
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. Pass **`currency`** (an ISO code, e.g. `"EUR"`) to set the store currency instead of the USD default — an explicit currency always wins, on a first seed and a re-run alike, and brings the currency's standard decimal count with it.
48
48
 
49
- > Suggested wording: *"Before you open the admin — should I generate a starter catalog for your store (products, descriptions, prices, images, categories, variants) based on what you're selling, seed the template's generic demo catalog so you can click around, or leave the store empty and set it up yourself? Generating one costs tokens — I write every product's copy and generate an image each — while the demo catalog is static and effectively free."*
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
- | Option | What you do | Result | Cost |
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
- Say the cost part out loud when you ask — it's the main trade-off between A and B, and the user is the one paying for it. A generated catalog of a dozen products with images is a real chunk of generation (text for every product plus one image call each); the static demo seed is a single backend call with no generation at all. If the user wants a populated store only to click around the admin, B is the better deal; A is worth it when the catalog is meant to survive into the real store.
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
- **Both A and B must pass `store_name`** — the app's name as the platform shows it. It is required on a first seed (**400** `store_name_required` without it), because nothing server-side can read the app's name and an unnamed store sends subjects like `[]: New order #1002`. It lands in `emails.store_name` — that is the only place it lives, and it drives email subjects, headings and the sender name. It is also published to storefronts as `settings.store_name` by `get-store-info`, so it is the public shop name too. On an already-seeded store the call fills a blank name and never overwrites one the merchant chose; the response reports which happened as `store_name: { value, action }` (`created` · `filled` · `unchanged` · `kept_existing`). It is the sender name too — there is no separate from-name; leave `store_name` blank and Base44 sends as the app's name.
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
- Both A and B call `seed-store`, which creates the business defaults — the six settings groups (`general`, `products`, `inventory`, `tax`, `shipping`, `emails`), payment gateways, tax classes and a fallback shipping zone. **Creating the `general` settings group is what marks the business as ready:** the admin's `SettingsProvider` (`src/commerce/admin/context/SettingsContext.jsx`) renders the first-run setup screen only while that group is missing. So under A and B the modal never appears — do **not** add a separate "ready" flag or suppress the screen in code; leave it working for option C, which is the only case it's meant for.
61
+ ### 2.1 The `products` payload
62
62
 
63
- Confirm afterwards:
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
- const { data } = (await base44.functions.invoke("commerce/admin-tools", { action: "status" })).data;
67
- // → seeded: true (i.e. settings_groups includes "general"),
68
- // settings_groups: ["general", "products", ...], counts: { "commerce.Product": n, ... }
66
+ await base44.functions.invoke("commerce/seed-store", {
67
+ store_name: "Aurora Threads",
68
+ currency: "EUR", // optional — defaults to USD
69
+ products: [
70
+ { // simple product
71
+ name: "Classic T-Shirt",
72
+ sku: "TEE-CLASSIC", // optional, but makes re-runs idempotent
73
+ regular_price: 19.99,
74
+ stock_quantity: 50, // implies manage_stock: true
75
+ categories: ["Clothing"], // get-or-create by name
76
+ tags: ["bestseller"],
77
+ images: ["https://…/tee.jpg"], // URLs or { src, alt }
78
+ short_description: "A soft, breathable everyday tee.",
79
+ description: "<p>Cut from combed cotton…</p><ul><li>100% combed cotton</li><li>Pre-shrunk</li></ul>",
80
+ },
81
+ { // variant product — attributes declare the axes, variations the stocked combos
82
+ name: "Runner Sneaker",
83
+ sku: "SNK-RUN",
84
+ regular_price: 89, // inherited by variations that don't override
85
+ categories: ["Shoes"],
86
+ images: [{ src: "https://…/sneaker.jpg", alt: "Runner Sneaker, side view" }],
87
+ attributes: [
88
+ { name: "Size", options: ["41", "42", "43"] },
89
+ { name: "Color", options: ["Black", "White"] },
90
+ ],
91
+ default_options: { Size: "42", Color: "Black" }, // pre-selected combination
92
+ variations: [ // omit entirely → all 6 combos auto-generated
93
+ { options: { Size: "41", Color: "Black" }, stock_quantity: 4 },
94
+ { options: { Size: "42", Color: "Black" }, stock_quantity: 6 },
95
+ { options: { Size: "43", Color: "Black" }, stock_quantity: 2 },
96
+ { options: { Size: "42", Color: "White" }, regular_price: 94, stock_quantity: 3,
97
+ image: "https://…/sneaker-white.jpg" }, // give a visual axis per-variation images
98
+ ],
99
+ },
100
+ ],
101
+ coupons: [{ code: "WELCOME10", discount_type: "percent", amount: 10 }], // optional
102
+ });
69
103
  ```
70
104
 
71
- ### 2.1 Generating the store's data (option A)
72
-
73
- The goal is a catalog that looks like **this** business, not a demo. Order of operations:
74
-
75
- 1. **Defaults first** — `commerce/seed-store` with `{ with_sample_data: false, store_name: "<the app's name>" }`. `store_name` is **required** on a first seed (else **400** `store_name_required`) — use the app's name as the platform shows it (ask the user if unsure — `base44/config.jsonc` → `name` can be stale, e.g. `New App` for an app called `Canvas`), since the function cannot read it. It lands in `emails.store_name`, which is both the name in email subjects and the sender name. It's idempotent and runs a canary schema check; on `422 schema_incompatible` fix the reported entities before continuing. Never follow it with `with_sample_data: true` — you'd mix demo products into a real catalog (the sample seeder is skipped once any product exists, so this usually silently does nothing, which is worse).
76
- 2. **Business settings** — ask for, or infer from the app, the store name (in the `emails` group), currency and units, and write them into the relevant `commerce.StoreSettings` groups (direct CRUD, one record per `group_id`; patch `values`, don't replace groups you weren't asked about).
77
- 3. **Taxonomy** — create these through `commerce/admin-products` **`save-term`** (`taxonomy: "category" | "tag" | "attribute" | "attribute-term"`), not direct entity CRUD: `save-term` is what derives a category's unique slug and an attribute's unique `code` (the key a storefront filter URL carries), makes tag creation get-or-create by name, and initializes `count`. A direct create leaves an attribute with a blank `code`. Nest categories via `parent_id`; for variants create the attribute (`{name, code, order}`) then its values (`{attribute_id, name, order}`) for e.g. Size and Color. Attributes are shared: create Size once and reuse it across shoes and shirts, picking per product which of its values apply.
78
- 4. **Products** — `commerce/admin-products` `save` (or `batch`, ≤100 items) per product. Write real merchandising copy: a distinct `name`, a one-line `short_description`, an HTML `description` (paragraph + `<ul>` of specifics), plus `sku`, `regular_price`, optional `sale_price`, `manage_stock: true` + `stock_quantity` where stock is tracked, `category_ids` and `images[]`. **On a product that will carry attributes, price the variations and leave the parent's price alone** — `regular_price`, `price` and `on_sale` are rolled up from the cheapest publishable variant on every save, so anything you set there is discarded. (`manage_stock` is a boolean on Product but `"yes"|"no"|"parent"` on ProductVariation.)
79
- 5. **Variants — an attribute in the attributes list does NOTHING on its own.** This is the step agents get wrong: they create a shared `Size` attribute with values, and every product stays a single item. There is **no product type and no per-attribute flag** — a product sells variants because it carries attributes — so listing the attribute **on the product**, with the values that product comes in, is what declares it. Two things must be true, and both happen in the *same* `commerce/admin-products` `save` call:
80
- 1. the attribute is listed **on the product** with its `options` — not just in `commerce.ProductAttribute`;
81
- 2. a `variations` array is sent with one entry per combination you actually stock (plus `default_attributes` for the combination to pre-select).
82
-
83
- ```js
84
- await call("admin-products", "save", {
85
- product: {
86
- name: "Runner Sneaker", status: "publish", sku: "SNK",
87
- category_ids: [shoesId], images: [{ src: "…" }],
88
- attributes: [{ attribute_id: sizeAttrId, name: "Size", position: 0,
89
- options: ["41", "42", "43"] }],
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
- ```
105
+ 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.
106
+
107
+ **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`).
108
+
109
+ The response reports everything:
110
+
111
+ ```jsonc
112
+ { "seeded": { "settings_groups": 6, "gateways": 2, ... },
113
+ "catalog": {
114
+ "categories": { "created": 2, "reused": 0 }, "tags": { ... }, "attributes": { ... }, "terms": { ... },
115
+ "products_created": 2, "products_skipped": 0, "variations_created": 4,
116
+ "coupons": { "created": 1, "skipped": 0 }, "tax_rates": { "created": 0, "skipped": 0 },
117
+ "products": [
118
+ { "name": "Classic T-Shirt", "id": "…", "slug": "classic-t-shirt", "sku": "TEE-CLASSIC", "variation_count": 0 },
119
+ { "name": "Runner Sneaker", "id": "…", "slug": "runner-sneaker", "sku": "SNK-RUN", "variation_count": 4 }
120
+ ]
121
+ },
122
+ "store_name": { "value": "Aurora Threads", "action": "created" },
123
+ "currency": { "value": "EUR", "action": "created" } } // "updated" | "unchanged" on re-runs; null when not passed
124
+ ```
125
+
126
+ **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.
102
127
 
103
- Give a visual axis (Color) a per-variation `image`. `manage_stock` is `"yes"|"no"|"parent"` on a variation but a boolean on the product. **Then check your work**: re-read the product and assert `variations.length > 0` — a product with attributes and no variations cannot be added to a cart at all (`400 variation_required`), and the storefront has nothing to show. Descriptive properties (Material, Care) are **not** attributes: put them in `meta_data`, which the admin surfaces as *modifiers*.
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`.
128
+ 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).
108
129
 
109
- Keep the generated catalog small unless asked — roughly 8–15 products across 3–5 categories, with at least one variable product if the business plausibly has options. Cost scales with the catalog: every product is generated copy plus at least one generated image, so confirm before going past that range rather than quietly producing a 50-product store. Products are created as `status: "publish"` only if you set it; leave them `draft` if the user wants to review before going live.
130
+ ### 2.2 Payments
131
+
132
+ Card payments are **already implemented** (hosted payment page, payment links, refunds). **Get the payment provider connected at the beginning of the implementation** — have the platform's **Stripe** integration configured for the app while you build, so it is already connected by the time you create the checkout and a test order can prove the whole path. 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
133
 
111
134
  ---
112
135
 
113
- ## 3. Register the template and skill in `AGENTS.md`
136
+ ## 3. Storefront quick start — logic only
137
+
138
+ 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
139
 
115
- So future agent sessions know the store exists and read the skill before touching it, **edit the app's existing `AGENTS.md` in place** — add the template to an **Installed templates** section and point at the skill from a **My Skills** section. Do **not** replace or overwrite the file: keep everything already in it and only append these two entries, creating either section only if it doesn't already exist (if a section is already there, add to it rather than duplicating it). The snippet below shows the entries to merge in, not a full-file replacement:
140
+ 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
141
 
117
- ```md
118
- ## Installed templates
142
+ ```js
143
+ const inv = (fn, payload) => base44.functions.invoke(fn, payload).then((r) => r.data.data);
144
+ ```
119
145
 
120
- - commerce
146
+ 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
147
 
122
- ## My Skills
148
+ ### 3.1 Product list
123
149
 
124
- - `skills/commerce/SKILL.md` — Base44 Commerce template: 24 `commerce.*` entities, 16 `commerce/*` backend functions (storefront + admin APIs + online payments), the shared commerce engine under `base44/shared/commerce/`, the Store Management UI mounted at `/store-admin`, and the `commerce/StoreAdmin` agent (admin copilot bot in the admin sidebar). Read before working on store features — catalog, cart, checkout, orders, payments, emails, webhooks, or the admin UI.
150
+ ```js
151
+ const info = await inv("commerce/storefront-catalog", { action: "get-store-info" });
152
+ // info.settings → { store_name, currency, currency_position, num_decimals, … } — format money with these
153
+ // info.payment_gateways → [{ slug, title, description, online }] — you'll need this at checkout
154
+ // info.countries / info.currencies → static tables for address forms and money display
155
+
156
+ const { products, page, per_page, has_next } = await inv("commerce/storefront-catalog", {
157
+ action: "list-products",
158
+ page: 1, per_page: 12, // optional: search, category_id, tag_id, featured, on_sale,
159
+ sort: "-created_date", // min_price, max_price, in_stock_only
160
+ }); // sort: -created_date | name | price | -price | popularity | rating
125
161
  ```
126
162
 
127
- ---
163
+ 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`).
164
+
165
+ **Carry forward:** each card links to the product page by **`slug`**.
166
+
167
+ ### 3.2 Product page — variant selection included
168
+
169
+ ```js
170
+ const { product, variations, categories, tags, reviews } =
171
+ await inv("commerce/storefront-catalog", { action: "get-product", slug }); // or { id }
172
+
173
+ // One selector PER product.attributes[] entry — never a flat list of variations.
174
+ import { defaultSelection, selectOption, resolveSelection } from "@/commerce/utils";
175
+
176
+ let selection = defaultSelection(product, variations); // merchant defaults + single-option axes
177
+ // on user pick: selection = selectOption(product, variations, selection, axisKey, option);
178
+
179
+ const view = resolveSelection(product, variations, selection);
180
+ // view.axes → [{ key, name, options }] — render one control each
181
+ // view.availability → { [axisKey]: { [option]: "available" | "out_of_stock" | "unavailable" } }
182
+ // view.display → { price, regular_price, on_sale, sku, stock_status, image, … } for the selection
183
+ // view.purchasable → gate the Add-to-cart button on this
184
+ // view.addToCart → { product_id, variation_id } — null until the selection resolves
185
+ ```
186
+
187
+ Add to cart — **create** mints the `cart_token` on first add; every later call reuses it:
188
+
189
+ ```js
190
+ let cart_token = localStorage.getItem("cart_token");
191
+ if (!cart_token) {
192
+ const cart = await inv("commerce/storefront-cart", { action: "create" });
193
+ cart_token = cart.cart_token;
194
+ localStorage.setItem("cart_token", cart_token);
195
+ }
196
+ await inv("commerce/storefront-cart", { action: "add-item", cart_token, ...view.addToCart, quantity: 1 });
197
+ ```
198
+
199
+ 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.
200
+
201
+ **Carry forward:** the **`cart_token`**.
202
+
203
+ ### 3.3 Cart
204
+
205
+ **Every cart action returns the same full priced view**, so re-render from whatever the last call returned — no separate refresh:
206
+
207
+ ```js
208
+ let cart = await inv("commerce/storefront-cart", { action: "get", cart_token });
209
+ // cart.items → [{ item_key, name, image, quantity, price, subtotal, total, attributes, purchasable }]
210
+ // cart.totals → { subtotal, discount_total, shipping_total, cart_tax, total_tax, total, … }
211
+ // cart.coupon_notices / cart.removed_items → tell the customer what auto-dropped and why
212
+
213
+ cart = await inv("commerce/storefront-cart", { action: "update-item", cart_token, item_key, quantity }); // ≤0 removes
214
+ cart = await inv("commerce/storefront-cart", { action: "remove-item", cart_token, item_key });
215
+ cart = await inv("commerce/storefront-cart", { action: "apply-coupon", cart_token, code });
216
+ ```
217
+
218
+ 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`):
219
+
220
+ ```js
221
+ cart = await inv("commerce/storefront-cart", { action: "set-shipping-address", cart_token,
222
+ address: { country, state, postcode, city } });
223
+ switch (cart.shipping_status) {
224
+ case "auto_selected": break; // only ONE option existed — the backend already applied it;
225
+ // no picker needed, just display cart.chosen_shipping_method
226
+ case "chosen": break; // customer's earlier choice still valid
227
+ case "choice_required": // several options — MUST render cart.available_shipping_methods
228
+ // [{ id, title, cost }] as a picker, then send the customer's pick:
229
+ cart = await inv("commerce/storefront-cart", { action: "choose-shipping-method", cart_token,
230
+ method_id: picked.id }); // the entry's id, not its method_id type
231
+ break;
232
+ case "none_available": /* the store doesn't ship there — say so, don't place the order */ break;
233
+ case "not_needed": break; // fully virtual cart
234
+ }
235
+ ```
236
+
237
+ **Carry forward:** the **`cart_token`** (address and method choice live on the cart).
128
238
 
129
- ## 4. Reviews — build them unless the user rules it out
239
+ ### 3.4 Checkout & order-received
130
240
 
131
- **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.
241
+ The checkout page renders **two sets of options that are store data, never hardcoded**: the shipping methods (already resolved on the cart in step 3 — `place-order` refuses with `400 shipping_method_required` until `shipping_status` is `chosen`/`auto_selected`/`not_needed`) and the payment methods, which come from `info.payment_gateways` (step 1) — already filtered to what can actually take payment right now, so every entry you render is payable:
242
+
243
+ ```js
244
+ // card payments ride the app's Stripe integration — setup & provider details:
245
+ // skills/commerce/references/online-payments.md
246
+ const gateways = info.payment_gateways; // [{ slug, title, description, online }] — admin-owned data
247
+ // several → render a picker using the admin's title/description as the labels
248
+ // exactly ONE → no picker: use it directly, but still show its title so the customer knows how they'll pay
249
+ // none → checkout cannot complete — say so instead of rendering a dead button
250
+ const payment_method = gateways.length === 1 ? gateways[0].slug : picked.slug; // never a hardcoded "stripe"
251
+ ```
132
252
 
133
- What ships, with nothing to write on the backend:
253
+ `online: true` marks the card/redirect gateway; `offline` is manual reconciliation. Then one call places the order:
134
254
 
135
- - `commerce/storefront-catalog` `get-product` returns **paginated `reviews`** plus `average_rating` and `rating_count`, and a `verified` flag per review;
136
- - `list-products` rows carry `average_rating`/`rating_count`, so **stars on cards cost no extra call**;
137
- - `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;
138
- - `commerce/storefront-account` `my-reviews` lists a customer's own;
139
- - moderation is already in the admin (Products → Reviews), and `commerce/admin-reviews` recalculates the product's rating on every status change.
255
+ ```js
256
+ const res = await inv("commerce/storefront-checkout", {
257
+ action: "place-order", cart_token,
258
+ payment_method, // the slug chosen above
259
+ billing: { first_name, last_name, address_1, city, country, email }, // the required set; phone, state, postcode optional
260
+ // shipping: { … } if it differs from billing; customer_note?; return_url: window.location.origin
261
+ });
262
+ // res → { order_id, order_number, order_key, status, totals, order,
263
+ // payment_instructions, // offline: { description, account_details } — render them
264
+ // payment } // online: { status: "requires_payment", checkout_url, … } | null
265
+
266
+ if (res.payment?.status === "requires_payment") window.location.href = res.payment.checkout_url;
267
+ else showConfirmation(res); // offline order placed — show payment_instructions
268
+ ```
140
269
 
141
- 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:
270
+ Every payment link returns to **`/order-received`** — the page from §1 step 3. It is one call, idempotent, safe on every visit:
142
271
 
143
- - anonymous callers get **`401 login_required`** — put the form behind a login prompt instead of hiding reviews from guests;
144
- - a new review is **`hold`** unless `auto_approve_reviews`, so tell the submitter it's awaiting approval rather than showing it as live;
145
- - `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`**);
146
- - the store can switch reviews off store-wide (`products.enable_reviews`) — read it and hide the UI when false.
272
+ ```js
273
+ // GET /order-received?order_id=…&order_key=…&payment=success|cancel
274
+ const params = new URLSearchParams(window.location.search);
275
+ const { state, order, payment_link } = await inv("commerce/payments", {
276
+ action: "complete-return",
277
+ order_id: params.get("order_id"), order_key: params.get("order_key"),
278
+ payment: params.get("payment"), // only a hint — the server verifies with the provider
279
+ return_url: window.location.origin,
280
+ });
281
+ // state === "paid" → thank-you + order summary (order is now marked paid)
282
+ // state === "unpaid" → offer payment_link.url to try again
283
+ // state === "cancelled" → payment was cancelled — offer payment_link.url or support
284
+ ```
147
285
 
148
- Shapes and error codes: [`docs/api-storefront.md`](./docs/api-storefront.md#submit-review--auth). Where ratings belong per view: [`references/product-render.md`](./references/product-render.md).
286
+ **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
287
 
150
288
  ---
151
289
 
152
- ## 5. Next
290
+ ## 4. Next
153
291
 
154
- Continue with the commerce skill — [`skills/commerce/SKILL.md`](./SKILL.md) — for day-2 work: UI changes, storefront building (start with [`references/product-render.md`](./references/product-render.md) for what to render, then [`references/storefront-product-page.md`](./references/storefront-product-page.md) for variants), Stripe wiring, scheduled maintenance, emails, webhooks, and operational limits.
292
+ 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.
@@ -10,38 +10,13 @@
10
10
 
11
11
  ---
12
12
 
13
- ## 1. What to do when building a store (strong recommendation, not a rule)
13
+ ## 1. Connecting a provider
14
14
 
15
- A store that can't take a card is usually not what the user wants: the gateway that works with no provider at all (`offline`) is **manual** — the customer leaves without paying and someone reconciles by hand. So **recommend online payment clearly** — but **at the right moment, which is not first.**
15
+ **Connect the Stripe integration for the app** — early, ideally at the beginning of the implementation, so it is already connected by the time the checkout exists and a test order can prove the whole path. The platform holds the keys and injects them into backend functions as **`STRIPE_SECRET_KEY`** / **`STRIPE_PUBLISHABLE_KEY`**, which is where `stripe.ts` reads the credential from — never put keys in the store's data, in code, or in a settings field.
16
16
 
17
- **Sequence it after the store exists.** Connecting a provider is a step only the *user* can take, in the platform dashboard: asking for it up front stops your work dead on someone else's action, and even once done there is nothing to verify against — no product to buy, no shipping method, no order. Set the store up first (settings → catalog → shipping), *then* raise payments, as the last thing before the store is usable or before you build checkout. The card gateway ships enabled and stays hidden from customers until a provider is connected, so nothing is broken while you wait, and nothing is lost by doing it late.
17
+ **Connecting to an already-deployed store? Redeploy the backend functions.** The key is injected **at deploy time**, so `commerce/*` functions deployed before the integration was connected keep the environment they started with and the store keeps reporting *no payment provider connected* even though Stripe is set up correctly. Redeploy them all (anything under `base44/shared/commerce/` is bundled into every function, so touching it redeploys the whole set) and allow up to a minute for the ~60s status cache.
18
18
 
19
- When you get there, make it easy:
20
-
21
- > *"Your store is set up. The last piece is taking money: card payments are already implemented, they just need Stripe connected for this app — that part is yours to do in the dashboard, and then I can place a test order end to end. Want to do that now, or start with manual payments (bank transfer / cash on delivery) for the moment?"*
22
-
23
- Then:
24
-
25
- 1. **Ask the user to set Stripe up for the app.** On **Base44** that's the platform's own Stripe integration (app dashboard → Integrations → Stripe; it can start in Stripe's test mode and be claimed with a real account later) — *not* an OAuth connector: there is no Stripe connector in Base44's catalog. The platform holds the keys and injects them into backend functions as **`STRIPE_SECRET_KEY`** / **`STRIPE_PUBLISHABLE_KEY`**, which is exactly where `stripe.ts` reads the credential from. Never put keys in the store's data, in code, or in a settings field.
26
- 2. **Redeploy the backend functions — connecting is not enough.** The platform injects `STRIPE_SECRET_KEY` **at deploy time**, so functions that were already deployed keep the environment they started with and cannot see a key added afterwards. `stripe.ts` reads `Deno.env.get("STRIPE_SECRET_KEY")`, so until the functions are redeployed the store reports *no payment provider connected* even though Stripe is set up correctly. This is the single most common "it doesn't work" after connecting.
27
-
28
- - **Hosted app (no CLI)** — you cannot run a deploy command, so cause one by **changing a file the functions bundle**: edit anything under `base44/shared/commerce/` and every `commerce/*` function redeploys, because `shared/` is bundled into all of them. A one-line comment edit is enough — `shared/commerce/payments.ts` carries a **deploy marker** comment at the top for exactly this: bump its number, save, and the whole set redeploys. (Editing a single function's `entry.ts` redeploys only that function, which is not enough: `payments`, `payment-webhook`, `storefront-checkout`, `storefront-catalog`, `admin-tools` and `admin-refunds` all read the credential.)
29
- - **CLI** — `npx base44 functions deploy`.
30
-
31
- 3. **Then confirm it landed** — `commerce/admin-tools` → `payment-connector-status` returns `{ connected, provider, gateway_slug }`; the admin's Payments screen shows the same state. Still *not connected*? In order: was the integration actually completed in the dashboard, did the redeploy above really happen, and has the ~60s status cache expired (`onlinePaymentStatus` caches per isolate).
32
- 4. **Nothing else.** The `Credit card` gateway is enabled by default and starts appearing at checkout the moment a provider is connected. No code, no keys, no placeholder to replace. (`commerce/seed-store` is idempotent and never rewrites an existing gateway, so a store seeded *before* this default landed keeps its old `enabled: false` — switch it on in Payments settings.)
33
- 5. **Optional but recommended: register the webhook** (§4) so payments confirm even when the buyer closes the tab.
34
- 6. **Test with the provider's test mode** — place an order, pay, and check the order reaches `processing` with `date_paid` and a payment reference; then try a refund. This is the other reason to do payments *after* the catalog: with products, a shipping method and an address in place, you can prove the whole path works instead of just wiring it.
35
-
36
- **To stop taking card payments, switch the gateway off** — don't rely on disconnecting the provider in the platform dashboard. Verified on Base44: after disconnecting Stripe there, the injected `STRIPE_SECRET_KEY` was still present *and still accepted by Stripe* (across a redeploy), so the store could genuinely still charge and correctly reported card payment as available. The store-level switch in **Payments settings** is the control that always works, because it's the store's own data.
37
-
38
- **The user can decline, and that's a legitimate answer.** Some businesses genuinely want invoice, bank transfer or cash on delivery only. If they do:
39
-
40
- - turn the `Credit card` gateway **off** in Payments settings — that is the whole opt-out;
41
- - the storefront never offers it, and the admin order page's payment actions sit disabled with *"No payment provider connected"*;
42
- - say plainly in the checkout UI how payment works (`payment_instructions` from the `place-order` response), and don't describe the store as taking cards anywhere in the copy.
43
-
44
- Don't leave the in-between state unexplained: the gateway enabled with **no provider connected** is safe (customers never see it — the storefront filters it out live), but the user should know it's waiting on them.
19
+ That's it — the `Credit card` gateway ships enabled and stays hidden from customers until a provider is connected, so nothing is broken in the meantime. To *stop* taking cards, switch the gateway off in Payments settings — disconnecting the provider alone can leave a still-working key in the functions' environment.
45
20
 
46
21
  ## 2. How it works
47
22
 
@@ -194,7 +169,7 @@ Rules for any checkout UI you build:
194
169
 
195
170
  Whether the store can take a card is a **live fact about the connector**, not a constant. Everything derives it:
196
171
 
197
- - backend — `onlinePaymentStatus(sr)` from the payment utility. It doesn't just look for a key, it **verifies** it with the provider (cheapest authenticated call, answer cached ~60s): a credential that has been disconnected, rotated or revoked lingers in a function's environment until the next deploy, so presence alone would advertise card payment the store can no longer take. The same deploy-time injection is why a *newly* connected provider stays invisible until the functions are redeployed (§2, step 2). Expect up to a minute for a change to show. A *rejected* credential means not connected; a network blip does **not** flip a working store to "no payments" while a good answer is still cached;
172
+ - backend — `onlinePaymentStatus(sr)` from the payment utility. It doesn't just look for a key, it **verifies** it with the provider (cheapest authenticated call, answer cached ~60s): a credential that has been disconnected, rotated or revoked lingers in a function's environment until the next deploy, so presence alone would advertise card payment the store can no longer take. The same deploy-time injection is why a *newly* connected provider stays invisible until the functions are redeployed (§1). Expect up to a minute for a change to show. A *rejected* credential means not connected; a network blip does **not** flip a working store to "no payments" while a good answer is still cached;
198
173
  - admin — the `usePaymentProvider()` hook (`commerce/admin-tools` → `payment-connector-status`);
199
174
  - storefront — the filtered `payment_gateways` list.
200
175
 
@@ -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).