@base44/app-plugin-commerce 0.1.19 → 0.2.1

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 (73) hide show
  1. package/README.md +25 -22
  2. package/base44/functions/commerce/admin-reports/entry.ts +1 -1
  3. package/base44/functions/commerce/seed-store/entry.ts +34 -0
  4. package/base44/functions/commerce/seed-store/seed-catalog.ts +39 -5
  5. package/base44/shared/commerce/card-payment.stripe.ts +178 -0
  6. package/base44/shared/commerce/scan.ts +1 -1
  7. package/base44/shared/commerce/sequence.ts +1 -1
  8. package/package.json +1 -1
  9. package/scripts/install.js +24 -14
  10. package/skills/commerce/SKILL.md +107 -51
  11. package/skills/commerce/docs/api-admin.md +89 -28
  12. package/skills/commerce/docs/api-storefront.md +113 -126
  13. package/skills/commerce/docs/entities.md +137 -0
  14. package/skills/commerce/install/01-install.md +101 -0
  15. package/skills/commerce/install/02-storefront.md +188 -0
  16. package/skills/commerce/install/03-data.md +162 -0
  17. package/skills/commerce/references/admin-product-form.md +10 -0
  18. package/skills/commerce/references/catalog-rendering.md +110 -0
  19. package/skills/commerce/references/emails.md +49 -12
  20. package/skills/commerce/references/guest-access-security.md +18 -5
  21. package/skills/commerce/references/online-payments.md +50 -149
  22. package/skills/commerce/references/operations.md +52 -0
  23. package/skills/commerce/references/reviews.md +31 -16
  24. package/skills/commerce/references/shipping-and-tax.md +110 -0
  25. package/skills/commerce/references/store-admin-agent.md +21 -0
  26. package/skills/commerce/references/store-settings.md +49 -0
  27. package/src/commerce/admin/README.md +2 -2
  28. package/src/commerce/admin/layout/AuthGuard.jsx +1 -1
  29. package/src/commerce/admin/pages/settings/InventorySettings.jsx +1 -1
  30. package/src/commerce/storefront/StorefrontProvider.jsx +106 -20
  31. package/src/commerce/storefront/blocks/AddToCartBlock.jsx +86 -0
  32. package/src/commerce/storefront/blocks/AddressFieldsBlock.jsx +96 -0
  33. package/src/commerce/storefront/blocks/BreadcrumbsBlock.jsx +52 -0
  34. package/src/commerce/storefront/blocks/CartLinesBlock.jsx +98 -0
  35. package/src/commerce/storefront/blocks/CheckoutBlock.jsx +247 -0
  36. package/src/commerce/storefront/blocks/CouponFieldBlock.jsx +84 -0
  37. package/src/commerce/storefront/blocks/OrderReceivedBlock.jsx +129 -0
  38. package/src/commerce/storefront/blocks/ProductGalleryBlock.jsx +66 -0
  39. package/src/commerce/storefront/blocks/ProductSpecsBlock.jsx +33 -0
  40. package/src/commerce/storefront/blocks/ProductStripBlock.jsx +55 -0
  41. package/src/commerce/storefront/blocks/QuantityStepper.jsx +62 -0
  42. package/src/commerce/storefront/blocks/ReviewsBlock.jsx +191 -0
  43. package/src/commerce/storefront/blocks/TotalsBlock.jsx +42 -0
  44. package/src/commerce/storefront/blocks/VariantSelectorBlock.jsx +81 -0
  45. package/src/commerce/storefront/blocks/index.js +44 -0
  46. package/src/commerce/storefront/index.js +59 -21
  47. package/src/commerce/storefront/internal/useAsyncData.js +86 -0
  48. package/src/commerce/storefront/pickers.jsx +20 -5
  49. package/src/commerce/storefront/useAddressForm.js +96 -0
  50. package/src/commerce/storefront/useCartLine.js +184 -0
  51. package/src/commerce/storefront/useCheckout.jsx +38 -11
  52. package/src/commerce/storefront/useProduct.js +227 -0
  53. package/src/commerce/storefront/useProductGallery.js +74 -0
  54. package/src/commerce/storefront/useProductList.js +153 -0
  55. package/src/commerce/storefront/useProductPrice.js +58 -0
  56. package/src/commerce/storefront/useProductReviews.js +242 -0
  57. package/src/commerce/storefront/useStorefrontSeo.js +204 -0
  58. package/src/commerce/storefront/useTotalsLines.js +109 -0
  59. package/src/commerce/utils/address-spec.js +89 -0
  60. package/src/commerce/utils/images.js +45 -0
  61. package/src/commerce/utils/index.js +18 -6
  62. package/src/commerce/utils/price.js +95 -0
  63. package/src/commerce/utils/storefront.js +47 -3
  64. package/src/commerce/utils/totals.js +110 -0
  65. package/src/commerce/utils/variants.js +10 -2
  66. package/skills/commerce/installation-guidelines.md +0 -93
  67. package/skills/commerce/post-installation.md +0 -495
  68. package/skills/commerce/references/limits-and-performance.md +0 -16
  69. package/skills/commerce/references/media-and-downloads.md +0 -4
  70. package/skills/commerce/references/product-render.md +0 -89
  71. package/skills/commerce/references/scheduled-work.md +0 -19
  72. package/skills/commerce/references/storefront-product-page.md +0 -83
  73. package/skills/commerce/references/webhooks.md +0 -10
@@ -0,0 +1,137 @@
1
+ # Entities — the data-model map
2
+
3
+ Every `commerce.*` entity, what holds what, and who may write it. Read this before **any** direct entity read or write; function-mediated flows never need it.
4
+
5
+ ## Addressing — read this first
6
+
7
+ Entity names are **dotted and namespaced**, so the SDK accessor is **bracket syntax, always**:
8
+
9
+ ```js
10
+ base44.entities["commerce.PaymentGateway"] // ✅ the only spelling that resolves
11
+ base44.entities["commerce__PaymentGateway"] // ❌ 404 Entity schema … not found
12
+ base44.entities["PaymentGateway"] // ❌ there is no un-namespaced alias
13
+ base44.entities.commerce.PaymentGateway // ❌ not a nested object
14
+ ```
15
+
16
+ Schema files live at **`base44/entities/commerce.<Name>.jsonc`** — one targeted read when you need a field this page doesn't carry.
17
+
18
+ `filter` is **exact-match only**; args are `(query, sort, limit, skip)`, `list` is `(sort, limit, skip)`. Plus `.get(id)` · `.create(fields)` · `.update(id, patch)` · `.delete(id)`.
19
+
20
+ ```js
21
+ const gateways = await base44.entities["commerce.PaymentGateway"].list("order", 100);
22
+ const [offline] = await base44.entities["commerce.PaymentGateway"].filter({ slug: "offline" }, undefined, 1);
23
+ await base44.entities["commerce.PaymentGateway"].update(offline.id, { title: "Bank transfer" });
24
+ ```
25
+
26
+ **Never** scan `base44/entities/` to discover names, guess a spelling and retry on 404, or web-search a kit API's shape. This page plus [`api-admin.md`](./api-admin.md) and [`api-storefront.md`](./api-storefront.md) are the answer.
27
+
28
+ ## The catalog — all 20
29
+
30
+ **RLS is identical on every entity: `admin` on read *and* write**, catalog included. A non-admin call is rejected by the backend whatever the UI does; storefront access goes exclusively through `commerce/storefront-*` (service role, safe projections) — [`../references/guest-access-security.md`](../references/guest-access-security.md). Hence no RLS column: assume admin-only.
31
+
32
+ **Owned by `<fn>` = never write directly** — the function maintains derived fields, counts, emails and webhooks a raw write skips. Names below are all `commerce/<name>`.
33
+
34
+ | Entity | Purpose | Key fields | Write path |
35
+ |---|---|---|---|
36
+ | `Product` | Sellable product; sells variants when it carries `attributes` — there is **no `type` field** | `name` `slug` `status` `price` `category_ids` `attributes[]` `images[]` | owned by **`admin-products`** |
37
+ | `ProductVariation` | One purchasable attribute combination of a parent | `product_id` `attributes[]` `sku` `regular_price` `stock_quantity` `status` | owned by **`admin-products`** — `save` diffs them inside the parent call |
38
+ | `ProductCategory` | Hierarchical category (`parent_id` tree) | `name` `slug` `parent_id` `menu_order` `count` | direct CRUD, or **`admin-products` `save-term`** (slug uniqueness, self-parent guard) |
39
+ | `ProductRibbon` | Flat cross-cutting label ("Best Seller") | `name` `count` | direct CRUD, or **`save-term`** — get-or-create by name, so it can't split a ribbon |
40
+ | `ProductAttribute` | Variant axis shared across products (Color, Size) | `name` `code` `order` | direct CRUD, or **`save-term`** (`code` uniqueness; `code` is what a filter URL carries) |
41
+ | `ProductAttributeTerm` | One value of an attribute (Size → M) | `attribute_id` `name` `order` `count` | direct CRUD, or **`save-term`** — products store values **by name**, so a rename rewrites them |
42
+ | `ProductReview` | Moderated customer review | `product_id` `status` `reviewer` `rating` `review` `verified` | owned by **`admin-reviews`** (recomputes the rating); created by `submit-review` |
43
+ | `Order` | Order with embedded line items, shipping/tax/fee/coupon lines, totals | `order_number` `order_key` `status` `line_items[]` `total` `billing` | owned by **`admin-orders`**; created by `place-order` |
44
+ | `OrderNote` | System, private, or customer-facing note | `order_id` `note` `is_customer_note` `added_by` | owned by **`admin-orders`** `add-note`/`delete-note` — a customer note emails the buyer |
45
+ | `OrderRefund` | Full or partial refund | `order_id` `amount` `line_items[]` `restock_items` | owned by **`admin-refunds`** (totals, restock, status) |
46
+ | `Customer` | Customer; guests have no `user_id` | `email` `first_name` `last_name` `is_guest` `billing` `shipping` | owned by **`admin-customers`**; checkout upserts it, `update-my-addresses` is the owner's path |
47
+ | `Coupon` | Discount coupon with restrictions and usage limits | `code` `enabled` `discount_type` `amount` `usage_count` | owned by **`admin-coupons`** (unique lowercased code) |
48
+ | `Cart` | Ephemeral cart + checkout draft keyed by `cart_token`; items hold **no prices** | `cart_token` `items[]` `coupon_codes` `shipping_address` `expires_at` | owned by **`storefront-cart`/`-checkout`** — never write directly |
49
+ | `PaymentGateway` | One checkout method (`offline`, `card`, manual options); no secrets here | `slug` `title` `description` `enabled` `order` `settings` | **mixed**: `enabled` via `seed-store` `payment_methods`; the rest direct CRUD |
50
+ | `ShippingTaxLocation` | A sales location: regions + its shipping rates, tax groups, shipping tax. Empty `regions` = catch-all | `name` `order` `regions[]` `shipping_rates[]` `tax_groups[]` | **direct CRUD** (or `seed-store` `locations`) — mint stable rate `id`s |
51
+ | `StoreSettings` | Config, **one record per `group_id`** (`general` `products` `inventory` `tax` `shipping` `emails`) | `group_id` `values` | **direct CRUD** — patch `values`, never replace the group |
52
+ | `DownloadPermission` | A customer's access to a purchased file | `order_id` `customer_email` `product_id` `downloads_remaining` `access_expires` | owned by the **order lifecycle** (`admin-orders` `grant-download`/`revoke-download`) |
53
+ | `Webhook` | Outgoing webhook definition | `name` `topic` `delivery_url` `secret` `status` | **direct CRUD** (+ `admin-webhooks` `test`/`redeliver`); `failure_count` engine-owned |
54
+ | `WebhookDelivery` | Log of one delivery attempt | `webhook_id` `topic` `response_code` `success` | **engine-owned, read-only**; prune via `admin-tools` |
55
+ | `EmailLog` | Log of transactional emails sent | `email_type` `recipient` `target` `success` `error` | **engine-owned, read-only** |
56
+
57
+ ## Relationships
58
+
59
+ ```
60
+ Product ──< ProductVariation one row per stocked combination
61
+ ├─ category_ids ─> ProductCategory ──< ProductCategory (parent_id tree)
62
+ ├─ ribbon_ids ─> ProductRibbon
63
+ ├─ attributes[].attribute_id ─> ProductAttribute ──< ProductAttributeTerm
64
+ └──< ProductReview
65
+
66
+ Cart ──place-order──> Order ──< OrderNote
67
+ (cart_token, 48h TTL) │ ──< OrderRefund
68
+ │ ──< DownloadPermission
69
+ └─ customer_id ─> Customer
70
+
71
+ Coupon referenced by code from Cart.coupon_codes / Order.coupon_lines
72
+ StoreSettings one record per group_id PaymentGateway one row per method
73
+ ShippingTaxLocation standalone; matched by `order` ascending at pricing time
74
+ Webhook ──< WebhookDelivery EmailLog append-only
75
+ ```
76
+
77
+ ## Derived fields — never write these
78
+
79
+ | Field | Changed instead by |
80
+ |---|---|
81
+ | variant parent's `price` `regular_price` `on_sale` — rolled up from the cheapest publishable variation, which is what makes cards, price sort and price filters agree | `admin-products` `save` / `seed-store`; set prices on the **variations** |
82
+ | `stock_status` (product + variation) — from quantity + `backorders`, or rolled up from variations when the parent doesn't `manage_stock` | `admin-products` `set-stock`/`save`; order stock reduce/restore |
83
+ | `Product.average_rating` `rating_count` — from approved reviews | `admin-reviews`, `storefront-catalog` `submit-review` |
84
+ | `Product.total_sales` — first stock reduction, reversed on restore | the order transition engine |
85
+ | `count` on `ProductCategory` / `ProductRibbon` / `ProductAttributeTerm` | `admin-products`; repair with `admin-tools` `recount-terms` |
86
+ | `Order` totals (`subtotal` `discount_total` `shipping_total` `*_tax` `total`) and per-line totals | `admin-orders` `update`/`recalculate`/`apply-coupon`; `place-order` |
87
+ | `Order` status side effects — `date_paid` `date_completed` `stock_reduced` `coupon_usages_counted` `download_permissions_granted` `emails_sent` `hold_expires_at`; flag-guarded so a re-entered status never double-fires | `admin-orders` `update-status`/`bulk-status` — never a direct `status` write |
88
+ | `Order.total_refunded` | `admin-refunds` |
89
+ | `Customer.orders_count` `total_spent` `is_paying_customer` | `admin-customers` `recalculate-stats` |
90
+ | `Coupon.usage_count` `used_by` | the transition engine; repair with `admin-tools` `recount-coupon-usage` |
91
+
92
+ ## Recipes
93
+
94
+ **Enable/disable a payment gateway** — not an entity write. One call converges *every* gateway row to the set you name, validates the slugs, and is idempotent. Cards need a provider wired first ([`../install/03-data.md`](../install/03-data.md)):
95
+ ```js
96
+ await base44.functions.invoke("commerce/seed-store", { payment_methods: ["offline", "card"] });
97
+ ```
98
+
99
+ **Edit a gateway's copy or position** — ordinary config; direct CRUD, as Settings → Payments does.
100
+ ```js
101
+ await base44.entities["commerce.PaymentGateway"].update(id, { title: "Bank transfer", description: "…", order: 1 });
102
+ ```
103
+
104
+ **Read recent orders** — free-text/date search is `admin-orders` `search`; `filter` is exact-match only:
105
+ ```js
106
+ const rows = await base44.entities["commerce.Order"].filter({ status: "processing" }, "-created_date", 20);
107
+ ```
108
+
109
+ **Add an order note** — via the function: a customer note emails the buyer.
110
+ ```js
111
+ await base44.functions.invoke("commerce/admin-orders", { action: "add-note", order_id, note: "Shipped", is_customer_note: true });
112
+ ```
113
+
114
+ **Adjust a variation's stock** — via the function: it re-derives `stock_status` and sends stock alerts.
115
+ ```js
116
+ await base44.functions.invoke("commerce/admin-products", { action: "set-stock", id: product_id, variation_id, quantity: 12 });
117
+ ```
118
+
119
+ **Edit a shipping location** — direct CRUD. Rate `id`s must be **stable** (carts and orders reference the chosen rate by id), so mint one per rate and keep it. Regions and matching: [`../references/shipping-and-tax.md`](../references/shipping-and-tax.md):
120
+ ```js
121
+ const loc = await base44.entities["commerce.ShippingTaxLocation"].get(id);
122
+ await base44.entities["commerce.ShippingTaxLocation"].update(id, {
123
+ shipping_rates: [...loc.shipping_rates, { id: crypto.randomUUID(), name: "Express", cost: 25, free_over: null }],
124
+ });
125
+ ```
126
+
127
+ **Patch a settings group** — merge into `values`; replacing the object drops every other key in the group. Keys per group: [`../references/store-settings.md`](../references/store-settings.md):
128
+ ```js
129
+ const [g] = await base44.entities["commerce.StoreSettings"].filter({ group_id: "products" }, undefined, 1);
130
+ await base44.entities["commerce.StoreSettings"].update(g.id, { values: { ...g.values, auto_approve_reviews: true } });
131
+ ```
132
+
133
+ ## Where the rest lives
134
+
135
+ - A function's actions, payloads and error codes → [`api-admin.md`](./api-admin.md) · [`api-storefront.md`](./api-storefront.md)
136
+ - One entity's complete field list, types, enums, defaults → `base44/entities/commerce.<Name>.jsonc` (one read, never a directory scan)
137
+ - Seeding catalog / locations / payment methods in one call → [`api-admin.md#commerceseed-store`](./api-admin.md#commerceseed-store); worked example in [`../install/03-data.md`](../install/03-data.md)
@@ -0,0 +1,101 @@
1
+ ---
2
+ stage: install/01
3
+ read_when: "The commerce kit's files were just copied into the app, or you are installing it now."
4
+ skip_when: "The admin already mounts at /store-admin/* behind the shipped AuthGuard and / routes somewhere real."
5
+ forget_when: "The checklist at the bottom of this file passes (admin mounts, / routes somewhere real, /order-received exists)."
6
+ carry_forward:
7
+ - "Admin enforcement is three layers — AuthGuard (UI), admin-only entity RLS, requireAdmin() in every admin function. Never weaken any of them."
8
+ - "/order-received must exist as a route: every payment link returns there, and confirming is what marks an order paid."
9
+ - "Interleave: start image generation first → mount admin + build the storefront while images render → seed when the URLs are back → payments last."
10
+ - "Entities are dotted + bracket-syntax only (`base44.entities[\"commerce.X\"]`); the map is ../docs/entities.md — never scan base44/entities/."
11
+ ---
12
+
13
+ # 01 — Install
14
+
15
+ What lands in the app, how the admin gets mounted, and the order to do the rest of the work in.
16
+
17
+ **Inside the Base44 runtime, writing a resource file *is* the deploy** — entities, functions and the `commerce/StoreAdmin` agent go live the moment the files exist. Nothing to push, no build step. If `scripts/install.js` ran, everything below is already in place.
18
+
19
+ | Copied to | What it is |
20
+ |---|---|
21
+ | `base44/entities/commerce.*.jsonc` | 20 entity schemas, all admin-only RLS |
22
+ | `base44/functions/commerce/` + `shared/` + `agents/` | 16 functions, the engine, the StoreAdmin copilot |
23
+ | `src/commerce/admin/` | the finished admin app — **don't validate it, it ships tested** |
24
+ | `src/commerce/storefront/` + `utils/` | the hooks and blocks you build against ([`./02-storefront.md`](./02-storefront.md)) |
25
+ | `.agents/skills/commerce/` | these docs |
26
+
27
+ <details>
28
+ <summary>CLI path (outside the runtime only — as a Base44 agent, skip all of it)</summary>
29
+
30
+ ```bash
31
+ node examples/commerce/scripts/install.js # copies the table above
32
+ npx base44 entities push && npx base44 functions deploy && npx base44 agents push
33
+ ```
34
+ Confirm `base44/config.jsonc`'s `entitiesDir`/`functionsDir` point at those folders (the defaults do). Granting a user the `admin` role is an operator step, not yours.
35
+ </details>
36
+
37
+ **Dependencies.** Check `package.json` for `sonner`, `recharts` and `react-markdown`, and `npm i` **only** the ones actually absent — all three ship with the default Base44 template, so the normal outcome is installing nothing. Never re-install a package that is already a dependency. The kit needs no other dependency; verify the shadcn primitives listed in `src/commerce/admin/README.md` exist.
38
+
39
+ ## Work order — interleave, don't queue
40
+
41
+ Image generation is the slowest step of the install and nothing depends on it until seed time. The storefront doesn't wait on live data either — every shape you build against is documented in [`./02-storefront.md`](./02-storefront.md). So:
42
+
43
+ 1. **Start image generation first** — every product image, before anything else.
44
+ 2. **Mount the admin (below) and build the storefront** while the images render.
45
+ 3. **Seed the moment the image URLs are back** — one `commerce/seed-store` call ([`./03-data.md`](./03-data.md)); don't idle on it, pick the response (slugs) up when you need it.
46
+ 4. **Converge**: open the finished pages against the live catalog.
47
+ 5. **Payments last, if at all** — cards are off by default and nothing above depends on them ([`./03-data.md`](./03-data.md) decides it).
48
+
49
+ The only real dependency edges are *image URLs → seed payload* and *seed done → real products on the pages*. Everything else overlaps.
50
+
51
+ ## Mount the admin router
52
+
53
+ ```jsx
54
+ import AdminApp from "@/commerce/admin";
55
+ import { Navigate } from "react-router-dom";
56
+
57
+ <Route path="/store-admin/*" element={<AdminApp />} />
58
+ <Route path="/" element={<Navigate to="/store-admin" replace />} /> {/* until a storefront exists */}
59
+ <Route path="/order-received" element={<OrderReceived />} /> {/* mandatory — see below */}
60
+ ```
61
+
62
+ - **The `/*` splat is required.** The admin renders nested routes; a bare `path="/store-admin"` matches only the dashboard and every deeper link 404s. Mounting elsewhere: `<AdminApp basePath="/backoffice" />` — the prefix *without* the splat.
63
+ - **Give `/` something.** A blank Base44 app has no `/` route, so after mounting only the admin the app's own URL renders "page not found", which reads exactly like a broken install. Redirect until the storefront exists.
64
+ - **`/order-received` is mandatory**, even for a store that only ever takes offline payments. Every payment link (checkout, the admin's payment link, emails) returns there, and confirming is what marks an order paid — without the route a paying customer hits a 404 and the order stays unpaid. The page body is one block, `<OrderReceivedBlock />` ([`./02-storefront.md`](./02-storefront.md)). A different path must be set in Settings → General → *Payment return path*.
65
+
66
+ ## Admin-role enforcement — do not weaken
67
+
68
+ The shipped `AuthGuard` requires an authenticated user whose `role === "admin"`: not signed in → "Please sign in"; signed in without the role → "Admin access required". Grant it in the Base44 dashboard or with `base44.users.inviteUser(email, "admin")`.
69
+
70
+ That guard is the **first of three layers**, and all three are load-bearing:
71
+
72
+ 1. **UI guard** — `AuthGuard` around every admin route (client-side; convenience and UX).
73
+ 2. **Entity RLS** — every commerce entity carries `"user_condition": { "role": "admin" }` on all operations, so a direct SDK read or write by a non-admin is rejected by the backend.
74
+ 3. **Function guard** — every `commerce/admin-*` function and `commerce/seed-store` calls `requireAdmin()` before touching data via the service role: **401** unauthenticated, **403** not an admin.
75
+
76
+ Bypassing the client guard therefore reaches nothing. Storefront functions are public on purpose and verify per action instead (auth session, `cart_token`, or `order_key`). The StoreAdmin agent has no entity tools and no service role for the same reason: its calls run with the chatting user's own credentials, so layer 3 still authorizes them.
77
+
78
+ Check the install at any point:
79
+
80
+ ```js
81
+ const { data } = (await base44.functions.invoke("commerce/admin-tools", { action: "status" })).data;
82
+ // → { template_version, seeded, settings_groups, counts: { "commerce.Product": n, … },
83
+ // checks: { has_payment_gateways, has_default_location } }
84
+ ```
85
+
86
+ ## Done — forget this file
87
+
88
+ - [ ] `/store-admin/*` mounted with the splat, behind the shipped `AuthGuard`; the three enforcement layers untouched.
89
+ - [ ] `/` routes somewhere real — the storefront, or the redirect above.
90
+ - [ ] `/order-received` is a route in the app's `<Routes>`.
91
+ - [ ] `sonner` / `recharts` / `react-markdown` present (installed only if they were missing).
92
+ - [ ] Image generation is already running, or the store has no product images to make.
93
+
94
+ Then continue: **[`./02-storefront.md`](./02-storefront.md) when you start building UI**, **[`./03-data.md`](./03-data.md) when you start the seed payload**. Do **not** read them now, and do not open any `references/` or `docs/` file during an install — the stage files say when one is genuinely needed, and a file read early costs its size on every later call.
95
+
96
+ Record these lines in your working notes; do not re-read this file.
97
+
98
+ - Admin enforcement is three layers — AuthGuard (UI), admin-only entity RLS, requireAdmin() in every admin function. Never weaken any of them.
99
+ - `/order-received` must exist as a route: every payment link returns there, and confirming is what marks an order paid.
100
+ - Interleave: start image generation first → mount admin + build the storefront while images render → seed when the URLs are back → payments last.
101
+ - Entities are dotted + bracket-syntax only (`base44.entities["commerce.X"]`); the map is [`../docs/entities.md`](../docs/entities.md) — never scan `base44/entities/`.
@@ -0,0 +1,188 @@
1
+ ---
2
+ stage: install/02
3
+ read_when: "You are about to build storefront pages (product list/page, cart, checkout, order-received)."
4
+ skip_when: "The storefront pages already render against live data and pass the checklist at the bottom of this file."
5
+ forget_when: "The checklist at the bottom passes — every page renders against the seeded catalog and an offline order completes."
6
+ carry_forward:
7
+ - "Payment gateways, currency and countries come from useStoreInfo() only — never off a cart (cart.payment_gateways is always undefined)."
8
+ - "A store with any coupons must have a coupon field. <CartLinesBlock/> and <CheckoutBlock/> both ship one by default (showCoupon) — keep it unless the store has no codes."
9
+ - "/order-received renders <OrderReceivedBlock/>, which shows paymentInstructions — how a normal (offline) customer learns how to pay."
10
+ - "Branch cart/list/product UI on `status`, never on `isEmpty`/nullable data — `isEmpty` is false while loading by design."
11
+ ---
12
+
13
+ # 02 — Storefront
14
+
15
+ Two tiers, and the split decides how much you write.
16
+
17
+ - **Identity tier — yours, always.** Home, the collection grid, **the product card**, the product page's layout, typography, motion, theme. Where "make it look like X" lives; no markup ships for it.
18
+ - **Commodity tier — ships as blocks.** Checkout, cart lines, totals, coupon field, reviews, order-received, and the product page's internals (variant selector, gallery, specs, breadcrumbs, strips). Every store's version is functionally identical: **restyle or replace them, never hand-roll their logic.**
19
+
20
+ Blocks are thin compositions of the same package's hooks, styled by inheritance — semantic markup, your theme tokens, a `data-commerce="…"` attribute on every element. Each takes `className`, most take `slots` or a render prop, so outgrowing one means rewriting *one region* against hooks you already know. Everything imports from `@/commerce/storefront`.
21
+
22
+ ## Setup — once
23
+
24
+ ```jsx
25
+ import { StorefrontProvider } from "@/commerce/storefront";
26
+ import { base44 } from "@/api/base44Client";
27
+
28
+ <BrowserRouter>
29
+ <StorefrontProvider base44={base44}> {/* wraps <Routes> — never a child of it */}
30
+ <Routes> {/* ONE <Routes> — merge new pages into the app's */}
31
+ <Route path="/" element={<Home />} />
32
+ <Route path="/product/:slug" element={<ProductPage />} />
33
+ <Route path="/bag" element={<Bag />} />
34
+ <Route path="/checkout" element={<Checkout />} />
35
+ <Route path="/order-received" element={<OrderReceived />} />
36
+ <Route path="/store-admin/*" element={<AdminApp />} />
37
+ </Routes>
38
+ </StorefrontProvider>
39
+ </BrowserRouter>
40
+ ```
41
+
42
+ The provider owns the shared client, the store-info cache and **one** shared cart, so a header badge, a drawer and the checkout render the same state. Never mount a second provider, and never touch the `cart_token` — the provider owns its whole lifecycle.
43
+
44
+ > ⚠ **`<Routes>` accepts only `<Route>` children.** Nesting the provider inside it — the natural reading of "wrap the storefront routes" — throws at render: `Error: [StorefrontProvider] is not a <Route> component. All component children of <Routes> must be a <Route> or <React.Fragment>`. To scope the provider to storefront routes only, use a pathless **layout route**, the one place a wrapper is legal:
45
+ > ```jsx
46
+ > <Route element={<StorefrontProvider base44={base44}><Outlet /></StorefrontProvider>}>
47
+ > <Route path="/" element={<Home />} />
48
+ > <Route path="/checkout" element={<Checkout />} />
49
+ > </Route>
50
+ > <Route path="/store-admin/*" element={<AdminApp />} /> {/* outside the provider */}
51
+ > ```
52
+
53
+ ## Product list / collection — identity tier
54
+
55
+ ```jsx
56
+ const list = useProductList({ per_page: 12, sort: "-created_date" });
57
+ const { items: categories } = useCategories(); // ARRAY, children nested
58
+
59
+ if (list.status === "error") return <ErrorState onRetry={list.reload} />;
60
+ if (list.status === "empty") return <EmptyState />;
61
+
62
+ {list.products.map((p) => <MyCard key={p.id} product={p} />)} {/* your card */}
63
+ {list.hasNext && <button onClick={list.next}>Next</button>}
64
+ // list.setParams({ category_id, search, on_sale: true, min_price, in_stock_only })
65
+ // → resets to page 1, keeps the current rows on screen (list.refreshing) while the page loads
66
+ ```
67
+
68
+ `status` is `"loading" | "ready" | "empty" | "error"` — branch on it, so a failed request renders as a failure instead of an empty grid. `useRibbons()` has the same shape as `useCategories()`.
69
+
70
+ A card uses `name`, `images[0]?.src` (**images are `{src,name,alt}` objects and the array may be empty — render a placeholder, never a broken `<img>`**), `useProductPrice(row).label` (already "From €19.99" when the product sells variants — there is no product `type` flag), `on_sale`, `short_description`, `stock_status`, `average_rating`/`rating_count`, `ribbons`. Full field matrix: [`../references/catalog-rendering.md`](../references/catalog-rendering.md).
71
+
72
+ **Rails** (featured row, "new in", upsells) are one block, and `renderCard` is required because the card is identity tier:
73
+
74
+ ```jsx
75
+ <ProductStripBlock params={{ featured: true, per_page: 4 }} title="Featured"
76
+ renderCard={(p) => <MyCard key={p.id} product={p} />} />
77
+ // or products={p.upsells} (from useProduct) for rows you already have. A filter may match nothing —
78
+ // the block renders nothing at all rather than a heading over an empty row.
79
+ ```
80
+
81
+ ## Product page — custom layout, blocks inside
82
+
83
+ ```jsx
84
+ const p = useProduct(slug); // slug from the route; { id } also works
85
+ if (p.status === "loading") return <Skeleton />;
86
+ if (p.status === "not_found") return <NotFound />; // a 404 is a status, not a spinner
87
+ const { product, view, price, categories } = p;
88
+
89
+ <BreadcrumbsBlock categories={categories} current={product.name} />
90
+ <ProductGalleryBlock product={product} view={view} imageClassName="aspect-[3/4] object-cover" />
91
+ <h1>{product.name}</h1>
92
+ <p>{price.label}{price.compareAtLabel && <s>{price.compareAtLabel}</s>}</p>
93
+ <VariantSelectorBlock view={view} onPick={p.pick} /> {/* one control per axis */}
94
+ <AddToCartBlock product={p} onAdded={() => navigate("/bag")} /> {/* pass the whole hook result */}
95
+ <div dangerouslySetInnerHTML={{ __html: product.description }} /> {/* HTML — render as rich text */}
96
+ <ProductSpecsBlock product={product} /> {/* product.meta_data → spec table */}
97
+ <ReviewsBlock product={product} />
98
+ ```
99
+
100
+ `view` is the resolved selection: `view.axes` (one control each), `view.availability` (unbuyable options render *disabled*, not hidden — the block does this), `view.purchasable`, `view.addToCart` (`{product_id, variation_id}` — a product with attributes is rejected without it). `p.price` is the *current* selection's price, a range until it resolves; the selection is mirrored to the URL, so a variant is linkable. Custom buy box: `p.pick`, `p.quantity`/`p.incQuantity`/`p.maxQuantity`, and `useAddToCart()` whose `add()` never throws and resolves `{ ok, error: { code, message, shouldReload } }`.
101
+
102
+ **Reviews** are the one line above. The custom path is `useProductReviews(product, { policy, user })` — list, paging, aggregate rating, and the submit form with field-level errors; `policy` is `"open" | "login" | "verified_buyers"`, and the confirmation copy comes from the server's response, so it is right whether or not the store auto-approves.
103
+
104
+ ## Cart / bag — commodity tier
105
+
106
+ A cart *page* is optional: a store selling one made-to-order piece reads better as buy-now straight to checkout. When you build one:
107
+
108
+ ```jsx
109
+ const { status } = useCart();
110
+ if (status === "loading") return <Skeleton />; // never branch on isEmpty while loading
111
+ if (status === "empty") return <EmptyBag />;
112
+
113
+ <CartLinesBlock /> {/* lines, variant labels, steppers, and the notices saying what auto-dropped */}
114
+ {/* <CartLinesBlock/> already renders the coupon field (showCoupon, default on) */}
115
+ <TotalsBlock /> {/* subtotal · discount · shipping · tax · total, zero rows hidden */}
116
+ <Link to="/checkout">Checkout</Link>
117
+ ```
118
+
119
+ **A store with any coupons must have a coupon field, or its codes can never be redeemed.** Coupons are admin-only data — a storefront cannot list codes, so the only way in is a field the customer types into. Both `<CartLinesBlock/>` and `<CheckoutBlock/>` render one by default (`showCoupon`), so the safe outcome is the one you get for free; pass `showCoupon={false}` only for a store with no codes. Standalone, the field is `<CouponFieldBlock/>`. If no field exists anywhere, don't seed coupons and don't write "use WELCOME10" in the copy.
120
+
121
+ No shipping estimator here — `useCheckout` reprices shipping and tax from the address. Custom rows: `useCartLine(line)` (optimistic, clamped, coalesced) and `useCoupon()`.
122
+
123
+ ## Checkout — one block
124
+
125
+ ```jsx
126
+ export default function Checkout() {
127
+ return <main className="mx-auto max-w-3xl px-6 py-16">
128
+ <h1 className="font-heading text-5xl">Checkout</h1>
129
+ <CheckoutBlock />
130
+ </main>;
131
+ }
132
+ ```
133
+
134
+ That is the whole page: address → delivery → payment → coupon → summary → place order, with its own `<CheckoutProvider>`. Correct with zero props in a default-seeded store, and every branch a checkout must handle is wired: shipping repricing from the address, a single option reading as selected, a store with no enabled gateway saying so instead of rendering a dead button, the place-order gate explaining itself, the redirect for an online gateway. Restyle with `className`, replace a region with `slots={{ summary: <MySummary/> }}`, or set `onPlaced`/`orderReceivedPath`.
135
+
136
+ Escalate only for different *structure*, and then to the hooks the block itself uses: `useCheckout` / `CheckoutProvider` / `useCheckoutContext`, `useAddressForm("billing")` (fields including `state`, country options never null), `<ShippingMethodPicker>` / `<PaymentMethodPicker>` (headless render props over the two choices that are store data), `useTotalsLines()`, `useCheckoutBlockers()`.
137
+
138
+ **Payment methods, currency and countries come from `useStoreInfo()` only** — `cart.payment_gateways` is always `undefined`, and a default store offers `offline` only ([`./03-data.md`](./03-data.md)).
139
+
140
+ ## Order received
141
+
142
+ ```jsx
143
+ <main className="mx-auto max-w-2xl px-6 py-20"><OrderReceivedBlock /></main>
144
+ ```
145
+
146
+ Mandatory route. It renders all five states, including the two hand-written pages drop: **`paymentInstructions` for a manual/offline order** — the default gateway, so this is how the store's normal customer learns how to pay — and the pay-now link for an unpaid card order. It is `noindex`, as a receipt carrying an order key should be. Custom version: `useOrderReturn()` + `useTotalsLines(order)` (an order's totals are **flat** — `order.total`; there is no `order.totals`).
147
+
148
+ ## SEO — one line per page type
149
+
150
+ ```jsx
151
+ useStorefrontSeo(productSeo(p.product, p.view, { storeName, currency })); // product page
152
+ useStorefrontSeo(collectionSeo({ title, products: list.products })); // collection / home
153
+ useStorefrontSeo(orderSeo(order)); // order-received & checkout → noindex
154
+ ```
155
+
156
+ It is a hook — call it above the page's early returns (the builders tolerate a null product).
157
+
158
+ ## Per-page output budgets
159
+
160
+ | Page | budget (chars) | rationale |
161
+ |---|---|---|
162
+ | Checkout | ≤ 2K | `<CheckoutBlock/>` + theme overrides; hand-rolling any step means a block escape hatch was missed |
163
+ | Cart / bag | ≤ 2K | `<CartLinesBlock/>` + `<TotalsBlock/>` + empty state |
164
+ | Order-received | ≤ 1.5K | `<OrderReceivedBlock/>` + brand framing |
165
+ | Product page | ≤ 4K | custom layout around `useProduct` + the five blocks above |
166
+ | Collection | ≤ 3K | `useProductList` + custom card + pagination controls |
167
+ | Home | ≤ 5K | pure identity tier — hero/editorial earn their chars |
168
+ | Any single component file | ≤ 4K, hard ceiling 8K | Base1 evidence: decode is 34% of wall; a 12K file is a 45s write batch |
169
+
170
+ Over budget ⇒ extract components, or adopt the block you are re-implementing. **A page re-implementing something a block ships — an address step, a quantity stepper, a totals row, a variant control — has missed an escape hatch: take the block and restyle it.**
171
+
172
+ ## Done — forget this file
173
+
174
+ - [ ] Catalog UI exists in whatever form fits the store (list, product pages, or both), plus a checkout, plus `/order-received`.
175
+ - [ ] **One** `<StorefrontProvider>` above every storefront route, wrapping `<Routes>` (or a layout route's `<Outlet/>`); one client, no hand-rolled `cart_token`.
176
+ - [ ] Pages branch on `status`; no page maps a possibly-null list or shows an empty state while loading.
177
+ - [ ] Gateways/currency/countries read from `useStoreInfo()` only.
178
+ - [ ] If the store has coupons, a coupon field exists in the cart or the checkout.
179
+ - [ ] `/order-received` renders `<OrderReceivedBlock/>` (payment instructions included).
180
+ - [ ] Every page is within its budget above.
181
+ - [ ] A real purchase completes in the preview — pick a variant, add it, check out, place an offline order, land on `/order-received`.
182
+
183
+ Record these lines in your working notes; do not re-read this file.
184
+
185
+ - Payment gateways, currency and countries come from `useStoreInfo()` only — never off a cart (`cart.payment_gateways` is always undefined).
186
+ - A store with any coupons must have a coupon field. `<CartLinesBlock/>` and `<CheckoutBlock/>` both ship one by default (`showCoupon`) — keep it unless the store has no codes.
187
+ - `/order-received` renders `<OrderReceivedBlock/>`, which shows `paymentInstructions` — how a normal (offline) customer learns how to pay.
188
+ - Branch cart/list/product UI on `status`, never on `isEmpty`/nullable data — `isEmpty` is false while loading by design.
@@ -0,0 +1,162 @@
1
+ ---
2
+ stage: install/03
3
+ read_when: "You are about to build the seed payload — the store's catalog, shipping, coupons, currency — or deciding about payments."
4
+ skip_when: "commerce/seed-store has already returned success for this store and payments are decided."
5
+ forget_when: "The seed response is received and recorded (slugs + warnings), and the checklist at the bottom passes."
6
+ carry_forward:
7
+ - "Product slugs come from the seed response's catalog.products[] — link pages by slug, never by a client-side map."
8
+ - "Payments: report at handover where they landed (default = offline on, card off) — the owner must never learn it from a customer."
9
+ - "Turning card payments on or off later is one more seed call: { payment_methods: [\"offline\", \"card\"] }."
10
+ - "Seed-time `locations` is THE shipping path; patching commerce.ShippingTaxLocation is the day-2 route."
11
+ ---
12
+
13
+ # 03 — Store data
14
+
15
+ A fresh install has **no settings and no catalog**. One admin-only, idempotent call to `commerce/seed-store` creates both: the six settings groups (`general`, `products`, `inventory`, `tax`, `shipping`, `emails`), the two gateway rows (`offline` enabled, `card` disabled), and — from the payload — the catalog, coupons and shipping locations. Nothing in [`./02-storefront.md`](./02-storefront.md) waits on it: fire it when the image URLs are back and pick the response up when you need the slugs.
16
+
17
+ | Mode | Body | Products created |
18
+ |---|---|---|
19
+ | **Real catalog** | `{ store_name, products: [...] }` (+ optional `coupons`, `locations`) | yours — categories, ribbons, attributes, variants, all in this one call |
20
+ | **Demo data** | `{ store_name, with_sample_data: true }` | 10 generic demo products (skipped if any product exists) |
21
+ | **No products** | `{ store_name }` | none — defaults only |
22
+
23
+ `with_sample_data` cannot be combined with `products` (**400** `invalid_payload`). Never calling the function leaves the operator the admin's first-run "Set up your store" screen.
24
+
25
+ **`store_name` is required on a first seed** (**400** `store_name_required`) — the app's name as the platform shows it (`base44/config.jsonc` → `name` can be stale; ask if unsure). It becomes the email sender name and the public shop name. **`currency`** is an ISO code (`"EUR"`); prices are *formatted* per the viewer's locale, so there is nothing else to set. Explicit values always win, first seed and re-runs alike.
26
+
27
+ Full field contract — every payload key, every response field, all error codes: [`../docs/api-admin.md`](../docs/api-admin.md#commerceseed-store). Here is the working call.
28
+
29
+ ```js
30
+ try {
31
+ const res = await base44.functions.invoke("commerce/seed-store", {
32
+ store_name: "Aurora Threads",
33
+ currency: "EUR",
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
+ },
45
+ { name: "Runner Sneaker",
46
+ sku: "SNK-RUN",
47
+ regular_price: 89, // inherited by variations that don't override
48
+ attributes: [ // the axes → one selector each in the storefront
49
+ { name: "Size", options: ["41", "42"] },
50
+ { name: "Color", options: ["Black", "White"] },
51
+ ],
52
+ default_options: { Size: "42", Color: "Black" },
53
+ variations: [ // omit entirely → all 4 combos auto-generated
54
+ { options: { Size: "41", Color: "Black" }, stock_quantity: 4 },
55
+ { options: { Size: "42", Color: "White" }, regular_price: 94, stock_quantity: 3,
56
+ image: "https://…/sneaker-white.jpg" }, // per-variation image for a visual axis
57
+ ],
58
+ },
59
+ ],
60
+ coupons: [{ code: "WELCOME10", discount_type: "percent", amount: 10 }],
61
+ // ONLY with a coupon field in the cart or checkout (see ./02-storefront.md)
62
+ // locations: [ … ], // shipping — the next section; passing any makes them the store's ONLY ones
63
+ });
64
+ return res.data; // ← the { success, data } envelope: plain JSON
65
+ } catch (e) {
66
+ return { success: false, status: e.response?.status, ...(e.response?.data ?? { error: e.message }) };
67
+ }
68
+ ```
69
+
70
+ **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
+
72
+ 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
+
74
+ **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.
75
+
76
+ The response reports everything; these matter downstream:
77
+
78
+ ```jsonc
79
+ { "catalog": { "products_created": 2, "variations_created": 3,
80
+ "products": [{ "name": "Runner Sneaker", "id": "…", "slug": "runner-sneaker", "variation_count": 3 }] },
81
+ "store_name": { "value": "Aurora Threads", "action": "created" },
82
+ "payment_methods": null, // null = the default (offline on, card off)
83
+ "warnings": [] } // always present; read it — see the shipping section
84
+ ```
85
+
86
+ Link pages by the **`slug`** from `catalog.products[]`; never mirror the seed in client-side constants (the database is the source of truth and a mirror diverges the first time the merchant edits a product).
87
+
88
+ ## Shipping — declare it in the seed call
89
+
90
+ **Seed-time `locations` is THE way to configure shipping.** Each location is a scope plus its rates and taxes, locations match in `order` ascending, and `order` defaults to the payload position — so the array reads as the priority. "€20 in Europe, €100 everywhere else" is two locations:
91
+
92
+ ```js
93
+ locations: [
94
+ { name: "Europe", continents: ["EU"], shipping_rates: [{ name: "Standard", cost: 20 }] },
95
+ { name: "Worldwide", rest_of_world: true, shipping_rates: [{ name: "International", cost: 100 }] },
96
+ ]
97
+ ```
98
+
99
+ - **`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`.
101
+ - 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
+ - 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
+
104
+ **The catch-all trap.** Passing any `locations` suppresses the seeded "Rest of the world" fallback, so scoped locations with nothing behind them mean every other address gets `shipping_not_available` at checkout. That is plausibly intended, so it is not an error — it comes back in the response as `warnings: ["no_catchall_location: …"]`. Read `warnings` on every seed and either add a `rest_of_world` location or state the restriction to the user.
105
+
106
+ Patching `commerce.ShippingTaxLocation` records afterwards is the **harder, day-2 route** — you must mint stable rate `id`s yourself, and there is no admin function for it. Do shipping in the seed payload. For continent codes in full, state-level regions, tax-group binding, free-over thresholds, VAT-on-shipping and day-2 edits: [`../references/shipping-and-tax.md`](../references/shipping-and-tax.md).
107
+
108
+ **Tiered rates are worth one assertion**, because a wrong zone looks exactly like a right one until a customer in the wrong country pays. The cart prices from an address, so ask it directly — no UI needed:
109
+
110
+ ```js
111
+ // one item in the cart, then price it for each country the brief names
112
+ for (const country of ["DE", "AU"]) {
113
+ const { data } = await base44.functions.invoke("commerce/storefront-cart", {
114
+ action: "set-shipping-address", cart_token, address: { country, city: "x" },
115
+ });
116
+ console.log(country, data.data.available_shipping_methods.map((m) => m.cost));
117
+ }
118
+ // DE → [20], AU → [100] for the payload above. A [] means no location matched
119
+ // that address — the catch-all is missing.
120
+ ```
121
+
122
+ ## Images
123
+
124
+ Every product needs at least one, and **the URL you seed is the URL the store serves** — there are no placeholders to swap later. Resolve each to its **final, permanent URL before seeding**: the app's image generation, `base44.integrations.Core.UploadFile({ file })` → public URL, or stable public stock URLs (`seed-store/sample-data.ts` shows a working pattern). Match the image to the product.
125
+
126
+ Both failure modes are silent at seed time and visible only in the store, so check now: a **temporary or signed** URL (expiry params in the query string are the tell) must be re-hosted via `UploadFile`, and spot-check that one or two URLs resolve (200, image content type). Seeding without an image and setting it later through the admin API is allowed, but it is an open debt — track every imageless product and close it before handover.
127
+
128
+ ## Payments — the decision
129
+
130
+ **The rule, in full.** Online card payments are **off by default**: `commerce/seed-store` enables the manual `offline` method and leaves the `card` gateway disabled. Offline-only is a complete, payable store — the order goes on-hold with the gateway's description rendered as payment instructions on `/order-received`, which needs no code and no credentials. **Enable `card` only if a payment provider is wired, or will be in the same stretch of work:** enabled means offered, and an enabled card option with nothing behind it answers **`503 no_card_payment_provider`** the moment a customer picks it. Enabling and wiring are two halves of one step; neither half is useful alone.
131
+
132
+ **`payment_methods` is the on/off switch.** Pass gateway slugs and the listed gateways are enabled while **every other row is disabled** — `["offline"]`, `["card"]` (card-only) or both, with no `commerce.PaymentGateway` reads or writes of your own. Omit it and the store stays offline-only. It is idempotent and needs no catalog, so `{ payment_methods: ["offline", "card"] }` alone is the later on/off switch. Unknown slugs fail `400 invalid_payload` with the known list.
133
+
134
+ | What the request says | Decision |
135
+ |---|---|
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 |
138
+ | Paid another way (transfer, COD, invoice, pickup, quotes) | nothing to do — the default already is exactly this |
139
+ | Payments not mentioned at all | leave the default and **mention it at handover** |
140
+
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.
142
+
143
+ **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
+
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).
146
+
147
+ ## Done — forget this file
148
+
149
+ - [ ] `commerce/seed-store` returned success and reported the catalog — real products, final permanent image URLs.
150
+ - [ ] `warnings` in the response is empty, or every warning is deliberate and stated to the user.
151
+ - [ ] Shipping is expressed in `locations` (with a catch-all if the store ships worldwide), not patched into entities afterwards.
152
+ - [ ] `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).
154
+ - [ ] Product slugs from `catalog.products[]` recorded, and the storefront links by them.
155
+ - [ ] If the brief named tiered rates, each named region prices to its rate (the `set-shipping-address` check above).
156
+
157
+ Record these lines in your working notes; do not re-read this file.
158
+
159
+ - Product slugs come from the seed response's `catalog.products[]` — link pages by slug, never by a client-side map.
160
+ - Payments: report at handover where they landed (default = offline on, card off) — the owner must never learn it from a customer.
161
+ - Turning card payments on or off later is one more seed call: `{ payment_methods: ["offline", "card"] }`.
162
+ - Seed-time `locations` is THE shipping path; patching `commerce.ShippingTaxLocation` is the day-2 route.
@@ -1,3 +1,13 @@
1
+ ---
2
+ stage: reference
3
+ read_when: "You are changing the admin's product editing screens — sections, price/inventory rows, attribute or variant behaviour."
4
+ skip_when: "You are not editing the admin product form; the shipped form already covers every product shape."
5
+ forget_when: "The form change saves correctly for both a simple product and one with attributes."
6
+ carry_forward:
7
+ - "A product sells variants because it carries attributes — the attributes ARE the variant control; there is no product type and no generate-variants button."
8
+ - "A variant parent's price is derived from the cheapest publishable variant on save — never add an input for it."
9
+ ---
10
+
1
11
  # The admin product form
2
12
 
3
13
  Where to change what, in `src/commerce/admin/pages/products/`. The shape follows one