@base44/app-plugin-commerce 0.1.13 → 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/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/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",
|
|
@@ -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
|
}
|
|
@@ -5,7 +5,6 @@ import { Input } from "@/components/ui/input";
|
|
|
5
5
|
import { Label } from "@/components/ui/label";
|
|
6
6
|
import { Switch } from "@/components/ui/switch";
|
|
7
7
|
import { Checkbox } from "@/components/ui/checkbox";
|
|
8
|
-
import { Card } from "@/components/ui/card";
|
|
9
8
|
import {
|
|
10
9
|
Select,
|
|
11
10
|
SelectContent,
|
|
@@ -18,19 +17,19 @@ import { toast } from "sonner";
|
|
|
18
17
|
|
|
19
18
|
import MoneyInput from "../../../../components/MoneyInput";
|
|
20
19
|
import MediaUploader from "../../../../components/MediaUploader";
|
|
20
|
+
import ConfirmDialog from "../../../../components/ConfirmDialog";
|
|
21
21
|
import useMoney from "../../../../hooks/useMoney";
|
|
22
22
|
import { useSettings } from "../../../../context/SettingsContext";
|
|
23
23
|
import AttributesSection from "../AttributesSection";
|
|
24
24
|
import { comboKey, generateVariationCombos, variationLabel } from "../../../../lib/product-utils";
|
|
25
25
|
import { BACKORDER_OPTIONS, STOCK_STATUSES, TAX_STATUSES } from "../../../../lib/constants";
|
|
26
26
|
|
|
27
|
-
const ANY = "__any__";
|
|
28
27
|
const num = (v) => (v === "" ? null : Number(v));
|
|
29
28
|
const toDateInput = (iso) => (iso ? String(iso).slice(0, 10) : "");
|
|
30
29
|
const fromDateInput = (v) => (v ? new Date(`${v}T00:00:00Z`).toISOString() : null);
|
|
31
30
|
|
|
32
31
|
/**
|
|
33
|
-
* What "copy from" moves between variants, by group.
|
|
32
|
+
* What bulk "copy from" moves between variants, by group.
|
|
34
33
|
*
|
|
35
34
|
* `sku` is deliberately absent: it must be unique across products *and*
|
|
36
35
|
* variations, so copying it would fail the save with `duplicate_sku`. `image`
|
|
@@ -72,14 +71,16 @@ const NEW_VARIATION = {
|
|
|
72
71
|
};
|
|
73
72
|
|
|
74
73
|
/** Price, sale price and the sale window — identical for the product and a variant. */
|
|
75
|
-
function PriceFields({ values, set }) {
|
|
74
|
+
function PriceFields({ values, set, hideRegular = false }) {
|
|
76
75
|
const scheduled = Boolean(values.date_on_sale_from || values.date_on_sale_to);
|
|
77
76
|
return (
|
|
78
77
|
<>
|
|
79
|
-
|
|
80
|
-
<
|
|
81
|
-
|
|
82
|
-
|
|
78
|
+
{!hideRegular && (
|
|
79
|
+
<div className="space-y-1.5">
|
|
80
|
+
<Label className="text-xs">Regular price</Label>
|
|
81
|
+
<MoneyInput value={values.regular_price} onChange={(v) => set({ regular_price: v })} />
|
|
82
|
+
</div>
|
|
83
|
+
)}
|
|
83
84
|
<div className="space-y-1.5">
|
|
84
85
|
<Label className="text-xs">Sale price</Label>
|
|
85
86
|
<MoneyInput value={values.sale_price} onChange={(v) => set({ sale_price: v })} />
|
|
@@ -123,33 +124,6 @@ function PriceFields({ values, set }) {
|
|
|
123
124
|
);
|
|
124
125
|
}
|
|
125
126
|
|
|
126
|
-
/** Collapsible shell shared by the default row and every variant row. */
|
|
127
|
-
function Row({ title, summary, right, onRemove, children }) {
|
|
128
|
-
const [open, setOpen] = useState(false);
|
|
129
|
-
return (
|
|
130
|
-
<Card className="overflow-hidden">
|
|
131
|
-
<div className="flex items-center justify-between gap-2 px-3 py-2">
|
|
132
|
-
<button
|
|
133
|
-
type="button"
|
|
134
|
-
className="flex min-w-0 flex-1 items-center gap-2 text-left"
|
|
135
|
-
onClick={() => setOpen((o) => !o)}
|
|
136
|
-
>
|
|
137
|
-
{open ? <ChevronDown className="h-4 w-4 shrink-0" /> : <ChevronRight className="h-4 w-4 shrink-0" />}
|
|
138
|
-
<span className="truncate font-medium">{title}</span>
|
|
139
|
-
<span className="truncate text-xs text-muted-foreground">{summary}</span>
|
|
140
|
-
</button>
|
|
141
|
-
{right}
|
|
142
|
-
{onRemove && (
|
|
143
|
-
<Button type="button" variant="ghost" size="icon" onClick={onRemove}>
|
|
144
|
-
<Trash2 className="h-4 w-4 text-muted-foreground" />
|
|
145
|
-
</Button>
|
|
146
|
-
)}
|
|
147
|
-
</div>
|
|
148
|
-
{open && <div className="border-t p-3">{children}</div>}
|
|
149
|
-
</Card>
|
|
150
|
-
);
|
|
151
|
-
}
|
|
152
|
-
|
|
153
127
|
const Section = ({ label, children, className = "" }) => (
|
|
154
128
|
<div className={`space-y-2 sm:col-span-2 ${className}`}>
|
|
155
129
|
<p className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">{label}</p>
|
|
@@ -239,252 +213,265 @@ function DownloadsFields({ values, set }) {
|
|
|
239
213
|
}
|
|
240
214
|
|
|
241
215
|
/**
|
|
242
|
-
*
|
|
243
|
-
*
|
|
244
|
-
* product's own, so `manage_stock` is the boolean the entity uses here, not a
|
|
245
|
-
* variant's tri-state.
|
|
216
|
+
* Single-product mode: the product's own price, stock and shipping as plain,
|
|
217
|
+
* always-visible fields. No accordion — setting a price is zero clicks.
|
|
246
218
|
*/
|
|
247
|
-
function
|
|
248
|
-
const summary = [
|
|
249
|
-
product.price != null || product.regular_price != null
|
|
250
|
-
? money.format(product.price ?? product.regular_price)
|
|
251
|
-
: "no price",
|
|
252
|
-
product.manage_stock ? `${product.stock_quantity ?? 0} in stock` : null,
|
|
253
|
-
]
|
|
254
|
-
.filter(Boolean)
|
|
255
|
-
.join(" · ");
|
|
256
|
-
|
|
219
|
+
function SingleProductFields({ product, up, units }) {
|
|
257
220
|
return (
|
|
258
|
-
<
|
|
259
|
-
<
|
|
260
|
-
<
|
|
261
|
-
|
|
262
|
-
</Section>
|
|
221
|
+
<div className="grid max-w-2xl gap-4 sm:grid-cols-2">
|
|
222
|
+
<Section label="Price">
|
|
223
|
+
<PriceFields values={product} set={up} />
|
|
224
|
+
</Section>
|
|
263
225
|
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
226
|
+
<Section label="Inventory">
|
|
227
|
+
<div className="space-y-1.5">
|
|
228
|
+
<Label className="text-xs">SKU</Label>
|
|
229
|
+
<Input
|
|
230
|
+
placeholder="Unique stock keeping unit"
|
|
231
|
+
value={product.sku || ""}
|
|
232
|
+
onChange={(e) => up({ sku: e.target.value })}
|
|
233
|
+
/>
|
|
234
|
+
</div>
|
|
235
|
+
<div />
|
|
236
|
+
<div className="sm:col-span-2">
|
|
237
|
+
<label className="flex items-center gap-2 text-sm">
|
|
238
|
+
<Switch
|
|
239
|
+
checked={product.manage_stock}
|
|
240
|
+
onCheckedChange={(v) => up({ manage_stock: Boolean(v) })}
|
|
271
241
|
/>
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
242
|
+
Track stock quantity
|
|
243
|
+
</label>
|
|
244
|
+
</div>
|
|
245
|
+
{product.manage_stock ? (
|
|
246
|
+
<>
|
|
247
|
+
<div className="space-y-1.5">
|
|
248
|
+
<Label className="text-xs">Stock quantity</Label>
|
|
249
|
+
<Input
|
|
250
|
+
type="number"
|
|
251
|
+
value={product.stock_quantity ?? ""}
|
|
252
|
+
onChange={(e) => up({ stock_quantity: num(e.target.value) })}
|
|
279
253
|
/>
|
|
280
|
-
|
|
281
|
-
</label>
|
|
282
|
-
</div>
|
|
283
|
-
{product.manage_stock ? (
|
|
284
|
-
<>
|
|
285
|
-
<div className="space-y-1.5">
|
|
286
|
-
<Label className="text-xs">Stock quantity</Label>
|
|
287
|
-
<Input
|
|
288
|
-
type="number"
|
|
289
|
-
value={product.stock_quantity ?? ""}
|
|
290
|
-
onChange={(e) => up({ stock_quantity: num(e.target.value) })}
|
|
291
|
-
/>
|
|
292
|
-
</div>
|
|
293
|
-
<div className="space-y-1.5">
|
|
294
|
-
<Label className="text-xs">Backorders</Label>
|
|
295
|
-
<Select value={product.backorders || "no"} onValueChange={(v) => up({ backorders: v })}>
|
|
296
|
-
<SelectTrigger><SelectValue /></SelectTrigger>
|
|
297
|
-
<SelectContent>
|
|
298
|
-
{BACKORDER_OPTIONS.map((o) => (
|
|
299
|
-
<SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>
|
|
300
|
-
))}
|
|
301
|
-
</SelectContent>
|
|
302
|
-
</Select>
|
|
303
|
-
</div>
|
|
304
|
-
<div className="space-y-1.5">
|
|
305
|
-
<Label className="text-xs">Low stock threshold</Label>
|
|
306
|
-
<Input
|
|
307
|
-
type="number"
|
|
308
|
-
placeholder="Store default"
|
|
309
|
-
value={product.low_stock_amount ?? ""}
|
|
310
|
-
onChange={(e) => up({ low_stock_amount: num(e.target.value) })}
|
|
311
|
-
/>
|
|
312
|
-
</div>
|
|
313
|
-
</>
|
|
314
|
-
) : (
|
|
254
|
+
</div>
|
|
315
255
|
<div className="space-y-1.5">
|
|
316
|
-
<Label className="text-xs">
|
|
317
|
-
<Select value={product.
|
|
256
|
+
<Label className="text-xs">Backorders</Label>
|
|
257
|
+
<Select value={product.backorders || "no"} onValueChange={(v) => up({ backorders: v })}>
|
|
318
258
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
|
319
259
|
<SelectContent>
|
|
320
|
-
{
|
|
321
|
-
<SelectItem key={
|
|
260
|
+
{BACKORDER_OPTIONS.map((o) => (
|
|
261
|
+
<SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>
|
|
322
262
|
))}
|
|
323
263
|
</SelectContent>
|
|
324
264
|
</Select>
|
|
325
265
|
</div>
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
266
|
+
<div className="space-y-1.5">
|
|
267
|
+
<Label className="text-xs">Low stock threshold</Label>
|
|
268
|
+
<Input
|
|
269
|
+
type="number"
|
|
270
|
+
placeholder="Store default"
|
|
271
|
+
value={product.low_stock_amount ?? ""}
|
|
272
|
+
onChange={(e) => up({ low_stock_amount: num(e.target.value) })}
|
|
273
|
+
/>
|
|
274
|
+
</div>
|
|
275
|
+
</>
|
|
276
|
+
) : (
|
|
277
|
+
<div className="space-y-1.5">
|
|
278
|
+
<Label className="text-xs">Stock status</Label>
|
|
279
|
+
<Select value={product.stock_status || "instock"} onValueChange={(v) => up({ stock_status: v })}>
|
|
280
|
+
<SelectTrigger><SelectValue /></SelectTrigger>
|
|
281
|
+
<SelectContent>
|
|
282
|
+
{STOCK_STATUSES.map((s) => (
|
|
283
|
+
<SelectItem key={s.value} value={s.value}>{s.label}</SelectItem>
|
|
284
|
+
))}
|
|
285
|
+
</SelectContent>
|
|
286
|
+
</Select>
|
|
287
|
+
</div>
|
|
333
288
|
)}
|
|
334
|
-
</
|
|
335
|
-
|
|
289
|
+
</Section>
|
|
290
|
+
|
|
291
|
+
{!product.virtual && (
|
|
292
|
+
<Section label="Shipping">
|
|
293
|
+
<ShippingFields values={product} set={up} units={units} />
|
|
294
|
+
</Section>
|
|
295
|
+
)}
|
|
296
|
+
</div>
|
|
336
297
|
);
|
|
337
298
|
}
|
|
338
299
|
|
|
339
|
-
/**
|
|
340
|
-
function
|
|
341
|
-
const set = (patch) => onChange({ ...variation, ...patch });
|
|
342
|
-
const summary = [
|
|
343
|
-
variation.price != null || variation.regular_price != null
|
|
344
|
-
? money.format(variation.price ?? variation.regular_price)
|
|
345
|
-
: "no price",
|
|
346
|
-
variation.manage_stock === "yes" ? `${variation.stock_quantity ?? 0} in stock` : null,
|
|
347
|
-
variation.sku ? `SKU ${variation.sku}` : null,
|
|
348
|
-
]
|
|
349
|
-
.filter(Boolean)
|
|
350
|
-
.join(" · ");
|
|
351
|
-
|
|
300
|
+
/** Rarely-needed per-variant fields, shown when a table row is expanded. */
|
|
301
|
+
function VariantDetails({ variation, set, units, virtual }) {
|
|
352
302
|
return (
|
|
353
|
-
<
|
|
354
|
-
|
|
355
|
-
summary={summary}
|
|
356
|
-
right={
|
|
357
|
-
<label className="flex items-center gap-1.5 text-sm">
|
|
358
|
-
<Checkbox
|
|
359
|
-
checked={variation.status !== "private" && variation.status !== "draft"}
|
|
360
|
-
onCheckedChange={(v) => set({ status: v ? "publish" : "private" })}
|
|
361
|
-
/>
|
|
362
|
-
Enabled
|
|
363
|
-
</label>
|
|
364
|
-
}
|
|
365
|
-
>
|
|
366
|
-
<div className="grid gap-4 sm:grid-cols-[140px_1fr]">
|
|
367
|
-
<MediaUploader value={variation.image || null} onChange={(image) => set({ image })} label="Image" />
|
|
368
|
-
|
|
369
|
-
<div className="grid gap-3 sm:grid-cols-2">
|
|
370
|
-
<Section label="Price">
|
|
371
|
-
<PriceFields values={variation} set={set} />
|
|
372
|
-
</Section>
|
|
303
|
+
<div className="grid gap-4 sm:grid-cols-[140px_1fr]">
|
|
304
|
+
<MediaUploader value={variation.image || null} onChange={(image) => set({ image })} label="Image" />
|
|
373
305
|
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
306
|
+
<div className="grid gap-3 sm:grid-cols-2">
|
|
307
|
+
<Section label="Sale">
|
|
308
|
+
<PriceFields values={variation} set={set} hideRegular />
|
|
309
|
+
</Section>
|
|
310
|
+
|
|
311
|
+
<Section label="Inventory">
|
|
312
|
+
<div className="space-y-1.5">
|
|
313
|
+
<Label className="text-xs">Stock management</Label>
|
|
314
|
+
<Select value={variation.manage_stock || "parent"} onValueChange={(v) => set({ manage_stock: v })}>
|
|
315
|
+
<SelectTrigger><SelectValue /></SelectTrigger>
|
|
316
|
+
<SelectContent>
|
|
317
|
+
<SelectItem value="parent">Same as product</SelectItem>
|
|
318
|
+
<SelectItem value="yes">Track quantity</SelectItem>
|
|
319
|
+
<SelectItem value="no">Don't track</SelectItem>
|
|
320
|
+
</SelectContent>
|
|
321
|
+
</Select>
|
|
322
|
+
</div>
|
|
323
|
+
{variation.manage_stock === "yes" && (
|
|
379
324
|
<div className="space-y-1.5">
|
|
380
|
-
<Label className="text-xs">
|
|
381
|
-
<Select value={variation.
|
|
325
|
+
<Label className="text-xs">Backorders</Label>
|
|
326
|
+
<Select value={variation.backorders || "no"} onValueChange={(v) => set({ backorders: v })}>
|
|
382
327
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
|
383
328
|
<SelectContent>
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
329
|
+
{BACKORDER_OPTIONS.map((o) => (
|
|
330
|
+
<SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>
|
|
331
|
+
))}
|
|
387
332
|
</SelectContent>
|
|
388
333
|
</Select>
|
|
389
334
|
</div>
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
value={variation.stock_quantity ?? ""}
|
|
397
|
-
onChange={(e) => set({ stock_quantity: num(e.target.value) })}
|
|
398
|
-
/>
|
|
399
|
-
</div>
|
|
400
|
-
<div className="space-y-1.5">
|
|
401
|
-
<Label className="text-xs">Backorders</Label>
|
|
402
|
-
<Select value={variation.backorders || "no"} onValueChange={(v) => set({ backorders: v })}>
|
|
403
|
-
<SelectTrigger><SelectValue /></SelectTrigger>
|
|
404
|
-
<SelectContent>
|
|
405
|
-
{BACKORDER_OPTIONS.map((o) => (
|
|
406
|
-
<SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>
|
|
407
|
-
))}
|
|
408
|
-
</SelectContent>
|
|
409
|
-
</Select>
|
|
410
|
-
</div>
|
|
411
|
-
</>
|
|
412
|
-
)}
|
|
335
|
+
)}
|
|
336
|
+
</Section>
|
|
337
|
+
|
|
338
|
+
{!virtual && (
|
|
339
|
+
<Section label="Shipping">
|
|
340
|
+
<ShippingFields values={variation} set={set} units={units} />
|
|
413
341
|
</Section>
|
|
342
|
+
)}
|
|
414
343
|
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
344
|
+
<Section label="Type">
|
|
345
|
+
<div className="flex items-end gap-4 text-sm">
|
|
346
|
+
<label className="flex items-center gap-1.5">
|
|
347
|
+
<Checkbox checked={variation.virtual} onCheckedChange={(v) => set({ virtual: Boolean(v) })} />
|
|
348
|
+
Virtual
|
|
349
|
+
</label>
|
|
350
|
+
<label className="flex items-center gap-1.5">
|
|
351
|
+
<Checkbox
|
|
352
|
+
checked={variation.downloadable}
|
|
353
|
+
onCheckedChange={(v) => set({ downloadable: Boolean(v) })}
|
|
354
|
+
/>
|
|
355
|
+
Downloadable
|
|
356
|
+
</label>
|
|
357
|
+
</div>
|
|
358
|
+
</Section>
|
|
420
359
|
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
<Checkbox checked={variation.virtual} onCheckedChange={(v) => set({ virtual: Boolean(v) })} />
|
|
425
|
-
Virtual
|
|
426
|
-
</label>
|
|
427
|
-
<label className="flex items-center gap-1.5">
|
|
428
|
-
<Checkbox
|
|
429
|
-
checked={variation.downloadable}
|
|
430
|
-
onCheckedChange={(v) => set({ downloadable: Boolean(v) })}
|
|
431
|
-
/>
|
|
432
|
-
Downloadable
|
|
433
|
-
</label>
|
|
434
|
-
</div>
|
|
360
|
+
{variation.downloadable && (
|
|
361
|
+
<Section label="Files">
|
|
362
|
+
<DownloadsFields values={variation} set={set} />
|
|
435
363
|
</Section>
|
|
364
|
+
)}
|
|
365
|
+
</div>
|
|
366
|
+
</div>
|
|
367
|
+
);
|
|
368
|
+
}
|
|
436
369
|
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
370
|
+
/** Product-wide, set-once fields — collapsed by default so pricing leads. */
|
|
371
|
+
function TaxRules({ product, up, taxGroups }) {
|
|
372
|
+
const [open, setOpen] = useState(false);
|
|
373
|
+
const taxLabel = TAX_STATUSES.find((t) => t.value === product.tax_status)?.label || product.tax_status;
|
|
374
|
+
return (
|
|
375
|
+
<div className="border-t pt-3">
|
|
376
|
+
<button
|
|
377
|
+
type="button"
|
|
378
|
+
className="flex items-center gap-1.5 text-sm font-medium text-muted-foreground hover:text-foreground"
|
|
379
|
+
onClick={() => setOpen((o) => !o)}
|
|
380
|
+
>
|
|
381
|
+
{open ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
|
|
382
|
+
Tax & purchase rules
|
|
383
|
+
{!open && (
|
|
384
|
+
<span className="font-normal text-xs">
|
|
385
|
+
· {taxLabel} · {product.tax_group || "Products"}
|
|
386
|
+
{product.sold_individually ? " · max 1 per order" : ""}
|
|
387
|
+
</span>
|
|
388
|
+
)}
|
|
389
|
+
</button>
|
|
390
|
+
{open && (
|
|
391
|
+
<div className="mt-3 grid max-w-md gap-3 sm:grid-cols-2">
|
|
392
|
+
<div className="space-y-1.5">
|
|
393
|
+
<Label>Tax status</Label>
|
|
394
|
+
<Select value={product.tax_status} onValueChange={(v) => up({ tax_status: v })}>
|
|
395
|
+
<SelectTrigger><SelectValue /></SelectTrigger>
|
|
396
|
+
<SelectContent>
|
|
397
|
+
{TAX_STATUSES.map((t) => (
|
|
398
|
+
<SelectItem key={t.value} value={t.value}>{t.label}</SelectItem>
|
|
399
|
+
))}
|
|
400
|
+
</SelectContent>
|
|
401
|
+
</Select>
|
|
402
|
+
</div>
|
|
403
|
+
<div className="space-y-1.5">
|
|
404
|
+
<Label>Tax group</Label>
|
|
405
|
+
<Select value={product.tax_group || "Products"} onValueChange={(v) => up({ tax_group: v })}>
|
|
406
|
+
<SelectTrigger><SelectValue /></SelectTrigger>
|
|
407
|
+
<SelectContent>
|
|
408
|
+
{taxGroups.map((g) => (
|
|
409
|
+
<SelectItem key={g} value={g}>{g}</SelectItem>
|
|
410
|
+
))}
|
|
411
|
+
</SelectContent>
|
|
412
|
+
</Select>
|
|
413
|
+
</div>
|
|
414
|
+
<label className="flex items-center gap-2 text-sm sm:col-span-2">
|
|
415
|
+
<Checkbox
|
|
416
|
+
checked={product.sold_individually}
|
|
417
|
+
onCheckedChange={(v) => up({ sold_individually: Boolean(v) })}
|
|
418
|
+
/>
|
|
419
|
+
Limit purchases to 1 item per order
|
|
420
|
+
</label>
|
|
442
421
|
</div>
|
|
443
|
-
|
|
444
|
-
</
|
|
422
|
+
)}
|
|
423
|
+
</div>
|
|
445
424
|
);
|
|
446
425
|
}
|
|
447
426
|
|
|
448
427
|
/**
|
|
449
428
|
* Price & Inventory — one surface for what a customer actually buys.
|
|
450
429
|
*
|
|
451
|
-
*
|
|
452
|
-
*
|
|
453
|
-
*
|
|
454
|
-
*
|
|
455
|
-
*
|
|
456
|
-
* downloads blocks are shared components rather than two parallel forms.
|
|
430
|
+
* The merchant picks the mental model explicitly: a single product (one price,
|
|
431
|
+
* one stock count, plain fields) or a product with variants (options define
|
|
432
|
+
* the axes, every variant is a row in an inline-editable table). Variants
|
|
433
|
+
* still follow the options — adding a value inserts pre-filled rows, removing
|
|
434
|
+
* one takes them away after a confirmation upstream in AttributesSection.
|
|
457
435
|
*
|
|
458
436
|
* The parent's own price is *not* edited when variants exist — `admin-products`
|
|
459
|
-
* derives it from the cheapest variant on save.
|
|
437
|
+
* derives it from the cheapest variant on save; the "from …" label shows it live.
|
|
460
438
|
*/
|
|
461
439
|
export default function PriceInventoryTab({ product, up, refs, variations, setVariations }) {
|
|
462
440
|
const money = useMoney();
|
|
463
441
|
const settings = useSettings();
|
|
464
442
|
const weightUnit = settings?.get("general", "weight_unit", "kg");
|
|
465
443
|
const dimensionUnit = settings?.get("general", "dimension_unit", "cm");
|
|
466
|
-
const [copyFrom, setCopyFrom] = useState("0");
|
|
467
|
-
const [copyGroups, setCopyGroups] = useState(["price"]);
|
|
468
|
-
// Tax groups are named per Shipping & Tax Location; a product picks by name.
|
|
469
444
|
const taxGroups = refs.taxGroups?.length ? refs.taxGroups : ["Products"];
|
|
470
445
|
const units = { weightUnit, dimensionUnit };
|
|
471
446
|
|
|
472
447
|
const hasAttributes = (product.attributes || []).length > 0;
|
|
473
|
-
const
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
448
|
+
const [mode, setMode] = useState(hasAttributes ? "variants" : "single");
|
|
449
|
+
const [confirmSingle, setConfirmSingle] = useState(false);
|
|
450
|
+
useEffect(() => {
|
|
451
|
+
if (hasAttributes) setMode("variants");
|
|
452
|
+
}, [hasAttributes]);
|
|
453
|
+
|
|
454
|
+
// Table state: selection for bulk edits, one expandable row.
|
|
455
|
+
const [selected, setSelected] = useState([]);
|
|
456
|
+
const [expanded, setExpanded] = useState(null);
|
|
457
|
+
const [bulkPrice, setBulkPrice] = useState(null);
|
|
458
|
+
const [bulkStock, setBulkStock] = useState("");
|
|
459
|
+
const [copyFrom, setCopyFrom] = useState("0");
|
|
460
|
+
useEffect(() => {
|
|
461
|
+
setSelected((s) => s.filter((i) => i < variations.length));
|
|
462
|
+
setExpanded((e) => (e != null && e >= variations.length ? null : e));
|
|
463
|
+
}, [variations.length]);
|
|
477
464
|
|
|
478
465
|
/**
|
|
479
|
-
* Variants follow the
|
|
466
|
+
* Variants follow the options — no generate step, no per-row delete.
|
|
480
467
|
*
|
|
481
|
-
* Whenever the
|
|
468
|
+
* Whenever the option values change, every missing combination is added and
|
|
482
469
|
* every combination that is no longer possible is dropped. New rows inherit the
|
|
483
470
|
* first existing variant, or the product's own values when there is none yet, so
|
|
484
471
|
* adding a size to a €19.99 mug gives priced variants rather than blank ones.
|
|
485
472
|
*
|
|
486
473
|
* Keyed on a signature and skipped on the first render: a product loaded with
|
|
487
|
-
* variants that don't match its
|
|
474
|
+
* variants that don't match its options must not be silently rewritten before
|
|
488
475
|
* the merchant has touched anything.
|
|
489
476
|
*/
|
|
490
477
|
const attrSignature = JSON.stringify(
|
|
@@ -525,174 +512,310 @@ export default function PriceInventoryTab({ product, up, refs, variations, setVa
|
|
|
525
512
|
.map(([, combo], i) => ({ ...NEW_VARIATION, ...template, ...combo, menu_order: kept.length + i }));
|
|
526
513
|
|
|
527
514
|
if (added.length || kept.length !== variations.length) setVariations([...kept, ...added]);
|
|
528
|
-
// eslint-disable-next-line -- reconcile only when the
|
|
515
|
+
// eslint-disable-next-line -- reconcile only when the option values change
|
|
529
516
|
}, [attrSignature]);
|
|
530
517
|
|
|
531
|
-
|
|
518
|
+
const enabled = (v) => v.status !== "private" && v.status !== "draft";
|
|
519
|
+
|
|
520
|
+
// Live derived price — what admin-products computes on save.
|
|
521
|
+
const fromPrice = useMemo(() => {
|
|
522
|
+
const prices = variations
|
|
523
|
+
.filter(enabled)
|
|
524
|
+
.map((v) => v.sale_price ?? v.regular_price)
|
|
525
|
+
.filter((n) => n != null);
|
|
526
|
+
return prices.length ? Math.min(...prices) : null;
|
|
527
|
+
}, [variations]);
|
|
528
|
+
|
|
529
|
+
/** Default = the variant preselected on the product page; exactly one, via radio. */
|
|
530
|
+
const isDefault = (v) => {
|
|
531
|
+
const defs = product.default_attributes || [];
|
|
532
|
+
const combo = v.attributes || [];
|
|
533
|
+
if (!combo.length || defs.length !== combo.length) return false;
|
|
534
|
+
return combo.every((a) =>
|
|
535
|
+
defs.some(
|
|
536
|
+
(d) => (d.attribute_id || d.name) === (a.attribute_id || a.name) && d.option === a.option
|
|
537
|
+
)
|
|
538
|
+
);
|
|
539
|
+
};
|
|
540
|
+
const makeDefault = (v) =>
|
|
541
|
+
up({
|
|
542
|
+
default_attributes: (v.attributes || []).map((a) => ({
|
|
543
|
+
attribute_id: a.attribute_id || "",
|
|
544
|
+
name: a.name,
|
|
545
|
+
option: a.option,
|
|
546
|
+
})),
|
|
547
|
+
});
|
|
548
|
+
|
|
549
|
+
const setVariation = (index, nv) =>
|
|
550
|
+
setVariations(variations.map((x, j) => (j === index ? nv : x)));
|
|
551
|
+
|
|
552
|
+
const toggleSelect = (index, on) =>
|
|
553
|
+
setSelected((s) => (on ? [...s, index] : s.filter((i) => i !== index)));
|
|
554
|
+
const allSelected = variations.length > 0 && selected.length === variations.length;
|
|
555
|
+
|
|
556
|
+
const applyBulkPrice = () => {
|
|
557
|
+
setVariations(variations.map((v, i) => (selected.includes(i) ? { ...v, regular_price: bulkPrice } : v)));
|
|
558
|
+
toast.success(`Price set on ${selected.length} variant(s).`);
|
|
559
|
+
};
|
|
560
|
+
const applyBulkStock = () => {
|
|
561
|
+
const qty = Number(bulkStock);
|
|
562
|
+
setVariations(
|
|
563
|
+
variations.map((v, i) =>
|
|
564
|
+
selected.includes(i) ? { ...v, manage_stock: "yes", stock_quantity: qty } : v
|
|
565
|
+
)
|
|
566
|
+
);
|
|
567
|
+
toast.success(`Stock set on ${selected.length} variant(s).`);
|
|
568
|
+
};
|
|
569
|
+
/** Push one variant's price, inventory and shipping onto the selected rows. */
|
|
532
570
|
const applyCopy = () => {
|
|
533
|
-
const
|
|
534
|
-
if (!
|
|
535
|
-
const fields = COPY_GROUPS.
|
|
571
|
+
const src = variations[Number(copyFrom)];
|
|
572
|
+
if (!src) return;
|
|
573
|
+
const fields = COPY_GROUPS.flatMap((g) => g.fields);
|
|
536
574
|
setVariations(
|
|
537
575
|
variations.map((v, i) => {
|
|
538
|
-
if (i === Number(copyFrom)) return v;
|
|
576
|
+
if (!selected.includes(i) || i === Number(copyFrom)) return v;
|
|
539
577
|
const patch = {};
|
|
540
|
-
for (const f of fields) {
|
|
541
|
-
// dimensions is an object — copy it, don't share the reference
|
|
542
|
-
patch[f] = f === "dimensions" ? { ...(source.dimensions || {}) } : source[f];
|
|
543
|
-
}
|
|
578
|
+
for (const f of fields) patch[f] = f === "dimensions" ? { ...(src.dimensions || {}) } : src[f];
|
|
544
579
|
return { ...v, ...patch };
|
|
545
580
|
})
|
|
546
581
|
);
|
|
547
|
-
|
|
548
|
-
toast.success(`Copied ${what.join(" and ")} to ${variations.length - 1} variant(s).`);
|
|
582
|
+
toast.success(`Copied to ${selected.filter((i) => i !== Number(copyFrom)).length} variant(s).`);
|
|
549
583
|
};
|
|
550
584
|
|
|
551
|
-
const
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
});
|
|
585
|
+
const switchToSingle = () => {
|
|
586
|
+
if (hasAttributes && variations.length > 0) setConfirmSingle(true);
|
|
587
|
+
else if (hasAttributes) {
|
|
588
|
+
up({ attributes: [], default_attributes: [] });
|
|
589
|
+
setMode("single");
|
|
590
|
+
} else setMode("single");
|
|
558
591
|
};
|
|
559
592
|
|
|
593
|
+
const modeBtn = (active) =>
|
|
594
|
+
`rounded-md px-4 py-1.5 text-sm transition-colors ${
|
|
595
|
+
active ? "bg-background font-semibold shadow-sm" : "text-muted-foreground hover:text-foreground"
|
|
596
|
+
}`;
|
|
597
|
+
|
|
560
598
|
return (
|
|
561
599
|
<div className="space-y-4">
|
|
562
|
-
{/*
|
|
563
|
-
<div
|
|
564
|
-
<div className="
|
|
565
|
-
<
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
))}
|
|
572
|
-
</SelectContent>
|
|
573
|
-
</Select>
|
|
574
|
-
</div>
|
|
575
|
-
<div className="space-y-1.5">
|
|
576
|
-
<Label>Tax group</Label>
|
|
577
|
-
<Select value={product.tax_group || "Products"} onValueChange={(v) => up({ tax_group: v })}>
|
|
578
|
-
<SelectTrigger><SelectValue /></SelectTrigger>
|
|
579
|
-
<SelectContent>
|
|
580
|
-
{taxGroups.map((g) => (
|
|
581
|
-
<SelectItem key={g} value={g}>{g}</SelectItem>
|
|
582
|
-
))}
|
|
583
|
-
</SelectContent>
|
|
584
|
-
</Select>
|
|
600
|
+
{/* The one question a merchant actually understands, asked explicitly. */}
|
|
601
|
+
<div>
|
|
602
|
+
<div className="inline-flex rounded-lg border bg-muted/60 p-0.5">
|
|
603
|
+
<button type="button" className={modeBtn(mode === "single")} onClick={switchToSingle}>
|
|
604
|
+
Single product
|
|
605
|
+
</button>
|
|
606
|
+
<button type="button" className={modeBtn(mode === "variants")} onClick={() => setMode("variants")}>
|
|
607
|
+
Has variants
|
|
608
|
+
</button>
|
|
585
609
|
</div>
|
|
586
|
-
<
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
Limit purchases to 1 item per order
|
|
592
|
-
</label>
|
|
610
|
+
<p className="mt-1.5 text-xs text-muted-foreground">
|
|
611
|
+
{mode === "single"
|
|
612
|
+
? "One price, one stock count. Switch if it comes in sizes, packs or colors."
|
|
613
|
+
: "Each combination of option values becomes a variant with its own price, stock and SKU."}
|
|
614
|
+
</p>
|
|
593
615
|
</div>
|
|
594
616
|
|
|
595
|
-
|
|
596
|
-
<
|
|
597
|
-
|
|
617
|
+
{mode === "single" ? (
|
|
618
|
+
<SingleProductFields product={product} up={up} units={units} />
|
|
619
|
+
) : (
|
|
620
|
+
<>
|
|
621
|
+
<AttributesSection product={product} up={up} refs={refs} variations={variations} />
|
|
622
|
+
|
|
623
|
+
{hasAttributes && (
|
|
624
|
+
<div className="space-y-2 border-t pt-4">
|
|
625
|
+
<div className="flex flex-wrap items-center justify-between gap-2">
|
|
626
|
+
<span className="text-sm font-semibold">
|
|
627
|
+
{variations.length} variant{variations.length === 1 ? "" : "s"}
|
|
628
|
+
</span>
|
|
629
|
+
<span className="text-xs text-muted-foreground">
|
|
630
|
+
Store price:{" "}
|
|
631
|
+
<span className="font-semibold text-foreground">
|
|
632
|
+
{fromPrice != null ? `from ${money.format(fromPrice)}` : "—"}
|
|
633
|
+
</span>{" "}
|
|
634
|
+
(lowest enabled variant)
|
|
635
|
+
</span>
|
|
636
|
+
</div>
|
|
598
637
|
|
|
599
|
-
|
|
600
|
-
|
|
638
|
+
{selected.length > 0 && (
|
|
639
|
+
<div className="flex flex-wrap items-center gap-2 rounded-md border bg-muted/40 p-2 text-sm">
|
|
640
|
+
<span className="font-medium">{selected.length} selected</span>
|
|
641
|
+
<MoneyInput className="w-32" value={bulkPrice} onChange={setBulkPrice} />
|
|
642
|
+
<Button type="button" variant="outline" size="sm" disabled={bulkPrice == null} onClick={applyBulkPrice}>
|
|
643
|
+
Set price
|
|
644
|
+
</Button>
|
|
645
|
+
<Input
|
|
646
|
+
type="number"
|
|
647
|
+
className="h-9 w-24"
|
|
648
|
+
placeholder="Qty"
|
|
649
|
+
value={bulkStock}
|
|
650
|
+
onChange={(e) => setBulkStock(e.target.value)}
|
|
651
|
+
/>
|
|
652
|
+
<Button type="button" variant="outline" size="sm" disabled={bulkStock === ""} onClick={applyBulkStock}>
|
|
653
|
+
Set stock
|
|
654
|
+
</Button>
|
|
655
|
+
<div className="flex items-center gap-1.5">
|
|
656
|
+
<Copy className="h-4 w-4 shrink-0 text-muted-foreground" />
|
|
657
|
+
<Select value={copyFrom} onValueChange={setCopyFrom}>
|
|
658
|
+
<SelectTrigger className="h-9 w-44"><SelectValue /></SelectTrigger>
|
|
659
|
+
<SelectContent>
|
|
660
|
+
{variations.map((v, i) => (
|
|
661
|
+
<SelectItem key={v.id || `new-${i}`} value={String(i)}>
|
|
662
|
+
{variationLabel(v)}
|
|
663
|
+
</SelectItem>
|
|
664
|
+
))}
|
|
665
|
+
</SelectContent>
|
|
666
|
+
</Select>
|
|
667
|
+
<Button type="button" variant="outline" size="sm" onClick={applyCopy}>
|
|
668
|
+
Copy to selected
|
|
669
|
+
</Button>
|
|
670
|
+
</div>
|
|
671
|
+
<Button type="button" variant="ghost" size="sm" className="ml-auto" onClick={() => setSelected([])}>
|
|
672
|
+
Clear
|
|
673
|
+
</Button>
|
|
674
|
+
</div>
|
|
675
|
+
)}
|
|
601
676
|
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
</
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
677
|
+
{variations.length === 0 ? (
|
|
678
|
+
<Alert>
|
|
679
|
+
<TriangleAlert className="h-4 w-4" />
|
|
680
|
+
<AlertTitle>No variants yet — customers can't buy this product</AlertTitle>
|
|
681
|
+
<AlertDescription>
|
|
682
|
+
Pick the values this product comes in for the option above and its variants
|
|
683
|
+
appear here automatically.
|
|
684
|
+
</AlertDescription>
|
|
685
|
+
</Alert>
|
|
686
|
+
) : (
|
|
687
|
+
<div className="overflow-x-auto rounded-md border">
|
|
688
|
+
<table className="w-full text-sm">
|
|
689
|
+
<thead>
|
|
690
|
+
<tr className="border-b bg-muted/40 text-left text-[11px] uppercase tracking-wide text-muted-foreground">
|
|
691
|
+
<th className="w-9 p-2">
|
|
692
|
+
<Checkbox
|
|
693
|
+
checked={allSelected}
|
|
694
|
+
onCheckedChange={(v) => setSelected(v ? variations.map((_, i) => i) : [])}
|
|
695
|
+
aria-label="Select all variants"
|
|
696
|
+
/>
|
|
697
|
+
</th>
|
|
698
|
+
<th className="p-2 font-semibold">Variant</th>
|
|
699
|
+
<th className="w-36 p-2 font-semibold">Price</th>
|
|
700
|
+
<th className="w-24 p-2 font-semibold">Stock</th>
|
|
701
|
+
<th className="w-48 p-2 font-semibold">SKU</th>
|
|
702
|
+
<th className="w-16 p-2 text-center font-semibold">Default</th>
|
|
703
|
+
<th className="w-20 p-2 text-center font-semibold">Enabled</th>
|
|
704
|
+
</tr>
|
|
705
|
+
</thead>
|
|
706
|
+
<tbody>
|
|
707
|
+
{variations.map((v, i) => (
|
|
708
|
+
<React.Fragment key={v.id || comboKey(v) || `new-${i}`}>
|
|
709
|
+
<tr className="border-b last:border-b-0">
|
|
710
|
+
<td className="p-2">
|
|
711
|
+
<Checkbox
|
|
712
|
+
checked={selected.includes(i)}
|
|
713
|
+
onCheckedChange={(on) => toggleSelect(i, Boolean(on))}
|
|
714
|
+
aria-label={`Select ${variationLabel(v)}`}
|
|
715
|
+
/>
|
|
716
|
+
</td>
|
|
717
|
+
<td className="p-2">
|
|
718
|
+
<button
|
|
719
|
+
type="button"
|
|
720
|
+
className="flex items-center gap-1.5 text-left font-medium"
|
|
721
|
+
onClick={() => setExpanded(expanded === i ? null : i)}
|
|
722
|
+
title="More fields (image, sale, shipping)"
|
|
723
|
+
>
|
|
724
|
+
{expanded === i ? (
|
|
725
|
+
<ChevronDown className="h-4 w-4 shrink-0 text-muted-foreground" />
|
|
726
|
+
) : (
|
|
727
|
+
<ChevronRight className="h-4 w-4 shrink-0 text-muted-foreground" />
|
|
728
|
+
)}
|
|
729
|
+
{variationLabel(v)}
|
|
730
|
+
</button>
|
|
731
|
+
</td>
|
|
732
|
+
<td className="p-2">
|
|
733
|
+
<MoneyInput
|
|
734
|
+
value={v.regular_price}
|
|
735
|
+
onChange={(val) => setVariation(i, { ...v, regular_price: val })}
|
|
736
|
+
/>
|
|
737
|
+
</td>
|
|
738
|
+
<td className="p-2">
|
|
739
|
+
<Input
|
|
740
|
+
type="number"
|
|
741
|
+
placeholder="—"
|
|
742
|
+
title={v.manage_stock === "yes" ? "Tracked quantity" : "Not tracked — type a quantity to track"}
|
|
743
|
+
value={v.manage_stock === "yes" ? v.stock_quantity ?? "" : ""}
|
|
744
|
+
onChange={(e) =>
|
|
745
|
+
setVariation(
|
|
746
|
+
i,
|
|
747
|
+
e.target.value === ""
|
|
748
|
+
? { ...v, manage_stock: "parent", stock_quantity: null }
|
|
749
|
+
: { ...v, manage_stock: "yes", stock_quantity: Number(e.target.value) }
|
|
750
|
+
)
|
|
751
|
+
}
|
|
752
|
+
/>
|
|
753
|
+
</td>
|
|
754
|
+
<td className="p-2">
|
|
755
|
+
<Input
|
|
756
|
+
value={v.sku || ""}
|
|
757
|
+
onChange={(e) => setVariation(i, { ...v, sku: e.target.value })}
|
|
758
|
+
/>
|
|
759
|
+
</td>
|
|
760
|
+
<td className="p-2 text-center">
|
|
761
|
+
<input
|
|
762
|
+
type="radio"
|
|
763
|
+
name="default-variant"
|
|
764
|
+
className="h-4 w-4 accent-foreground"
|
|
765
|
+
checked={isDefault(v)}
|
|
766
|
+
onChange={() => makeDefault(v)}
|
|
767
|
+
aria-label={`Make ${variationLabel(v)} the default`}
|
|
768
|
+
/>
|
|
769
|
+
</td>
|
|
770
|
+
<td className="p-2 text-center">
|
|
771
|
+
<Checkbox
|
|
772
|
+
checked={enabled(v)}
|
|
773
|
+
onCheckedChange={(on) =>
|
|
774
|
+
setVariation(i, { ...v, status: on ? "publish" : "private" })
|
|
775
|
+
}
|
|
776
|
+
aria-label={`Enable ${variationLabel(v)}`}
|
|
777
|
+
/>
|
|
778
|
+
</td>
|
|
779
|
+
</tr>
|
|
780
|
+
{expanded === i && (
|
|
781
|
+
<tr className="border-b bg-muted/20 last:border-b-0">
|
|
782
|
+
<td />
|
|
783
|
+
<td colSpan={6} className="p-3">
|
|
784
|
+
<VariantDetails
|
|
785
|
+
variation={v}
|
|
786
|
+
set={(patch) => setVariation(i, { ...v, ...patch })}
|
|
787
|
+
units={units}
|
|
788
|
+
virtual={product.virtual}
|
|
789
|
+
/>
|
|
790
|
+
</td>
|
|
791
|
+
</tr>
|
|
792
|
+
)}
|
|
793
|
+
</React.Fragment>
|
|
794
|
+
))}
|
|
795
|
+
</tbody>
|
|
796
|
+
</table>
|
|
630
797
|
</div>
|
|
631
798
|
)}
|
|
632
799
|
</div>
|
|
800
|
+
)}
|
|
801
|
+
</>
|
|
802
|
+
)}
|
|
633
803
|
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
<label key={g.key} className="flex items-center gap-1.5">
|
|
650
|
-
<Checkbox
|
|
651
|
-
checked={copyGroups.includes(g.key)}
|
|
652
|
-
onCheckedChange={(v) =>
|
|
653
|
-
setCopyGroups((prev) => (v ? [...prev, g.key] : prev.filter((k) => k !== g.key)))
|
|
654
|
-
}
|
|
655
|
-
/>
|
|
656
|
-
{g.label}
|
|
657
|
-
</label>
|
|
658
|
-
))}
|
|
659
|
-
<Button type="button" variant="outline" size="sm" disabled={!copyGroups.length} onClick={applyCopy}>
|
|
660
|
-
Apply to the other {variations.length - 1}
|
|
661
|
-
</Button>
|
|
662
|
-
</div>
|
|
663
|
-
)}
|
|
664
|
-
|
|
665
|
-
{variations.length === 0 && (
|
|
666
|
-
<Alert>
|
|
667
|
-
<TriangleAlert className="h-4 w-4" />
|
|
668
|
-
<AlertTitle>No variants yet — customers can't buy this product</AlertTitle>
|
|
669
|
-
<AlertDescription>
|
|
670
|
-
Give an attribute above the values this product comes in and its variants appear
|
|
671
|
-
here automatically.
|
|
672
|
-
</AlertDescription>
|
|
673
|
-
</Alert>
|
|
674
|
-
)}
|
|
675
|
-
|
|
676
|
-
{variations.map((v, i) => (
|
|
677
|
-
<VariantRow
|
|
678
|
-
key={v.id || comboKey(v) || `new-${i}`}
|
|
679
|
-
variation={v}
|
|
680
|
-
refs={refs}
|
|
681
|
-
money={money}
|
|
682
|
-
units={units}
|
|
683
|
-
virtual={product.virtual}
|
|
684
|
-
onChange={(nv) => setVariations(variations.map((x, j) => (j === i ? nv : x)))}
|
|
685
|
-
/>
|
|
686
|
-
))}
|
|
687
|
-
|
|
688
|
-
<p className="text-xs text-muted-foreground">
|
|
689
|
-
Variants follow the attributes above — adding a value adds its variants (copied from the
|
|
690
|
-
first), removing one takes them away. The product's own price is the cheapest variant,
|
|
691
|
-
derived on save.
|
|
692
|
-
</p>
|
|
693
|
-
</>
|
|
694
|
-
)}
|
|
695
|
-
</div>
|
|
804
|
+
<TaxRules product={product} up={up} taxGroups={taxGroups} />
|
|
805
|
+
|
|
806
|
+
<ConfirmDialog
|
|
807
|
+
open={confirmSingle}
|
|
808
|
+
onOpenChange={setConfirmSingle}
|
|
809
|
+
title="Switch to a single product?"
|
|
810
|
+
description={`This removes ${variations.length} variant(s) and their stock and prices. The product keeps one price and one stock count.`}
|
|
811
|
+
confirmLabel="Remove variants"
|
|
812
|
+
onConfirm={() => {
|
|
813
|
+
up({ attributes: [], default_attributes: [] });
|
|
814
|
+
setVariations([]);
|
|
815
|
+
setMode("single");
|
|
816
|
+
setConfirmSingle(false);
|
|
817
|
+
}}
|
|
818
|
+
/>
|
|
696
819
|
</div>
|
|
697
820
|
);
|
|
698
821
|
}
|