@base44/app-plugin-commerce 0.2.1 → 0.2.3
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 +6 -6
- package/base44/agents/commerce/StoreAdmin.jsonc +1 -1
- package/base44/entities/commerce.OrderRefund.jsonc +1 -1
- package/base44/entities/commerce.PaymentGateway.jsonc +1 -1
- package/base44/entities/commerce.Webhook.jsonc +1 -1
- package/base44/functions/commerce/admin-products/entry.ts +1 -1
- package/base44/functions/commerce/admin-reports/entry.ts +1 -1
- package/base44/functions/commerce/payments/entry.ts +2 -2
- package/base44/functions/commerce/seed-store/defaults.ts +1 -1
- package/base44/functions/commerce/seed-store/seed-catalog.ts +64 -6
- package/base44/functions/commerce/storefront-catalog/entry.ts +1 -1
- package/base44/functions/commerce/storefront-checkout/entry.ts +1 -1
- package/base44/shared/commerce/card-payment.stripe.ts +29 -9
- package/base44/shared/commerce/card-payment.ts +1 -1
- package/base44/shared/commerce/payments.ts +2 -2
- package/base44/shared/commerce/scan.ts +1 -1
- package/base44/shared/commerce/sequence.ts +2 -2
- package/package.json +1 -1
- package/scripts/install.js +1 -1
- package/skills/commerce/SKILL.md +36 -26
- package/skills/commerce/docs/api-storefront.md +6 -6
- package/skills/commerce/install/01-install.md +2 -2
- package/skills/commerce/install/02-storefront.md +381 -94
- package/skills/commerce/install/03-data.md +62 -19
- package/skills/commerce/references/catalog-rendering.md +6 -6
- package/skills/commerce/references/online-payments.md +5 -6
- package/skills/commerce/references/reviews.md +5 -5
- package/skills/commerce/references/shipping-and-tax.md +2 -0
- package/src/commerce/admin/README.md +6 -3
- package/src/commerce/admin/layout/AuthGuard.jsx +1 -1
- package/src/commerce/admin/pages/products/Reviews.jsx +1 -1
- package/src/commerce/admin/pages/settings/InventorySettings.jsx +1 -1
- package/src/commerce/admin/pages/settings/PaymentsSettings.jsx +1 -1
- package/src/commerce/storefront/index.js +45 -33
- package/src/commerce/storefront/useAddressForm.js +41 -8
- package/src/commerce/storefront/useCartLine.js +37 -0
- package/src/commerce/storefront/useCheckout.jsx +18 -6
- package/src/commerce/storefront/useOrderReturn.js +36 -10
- package/src/commerce/storefront/useProduct.js +72 -0
- package/src/commerce/storefront/useProductGallery.js +4 -0
- package/src/commerce/utils/index.js +9 -6
- package/src/commerce/utils/shipping-promos.js +2 -2
- package/src/commerce/utils/specs.js +26 -0
- package/src/commerce/utils/variants.js +49 -2
- package/src/commerce/storefront/blocks/AddToCartBlock.jsx +0 -86
- package/src/commerce/storefront/blocks/AddressFieldsBlock.jsx +0 -96
- package/src/commerce/storefront/blocks/BreadcrumbsBlock.jsx +0 -52
- package/src/commerce/storefront/blocks/CartLinesBlock.jsx +0 -98
- package/src/commerce/storefront/blocks/CheckoutBlock.jsx +0 -247
- package/src/commerce/storefront/blocks/CouponFieldBlock.jsx +0 -84
- package/src/commerce/storefront/blocks/OrderReceivedBlock.jsx +0 -129
- package/src/commerce/storefront/blocks/ProductGalleryBlock.jsx +0 -66
- package/src/commerce/storefront/blocks/ProductSpecsBlock.jsx +0 -33
- package/src/commerce/storefront/blocks/ProductStripBlock.jsx +0 -55
- package/src/commerce/storefront/blocks/QuantityStepper.jsx +0 -62
- package/src/commerce/storefront/blocks/ReviewsBlock.jsx +0 -191
- package/src/commerce/storefront/blocks/TotalsBlock.jsx +0 -42
- package/src/commerce/storefront/blocks/VariantSelectorBlock.jsx +0 -81
- package/src/commerce/storefront/blocks/index.js +0 -44
|
@@ -32,19 +32,45 @@ try {
|
|
|
32
32
|
store_name: "Aurora Threads",
|
|
33
33
|
currency: "EUR",
|
|
34
34
|
products: [
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
short_description: "A soft, breathable everyday tee.",
|
|
43
|
-
description: "<p>Cut from combed cotton…</p>", // HTML, rendered as rich text
|
|
44
|
-
},
|
|
35
|
+
// ── 1. Minimal. `name` is the only required key; a real store wants a
|
|
36
|
+
// price too, and everything else below is opt-in.
|
|
37
|
+
{ name: "Linen Scarf", regular_price: 45 },
|
|
38
|
+
|
|
39
|
+
// ── 2. Every product key the seeder accepts, on one product. Take the
|
|
40
|
+
// lines a product actually needs and drop the rest — there is no
|
|
41
|
+
// "complete" product to fill in.
|
|
45
42
|
{ name: "Runner Sneaker",
|
|
46
|
-
sku: "SNK-RUN",
|
|
43
|
+
sku: "SNK-RUN", // optional; makes re-runs idempotent
|
|
44
|
+
slug: "runner-sneaker", // derived from name when omitted
|
|
45
|
+
status: "publish", // draft | pending | private | publish (seeder defaults to publish)
|
|
46
|
+
featured: true, // → useProductList({ featured: true }) rails
|
|
47
|
+
|
|
47
48
|
regular_price: 89, // inherited by variations that don't override
|
|
49
|
+
sale_price: 79, // sets on_sale; the storefront strikes through regular_price
|
|
50
|
+
date_on_sale_from: "2026-03-01T00:00:00Z", // optional sale window (omit → sale is open-ended)
|
|
51
|
+
date_on_sale_to: "2026-03-31T23:59:59Z",
|
|
52
|
+
|
|
53
|
+
stock_quantity: 12, // implies manage_stock: true
|
|
54
|
+
manage_stock: true, // only needed to force tracking with no quantity
|
|
55
|
+
low_stock_amount: 3, // overrides the store's threshold
|
|
56
|
+
backorders: "no", // no | notify | yes
|
|
57
|
+
sold_individually: false, // true → max 1 per order (kills the qty stepper)
|
|
58
|
+
|
|
59
|
+
short_description: "Cushioned everyday runner.",
|
|
60
|
+
description: "<p>Cut from recycled knit…</p>", // HTML, rendered as rich text
|
|
61
|
+
images: ["https://…/sneaker.jpg"], // URLs or { src, alt } — see Images below
|
|
62
|
+
|
|
63
|
+
categories: ["Shoes"], // get-or-created by display name
|
|
64
|
+
ribbons: ["Best Seller"], // flat labels, not a hierarchy
|
|
65
|
+
|
|
66
|
+
// Descriptive properties → the spec table (`productSpecs(product)`).
|
|
67
|
+
// NOT variant axes and NOT ribbons: they describe the product, they
|
|
68
|
+
// don't select anything. Values are strings; a leading `_` hides a row.
|
|
69
|
+
meta_data: [
|
|
70
|
+
{ key: "Material", value: "Recycled knit upper" },
|
|
71
|
+
{ key: "Care", value: "Machine wash cold" },
|
|
72
|
+
],
|
|
73
|
+
|
|
48
74
|
attributes: [ // the axes → one selector each in the storefront
|
|
49
75
|
{ name: "Size", options: ["41", "42"] },
|
|
50
76
|
{ name: "Color", options: ["Black", "White"] },
|
|
@@ -55,6 +81,21 @@ try {
|
|
|
55
81
|
{ options: { Size: "42", Color: "White" }, regular_price: 94, stock_quantity: 3,
|
|
56
82
|
image: "https://…/sneaker-white.jpg" }, // per-variation image for a visual axis
|
|
57
83
|
],
|
|
84
|
+
|
|
85
|
+
weight: 0.8, // store's weight/dimension units
|
|
86
|
+
dimensions: { length: 30, width: 20, height: 12 },
|
|
87
|
+
tax_status: "taxable", // taxable | none
|
|
88
|
+
tax_group: "Products", // a tax group from the matched location
|
|
89
|
+
|
|
90
|
+
virtual: false, // true → no shipping (a service, a booking)
|
|
91
|
+
downloadable: false, // ↓ the three download keys apply only when true
|
|
92
|
+
downloads: [{ name: "Care guide", file_url: "https://…/care.pdf" }],
|
|
93
|
+
download_limit: 3, // -1 / omit = unlimited
|
|
94
|
+
download_expiry: 30, // days after purchase
|
|
95
|
+
|
|
96
|
+
// Accepted, but they take Product *ids* — which only exist after this
|
|
97
|
+
// call. Cross-link in a later admin-products update, not here.
|
|
98
|
+
upsell_ids: [], cross_sell_ids: [],
|
|
58
99
|
},
|
|
59
100
|
],
|
|
60
101
|
coupons: [{ code: "WELCOME10", discount_type: "percent", amount: 10 }],
|
|
@@ -69,6 +110,8 @@ try {
|
|
|
69
110
|
|
|
70
111
|
**Running this through a code-execution tool? Return `res.data`, never the raw response.** `invoke` resolves to the raw HTTP response, which carries circular request/response objects — `return res` (or stringifying a thrown error whole) fails with `Converting circular structure to JSON` *even when the seed succeeded*, and a thrown error needs `e.response?.data` for the same reason.
|
|
71
112
|
|
|
113
|
+
**The two products above are the range, not a template.** `name` is the only required key: every other line is opt-in, and each product in the array picks its own set independently — a plain product stays two keys long next to a fully specified one, and the fields it omits simply don't apply to it (no attributes ⇒ it sells no variants; no `meta_data` ⇒ no spec table; no `downloads` ⇒ nothing to deliver). Seed each product with the keys its own catalog entry actually has, and leave the rest out rather than padding with empty values.
|
|
114
|
+
|
|
72
115
|
Reference taxonomy by **display name** (categories, ribbons, attributes, options) — existing records are matched case-insensitively and reused. The seeder derives slugs, checks SKU uniqueness, prices variations, and **rolls the parent's `price`/`regular_price`/`on_sale` up from the cheapest publishable variant** — never set a variant parent's price yourself. Unknown keys are rejected, so typos surface instead of vanishing.
|
|
73
116
|
|
|
74
117
|
**Idempotency:** a product whose `sku` (or derived slug) exists is skipped and reported — safe to retry after a timeout, or to seed into a store that already has products. **Limits:** ≤100 products, ≤500 variations per call, ≤50 per product, ≤50 locations. Bad payloads fail **400** `invalid_payload` with `errors: [{ path, error }]`, a modified schema **422** `schema_incompatible` — both before anything is written.
|
|
@@ -76,8 +119,8 @@ Reference taxonomy by **display name** (categories, ribbons, attributes, options
|
|
|
76
119
|
The response reports everything; these matter downstream:
|
|
77
120
|
|
|
78
121
|
```jsonc
|
|
79
|
-
{ "catalog": { "products_created": 2, "variations_created":
|
|
80
|
-
"products": [{ "name": "Runner Sneaker", "id": "…", "slug": "runner-sneaker", "variation_count":
|
|
122
|
+
{ "catalog": { "products_created": 2, "variations_created": 2,
|
|
123
|
+
"products": [{ "name": "Runner Sneaker", "id": "…", "slug": "runner-sneaker", "variation_count": 2 }] },
|
|
81
124
|
"store_name": { "value": "Aurora Threads", "action": "created" },
|
|
82
125
|
"payment_methods": null, // null = the default (offline on, card off)
|
|
83
126
|
"warnings": [] } // always present; read it — see the shipping section
|
|
@@ -97,7 +140,7 @@ locations: [
|
|
|
97
140
|
```
|
|
98
141
|
|
|
99
142
|
- **`continents: ["EU"]`** spares you a 51-code country list. The seven codes are `AF` `AN` `AS` `EU` `NA` `OC` `SA`, and `EU` is the *continent* Europe, not the European Union. An unknown code fails `400 invalid_payload` with the known list.
|
|
100
|
-
- **`rest_of_world: true`** is the catch-all — the location that matches every address no other location claims. It cannot also carry `countries`/`continents`/`regions
|
|
143
|
+
- **`rest_of_world: true`** is the catch-all — the location that matches every address no other location claims. It cannot also carry `countries`/`continents`/`regions`, and there is **no country code that means "everywhere"**: `countries: ["*"]`/`["ALL"]`/`["ROW"]`, and alpha-3 codes like `["USA"]`, are rejected **400** (every scope code is validated against the matcher's own country/continent/state data).
|
|
101
144
|
- Other scopes: `countries: ["IL", "DE"]`, or explicit `regions: [{ type: "state", code: "US:CA" }]`. Matching is **country + state only** — no postcode or city rules exist.
|
|
102
145
|
- One matched location supplies **both** the shipping rates and the tax groups: `shipping_rates: [{ name, cost, free_over? }]`, `tax_groups: [{ name, rates: [{ name, rate }] }]`, `shipping_tax: { type: "percent"|"fixed", value }`.
|
|
103
146
|
|
|
@@ -133,16 +176,16 @@ Both failure modes are silent at seed time and visible only in the store, so che
|
|
|
133
176
|
|
|
134
177
|
| What the request says | Decision |
|
|
135
178
|
|---|---|
|
|
136
|
-
| A provider is named
|
|
137
|
-
| Selling online implied, no provider named | cards are a reasonable read of *what* to offer, never of *when*:
|
|
179
|
+
| A provider is named ("use Stripe") | copy the provider file, enable `card` — whenever convenient; it blocks nothing |
|
|
180
|
+
| Selling online implied, no provider named | cards are a reasonable read of *what* to offer, never of *when*: raise the provider **after** the store works |
|
|
138
181
|
| Paid another way (transfer, COD, invoice, pickup, quotes) | nothing to do — the default already is exactly this |
|
|
139
182
|
| Payments not mentioned at all | leave the default and **mention it at handover** |
|
|
140
183
|
|
|
141
|
-
**Timing: late, never the opening move.** Payments have no dependency edge into anything — the admin, the seed and the entire buy path down to `place-order` work with no provider — so
|
|
184
|
+
**Timing: late, never the opening move.** Payments have no dependency edge into anything — the admin, the seed and the entire buy path down to `place-order` work with no provider — so putting the provider connection before the store exists only stalls the build. Any instruction that treats a payment provider as a required install step is superseded by this section.
|
|
142
185
|
|
|
143
186
|
**Say where payments landed** in your handover summary, including "offline only, cards off". A store's owner should never discover their payment configuration from a customer who couldn't pay.
|
|
144
187
|
|
|
145
|
-
*Only for a store that opted into cards:*
|
|
188
|
+
*Only for a store that opted into cards:* **if it is Stripe, the code is already written** — `base44/shared/commerce/card-payment.stripe.ts` is a complete implementation, used **as-is**. Copy it over `base44/shared/commerce/card-payment.ts` (`fs.copyFileSync`, whole file — never a partial edit, which leaves duplicate exports and breaks every commerce function's deploy) and enable the gateway with `payment_methods: ["offline", "card"]`. Nothing in it needs filling in and no key belongs in the code; it reads the credential the app's Stripe connection publishes. Any other provider means implementing four functions in that one file — [`../references/online-payments.md`](../references/online-payments.md).
|
|
146
189
|
|
|
147
190
|
## Done — forget this file
|
|
148
191
|
|
|
@@ -150,7 +193,7 @@ Both failure modes are silent at seed time and visible only in the store, so che
|
|
|
150
193
|
- [ ] `warnings` in the response is empty, or every warning is deliberate and stated to the user.
|
|
151
194
|
- [ ] Shipping is expressed in `locations` (with a catch-all if the store ships worldwide), not patched into entities afterwards.
|
|
152
195
|
- [ ] `coupons` seeded only if a coupon field exists ([`./02-storefront.md`](./02-storefront.md)).
|
|
153
|
-
- [ ] Cards are either off, or on with
|
|
196
|
+
- [ ] Cards are either off, or on with the provider file copied whole and the `card` gateway enabled.
|
|
154
197
|
- [ ] Product slugs from `catalog.products[]` recorded, and the storefront links by them.
|
|
155
198
|
- [ ] If the brief named tiered rates, each named region prices to its rate (the `set-shipping-address` check above).
|
|
156
199
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
stage: reference
|
|
3
3
|
read_when: "You are asking which fields a card or a product page can actually show, or hit a variant edge case (unavailable combinations, attributes with no variations, price ranges, linkable selections)."
|
|
4
|
-
skip_when: "The listing and product page render correctly from useProductList / useProduct and the
|
|
4
|
+
skip_when: "The listing and product page render correctly from useProductList / useProduct and the render-model helpers — the quick start in ../install/02-storefront.md covers the happy path."
|
|
5
5
|
forget_when: "Cards and the product page render the fields you intended, variant selection resolves to a variation, and add-to-cart succeeds."
|
|
6
6
|
carry_forward:
|
|
7
7
|
- "There is no product `type` field: a non-empty `attributes[]` is what makes a product sell variants, and such a product is only sellable via a `variation_id`."
|
|
@@ -31,7 +31,7 @@ A listing **row** is the product record itself (minus paywalled fields) plus res
|
|
|
31
31
|
| `images[]`, `featured`, `short_description`, `description` | ✅ | ✅ | Cards normally use `images[0]` + `short_description`; every entry is an **object** — §2 |
|
|
32
32
|
| `sku`, `stock_status`, `stock_quantity`, `manage_stock`, `backorders` | ✅ | ✅ | |
|
|
33
33
|
| `average_rating`, `rating_count`, `total_sales` | ✅ | ✅ | Enough for stars on a card; the reviews themselves are not in a row |
|
|
34
|
-
| `downloadable`, `virtual`, `weight`, `dimensions`, `attributes[]`, `meta_data` | ✅ | ✅ | `attributes[]` is the variant axes; `meta_data` is the descriptive **modifiers** (
|
|
34
|
+
| `downloadable`, `virtual`, `weight`, `dimensions`, `attributes[]`, `meta_data` | ✅ | ✅ | `attributes[]` is the variant axes; `meta_data` is the descriptive **modifiers** (`productSpecs(product)` turns them into spec-table rows) |
|
|
35
35
|
| **`ribbons`** (resolved), `ribbon_ids`, `category_ids` | ✅ | ✅ | Rows carry `{id, name}` ribbons; `get-product` returns the full records |
|
|
36
36
|
| **`categories`** (resolved) | ❌ *ids only* | ✅ | §6 to add them to rows |
|
|
37
37
|
| **`variations[]`** (per-variant price/stock/image/attributes) | ❌ | ✅ | Why a product with variants can't be fully priced from a row |
|
|
@@ -53,14 +53,14 @@ A product with attributes is **only** sellable through a variant: `add-item` wit
|
|
|
53
53
|
Three rules used to be prose here and are now enforced by exports — use them and they can't drift between views:
|
|
54
54
|
|
|
55
55
|
- **From-price.** `admin-products` (and the seeder) roll a parent's `regular_price`/`price`/`on_sale` up from the cheapest publishable variant on every save, so the parent price is real, sortable and filterable — but it is the **lowest** price, not *the* price. `productPrice(rowOrView, {formatMoney})` / `useProductPrice(rowOrView)` accept **either** a listing row or a `resolveSelection` view and return `{label, compareAtLabel, onSale, isFrom, isRange, min, max}`: "From €19.99" on a card, a range on an unresolved page, the exact price once resolved.
|
|
56
|
-
- **Images are objects, and may be absent.** Every stored image is `{src, name, alt}`, never a URL string, and `images` can legitimately be empty. `productImages(product)` / `normalizeImage(entry)` return clean entries (non-empty `src`, defaulted `alt`), and an empty array is the *render your placeholder* signal — `useProductGallery`
|
|
56
|
+
- **Images are objects, and may be absent.** Every stored image is `{src, name, alt}`, never a URL string, and `images` can legitimately be empty. `productImages(product)` / `normalizeImage(entry)` return clean entries (non-empty `src`, defaulted `alt`), and an empty array is the *render your placeholder* signal — `useProductGallery` builds on them (`hasImages`). Passing the object itself to an `<img src>` fails the load and shows the placeholder for every product in the store.
|
|
57
57
|
- **Modifiers are not attributes.** `meta_data` (Material, Care, GTIN) is a spec table — never a selector, never a ribbon.
|
|
58
58
|
|
|
59
59
|
## 3. What each view renders
|
|
60
60
|
|
|
61
|
-
**Card:** image, name, `price.label`, sale badge from `on_sale`, stars from `average_rating`/`rating_count`, out-of-stock state from `stock_status`, one or two ribbons. Link the whole card to the product page
|
|
61
|
+
**Card:** image, name, `price.label`, sale badge from `on_sale`, stars from `average_rating`/`rating_count`, out-of-stock state from `stock_status`, one or two ribbons. Link the whole card to the product page — like every other piece of storefront UI, the card is entirely yours to design.
|
|
62
62
|
|
|
63
|
-
**Product page:** gallery, name, price, variant selectors, stock, `short_description` then `description`, SKU, categories breadcrumb, ribbons, reviews, then upsells/cross-sells. Everything except
|
|
63
|
+
**Product page:** gallery, name, price, variant selectors, stock, `short_description` then `description`, SKU, categories breadcrumb, ribbons, reviews, then upsells/cross-sells. Everything except the markup has a hook or helper: `useProductGallery`, `variantAxes(view, pick)`, `useAddToCartButton`, `productSpecs(product)`, `useProductReviews`, `p.upsells`/`p.crossSells`.
|
|
64
64
|
|
|
65
65
|
## 4. Ribbons — in **both** views
|
|
66
66
|
|
|
@@ -73,7 +73,7 @@ Ribbons are flat, cross-cutting labels ("Best Seller", "New", "Gift"); categorie
|
|
|
73
73
|
|
|
74
74
|
## 5. Variant selection
|
|
75
75
|
|
|
76
|
-
The one interaction agents reliably get wrong.
|
|
76
|
+
The one interaction agents reliably get wrong. `variantAxes(view, pick)` hands you the render-ready model that encodes the rule: **one control per axis, never a list of variations** (`Red / S`, `Red / M`, … is `n × m` noise that hides the product's structure), and an option that isn't buyable renders **disabled, not removed** (`o.disabled`; `o.outOfStock` stays visible, just marked). Map it to any control — buttons, swatches, dropdowns — and call `o.pick()` on select.
|
|
77
77
|
|
|
78
78
|
Underneath, `useProduct` composes the framework-free helpers in `src/commerce/utils/variants.js` — `defaultSelection` → `selectOption` on a click → `resolveSelection` for the view. Every `product.attributes[]` entry is an **axis** in `position` order; every `variations[]` record is one combination. Bind the UI to `view`, not to `product.*`, or a selection changes nothing:
|
|
79
79
|
|
|
@@ -2,9 +2,10 @@
|
|
|
2
2
|
stage: reference
|
|
3
3
|
read_when: "The store opted into card payments and you are wiring the provider NOW."
|
|
4
4
|
skip_when: "The store takes offline payments (the default) — nothing here applies; the decision itself lives in ../install/03-data.md."
|
|
5
|
-
forget_when: "A test payment reaches /order-received as paid, or the provider file is copied
|
|
5
|
+
forget_when: "A test payment reaches /order-received as paid, or the provider file is copied and the `card` gateway is enabled."
|
|
6
6
|
carry_forward:
|
|
7
7
|
- "Wiring a provider = replace shared/commerce/card-payment.ts WHOLE (copy the shipped card-payment.<provider>.ts over it) — a partial edit breaks every commerce function's deploy with duplicate exports."
|
|
8
|
+
- "Stripe: the shipped card-payment.stripe.ts is used as-is once the app is connected to Stripe — connecting is outside the kit. Enabling the `card` gateway is the seed call."
|
|
8
9
|
- "Only the provider can say an order is paid: confirmation always goes through checkCardPaymentPaid against the provider's API."
|
|
9
10
|
---
|
|
10
11
|
|
|
@@ -34,11 +35,9 @@ fs.copyFileSync(
|
|
|
34
35
|
);
|
|
35
36
|
```
|
|
36
37
|
|
|
37
|
-
|
|
38
|
+
…and **enable the gateway**: `commerce/seed-store` with `{ payment_methods: ["offline", "card"] }`. The `card` row is seeded off, so that call is what makes card payment visible at checkout — the usual reason a wired provider "doesn't show up".
|
|
38
39
|
|
|
39
|
-
|
|
40
|
-
2. **Webhook endpoint** — register `https://<app-domain>/functions/commerce/payment-webhook` with Stripe for `checkout.session.completed` (dashboard, or `POST https://api.stripe.com/v1/webhook_endpoints` with `url` + `enabled_events[]` using the same secret key). **There is no signing secret to store**: the shipped `parseWebhook` treats events as nudges verified against Stripe's API (see below).
|
|
41
|
-
3. **Enable the gateway** — `commerce/seed-store` with `{ payment_methods: ["offline", "card"] }`. It is seeded off, so none of the above is visible at checkout until this runs.
|
|
40
|
+
That is the whole of it. The file expects the app to be connected to Stripe and reads the secret key that connection publishes; **connecting the app is outside this kit and not described here.** Nothing in the file needs filling in, and no key belongs in the code. A missing key is not silent — checkout answers `503 no_card_payment_provider`.
|
|
42
41
|
|
|
43
42
|
A kit update re-copies `shared/commerce/` and restores the stub — re-run the copy after updating. A different provider ships the same way (`card-payment.<provider>.ts` beside the stub); until one does, implement the four functions against its API per the rules below, with the Stripe file as the worked model.
|
|
44
43
|
|
|
@@ -56,7 +55,7 @@ With the gateway enabled and no provider behind it, picking Credit card answers
|
|
|
56
55
|
For a **custom** provider (the shipped files already obey all of these):
|
|
57
56
|
|
|
58
57
|
- **Write `card-payment.ts` whole** — one write of the complete file, never a `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 …"). The fix is always the whole-file write — which is also why the shipped provider files exist.
|
|
59
|
-
- **Credentials come from backend env** (`Deno.env.get(...)`) — never an entity, never the client — and are read **lazily inside the call**, so a store with the file but no
|
|
58
|
+
- **Credentials come from backend env** (`Deno.env.get(...)`) — never an entity, never the client — and are read **lazily inside the call**, so a store with the file but no credential yet answers a clean 503 instead of failing to boot every function that imports it.
|
|
60
59
|
- **Only the provider can say an order is paid.** `checkCardPaymentPaid` must ask the provider's API about the stored `reference` **and** check the payment names this order (its metadata `order_id` vs `order.id`) — that stops a reference to some other, genuinely paid payment being replayed against a different order.
|
|
61
60
|
- **Attach the metadata.** `createCardPayment` must put `order.id` and `order.order_key` on the payment's metadata (Stripe: `metadata` **and** `payment_intent_data.metadata`) — that echo is how `parseWebhook` names the order, and what the check above compares against.
|
|
62
61
|
- **Amounts**: `order.total` is in display units (`12.34`) with `order.currency`; convert to the provider's minor units yourself, remembering the zero-decimal currencies.
|
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
---
|
|
2
2
|
stage: reference
|
|
3
|
-
read_when: "You need review behaviour beyond
|
|
4
|
-
skip_when: "
|
|
3
|
+
read_when: "You need review behaviour beyond useProductReviews — moderation, auto-approval, a customer's own reviews, or a policy the three built-in ones don't cover."
|
|
4
|
+
skip_when: "useProductReviews is on the product page — that is list + submit + policy + the right confirmation copy already."
|
|
5
5
|
forget_when: "A review submits, appears (or is held) as the store's auto-approve setting dictates, and the aggregate rating renders."
|
|
6
6
|
carry_forward:
|
|
7
|
-
- "Reviews are part of the happy path: one
|
|
7
|
+
- "Reviews are part of the happy path: one hook. The confirmation copy must come from the submit response, never hardcoded."
|
|
8
8
|
---
|
|
9
9
|
|
|
10
10
|
# Reviews
|
|
11
11
|
|
|
12
|
-
Reviews are **part of the happy path**, not an extra: the backend always shipped complete, and
|
|
12
|
+
Reviews are **part of the happy path**, not an extra: the backend always shipped complete, and the whole UI contract is one hook. Unless the store explicitly doesn't want them, put them on the product page: `useProductReviews(product, { policy, user, requireRating, perPage })` — the list with paging (`items`, `hasNext`/`loadMore`), the aggregates (`averageRating`, `ratingCount`), the whole form contract (`form`/`setField`/`fieldErrors`/`valid`/`submit`/`message`) and the policy gate (`canReview`, `reviewBlockedReason`, `requiresEmail`); exact shapes in its JSDoc. The markup around it — stars, rows, the form — is yours, like all storefront UI.
|
|
13
13
|
|
|
14
14
|
## What ships
|
|
15
15
|
|
|
@@ -20,7 +20,7 @@ Reviews are **part of the happy path**, not an extra: the backend always shipped
|
|
|
20
20
|
|
|
21
21
|
## The `policy` prop
|
|
22
22
|
|
|
23
|
-
`policy` is the store's review rule as one
|
|
23
|
+
`policy` is the store's review rule as one option, replacing the patterns a storefront used to implement by hand. Pass your app's current user alongside it (`useProductReviews(product, { policy: "login", user })`) — the stricter policies need it:
|
|
24
24
|
|
|
25
25
|
| `policy` | Who may submit | Blocked as |
|
|
26
26
|
|---|---|---|
|
|
@@ -103,6 +103,8 @@ A payload carrying `locations` **suppresses** the seeded "Rest of the world" fal
|
|
|
103
103
|
|
|
104
104
|
`rest_of_world: true` is the catch-all *instead of* a scope — combining it with `countries`/`continents`/`regions` is a payload error, not a merge. (An empty `regions: []` still works and means the same.)
|
|
105
105
|
|
|
106
|
+
**There is no country code for "everywhere".** `countries: ["*"]`, `["ALL"]`, `["ROW"]` and alpha-3 codes like `["USA"]` are all rejected **400** `invalid_payload` — every scope code is checked against the same static data the matcher uses (`US`/`IL`/`DE` for countries, the seven continent codes, `US:CA` for states), so a code that could never match a real address fails at seed time instead of creating a location that silently matches nothing.
|
|
107
|
+
|
|
106
108
|
## Day-2 edits
|
|
107
109
|
|
|
108
110
|
No admin function owns locations, so there are two routes: **direct CRUD** on `commerce.ShippingTaxLocation` (admin-only RLS, bracket syntax — [`../docs/entities.md`](../docs/entities.md)), where `shipping_rates` is written whole so you must **mint stable `id`s yourself and never renumber existing ones** (orders reference them); or **the merchant's screen**, admin → Settings → Shipping & Tax (`settings/shipping-tax`), which edits regions, rates, groups and shipping tax directly.
|
|
@@ -4,8 +4,11 @@ React admin UI for the Base44 commerce template. Copy this
|
|
|
4
4
|
folder into a Base44 app built on the default template (Vite + React +
|
|
5
5
|
Tailwind + shadcn/ui + React Router) to get a full store back office.
|
|
6
6
|
|
|
7
|
-
>
|
|
8
|
-
>
|
|
7
|
+
> Docs live in the commerce skill, and every path below is **relative to that
|
|
8
|
+
> skill folder** (`.agents/skills/commerce/` once installed): install, mounting
|
|
9
|
+
> and role setup are `install/01-install.md`; the map for everything else is
|
|
10
|
+
> `SKILL.md`; the API references are `docs/api-admin.md` and
|
|
11
|
+
> `docs/api-storefront.md`.
|
|
9
12
|
|
|
10
13
|
## Mounting
|
|
11
14
|
|
|
@@ -39,7 +42,7 @@ Tailwind + shadcn/ui + React Router) to get a full store back office.
|
|
|
39
42
|
sample data when the store has no products yet. If `commerce/seed-store` was
|
|
40
43
|
already run during installation — including when an agent generated a real
|
|
41
44
|
catalog — the store counts as ready and this screen never shows; see
|
|
42
|
-
|
|
45
|
+
the commerce skill's install/03-data.md.
|
|
43
46
|
|
|
44
47
|
## External touchpoints
|
|
45
48
|
|
|
@@ -24,7 +24,7 @@ const AUTH_CHECK_TIMEOUT_MS = 8000;
|
|
|
24
24
|
|
|
25
25
|
/**
|
|
26
26
|
* Blocks the admin UI unless the caller is an authenticated user with
|
|
27
|
-
* role === "admin". Do NOT weaken this check — see
|
|
27
|
+
* role === "admin". Do NOT weaken this check — see the commerce skill's install/01-install.md.
|
|
28
28
|
* (Server-side RLS + requireAdmin() in functions enforce this independently.)
|
|
29
29
|
*
|
|
30
30
|
* Timing out resolves to **no user**, i.e. the sign-in screen — it never grants
|
|
@@ -263,7 +263,7 @@ export default function Reviews() {
|
|
|
263
263
|
/**
|
|
264
264
|
* The one server-enforced review setting: everything else (login-gating,
|
|
265
265
|
* verified-buyers-only, required ratings) is storefront policy — see
|
|
266
|
-
*
|
|
266
|
+
* the commerce skill's references/reviews.md.
|
|
267
267
|
*/
|
|
268
268
|
function AutoApproveToggle() {
|
|
269
269
|
const settings = useSettings();
|
|
@@ -51,7 +51,7 @@ export default function InventorySettings() {
|
|
|
51
51
|
<p className="text-xs text-muted-foreground">
|
|
52
52
|
Hold stock for unpaid orders for this many minutes. When the limit is reached, the
|
|
53
53
|
pending order is cancelled and its stock released. Release runs opportunistically —
|
|
54
|
-
see
|
|
54
|
+
see the commerce skill's references/operations.md.
|
|
55
55
|
</p>
|
|
56
56
|
</div>
|
|
57
57
|
|
|
@@ -209,7 +209,7 @@ export default function PaymentsSettings() {
|
|
|
209
209
|
wiring one means implementing the four functions in
|
|
210
210
|
shared/commerce/card-payment.ts (rules, and a Stripe
|
|
211
211
|
implementation to paste, in
|
|
212
|
-
|
|
212
|
+
the commerce skill's references/online-payments.md; the
|
|
213
213
|
payment webhook is premade). Enabled without that, picking it
|
|
214
214
|
at checkout answers 503 no_card_payment_provider.
|
|
215
215
|
Deliberately not shown to the store operator — it's developer
|
|
@@ -1,15 +1,20 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Storefront React layer — hooks and
|
|
3
|
-
*
|
|
4
|
-
*
|
|
2
|
+
* Storefront React layer — **headless**: hooks and render-prop components that
|
|
3
|
+
* own the store's logic and hand you the data; they render nothing and carry
|
|
4
|
+
* no styling. Every element, class and word of copy in the storefront is
|
|
5
|
+
* written by you, against these APIs. Ships with the Base44 Commerce Template
|
|
6
|
+
* next to the framework-free `@/commerce/utils` (which it builds on); needs
|
|
7
|
+
* React and nothing else.
|
|
5
8
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* checkout
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
9
|
+
* The split: **logic is premade, UI never is.** Checkout repricing, variant
|
|
10
|
+
* resolution, cart state, review policies, order-return verification — done
|
|
11
|
+
* here, and hand-rolling any of it is where storefront bugs cluster. What a
|
|
12
|
+
* checkout or a product page *looks like* is the store's identity, and no two
|
|
13
|
+
* stores should share it — so nothing here emits markup. Each hook returns a
|
|
14
|
+
* complete view-model (statuses to branch on, ready-to-map arrays, handlers,
|
|
15
|
+
* error objects), and each doc comment states the render rules that keep the
|
|
16
|
+
* store correct (e.g. an unbuyable variant option renders *disabled, not
|
|
17
|
+
* hidden*; a receipt page must render `paymentInstructions`).
|
|
13
18
|
*
|
|
14
19
|
* Setup (once, above every storefront route — it wraps <Routes>; placed as a
|
|
15
20
|
* child of <Routes> React Router throws "is not a <Route> component"):
|
|
@@ -18,40 +23,47 @@
|
|
|
18
23
|
* import { base44 } from "@/api/base44Client";
|
|
19
24
|
* <StorefrontProvider base44={base44}> <Routes>…</Routes> </StorefrontProvider>
|
|
20
25
|
*
|
|
21
|
-
* ## Blocks (drop in, then restyle)
|
|
22
|
-
* `CheckoutBlock` · `CartLinesBlock` · `TotalsBlock` · `CouponFieldBlock` ·
|
|
23
|
-
* `QuantityStepper` · `OrderReceivedBlock` · `ReviewsBlock` ·
|
|
24
|
-
* `AddressFieldsBlock` · `VariantSelectorBlock` · `AddToCartBlock` ·
|
|
25
|
-
* `ProductGalleryBlock` · `ProductSpecsBlock` · `BreadcrumbsBlock` ·
|
|
26
|
-
* `ProductStripBlock` (needs your `renderCard` — no default card ships).
|
|
27
|
-
*
|
|
28
26
|
* ## Hooks
|
|
29
27
|
* - `useStorefront` / `useStoreInfo` / `useFormatMoney` / `useMoney` — the
|
|
30
28
|
* shared client, cached store info (the ONLY source of payment gateways,
|
|
31
29
|
* currency and countries), money in the store's currency.
|
|
32
30
|
* - `useProductList` / `useCategories` / `useRibbons` — a listing with paging,
|
|
33
31
|
* filters, `refreshing`, and failure as a visible state.
|
|
34
|
-
* - `useProduct` / `useAddToCart` / `
|
|
35
|
-
* the product page: fetch + variant selection +
|
|
36
|
-
* race-safe, with `status: "not_found"` and
|
|
32
|
+
* - `useProduct` / `useAddToCart` / `useAddToCartButton` / `useProductPrice` /
|
|
33
|
+
* `useProductGallery` — the product page: fetch + variant selection +
|
|
34
|
+
* quantity + price + gallery, race-safe, with `status: "not_found"` and
|
|
35
|
+
* every add-to-cart failure handled. `variantAxes(view, pick)` (from
|
|
36
|
+
* `@/commerce/utils`, re-exported here) turns the resolved view into a
|
|
37
|
+
* render-ready model for the selector you write.
|
|
37
38
|
* - `useProductReviews` — the review list and the submit form, with the store's
|
|
38
|
-
* policy as a prop.
|
|
39
|
+
* policy as a prop and field errors matching the server's codes.
|
|
39
40
|
* - `useCart` / `useCartLine` / `useCoupon` — the shared cart (branch on
|
|
40
41
|
* `status`, render `lines` and `notices`), quantity steppers that clamp and
|
|
41
42
|
* recover, and the coupon field a store with coupons must have.
|
|
42
43
|
* - `useCheckout` / `CheckoutProvider` / `useCheckoutContext` — the guided
|
|
43
44
|
* checkout: address state with automatic debounced shipping/tax
|
|
44
45
|
* recalculation, shipping and payment choice, a `canPlaceOrder` gate with
|
|
45
|
-
* named blockers, `placeOrder` with
|
|
46
|
+
* named blockers, `placeOrder` with both navigations handled (online →
|
|
47
|
+
* provider redirect, manual → `/order-received`).
|
|
46
48
|
* - `useAddressForm` / `useCountries` / `useTotalsLines` /
|
|
47
|
-
* `useCheckoutBlockers` — the address form as
|
|
48
|
-
* country options never null), one totals
|
|
49
|
-
* and blocker codes turned into copy.
|
|
50
|
-
* - `
|
|
51
|
-
*
|
|
52
|
-
* - `useOrderReturn` — the mandatory `/order-received` page in one hook.
|
|
49
|
+
* `useCheckoutBlockers` — the address form as a field spec you map to your
|
|
50
|
+
* own inputs (state included, country options never null), one totals
|
|
51
|
+
* projection for cart and order, and blocker codes turned into copy.
|
|
52
|
+
* - `useOrderReturn` — the mandatory `/order-received` page in one hook:
|
|
53
|
+
* status, order, `lines`, `paymentLink`, `paymentInstructions`, noindex.
|
|
53
54
|
* - `useStorefrontSeo` + `productSeo` / `collectionSeo` / `orderSeo` — titles,
|
|
54
55
|
* meta and product structured data; receipts are `noindex`.
|
|
56
|
+
*
|
|
57
|
+
* ## Render-prop components (headless — children is a function, no markup ships)
|
|
58
|
+
* - `ShippingMethodPicker` / `PaymentMethodPicker` — the two checkout choices
|
|
59
|
+
* that are store data, never hardcoded, with their branching enumerated.
|
|
60
|
+
* - `CartLine` — per-line `useCartLine` binding for your cart rows, so a
|
|
61
|
+
* `lines.map(...)` never calls a hook in a loop.
|
|
62
|
+
*
|
|
63
|
+
* ## Helpers re-exported from `@/commerce/utils`
|
|
64
|
+
* - `variantAxes(view, pick)` — axes → options with selected/disabled/stock
|
|
65
|
+
* state derived, for the variant selector you write.
|
|
66
|
+
* - `productSpecs(product)` — `meta_data` → spec-table rows.
|
|
55
67
|
*/
|
|
56
68
|
export {
|
|
57
69
|
StorefrontProvider,
|
|
@@ -62,7 +74,7 @@ export {
|
|
|
62
74
|
useCart,
|
|
63
75
|
} from "./StorefrontProvider";
|
|
64
76
|
export { useCheckout, CheckoutProvider, useCheckoutContext } from "./useCheckout";
|
|
65
|
-
export { useOrderReturn } from "./useOrderReturn";
|
|
77
|
+
export { useOrderReturn, orderReceivedUrl } from "./useOrderReturn";
|
|
66
78
|
export { ShippingMethodPicker, PaymentMethodPicker } from "./pickers";
|
|
67
79
|
export {
|
|
68
80
|
REQUIRED_BILLING_FIELDS,
|
|
@@ -73,18 +85,18 @@ export {
|
|
|
73
85
|
|
|
74
86
|
// ── catalog ────────────────────────────────────────────────────────────────
|
|
75
87
|
export { useProductList, useCategories, useRibbons } from "./useProductList";
|
|
76
|
-
export { useProduct, useAddToCart } from "./useProduct";
|
|
88
|
+
export { useProduct, useAddToCart, useAddToCartButton } from "./useProduct";
|
|
77
89
|
export { useProductPrice, useMoney } from "./useProductPrice";
|
|
78
90
|
export { useProductGallery } from "./useProductGallery";
|
|
79
91
|
export { useProductReviews } from "./useProductReviews";
|
|
80
92
|
|
|
81
93
|
// ── cart & checkout ────────────────────────────────────────────────────────
|
|
82
|
-
export { useCartLine, useCoupon } from "./useCartLine";
|
|
94
|
+
export { useCartLine, useCoupon, CartLine } from "./useCartLine";
|
|
83
95
|
export { useAddressForm, useCountries } from "./useAddressForm";
|
|
84
96
|
export { useTotalsLines, useCheckoutBlockers, blockerMessage } from "./useTotalsLines";
|
|
85
97
|
|
|
86
98
|
// ── SEO ────────────────────────────────────────────────────────────────────
|
|
87
99
|
export { useStorefrontSeo, productSeo, collectionSeo, orderSeo } from "./useStorefrontSeo";
|
|
88
100
|
|
|
89
|
-
// ──
|
|
90
|
-
export
|
|
101
|
+
// ── view-model helpers (framework-free, from @/commerce/utils) ─────────────
|
|
102
|
+
export { variantAxes, productSpecs } from "@/commerce/utils";
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useMemo } from "react";
|
|
1
|
+
import { useCallback, useMemo } from "react";
|
|
2
2
|
import { addressFieldSpec } from "@/commerce/utils";
|
|
3
3
|
import { useStoreInfo } from "./StorefrontProvider";
|
|
4
4
|
import { useCheckoutContext } from "./useCheckout";
|
|
@@ -22,24 +22,47 @@ export function useCountries() {
|
|
|
22
22
|
return { countries: list, options, loading, error };
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
+
/**
|
|
26
|
+
* Read the new value out of whatever a field's `set` was handed. All three
|
|
27
|
+
* forms an onChange is plausibly written as work, so a field setter can't be
|
|
28
|
+
* called "wrong":
|
|
29
|
+
*
|
|
30
|
+
* f.set(e.target.value) // the value
|
|
31
|
+
* f.set(e) // the change event (onChange={f.set})
|
|
32
|
+
* f.set(f.key, e.target.value) // key + value, mirroring the top-level set()
|
|
33
|
+
*/
|
|
34
|
+
function newValue(args) {
|
|
35
|
+
if (args.length >= 2) return args[1];
|
|
36
|
+
const first = args[0];
|
|
37
|
+
if (first && typeof first === "object" && "target" in first) return first.target?.value ?? "";
|
|
38
|
+
return first;
|
|
39
|
+
}
|
|
40
|
+
|
|
25
41
|
/**
|
|
26
42
|
* useAddressForm — the checkout address form as a field list bound to the
|
|
27
43
|
* guided checkout. Needs a `<CheckoutProvider>` above it.
|
|
28
44
|
*
|
|
29
|
-
*
|
|
45
|
+
* Every field is **self-contained** — it carries its own setter, so a `.map`
|
|
46
|
+
* never has to reach back out of the loop:
|
|
47
|
+
*
|
|
48
|
+
* const { fields } = useAddressForm("billing");
|
|
30
49
|
* {fields.map(f => (
|
|
31
50
|
* <label key={f.key}>
|
|
32
51
|
* {f.label}{f.required && " *"}
|
|
33
52
|
* {f.type === "select"
|
|
34
|
-
* ? <select value={f.value} onChange={e => set(
|
|
53
|
+
* ? <select value={f.value} onChange={e => f.set(e.target.value)}>
|
|
35
54
|
* {f.options.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
|
|
36
55
|
* </select>
|
|
37
56
|
* : <input type={f.type} value={f.value} autoComplete={f.autoComplete}
|
|
38
|
-
* onChange={e => set(
|
|
57
|
+
* onChange={e => f.set(e.target.value)} />}
|
|
39
58
|
* {f.error && <span role="alert">{f.error}</span>}
|
|
40
59
|
* </label>
|
|
41
60
|
* ))}
|
|
42
61
|
*
|
|
62
|
+
* `f.set` also accepts the raw event (`onChange={f.set}`) or a `(key, value)`
|
|
63
|
+
* pair; the hook's top-level `set(key, value)` is still there for code that
|
|
64
|
+
* writes a field outside the map.
|
|
65
|
+
*
|
|
43
66
|
* Editing a field is all it takes to trigger the shipping/tax recalculation —
|
|
44
67
|
* `useCheckout` debounces and calls `set-shipping-address` once the address is
|
|
45
68
|
* complete enough to price. Two things the field list gets right that a
|
|
@@ -49,6 +72,11 @@ export function useCountries() {
|
|
|
49
72
|
*
|
|
50
73
|
* @param {"billing"|"shipping"} [which]
|
|
51
74
|
* @param {{includeState?: boolean, includePhone?: boolean, includeCompany?: boolean}} [options]
|
|
75
|
+
* @returns {{fields: Array<{key: string, label: string, type: string,
|
|
76
|
+
* value: string, required: boolean, options: Array<object>, error: string|null,
|
|
77
|
+
* autoComplete: string, colSpan: number, set: (...args: any[]) => void}>,
|
|
78
|
+
* set: (key: string, value: any) => void, values: object, missing: Array<string>,
|
|
79
|
+
* complete: boolean, error: object|null, countriesLoading: boolean}}
|
|
52
80
|
*/
|
|
53
81
|
export function useAddressForm(which = "billing", options = {}) {
|
|
54
82
|
const checkout = useCheckoutContext();
|
|
@@ -56,9 +84,11 @@ export function useAddressForm(which = "billing", options = {}) {
|
|
|
56
84
|
|
|
57
85
|
const isBilling = which === "billing";
|
|
58
86
|
const values = isBilling ? checkout.billing : checkout.shipping;
|
|
59
|
-
const
|
|
60
|
-
|
|
61
|
-
|
|
87
|
+
const { updateBilling, updateShipping } = checkout;
|
|
88
|
+
const set = useCallback(
|
|
89
|
+
(key, value) => (isBilling ? updateBilling({ [key]: value }) : updateShipping({ [key]: value })),
|
|
90
|
+
[isBilling, updateBilling, updateShipping],
|
|
91
|
+
);
|
|
62
92
|
|
|
63
93
|
// `place-order` only enforces required fields on billing; a separate shipping
|
|
64
94
|
// address is priced, not validated field-by-field.
|
|
@@ -75,6 +105,9 @@ export function useAddressForm(which = "billing", options = {}) {
|
|
|
75
105
|
return spec.map((f) => ({
|
|
76
106
|
...f,
|
|
77
107
|
value: values?.[f.key] ?? "",
|
|
108
|
+
// Self-contained: the field knows its own key, so a .map never has to
|
|
109
|
+
// reach back out to the hook's set() (and can't pass the wrong key).
|
|
110
|
+
set: (...args) => set(f.key, newValue(args)),
|
|
78
111
|
// The address-level error ("we don't ship there") belongs on country.
|
|
79
112
|
error:
|
|
80
113
|
f.key === "country" && checkout.addressError?.code === "shipping_not_available"
|
|
@@ -82,7 +115,7 @@ export function useAddressForm(which = "billing", options = {}) {
|
|
|
82
115
|
: null,
|
|
83
116
|
}));
|
|
84
117
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
85
|
-
}, [countries, values, isBilling, checkout.addressError, JSON.stringify(options)]);
|
|
118
|
+
}, [countries, values, isBilling, set, checkout.addressError, JSON.stringify(options)]);
|
|
86
119
|
|
|
87
120
|
return {
|
|
88
121
|
fields,
|