@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.
Files changed (59) hide show
  1. package/README.md +6 -6
  2. package/base44/agents/commerce/StoreAdmin.jsonc +1 -1
  3. package/base44/entities/commerce.OrderRefund.jsonc +1 -1
  4. package/base44/entities/commerce.PaymentGateway.jsonc +1 -1
  5. package/base44/entities/commerce.Webhook.jsonc +1 -1
  6. package/base44/functions/commerce/admin-products/entry.ts +1 -1
  7. package/base44/functions/commerce/admin-reports/entry.ts +1 -1
  8. package/base44/functions/commerce/payments/entry.ts +2 -2
  9. package/base44/functions/commerce/seed-store/defaults.ts +1 -1
  10. package/base44/functions/commerce/seed-store/seed-catalog.ts +64 -6
  11. package/base44/functions/commerce/storefront-catalog/entry.ts +1 -1
  12. package/base44/functions/commerce/storefront-checkout/entry.ts +1 -1
  13. package/base44/shared/commerce/card-payment.stripe.ts +29 -9
  14. package/base44/shared/commerce/card-payment.ts +1 -1
  15. package/base44/shared/commerce/payments.ts +2 -2
  16. package/base44/shared/commerce/scan.ts +1 -1
  17. package/base44/shared/commerce/sequence.ts +2 -2
  18. package/package.json +1 -1
  19. package/scripts/install.js +1 -1
  20. package/skills/commerce/SKILL.md +36 -26
  21. package/skills/commerce/docs/api-storefront.md +6 -6
  22. package/skills/commerce/install/01-install.md +2 -2
  23. package/skills/commerce/install/02-storefront.md +381 -94
  24. package/skills/commerce/install/03-data.md +62 -19
  25. package/skills/commerce/references/catalog-rendering.md +6 -6
  26. package/skills/commerce/references/online-payments.md +5 -6
  27. package/skills/commerce/references/reviews.md +5 -5
  28. package/skills/commerce/references/shipping-and-tax.md +2 -0
  29. package/src/commerce/admin/README.md +6 -3
  30. package/src/commerce/admin/layout/AuthGuard.jsx +1 -1
  31. package/src/commerce/admin/pages/products/Reviews.jsx +1 -1
  32. package/src/commerce/admin/pages/settings/InventorySettings.jsx +1 -1
  33. package/src/commerce/admin/pages/settings/PaymentsSettings.jsx +1 -1
  34. package/src/commerce/storefront/index.js +45 -33
  35. package/src/commerce/storefront/useAddressForm.js +41 -8
  36. package/src/commerce/storefront/useCartLine.js +37 -0
  37. package/src/commerce/storefront/useCheckout.jsx +18 -6
  38. package/src/commerce/storefront/useOrderReturn.js +36 -10
  39. package/src/commerce/storefront/useProduct.js +72 -0
  40. package/src/commerce/storefront/useProductGallery.js +4 -0
  41. package/src/commerce/utils/index.js +9 -6
  42. package/src/commerce/utils/shipping-promos.js +2 -2
  43. package/src/commerce/utils/specs.js +26 -0
  44. package/src/commerce/utils/variants.js +49 -2
  45. package/src/commerce/storefront/blocks/AddToCartBlock.jsx +0 -86
  46. package/src/commerce/storefront/blocks/AddressFieldsBlock.jsx +0 -96
  47. package/src/commerce/storefront/blocks/BreadcrumbsBlock.jsx +0 -52
  48. package/src/commerce/storefront/blocks/CartLinesBlock.jsx +0 -98
  49. package/src/commerce/storefront/blocks/CheckoutBlock.jsx +0 -247
  50. package/src/commerce/storefront/blocks/CouponFieldBlock.jsx +0 -84
  51. package/src/commerce/storefront/blocks/OrderReceivedBlock.jsx +0 -129
  52. package/src/commerce/storefront/blocks/ProductGalleryBlock.jsx +0 -66
  53. package/src/commerce/storefront/blocks/ProductSpecsBlock.jsx +0 -33
  54. package/src/commerce/storefront/blocks/ProductStripBlock.jsx +0 -55
  55. package/src/commerce/storefront/blocks/QuantityStepper.jsx +0 -62
  56. package/src/commerce/storefront/blocks/ReviewsBlock.jsx +0 -191
  57. package/src/commerce/storefront/blocks/TotalsBlock.jsx +0 -42
  58. package/src/commerce/storefront/blocks/VariantSelectorBlock.jsx +0 -81
  59. 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
- { name: "Classic T-Shirt",
36
- sku: "TEE-CLASSIC", // optional; makes re-runs idempotent
37
- regular_price: 19.99,
38
- stock_quantity: 50, // implies manage_stock
39
- categories: ["Clothing"], // get-or-created by display name
40
- ribbons: ["Best Seller"],
41
- images: ["https://…/tee.jpg"], // URLs or { src, alt } — see Images below
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": 3,
80
- "products": [{ "name": "Runner Sneaker", "id": "…", "slug": "runner-sneaker", "variation_count": 3 }] },
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 or a key handed over ("connect Stripe") | wire it, then enable `card` — whenever convenient; it blocks nothing |
137
- | Selling online implied, no provider named | cards are a reasonable read of *what* to offer, never of *when*: ask for the provider + key **after** the store works |
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 asking a user to fetch a secret key before their store exists only stalls the build. Any instruction that treats a payment provider as a required install step is superseded by this section.
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:* wiring is a **file copy, not code you write** — `base44/shared/commerce/card-payment.stripe.ts` is a complete implementation; 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), store the provider secret as an app secret, register `https://<app-domain>/functions/commerce/payment-webhook` with the provider, then enable the gateway. Steps, other providers and the webhook model: [`../references/online-payments.md`](../references/online-payments.md).
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 a provider wired (file copied whole, secret stored, webhook registered).
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 blocks — the quick start in ../install/02-storefront.md covers the happy path."
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** (`<ProductSpecsBlock/>` renders them) |
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` and `<ProductGalleryBlock/>` build on them. Passing the object itself to an `<img src>` fails the load and shows the placeholder for every product in the store.
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. The card is deliberately the one thing no block ships — it is the most identity-defining component in a storefront.
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 layout and styling has a block: `<ProductGalleryBlock/>`, `<VariantSelectorBlock/>`, `<AddToCartBlock/>`, `<ProductSpecsBlock/>`, `<BreadcrumbsBlock/>`, `<ReviewsBlock/>`, `<ProductStripBlock/>`.
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. `<VariantSelectorBlock view={view} onPick={pick} />` 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 is rendered **disabled, not removed**. Take the block; drop to the hook only for different *structure* (`renderOption` covers swatches).
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, the secret stored, the webhook registered and the `card` gateway enabled."
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
- Then three steps, in order:
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
- 1. **Secret** store the merchant's Stripe **secret key** as the `STRIPE_SECRET_KEY` app secret (backend env; never in code, never in an entity) and redeploy the backend functions so they see it. Test keys (`sk_test_…`) work end to end.
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 secret yet answers a clean 503 instead of failing to boot every function that imports it. Env vars are injected at deploy time: after adding a secret, redeploy.
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 dropping in <ReviewsBlock/> — moderation, auto-approval, a customer's own reviews, or a policy the three built-in ones don't cover."
4
- skip_when: "<ReviewsBlock product={p}/> or useProductReviews is on the product page — that is list + submit + policy + the right confirmation copy already."
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 block. The confirmation copy must come from the submit response, never hardcoded."
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 now the UI does too. Unless the store explicitly doesn't want them, put them on the product page — it is one line: `<ReviewsBlock product={product} />` (plus `policy` / `user` below). For custom markup take the hook it composes, `useProductReviews(product, { policy, user, requireRating, perPage })` — the list, the whole form contract (`form`/`setField`/`fieldErrors`/`valid`/`submit`/`message`) and the policy gate (`canReview`, `reviewBlockedReason`, `requiresEmail`); exact shapes in its JSDoc.
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 prop, replacing the patterns a storefront used to implement by hand. Pass your app's current user alongside it (`<ReviewsBlock product={p} policy="login" user={user} />`) — the stricter policies need it:
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
- > Install, mounting & role setup: [`.agents/skills/commerce/install/01-install.md`](../../../.agents/skills/commerce/install/01-install.md) · architecture & operations: the commerce skill, [`.agents/skills/commerce/SKILL.md`](../../../.agents/skills/commerce/SKILL.md)
8
- > API references: [`.agents/skills/commerce/docs/api-admin.md`](../../../.agents/skills/commerce/docs/api-admin.md), [`.agents/skills/commerce/docs/api-storefront.md`](../../../.agents/skills/commerce/docs/api-storefront.md)
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
- `.agents/skills/commerce/install/03-data.md`.
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 .agents/skills/commerce/install/01-install.md.
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
- * .agents/skills/commerce/references/reviews.md.
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 .agents/skills/commerce/references/operations.md.
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
- .agents/skills/commerce/references/online-payments.md; the
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 ready-made blocks for the shopfront you
3
- * build. Ships with the Base44 Commerce Template next to the framework-free
4
- * `@/commerce/utils` (which it builds on); needs React and nothing else.
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
- * **Two tiers.** The *identity* surfaces are yours to design — home, the
7
- * collection grid, the product page's layout, the product card, the theme. The
8
- * *commodity* surfaces ship as working blocks that inherit your theme
9
- * checkout, cart lines, totals, coupon field, reviews, order-received, and the
10
- * product page's internals. Restyle or replace them; never hand-roll their
11
- * logic. Blocks are compositions of the hooks below, so outgrowing one means
12
- * rewriting a single region against an API you already know.
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` / `useProductPrice` / `useProductGallery`
35
- * the product page: fetch + variant selection + quantity + price + gallery,
36
- * race-safe, with `status: "not_found"` and add-to-cart errors handled.
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 the online-payment redirect handled.
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 fields (state included,
48
- * country options never null), one totals projection for cart and order,
49
- * and blocker codes turned into copy.
50
- * - `ShippingMethodPicker` / `PaymentMethodPicker` headless (render-prop)
51
- * wrappers over the two choices that are store data, never hardcoded.
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
- // ── blocks (default markup for the commodity surfaces) ─────────────────────
90
- export * from "./blocks";
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
- * const { fields, set } = useAddressForm("billing");
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(f.key, e.target.value)}>
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(f.key, e.target.value)} />}
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 set = isBilling
60
- ? (key, value) => checkout.updateBilling({ [key]: value })
61
- : (key, value) => checkout.updateShipping({ [key]: value });
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,