@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 +1 -1
- package/base44/functions/commerce/seed-store/entry.ts +50 -2
- package/base44/shared/commerce/card-payment.ts +6 -0
- package/package.json +1 -1
- package/skills/commerce/SKILL.md +1 -0
- package/skills/commerce/docs/api-admin.md +1 -1
- package/skills/commerce/post-installation.md +58 -50
- package/skills/commerce/references/online-payments.md +2 -0
- package/src/commerce/admin/hooks/useAsync.js +1 -1
- package/src/commerce/admin/hooks/usePagedList.js +1 -1
- package/src/commerce/utils/index.js +5 -1
- package/src/commerce/utils/storefront.js +139 -0
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
|
|
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(
|
|
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.
|
|
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",
|
package/skills/commerce/SKILL.md
CHANGED
|
@@ -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" }
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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 `
|
|
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
|
|
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 —
|
|
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
|
|
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.
|
|
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:**
|
|
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
|
|
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
|
-
|
|
350
|
-
|
|
351
|
-
|
|
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
|
|
359
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
420
|
-
|
|
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 `
|
|
445
|
-
- [ ] Product page renders from `
|
|
446
|
-
- [ ]
|
|
447
|
-
- [ ] `/order-received` calls `
|
|
448
|
-
- [ ] Card payments: `card-payment.ts` implemented (Stripe: paste §2.2), `STRIPE_SECRET_KEY` secret set, webhook endpoint registered — **or** the card gateway
|
|
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
|
|
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 --
|
|
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
|
+
}
|