@base44/app-plugin-commerce 0.1.12 → 0.1.14
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 +26 -6
- 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/storefront.js +10 -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.14",
|
|
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** (§2.2
|
|
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).
|
|
18
18
|
|
|
19
19
|
## Working on the UI
|
|
20
20
|
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# Post-installation
|
|
2
2
|
|
|
3
|
-
What to do right after the static installation ([`installation-guidelines.md`](./installation-guidelines.md)): embed the admin pages into the app, seed the store's data,
|
|
3
|
+
What to do right after the static installation ([`installation-guidelines.md`](./installation-guidelines.md)): embed the admin pages into the app, seed the store's data, build the storefront from the quick start below, and — **if and when the store needs online payments, a call you make deliberately (§2.2)** — wire a payment provider. Installed into the app at `.agents/skills/commerce/post-installation.md`.
|
|
4
4
|
|
|
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, card payments) is on this page, ending with the [Done when](#4-done-when) checklist. Reading more first just burns time.
|
|
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
7
|
---
|
|
8
8
|
|
|
@@ -145,6 +145,12 @@ A successful response means the data is in — the catalog and settings are live
|
|
|
145
145
|
|
|
146
146
|
The order side of payments is **already implemented** (checkout routing, confirmation, payment links, refund records). The `offline` gateway — and any option the admin adds in Settings → Payments — works with nothing to configure: the order goes on-hold with the option's description as instructions.
|
|
147
147
|
|
|
148
|
+
**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
|
+
|
|
150
|
+
- **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 needs the user's provider secret key, so it usually fits **after** the storefront is standing — asking for a Stripe key before there is anything to sell is premature. 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
|
+
- **Unclear from the request** → build everything else first and raise payments with the user at the end, or ask.
|
|
153
|
+
|
|
148
154
|
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.
|
|
149
155
|
|
|
150
156
|
The **Credit card** option needs a payment provider — **any** provider works (Stripe, PayPal, Adyen, a local PSP…), and whichever one it is, wiring it touches exactly **one file**: `base44/shared/commerce/card-payment.ts` — four functions, each backing a premade flow. `commerce/payment-webhook` (the function) is **premade — do not edit it**: it calls this file's `parseWebhook` to validate each event and **never trusts an event body on its own** — an unverified event only *names* an order, and whether money arrived is asked of the provider itself through `checkCardPaymentPaid`, so a forged webhook call can never mark an order paid and **no signing secret is needed**. Until the file is implemented the card option answers `503 no_card_payment_provider` at checkout (or switch it off in Settings → Payments).
|
|
@@ -287,6 +293,19 @@ export const store = createStorefront(base44);
|
|
|
287
293
|
|
|
288
294
|
The client owns the two things hand-rolled storefronts keep getting wrong, so **don't reimplement either**: 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). For anything beyond its methods, `store.inv(fn, payload)` unwraps the `{ success, data }` envelope (`res.data.data`).
|
|
289
295
|
|
|
296
|
+
**What each method resolves to** — the client already unwraps every envelope, so take these shapes at face value (no `.data`, and no `.categories` on a list result):
|
|
297
|
+
|
|
298
|
+
| Method | Resolves to |
|
|
299
|
+
|---|---|
|
|
300
|
+
| `store.getStoreInfo()` | `{ settings, payment_gateways, countries, currencies }` — cached; the **only** source of `payment_gateways` |
|
|
301
|
+
| `store.listProducts(params)` | `{ products, page, per_page, has_next }` — a page object; the array is `products` |
|
|
302
|
+
| `store.getProduct(slug \| { id })` | `{ product, variations, categories, ribbons, reviews }` |
|
|
303
|
+
| `store.listCategories()` | an **array** of root categories, subcategories nested under `children` |
|
|
304
|
+
| `store.listRibbons()` | an **array** of `{ id, name, count }` |
|
|
305
|
+
| `store.getCart()` and every cart mutation | the full cart view (`getCart()` alone may resolve to `null` — no cart yet); `cart.items[n].attributes` is an **array** of `{ name, option }`, never a map |
|
|
306
|
+
| `store.placeOrder(details)` | `{ order_id, order_number, order_key, status, totals, order, payment_instructions, payment }` |
|
|
307
|
+
| `store.completeReturn()` | `{ state, order, payment_link, payment_instructions }` |
|
|
308
|
+
|
|
290
309
|
Keep the UI in **small focused components** (~2–4K characters each — product card, gallery, cart panel, address step, payment step…), not monolithic page files: smaller files are faster to emit, review and fix.
|
|
291
310
|
|
|
292
311
|
### 3.1 Product list
|
|
@@ -305,7 +324,7 @@ const { products, page, per_page, has_next } = await store.listProducts({
|
|
|
305
324
|
}); // sort: -created_date | name | price | -price | popularity | rating
|
|
306
325
|
```
|
|
307
326
|
|
|
308
|
-
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()` (
|
|
327
|
+
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.
|
|
309
328
|
|
|
310
329
|
**Carry forward:** each card links to the product page by **`slug`**.
|
|
311
330
|
|
|
@@ -348,6 +367,8 @@ A product with attributes is **rejected without a `variation_id`** (`400 variati
|
|
|
348
367
|
```js
|
|
349
368
|
let cart = await store.getCart(); // null → no cart yet (an expired token self-clears)
|
|
350
369
|
// cart.items → [{ item_key, name, image, quantity, price, subtotal, total, attributes, purchasable }]
|
|
370
|
+
// item.attributes is an ARRAY of { name, option } — render "Size: 42" by
|
|
371
|
+
// mapping it (attributes.map(a => `${a.name}: ${a.option}`)); it is never a map
|
|
351
372
|
// cart.totals → { subtotal, discount_total, shipping_total, cart_tax, total_tax, total, … }
|
|
352
373
|
// cart.coupon_notices / cart.removed_items → tell the customer what auto-dropped and why
|
|
353
374
|
//
|
|
@@ -444,7 +465,7 @@ Two shapes to get right when rendering: **`order` carries flat totals** — `ord
|
|
|
444
465
|
|
|
445
466
|
## 4. Done when
|
|
446
467
|
|
|
447
|
-
Post-installation is complete when every line below holds —
|
|
468
|
+
Post-installation is complete when every line below holds — check against this list instead of re-reading docs. **Do not validate the admin part at all**: everything under `/store-admin` ships finished and already tested — there is nothing there for you to verify.
|
|
448
469
|
|
|
449
470
|
- [ ] `/store-admin/*` mounted behind the shipped `AuthGuard`; `/` routes somewhere real (storefront or a redirect).
|
|
450
471
|
- [ ] `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.
|
|
@@ -452,8 +473,7 @@ Post-installation is complete when every line below holds — verify against thi
|
|
|
452
473
|
- [ ] Product page renders from `store.getProduct` with one selector per attribute, resolving to `view.addToCart`.
|
|
453
474
|
- [ ] 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.
|
|
454
475
|
- [ ] `/order-received` calls `completeReturn` and renders `paid` / `unpaid` / `cancelled`.
|
|
455
|
-
- [ ]
|
|
456
|
-
- [ ] **Verified by driving a real test order end to end** — add to cart → address → shipping → `place-order` → `/order-received` renders the right state — then the test order cancelled/deleted in the admin. Placing and cleaning up a test order is routine install verification, not a destructive action. A button existing is **not** evidence the flow works; only the driven flow is.
|
|
476
|
+
- [ ] 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.
|
|
457
477
|
|
|
458
478
|
## 5. Next
|
|
459
479
|
|
|
@@ -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
|
}
|