@base44/app-plugin-commerce 0.8.0 → 0.8.2
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/base44/shared/commerce/card-payment.stripe.ts +7 -3
- package/base44/shared/commerce/card-payment.ts +5 -3
- package/package.json +1 -1
- package/skills/commerce/SKILL.md +1 -1
- package/skills/commerce/install/02-storefront.md +5 -2
- package/skills/commerce/install/03-data.md +2 -2
- package/skills/commerce/references/catalog-rendering.md +1 -1
- package/skills/commerce/references/reviews.md +1 -1
- package/src/commerce/storefront/pickers.jsx +5 -0
- package/src/commerce/utils/storefront.js +9 -0
|
@@ -39,7 +39,6 @@
|
|
|
39
39
|
* `card-payment.<provider>.ts` next to the stub. `references/online-payments.md`
|
|
40
40
|
* has the rules; this file is the worked model.
|
|
41
41
|
*/
|
|
42
|
-
import { secrets } from "base44:runtime";
|
|
43
42
|
import { HttpError } from "./auth.ts";
|
|
44
43
|
|
|
45
44
|
/** Stripe's REST API, called directly — no SDK to bundle in the function. */
|
|
@@ -63,8 +62,13 @@ export interface CardPaymentPage {
|
|
|
63
62
|
* checkout instead of failing to boot every commerce function that imports it.
|
|
64
63
|
*/
|
|
65
64
|
const secret = (name: string): string => {
|
|
65
|
+
// Deno.env, never `secrets` from "base44:runtime": that specifier resolves
|
|
66
|
+
// only in the Deno function runtime, and this file lives under shared/ where
|
|
67
|
+
// a client bundler can reach it — the static import fails the Vite build of
|
|
68
|
+
// the whole storefront. Base44 publishes app secrets into the function
|
|
69
|
+
// environment, so Deno.env.get reads the same values.
|
|
66
70
|
try {
|
|
67
|
-
return String(
|
|
71
|
+
return String(Deno.env.get(name) ?? "");
|
|
68
72
|
} catch {
|
|
69
73
|
return "";
|
|
70
74
|
}
|
|
@@ -100,7 +104,7 @@ const stripeKey = (): string => {
|
|
|
100
104
|
* as `base44_app_id`, which is how the platform attributes a Stripe payment
|
|
101
105
|
* back to this app — send it on every call that creates money movement.
|
|
102
106
|
*/
|
|
103
|
-
const base44AppId = (): string => secret("BASE44_APP_ID")
|
|
107
|
+
const base44AppId = (): string => secret("BASE44_APP_ID");
|
|
104
108
|
|
|
105
109
|
/**
|
|
106
110
|
* One Stripe REST call. A body makes it a POST (form-encoded, with an
|
|
@@ -34,9 +34,11 @@
|
|
|
34
34
|
* Until implemented, the Credit Card checkout option answers
|
|
35
35
|
* 503 `no_card_payment_provider`.
|
|
36
36
|
*
|
|
37
|
-
* Credentials belong in Base44 secrets
|
|
38
|
-
*
|
|
39
|
-
*
|
|
37
|
+
* Credentials belong in Base44 secrets, read with `Deno.env.get("...")` —
|
|
38
|
+
* never in an entity, never in the code, never from the client. Do NOT import
|
|
39
|
+
* `secrets` from `base44:runtime` here: that specifier resolves only in the
|
|
40
|
+
* Deno function runtime, and a static import of it in this shared file fails
|
|
41
|
+
* the client bundle of the whole storefront. When one is missing, log which one and answer the caller with the
|
|
40
42
|
* flat 503 below: the storefront must not learn the names of the app's
|
|
41
43
|
* secrets.
|
|
42
44
|
*/
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@base44/app-plugin-commerce",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.2",
|
|
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
|
@@ -123,7 +123,7 @@ batch (above).
|
|
|
123
123
|
| Topic | Open when | Size |
|
|
124
124
|
|---|---|---|
|
|
125
125
|
| [`install/01-install.md`](./install/01-install.md) | installing — routes you to 02 and 03 | 6K |
|
|
126
|
-
| [`install/02-storefront.md`](./install/02-storefront.md) | building storefront pages |
|
|
126
|
+
| [`install/02-storefront.md`](./install/02-storefront.md) | building storefront pages | 38K |
|
|
127
127
|
| [`install/03-data.md`](./install/03-data.md) | seeding catalog, shipping rates/zones, payments; re-callable per slice | 11K |
|
|
128
128
|
| [`docs/entities.md`](./docs/entities.md) | any direct entity read/write ("which entity holds X") | 11K |
|
|
129
129
|
| [`references/catalog-rendering.md`](./references/catalog-rendering.md) | field shapes each catalog call returns, variant edge cases | 16K |
|
|
@@ -25,6 +25,8 @@ One split decides everything here: **the logic is premade, the UI never is.** Th
|
|
|
25
25
|
|
|
26
26
|
**One import path: `@/commerce/storefront`.** Each section opens with its page's exact import line — copy it verbatim, then delete unused names. Everything a page needs is re-exported there, so a React page never imports `@/commerce/utils` directly. `useStoreInfo` is the name most often left out.
|
|
27
27
|
|
|
28
|
+
⚑ **Only some of what lives there is importable.** Hooks and helpers (`useProduct`, `useCart`, `productImages`, …) are named exports; a few operations exist **only on the client** `useStorefront()` returns — `submitReview`, `getProductReviews`, `listProducts`, `getProduct`, `applyCoupon`, `chooseShippingMethod`, `completeReturn`. Importing one by name throws `does not provide an export named …` and blanks the whole app: `const c = useStorefront(); await c.submitReview(…)`.
|
|
29
|
+
|
|
28
30
|
## Setup — once
|
|
29
31
|
|
|
30
32
|
Nearly every store has shared chrome, so **start from a pathless layout route** — it also keeps the admin outside the storefront's provider:
|
|
@@ -197,9 +199,10 @@ Build your layout from — all optional, **not one component style**:
|
|
|
197
199
|
- **Reviews, only if the store wants them** — no review UI is a complete outcome (then no star ratings on cards either: an average of nothing is `0`). ⚑ **Both shapes are exact** — entity-style names (`content`, `reviewer_name`) throw on submit and render blank in the list:
|
|
198
200
|
|
|
199
201
|
```jsx
|
|
202
|
+
const client = useStorefront(); // submitReview is a CLIENT METHOD, not a barrel export
|
|
200
203
|
p.reviews // { items, page, per_page, has_next }
|
|
201
204
|
p.reviews.items[0] // { id, reviewer, review, rating, verified, created_date }
|
|
202
|
-
await submitReview({ product_id, review, rating, reviewer, email }) // NOT content/reviewer_name/reviewer_email
|
|
205
|
+
await client.submitReview({ product_id, review, rating, reviewer, email }) // NOT content/reviewer_name/reviewer_email
|
|
203
206
|
```
|
|
204
207
|
|
|
205
208
|
Text or stars — either alone submits. ⚑ **The form renders for every visitor by default** (guests supply an email; hide it when signed in) — login-gate it only when the store asks. ⚑ Derive the confirmation from the response's `status` (`"approved"` vs `"hold"`) — a hardcoded "awaiting approval" lies to every auto-approving store — and refresh the list after, or the review doesn't appear. Codes, policies, moderation: [`../references/reviews.md`](../references/reviews.md).
|
|
@@ -316,7 +319,7 @@ function CheckoutForm() {
|
|
|
316
319
|
|
|
317
320
|
**`<AddressFields>` is the one shipped component — use it, never hand-roll the address form.** It owns what hand-rolled forms get wrong: the state/province field appears with the right options once a country is picked (shipping rates and taxes match on country *plus* state, so a form without it mis-prices US/CA/AU orders with no error anywhere), every field keeps its `autoComplete` token (what makes browser autofill work), required marks arm on first blur, and the server's "we don't ship there" lands on the country field. `which="shipping"` renders null until `shipToDifferent` is on — the deliver-elsewhere checkbox itself is yours, wired to `c.shipToDifferent` / `c.setShipToDifferent`.
|
|
318
321
|
|
|
319
|
-
It ships **no CSS** bar a `max-width:100%` cap on the selects (an unstyled checkout must not scroll sideways): every element carries `data-part` (`address-fields`, `field`, `label`, `control`, `required`, `error`) plus `data-key` (the field) and `data-span` (1 or 2 — the field's natural width in a two-column grid), so style it in your `index.css` via `[data-part]` selectors or pass `className`/`classes={{ field, label, control, error }}`. ⚑ **`data-part` sits on the element, not a wrapper** — `select[data-part="control"]`, never `[data-part="control"] input`: the descendant form matches nothing and ships the form unstyled. Props: `includeCompany` (false), `includePhone` (true), `omit={["…"]}`, `labels={{ postcode: "ZIP code" }}`, `selectPlaceholder`, and two escape hatches — `inputRender` swaps the control only (spread the handed `dom` props onto your input), `fieldRender` replaces the whole labeled block. `c.missingBillingFields` stays the live list of what is missing, for your own per-field marks.
|
|
322
|
+
⚑ **Pass `classes.control`** — an unstyled `<input>` still reads as deliberate; a `<select>` does not, so skipping this one prop leaves the checkout looking broken on exactly one field. It ships **no CSS** bar a `max-width:100%` cap on the selects (an unstyled checkout must not scroll sideways): every element carries `data-part` (`address-fields`, `field`, `label`, `control`, `required`, `error`) plus `data-key` (the field) and `data-span` (1 or 2 — the field's natural width in a two-column grid), so style it in your `index.css` via `[data-part]` selectors or pass `className`/`classes={{ field, label, control, error }}`. ⚑ **`data-part` sits on the element, not a wrapper** — `select[data-part="control"]`, never `[data-part="control"] input`: the descendant form matches nothing and ships the form unstyled. Props: `includeCompany` (false), `includePhone` (true), `omit={["…"]}`, `labels={{ postcode: "ZIP code" }}`, `selectPlaceholder`, and two escape hatches — `inputRender` swaps the control only (spread the handed `dom` props onto your input), `fieldRender` replaces the whole labeled block. `c.missingBillingFields` stays the live list of what is missing, for your own per-field marks.
|
|
320
323
|
|
|
321
324
|
⚑ **The `stage === "submitted"` guard goes above the empty-cart branch** — placing an order clears the cart before the browser navigates, and without the guard the page flashes an empty bag over a just-placed order.
|
|
322
325
|
|
|
@@ -5,7 +5,7 @@ skip_when: "commerce/seed-store has already returned success for this store and
|
|
|
5
5
|
forget_when: "The seed response is received and recorded (slugs + warnings), and the checklist at the bottom passes."
|
|
6
6
|
carry_forward:
|
|
7
7
|
- "Product slugs come from the seed response's catalog.products[] — link pages by slug, never by a client-side map."
|
|
8
|
-
- "Payments: report at handover where they landed (default = offline on, card off) — the owner must never learn it from a customer."
|
|
8
|
+
- "Payments: report at handover where they landed (default = offline on, card off) — the owner must never learn it from a customer. Shipping the Stripe file is not a Stripe connection; only the user can authorise that."
|
|
9
9
|
- "Turning card payments on or off later is one more seed call: { payment_methods: [\"offline\", \"card\"] }."
|
|
10
10
|
- "Seed-time `locations` is THE shipping path; patching commerce.ShippingTaxLocation is the day-2 route."
|
|
11
11
|
---
|
|
@@ -131,7 +131,7 @@ Online card payments are **off by default**: the seeded store takes the manual `
|
|
|
131
131
|
| Paid another way (transfer, COD, invoice, pickup) | nothing to do — the default is exactly this |
|
|
132
132
|
| Payments not mentioned | leave the default, **state it at handover** |
|
|
133
133
|
|
|
134
|
-
*Cards only:* **if it is Stripe the code is already written** — copy `base44/shared/commerce/card-payment.stripe.ts` over `base44/shared/commerce/card-payment.ts` **whole** (`fs.copyFileSync`; a partial edit breaks every function's deploy), then enable via `payment_methods`. Nothing needs filling in. Any other provider: [`../references/online-payments.md`](../references/online-payments.md).
|
|
134
|
+
*Cards only:* **if it is Stripe the code is already written** — copy `base44/shared/commerce/card-payment.stripe.ts` over `base44/shared/commerce/card-payment.ts` **whole** (`fs.copyFileSync`; a partial edit breaks every function's deploy), then enable via `payment_methods`. Nothing needs filling in. ⚑ **That is the code, not a connection** — the app is not connected to Stripe until the user authorises it and its secret key reaches the app's secrets, which only they can do. Say the card path is *ready* and ask them to connect Stripe; never report it as connected, and never ask for a key in chat. Any other provider: [`../references/online-payments.md`](../references/online-payments.md).
|
|
135
135
|
|
|
136
136
|
## Done — forget this file
|
|
137
137
|
|
|
@@ -93,7 +93,7 @@ Both lists are inventories of what the data supports — **not a layout and not
|
|
|
93
93
|
|
|
94
94
|
**Card:** image, name, `price.label`, a sale badge from `on_sale`, stars from `average_rating`/`rating_count`, out-of-stock from `stock_status`, one or two ribbons — plus anything else on the row (`weight`, `dimensions`, `productSpecs`). Link the whole card through; the layout is yours.
|
|
95
95
|
|
|
96
|
-
**Product page:** gallery, name, price, variant selectors, stock, `short_description` then `description`, SKU, categories breadcrumb, ribbons, reviews, upsells/cross-sells. All but the markup is handed to you: `productImages(product)` + your own active index, `variantAxes(view, pick)`, `useAddToCart`, `productSpecs(product)`, `p.reviews` (+ `
|
|
96
|
+
**Product page:** gallery, name, price, variant selectors, stock, `short_description` then `description`, SKU, categories breadcrumb, ribbons, reviews, upsells/cross-sells. All but the markup is handed to you: `productImages(product)` + your own active index, `variantAxes(view, pick)`, `useAddToCart`, `productSpecs(product)`, `p.reviews` (+ `useStorefront().submitReview`), and `p.upsells`/`p.crossSells` — added with `useCart().addItem`, matching "already in cart" by product id.
|
|
97
97
|
|
|
98
98
|
**Attributes and modifiers are individually designable.** `productSpecs(product)` rows are `{key, label, titleLabel, value}` plus an inferred `type` — `"numeric"` / `"duration"` (with `number` and `unit` split out), `"location"`, `"list"` (with `items`), `"text"` — so the rendering branch is already made for you: a weight can be a figure, a composition bars, a provenance a place. ⚑ **Never `.map()` the whole list into one grey label/value table**; design the two or three rows this catalog is judged on and let the rest fall through to a plain row. ⚑ **Look a row up with `findSpec(rows, "care")`**, which ignores case, spaces, `_` and `-`: meta keys are free text typed per product (`care`, `Care`, `Care Instructions`), so an equality test on `label` — or on one spelling of `key` — silently never fires and the feature renders its fallback forever; `titleLabel` is the display-cased form, for a heading. What a row *looks* like is a per-catalog design decision — not one uniform chip row per axis and one grey table ([`../install/02-storefront.md`](../install/02-storefront.md)); §5's rules govern selector *behaviour*, never its form.
|
|
99
99
|
|
|
@@ -17,7 +17,7 @@ Reviews are **part of the happy path**, not an extra: the backend always shipped
|
|
|
17
17
|
Both live on the storefront client in `@/commerce/utils` — in React, `useStorefront()` is that client:
|
|
18
18
|
|
|
19
19
|
- **`getProductReviews(slugOrRef, { page, per_page })`** → `{ items, page, per_page, has_next, average_rating, rating_count }`. The same reviews `get-product` returns — page or refresh the list without re-fetching the page; `useProduct(slug, { reviewsPerPage })` sizes the first one.
|
|
20
|
-
- **`submitReview({ product_id, review?, rating?, reviewer?, email? })`** → `{ review_id, status, verified }`. **Text or stars — at least one**: `review` is the body text, `rating` is **1–5 stars** (0 counts as unrated); stars-only and text-only are both valid, only both missing rejects. `reviewer` is the display name. ⚑ **One set of names, both directions** — the payload above, rows back as `{ id, reviewer, review, rating, verified, created_date }`. Not the entity's columns: a near-miss (`content`, `reviewer_name`, `reviewer_email`, …) throws client-side rather than being aliased, since the same wrong names would render the list blank too. It **rejects** with `email_required` | `review_incomplete` (neither text nor stars) | `invalid_rating` | `not_found` — catch it, read `storefrontErrorCode(e)`, and land each code on its own field, so a failed submit says what to fix instead of resolving into nothing. After an approved submission, refresh the list yourself so the review actually appears.
|
|
20
|
+
- **`useStorefront().submitReview({ product_id, review?, rating?, reviewer?, email? })`** → `{ review_id, status, verified }`. ⚑ **A client method, never a barrel export** — importing the name itself from `@/commerce/storefront` throws `does not provide an export named …` and the whole app renders blank. **Text or stars — at least one**: `review` is the body text, `rating` is **1–5 stars** (0 counts as unrated); stars-only and text-only are both valid, only both missing rejects. `reviewer` is the display name. ⚑ **One set of names, both directions** — the payload above, rows back as `{ id, reviewer, review, rating, verified, created_date }`. Not the entity's columns: a near-miss (`content`, `reviewer_name`, `reviewer_email`, …) throws client-side rather than being aliased, since the same wrong names would render the list blank too. It **rejects** with `email_required` | `review_incomplete` (neither text nor stars) | `invalid_rating` | `not_found` — catch it, read `storefrontErrorCode(e)`, and land each code on its own field, so a failed submit says what to fix instead of resolving into nothing. After an approved submission, refresh the list yourself so the review actually appears.
|
|
21
21
|
|
|
22
22
|
## What ships
|
|
23
23
|
|
|
@@ -125,6 +125,7 @@ export function ShippingMethodPicker({ checkout: checkoutProp, children }) {
|
|
|
125
125
|
* value the selected slug ("" while none)
|
|
126
126
|
* select (slug) => void
|
|
127
127
|
* selected the selected gateway entry, or null
|
|
128
|
+
* mustChoose more than one gateway → render them as a picker
|
|
128
129
|
* single exactly one gateway — already selected
|
|
129
130
|
* hint { code: "none_available", severity, serverMessage } | null —
|
|
130
131
|
* no gateways at all: say checkout is unavailable, in your words
|
|
@@ -160,6 +161,10 @@ export function PaymentMethodPicker({ checkout: checkoutProp, children }) {
|
|
|
160
161
|
value,
|
|
161
162
|
select,
|
|
162
163
|
selected: decorated.find((g) => g.selected) ?? null,
|
|
164
|
+
// Symmetrical with ShippingMethodPicker: a checkout that gates its radios
|
|
165
|
+
// on `mustChoose` renders NOTHING when the field is missing, and a payment
|
|
166
|
+
// picker that renders nothing is a checkout no one can complete.
|
|
167
|
+
mustChoose: gateways.length > 1,
|
|
163
168
|
single: single ?? (gateways.length === 1), // fallback: a hand-built checkout object
|
|
164
169
|
hint,
|
|
165
170
|
});
|
|
@@ -65,6 +65,15 @@ export function storefrontErrorMessage(e) {
|
|
|
65
65
|
}
|
|
66
66
|
|
|
67
67
|
export function createStorefront(base44, { storageKey = "cart_token", storage } = {}) {
|
|
68
|
+
// Fail at construction, not four calls later. Without this the first symptom
|
|
69
|
+
// is "Cannot read properties of undefined (reading 'functions')" thrown from
|
|
70
|
+
// inside a cart call, which reads as a kit bug rather than a missing prop.
|
|
71
|
+
if (!base44 || typeof base44.functions?.invoke !== "function") {
|
|
72
|
+
throw new Error(
|
|
73
|
+
"StorefrontProvider requires the base44 client: <StorefrontProvider base44={base44}> " +
|
|
74
|
+
'(import { base44 } from "@/api/base44Client").',
|
|
75
|
+
);
|
|
76
|
+
}
|
|
68
77
|
const bag = storage ?? (typeof localStorage !== "undefined" ? localStorage : null);
|
|
69
78
|
|
|
70
79
|
// Every function returns the envelope { success, data }; with the SDK the
|