@base44/app-plugin-commerce 0.1.11 → 0.1.12

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 CHANGED
@@ -11,7 +11,7 @@ It provides a full-featured **commerce data model and behavior** (variant-driven
11
11
  - **Online card payments, order side premade** — checkout routing, payment links for unpaid orders, two idempotent confirmation paths (customer return + webhook, both premade) and refund records are all built. Wiring a provider (Stripe, PayPal, a local PSP…) means implementing **four functions in one file** — `base44/shared/commerce/card-payment.ts` — and nothing else; a complete Stripe implementation to paste in ships in [`skills/commerce/post-installation.md`](./skills/commerce/post-installation.md) §2.2. Every other payment option is manual (on-hold + instructions) and needs no code; the admin can add more in Settings → Payments. See [`skills/commerce/references/online-payments.md`](./skills/commerce/references/online-payments.md).
12
12
  - **Shared commerce engine** (`base44/shared/commerce/`) — totals, tax, shipping, coupons, stock, order lifecycle, webhook dispatch (HMAC-signed), emails, card-payment plumbing, plus static country/currency/continent data.
13
13
  - **Admin UI** (`src/commerce/admin/`) — a React/Tailwind/shadcn admin with a familiar store back-office information architecture: dashboard, orders, products, coupons, customers, reports, and full settings including webhooks. Admin-role gated.
14
- - **Storefront helpers** (`src/commerce/utils/`) — framework-free, dependency-free functions for the shopfront you build: `variants.js` maps an attribute selection (Size, Color) onto a `ProductVariation` and back, plus per-option availability and variant price ranges; `shipping-promos.js` reads the store's real free-shipping configuration so "Free shipping over €150" copy states a configured rule rather than an invented number. See [`skills/commerce/references/storefront-product-page.md`](./skills/commerce/references/storefront-product-page.md).
14
+ - **Storefront helpers** (`src/commerce/utils/`) — framework-free, dependency-free modules for the shopfront you build: `storefront.js` is the API client (`createStorefront(base44)` — cart-token lifecycle, cached store-info, catalog/cart/checkout/return-page calls); `variants.js` maps an attribute selection (Size, Color) onto a `ProductVariation` and back, plus per-option availability and variant price ranges; `shipping-promos.js` reads the store's real free-shipping configuration so "Free shipping over €150" copy states a configured rule rather than an invented number. See [`skills/commerce/references/storefront-product-page.md`](./skills/commerce/references/storefront-product-page.md).
15
15
  - **StoreAdmin agent + bot** — an AI copilot (`base44/agents/commerce/StoreAdmin.jsonc`, registered as `commerce/StoreAdmin`) with the `commerce/*` functions attached directly as tools (calls run as the chatting user → `requireAdmin()` still applies), variant-aware order editing, plus a chat panel in the admin sidebar with GFM markdown-table rendering.
16
16
  - **Docs** — this README plus the commerce skill folder [`skills/commerce/`](./skills/commerce/), which holds [`SKILL.md`](./skills/commerce/SKILL.md) (the short map agents start from), [`installation-guidelines.md`](./skills/commerce/installation-guidelines.md), [`post-installation.md`](./skills/commerce/post-installation.md), per-topic guides in [`references/`](./skills/commerce/references/) and the API references in [`docs/`](./skills/commerce/docs/) — the whole folder is installed into the app at `.agents/skills/commerce/` so agents pick it up natively.
17
17
 
@@ -15,9 +15,14 @@
15
15
  * best-effort rollback on mid-failure. See seed-catalog.ts for the pipeline.
16
16
  *
17
17
  * Body: { store_name?, currency?, weight_unit?, dimension_unit?,
18
- * with_sample_data?, products?, coupons?, locations? }
18
+ * payment_methods?, with_sample_data?, products?, coupons?, locations? }
19
19
  * — with_sample_data cannot be combined with the catalog keys.
20
20
  *
21
+ * `payment_methods` (e.g. ["card"]) names the checkout methods the store
22
+ * offers: listed gateways are enabled, every other gateway row is disabled —
23
+ * the one call covers "card-only" stores with no entity editing. Explicit
24
+ * values always win, on first seed and re-runs alike (like `currency`).
25
+ *
21
26
  * `store_name` is required on a first seed: a function's env is only
22
27
  * BASE44_APP_ID, so it cannot read the app's name, and subjects need one.
23
28
  * `currency` is an ISO code and `weight_unit`/`dimension_unit` are the
@@ -65,6 +70,30 @@ Deno.serve(async (req) => {
65
70
  if (dimensionUnit && !DIMENSION_UNITS.includes(dimensionUnit)) {
66
71
  unitErrors.push({ path: "dimension_unit", error: `must be one of: ${DIMENSION_UNITS.join(", ")}` });
67
72
  }
73
+ let methodSlugs: string[] | null = null;
74
+ if (body.payment_methods !== undefined) {
75
+ const raw = body.payment_methods;
76
+ if (!Array.isArray(raw) || !raw.length || raw.some((s: unknown) => typeof s !== "string" || !String(s).trim())) {
77
+ unitErrors.push({ path: "payment_methods", error: `must be a non-empty array of gateway slugs, e.g. ["card"]` });
78
+ } else {
79
+ methodSlugs = [...new Set(raw.map((s: string) => s.trim().toLowerCase()))];
80
+ // Validate against the gateways that will exist after this call — the
81
+ // seeded defaults plus any rows already in the store ("stripe" rows are
82
+ // renamed to "card" below). Read-only, so failing here writes nothing.
83
+ const rows = (await sr.entities["commerce.PaymentGateway"].list(undefined, 100)) ?? [];
84
+ const known = new Set([
85
+ ...GATEWAY_DEFAULTS.map((g) => g.slug),
86
+ ...rows.map((r: any) => String(r.slug === "stripe" ? "card" : r.slug)),
87
+ ]);
88
+ const unknown = methodSlugs.filter((s) => !known.has(s));
89
+ if (unknown.length) {
90
+ unitErrors.push({
91
+ path: "payment_methods",
92
+ error: `unknown gateway slug(s): ${unknown.join(", ")} — known: ${[...known].join(", ")}`,
93
+ });
94
+ }
95
+ }
96
+ }
68
97
  if (unitErrors.length) {
69
98
  return fail(400, "Invalid catalog payload — nothing was written.", "invalid_payload", { errors: unitErrors });
70
99
  }
@@ -159,10 +188,28 @@ Deno.serve(async (req) => {
159
188
  for (const gw of GATEWAY_DEFAULTS) {
160
189
  const hits = (await sr.entities["commerce.PaymentGateway"].filter({ slug: gw.slug }, undefined, 1)) ?? [];
161
190
  if (hits.length) continue;
162
- await sr.entities["commerce.PaymentGateway"].create(gw);
191
+ await sr.entities["commerce.PaymentGateway"].create(
192
+ methodSlugs ? { ...gw, enabled: methodSlugs.includes(gw.slug) } : gw,
193
+ );
163
194
  seeded.gateways++;
164
195
  }
165
196
 
197
+ // Explicit payment_methods always win, like currency: converge EVERY
198
+ // gateway row's enabled flag to the requested set, so "card-only" is one
199
+ // seed call — no entity editing, on first seed and re-runs alike.
200
+ let paymentMethods: { enabled: string[]; disabled: string[] } | null = null;
201
+ if (methodSlugs) {
202
+ const rows = (await sr.entities["commerce.PaymentGateway"].list(undefined, 100)) ?? [];
203
+ paymentMethods = { enabled: [], disabled: [] };
204
+ for (const row of rows) {
205
+ const want = methodSlugs.includes(String(row.slug));
206
+ if (Boolean(row.enabled) !== want) {
207
+ await sr.entities["commerce.PaymentGateway"].update(row.id, { enabled: want });
208
+ }
209
+ (want ? paymentMethods.enabled : paymentMethods.disabled).push(String(row.slug));
210
+ }
211
+ }
212
+
166
213
  // Caller-supplied `locations` ARE the store's shipping story — seeding the
167
214
  // free-shipping fallback next to them would leave a second no-region
168
215
  // location the merchant never asked for (shadowed while the caller's has a
@@ -199,6 +246,7 @@ Deno.serve(async (req) => {
199
246
  catalog: catalogResult,
200
247
  store_name: { value: storeNameAction === "kept_existing" ? currentName : storeName, action: storeNameAction },
201
248
  currency: currencyInfo ? { value: currencyInfo.code, action: currencyAction } : null,
249
+ payment_methods: paymentMethods,
202
250
  });
203
251
  } catch (e) {
204
252
  if (e instanceof HttpError) return fail(e.status, e.message, e.code);
@@ -12,6 +12,12 @@
12
12
  * `.agents/skills/commerce/post-installation.md` §2.2 to paste over this
13
13
  * file — another provider implements the same functions against its own API.
14
14
  *
15
+ * REPLACE THIS FILE WHOLE (one write of the full new content). Never
16
+ * find_replace into these stubs: a partial patch leaves the originals behind
17
+ * and every commerce function fails to deploy with duplicate-export bundle
18
+ * errors ("Multiple exports with the same name ...") — the fix is always to
19
+ * rewrite the file whole.
20
+ *
15
21
  * Everything around these functions is already built: checkout creates the
16
22
  * order, prices it, holds stock and routes the customer to the URL you
17
23
  * return; the return page and the payment webhook confirm payment
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@base44/app-plugin-commerce",
3
- "version": "0.1.11",
3
+ "version": "0.1.12",
4
4
  "description": "Base44 Commerce plugin — entities, backend functions, shared commerce engine, admin UI and the commerce skill, shipped as copyable source",
5
5
  "keywords": [
6
6
  "base44",
@@ -10,6 +10,7 @@ Operational guidance for extending, operating and building on the Base44 Commerc
10
10
  > **If you are a Base44 agent working inside the runtime, read this first:**
11
11
  > - **Don't read the whole codebase up front.** Start with this file, then open only the files your current task touches plus the matching reference below, pulling in more as you go. Reading everything first just burns context.
12
12
  > - **Don't weaken the admin gating.** The UI guard, entity RLS and function guards form three enforcement layers — see [`.agents/skills/commerce/post-installation.md`](./post-installation.md) — keep all of them intact when changing routes or schemas.
13
+ > - **Entity names are dotted — SDK access is bracket syntax only:** `base44.entities["commerce.PaymentGateway"]` (schema file: `base44/entities/commerce.PaymentGateway.jsonc`). `commerce__PaymentGateway` / `PaymentGateway` don't exist. Gateway on/off is seed data anyway: `commerce/seed-store` `payment_methods`.
13
14
 
14
15
  ## IMPORTANT — first-time installation
15
16
 
@@ -182,7 +182,7 @@ Actions: `create-link` · `complete-return` · `verify` — the admin side of on
182
182
 
183
183
  ## commerce/seed-store
184
184
 
185
- Not action-routed. Body `{ store_name?, currency?, weight_unit?, dimension_unit?, with_sample_data?, products?, coupons?, locations? }`. **`store_name` is required** when the `emails` group doesn't exist yet (**400** `store_name_required` otherwise) — pass the app's name **as the platform shows it** — ask the user or read it from the dashboard. `base44/config.jsonc` → `name` is *not* authoritative: it can still say `New App` for an app the platform calls `Canvas`. A backend function can't read either, its environment being only `BASE44_APP_ID`. It lands in `emails.store_name` — one setting serving as both the store name in email subjects and the sender name on every transactional email; a nameless store renders subjects like `[]: New order #1002`, which is why seeding refuses one. Requires admin. Runs a **canary schema check** first — on any incompatibility returns **422** `{ success:false, code:"schema_incompatible", errors:[{ entity, error }] }` and writes nothing. Otherwise seeds defaults idempotently, then the catalog. On an already-seeded store a passed `store_name` fills a **blank** name and never overwrites one the merchant chose. **`currency`** (ISO code, validated against the shared currencies table) and **`weight_unit`/`dimension_unit`** set the `general` group; unknown values fail as **400** `invalid_payload`. Unlike `store_name` they **always win** — there is no blank state to distinguish a merchant's choice from the seeded default, so passing one on a re-run updates the store. Prices are formatted with `Intl.NumberFormat` — the currency is a value, there are no format settings.
185
+ Not action-routed. Body `{ store_name?, currency?, weight_unit?, dimension_unit?, payment_methods?, with_sample_data?, products?, coupons?, locations? }`. **`payment_methods`** (gateway slugs, e.g. `["card"]`) enables exactly the listed gateways and disables every other gateway row — the one-call way to honor "card-only"/"offline-only"; explicit values win on re-runs, unknown slugs fail as `400 invalid_payload`, and the response reports `payment_methods: { enabled, disabled }` (`null` when not passed). **`store_name` is required** when the `emails` group doesn't exist yet (**400** `store_name_required` otherwise) — pass the app's name **as the platform shows it** — ask the user or read it from the dashboard. `base44/config.jsonc` → `name` is *not* authoritative: it can still say `New App` for an app the platform calls `Canvas`. A backend function can't read either, its environment being only `BASE44_APP_ID`. It lands in `emails.store_name` — one setting serving as both the store name in email subjects and the sender name on every transactional email; a nameless store renders subjects like `[]: New order #1002`, which is why seeding refuses one. Requires admin. Runs a **canary schema check** first — on any incompatibility returns **422** `{ success:false, code:"schema_incompatible", errors:[{ entity, error }] }` and writes nothing. Otherwise seeds defaults idempotently, then the catalog. On an already-seeded store a passed `store_name` fills a **blank** name and never overwrites one the merchant chose. **`currency`** (ISO code, validated against the shared currencies table) and **`weight_unit`/`dimension_unit`** set the `general` group; unknown values fail as **400** `invalid_payload`. Unlike `store_name` they **always win** — there is no blank state to distinguish a merchant's choice from the seeded default, so passing one on a re-run updates the store. Prices are formatted with `Intl.NumberFormat` — the currency is a value, there are no format settings.
186
186
 
187
187
  **`products`** is the one-call catalog bootstrap — the worked example and full semantics are in [`../post-installation.md` §2.1](../post-installation.md#21-the-products-payload). Each entry references categories/ribbons/attributes by **display name** (taxonomy is get-or-created: slugs/codes derived, existing records matched case-insensitively and reused, with the stored casing canonicalized into the product). `attributes: [{ name, options }]` (or `{ <name>: [options] }`) declares the variant axes; `variations: [{ options: { <name>: <option> }, ...overrides }]` lists the stocked combinations — omit it to auto-generate **all** combinations, each inheriting the product-level price/sale fields. A variation with its own `stock_quantity` gets `manage_stock: "yes"`; without one it draws on the parent's pooled stock (`"parent"`). Variation SKUs are synthesized from the parent SKU when absent. Parent `price`/`regular_price`/`on_sale` are rolled up from the cheapest publishable variant, `stock_status` derived, category/ribbon counts maintained — same helpers as `admin-products` `save`, but **no `product.created` webhooks are dispatched** (bootstrap precedes webhook subscribers; use `admin-products` for webhook-visible creates). Payload problems fail before any write as **400** `invalid_payload` with `errors: [{ path, error }]`; an explicit variation SKU already in use is **409** `duplicate_sku`; a mid-write failure rolls back everything the call created (**500** `catalog_seed_failed`) without touching reused taxonomy. **Re-runs converge**: a product whose `sku` (or, without one, derived slug) already exists is skipped and reported, so retries never duplicate. Limits: ≤100 products, ≤50 variations per product, ≤500 variations per call, ≤50 coupons/locations. `coupons` is a thin passthrough (code lowercased, skip-if-exists); **`locations`** creates Shipping & Tax Locations (`{ name, countries?: ["IL"], shipping_rates: [{ name, cost, free_over? }], tax_groups?: [{ name, rates: [{ name, rate }] }], shipping_tax?: { type: "percent"|"fixed", value } }` — the default `Products` tax group is added when missing; skip-if-exists by name). Passing any `locations` suppresses the seeded "Rest of the world" fallback — yours become the store's only locations; without them the free-shipping fallback is seeded. `with_sample_data: true` seeds the template's demo catalog instead (only when the store has zero products) and cannot be combined with the catalog keys.
188
188
 
@@ -48,6 +48,8 @@ Even if the client guard were bypassed, layers 2 and 3 keep the store data safe.
48
48
 
49
49
  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) and the `offline` and `card` payment gateways — and, depending on the payload, the catalog. A fallback "Rest of the world" **Shipping & Tax Location** (one free shipping rate, no tax) is seeded **only when the payload carries no `locations`** — locations you pass are the store's only shipping data, with no seeded fallback beside them. Pass **`currency`** (an ISO code, e.g. `"EUR"`) and/or **`weight_unit`**/**`dimension_unit`** to set the store's currency and measurement units instead of the defaults — explicit values always win, on a first seed and a re-run alike. (Prices are *formatted* with `Intl.NumberFormat` per the viewer's locale — the currency is a value; there are no format settings.)
50
50
 
51
+ Pass **`payment_methods`** (gateway slugs, e.g. `["card"]`) when the user restricts how they get paid: the listed gateways are enabled and **every other gateway row is disabled** — "card-only" or "offline-only" is part of the same seed call, with **no `commerce.PaymentGateway` reads or writes of your own**. Explicit values win on re-runs too. Unknown slugs fail as `400 invalid_payload` (the error lists the known ones). Should you ever need direct entity access, names are dotted — bracket syntax only: `base44.entities["commerce.PaymentGateway"]` (`commerce__PaymentGateway` / `PaymentGateway` don't exist).
52
+
51
53
  | Mode | Body | Products created |
52
54
  |---|---|---|
53
55
  | **Real catalog** | `{ store_name, products: [...] }` (+ optional `coupons`, `locations`) | Yours — categories, ribbons, attributes, variants and all, in this one call |
@@ -70,6 +72,7 @@ Reference everything by **display name** — categories, ribbons, attributes and
70
72
  await base44.functions.invoke("commerce/seed-store", {
71
73
  store_name: "Aurora Threads",
72
74
  currency: "EUR", // optional — defaults to USD
75
+ payment_methods: ["card"], // optional — enables ONLY these; omit to keep offline + card
73
76
  products: [
74
77
  { // simple product
75
78
  name: "Classic T-Shirt",
@@ -130,7 +133,8 @@ The response reports everything:
130
133
  ]
131
134
  },
132
135
  "store_name": { "value": "Aurora Threads", "action": "created" },
133
- "currency": { "value": "EUR", "action": "created" } } // "updated" | "unchanged" on re-runs; null when not passed
136
+ "currency": { "value": "EUR", "action": "created" }, // "updated" | "unchanged" on re-runs; null when not passed
137
+ "payment_methods": { "enabled": ["card"], "disabled": ["offline"] } } // null when not passed
134
138
  ```
135
139
 
136
140
  **Images**: every product needs at least one, and the URL you seed is the URL the store serves — there are no placeholders to swap later. So resolve each image to its **final URL before seeding**: use whatever image generation the app has available and store the returned URL, upload real assets with `base44.integrations.Core.UploadFile({ file })` → public URL, or fall back to stable public stock URLs (`base44/functions/commerce/seed-store/sample-data.ts` shows a working Unsplash pattern). Match the image to the product. Image generation is usually the **slowest step of the whole install** — kick all product images off first, do the rest (router mount, payment file, storefront pages) while they render, and seed once the URLs are back. If an image isn't ready at seed time, seed without it and set it afterwards through the admin API — don't seed a dead path and compensate in the frontend.
@@ -141,6 +145,8 @@ A successful response means the data is in — the catalog and settings are live
141
145
 
142
146
  The order side of payments is **already implemented** (checkout routing, confirmation, payment links, refund records). The `offline` gateway — and any option the admin adds in Settings → Payments — works with nothing to configure: the order goes on-hold with the option's description as instructions.
143
147
 
148
+ Which methods the store offers is **seed data**: pass `payment_methods` to `commerce/seed-store` (§2) — e.g. `["card"]` for a card-only store — instead of ever editing `commerce.PaymentGateway` records yourself.
149
+
144
150
  The **Credit card** option needs a payment provider — **any** provider works (Stripe, PayPal, Adyen, a local PSP…), and whichever one it is, wiring it touches exactly **one file**: `base44/shared/commerce/card-payment.ts` — four functions, each backing a premade flow. `commerce/payment-webhook` (the function) is **premade — do not edit it**: it calls this file's `parseWebhook` to validate each event and **never trusts an event body on its own** — an unverified event only *names* an order, and whether money arrived is asked of the provider itself through `checkCardPaymentPaid`, so a forged webhook call can never mark an order paid and **no signing secret is needed**. Until the file is implemented the card option answers `503 no_card_payment_provider` at checkout (or switch it off in Settings → Payments).
145
151
 
146
152
  | Function | Backs |
@@ -150,7 +156,7 @@ The **Credit card** option needs a payment provider — **any** provider works (
150
156
  | `refundCardPayment` | admin refunds with `refund_payment: true` |
151
157
  | `parseWebhook` | webhook validation — names the order the event is about; `paid: true` only after signature verification, never from a raw body |
152
158
 
153
- **Stripe is the reference implementation** and ships ready to use — don't write the file yourself; overwrite `base44/shared/commerce/card-payment.ts` with this as-is. For another provider, implement the same four functions against its API instead (same shape: hosted page in, paid-check and refund by `reference`, event naming an order — rules in [`references/online-payments.md`](./references/online-payments.md)):
159
+ **Stripe is the reference implementation** and ships ready to use — don't write the file yourself; overwrite `base44/shared/commerce/card-payment.ts` with this as-is, **as one whole-file write. Never `find_replace` into the stub**: a partial patch leaves the original stubs behind and breaks every commerce function's deploy with duplicate-export bundle errors (`Multiple exports with the same name "createCardPayment"` → rewrite the file whole). For another provider, implement the same four functions against its API instead (same shape: hosted page in, paid-check and refund by `reference`, event naming an order — rules in [`references/online-payments.md`](./references/online-payments.md)):
154
160
 
155
161
  ```ts
156
162
  // base44/shared/commerce/card-payment.ts — Stripe implementation
@@ -270,31 +276,36 @@ That's it — checkout redirect, `/order-received` confirmation, the webhook, th
270
276
 
271
277
  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.
272
278
 
273
- Every function returns the envelope `{ success, data }`; with the SDK the payload is `res.data.data`, so define one helper and use it everywhere:
279
+ Build on the **shipped API client** — create it once and import that instance everywhere (wrapping it in a React context is fine; never a second copy):
274
280
 
275
281
  ```js
276
- const inv = (fn, payload) => base44.functions.invoke(fn, payload).then((r) => r.data.data);
282
+ // src/lib/storefront.js
283
+ import { createStorefront } from "@/commerce/utils";
284
+ import { base44 } from "@/api/base44Client";
285
+ export const store = createStorefront(base44);
277
286
  ```
278
287
 
279
- 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.
288
+ The client owns the two things hand-rolled storefronts keep getting wrong, so **don't reimplement either**: the **`cart_token` lifecycle** (sent with every cart/checkout call, re-persisted from every response — a stale token silently starts a fresh cart; rolling 48 h expiry; cleared when checkout consumes the cart) and the **store-info cache** (`payment_gateways`, currency, countries live **only** on `get-store-info` — the cart view never carries them). For anything beyond its methods, `store.inv(fn, payload)` unwraps the `{ success, data }` envelope (`res.data.data`).
289
+
290
+ Keep the UI in **small focused components** (~2–4K characters each — product card, gallery, cart panel, address step, payment step…), not monolithic page files: smaller files are faster to emit, review and fix.
280
291
 
281
292
  ### 3.1 Product list
282
293
 
283
294
  ```js
284
- const info = await inv("commerce/storefront-catalog", { action: "get-store-info" });
295
+ const info = await store.getStoreInfo(); // cached — call it wherever needed
285
296
  // info.settings → { store_name, currency, weight_unit, … } — format money with
286
297
  // Intl.NumberFormat(undefined, { style: "currency", currency: info.settings.currency })
287
- // info.payment_gateways → [{ slug, title, description, online }] — you'll need this at checkout
298
+ // info.payment_gateways → [{ slug, title, description, online }] — you'll need this at checkout;
299
+ // this call is its ONLY source (it is never on the cart view)
288
300
  // info.countries / info.currencies → static tables for address forms and money display
289
301
 
290
- const { products, page, per_page, has_next } = await inv("commerce/storefront-catalog", {
291
- action: "list-products",
302
+ const { products, page, per_page, has_next } = await store.listProducts({
292
303
  page: 1, per_page: 12, // optional: search, category_id, ribbon_id, featured, on_sale,
293
304
  sort: "-created_date", // min_price, max_price, in_stock_only
294
305
  }); // sort: -created_date | name | price | -price | popularity | rating
295
306
  ```
296
307
 
297
- Each row is a full product record — for a card use `name`, `images[0]?.src` (**may be empty — render a placeholder, never a broken `<img>`**), `price`, `regular_price`, `on_sale` (sale badge), `short_description`, `stock_status`, `average_rating`/`rating_count` (stars cost no extra call) and `ribbons` (`[{ id, name }]`, may be absent — labels like "Best Seller" for the card corner). **There is no product type flag**: `product.attributes?.length > 0` means the product sells variants and its `price` is a *from*-price rolled up from the cheapest variant — render it as "From …". Categories for the nav come from `{ action: "list-categories" }` (a tree via `parent_id`). That is the whole card — no other call or reference needed for the list view.
308
+ Each row is a full product record — for a card use `name`, `images[0]?.src` (**may be empty — render a placeholder, never a broken `<img>`**), `price`, `regular_price`, `on_sale` (sale badge), `short_description`, `stock_status`, `average_rating`/`rating_count` (stars cost no extra call) and `ribbons` (`[{ id, name }]`, may be absent — labels like "Best Seller" for the card corner). **There is no product type flag**: `product.attributes?.length > 0` means the product sells variants and its `price` is a *from*-price rolled up from the cheapest variant — render it as "From …". Categories for the nav come from `store.listCategories()` (a tree via `parent_id`). That is the whole card — no other call or reference needed for the list view.
298
309
 
299
310
  **Carry forward:** each card links to the product page by **`slug`**.
300
311
 
@@ -302,7 +313,7 @@ Each row is a full product record — for a card use `name`, `images[0]?.src` (*
302
313
 
303
314
  ```js
304
315
  const { product, variations, categories, ribbons, reviews } =
305
- await inv("commerce/storefront-catalog", { action: "get-product", slug }); // or { id }
316
+ await store.getProduct(slug); // or store.getProduct({ id })
306
317
 
307
318
  // One selector PER product.attributes[] entry — never a flat list of variations.
308
319
  import { defaultSelection, selectOption, resolveSelection } from "@/commerce/utils";
@@ -318,45 +329,43 @@ const view = resolveSelection(product, variations, selection);
318
329
  // view.addToCart → { product_id, variation_id } — null until the selection resolves
319
330
  ```
320
331
 
321
- Add to cart — `add-item` bootstraps the cart itself: a missing, stale or expired token starts a fresh cart, so the one rule is to **re-persist the `cart_token` from every response** (your cached token may have expired — 48h TTL — or been consumed by a checkout):
332
+ Add to cart — the cart bootstraps itself (a missing, stale or expired token starts a fresh one) and the client does all the token bookkeeping:
322
333
 
323
334
  ```js
324
- const cart = await inv("commerce/storefront-cart", {
325
- action: "add-item",
326
- cart_token: localStorage.getItem("cart_token") || undefined, // fine if absent/stale
327
- ...view.addToCart,
328
- quantity: 1,
329
- });
330
- localStorage.setItem("cart_token", cart.cart_token); // ALWAYS — the token may be a new cart's
335
+ const cart = await store.addItem({ ...view.addToCart, quantity: 1 });
331
336
  ```
332
337
 
333
- 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. On the other cart actions a `404 cart_not_found|cart_expired` means the cached token went stale — clear it and treat the cart as empty.
338
+ 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.
334
339
 
335
340
  **What the page renders — all from this one `get-product` call, no extra reads:** a gallery from `product.images` (`view.display.image` is the variant-selected one; placeholder when empty), name, price from `view.display` (`price`/`regular_price`/`on_sale` → sale badge), one selector per axis, stock state, `short_description` then `description` (**both HTML — render as rich text, don't escape or truncate away the markup**), SKU, `categories` as a breadcrumb, `ribbons` as light labels near the metadata, the `reviews` block (`{ items, has_next, average_rating, rating_count }`), and the `upsells`/`cross_sells` summaries. Descriptive properties (Material, Care…) live in `product.meta_data` — render them as a spec table; they are not attributes and not ribbons. That is the complete product page — [`references/storefront-product-page.md`](./references/storefront-product-page.md) and [`references/product-render.md`](./references/product-render.md) are only for edge cases and for adding fields to the *listing* call.
336
341
 
337
- **Carry forward:** the **`cart_token`** — from the response, every time.
342
+ **Carry forward:** nothing — the client keeps the `cart_token`.
338
343
 
339
344
  ### 3.3 Cart
340
345
 
341
346
  **Every cart action returns the same full priced view**, so re-render from whatever the last call returned — no separate refresh:
342
347
 
343
348
  ```js
344
- let cart = await inv("commerce/storefront-cart", { action: "get", cart_token });
349
+ let cart = await store.getCart(); // null → no cart yet (an expired token self-clears)
345
350
  // cart.items → [{ item_key, name, image, quantity, price, subtotal, total, attributes, purchasable }]
346
351
  // cart.totals → { subtotal, discount_total, shipping_total, cart_tax, total_tax, total, … }
347
352
  // cart.coupon_notices / cart.removed_items → tell the customer what auto-dropped and why
348
-
349
- cart = await inv("commerce/storefront-cart", { action: "update-item", cart_token, item_key, quantity }); // ≤0 removes
350
- cart = await inv("commerce/storefront-cart", { action: "remove-item", cart_token, item_key });
351
- cart = await inv("commerce/storefront-cart", { action: "apply-coupon", cart_token, code });
353
+ //
354
+ // NOT in the cart view: payment_gateways (store.getStoreInfo() ONLY — cart.payment_gateways
355
+ // is always undefined), the product catalog (listProducts/getProduct), countries/currencies
356
+ // (getStoreInfo). Never dot into the cart for any of those.
357
+
358
+ cart = await store.updateItem(item_key, quantity); // ≤0 removes
359
+ cart = await store.removeItem(item_key);
360
+ cart = await store.applyCoupon(code);
352
361
  ```
353
362
 
354
363
  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`). **Call `set-shipping-address` the moment the customer provides an address** — every shipping option and cost is recalculated by that call (never reuse a list fetched earlier), and an address the store doesn't ship to **fails right there** with `400 shipping_not_available`, so the address form is where you surface it:
355
364
 
356
365
  ```js
357
366
  // as soon as the address is entered — this is what (re)calculates shipping options + cost
358
- cart = await inv("commerce/storefront-cart", { action: "set-shipping-address", cart_token,
359
- address: { country, state, postcode, city } }); // 400 shipping_not_available → show it on the address form
367
+ cart = await store.setShippingAddress({ country, state, postcode, city });
368
+ // 400 shipping_not_available → show it on the address form
360
369
  // cart.chosen_shipping_method is the rate's ID (a string) — to display it, look
361
370
  // it up: cart.available_shipping_methods.find(m => m.id === cart.chosen_shipping_method)
362
371
  // and render that entry's title + cost. Never render the id itself.
@@ -366,11 +375,10 @@ switch (cart.shipping_status) {
366
375
  case "chosen": break; // customer's earlier choice still valid
367
376
  case "choice_required": // several options — MUST render cart.available_shipping_methods
368
377
  // [{ id, title, cost }] as a picker, then send the customer's pick:
369
- cart = await inv("commerce/storefront-cart", { action: "choose-shipping-method", cart_token,
370
- method_id: picked.id }); // the entry's id, not its method_id type
378
+ cart = await store.chooseShippingMethod(picked.id); // the entry's id, not its method_id type
371
379
  break;
372
380
  case "missing_address": break; // several zones, no address yet — shipping cost is NOT
373
- // calculated; collect the address and call set-shipping-address
381
+ // calculated; collect the address and call setShippingAddress
374
382
  case "not_needed": break; // fully virtual cart
375
383
  }
376
384
  ```
@@ -381,12 +389,17 @@ A store with exactly **one shipping zone** shows its options (and, auto-selected
381
389
 
382
390
  ### 3.4 Checkout & order-received
383
391
 
384
- 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) — every gateway the admin has **enabled**:
392
+ 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 — every gateway the admin has **enabled**:
385
393
 
386
394
  ```js
387
395
  // card payments = ONE file to implement — §2.2 above has the complete
388
396
  // Stripe implementation to paste; no other reading needed
389
- const gateways = info.payment_gateways; // [{ slug, title, description, online }] — admin-owned data
397
+ const { payment_gateways: gateways } = await store.getStoreInfo();
398
+ // ⚠ get-store-info is the ONLY source of payment_gateways — the cart view does
399
+ // NOT include them (cart.payment_gateways is always undefined, which reads as
400
+ // "no methods" and dead-disables the place-order button). If a context caches
401
+ // store-info, read it from there — never from the cart object.
402
+ // gateways → [{ slug, title, description, online }] — admin-owned data
390
403
  // several → render a picker using the admin's title/description as the labels
391
404
  // exactly ONE → no picker: use it directly, but still show its title so the customer knows how they'll pay
392
405
  // none → checkout cannot complete — say so instead of rendering a dead button
@@ -398,12 +411,11 @@ While the store has no card provider implemented, picking the card gateway fails
398
411
  `online: true` marks the card/redirect gateway; every other gateway is manual reconciliation. Then one call places the order:
399
412
 
400
413
  ```js
401
- const res = await inv("commerce/storefront-checkout", {
402
- action: "place-order", cart_token,
414
+ const res = await store.placeOrder({
403
415
  payment_method, // the slug chosen above
404
416
  billing: { first_name, last_name, address_1, city, country, email }, // the required set; phone, state, postcode optional
405
- // shipping: { … } if it differs from billing; customer_note?; return_url: window.location.origin
406
- });
417
+ // shipping: { … } if it differs from billing; customer_note?
418
+ }); // token + return_url handled by the client; the cart is consumed
407
419
  // res → { order_id, order_number, order_key, status, totals, order,
408
420
  // payment_instructions, // manual gateways: { description, account_details } — render them
409
421
  // payment } // card: { status: "requires_payment", checkout_url, … } | null
@@ -416,13 +428,8 @@ Every payment link returns to **`/order-received`** — the page from §1 step 3
416
428
 
417
429
  ```js
418
430
  // GET /order-received?order_id=…&order_key=…&payment=success|cancel
419
- const params = new URLSearchParams(window.location.search);
420
- const { state, order, payment_link, payment_instructions } = await inv("commerce/payments", {
421
- action: "complete-return",
422
- order_id: params.get("order_id"), order_key: params.get("order_key"),
423
- payment: params.get("payment"), // only a hint — the server verifies with the provider
424
- return_url: window.location.origin,
425
- });
431
+ const { state, order, payment_link, payment_instructions } = await store.completeReturn();
432
+ // (reads the URL params itself; ?payment= is only a hint — the server verifies with the provider)
426
433
  // state === "paid" → thank-you + order summary (order is now marked paid)
427
434
  // state === "unpaid" → card order: offer payment_link.url to pay now;
428
435
  // manual order: render payment_instructions ({ description, account_details })
@@ -440,12 +447,13 @@ Two shapes to get right when rendering: **`order` carries flat totals** — `ord
440
447
  Post-installation is complete when every line below holds — verify against this list instead of re-reading docs:
441
448
 
442
449
  - [ ] `/store-admin/*` mounted behind the shipped `AuthGuard`; `/` routes somewhere real (storefront or a redirect).
443
- - [ ] `commerce/seed-store` ran once and reported the catalog — real products, final image URLs.
444
- - [ ] Product list renders from `list-products` (cards: image/placeholder, name, price or "From …", sale badge, stars, ribbons) and links by `slug`.
445
- - [ ] Product page renders from `get-product` with one selector per attribute, resolving to `view.addToCart`.
446
- - [ ] Cart → address (`set-shipping-address`) → shipping choice → `place-order` completes, and the `cart_token` is re-persisted from every response.
447
- - [ ] `/order-received` calls `complete-return` and renders `paid` / `unpaid` / `cancelled`.
448
- - [ ] Card payments: `card-payment.ts` implemented (Stripe: paste §2.2), `STRIPE_SECRET_KEY` secret set, webhook endpoint registered — **or** the card gateway switched off in Settings → Payments.
450
+ - [ ] `commerce/seed-store` ran once and reported the catalog — real products, final image URLs; if the user restricted payment methods, `payment_methods` was passed in that same call.
451
+ - [ ] Product list renders from `store.listProducts` (cards: image/placeholder, name, price or "From …", sale badge, stars, ribbons) and links by `slug`.
452
+ - [ ] Product page renders from `store.getProduct` with one selector per attribute, resolving to `view.addToCart`.
453
+ - [ ] The storefront talks to the API through **one `createStorefront` instance** — no hand-rolled `cart_token` handling, and `payment_gateways` read from `getStoreInfo()` only, never off a cart.
454
+ - [ ] `/order-received` calls `completeReturn` and renders `paid` / `unpaid` / `cancelled`.
455
+ - [ ] Card payments: `card-payment.ts` implemented (Stripe: paste §2.2), `STRIPE_SECRET_KEY` secret set, webhook endpoint registered — **or** the card gateway disabled (seed `payment_methods` without `"card"`).
456
+ - [ ] **Verified by driving a real test order end to end** — add to cart → address → shipping → `place-order` → `/order-received` renders the right state — then the test order cancelled/deleted in the admin. Placing and cleaning up a test order is routine install verification, not a destructive action. A button existing is **not** evidence the flow works; only the driven flow is.
449
457
 
450
458
  ## 5. Next
451
459
 
@@ -26,7 +26,9 @@ Until the file is implemented, picking Credit card at checkout answers `503 no_c
26
26
 
27
27
  ## Implementation rules
28
28
 
29
+ - **Replace `card-payment.ts` whole** — one write of the full new file, never a `find_replace` into the shipped stub: a partial patch leaves the original stubs behind and breaks every commerce function's deploy with duplicate-export bundle errors ("Multiple exports with the same name …"); the fix is always to rewrite the file whole.
29
30
  - **Credentials** come from backend secrets/env (`Deno.env.get(...)`) — never from an entity, never from the client. On Base44, env vars are injected at deploy time; after adding a secret, redeploy the backend functions so they can see it.
31
+ - **Which methods the store offers is seed data** — `commerce/seed-store`'s `payment_methods` (e.g. `["card"]`) enables the listed gateways and disables the rest; don't edit `commerce.PaymentGateway` records to turn methods on or off.
30
32
  - **Only the provider can say an order is paid.** `checkCardPaymentPaid` must ask the provider's API about the stored `reference`; never return true because a request claimed it. It should also check the payment **names this order** (compare the payment's metadata `order_id` to `order.id`) — that stops a reference to some other, genuinely paid payment being replayed against a different order.
31
33
  - **Attach the metadata.** `createCardPayment` must put `order.id` and `order.order_key` on the payment's metadata (Stripe: `metadata` + `payment_intent_data.metadata`) — that echo is how `parseWebhook` names the order, and what the check above compares against.
32
34
  - **`parseWebhook` never trusts a raw body.** Return `paid: false` (the verify-via-API nudge) unless you verified the provider's signature over the raw payload bytes; set `reference` only from a verified event, otherwise leave it unset so the premade flow uses the reference stored on the order at checkout.
@@ -27,7 +27,7 @@ export default function useAsync(fn, deps = []) {
27
27
 
28
28
  useEffect(() => {
29
29
  run();
30
- // eslint-disable-next-line -- deps intentionally partial
30
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- caller-supplied deps; `run` is stable and intentionally omitted
31
31
  }, deps);
32
32
 
33
33
  // Note: `refetch` deliberately takes no arguments, so it stays safe to pass
@@ -55,7 +55,7 @@ export default function usePagedList(fetcher, { pageSize = 20, initialSort = "-c
55
55
  }
56
56
  if (page !== 0) setPage(0);
57
57
  else load(0, sort);
58
- // eslint-disable-next-line -- deps intentionally partial
58
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- keyed on depsKey alone; adding page/sort/load would double-fetch
59
59
  }, [depsKey]);
60
60
 
61
61
  useEffect(() => {
@@ -6,8 +6,11 @@
6
6
  * template, but this logic does — it is the part that is easy to get subtly
7
7
  * wrong.
8
8
  *
9
- * import { resolveSelection, defaultSelection, selectOption } from "@/commerce/utils";
9
+ * import { createStorefront, resolveSelection, defaultSelection, selectOption } from "@/commerce/utils";
10
10
  *
11
+ * - `storefront.js` — the API client: cart_token lifecycle, cached store-info
12
+ * (payment_gateways live there, never on the cart view), catalog/cart/
13
+ * checkout/return-page calls. Create ONE instance and import it everywhere.
11
14
  * - `variants.js` — variant selection: map attribute selections (Size, Color)
12
15
  * to a `ProductVariation` and back, per-option availability, variant price
13
16
  * ranges. See `.agents/skills/commerce/references/storefront-product-page.md`.
@@ -15,5 +18,6 @@
15
18
  * "Free shipping over €150" copy states a configured rule, not an invented
16
19
  * number. See `.agents/skills/commerce/docs/api-storefront.md`.
17
20
  */
21
+ export * from "./storefront.js";
18
22
  export * from "./variants.js";
19
23
  export * from "./shipping-promos.js";
@@ -0,0 +1,139 @@
1
+ /**
2
+ * Storefront API client — the thin layer between the visitor UI you build and
3
+ * the commerce/storefront-* functions. Framework-free. Create it ONCE and
4
+ * import that instance everywhere (wrapping it in a React context is fine —
5
+ * never create a second copy):
6
+ *
7
+ * // src/lib/storefront.js
8
+ * import { createStorefront } from "@/commerce/utils";
9
+ * import { base44 } from "@/api/base44Client";
10
+ * export const store = createStorefront(base44);
11
+ *
12
+ * It owns the two things hand-rolled clients keep getting wrong:
13
+ *
14
+ * - The cart_token lifecycle: the token is sent with every cart/checkout call
15
+ * and re-persisted from every response (a stale token silently starts a
16
+ * fresh cart), and it is cleared when checkout consumes the cart.
17
+ * - The store-info split: payment_gateways, currency, countries/currencies
18
+ * live ONLY on get-store-info — the cart view never carries them.
19
+ * getStoreInfo() caches the call; read them from there, never off a cart.
20
+ */
21
+
22
+ /** The stable error code a failed storefront call carries, if any. */
23
+ export function storefrontErrorCode(e) {
24
+ return e?.response?.data?.code ?? e?.data?.code ?? e?.code ?? null;
25
+ }
26
+
27
+ export function createStorefront(base44, { storageKey = "cart_token", storage } = {}) {
28
+ const bag = storage ?? (typeof localStorage !== "undefined" ? localStorage : null);
29
+
30
+ // Every function returns the envelope { success, data }; with the SDK the
31
+ // payload is res.data.data. Exposed for calls beyond the methods below.
32
+ const inv = (fn, payload) =>
33
+ base44.functions.invoke(fn, payload).then((r) => r.data.data);
34
+
35
+ const token = () => bag?.getItem(storageKey) || undefined;
36
+ const remember = (cart) => {
37
+ // ALWAYS re-persist — the backend may have started a fresh cart.
38
+ if (cart?.cart_token && bag) bag.setItem(storageKey, cart.cart_token);
39
+ return cart;
40
+ };
41
+ const forget = () => bag?.removeItem(storageKey);
42
+
43
+ const cartAction = (action, payload = {}) =>
44
+ inv("commerce/storefront-cart", { action, cart_token: token(), ...payload }).then(remember);
45
+
46
+ let infoPromise = null;
47
+
48
+ return {
49
+ inv,
50
+
51
+ // ── store info (cached) ──────────────────────────────────────────────
52
+ /** settings, payment_gateways, countries, currencies — this call ONLY. */
53
+ getStoreInfo() {
54
+ infoPromise ??= inv("commerce/storefront-catalog", { action: "get-store-info" })
55
+ .catch((e) => { infoPromise = null; throw e; });
56
+ return infoPromise;
57
+ },
58
+
59
+ // ── catalog ──────────────────────────────────────────────────────────
60
+ listProducts(params = {}) {
61
+ return inv("commerce/storefront-catalog", { action: "list-products", ...params });
62
+ },
63
+ /** getProduct("slug") or getProduct({ id }) */
64
+ getProduct(ref) {
65
+ const by = typeof ref === "string" ? { slug: ref } : ref;
66
+ return inv("commerce/storefront-catalog", { action: "get-product", ...by });
67
+ },
68
+ listCategories() {
69
+ return inv("commerce/storefront-catalog", { action: "list-categories" });
70
+ },
71
+ listRibbons() {
72
+ return inv("commerce/storefront-catalog", { action: "list-ribbons" });
73
+ },
74
+
75
+ // ── cart (token handled internally; every call returns the full view) ─
76
+ /** The current cart view, or null when there is none (an expired token self-clears). */
77
+ async getCart() {
78
+ if (!token()) return null;
79
+ try {
80
+ return await cartAction("get");
81
+ } catch (e) {
82
+ if (["cart_not_found", "cart_expired"].includes(storefrontErrorCode(e))) {
83
+ forget();
84
+ return null;
85
+ }
86
+ throw e;
87
+ }
88
+ },
89
+ /** addItem(view.addToCart) — a product with attributes needs the resolved variation_id. */
90
+ addItem(item) {
91
+ return cartAction("add-item", { quantity: 1, ...item });
92
+ },
93
+ updateItem(item_key, quantity) {
94
+ return cartAction("update-item", { item_key, quantity }); // ≤0 removes
95
+ },
96
+ removeItem(item_key) {
97
+ return cartAction("remove-item", { item_key });
98
+ },
99
+ applyCoupon(code) {
100
+ return cartAction("apply-coupon", { code });
101
+ },
102
+ removeCoupon() {
103
+ return cartAction("remove-coupon");
104
+ },
105
+ /** Recalculates shipping options + cost; 400 shipping_not_available belongs on the address form. */
106
+ setShippingAddress(address) {
107
+ return cartAction("set-shipping-address", { address });
108
+ },
109
+ chooseShippingMethod(method_id) {
110
+ return cartAction("choose-shipping-method", { method_id });
111
+ },
112
+
113
+ // ── checkout & return page ───────────────────────────────────────────
114
+ /** place-order; on success the cart is consumed, so the stored token is cleared. */
115
+ async placeOrder(details) {
116
+ const res = await inv("commerce/storefront-checkout", {
117
+ action: "place-order",
118
+ cart_token: token(),
119
+ return_url: typeof location !== "undefined" ? location.origin : undefined,
120
+ ...details,
121
+ });
122
+ forget();
123
+ return res;
124
+ },
125
+ /** The whole /order-received page in one call — pass nothing to read the URL params. */
126
+ completeReturn(params) {
127
+ const q = params ?? (typeof location !== "undefined"
128
+ ? new URLSearchParams(location.search)
129
+ : new URLSearchParams());
130
+ return inv("commerce/payments", {
131
+ action: "complete-return",
132
+ order_id: q.get("order_id"),
133
+ order_key: q.get("order_key"),
134
+ payment: q.get("payment") ?? undefined,
135
+ return_url: typeof location !== "undefined" ? location.origin : undefined,
136
+ });
137
+ },
138
+ };
139
+ }