@base44/app-plugin-commerce 0.1.3 → 0.1.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -11
- package/base44/agents/commerce/StoreAdmin.jsonc +7 -7
- package/base44/entities/commerce.Order.jsonc +1 -1
- package/base44/entities/commerce.PaymentGateway.jsonc +3 -3
- package/base44/entities/commerce.Product.jsonc +9 -45
- package/base44/entities/commerce.ProductAttribute.jsonc +7 -19
- package/base44/entities/commerce.ProductAttributeTerm.jsonc +4 -10
- package/base44/entities/commerce.ProductTag.jsonc +1 -8
- package/base44/entities/commerce.ProductVariation.jsonc +1 -1
- package/base44/entities/commerce.StoreSettings.jsonc +1 -1
- package/base44/functions/commerce/admin-products/entry.ts +73 -121
- package/base44/functions/commerce/admin-refunds/entry.ts +1 -1
- package/base44/functions/commerce/admin-reports/entry.ts +1 -1
- package/base44/functions/commerce/admin-tools/entry.ts +4 -3
- package/base44/functions/commerce/payments/entry.ts +0 -2
- package/base44/functions/commerce/seed-store/defaults.ts +13 -39
- package/base44/functions/commerce/seed-store/entry.ts +50 -166
- package/base44/functions/commerce/seed-store/sample-data.ts +28 -66
- package/base44/functions/commerce/seed-store/seed-catalog.ts +717 -0
- package/base44/functions/commerce/storefront-cart/entry.ts +0 -7
- package/base44/functions/commerce/storefront-catalog/entry.ts +12 -21
- package/base44/functions/commerce/storefront-checkout/entry.ts +5 -12
- package/base44/shared/commerce/catalog.ts +134 -0
- package/base44/shared/commerce/coupons.ts +4 -7
- package/base44/shared/commerce/email-templates.ts +15 -13
- package/base44/shared/commerce/emails.ts +4 -3
- package/base44/shared/commerce/payments.ts +8 -20
- package/base44/shared/commerce/products.ts +24 -0
- package/base44/shared/commerce/settings.ts +2 -3
- package/base44/shared/commerce/stock.ts +2 -4
- package/base44/shared/commerce/totals.ts +37 -42
- package/package.json +1 -1
- package/scripts/install.js +23 -7
- package/skills/commerce/SKILL.md +9 -7
- package/skills/commerce/docs/api-admin.md +20 -14
- package/skills/commerce/docs/api-storefront.md +39 -35
- package/skills/commerce/installation-guidelines.md +8 -7
- package/skills/commerce/post-installation.md +205 -84
- package/skills/commerce/references/admin-product-form.md +56 -0
- package/skills/commerce/references/emails.md +4 -3
- package/skills/commerce/references/guest-access-security.md +2 -2
- package/skills/commerce/references/limits-and-performance.md +1 -1
- package/skills/commerce/references/online-payments.md +8 -8
- package/skills/commerce/references/product-render.md +25 -23
- package/skills/commerce/references/reviews.md +20 -0
- package/skills/commerce/references/storefront-product-page.md +9 -9
- package/skills/commerce/references/webhooks.md +3 -1
- package/src/commerce/admin/README.md +4 -4
- package/src/commerce/admin/context/BasePathContext.jsx +5 -5
- package/src/commerce/admin/context/SettingsContext.jsx +4 -4
- package/src/commerce/admin/index.jsx +3 -3
- package/src/commerce/admin/layout/Sidebar.jsx +1 -9
- package/src/commerce/admin/lib/constants.js +0 -7
- package/src/commerce/admin/lib/paths.js +6 -6
- package/src/commerce/admin/lib/product-utils.js +18 -15
- package/src/commerce/admin/pages/orders/components/AddProductDialog.jsx +8 -8
- package/src/commerce/admin/pages/products/ProductEditor.jsx +10 -12
- package/src/commerce/admin/pages/products/ProductsList.jsx +3 -15
- package/src/commerce/admin/pages/products/components/AttributesSection.jsx +426 -0
- package/src/commerce/admin/pages/products/components/ProductDataPanel.jsx +33 -69
- package/src/commerce/admin/pages/products/components/TaxonomyPanel.jsx +1 -1
- package/src/commerce/admin/pages/products/components/tabs/LinkedTab.jsx +1 -14
- package/src/commerce/admin/pages/products/components/tabs/ModifiersTab.jsx +19 -0
- package/src/commerce/admin/pages/products/components/tabs/PriceInventoryTab.jsx +728 -0
- package/src/commerce/admin/pages/reports/Reports.jsx +3 -3
- package/src/commerce/admin/pages/settings/EmailsSettings.jsx +12 -14
- package/src/commerce/admin/pages/settings/GeneralSettings.jsx +6 -116
- package/src/commerce/admin/pages/settings/PaymentsSettings.jsx +101 -62
- package/src/commerce/admin/pages/settings/SettingsLayout.jsx +2 -6
- package/src/commerce/admin/pages/settings/TaxSettings.jsx +0 -1
- package/src/commerce/admin/pages/status/WebhookEditor.jsx +3 -3
- package/src/commerce/admin/pages/status/Webhooks.jsx +2 -2
- package/src/commerce/admin/routes.jsx +10 -19
- package/src/commerce/utils/index.js +2 -2
- package/src/commerce/utils/shipping-promos.js +9 -6
- package/src/commerce/utils/variants.js +21 -21
- package/src/commerce/admin/pages/products/AttributeTerms.jsx +0 -180
- package/src/commerce/admin/pages/products/Attributes.jsx +0 -183
- package/src/commerce/admin/pages/products/Tags.jsx +0 -150
- package/src/commerce/admin/pages/products/components/tabs/AdvancedTab.jsx +0 -48
- package/src/commerce/admin/pages/products/components/tabs/AttributesTab.jsx +0 -208
- package/src/commerce/admin/pages/products/components/tabs/ExternalTab.jsx +0 -41
- package/src/commerce/admin/pages/products/components/tabs/GeneralTab.jsx +0 -103
- package/src/commerce/admin/pages/products/components/tabs/InventoryTab.jsx +0 -93
- package/src/commerce/admin/pages/products/components/tabs/ShippingTab.jsx +0 -86
- package/src/commerce/admin/pages/products/components/tabs/VariationsTab.jsx +0 -377
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
- `base44/shared/commerce/payments.ts` — the **provider-neutral payment utility** every caller uses, plus the one-line `ACTIVE_PROVIDER` binding.
|
|
6
6
|
- `base44/shared/commerce/stripe.ts` — the Stripe adapter (the only file that speaks Stripe).
|
|
7
|
-
- `base44/functions/commerce/payments/` — `status` · `create-link` · `verify`.
|
|
7
|
+
- `base44/functions/commerce/payments/` — `status` · `create-link` · `complete-return` · `verify`.
|
|
8
8
|
- `base44/functions/commerce/payment-webhook/` — the provider's signed callback.
|
|
9
9
|
- `commerce/storefront-checkout` `place-order` returns a `checkout_url`; `commerce/admin-refunds` refunds through the provider; the admin order page has a **Payment** panel.
|
|
10
10
|
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
|
|
13
13
|
## 1. What to do when building a store (strong recommendation, not a rule)
|
|
14
14
|
|
|
15
|
-
A store that can't take a card is usually not what the user wants: the
|
|
15
|
+
A store that can't take a card is usually not what the user wants: the gateway that works with no provider at all (`offline`) is **manual** — the customer leaves without paying and someone reconciles by hand. So **recommend online payment clearly** — but **at the right moment, which is not first.**
|
|
16
16
|
|
|
17
17
|
**Sequence it after the store exists.** Connecting a provider is a step only the *user* can take, in the platform dashboard: asking for it up front stops your work dead on someone else's action, and even once done there is nothing to verify against — no product to buy, no shipping method, no order. Set the store up first (settings → catalog → shipping), *then* raise payments, as the last thing before the store is usable or before you build checkout. The card gateway ships enabled and stays hidden from customers until a provider is connected, so nothing is broken while you wait, and nothing is lost by doing it late.
|
|
18
18
|
|
|
@@ -60,11 +60,11 @@ Card data never touches this app — the customer pays on the provider's page.
|
|
|
60
60
|
|
|
61
61
|
1. `success_url` / `cancel_url` — a full page URL per outcome;
|
|
62
62
|
2. `return_url` — **your return page**, used for both outcomes (`payment=success|cancel` distinguishes them). A bare origin (`https://shop`, no path) is instead treated as a base, since there's no page in it;
|
|
63
|
-
3. the
|
|
63
|
+
3. the origin the request came from (the storefront that called this) + the store's **Payment return path** (`settings.general.order_received_path`).
|
|
64
64
|
|
|
65
65
|
Every result carries `order_id`, `order_key` and `payment`, **including URLs you supplied** — a `success_url` without them can't verify anything. Existing query parameters survive. With nothing to build from, the call fails `400 return_url_required` rather than letting the provider answer "Not a valid URL".
|
|
66
66
|
|
|
67
|
-
**Set the return path to whatever route you built.** It defaults to `/order-received`, and a wrong value sends paying customers to a 404 — including the confusing `/<your-page>/<your-page>` you get if you treat `return_url` as a base and your page path as a suffix. The admin's payment links deliberately pass no URL at all: the route belongs to the storefront, so they
|
|
67
|
+
**Set the return path to whatever route you built.** It defaults to `/order-received`, and a wrong value sends paying customers to a 404 — including the confusing `/<your-page>/<your-page>` you get if you treat `return_url` as a base and your page path as a suffix. The admin's payment links deliberately pass no URL at all: the route belongs to the storefront, so they resolve to the app's own origin plus the store's configured path.
|
|
68
68
|
|
|
69
69
|
### MANDATORY: the payment return page (`/order-received`)
|
|
70
70
|
|
|
@@ -101,7 +101,7 @@ Rules for whatever you build:
|
|
|
101
101
|
- **Handle all three states**, not just the happy one — `unpaid` is common (bank delays, closed tabs) and needs a *Check again* plus *Pay now*, not an error.
|
|
102
102
|
- **Don't gate it behind login.** Guests pay too; `order_key` in the link is the credential.
|
|
103
103
|
|
|
104
|
-
**Storefront wiring** (the only integration work)
|
|
104
|
+
**Storefront wiring** (the only integration work):
|
|
105
105
|
|
|
106
106
|
```js
|
|
107
107
|
const res = await inv("commerce/storefront-checkout", {
|
|
@@ -128,7 +128,7 @@ The order page's **Payment** panel shows the method, total, paid state and payme
|
|
|
128
128
|
- **Create payment link** — the provider-hosted page for exactly that order's total; copy it to the customer.
|
|
129
129
|
- **Check payment** — re-asks the provider and moves the order on if the money arrived.
|
|
130
130
|
|
|
131
|
-
**The order's current payment method doesn't matter.** Asking for a link *is* the decision to collect online, and an admin-created order starts with no method at all, so `create-link` accepts any unpaid order and switches it onto the online gateway, recording the change in the order log (`Payment method set to Credit card (was
|
|
131
|
+
**The order's current payment method doesn't matter.** Asking for a link *is* the decision to collect online, and an admin-created order starts with no method at all, so `create-link` accepts any unpaid order and switches it onto the online gateway, recording the change in the order log (`Payment method set to Credit card (was Offline payment)…`). That switch matters beyond tidiness: refunds key off `payment_method`, so an order paid by card must say so or it can't be refunded through the provider. The panel warns before it happens.
|
|
132
132
|
|
|
133
133
|
The one refusal is a store that has **turned online payment off** — `400 online_payments_disabled` — because that's the operator declining it, not a missing setup.
|
|
134
134
|
|
|
@@ -186,9 +186,9 @@ Rules for any checkout UI you build:
|
|
|
186
186
|
- **Fetch, don't assume.** `commerce/storefront-catalog` → `get-store-info` returns `payment_gateways`, filtered to `enabled: true`, sorted by the admin's `order`, and with any online gateway **dropped while no provider is connected** — so what you render is always payable. Each entry is `{ slug, title, description, online }`.
|
|
187
187
|
- **Use the admin's copy.** Show `title`/`description` as the customer-facing labels instead of your own strings, and key your logic off `slug` (`online: true` marks the one that redirects to a hosted page).
|
|
188
188
|
- **Render the zero-state honestly.** An empty `payment_gateways` means checkout cannot complete; say so rather than showing a dead button.
|
|
189
|
-
- **Let the server arbitrate.** Gateway `settings` are intentionally *not* exposed to the storefront, so the client cannot evaluate
|
|
189
|
+
- **Let the server arbitrate.** Gateway `settings` are intentionally *not* exposed to the storefront, so the client cannot evaluate a gateway's own rules. Send the chosen `slug` and treat `400 invalid_payment_method` from `place-order` as the authoritative answer.
|
|
190
190
|
- **Re-fetch rather than cache hard.** An admin can enable, disable or reorder gateways at any time; a long-lived cached list is how a storefront drifts out of sync with the store.
|
|
191
|
-
- **Show `payment_instructions` from the response** for
|
|
191
|
+
- **Show `payment_instructions` from the response** for the `offline` flow rather than hardcoding bank details in the UI.
|
|
192
192
|
|
|
193
193
|
## 8. Never hardcode payment availability
|
|
194
194
|
|
|
@@ -5,7 +5,7 @@ What to show for a product, and **which view can show it**. Both storefront surf
|
|
|
5
5
|
| View | Call | Returns |
|
|
6
6
|
|---|---|---|
|
|
7
7
|
| Listing / grid / search results / tag & category pages | `commerce/storefront-catalog` `list-products` | `{ products: [row...], page, per_page, has_next }` |
|
|
8
|
-
| Product page | `commerce/storefront-catalog` `get-product` | `{ product, variations, categories, tags, reviews, upsells, cross_sells
|
|
8
|
+
| Product page | `commerce/storefront-catalog` `get-product` | `{ product, variations, categories, tags, reviews, upsells, cross_sells }` |
|
|
9
9
|
|
|
10
10
|
Request/response shapes: [`../docs/api-storefront.md`](../docs/api-storefront.md). Variant selection mechanics (axes, resolving a selection to a variation, unavailable combinations): [`storefront-product-page.md`](./storefront-product-page.md).
|
|
11
11
|
|
|
@@ -19,48 +19,50 @@ A listing **row** is the product record itself (minus paywalled fields), plus re
|
|
|
19
19
|
|
|
20
20
|
| Data | `list-products` row | `get-product` | Notes |
|
|
21
21
|
|---|---|---|---|
|
|
22
|
-
| `id`, `name`, `slug`, `
|
|
23
|
-
| `price`, `regular_price`, `sale_price`, `on_sale` | ✅ | ✅ |
|
|
22
|
+
| `id`, `name`, `slug`, `status` | ✅ | ✅ | There is no `type` field — a product sells variants when `attributes[]` is non-empty |
|
|
23
|
+
| `price`, `regular_price`, `sale_price`, `on_sale` | ✅ | ✅ | With variants the parent `price` is a starting point, not the truth — see §3 |
|
|
24
24
|
| `images[]`, `featured`, `short_description`, `description` | ✅ | ✅ | Cards normally use `images[0]` + `short_description`. `images` can be **empty** — render a placeholder, don't leave a broken `img` (on the product page `resolveSelection`'s `display.image` is `null` in that same case) |
|
|
25
25
|
| `sku`, `stock_status`, `stock_quantity`, `manage_stock`, `backorders` | ✅ | ✅ | |
|
|
26
26
|
| `average_rating`, `rating_count`, `total_sales` | ✅ | ✅ | Enough for stars on a card; the reviews themselves are not in a row |
|
|
27
|
-
| `downloadable`, `virtual`, `weight`, `dimensions`, `attributes[]`, `meta_data` | ✅ | ✅ | `attributes[]` is the
|
|
28
|
-
| **`tags`** (resolved `{id, name
|
|
27
|
+
| `downloadable`, `virtual`, `weight`, `dimensions`, `attributes[]`, `meta_data` | ✅ | ✅ | `attributes[]` is the variant axes; `meta_data` is the descriptive properties the admin calls **modifiers** |
|
|
28
|
+
| **`tags`** (resolved `{id, name}`) | ✅ | ✅ | `get-product` returns the full tag records; rows carry the short form |
|
|
29
29
|
| `tag_ids`, `category_ids` | ✅ | ✅ | Raw ids |
|
|
30
30
|
| **`categories`** (resolved) | ❌ *ids only* | ✅ | See §5 to add |
|
|
31
|
-
| **`variations[]`** (per-
|
|
31
|
+
| **`variations[]`** (per-variant price/stock/image/attributes) | ❌ | ✅ | The reason a product with variants can't be fully priced from a row |
|
|
32
32
|
| **`reviews`** (paged items + `average_rating`/`rating_count`) | ❌ | ✅ | Row still has the aggregate numbers |
|
|
33
33
|
| **`upsells`, `cross_sells`** (summaries) | ❌ | ✅ | `{id, name, slug, price, on_sale, image}` |
|
|
34
|
-
|
|
|
35
|
-
| `downloads[]`, `download_limit`, `download_expiry`, `purchase_note` | ❌ | ❌ | **Never** exposed publicly — paywalled/post-purchase. Gated by `commerce/storefront-account` `get-download` |
|
|
34
|
+
| `downloads[]`, `download_limit`, `download_expiry` | ❌ | ❌ | **Never** exposed publicly — paywalled. Gated by `commerce/storefront-account` `get-download` |
|
|
36
35
|
|
|
37
36
|
## 2. Tags — render them in **both** views
|
|
38
37
|
|
|
39
38
|
Tags are a flat, cross-cutting axis ("gift", "summer", "vegan"); categories are the hierarchical spine. Generated storefronts routinely omit tags entirely. Don't.
|
|
40
39
|
|
|
41
|
-
- **Listing rows carry resolved `tags`** (`{id, name
|
|
42
|
-
- **On the product page**, render `product.tags` (the top-level `tags` array
|
|
43
|
-
- **Every chip links to a filtered listing** — `list-products` with `tag_id` — never a dead label.
|
|
40
|
+
- **Listing rows carry resolved `tags`** (`{id, name}`), so a card can render them with no extra call. One or two chips per card is useful ("New", "Bundle"); more is noise. Hide the block when the array is empty — never a dangling "Tags:" label.
|
|
41
|
+
- **On the product page**, render `product.tags` (the top-level `tags` array) as chips near the metadata, visually lighter than the category breadcrumb.
|
|
42
|
+
- **Every chip links to a filtered listing** — `list-products` with `tag_id` — never a dead label. A tag has no slug, so key the URL on its id (`/shop?tag=<tag_id>`) so the page is shareable and survives reload. Use `list-tags` for a name to label it with.
|
|
44
43
|
- **Offer tags as a listing filter** from `list-tags`, which hides tags no published product carries and gives a `count` for labels like "Gift (12)". `tag_id` stacks with `category_id`, price, `on_sale`, `featured`, `in_stock_only`.
|
|
45
|
-
- **Tag landing page:** `list-products` filtered by `tag_id`, with the tag's `name` as heading
|
|
44
|
+
- **Tag landing page:** `list-products` filtered by `tag_id`, with the tag's `name` as the heading.
|
|
46
45
|
- Don't put tags in the breadcrumb, and don't use them as variant options — a size or colour is an `attribute`, not a tag.
|
|
46
|
+
- Descriptive properties (Material, Care) are **modifiers** in `meta_data`, not tags and not attributes. Render them as a spec table on the product page.
|
|
47
47
|
|
|
48
|
-
## 3. Product
|
|
48
|
+
## 3. Product shapes in each view
|
|
49
49
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
|
53
|
-
|
|
54
|
-
| `
|
|
55
|
-
| `
|
|
50
|
+
There are only two shapes, and **`attributes[]` tells them apart** — there is no `type` field:
|
|
51
|
+
|
|
52
|
+
| Shape | Test | Card | Product page |
|
|
53
|
+
|---|---|---|---|
|
|
54
|
+
| **Single item** | `attributes` is empty | price, **Add to cart** straight from the card if you want one | single **Add to cart** — `resolveSelection` returns no axes, `complete: true`, a ready `addToCart` |
|
|
55
|
+
| **Has variants** | `attributes` is non-empty | price **range or `From €19`** (see below), never selectors — link through | one selector per attribute: [`storefront-product-page.md`](./storefront-product-page.md) |
|
|
56
|
+
|
|
57
|
+
A product with attributes is **only** sellable through a variant: `add-item` without a `variation_id` is `400 variation_required`, and there is no fall-back to the parent. So one with attributes but no variations is unsellable by design, not by accident.
|
|
56
58
|
|
|
57
|
-
Downloadable/virtual products are
|
|
59
|
+
Downloadable/virtual products are either shape with `downloadable: true` — see [`media-and-downloads.md`](./media-and-downloads.md).
|
|
58
60
|
|
|
59
|
-
### A
|
|
61
|
+
### A variant parent's `price` is its *from* price
|
|
60
62
|
|
|
61
|
-
|
|
63
|
+
`admin-products` rolls both **stock** and **price** up from the variations: a parent's `regular_price`, `price` and `on_sale` are derived from the cheapest publishable variant on every save. So the parent price is real and safe to sort and filter on — but it is the **lowest** price, not *the* price.
|
|
62
64
|
|
|
63
|
-
|
|
65
|
+
A listing row has no `variations`, so a card still cannot compute the full range from a row alone: render `From {product.price}`, never the parent price presented as *the* price. On the product page you have `variations`, so show the real range via `priceRange` from the variant helpers.
|
|
64
66
|
|
|
65
67
|
## 4. Sensible defaults per view
|
|
66
68
|
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# Reviews
|
|
2
|
+
|
|
3
|
+
**Unless you are sure the store doesn't want reviews, implement them: the backend already exists.** Skipping them is the common miss, and it costs the store the social proof most shoppers look for before buying.
|
|
4
|
+
|
|
5
|
+
What ships, with nothing to write on the backend:
|
|
6
|
+
|
|
7
|
+
- `commerce/storefront-catalog` `get-product` returns **paginated `reviews`** plus `average_rating` and `rating_count`, and a `verified` flag per review;
|
|
8
|
+
- `list-products` rows carry `average_rating`/`rating_count`, so **stars on cards cost no extra call**;
|
|
9
|
+
- `commerce/storefront-catalog` `submit-review` accepts `{ product_id, reviewer?, review, rating }` from a **signed-in** customer — the email comes from the session, never the payload;
|
|
10
|
+
- `commerce/storefront-account` `my-reviews` lists a customer's own;
|
|
11
|
+
- moderation is already in the admin (Products → Reviews), and `commerce/admin-reviews` recalculates the product's rating on every status change.
|
|
12
|
+
|
|
13
|
+
So the work is UI: stars on cards and the product page, a reviews list, and a submit form. Behaviors to respect rather than fight:
|
|
14
|
+
|
|
15
|
+
- anonymous callers get **`401 login_required`** — put the form behind a login prompt instead of hiding reviews from guests;
|
|
16
|
+
- a new review is **`hold`** unless `auto_approve_reviews`, so tell the submitter it's awaiting approval rather than showing it as live;
|
|
17
|
+
- `only_verified_reviews` requires the customer to have a `processing`/`completed` order with that product (**`403 verified_only`**), and `review_rating_required` makes the rating mandatory (**`400 rating_required`**);
|
|
18
|
+
- the store can switch reviews off store-wide (`products.enable_reviews`) — read it and hide the UI when false.
|
|
19
|
+
|
|
20
|
+
Shapes and error codes: [`docs/api-storefront.md`](../docs/api-storefront.md#submit-review--auth). Where ratings belong per view: [`references/product-render.md`](./product-render.md).
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Variant selection on the product page
|
|
2
2
|
|
|
3
|
-
The one interaction agents reliably get wrong: turning a
|
|
3
|
+
The one interaction agents reliably get wrong: turning a product with variants into controls a customer can buy from. Everything else about the page — what to show, and which fields each call returns — is [`product-render.md`](./product-render.md); request/response shapes are [`docs/api-storefront.md`](../docs/api-storefront.md).
|
|
4
4
|
|
|
5
5
|
The logic ships: **`src/commerce/utils/variants.js`** (framework-free, no I/O, full JSDoc on every export — read it for signatures). Import it and spend your effort on the markup.
|
|
6
6
|
|
|
@@ -8,7 +8,7 @@ The logic ships: **`src/commerce/utils/variants.js`** (framework-free, no I/O, f
|
|
|
8
8
|
|
|
9
9
|
## 1. The model, and the rule
|
|
10
10
|
|
|
11
|
-
`get-product` returns `{ product, variations }`. `product.attributes[]`
|
|
11
|
+
`get-product` returns `{ product, variations }`. **Every** `product.attributes[]` entry is an **axis**, in `position` order; each `variations[]` record is one combination. There is no product type and no per-attribute flag — carrying attributes is what makes a product sell variants. Descriptive properties are `meta_data` (the admin calls them modifiers) — a spec table, **never** a selector.
|
|
12
12
|
|
|
13
13
|
**One control per axis. Never one option per variation.** Do not flatten `variations[]` into `Red / S`, `Red / M`, `Blue / S`, … — that is `n × m` noise, it hides the product's structure, and it stops scaling immediately.
|
|
14
14
|
|
|
@@ -43,16 +43,16 @@ Bind the UI to `view` — not to `product.*` — so a selection actually changes
|
|
|
43
43
|
| `purchasable` | gate **Add to cart** on this (resolved *and* buyable) |
|
|
44
44
|
| `addToCart` | `{ product_id, variation_id }` to spread into the cart call, or `null` |
|
|
45
45
|
|
|
46
|
-
`view.variation` and `view.candidates` are there when you need the record itself or the variations still reachable. For a
|
|
46
|
+
`view.variation` and `view.candidates` are there when you need the record itself or the variations still reachable. For a product with no attributes `resolveSelection` returns no axes, `complete: true` and a ready `addToCart` — the same code path; it branches on whether there are axes, so `variations` can be `[]`.
|
|
47
47
|
|
|
48
|
-
A **
|
|
48
|
+
A **product with attributes but no usable variations** — the state attaching an attribute and stopping leaves you in — is deliberately *not* given a parent fallback, because `add-item` would reject it. You get axes whose `options` arrays are empty, `purchasable: false` and `addToCart: null`. Render it as unavailable rather than painting selector groups with no buttons, and guard the "Select a …" label: with no attributes at all, `missingAxes` is empty and `view.missingAxes[0]?.name` is `undefined`.
|
|
49
49
|
|
|
50
50
|
The other exports cover what the view model doesn't: `selectionForVariation` (variation → selection, to hydrate controls from a cart line or `?variation=` link), `selectionToParams`/`selectionFromParams` (URL round-trip), `priceRange`, and the individual pieces if you build your own view model. Signatures and edge cases are in the JSDoc; two behaviors are easy to miss when hand-rolling: non-publishable variations never leak an option into the UI, and an empty `option` on an axis means **"any"**.
|
|
51
51
|
|
|
52
52
|
## 3. The three UI decisions the helpers can't make for you
|
|
53
53
|
|
|
54
54
|
- **Unavailable vs out of stock.** `"unavailable"` (no such combination) → **disable, don't hide**; options that vanish and reappear as the customer clicks are disorienting. `"out_of_stock"` → keep it visible and label it. `onbackorder` counts as available and purchasable — label it ("Ships in 2–3 weeks"), don't disable it.
|
|
55
|
-
- **Incomplete selection.** Show `view.priceRange` (`$21.99 – $23.99`, or `From $21.99`) — never `$0`, and never the parent `price`, which for a
|
|
55
|
+
- **Incomplete selection.** Show `view.priceRange` (`$21.99 – $23.99`, or `From $21.99`) — never `$0`, and never the parent `price`, which for a product with variants is not its price ([`product-render.md` §3](./product-render.md#3-product-shapes-in-each-view)). Keep **Add to cart** disabled with a hint at what's missing.
|
|
56
56
|
- **Initial state and linkability.** Start from `defaultSelection` (honours the merchant's `default_attributes`, preselects single-option axes). Mirror the selection into the URL with `selectionToParams` and hydrate with `selectionFromParams`, so a chosen variant is shareable and survives a refresh.
|
|
57
57
|
|
|
58
58
|
Route every click through `selectOption` and dead ends take care of themselves: it keeps the just-picked option and clears only conflicting axes (Red/L → click Blue → `{ Color: Blue }`, size re-open) instead of leaving the customer on a combination that resolves to nothing.
|
|
@@ -63,21 +63,21 @@ Route every click through `selectOption` and dead ends take care of themselves:
|
|
|
63
63
|
await base44.functions.invoke("commerce/storefront-cart", {
|
|
64
64
|
action: "add-item",
|
|
65
65
|
cart_token,
|
|
66
|
-
...view.addToCart, // { product_id, variation_id } — variation_id is REQUIRED
|
|
66
|
+
...view.addToCart, // { product_id, variation_id } — variation_id is REQUIRED once a product has attributes
|
|
67
67
|
quantity,
|
|
68
68
|
});
|
|
69
69
|
```
|
|
70
70
|
|
|
71
|
-
Handle `400 variation_required` (empty `variation_id` on a
|
|
71
|
+
Handle `400 variation_required` (empty `variation_id` on a product with attributes — the page has a bug), the stock codes (`out_of_stock`, `insufficient_stock`, …), and `404 variation_not_found` (catalog changed underneath the page: refetch `get-product`). `product.sold_individually` caps the quantity control at 1.
|
|
72
72
|
|
|
73
73
|
## 5. Checklist
|
|
74
74
|
|
|
75
75
|
- [ ] One control per axis in `view.axes` — no combination list anywhere in the UI.
|
|
76
|
-
- [ ]
|
|
76
|
+
- [ ] `meta_data` modifiers render as specs, not selectors.
|
|
77
77
|
- [ ] Image, price, SKU and availability all come from `view.display`, with a placeholder for `display.image === null`.
|
|
78
78
|
- [ ] Incomplete selection shows `view.priceRange` — never `$0`, never the parent `price`.
|
|
79
79
|
- [ ] `"unavailable"` disabled, `"out_of_stock"` labelled; every click routed through `selectOption`.
|
|
80
80
|
- [ ] Initial state from `defaultSelection`; selection mirrored into the URL.
|
|
81
81
|
- [ ] **Add to cart** gated on `view.purchasable`, sending `view.addToCart`.
|
|
82
|
-
- [ ] A
|
|
82
|
+
- [ ] A product with empty `axes[].options` shows as unavailable — no empty selector groups, no "Select a undefined".
|
|
83
83
|
- [ ] Tags, ratings, upsells and the rest per [`product-render.md`](./product-render.md).
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
# Webhooks
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Managed in the admin at **Settings → Webhooks** (`settings/webhooks`; the source still lives under `pages/status/`).
|
|
4
|
+
|
|
5
|
+
`webhooks.ts` `dispatch()` fires on `order.*`, `product.*`, `customer.*`, `coupon.*` (created/updated/deleted, note that `restored` is selectable on a webhook but **never emitted** — nothing dispatches it). Each active `commerce.Webhook` matching the topic receives an HTTP POST with headers `X-Commerce-Webhook-Topic/-Resource/-Event/-ID/-Delivery-ID/-Signature`. The signature is **base64 HMAC-SHA256** of the body, keyed by the webhook's `secret` (Web Crypto).
|
|
4
6
|
|
|
5
7
|
- Every attempt is logged as a `commerce.WebhookDelivery` (request/response bodies truncated to 32 KB). Prune with `commerce/admin-tools` `prune-webhook-deliveries`.
|
|
6
8
|
- `failure_count` increments on non-2xx/timeout and resets on success; a webhook auto-disables after **5** consecutive failures.
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# Store
|
|
1
|
+
# Store Management (`src/commerce/admin/`)
|
|
2
2
|
|
|
3
3
|
React admin UI for the Base44 commerce template. Copy this
|
|
4
4
|
folder into a Base44 app built on the default template (Vite + React +
|
|
@@ -27,14 +27,14 @@ Tailwind + shadcn/ui + React Router) to get a full store back office.
|
|
|
27
27
|
```jsx
|
|
28
28
|
import AdminApp from "@/commerce/admin";
|
|
29
29
|
|
|
30
|
-
<Route path="/admin/*" element={<AdminApp />} />
|
|
30
|
+
<Route path="/store-admin/*" element={<AdminApp />} />
|
|
31
31
|
// mounted elsewhere? → <AdminApp basePath="/backoffice" />
|
|
32
32
|
```
|
|
33
33
|
|
|
34
34
|
5. Make sure your user has the **admin role** (Base44 dashboard → Users, or
|
|
35
35
|
`base44.users.inviteUser(email, "admin")`). Signed-in users without the
|
|
36
36
|
admin role get an access-denied screen — do not weaken this check.
|
|
37
|
-
6. Open `/admin`. If the store has never been seeded (no `general` StoreSettings
|
|
37
|
+
6. Open `/store-admin`. If the store has never been seeded (no `general` StoreSettings
|
|
38
38
|
group) the first-run screen appears and seeds store defaults, optionally with
|
|
39
39
|
sample data when the store has no products yet. If `commerce/seed-store` was
|
|
40
40
|
already run during installation — including when an agent generated a real
|
|
@@ -61,7 +61,7 @@ backend functions directly as its tools.
|
|
|
61
61
|
|
|
62
62
|
alert, alert-dialog, badge, button, calendar, card, checkbox, command, dialog,
|
|
63
63
|
dropdown-menu, input, label, popover, radio-group, scroll-area, select,
|
|
64
|
-
separator, sheet, skeleton, switch, table, tabs, textarea
|
|
64
|
+
separator, sheet, skeleton, switch, table, tabs, textarea
|
|
65
65
|
|
|
66
66
|
The default Base44 template ships all of these. If one is missing:
|
|
67
67
|
|
|
@@ -2,15 +2,15 @@ import React, { createContext, useContext } from "react";
|
|
|
2
2
|
import { normalizeBasePath } from "../lib/paths";
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
|
-
* The URL prefix the admin app is mounted under (default `/admin`).
|
|
5
|
+
* The URL prefix the admin app is mounted under (default `/store-admin`).
|
|
6
6
|
* All internal links are built from this, so the admin folder works no matter
|
|
7
7
|
* where the consumer mounts it: `<AdminApp basePath="/backoffice" />`.
|
|
8
8
|
*/
|
|
9
|
-
const BasePathContext = createContext("/admin");
|
|
9
|
+
const BasePathContext = createContext("/store-admin");
|
|
10
10
|
|
|
11
|
-
export function BasePathProvider({ value = "/admin", children }) {
|
|
11
|
+
export function BasePathProvider({ value = "/store-admin", children }) {
|
|
12
12
|
// normalizeBasePath also tolerates the route *pattern* being passed in
|
|
13
|
-
// (`basePath="/admin/*"`), an easy copy/paste slip from the mount — every
|
|
13
|
+
// (`basePath="/store-admin/*"`), an easy copy/paste slip from the mount — every
|
|
14
14
|
// internal link would otherwise carry a literal "*" segment.
|
|
15
15
|
return <BasePathContext.Provider value={normalizeBasePath(value)}>{children}</BasePathContext.Provider>;
|
|
16
16
|
}
|
|
@@ -19,7 +19,7 @@ export function useBasePath() {
|
|
|
19
19
|
return useContext(BasePathContext);
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
-
/** Returns a builder: href("orders/123") → "/admin/orders/123"; href() → "/admin". */
|
|
22
|
+
/** Returns a builder: href("orders/123") → "/store-admin/orders/123"; href() → "/store-admin". */
|
|
23
23
|
export function useAdminHref() {
|
|
24
24
|
const base = useBasePath();
|
|
25
25
|
return (path = "") => (path ? `${base}/${String(path).replace(/^\/+/, "")}` : base);
|
|
@@ -155,8 +155,8 @@ function SetupScreen({ onDone }) {
|
|
|
155
155
|
onChange={(e) => setStoreName(e.target.value)}
|
|
156
156
|
/>
|
|
157
157
|
<p className="text-xs text-muted-foreground">
|
|
158
|
-
|
|
159
|
-
Editable later in Settings →
|
|
158
|
+
Used in the subject and as the sender name on every transactional email.
|
|
159
|
+
Editable later in Settings → Emails.
|
|
160
160
|
</p>
|
|
161
161
|
</div>
|
|
162
162
|
{hasProducts === false && (
|
|
@@ -171,8 +171,8 @@ function SetupScreen({ onDone }) {
|
|
|
171
171
|
Include sample data
|
|
172
172
|
</Label>
|
|
173
173
|
<p className="text-xs text-muted-foreground">
|
|
174
|
-
Adds demo categories, products (
|
|
175
|
-
|
|
174
|
+
Adds demo categories, 10 products (three with size/colour variants, one
|
|
175
|
+
downloadable) and coupons so you can explore the admin.
|
|
176
176
|
</p>
|
|
177
177
|
</div>
|
|
178
178
|
</div>
|
|
@@ -10,15 +10,15 @@ import { BasePathProvider } from "./context/BasePathContext";
|
|
|
10
10
|
* The store admin application.
|
|
11
11
|
*
|
|
12
12
|
* Mount inside your app's router:
|
|
13
|
-
* <Route path="/admin/*" element={<AdminApp />} />
|
|
13
|
+
* <Route path="/store-admin/*" element={<AdminApp />} />
|
|
14
14
|
*
|
|
15
|
-
* If mounted somewhere other than /admin, pass the prefix:
|
|
15
|
+
* If mounted somewhere other than /store-admin, pass the prefix:
|
|
16
16
|
* <Route path="/backoffice/*" element={<AdminApp basePath="/backoffice" />} />
|
|
17
17
|
*
|
|
18
18
|
* Requires an authenticated user with role "admin" (enforced by AuthGuard,
|
|
19
19
|
* and independently by entity RLS + requireAdmin() in backend functions).
|
|
20
20
|
*/
|
|
21
|
-
export default function AdminApp({ basePath = "/admin" }) {
|
|
21
|
+
export default function AdminApp({ basePath = "/store-admin" }) {
|
|
22
22
|
return (
|
|
23
23
|
<BasePathProvider value={basePath}>
|
|
24
24
|
<Toaster richColors position="top-right" />
|
|
@@ -10,7 +10,6 @@ import {
|
|
|
10
10
|
ShoppingCart,
|
|
11
11
|
Sparkles,
|
|
12
12
|
Users,
|
|
13
|
-
Webhook,
|
|
14
13
|
} from "lucide-react";
|
|
15
14
|
import { call } from "../lib/api";
|
|
16
15
|
import { useAdminHref } from "../context/BasePathContext";
|
|
@@ -30,8 +29,6 @@ const NAV = [
|
|
|
30
29
|
items: [
|
|
31
30
|
{ label: "All Products", path: "products", end: true },
|
|
32
31
|
{ label: "Categories", path: "products/categories" },
|
|
33
|
-
{ label: "Tags", path: "products/tags" },
|
|
34
|
-
{ label: "Attributes", path: "products/attributes" },
|
|
35
32
|
{ label: "Reviews", path: "products/reviews" },
|
|
36
33
|
],
|
|
37
34
|
},
|
|
@@ -43,11 +40,6 @@ const NAV = [
|
|
|
43
40
|
{
|
|
44
41
|
items: [{ label: "Settings", path: "settings", icon: Settings }],
|
|
45
42
|
},
|
|
46
|
-
{
|
|
47
|
-
title: "Status",
|
|
48
|
-
icon: Webhook,
|
|
49
|
-
items: [{ label: "Webhooks", path: "webhooks" }],
|
|
50
|
-
},
|
|
51
43
|
];
|
|
52
44
|
|
|
53
45
|
/** Left navigation. `onNavigate` closes the mobile sheet; `onOpenBot` opens the StoreAdmin bot panel. */
|
|
@@ -78,7 +70,7 @@ export default function Sidebar({ onNavigate, onOpenBot }) {
|
|
|
78
70
|
<div className="flex h-full flex-col">
|
|
79
71
|
<div className="flex h-14 items-center gap-2 border-b px-4">
|
|
80
72
|
<Package className="h-5 w-5 text-primary" />
|
|
81
|
-
<span className="font-semibold">Store
|
|
73
|
+
<span className="font-semibold">Store Management</span>
|
|
82
74
|
</div>
|
|
83
75
|
<nav className="flex-1 space-y-4 overflow-y-auto p-3">
|
|
84
76
|
{NAV.map((group, gi) => (
|
|
@@ -17,13 +17,6 @@ export const PRODUCT_STATUSES = [
|
|
|
17
17
|
{ value: "publish", label: "Published", color: "bg-green-100 text-green-800 border-green-200" },
|
|
18
18
|
];
|
|
19
19
|
|
|
20
|
-
export const PRODUCT_TYPES = [
|
|
21
|
-
{ value: "simple", label: "Simple product" },
|
|
22
|
-
{ value: "grouped", label: "Grouped product" },
|
|
23
|
-
{ value: "external", label: "External/Affiliate product" },
|
|
24
|
-
{ value: "variable", label: "Variable product" },
|
|
25
|
-
];
|
|
26
|
-
|
|
27
20
|
export const CATALOG_VISIBILITIES = [
|
|
28
21
|
{ value: "visible", label: "Shop and search results" },
|
|
29
22
|
{ value: "catalog", label: "Shop only" },
|
|
@@ -1,16 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Mount-path handling for the admin app.
|
|
3
3
|
*
|
|
4
|
-
* The admin is mounted with a splat route — `<Route path="/admin/*">` — and both
|
|
4
|
+
* The admin is mounted with a splat route — `<Route path="/store-admin/*">` — and both
|
|
5
5
|
* halves of that pattern leak into places they shouldn't: the `basePath` prop
|
|
6
|
-
* gets the pattern pasted in verbatim, and the literal URL `/admin/*` gets
|
|
6
|
+
* gets the pattern pasted in verbatim, and the literal URL `/store-admin/*` gets
|
|
7
7
|
* opened (pattern copied into the address bar, or a nav link built from the
|
|
8
8
|
* route table). Neither is a real page, so both are normalized here rather than
|
|
9
9
|
* dead-ending the admin.
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
-
/** Strip a trailing route splat and slashes: "/admin/*" → "/admin". */
|
|
13
|
-
export function normalizeBasePath(value, fallback = "/admin") {
|
|
12
|
+
/** Strip a trailing route splat and slashes: "/store-admin/*" → "/store-admin". */
|
|
13
|
+
export function normalizeBasePath(value, fallback = "/store-admin") {
|
|
14
14
|
const clean = String(value ?? "")
|
|
15
15
|
.replace(/\/+\*+$/, "")
|
|
16
16
|
.replace(/\/+$/, "");
|
|
@@ -21,8 +21,8 @@ export function normalizeBasePath(value, fallback = "/admin") {
|
|
|
21
21
|
* Is this leftover path (the `*` param of the catch-all route) nothing but
|
|
22
22
|
* route-*pattern* segments — `*`, its percent-encoded form, or `:param`?
|
|
23
23
|
*
|
|
24
|
-
* `/admin/*` → true (send them to the dashboard)
|
|
25
|
-
* `/admin/ordrs` → false (a genuine 404)
|
|
24
|
+
* `/store-admin/*` → true (send them to the dashboard)
|
|
25
|
+
* `/store-admin/ordrs` → false (a genuine 404)
|
|
26
26
|
*/
|
|
27
27
|
export function isMountPatternPath(rest) {
|
|
28
28
|
const segments = String(rest ?? "")
|
|
@@ -9,20 +9,20 @@ export function slugify(name) {
|
|
|
9
9
|
.replace(/^-+|-+$/g, "");
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
12
|
+
/**
|
|
13
|
+
* A product sells variants because it carries attributes — every entry in
|
|
14
|
+
* `attributes[]` is a variant axis, so attaching one is the whole declaration.
|
|
15
|
+
* Descriptive properties are `meta_data` (modifiers), not attributes.
|
|
16
|
+
*/
|
|
17
|
+
export const isVariable = (p) => (p?.attributes ?? []).length > 0;
|
|
16
18
|
|
|
17
19
|
/**
|
|
18
|
-
* Cartesian product of
|
|
20
|
+
* Cartesian product of every attribute that has options.
|
|
19
21
|
* Returns [{attributes: [{attribute_id, name, option}]}] — one entry per combo.
|
|
20
|
-
* Returns [] when no
|
|
22
|
+
* Returns [] when no attribute has options yet.
|
|
21
23
|
*/
|
|
22
24
|
export function generateVariationCombos(attributes) {
|
|
23
|
-
const varAttrs = (attributes || []).filter(
|
|
24
|
-
(a) => a.variation && (a.options || []).length > 0
|
|
25
|
-
);
|
|
25
|
+
const varAttrs = (attributes || []).filter((a) => (a.options || []).length > 0);
|
|
26
26
|
if (!varAttrs.length) return [];
|
|
27
27
|
const combos = varAttrs.reduce(
|
|
28
28
|
(acc, attr) =>
|
|
@@ -44,12 +44,15 @@ export function variationLabel(variation) {
|
|
|
44
44
|
.join(" / ") || "Any";
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
+
/** Order-independent identity of a variation's attribute combination. */
|
|
48
|
+
export function comboKey(variation) {
|
|
49
|
+
return (variation?.attributes || [])
|
|
50
|
+
.map((x) => `${(x.name || "").toLowerCase()}=${(x.option || "").toLowerCase()}`)
|
|
51
|
+
.sort()
|
|
52
|
+
.join("|");
|
|
53
|
+
}
|
|
54
|
+
|
|
47
55
|
/** True when two variations have the same attribute combo. */
|
|
48
56
|
export function sameCombo(a, b) {
|
|
49
|
-
|
|
50
|
-
(v?.attributes || [])
|
|
51
|
-
.map((x) => `${(x.name || "").toLowerCase()}=${(x.option || "").toLowerCase()}`)
|
|
52
|
-
.sort()
|
|
53
|
-
.join("|");
|
|
54
|
-
return key(a) === key(b);
|
|
57
|
+
return comboKey(a) === comboKey(b);
|
|
55
58
|
}
|
|
@@ -20,11 +20,11 @@ import { Loader2 } from "lucide-react";
|
|
|
20
20
|
|
|
21
21
|
import { call, base44 } from "../../../lib/api";
|
|
22
22
|
import SearchSelect from "../../../components/SearchSelect";
|
|
23
|
-
import { variationLabel } from "../../../lib/product-utils";
|
|
23
|
+
import { isVariable, variationLabel } from "../../../lib/product-utils";
|
|
24
24
|
|
|
25
25
|
/**
|
|
26
26
|
* Two-step product picker for the order editor:
|
|
27
|
-
* product search → (
|
|
27
|
+
* product search → (products with variants only) variant select → quantity.
|
|
28
28
|
*
|
|
29
29
|
* Props: { open, onOpenChange, onAdd({product, variation|null, quantity}) }
|
|
30
30
|
*/
|
|
@@ -50,7 +50,7 @@ export default function AddProductDialog({ open, onOpenChange, onAdd }) {
|
|
|
50
50
|
return rows.map((p) => ({
|
|
51
51
|
value: p.id,
|
|
52
52
|
label: p.name,
|
|
53
|
-
meta:
|
|
53
|
+
meta: p.sku ? `SKU: ${p.sku}` : undefined,
|
|
54
54
|
product: p,
|
|
55
55
|
}));
|
|
56
56
|
};
|
|
@@ -59,7 +59,7 @@ export default function AddProductDialog({ open, onOpenChange, onAdd }) {
|
|
|
59
59
|
setPicked(opt);
|
|
60
60
|
setVariations(null);
|
|
61
61
|
setVariationId("");
|
|
62
|
-
if (opt?.product
|
|
62
|
+
if (isVariable(opt?.product)) {
|
|
63
63
|
setLoadingVariations(true);
|
|
64
64
|
try {
|
|
65
65
|
const rows = await base44.entities["commerce.ProductVariation"].filter({ product_id: opt.value }, "menu_order", 200);
|
|
@@ -70,11 +70,11 @@ export default function AddProductDialog({ open, onOpenChange, onAdd }) {
|
|
|
70
70
|
}
|
|
71
71
|
};
|
|
72
72
|
|
|
73
|
-
const
|
|
74
|
-
const canAdd = picked && quantity > 0 && (!
|
|
73
|
+
const hasVariants = isVariable(picked?.product);
|
|
74
|
+
const canAdd = picked && quantity > 0 && (!hasVariants || variationId);
|
|
75
75
|
|
|
76
76
|
const submit = () => {
|
|
77
|
-
const variation =
|
|
77
|
+
const variation = hasVariants ? (variations || []).find((v) => v.id === variationId) : null;
|
|
78
78
|
onAdd({ product: picked.product, variation, quantity });
|
|
79
79
|
onOpenChange(false);
|
|
80
80
|
};
|
|
@@ -97,7 +97,7 @@ export default function AddProductDialog({ open, onOpenChange, onAdd }) {
|
|
|
97
97
|
/>
|
|
98
98
|
</div>
|
|
99
99
|
|
|
100
|
-
{
|
|
100
|
+
{hasVariants && (
|
|
101
101
|
<div className="grid gap-1.5">
|
|
102
102
|
<Label>Variation</Label>
|
|
103
103
|
{loadingVariations ? (
|