@base44/app-plugin-commerce 0.1.13 → 0.1.15
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 +1 -1
- package/skills/commerce/SKILL.md +1 -1
- package/skills/commerce/post-installation.md +25 -5
- package/skills/commerce/references/product-render.md +1 -1
- package/skills/commerce/references/storefront-product-page.md +2 -2
- package/src/commerce/admin/pages/products/components/AttributesSection.jsx +84 -15
- package/src/commerce/admin/pages/products/components/ProductDataPanel.jsx +1 -11
- package/src/commerce/admin/pages/products/components/tabs/ModifiersTab.jsx +9 -3
- package/src/commerce/admin/pages/products/components/tabs/PriceInventoryTab.jsx +492 -369
- package/src/commerce/utils/variants.js +21 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@base44/app-plugin-commerce",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.15",
|
|
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",
|
package/skills/commerce/SKILL.md
CHANGED
|
@@ -14,7 +14,7 @@ Operational guidance for extending, operating and building on the Base44 Commerc
|
|
|
14
14
|
|
|
15
15
|
## IMPORTANT — first-time installation
|
|
16
16
|
|
|
17
|
-
If the template was just installed (or you are installing it right now), read [`.agents/skills/commerce/post-installation.md`](./post-installation.md) **before anything else — and, unless the user has a special requirement, read nothing else**: it alone covers embedding the admin pages, the three-layer admin-role enforcement (do not weaken), seeding the store's data — **one `commerce/seed-store` call takes the whole catalog** (products with attributes; variants, categories, ribbons and Shipping & Tax Locations created internally — §2) — **card payments in one file, wired only if — and whenever in the flow — the store actually needs online payments** (§2.2 opens with that decision, then a complete Stripe implementation to paste over `shared/commerce/card-payment.ts`; the payment webhook is premade), and the **storefront quick start** (§3): logic-only chunks for product list → product page → cart → checkout. The references below and the API docs are for requests that go beyond that happy path, not for the install. The full install-from-scratch steps are in [`.agents/skills/commerce/installation-guidelines.md`](./installation-guidelines.md).
|
|
17
|
+
If the template was just installed (or you are installing it right now), read [`.agents/skills/commerce/post-installation.md`](./post-installation.md) **before anything else — and, unless the user has a special requirement, read nothing else**: it alone covers embedding the admin pages, the three-layer admin-role enforcement (do not weaken), seeding the store's data — **one `commerce/seed-store` call takes the whole catalog** (products with attributes; variants, categories, ribbons and Shipping & Tax Locations created internally — §2) — **card payments in one file, wired only if — and whenever in the flow — the store actually needs online payments** (§2.2 opens with that decision, then a complete Stripe implementation to paste over `shared/commerce/card-payment.ts`; the payment webhook is premade), and the **storefront quick start** (§3): logic-only chunks for product list → product page → cart → checkout. Its §0 schedules the whole install: **storefront components are written while image generation, the ~20 s seed call and payment wiring run — never after them**. The references below and the API docs are for requests that go beyond that happy path, not for the install. The full install-from-scratch steps are in [`.agents/skills/commerce/installation-guidelines.md`](./installation-guidelines.md).
|
|
18
18
|
|
|
19
19
|
## Working on the UI
|
|
20
20
|
|
|
@@ -4,6 +4,20 @@ What to do right after the static installation ([`installation-guidelines.md`](.
|
|
|
4
4
|
|
|
5
5
|
> **This file is intentionally the whole job.** Unless the user has a special requirement (a non-Stripe payment provider, signature-verified webhooks, filters/reviews/accounts beyond the happy path), do **not** open the `references/` guides or the `docs/` API references — everything needed to ship a working store (admin mount, seeding, product list, product page, cart, checkout, order-received, and card payments for the stores that need them) is on this page, ending with the [Done when](#4-done-when) checklist. Reading more first just burns time.
|
|
6
6
|
|
|
7
|
+
## 0. Sequencing — build the UI while the slow work runs
|
|
8
|
+
|
|
9
|
+
The sections below are ordered for **reading** — they are not a schedule. Executed strictly top-to-bottom, the install spends most of its wall-clock waiting on things that never needed to block: image generation (the slowest step of the whole install), the `commerce/seed-store` call (~20 seconds), and — for stores that take card payments — the user round-trip for the provider secret key. **None of these block writing the storefront**: every request/response shape §3 builds against is documented on this page, so the components are written from the docs, not from live data. Live data is only needed at the very end, to verify.
|
|
10
|
+
|
|
11
|
+
So interleave — whenever a slow activity is in flight, write UI instead of waiting:
|
|
12
|
+
|
|
13
|
+
1. **Start image generation first** — kick off every product image before anything else (§2.1 "Images"), because it takes the longest and nothing depends on it until seed time.
|
|
14
|
+
2. **Mount the admin router (§1)** — minutes of work — and start writing storefront components (§3) while the images render.
|
|
15
|
+
3. **The moment the image URLs are back, fire `commerce/seed-store` (§2) and keep writing UI while it runs.** Don't idle on the call; pick up its response (catalog report, slugs) when you next need it. If your tooling runs calls in the background, use that; if not, order the work so the call sits between two chunks of component-writing, never between you and an empty wait.
|
|
16
|
+
4. **Payment wiring (§2.2), if the store takes card payments, runs in parallel too** — the file paste and webhook registration touch nothing the storefront depends on, and the ask for the secret key can be pending while you build.
|
|
17
|
+
5. **Converge at the end**: with the seed done and pages written, verify the storefront against the live catalog and walk the [Done when](#4-done-when) checklist.
|
|
18
|
+
|
|
19
|
+
The only real dependency edges are: image URLs → seed payload, and seed done → final verification. Everything else overlaps.
|
|
20
|
+
|
|
7
21
|
---
|
|
8
22
|
|
|
9
23
|
## 1. Embedding the admin pages
|
|
@@ -46,7 +60,7 @@ Even if the client guard were bypassed, layers 2 and 3 keep the store data safe.
|
|
|
46
60
|
|
|
47
61
|
## 2. Store data — seeding
|
|
48
62
|
|
|
49
|
-
A fresh install has **no settings and no catalog**. One call to `commerce/seed-store` (admin-only, idempotent) initializes both. It always creates the business defaults — the six settings groups (`general`, `products`, `inventory`, `tax`, `shipping`, `emails`; USD, kg/cm, taxes off prices) and the `offline` and `card` payment gateways — and, depending on the payload, the catalog. A fallback "Rest of the world" **Shipping & Tax Location** (one free shipping rate, no tax) is seeded **only when the payload carries no `locations`** — locations you pass are the store's only shipping data, with no seeded fallback beside them. Pass **`currency`** (an ISO code, e.g. `"EUR"`) and/or **`weight_unit`**/**`dimension_unit`** to set the store's currency and measurement units instead of the defaults — explicit values always win, on a first seed and a re-run alike. (Prices are *formatted* with `Intl.NumberFormat` per the viewer's locale — the currency is a value; there are no format settings.)
|
|
63
|
+
A fresh install has **no settings and no catalog**. One call to `commerce/seed-store` (admin-only, idempotent) initializes both. The call takes **~20 seconds** — never sit through it: fire it and write storefront components while it runs (§0); nothing in §3 needs its response, only the final verification does. It always creates the business defaults — the six settings groups (`general`, `products`, `inventory`, `tax`, `shipping`, `emails`; USD, kg/cm, taxes off prices) and the `offline` and `card` payment gateways — and, depending on the payload, the catalog. A fallback "Rest of the world" **Shipping & Tax Location** (one free shipping rate, no tax) is seeded **only when the payload carries no `locations`** — locations you pass are the store's only shipping data, with no seeded fallback beside them. Pass **`currency`** (an ISO code, e.g. `"EUR"`) and/or **`weight_unit`**/**`dimension_unit`** to set the store's currency and measurement units instead of the defaults — explicit values always win, on a first seed and a re-run alike. (Prices are *formatted* with `Intl.NumberFormat` per the viewer's locale — the currency is a value; there are no format settings.)
|
|
50
64
|
|
|
51
65
|
Pass **`payment_methods`** (gateway slugs, e.g. `["card"]`) when the user restricts how they get paid: the listed gateways are enabled and **every other gateway row is disabled** — "card-only" or "offline-only" is part of the same seed call, with **no `commerce.PaymentGateway` reads or writes of your own**. Explicit values win on re-runs too. Unknown slugs fail as `400 invalid_payload` (the error lists the known ones). Should you ever need direct entity access, names are dotted — bracket syntax only: `base44.entities["commerce.PaymentGateway"]` (`commerce__PaymentGateway` / `PaymentGateway` don't exist).
|
|
52
66
|
|
|
@@ -137,7 +151,7 @@ The response reports everything:
|
|
|
137
151
|
"payment_methods": { "enabled": ["card"], "disabled": ["offline"] } } // null when not passed
|
|
138
152
|
```
|
|
139
153
|
|
|
140
|
-
**Images**: every product needs at least one, and the URL you seed is the URL the store serves — there are no placeholders to swap later. So resolve each image to its **final URL before seeding**: use whatever image generation the app has available and store the returned URL, upload real assets with `base44.integrations.Core.UploadFile({ file })` → public URL, or fall back to stable public stock URLs (`base44/functions/commerce/seed-store/sample-data.ts` shows a working Unsplash pattern). Match the image to the product. Image generation is usually the **slowest step of the whole install** — kick all product images off first, do the rest (router mount, payment file, storefront pages) while they render, and seed
|
|
154
|
+
**Images**: every product needs at least one, and the URL you seed is the URL the store serves — there are no placeholders to swap later. So resolve each image to its **final URL before seeding**: use whatever image generation the app has available and store the returned URL, upload real assets with `base44.integrations.Core.UploadFile({ file })` → public URL, or fall back to stable public stock URLs (`base44/functions/commerce/seed-store/sample-data.ts` shows a working Unsplash pattern). Match the image to the product. **Final means permanent and resolving**, and both fail silently later rather than at seed time, so check them now: an image tool that returns a *temporary or signed* URL (expiry params in the query string are the tell) must be re-hosted — download and `UploadFile` it for a stable public URL — and before seeding, spot-check that the URLs actually resolve (fetch one or two: HTTP 200, image content type). A dead or expiring URL seeds fine and then renders as a broken image or an eternal placeholder in the store. Image generation is usually the **slowest step of the whole install** — this is dependency edge #1 of §0: kick all product images off first, do the rest (router mount, payment file, storefront pages) while they render, and seed the moment the URLs are back — then keep writing UI through the seed call too. If an image isn't ready at seed time you *may* seed without it and set it afterwards through the admin API (never seed a dead path and compensate in the frontend) — but that is **an open debt, not a resolution**: track every product seeded imageless and close it before handover. The [Done when](#4-done-when) checklist fails while any product shows a placeholder for lack of a real image.
|
|
141
155
|
|
|
142
156
|
A successful response means the data is in — the catalog and settings are live exactly as reported. Write any remaining store-specific settings into `commerce.StoreSettings` (direct CRUD, one record per `group_id` — weight/dimension units are the usual ones; patch `values`, don't replace groups you weren't asked about).
|
|
143
157
|
|
|
@@ -148,7 +162,7 @@ The order side of payments is **already implemented** (checkout routing, confirm
|
|
|
148
162
|
**Whether the store needs online payments — and when in the install to wire them — is your decision, not a fixed step.** Nothing else on this page depends on this section: the admin, the seed and the entire storefront work with no provider wired (until then the card option answers `503 no_card_payment_provider` at checkout, and §3.4 shows the graceful fallback). Decide from what the user is actually building:
|
|
149
163
|
|
|
150
164
|
- **The store doesn't take online payments** (cash on delivery, bank transfer, invoicing, pickup, quote-based…) → seed `payment_methods` without `"card"` and skip the rest of this section — the manual gateways are complete as shipped.
|
|
151
|
-
- **The store does take online payments** → this section is the how-to (Stripe below is the reference), but *you* pick the point in the flow. The wiring is self-contained and
|
|
165
|
+
- **The store does take online payments** → this section is the how-to (Stripe below is the reference), but *you* pick the point in the flow. The wiring is self-contained — the file paste and webhook registration touch nothing the storefront depends on, so it **runs in parallel with the storefront build** (§0) rather than before or after it. The one part that involves the user is the provider secret key: ask when it makes sense in the conversation, and keep building while the answer is pending — never stall the install on it. What's non-negotiable is only the end state: don't hand over a finished store with the card gateway enabled but no provider behind it (wire one, or disable the gateway).
|
|
152
166
|
- **Unclear from the request** → build everything else first and raise payments with the user at the end, or ask.
|
|
153
167
|
|
|
154
168
|
Which methods the store offers is **seed data**: pass `payment_methods` to `commerce/seed-store` (§2) — e.g. `["card"]` for a card-only store — instead of ever editing `commerce.PaymentGateway` records yourself.
|
|
@@ -280,7 +294,7 @@ That's it — checkout redirect, `/order-received` confirmation, the webhook, th
|
|
|
280
294
|
|
|
281
295
|
## 3. Storefront quick start — logic only
|
|
282
296
|
|
|
283
|
-
No visitor UI ships; the storefront **API** is complete. The four chunks below are the whole happy path — product list → product page → cart → checkout — showing what to call, what comes back, and what to carry into the next step. 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.
|
|
297
|
+
No visitor UI ships; the storefront **API** is complete. The four chunks below are the whole happy path — product list → product page → cart → checkout — showing what to call, what comes back, and what to carry into the next step. **None of it waits on the seed**: every shape you build against is documented right here, so write these components while `seed-store` and image generation are still running (§0) — live data is only needed to verify the finished pages. 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.
|
|
284
298
|
|
|
285
299
|
Build on the **shipped API client** — create it once and import that instance everywhere (wrapping it in a React context is fine; never a second copy):
|
|
286
300
|
|
|
@@ -344,6 +358,11 @@ const view = resolveSelection(product, variations, selection);
|
|
|
344
358
|
// view.axes → [{ key, name, options }] — render one control each
|
|
345
359
|
// view.availability → { [axisKey]: { [option]: "available" | "out_of_stock" | "unavailable" } }
|
|
346
360
|
// view.display → { price, regular_price, on_sale, sku, stock_status, image, … } for the selection
|
|
361
|
+
// ⚠ display.image is an OBJECT — { src, name, alt } | null. Render
|
|
362
|
+
// <img src={view.display.image?.src} alt={view.display.image?.alt}>.
|
|
363
|
+
// Passing the object itself as src fails the load and your fallback
|
|
364
|
+
// shows a placeholder for every product — with the real image sitting
|
|
365
|
+
// one `.src` away. Same shape everywhere: product.images[n].src too.
|
|
347
366
|
// view.purchasable → gate the Add-to-cart button on this
|
|
348
367
|
// view.addToCart → { product_id, variation_id } — null until the selection resolves
|
|
349
368
|
```
|
|
@@ -356,7 +375,7 @@ const cart = await store.addItem({ ...view.addToCart, quantity: 1 });
|
|
|
356
375
|
|
|
357
376
|
A product with attributes is **rejected without a `variation_id`** (`400 variation_required`) — that is why `view.addToCart` and not a bare `product_id` goes into the call.
|
|
358
377
|
|
|
359
|
-
**What the page renders — all from this one `get-product` call, no extra reads:** a gallery from `product.images` (`view.display.image` is the variant-selected one; placeholder when
|
|
378
|
+
**What the page renders — all from this one `get-product` call, no extra reads:** a gallery from `product.images` (`view.display.image` is the variant-selected one; placeholder **only** when the product truly has no images — every image is an `{ src, alt }` object, so render `img.src`/`img.alt`, never the object itself), name, price from `view.display` (`price`/`regular_price`/`on_sale` → sale badge), one selector per axis, stock state, `short_description` then `description` (**both HTML — render as rich text, don't escape or truncate away the markup**), SKU, `categories` as a breadcrumb, `ribbons` as light labels near the metadata, the `reviews` block (`{ items, has_next, average_rating, rating_count }`), and the `upsells`/`cross_sells` summaries. Descriptive properties (Material, Care…) live in `product.meta_data` — render them as a spec table; they are not attributes and not ribbons. That is the complete product page — [`references/storefront-product-page.md`](./references/storefront-product-page.md) and [`references/product-render.md`](./references/product-render.md) are only for edge cases and for adding fields to the *listing* call.
|
|
360
379
|
|
|
361
380
|
**Carry forward:** nothing — the client keeps the `cart_token`.
|
|
362
381
|
|
|
@@ -471,6 +490,7 @@ Post-installation is complete when every line below holds — check against this
|
|
|
471
490
|
- [ ] `commerce/seed-store` ran once and reported the catalog — real products, final image URLs; if the user restricted payment methods, `payment_methods` was passed in that same call.
|
|
472
491
|
- [ ] Product list renders from `store.listProducts` (cards: image/placeholder, name, price or "From …", sale badge, stars, ribbons) and links by `slug`.
|
|
473
492
|
- [ ] Product page renders from `store.getProduct` with one selector per attribute, resolving to `view.addToCart`.
|
|
493
|
+
- [ ] **Real images actually render** — no placeholder anywhere except for a product genuinely without images: every image rendered via `.src` (`images[n].src`, `view.display.image?.src` — they are objects, not URL strings), every seeded URL permanent and resolving, and any product deliberately seeded imageless (§2.1) since given its image.
|
|
474
494
|
- [ ] The storefront talks to the API through **one `createStorefront` instance** — no hand-rolled `cart_token` handling, and `payment_gateways` read from `getStoreInfo()` only, never off a cart.
|
|
475
495
|
- [ ] `/order-received` calls `completeReturn` and renders `paid` / `unpaid` / `cancelled`.
|
|
476
496
|
- [ ] Online payments **decided, not defaulted** (§2.2): if the store takes card payments, `card-payment.ts` implemented (Stripe: paste §2.2), `STRIPE_SECRET_KEY` secret set and webhook endpoint registered — wired at whatever point in the flow you judged right; if it doesn't (or not yet), the card gateway disabled (seed `payment_methods` without `"card"`). Either way, checkout never offers a card option with no provider behind it.
|
|
@@ -21,7 +21,7 @@ A listing **row** is the product record itself (minus paywalled fields), plus re
|
|
|
21
21
|
|---|---|---|---|
|
|
22
22
|
| `id`, `name`, `slug`, `status` | ✅ | ✅ | There is no `type` field — a product sells variants when `attributes[]` is non-empty |
|
|
23
23
|
| `price`, `regular_price`, `sale_price`, `on_sale` | ✅ | ✅ | With variants the parent `price` is a starting point, not the truth — see §3 |
|
|
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) |
|
|
24
|
+
| `images[]`, `featured`, `short_description`, `description` | ✅ | ✅ | Cards normally use `images[0]` + `short_description`. Every entry is an **object** `{ src, name, alt, position }` — render `images[0]?.src`, never the entry itself (an object passed as `src` fails the load and your fallback shows a placeholder despite the image existing). Sub-fields aren't guaranteed on raw entries — the `normalizeImage` helper (`@/commerce/utils`) returns `{src, name, alt}` with a non-empty `src` or `null`. `images` can also be genuinely **empty** — render a placeholder, don't leave a broken `img` (on the product page `resolveSelection`'s `display.image` is `null` in that same case, already normalized) |
|
|
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
27
|
| `downloadable`, `virtual`, `weight`, `dimensions`, `attributes[]`, `meta_data` | ✅ | ✅ | `attributes[]` is the variant axes; `meta_data` is the descriptive properties the admin calls **modifiers** |
|
|
@@ -36,7 +36,7 @@ Bind the UI to `view` — not to `product.*` — so a selection actually changes
|
|
|
36
36
|
| `view` field | Binds to |
|
|
37
37
|
|---|---|
|
|
38
38
|
| `axes` | the controls: `[{ key, name, attribute_id, options[] }]`, dead options already dropped |
|
|
39
|
-
| `display` | image, price, `regular_price`, `on_sale`, SKU, stock, weight/dimensions, description — variation-first with parent fallback. `display.image` is `{src, name, alt}
|
|
39
|
+
| `display` | image, price, `regular_price`, `on_sale`, SKU, stock, weight/dimensions, description — variation-first with parent fallback. `display.image` is an **object** — always the normalized `{src, name, alt}` (`src` guaranteed non-empty, `alt` defaulted; stored entries are not uniform, so never read `variation.image`/`product.images` raw when this field exists), or **`null` when neither the variation nor the product has a renderable image** (the one case needing a placeholder in the UI). Render `display.image?.src` / `display.image?.alt`; **passing the object itself to an `<img src>` or Image component fails the load and shows the placeholder for every product**. For galleries, run raw entries through the exported `normalizeImage`: `product.images.map(normalizeImage).filter(Boolean)` |
|
|
40
40
|
| `availability[axisKey][option]` | per-option state: `"available"` / `"out_of_stock"` / `"unavailable"` — §3 |
|
|
41
41
|
| `priceRange` | `{ min, max, on_sale, count }` while the selection is incomplete — §3 |
|
|
42
42
|
| `complete` / `missingAxes` | the button label: `Select a ${view.missingAxes[0]?.name}` |
|
|
@@ -74,7 +74,7 @@ Handle `400 variation_required` (empty `variation_id` on a product with attribut
|
|
|
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
|
-
- [ ] Image, price, SKU and availability all come from `view.display
|
|
77
|
+
- [ ] Image, price, SKU and availability all come from `view.display`; the image is rendered from `display.image.src` (it's an object, not a URL string), with a placeholder **only** 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.
|
|
@@ -14,6 +14,7 @@ import { ChevronDown, ChevronRight, ChevronUp, Loader2, Plus, Settings2, Trash2
|
|
|
14
14
|
|
|
15
15
|
import { base44, call } from "../../../lib/api";
|
|
16
16
|
import SearchSelect from "../../../components/SearchSelect";
|
|
17
|
+
import ConfirmDialog from "../../../components/ConfirmDialog";
|
|
17
18
|
import { slugify } from "../../../lib/product-utils";
|
|
18
19
|
|
|
19
20
|
/** Sentinel option value for "create the thing I just typed". */
|
|
@@ -35,12 +36,16 @@ const fetchValues = (attributeId) =>
|
|
|
35
36
|
* immediately, while the choice of which values *this* product uses is part of
|
|
36
37
|
* the product form and saves with it.
|
|
37
38
|
*/
|
|
38
|
-
export default function AttributesSection({ product, up, refs }) {
|
|
39
|
+
export default function AttributesSection({ product, up, refs, variations = [] }) {
|
|
39
40
|
const attributes = product.attributes || [];
|
|
40
41
|
const globalAttributes = refs.attributes || [];
|
|
41
42
|
const [valuesByAttr, setValuesByAttr] = useState({});
|
|
42
43
|
const [manageOpen, setManageOpen] = useState(false);
|
|
43
44
|
const [busy, setBusy] = useState(false);
|
|
45
|
+
// Destructive edits (removing a value or an option) confirm before applying,
|
|
46
|
+
// because the variant list is rebuilt from these and rows are deleted.
|
|
47
|
+
const [pendingValues, setPendingValues] = useState(null); // {index, options, removed, affected, stock}
|
|
48
|
+
const [pendingRemoveRow, setPendingRemoveRow] = useState(null); // index
|
|
44
49
|
|
|
45
50
|
const loadValues = useCallback(async (attributeId) => {
|
|
46
51
|
const rows = await fetchValues(attributeId);
|
|
@@ -68,6 +73,17 @@ export default function AttributesSection({ product, up, refs }) {
|
|
|
68
73
|
const removeRow = (index) =>
|
|
69
74
|
up({ attributes: attributes.filter((_, i) => i !== index).map((a, i) => ({ ...a, position: i })) });
|
|
70
75
|
|
|
76
|
+
/** Variants that carry one of the removed values for this option. */
|
|
77
|
+
const affectedBy = (row, removed) =>
|
|
78
|
+
variations.filter((v) =>
|
|
79
|
+
(v.attributes || []).some(
|
|
80
|
+
(a) =>
|
|
81
|
+
(a.attribute_id || a.name) === (row.attribute_id || row.name) && removed.includes(a.option)
|
|
82
|
+
)
|
|
83
|
+
);
|
|
84
|
+
const stockOf = (list) =>
|
|
85
|
+
list.reduce((sum, v) => sum + (v.manage_stock === "yes" ? Number(v.stock_quantity) || 0 : 0), 0);
|
|
86
|
+
|
|
71
87
|
const moveRow = (index, delta) => {
|
|
72
88
|
const target = index + delta;
|
|
73
89
|
if (target < 0 || target >= attributes.length) return;
|
|
@@ -125,20 +141,25 @@ export default function AttributesSection({ product, up, refs }) {
|
|
|
125
141
|
<div className="space-y-3">
|
|
126
142
|
<div className="flex items-start justify-between gap-4">
|
|
127
143
|
<div>
|
|
128
|
-
<h3 className="text-sm font-semibold">
|
|
144
|
+
<h3 className="text-sm font-semibold">Options</h3>
|
|
129
145
|
<p className="text-xs text-muted-foreground">
|
|
130
|
-
What this product
|
|
131
|
-
the values you pick.
|
|
146
|
+
What this product comes in — each combination of values becomes a variant below.
|
|
132
147
|
</p>
|
|
133
148
|
</div>
|
|
134
|
-
<Button
|
|
135
|
-
|
|
149
|
+
<Button
|
|
150
|
+
type="button"
|
|
151
|
+
variant="ghost"
|
|
152
|
+
size="icon"
|
|
153
|
+
title="Manage option catalog (affects all products)"
|
|
154
|
+
onClick={() => setManageOpen(true)}
|
|
155
|
+
>
|
|
156
|
+
<Settings2 className="h-4 w-4 text-muted-foreground" />
|
|
136
157
|
</Button>
|
|
137
158
|
</div>
|
|
138
159
|
|
|
139
160
|
{attributes.length === 0 && (
|
|
140
161
|
<p className="text-sm text-muted-foreground">
|
|
141
|
-
|
|
162
|
+
No options yet. Add one (Size, Color, Pack…) and pick the values this product comes in.
|
|
142
163
|
</p>
|
|
143
164
|
)}
|
|
144
165
|
|
|
@@ -173,7 +194,11 @@ export default function AttributesSection({ product, up, refs }) {
|
|
|
173
194
|
type="button"
|
|
174
195
|
variant="ghost"
|
|
175
196
|
size="icon"
|
|
176
|
-
onClick={() =>
|
|
197
|
+
onClick={() =>
|
|
198
|
+
(row.options || []).length && variations.length
|
|
199
|
+
? setPendingRemoveRow(i)
|
|
200
|
+
: removeRow(i)
|
|
201
|
+
}
|
|
177
202
|
title="Remove from this product"
|
|
178
203
|
>
|
|
179
204
|
<Trash2 className="h-4 w-4 text-muted-foreground" />
|
|
@@ -185,8 +210,18 @@ export default function AttributesSection({ product, up, refs }) {
|
|
|
185
210
|
value={selected.map((o) => ({ value: o, label: o }))}
|
|
186
211
|
onChange={(next) => {
|
|
187
212
|
const created = next.find((o) => o.value === CREATE);
|
|
188
|
-
if (created)
|
|
189
|
-
|
|
213
|
+
if (created) {
|
|
214
|
+
createAndSelectValue(row, i, createdName(created.label));
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
const options = next.map((o) => o.value);
|
|
218
|
+
const removed = selected.filter((o) => !options.includes(o));
|
|
219
|
+
const affected = removed.length ? affectedBy(row, removed) : [];
|
|
220
|
+
if (affected.length) {
|
|
221
|
+
setPendingValues({ index: i, options, removed, affected, stock: stockOf(affected) });
|
|
222
|
+
} else {
|
|
223
|
+
setRow(i, { options });
|
|
224
|
+
}
|
|
190
225
|
}}
|
|
191
226
|
search={async (q) => {
|
|
192
227
|
const term = (q || "").trim();
|
|
@@ -205,7 +240,7 @@ export default function AttributesSection({ product, up, refs }) {
|
|
|
205
240
|
<div className="flex items-center gap-2">
|
|
206
241
|
<SearchSelect
|
|
207
242
|
className="w-72"
|
|
208
|
-
placeholder="Add
|
|
243
|
+
placeholder="Add option…"
|
|
209
244
|
value={null}
|
|
210
245
|
onChange={(opt) => {
|
|
211
246
|
if (!opt) return;
|
|
@@ -235,6 +270,40 @@ export default function AttributesSection({ product, up, refs }) {
|
|
|
235
270
|
onChanged={refs.refreshAttributes}
|
|
236
271
|
/>
|
|
237
272
|
)}
|
|
273
|
+
|
|
274
|
+
{pendingValues && (
|
|
275
|
+
<ConfirmDialog
|
|
276
|
+
open
|
|
277
|
+
onOpenChange={(o) => !o && setPendingValues(null)}
|
|
278
|
+
title={`Remove ${pendingValues.removed.map((r) => `“${r}”`).join(", ")}?`}
|
|
279
|
+
description={`This deletes ${pendingValues.affected.length} variant(s)${
|
|
280
|
+
pendingValues.stock ? ` and their ${pendingValues.stock} units of stock` : ""
|
|
281
|
+
}. Their prices, stock and SKUs will be lost when you save.`}
|
|
282
|
+
confirmLabel="Delete variants"
|
|
283
|
+
onConfirm={() => {
|
|
284
|
+
setRow(pendingValues.index, { options: pendingValues.options });
|
|
285
|
+
setPendingValues(null);
|
|
286
|
+
}}
|
|
287
|
+
/>
|
|
288
|
+
)}
|
|
289
|
+
|
|
290
|
+
{pendingRemoveRow != null && (
|
|
291
|
+
<ConfirmDialog
|
|
292
|
+
open
|
|
293
|
+
onOpenChange={(o) => !o && setPendingRemoveRow(null)}
|
|
294
|
+
title={`Remove the “${attributes[pendingRemoveRow]?.name}” option?`}
|
|
295
|
+
description={
|
|
296
|
+
attributes.length === 1
|
|
297
|
+
? `This deletes all ${variations.length} variant(s) with their prices, stock and SKUs.`
|
|
298
|
+
: `The variant list is rebuilt without it — prices, stock and SKUs of the current ${variations.length} variant(s) may be lost.`
|
|
299
|
+
}
|
|
300
|
+
confirmLabel="Remove option"
|
|
301
|
+
onConfirm={() => {
|
|
302
|
+
removeRow(pendingRemoveRow);
|
|
303
|
+
setPendingRemoveRow(null);
|
|
304
|
+
}}
|
|
305
|
+
/>
|
|
306
|
+
)}
|
|
238
307
|
</div>
|
|
239
308
|
);
|
|
240
309
|
}
|
|
@@ -329,10 +398,10 @@ function ManageAttributesDialog({ attributes, valuesByAttr, loadValues, onClose,
|
|
|
329
398
|
<Dialog open onOpenChange={(o) => !o && onClose()}>
|
|
330
399
|
<DialogContent className="max-h-[85vh] overflow-y-auto sm:max-w-2xl">
|
|
331
400
|
<DialogHeader>
|
|
332
|
-
<DialogTitle>
|
|
401
|
+
<DialogTitle>Option catalog</DialogTitle>
|
|
333
402
|
</DialogHeader>
|
|
334
403
|
<p className="-mt-2 text-xs text-muted-foreground">
|
|
335
|
-
Shared across every product — changes save immediately. To delete an
|
|
404
|
+
Shared across every product — changes save immediately. To delete an option or a value,
|
|
336
405
|
remove it from the entity data.
|
|
337
406
|
</p>
|
|
338
407
|
|
|
@@ -398,12 +467,12 @@ function ManageAttributesDialog({ attributes, valuesByAttr, loadValues, onClose,
|
|
|
398
467
|
)}
|
|
399
468
|
</div>
|
|
400
469
|
))}
|
|
401
|
-
{!attributes.length && <p className="text-sm text-muted-foreground">No
|
|
470
|
+
{!attributes.length && <p className="text-sm text-muted-foreground">No options yet.</p>}
|
|
402
471
|
</div>
|
|
403
472
|
|
|
404
473
|
<div className="flex items-end gap-2 border-t pt-3">
|
|
405
474
|
<div className="flex-1 space-y-1.5">
|
|
406
|
-
<Label>New
|
|
475
|
+
<Label>New option</Label>
|
|
407
476
|
<Input
|
|
408
477
|
placeholder="e.g. Fabric"
|
|
409
478
|
value={newName}
|
|
@@ -1,9 +1,7 @@
|
|
|
1
1
|
import React from "react";
|
|
2
2
|
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
|
3
3
|
import { Checkbox } from "@/components/ui/checkbox";
|
|
4
|
-
import { Separator } from "@/components/ui/separator";
|
|
5
4
|
|
|
6
|
-
import { isVariable } from "../../../lib/product-utils";
|
|
7
5
|
import PriceInventoryTab from "./tabs/PriceInventoryTab";
|
|
8
6
|
import ModifiersTab from "./tabs/ModifiersTab";
|
|
9
7
|
import LinkedTab from "./tabs/LinkedTab";
|
|
@@ -31,7 +29,7 @@ export default function ProductDataPanel({ product, up, refs, variations, setVar
|
|
|
31
29
|
/>
|
|
32
30
|
),
|
|
33
31
|
},
|
|
34
|
-
{ id: "modifiers", label: "
|
|
32
|
+
{ id: "modifiers", label: "Specifications", body: <ModifiersTab product={product} up={up} /> },
|
|
35
33
|
...(product.downloadable
|
|
36
34
|
? [{ id: "downloads", label: "Downloads", body: <DownloadsTab product={product} up={up} /> }]
|
|
37
35
|
: []),
|
|
@@ -68,14 +66,6 @@ export default function ProductDataPanel({ product, up, refs, variations, setVar
|
|
|
68
66
|
</section>
|
|
69
67
|
))}
|
|
70
68
|
</div>
|
|
71
|
-
<Separator />
|
|
72
|
-
<div className="px-4 py-2 text-xs text-muted-foreground">
|
|
73
|
-
{isVariable(product)
|
|
74
|
-
? `${(product.attributes || []).length} variant attribute(s) · ${(variations || []).length} variant(s)`
|
|
75
|
-
: "No variants"}
|
|
76
|
-
{product.virtual ? " · Virtual" : ""}
|
|
77
|
-
{product.downloadable ? " · Downloadable" : ""}
|
|
78
|
-
</div>
|
|
79
69
|
</CardContent>
|
|
80
70
|
</Card>
|
|
81
71
|
);
|
|
@@ -4,16 +4,22 @@ import MetaDataEditor from "../../../../components/MetaDataEditor";
|
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* Descriptive properties — Material, Care, GTIN. They are `meta_data`, never
|
|
7
|
-
*
|
|
7
|
+
* options: they don't create variants and never affect price or stock.
|
|
8
8
|
*/
|
|
9
9
|
export default function ModifiersTab({ product, up }) {
|
|
10
|
+
const empty = !(product.meta_data || []).length;
|
|
10
11
|
return (
|
|
11
12
|
<div className="max-w-xl space-y-2">
|
|
12
13
|
<p className="text-xs text-muted-foreground">
|
|
13
|
-
|
|
14
|
-
|
|
14
|
+
Descriptive details shown on the product page (e.g. ingredients, allergens, care
|
|
15
|
+
instructions).
|
|
15
16
|
</p>
|
|
16
17
|
<MetaDataEditor value={product.meta_data || []} onChange={(meta_data) => up({ meta_data })} />
|
|
18
|
+
{empty && (
|
|
19
|
+
<p className="text-xs text-muted-foreground">
|
|
20
|
+
Want the product to vary by something? Add it as an option under Price & Inventory.
|
|
21
|
+
</p>
|
|
22
|
+
)}
|
|
17
23
|
</div>
|
|
18
24
|
);
|
|
19
25
|
}
|