@base44/app-plugin-commerce 0.1.18 → 0.1.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@base44/app-plugin-commerce",
3
- "version": "0.1.18",
3
+ "version": "0.1.19",
4
4
  "description": "Base44 Commerce plugin — entities, backend functions, shared commerce engine, admin UI and the commerce skill, shipped as copyable source",
5
5
  "keywords": [
6
6
  "base44",
@@ -16,7 +16,8 @@ Two access styles. **Reads are direct** entity SDK calls; **mutations with side
16
16
  | commerce.ProductReview | direct | **`commerce/admin-reviews`** | rating recalculation |
17
17
  | commerce.ProductCategory, commerce.ProductRibbon | direct | **direct CRUD**, or `commerce/admin-products` `save-term`/`delete-term`/`list-terms` (the API/agent path) | category slug uniqueness; ribbon get-or-create by name |
18
18
  | commerce.ProductAttribute, commerce.ProductAttributeTerm | direct | **direct CRUD**, or `commerce/admin-products` `save-term`/`delete-term`/`list-terms` (the API/agent path) | attribute `code` uniqueness; value rename rewrites products; attribute delete cascades its values |
19
- | commerce.ShippingTaxLocation, commerce.PaymentGateway | direct | **direct CRUD** | config; consumed by the pricing engine at read time |
19
+ | commerce.ShippingTaxLocation | direct | **direct CRUD** | config; consumed by the pricing engine at read time |
20
+ | commerce.PaymentGateway | direct | **`commerce/seed-store`** `payment_methods` to switch methods on/off; **direct CRUD** to change a record (add or rename a manual option, its `description`, bank accounts, `order`) | one seed call converges every gateway row to the given set, so "enable cards", "card-only" and "offline-only" are one idempotent call; the record's own fields are ordinary config — the admin's Settings → Payments screen edits them directly |
20
21
  | commerce.StoreSettings | direct | **direct CRUD** (one record per `group_id`) | grouped config |
21
22
  | commerce.Webhook | direct | **direct CRUD** (+ `commerce/admin-webhooks` for test/redeliver) | definition is data; dispatch is engine |
22
23
  | commerce.WebhookDelivery, commerce.EmailLog | direct (read-only logs) | written by the engine | audit logs |
@@ -89,6 +89,20 @@ Each row is the product record (minus paywalled fields) **plus a resolved `ribbo
89
89
 
90
90
  **Filters stack.** `category_id`, `ribbon_id`, `attribute_id` + `attribute_term`, `min_price`/`max_price`, `on_sale`, `featured` and `in_stock_only` are ANDed, so "Dresses + gift + on sale" is one request. Build the controls from [`list-categories`](#list-categories), [`list-ribbons`](#list-ribbons) (its `count` gives you "Gift (12)") and [`list-attributes`](#list-attributes), and mirror active filters into the URL so a filtered listing is shareable and survives reload.
91
91
 
92
+ **The same call also serves a short strip anywhere else** — a homepage hero or banner, a "featured" row, a "new in" rail, related picks beside an article. `per_page` bounds the result and the filters/sort choose what lands in it, so a small curated slice is one request; there is no separate endpoint, and no reason to fetch a big page and slice it client-side. Available if a page wants one:
93
+
94
+ ```js
95
+ const inv = (fn, payload) => base44.functions.invoke(fn, payload).then(r => r.data.data);
96
+ const cat = (payload) => inv("commerce/storefront-catalog", { action: "list-products", ...payload });
97
+
98
+ const { products: featured } = await cat({ featured: true, per_page: 4 });
99
+ const { products: newest } = await cat({ sort: "-created_date", per_page: 6 });
100
+ const { products: deals } = await cat({ on_sale: true, in_stock_only: true, per_page: 4 });
101
+ const { products: topInCat } = await cat({ category_id, sort: "popularity", per_page: 4 });
102
+ ```
103
+
104
+ `featured` is the admin's own per-product flag (the star in the products list, *Featured product* in the editor's Publish box; `commerce/seed-store` accepts `featured: true` per product), so a featured strip is curated store data rather than slugs pinned in the UI. Rows are the ordinary listing rows — same fields a grid card renders ([`../references/product-render.md`](../references/product-render.md)). **A filter may legitimately match nothing** (no product starred, nothing discounted), so drive the block off the returned array's length — hide it or fall back to another slice — instead of assuming rows came back; `has_next` tells you whether a "see all" link has more behind it.
105
+
92
106
  ### `get-product`
93
107
  **Payload:** `{ id }` **or** `{ slug }`; optional `reviews_page` (1), `reviews_per_page` (10, max 50).
94
108
 
@@ -64,17 +64,35 @@ Even if the client guard were bypassed, layers 2 and 3 keep the store data safe.
64
64
 
65
65
  No visitor UI ships — and no visual component ships either: **every pixel of the shopfront stays yours to design**. What ships is the logic: the storefront **API**, the framework-free helpers in `@/commerce/utils`, and the React layer in `@/commerce/storefront` — hooks and headless pickers owning the contracts every store must get right. The catalog views (§2.1–2.2) are deliberately the thinnest, because that is where storefronts differ most; the cart and checkout (§2.3–2.4) are more guided, because shipping recalculation, payment methods and the place-order gate work the same in every store. **None of it waits on anything**: every shape you build against is documented right here, so seeding (§3) runs in parallel with building these pages (§0 has the schedule) — kick off image generation, write the storefront while it renders, seed when the URLs are back. Live data is only needed once, to see real products on the finished pages. **Payments are not a prerequisite for any of this** — the whole buy path down to `place-order` is built and reviewable before a provider exists (a card gateway with no provider simply answers `503 no_card_payment_provider`, and §2.4 shows the graceful fallback), which is why the payment decision comes after these pages work, not before (§4). The four chunks below are the whole happy path; open [`docs/api-storefront.md`](./docs/api-storefront.md) only for what's beyond them (attribute/price filters, reviews, customer accounts, refunds), and [`references/product-render.md`](./references/product-render.md) for which fields belong in which view.
66
66
 
67
- **Set up once** — mount the provider above every storefront route. It owns the shared API client, the store-info cache and ONE shared cart, so a header badge, a cart drawer and the checkout all render the same state:
67
+ **Set up once** — mount the provider **around `<Routes>`**, so it sits above every storefront route. It owns the shared API client, the store-info cache and ONE shared cart, so a header badge, a cart drawer and the checkout all render the same state:
68
68
 
69
69
  ```jsx
70
70
  import { StorefrontProvider } from "@/commerce/storefront";
71
71
  import { base44 } from "@/api/base44Client";
72
-
73
- <StorefrontProvider base44={base44}>
74
- {/* storefront routes */}
75
- </StorefrontProvider>
72
+ import AdminApp from "@/commerce/admin";
73
+
74
+ <BrowserRouter> {/* the app's existing router, wherever it lives */}
75
+ <StorefrontProvider base44={base44}> {/* wraps the router's <Routes>, not a child of it */}
76
+ <Routes> {/* ONE <Routes> — merge storefront routes into the app's */}
77
+ <Route path="/" element={<Home />} />
78
+ <Route path="/product/:slug" element={<ProductPage />} />
79
+ <Route path="/checkout" element={<Checkout />} />
80
+ <Route path="/order-received" element={<OrderReceived />} />
81
+ <Route path="/store-admin/*" element={<AdminApp />} />
82
+ </Routes>
83
+ </StorefrontProvider>
84
+ </BrowserRouter>
76
85
  ```
77
86
 
87
+ > ⚠ **`<Routes>` accepts only `<Route>` children — the provider goes outside it.** Nesting it inside (the natural reading of "wrap the storefront routes", and the usual mistake) throws at render: `Error: [StorefrontProvider] is not a <Route> component. All component children of <Routes> must be a <Route> or <React.Fragment>`. Wrapping the whole `<Routes>` as above is the simplest correct shape and fine even with the admin route inside — the admin doesn't use the storefront hooks. Same rule per file: an app that already has a `<Routes>` gets the new pages **added to it**, never a second `<Routes>` block. To scope the provider to storefront routes only, use a pathless **layout route** — the one place a wrapper component is legal — and render `<Outlet />` inside it:
88
+ > ```jsx
89
+ > <Route element={<StorefrontProvider base44={base44}><Outlet /></StorefrontProvider>}>
90
+ > <Route path="/" element={<Home />} /> {/* these share one cart */}
91
+ > <Route path="/checkout" element={<Checkout />} />
92
+ > </Route>
93
+ > <Route path="/store-admin/*" element={<AdminApp />} /> {/* outside the provider */}
94
+ > ```
95
+
78
96
  The provider owns the two things hand-rolled storefronts keep getting wrong, so **don't reimplement either — and never mount a second provider or create a second client**: the **`cart_token` lifecycle** (sent with every cart/checkout call, re-persisted from every response — a stale token silently starts a fresh cart; rolling 48 h expiry; cleared when checkout consumes the cart) and the **store-info cache** (`payment_gateways`, currency, countries live **only** on `get-store-info` — the cart view never carries them). Inside the tree, `useStorefront()` returns the shared client for catalog calls, and `store.inv(fn, payload)` on it is the raw escape hatch (it unwraps the `{ success, data }` envelope). If non-React code also needs the client, create it once in `src/lib/storefront.js` (`createStorefront(base44)` from `@/commerce/utils`) and pass that same instance via `<StorefrontProvider store={store}>`.
79
97
 
80
98
  **What each hook / client method resolves to** — every envelope is already unwrapped, so take these shapes at face value (no `.data`, and no `.categories` on a list result):
@@ -111,6 +129,19 @@ const { products, page, per_page, has_next } = await store.listProducts({
111
129
 
112
130
  Each row is a full product record — for a card use `name`, `images[0]?.src` (**may be empty — render a placeholder, never a broken `<img>`**), `price`, `regular_price`, `on_sale` (sale badge), `short_description`, `stock_status`, `average_rating`/`rating_count` (stars cost no extra call) and `ribbons` (`[{ id, name }]`, may be absent — labels like "Best Seller" for the card corner). **There is no product type flag**: `product.attributes?.length > 0` means the product sells variants and its `price` is a *from*-price rolled up from the cheapest variant — render it as "From …". Categories for the nav come from `store.listCategories()` — an **array** of root categories with subcategories nested under `children` (map over it directly; there is no `{ categories }` wrapper on the result). That is the whole card — no other call or reference needed for the list view.
113
131
 
132
+ **A short strip of products is the same call with a small `per_page`.** Products are not confined to the grid — a homepage hero or banner, a "featured" row, a "new in" rail, a few picks beside an article are all one `listProducts` call: `per_page` bounds how many come back and the filters/sort decide which ones. No separate endpoint, and never fetch a big page to slice client-side. Use this if a page of yours wants such a strip; the catalog pages above are complete without one:
133
+
134
+ ```js
135
+ const { products: featured } = await store.listProducts({ featured: true, per_page: 4 });
136
+ const { products: newest } = await store.listProducts({ sort: "-created_date", per_page: 6 });
137
+ const { products: deals } = await store.listProducts({ on_sale: true, per_page: 4 });
138
+ const { products: topInCat } = await store.listProducts({ category_id, sort: "popularity", per_page: 4 });
139
+ // filters stack (featured + category_id + in_stock_only …); sort: -created_date | name |
140
+ // price | -price | popularity | rating. Rows are the same shape the cards above render.
141
+ ```
142
+
143
+ `featured` is the merchant's own flag — the star in the admin's product list and *Featured product* in the editor's Publish box — and `seed-store` accepts `featured: true` per product, so such a row stays curated store data instead of a hardcoded list of slugs. **Any filter can legitimately match nothing** (nobody has starred a product, nothing is discounted): render the block from the returned array's length — hide it, or fall back to another slice — rather than assuming rows came back.
144
+
114
145
  **Carry forward:** each card links to the product page by **`slug`**.
115
146
 
116
147
  ### 2.2 Product page — variant selection included
@@ -422,7 +453,7 @@ Decide from what the user actually asked for:
422
453
 
423
454
  Whichever row applies, **say where payments landed** in your summary to the user — including "offline only, cards off". A store's owner should never discover their payment configuration from a customer who couldn't pay.
424
455
 
425
- Changing the answer later is one more seed call, not surgery: `commerce/seed-store` with just `{ payment_methods: [...] }` is idempotent (no catalog needed — products are skipped) and converges **every** gateway row to that set, so an offline-only store can add cards, or go card-only, at any point. Never edit `commerce.PaymentGateway` records yourself.
456
+ Changing the answer later is one more seed call, not surgery: `commerce/seed-store` with just `{ payment_methods: [...] }` is idempotent (no catalog needed — products are skipped) and converges **every** gateway row to that set, so an offline-only store can add cards, or go card-only, at any point. That call is the on/off switch; editing a `commerce.PaymentGateway` record directly is for the record's own fields (a new or renamed manual option, its description, bank accounts, ordering).
426
457
 
427
458
  ### 4.2 Wiring a provider — one file
428
459
 
@@ -440,7 +471,9 @@ Four steps, whichever provider it is:
440
471
  1. **Implement the four functions** in `base44/shared/commerce/card-payment.ts` against the provider's API. **No provider ships with the template** — the file arrives as stubs, and [`references/online-payments.md`](./references/online-payments.md) holds the per-provider rules plus a complete **Stripe** implementation to paste if Stripe is the provider the store chose. Write the file **whole, in one write. Never `find_replace` into the stubs**: a partial patch leaves the originals behind and breaks every commerce function's deploy with duplicate-export bundle errors (`Multiple exports with the same name "createCardPayment"` → rewrite the file whole).
441
472
  2. **Store the provider's API credential as a backend app secret** (read with `Deno.env.get(...)` — never in code, never in an entity). Test credentials work end to end. This is the only step that needs the user, so it sets the timing of the whole section (§4.1): ask when the store is standing, not in the install's first message — and if the answer takes a while, keep the rest of the work moving rather than idling on it.
442
473
  3. **Register the premade webhook URL** — `https://<app-domain>/functions/commerce/payment-webhook` — with the provider, for its "payment succeeded" event, so orders are confirmed even when the buyer pays and closes the tab. There is **no signing secret to store**: events are treated as nudges and verified against the provider's API. (Per-provider registration calls are in the reference; the user can also do it in the provider's dashboard.)
443
- 4. **Enable the gateway** — the `card` row is seeded **off** (§4.1), so the file alone changes nothing a customer sees. Call `commerce/seed-store` with `{ payment_methods: ["offline", "card"] }` (or `["card"]` for card-only) — safe on a seeded store: no catalog needed, products are skipped, and it converges every gateway row to that set. Skipping this step is the usual reason a freshly wired provider "doesn't show up at checkout"; doing it *without* steps 1–2 is what produces `503 no_card_payment_provider`.
474
+ 4. **Enable the gateway** — the `card` row is seeded **off** (§4.1), so the file alone changes nothing a customer sees. **Turning a method on or off is the seed call:** `commerce/seed-store` with `{ payment_methods: ["offline", "card"] }` (or `["card"]` for card-only) — safe on a seeded store (no catalog needed, products are skipped), idempotent, and it converges every gateway row to that set, so the same call also goes card-only or switches cards back off later. Reach for direct CRUD on `commerce.PaymentGateway` when the **record itself** needs changing — adding a new manual option, renaming one, editing its description or bank accounts, reordering them — which is what the admin's Settings → Payments screen does.
475
+
476
+ Skipping this step is the usual reason a freshly wired provider "doesn't show up at checkout"; doing it *without* steps 1–2 is what produces `503 no_card_payment_provider`.
444
477
 
445
478
  That's it — checkout redirect, `/order-received` confirmation, the webhook, the admin's "Check payment" button, payment links and provider refunds all run through this one file.
446
479
 
@@ -454,7 +487,7 @@ Post-installation is complete when every line below holds. **Do not validate the
454
487
  - [ ] `commerce/seed-store` ran once and reported the catalog — real products, final image URLs; `payment_methods` passed only if the store's methods differ from the default (offline on, cards off).
455
488
  - [ ] **Catalog pages and a checkout exist** — the catalog UI in whatever form fits the store (a product list, product pages, or both — one can be enough), a cart step only if the store wants one (§2.3), and the checkout built on `useCheckout` with `/order-received` rendering `useOrderReturn` (§2.4).
456
489
  - [ ] **Every coupon the store has can actually be redeemed** — if `coupons` was seeded (or the operator has codes), a code field with `applyCoupon` exists in the cart or the checkout, showing `discount_total` and rendering invalid codes inline (§2.3/§2.4). No field → don't seed coupons and don't mention codes in the copy.
457
- - [ ] **One `<StorefrontProvider>`** above every storefront route — no second client, no hand-rolled `cart_token` handling, payment gateways read from `useStoreInfo()` only (never off a cart), and cart state everywhere through `useCart()`.
490
+ - [ ] **One `<StorefrontProvider>`** above every storefront route — wrapping `<Routes>` (or a layout route's `<Outlet />`), never placed as a child of `<Routes>`, which React Router rejects (§2) — no second client, no hand-rolled `cart_token` handling, payment gateways read from `useStoreInfo()` only (never off a cart), and cart state everywhere through `useCart()`.
458
491
  - [ ] **Cards are either off, or on with a provider behind them** (§4.1) — the default (offline enabled, `card` off) is a complete state and needs nothing. If you enabled cards, the provider must be wired: `card-payment.ts` written whole, secret stored, webhook registered. Enabled without a wired provider means `503 no_card_payment_provider` at checkout; wired without enabling means customers never see the option. Say where payments landed when you hand over.
459
492
 
460
493
  ## 6. Next
@@ -13,10 +13,14 @@ import { createStorefront, storefrontErrorCode, storefrontErrorMessage } from "@
13
13
  * StorefrontProvider — one client, one store-info cache, ONE shared cart.
14
14
  *
15
15
  * Mount it once, above every storefront page (product list, product page,
16
- * cart, checkout, order-received):
16
+ * cart, checkout, order-received) — it wraps <Routes>, it is NOT a <Route>:
17
17
  *
18
18
  * import { base44 } from "@/api/base44Client";
19
- * <StorefrontProvider base44={base44}> ... </StorefrontProvider>
19
+ * <StorefrontProvider base44={base44}> <Routes>…</Routes> </StorefrontProvider>
20
+ *
21
+ * Inside <Routes> it throws ("is not a <Route> component"), since React Router
22
+ * allows only <Route>/<Fragment> children there. To cover just some routes,
23
+ * use a pathless layout route: element={<StorefrontProvider …><Outlet/></…>}.
20
24
  *
21
25
  * or, if other modules also need the raw client, create it once and share it:
22
26
  *
@@ -102,7 +106,7 @@ export function useStorefrontState() {
102
106
  const ctx = useContext(StorefrontContext);
103
107
  if (!ctx) {
104
108
  throw new Error(
105
- "Storefront hooks need a <StorefrontProvider> above them — mount it once around your storefront routes.",
109
+ "Storefront hooks need a <StorefrontProvider> above them — mount it once around your <Routes> (it wraps the router; it is not a <Route>, and inside <Routes> React Router rejects it).",
106
110
  );
107
111
  }
108
112
  return ctx;
@@ -6,7 +6,8 @@
6
6
  * every pixel stays yours. The hooks own the contracts that are easy to get
7
7
  * subtly wrong; you own the markup.
8
8
  *
9
- * Setup (once, above every storefront route):
9
+ * Setup (once, above every storefront route — it wraps <Routes>; placed as a
10
+ * child of <Routes> React Router throws "is not a <Route> component"):
10
11
  *
11
12
  * import { StorefrontProvider } from "@/commerce/storefront";
12
13
  * import { base44 } from "@/api/base44Client";