@base44/app-plugin-commerce 0.1.3 → 0.1.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -11
- package/base44/agents/commerce/StoreAdmin.jsonc +7 -7
- package/base44/entities/commerce.Order.jsonc +1 -1
- package/base44/entities/commerce.PaymentGateway.jsonc +3 -3
- package/base44/entities/commerce.Product.jsonc +9 -45
- package/base44/entities/commerce.ProductAttribute.jsonc +7 -19
- package/base44/entities/commerce.ProductAttributeTerm.jsonc +4 -10
- package/base44/entities/commerce.ProductTag.jsonc +1 -8
- package/base44/entities/commerce.ProductVariation.jsonc +1 -1
- package/base44/entities/commerce.StoreSettings.jsonc +1 -1
- package/base44/functions/commerce/admin-products/entry.ts +73 -121
- package/base44/functions/commerce/admin-refunds/entry.ts +1 -1
- package/base44/functions/commerce/admin-reports/entry.ts +1 -1
- package/base44/functions/commerce/admin-tools/entry.ts +4 -3
- package/base44/functions/commerce/payments/entry.ts +0 -2
- package/base44/functions/commerce/seed-store/defaults.ts +13 -39
- package/base44/functions/commerce/seed-store/entry.ts +50 -166
- package/base44/functions/commerce/seed-store/sample-data.ts +28 -66
- package/base44/functions/commerce/seed-store/seed-catalog.ts +717 -0
- package/base44/functions/commerce/storefront-cart/entry.ts +0 -7
- package/base44/functions/commerce/storefront-catalog/entry.ts +12 -21
- package/base44/functions/commerce/storefront-checkout/entry.ts +5 -12
- package/base44/shared/commerce/catalog.ts +134 -0
- package/base44/shared/commerce/coupons.ts +4 -7
- package/base44/shared/commerce/email-templates.ts +15 -13
- package/base44/shared/commerce/emails.ts +4 -3
- package/base44/shared/commerce/payments.ts +8 -20
- package/base44/shared/commerce/products.ts +24 -0
- package/base44/shared/commerce/settings.ts +2 -3
- package/base44/shared/commerce/stock.ts +2 -4
- package/base44/shared/commerce/totals.ts +37 -42
- package/package.json +1 -1
- package/scripts/install.js +23 -7
- package/skills/commerce/SKILL.md +9 -7
- package/skills/commerce/docs/api-admin.md +20 -14
- package/skills/commerce/docs/api-storefront.md +39 -35
- package/skills/commerce/installation-guidelines.md +8 -7
- package/skills/commerce/post-installation.md +205 -84
- package/skills/commerce/references/admin-product-form.md +56 -0
- package/skills/commerce/references/emails.md +4 -3
- package/skills/commerce/references/guest-access-security.md +2 -2
- package/skills/commerce/references/limits-and-performance.md +1 -1
- package/skills/commerce/references/online-payments.md +8 -8
- package/skills/commerce/references/product-render.md +25 -23
- package/skills/commerce/references/reviews.md +20 -0
- package/skills/commerce/references/storefront-product-page.md +9 -9
- package/skills/commerce/references/webhooks.md +3 -1
- package/src/commerce/admin/README.md +4 -4
- package/src/commerce/admin/context/BasePathContext.jsx +5 -5
- package/src/commerce/admin/context/SettingsContext.jsx +4 -4
- package/src/commerce/admin/index.jsx +3 -3
- package/src/commerce/admin/layout/Sidebar.jsx +1 -9
- package/src/commerce/admin/lib/constants.js +0 -7
- package/src/commerce/admin/lib/paths.js +6 -6
- package/src/commerce/admin/lib/product-utils.js +18 -15
- package/src/commerce/admin/pages/orders/components/AddProductDialog.jsx +8 -8
- package/src/commerce/admin/pages/products/ProductEditor.jsx +10 -12
- package/src/commerce/admin/pages/products/ProductsList.jsx +3 -15
- package/src/commerce/admin/pages/products/components/AttributesSection.jsx +426 -0
- package/src/commerce/admin/pages/products/components/ProductDataPanel.jsx +33 -69
- package/src/commerce/admin/pages/products/components/TaxonomyPanel.jsx +1 -1
- package/src/commerce/admin/pages/products/components/tabs/LinkedTab.jsx +1 -14
- package/src/commerce/admin/pages/products/components/tabs/ModifiersTab.jsx +19 -0
- package/src/commerce/admin/pages/products/components/tabs/PriceInventoryTab.jsx +728 -0
- package/src/commerce/admin/pages/reports/Reports.jsx +3 -3
- package/src/commerce/admin/pages/settings/EmailsSettings.jsx +12 -14
- package/src/commerce/admin/pages/settings/GeneralSettings.jsx +6 -116
- package/src/commerce/admin/pages/settings/PaymentsSettings.jsx +101 -62
- package/src/commerce/admin/pages/settings/SettingsLayout.jsx +2 -6
- package/src/commerce/admin/pages/settings/TaxSettings.jsx +0 -1
- package/src/commerce/admin/pages/status/WebhookEditor.jsx +3 -3
- package/src/commerce/admin/pages/status/Webhooks.jsx +2 -2
- package/src/commerce/admin/routes.jsx +10 -19
- package/src/commerce/utils/index.js +2 -2
- package/src/commerce/utils/shipping-promos.js +9 -6
- package/src/commerce/utils/variants.js +21 -21
- package/src/commerce/admin/pages/products/AttributeTerms.jsx +0 -180
- package/src/commerce/admin/pages/products/Attributes.jsx +0 -183
- package/src/commerce/admin/pages/products/Tags.jsx +0 -150
- package/src/commerce/admin/pages/products/components/tabs/AdvancedTab.jsx +0 -48
- package/src/commerce/admin/pages/products/components/tabs/AttributesTab.jsx +0 -208
- package/src/commerce/admin/pages/products/components/tabs/ExternalTab.jsx +0 -41
- package/src/commerce/admin/pages/products/components/tabs/GeneralTab.jsx +0 -103
- package/src/commerce/admin/pages/products/components/tabs/InventoryTab.jsx +0 -93
- package/src/commerce/admin/pages/products/components/tabs/ShippingTab.jsx +0 -86
- package/src/commerce/admin/pages/products/components/tabs/VariationsTab.jsx +0 -377
|
@@ -0,0 +1,728 @@
|
|
|
1
|
+
import React, { useEffect, useMemo, useRef, useState } from "react";
|
|
2
|
+
import { Button } from "@/components/ui/button";
|
|
3
|
+
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
|
4
|
+
import { Input } from "@/components/ui/input";
|
|
5
|
+
import { Label } from "@/components/ui/label";
|
|
6
|
+
import { Switch } from "@/components/ui/switch";
|
|
7
|
+
import { Checkbox } from "@/components/ui/checkbox";
|
|
8
|
+
import { Card } from "@/components/ui/card";
|
|
9
|
+
import {
|
|
10
|
+
Select,
|
|
11
|
+
SelectContent,
|
|
12
|
+
SelectItem,
|
|
13
|
+
SelectTrigger,
|
|
14
|
+
SelectValue,
|
|
15
|
+
} from "@/components/ui/select";
|
|
16
|
+
import { ChevronDown, ChevronRight, Copy, Trash2, TriangleAlert } from "lucide-react";
|
|
17
|
+
import { toast } from "sonner";
|
|
18
|
+
|
|
19
|
+
import MoneyInput from "../../../../components/MoneyInput";
|
|
20
|
+
import MediaUploader from "../../../../components/MediaUploader";
|
|
21
|
+
import useMoney from "../../../../hooks/useMoney";
|
|
22
|
+
import { useSettings } from "../../../../context/SettingsContext";
|
|
23
|
+
import AttributesSection from "../AttributesSection";
|
|
24
|
+
import { comboKey, generateVariationCombos, variationLabel } from "../../../../lib/product-utils";
|
|
25
|
+
import { BACKORDER_OPTIONS, STOCK_STATUSES, TAX_STATUSES } from "../../../../lib/constants";
|
|
26
|
+
|
|
27
|
+
const NONE = "__none__";
|
|
28
|
+
const ANY = "__any__";
|
|
29
|
+
const num = (v) => (v === "" ? null : Number(v));
|
|
30
|
+
const toDateInput = (iso) => (iso ? String(iso).slice(0, 10) : "");
|
|
31
|
+
const fromDateInput = (v) => (v ? new Date(`${v}T00:00:00Z`).toISOString() : null);
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* What "copy from" moves between variants, by group.
|
|
35
|
+
*
|
|
36
|
+
* `sku` is deliberately absent: it must be unique across products *and*
|
|
37
|
+
* variations, so copying it would fail the save with `duplicate_sku`. `image`
|
|
38
|
+
* and the attribute combination are what makes a variant itself, so they stay.
|
|
39
|
+
*/
|
|
40
|
+
const COPY_GROUPS = [
|
|
41
|
+
{ key: "price", label: "Price", fields: ["regular_price", "sale_price", "date_on_sale_from", "date_on_sale_to"] },
|
|
42
|
+
{ key: "inventory", label: "Inventory", fields: ["manage_stock", "stock_quantity", "backorders", "stock_status"] },
|
|
43
|
+
{
|
|
44
|
+
key: "shipping",
|
|
45
|
+
label: "Shipping",
|
|
46
|
+
fields: ["weight", "dimensions", "shipping_class_id", "virtual", "downloadable"],
|
|
47
|
+
},
|
|
48
|
+
];
|
|
49
|
+
|
|
50
|
+
const NEW_VARIATION = {
|
|
51
|
+
status: "publish",
|
|
52
|
+
sku: "",
|
|
53
|
+
regular_price: null,
|
|
54
|
+
sale_price: null,
|
|
55
|
+
date_on_sale_from: null,
|
|
56
|
+
date_on_sale_to: null,
|
|
57
|
+
virtual: false,
|
|
58
|
+
downloadable: false,
|
|
59
|
+
downloads: [],
|
|
60
|
+
download_limit: -1,
|
|
61
|
+
download_expiry: -1,
|
|
62
|
+
tax_status: "parent",
|
|
63
|
+
tax_class: "",
|
|
64
|
+
manage_stock: "parent",
|
|
65
|
+
stock_quantity: null,
|
|
66
|
+
stock_status: "instock",
|
|
67
|
+
backorders: "no",
|
|
68
|
+
weight: null,
|
|
69
|
+
dimensions: { length: null, width: null, height: null },
|
|
70
|
+
shipping_class_id: "",
|
|
71
|
+
image: null,
|
|
72
|
+
menu_order: 0,
|
|
73
|
+
description: "",
|
|
74
|
+
meta_data: [],
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
/** Price, sale price and the sale window — identical for the product and a variant. */
|
|
78
|
+
function PriceFields({ values, set }) {
|
|
79
|
+
const scheduled = Boolean(values.date_on_sale_from || values.date_on_sale_to);
|
|
80
|
+
return (
|
|
81
|
+
<>
|
|
82
|
+
<div className="space-y-1.5">
|
|
83
|
+
<Label className="text-xs">Regular price</Label>
|
|
84
|
+
<MoneyInput value={values.regular_price} onChange={(v) => set({ regular_price: v })} />
|
|
85
|
+
</div>
|
|
86
|
+
<div className="space-y-1.5">
|
|
87
|
+
<Label className="text-xs">Sale price</Label>
|
|
88
|
+
<MoneyInput value={values.sale_price} onChange={(v) => set({ sale_price: v })} />
|
|
89
|
+
</div>
|
|
90
|
+
<div className="sm:col-span-2">
|
|
91
|
+
<label className="flex items-center gap-2 text-sm">
|
|
92
|
+
<Switch
|
|
93
|
+
checked={scheduled}
|
|
94
|
+
onCheckedChange={(v) =>
|
|
95
|
+
set(
|
|
96
|
+
v
|
|
97
|
+
? { date_on_sale_from: new Date().toISOString() }
|
|
98
|
+
: { date_on_sale_from: null, date_on_sale_to: null }
|
|
99
|
+
)
|
|
100
|
+
}
|
|
101
|
+
/>
|
|
102
|
+
Schedule sale
|
|
103
|
+
</label>
|
|
104
|
+
</div>
|
|
105
|
+
{scheduled && (
|
|
106
|
+
<>
|
|
107
|
+
<div className="space-y-1.5">
|
|
108
|
+
<Label className="text-xs">Sale start</Label>
|
|
109
|
+
<Input
|
|
110
|
+
type="date"
|
|
111
|
+
value={toDateInput(values.date_on_sale_from)}
|
|
112
|
+
onChange={(e) => set({ date_on_sale_from: fromDateInput(e.target.value) })}
|
|
113
|
+
/>
|
|
114
|
+
</div>
|
|
115
|
+
<div className="space-y-1.5">
|
|
116
|
+
<Label className="text-xs">Sale end</Label>
|
|
117
|
+
<Input
|
|
118
|
+
type="date"
|
|
119
|
+
value={toDateInput(values.date_on_sale_to)}
|
|
120
|
+
onChange={(e) => set({ date_on_sale_to: fromDateInput(e.target.value) })}
|
|
121
|
+
/>
|
|
122
|
+
</div>
|
|
123
|
+
</>
|
|
124
|
+
)}
|
|
125
|
+
</>
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Collapsible shell shared by the default row and every variant row. */
|
|
130
|
+
function Row({ title, summary, right, onRemove, children }) {
|
|
131
|
+
const [open, setOpen] = useState(false);
|
|
132
|
+
return (
|
|
133
|
+
<Card className="overflow-hidden">
|
|
134
|
+
<div className="flex items-center justify-between gap-2 px-3 py-2">
|
|
135
|
+
<button
|
|
136
|
+
type="button"
|
|
137
|
+
className="flex min-w-0 flex-1 items-center gap-2 text-left"
|
|
138
|
+
onClick={() => setOpen((o) => !o)}
|
|
139
|
+
>
|
|
140
|
+
{open ? <ChevronDown className="h-4 w-4 shrink-0" /> : <ChevronRight className="h-4 w-4 shrink-0" />}
|
|
141
|
+
<span className="truncate font-medium">{title}</span>
|
|
142
|
+
<span className="truncate text-xs text-muted-foreground">{summary}</span>
|
|
143
|
+
</button>
|
|
144
|
+
{right}
|
|
145
|
+
{onRemove && (
|
|
146
|
+
<Button type="button" variant="ghost" size="icon" onClick={onRemove}>
|
|
147
|
+
<Trash2 className="h-4 w-4 text-muted-foreground" />
|
|
148
|
+
</Button>
|
|
149
|
+
)}
|
|
150
|
+
</div>
|
|
151
|
+
{open && <div className="border-t p-3">{children}</div>}
|
|
152
|
+
</Card>
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const Section = ({ label, children, className = "" }) => (
|
|
157
|
+
<div className={`space-y-2 sm:col-span-2 ${className}`}>
|
|
158
|
+
<p className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">{label}</p>
|
|
159
|
+
<div className="grid gap-3 sm:grid-cols-2">{children}</div>
|
|
160
|
+
</div>
|
|
161
|
+
);
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Weight, dimensions and shipping class live per purchasable thing, not per
|
|
165
|
+
* product — a large mug weighs more than a small one.
|
|
166
|
+
*/
|
|
167
|
+
function ShippingFields({ values, set, units, classes, inheritLabel }) {
|
|
168
|
+
const dims = values.dimensions || {};
|
|
169
|
+
return (
|
|
170
|
+
<>
|
|
171
|
+
<div className="space-y-1.5">
|
|
172
|
+
<Label className="text-xs">Weight ({units.weightUnit})</Label>
|
|
173
|
+
<Input
|
|
174
|
+
type="number"
|
|
175
|
+
step="any"
|
|
176
|
+
value={values.weight ?? ""}
|
|
177
|
+
onChange={(e) => set({ weight: num(e.target.value) })}
|
|
178
|
+
/>
|
|
179
|
+
</div>
|
|
180
|
+
<div className="space-y-1.5">
|
|
181
|
+
<Label className="text-xs">Dimensions L×W×H ({units.dimensionUnit})</Label>
|
|
182
|
+
<div className="grid grid-cols-3 gap-1">
|
|
183
|
+
{["length", "width", "height"].map((k) => (
|
|
184
|
+
<Input
|
|
185
|
+
key={k}
|
|
186
|
+
type="number"
|
|
187
|
+
step="any"
|
|
188
|
+
placeholder={k[0].toUpperCase()}
|
|
189
|
+
value={dims[k] ?? ""}
|
|
190
|
+
onChange={(e) => set({ dimensions: { ...dims, [k]: num(e.target.value) } })}
|
|
191
|
+
/>
|
|
192
|
+
))}
|
|
193
|
+
</div>
|
|
194
|
+
</div>
|
|
195
|
+
<div className="space-y-1.5">
|
|
196
|
+
<Label className="text-xs">Shipping class</Label>
|
|
197
|
+
<Select
|
|
198
|
+
value={values.shipping_class_id || NONE}
|
|
199
|
+
onValueChange={(v) => set({ shipping_class_id: v === NONE ? "" : v })}
|
|
200
|
+
>
|
|
201
|
+
<SelectTrigger><SelectValue /></SelectTrigger>
|
|
202
|
+
<SelectContent>
|
|
203
|
+
<SelectItem value={NONE}>{inheritLabel}</SelectItem>
|
|
204
|
+
{classes.map((c) => (
|
|
205
|
+
<SelectItem key={c.id} value={c.id}>{c.name}</SelectItem>
|
|
206
|
+
))}
|
|
207
|
+
</SelectContent>
|
|
208
|
+
</Select>
|
|
209
|
+
</div>
|
|
210
|
+
</>
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** Downloadable-file rows, shared by the product and a variant. */
|
|
215
|
+
function DownloadsFields({ values, set }) {
|
|
216
|
+
const downloads = values.downloads || [];
|
|
217
|
+
return (
|
|
218
|
+
<div className="space-y-2 sm:col-span-2">
|
|
219
|
+
{downloads.map((d, i) => (
|
|
220
|
+
<div key={i} className="flex items-center gap-2">
|
|
221
|
+
<Input
|
|
222
|
+
className="w-48"
|
|
223
|
+
placeholder="Name"
|
|
224
|
+
value={d.name || ""}
|
|
225
|
+
onChange={(e) =>
|
|
226
|
+
set({ downloads: downloads.map((x, j) => (j === i ? { ...x, name: e.target.value } : x)) })
|
|
227
|
+
}
|
|
228
|
+
/>
|
|
229
|
+
<Input
|
|
230
|
+
className="flex-1"
|
|
231
|
+
placeholder="File URL"
|
|
232
|
+
value={d.file_url || ""}
|
|
233
|
+
onChange={(e) =>
|
|
234
|
+
set({ downloads: downloads.map((x, j) => (j === i ? { ...x, file_url: e.target.value } : x)) })
|
|
235
|
+
}
|
|
236
|
+
/>
|
|
237
|
+
<Button
|
|
238
|
+
type="button"
|
|
239
|
+
variant="ghost"
|
|
240
|
+
size="icon"
|
|
241
|
+
onClick={() => set({ downloads: downloads.filter((_, j) => j !== i) })}
|
|
242
|
+
>
|
|
243
|
+
<Trash2 className="h-4 w-4" />
|
|
244
|
+
</Button>
|
|
245
|
+
</div>
|
|
246
|
+
))}
|
|
247
|
+
<Button
|
|
248
|
+
type="button"
|
|
249
|
+
variant="outline"
|
|
250
|
+
size="sm"
|
|
251
|
+
onClick={() => set({ downloads: [...downloads, { name: "", file_url: "" }] })}
|
|
252
|
+
>
|
|
253
|
+
Add file
|
|
254
|
+
</Button>
|
|
255
|
+
</div>
|
|
256
|
+
);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* The product itself — what a product with no attributes is sold as, and the
|
|
261
|
+
* values its variants inherit when it gains attributes. The fields are the
|
|
262
|
+
* product's own, so `manage_stock` is the boolean the entity uses here, not a
|
|
263
|
+
* variant's tri-state.
|
|
264
|
+
*/
|
|
265
|
+
function DefaultRow({ product, up, money, units, refs }) {
|
|
266
|
+
const summary = [
|
|
267
|
+
product.price != null || product.regular_price != null
|
|
268
|
+
? money.format(product.price ?? product.regular_price)
|
|
269
|
+
: "no price",
|
|
270
|
+
product.manage_stock ? `${product.stock_quantity ?? 0} in stock` : null,
|
|
271
|
+
]
|
|
272
|
+
.filter(Boolean)
|
|
273
|
+
.join(" · ");
|
|
274
|
+
|
|
275
|
+
return (
|
|
276
|
+
<Row title="Base price" summary={summary}>
|
|
277
|
+
<div className="grid gap-3 sm:grid-cols-2">
|
|
278
|
+
<Section label="Price">
|
|
279
|
+
<PriceFields values={product} set={up} />
|
|
280
|
+
</Section>
|
|
281
|
+
|
|
282
|
+
<Section label="Inventory">
|
|
283
|
+
<div className="space-y-1.5">
|
|
284
|
+
<Label className="text-xs">SKU</Label>
|
|
285
|
+
<Input
|
|
286
|
+
placeholder="Unique stock keeping unit"
|
|
287
|
+
value={product.sku || ""}
|
|
288
|
+
onChange={(e) => up({ sku: e.target.value })}
|
|
289
|
+
/>
|
|
290
|
+
</div>
|
|
291
|
+
<div />
|
|
292
|
+
<div className="sm:col-span-2">
|
|
293
|
+
<label className="flex items-center gap-2 text-sm">
|
|
294
|
+
<Switch
|
|
295
|
+
checked={product.manage_stock}
|
|
296
|
+
onCheckedChange={(v) => up({ manage_stock: Boolean(v) })}
|
|
297
|
+
/>
|
|
298
|
+
Track stock quantity
|
|
299
|
+
</label>
|
|
300
|
+
</div>
|
|
301
|
+
{product.manage_stock ? (
|
|
302
|
+
<>
|
|
303
|
+
<div className="space-y-1.5">
|
|
304
|
+
<Label className="text-xs">Stock quantity</Label>
|
|
305
|
+
<Input
|
|
306
|
+
type="number"
|
|
307
|
+
value={product.stock_quantity ?? ""}
|
|
308
|
+
onChange={(e) => up({ stock_quantity: num(e.target.value) })}
|
|
309
|
+
/>
|
|
310
|
+
</div>
|
|
311
|
+
<div className="space-y-1.5">
|
|
312
|
+
<Label className="text-xs">Backorders</Label>
|
|
313
|
+
<Select value={product.backorders || "no"} onValueChange={(v) => up({ backorders: v })}>
|
|
314
|
+
<SelectTrigger><SelectValue /></SelectTrigger>
|
|
315
|
+
<SelectContent>
|
|
316
|
+
{BACKORDER_OPTIONS.map((o) => (
|
|
317
|
+
<SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>
|
|
318
|
+
))}
|
|
319
|
+
</SelectContent>
|
|
320
|
+
</Select>
|
|
321
|
+
</div>
|
|
322
|
+
<div className="space-y-1.5">
|
|
323
|
+
<Label className="text-xs">Low stock threshold</Label>
|
|
324
|
+
<Input
|
|
325
|
+
type="number"
|
|
326
|
+
placeholder="Store default"
|
|
327
|
+
value={product.low_stock_amount ?? ""}
|
|
328
|
+
onChange={(e) => up({ low_stock_amount: num(e.target.value) })}
|
|
329
|
+
/>
|
|
330
|
+
</div>
|
|
331
|
+
</>
|
|
332
|
+
) : (
|
|
333
|
+
<div className="space-y-1.5">
|
|
334
|
+
<Label className="text-xs">Stock status</Label>
|
|
335
|
+
<Select value={product.stock_status || "instock"} onValueChange={(v) => up({ stock_status: v })}>
|
|
336
|
+
<SelectTrigger><SelectValue /></SelectTrigger>
|
|
337
|
+
<SelectContent>
|
|
338
|
+
{STOCK_STATUSES.map((s) => (
|
|
339
|
+
<SelectItem key={s.value} value={s.value}>{s.label}</SelectItem>
|
|
340
|
+
))}
|
|
341
|
+
</SelectContent>
|
|
342
|
+
</Select>
|
|
343
|
+
</div>
|
|
344
|
+
)}
|
|
345
|
+
</Section>
|
|
346
|
+
|
|
347
|
+
{!product.virtual && (
|
|
348
|
+
<Section label="Shipping">
|
|
349
|
+
<ShippingFields
|
|
350
|
+
values={product}
|
|
351
|
+
set={up}
|
|
352
|
+
units={units}
|
|
353
|
+
classes={refs.shippingClasses || []}
|
|
354
|
+
inheritLabel="No shipping class"
|
|
355
|
+
/>
|
|
356
|
+
</Section>
|
|
357
|
+
)}
|
|
358
|
+
</div>
|
|
359
|
+
</Row>
|
|
360
|
+
);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/** One variant. Removing it is done by removing the attribute value it came from. */
|
|
364
|
+
function VariantRow({ variation, onChange, refs, money, units, virtual }) {
|
|
365
|
+
const set = (patch) => onChange({ ...variation, ...patch });
|
|
366
|
+
const summary = [
|
|
367
|
+
variation.price != null || variation.regular_price != null
|
|
368
|
+
? money.format(variation.price ?? variation.regular_price)
|
|
369
|
+
: "no price",
|
|
370
|
+
variation.manage_stock === "yes" ? `${variation.stock_quantity ?? 0} in stock` : null,
|
|
371
|
+
variation.sku ? `SKU ${variation.sku}` : null,
|
|
372
|
+
]
|
|
373
|
+
.filter(Boolean)
|
|
374
|
+
.join(" · ");
|
|
375
|
+
|
|
376
|
+
return (
|
|
377
|
+
<Row
|
|
378
|
+
title={variationLabel(variation)}
|
|
379
|
+
summary={summary}
|
|
380
|
+
right={
|
|
381
|
+
<label className="flex items-center gap-1.5 text-sm">
|
|
382
|
+
<Checkbox
|
|
383
|
+
checked={variation.status !== "private" && variation.status !== "draft"}
|
|
384
|
+
onCheckedChange={(v) => set({ status: v ? "publish" : "private" })}
|
|
385
|
+
/>
|
|
386
|
+
Enabled
|
|
387
|
+
</label>
|
|
388
|
+
}
|
|
389
|
+
>
|
|
390
|
+
<div className="grid gap-4 sm:grid-cols-[140px_1fr]">
|
|
391
|
+
<MediaUploader value={variation.image || null} onChange={(image) => set({ image })} label="Image" />
|
|
392
|
+
|
|
393
|
+
<div className="grid gap-3 sm:grid-cols-2">
|
|
394
|
+
<Section label="Price">
|
|
395
|
+
<PriceFields values={variation} set={set} />
|
|
396
|
+
</Section>
|
|
397
|
+
|
|
398
|
+
<Section label="Inventory">
|
|
399
|
+
<div className="space-y-1.5">
|
|
400
|
+
<Label className="text-xs">SKU</Label>
|
|
401
|
+
<Input value={variation.sku || ""} onChange={(e) => set({ sku: e.target.value })} />
|
|
402
|
+
</div>
|
|
403
|
+
<div className="space-y-1.5">
|
|
404
|
+
<Label className="text-xs">Stock management</Label>
|
|
405
|
+
<Select value={variation.manage_stock || "parent"} onValueChange={(v) => set({ manage_stock: v })}>
|
|
406
|
+
<SelectTrigger><SelectValue /></SelectTrigger>
|
|
407
|
+
<SelectContent>
|
|
408
|
+
<SelectItem value="parent">Same as product</SelectItem>
|
|
409
|
+
<SelectItem value="yes">Track quantity</SelectItem>
|
|
410
|
+
<SelectItem value="no">Don't track</SelectItem>
|
|
411
|
+
</SelectContent>
|
|
412
|
+
</Select>
|
|
413
|
+
</div>
|
|
414
|
+
{variation.manage_stock === "yes" && (
|
|
415
|
+
<>
|
|
416
|
+
<div className="space-y-1.5">
|
|
417
|
+
<Label className="text-xs">Stock quantity</Label>
|
|
418
|
+
<Input
|
|
419
|
+
type="number"
|
|
420
|
+
value={variation.stock_quantity ?? ""}
|
|
421
|
+
onChange={(e) => set({ stock_quantity: num(e.target.value) })}
|
|
422
|
+
/>
|
|
423
|
+
</div>
|
|
424
|
+
<div className="space-y-1.5">
|
|
425
|
+
<Label className="text-xs">Backorders</Label>
|
|
426
|
+
<Select value={variation.backorders || "no"} onValueChange={(v) => set({ backorders: v })}>
|
|
427
|
+
<SelectTrigger><SelectValue /></SelectTrigger>
|
|
428
|
+
<SelectContent>
|
|
429
|
+
{BACKORDER_OPTIONS.map((o) => (
|
|
430
|
+
<SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>
|
|
431
|
+
))}
|
|
432
|
+
</SelectContent>
|
|
433
|
+
</Select>
|
|
434
|
+
</div>
|
|
435
|
+
</>
|
|
436
|
+
)}
|
|
437
|
+
</Section>
|
|
438
|
+
|
|
439
|
+
{!virtual && (
|
|
440
|
+
<Section label="Shipping">
|
|
441
|
+
<ShippingFields
|
|
442
|
+
values={variation}
|
|
443
|
+
set={set}
|
|
444
|
+
units={units}
|
|
445
|
+
classes={refs.shippingClasses || []}
|
|
446
|
+
inheritLabel="Same as product"
|
|
447
|
+
/>
|
|
448
|
+
</Section>
|
|
449
|
+
)}
|
|
450
|
+
|
|
451
|
+
<Section label="Type">
|
|
452
|
+
<div className="flex items-end gap-4 text-sm">
|
|
453
|
+
<label className="flex items-center gap-1.5">
|
|
454
|
+
<Checkbox checked={variation.virtual} onCheckedChange={(v) => set({ virtual: Boolean(v) })} />
|
|
455
|
+
Virtual
|
|
456
|
+
</label>
|
|
457
|
+
<label className="flex items-center gap-1.5">
|
|
458
|
+
<Checkbox
|
|
459
|
+
checked={variation.downloadable}
|
|
460
|
+
onCheckedChange={(v) => set({ downloadable: Boolean(v) })}
|
|
461
|
+
/>
|
|
462
|
+
Downloadable
|
|
463
|
+
</label>
|
|
464
|
+
</div>
|
|
465
|
+
</Section>
|
|
466
|
+
|
|
467
|
+
{variation.downloadable && (
|
|
468
|
+
<Section label="Files">
|
|
469
|
+
<DownloadsFields values={variation} set={set} />
|
|
470
|
+
</Section>
|
|
471
|
+
)}
|
|
472
|
+
</div>
|
|
473
|
+
</div>
|
|
474
|
+
</Row>
|
|
475
|
+
);
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/**
|
|
479
|
+
* Price & Inventory — one surface for what a customer actually buys.
|
|
480
|
+
*
|
|
481
|
+
* Tax applies to the whole product, so it sits at the top. Then the attributes,
|
|
482
|
+
* because they are what the variant list below is made of. Then one row per thing
|
|
483
|
+
* that can be bought: a product with no attributes has a single **Base price**
|
|
484
|
+
* row, which is the product itself; one with attributes has a row per variant.
|
|
485
|
+
* Both edit the same fields, which is why the price, inventory, shipping and
|
|
486
|
+
* downloads blocks are shared components rather than two parallel forms.
|
|
487
|
+
*
|
|
488
|
+
* The parent's own price is *not* edited when variants exist — `admin-products`
|
|
489
|
+
* derives it from the cheapest variant on save.
|
|
490
|
+
*/
|
|
491
|
+
export default function PriceInventoryTab({ product, up, refs, variations, setVariations }) {
|
|
492
|
+
const money = useMoney();
|
|
493
|
+
const settings = useSettings();
|
|
494
|
+
const weightUnit = settings?.get("products", "weight_unit", "kg");
|
|
495
|
+
const dimensionUnit = settings?.get("products", "dimension_unit", "cm");
|
|
496
|
+
const [copyFrom, setCopyFrom] = useState("0");
|
|
497
|
+
const [copyGroups, setCopyGroups] = useState(["price"]);
|
|
498
|
+
const taxClasses = refs.taxClasses || [];
|
|
499
|
+
const units = { weightUnit, dimensionUnit };
|
|
500
|
+
|
|
501
|
+
const hasAttributes = (product.attributes || []).length > 0;
|
|
502
|
+
const axes = useMemo(
|
|
503
|
+
() => (product.attributes || []).filter((a) => (a.options || []).length > 0),
|
|
504
|
+
[product.attributes]
|
|
505
|
+
);
|
|
506
|
+
|
|
507
|
+
/**
|
|
508
|
+
* Variants follow the attributes — no generate step, no per-row delete.
|
|
509
|
+
*
|
|
510
|
+
* Whenever the attribute options change, every missing combination is added and
|
|
511
|
+
* every combination that is no longer possible is dropped. New rows inherit the
|
|
512
|
+
* first existing variant, or the product's own values when there is none yet, so
|
|
513
|
+
* adding a size to a €19.99 mug gives priced variants rather than blank ones.
|
|
514
|
+
*
|
|
515
|
+
* Keyed on a signature and skipped on the first render: a product loaded with
|
|
516
|
+
* variants that don't match its attributes must not be silently rewritten before
|
|
517
|
+
* the merchant has touched anything.
|
|
518
|
+
*/
|
|
519
|
+
const attrSignature = JSON.stringify(
|
|
520
|
+
(product.attributes || []).map((a) => [a.attribute_id || a.name, a.options || []])
|
|
521
|
+
);
|
|
522
|
+
const lastSignature = useRef(attrSignature);
|
|
523
|
+
useEffect(() => {
|
|
524
|
+
if (lastSignature.current === attrSignature) return;
|
|
525
|
+
lastSignature.current = attrSignature;
|
|
526
|
+
|
|
527
|
+
const combos = generateVariationCombos(product.attributes);
|
|
528
|
+
const wanted = new Map(combos.map((c) => [comboKey(c), c]));
|
|
529
|
+
const kept = variations.filter((v) => wanted.has(comboKey(v)));
|
|
530
|
+
const keptKeys = new Set(kept.map(comboKey));
|
|
531
|
+
|
|
532
|
+
const source = variations[0];
|
|
533
|
+
const template = source
|
|
534
|
+
? Object.fromEntries(
|
|
535
|
+
COPY_GROUPS.flatMap((g) => g.fields).map((f) => [
|
|
536
|
+
f,
|
|
537
|
+
f === "dimensions" ? { ...(source.dimensions || {}) } : source[f],
|
|
538
|
+
])
|
|
539
|
+
)
|
|
540
|
+
: {
|
|
541
|
+
regular_price: product.regular_price,
|
|
542
|
+
sale_price: product.sale_price,
|
|
543
|
+
date_on_sale_from: product.date_on_sale_from,
|
|
544
|
+
date_on_sale_to: product.date_on_sale_to,
|
|
545
|
+
manage_stock: product.manage_stock ? "yes" : "parent",
|
|
546
|
+
stock_quantity: product.stock_quantity,
|
|
547
|
+
backorders: product.backorders,
|
|
548
|
+
weight: product.weight,
|
|
549
|
+
dimensions: { ...(product.dimensions || {}) },
|
|
550
|
+
shipping_class_id: product.shipping_class_id,
|
|
551
|
+
};
|
|
552
|
+
|
|
553
|
+
const added = [...wanted.entries()]
|
|
554
|
+
.filter(([key]) => !keptKeys.has(key))
|
|
555
|
+
.map(([, combo], i) => ({ ...NEW_VARIATION, ...template, ...combo, menu_order: kept.length + i }));
|
|
556
|
+
|
|
557
|
+
if (added.length || kept.length !== variations.length) setVariations([...kept, ...added]);
|
|
558
|
+
// eslint-disable-next-line -- reconcile only when the attribute options change
|
|
559
|
+
}, [attrSignature]);
|
|
560
|
+
|
|
561
|
+
/** Push one variant's values onto every other, so a 12-variant grid is one action. */
|
|
562
|
+
const applyCopy = () => {
|
|
563
|
+
const source = variations[Number(copyFrom)];
|
|
564
|
+
if (!source || !copyGroups.length) return;
|
|
565
|
+
const fields = COPY_GROUPS.filter((g) => copyGroups.includes(g.key)).flatMap((g) => g.fields);
|
|
566
|
+
setVariations(
|
|
567
|
+
variations.map((v, i) => {
|
|
568
|
+
if (i === Number(copyFrom)) return v;
|
|
569
|
+
const patch = {};
|
|
570
|
+
for (const f of fields) {
|
|
571
|
+
// dimensions is an object — copy it, don't share the reference
|
|
572
|
+
patch[f] = f === "dimensions" ? { ...(source.dimensions || {}) } : source[f];
|
|
573
|
+
}
|
|
574
|
+
return { ...v, ...patch };
|
|
575
|
+
})
|
|
576
|
+
);
|
|
577
|
+
const what = COPY_GROUPS.filter((g) => copyGroups.includes(g.key)).map((g) => g.label.toLowerCase());
|
|
578
|
+
toast.success(`Copied ${what.join(" and ")} to ${variations.length - 1} variant(s).`);
|
|
579
|
+
};
|
|
580
|
+
|
|
581
|
+
const setDefault = (attr, option) => {
|
|
582
|
+
const key = (d) => d.attribute_id || d.name;
|
|
583
|
+
const rest = (product.default_attributes || []).filter((d) => key(d) !== key(attr));
|
|
584
|
+
up({
|
|
585
|
+
default_attributes:
|
|
586
|
+
option === ANY ? rest : [...rest, { attribute_id: attr.attribute_id || "", name: attr.name, option }],
|
|
587
|
+
});
|
|
588
|
+
};
|
|
589
|
+
|
|
590
|
+
return (
|
|
591
|
+
<div className="space-y-4">
|
|
592
|
+
{/* Product-wide, so it sits above everything the customer picks between. */}
|
|
593
|
+
<div className="grid max-w-md gap-3 sm:grid-cols-2">
|
|
594
|
+
<div className="space-y-1.5">
|
|
595
|
+
<Label>Tax status</Label>
|
|
596
|
+
<Select value={product.tax_status} onValueChange={(v) => up({ tax_status: v })}>
|
|
597
|
+
<SelectTrigger><SelectValue /></SelectTrigger>
|
|
598
|
+
<SelectContent>
|
|
599
|
+
{TAX_STATUSES.map((t) => (
|
|
600
|
+
<SelectItem key={t.value} value={t.value}>{t.label}</SelectItem>
|
|
601
|
+
))}
|
|
602
|
+
</SelectContent>
|
|
603
|
+
</Select>
|
|
604
|
+
</div>
|
|
605
|
+
<div className="space-y-1.5">
|
|
606
|
+
<Label>Tax class</Label>
|
|
607
|
+
<Select value={product.tax_class || "standard"} onValueChange={(v) => up({ tax_class: v })}>
|
|
608
|
+
<SelectTrigger><SelectValue /></SelectTrigger>
|
|
609
|
+
<SelectContent>
|
|
610
|
+
{(taxClasses.length ? taxClasses : [{ slug: "standard", name: "Standard" }]).map((c) => (
|
|
611
|
+
<SelectItem key={c.slug} value={c.slug}>{c.name}</SelectItem>
|
|
612
|
+
))}
|
|
613
|
+
</SelectContent>
|
|
614
|
+
</Select>
|
|
615
|
+
</div>
|
|
616
|
+
<label className="flex items-center gap-2 text-sm sm:col-span-2">
|
|
617
|
+
<Checkbox
|
|
618
|
+
checked={product.sold_individually}
|
|
619
|
+
onCheckedChange={(v) => up({ sold_individually: Boolean(v) })}
|
|
620
|
+
/>
|
|
621
|
+
Limit purchases to 1 item per order
|
|
622
|
+
</label>
|
|
623
|
+
</div>
|
|
624
|
+
|
|
625
|
+
<div className="border-t pt-4">
|
|
626
|
+
<AttributesSection product={product} up={up} refs={refs} />
|
|
627
|
+
</div>
|
|
628
|
+
|
|
629
|
+
<div className="space-y-2 border-t pt-4">
|
|
630
|
+
{!hasAttributes && <DefaultRow product={product} up={up} money={money} units={units} refs={refs} />}
|
|
631
|
+
|
|
632
|
+
{hasAttributes && (
|
|
633
|
+
<>
|
|
634
|
+
<div className="flex flex-wrap items-center justify-between gap-2">
|
|
635
|
+
<span className="text-sm font-semibold">
|
|
636
|
+
{variations.length} variant{variations.length === 1 ? "" : "s"}
|
|
637
|
+
</span>
|
|
638
|
+
{axes.length > 0 && (
|
|
639
|
+
<div className="flex flex-wrap gap-3">
|
|
640
|
+
{axes.map((attr) => {
|
|
641
|
+
const key = attr.attribute_id || attr.name;
|
|
642
|
+
const current = (product.default_attributes || []).find(
|
|
643
|
+
(d) => (d.attribute_id || d.name) === key
|
|
644
|
+
);
|
|
645
|
+
return (
|
|
646
|
+
<div key={key} className="flex items-center gap-1.5">
|
|
647
|
+
<Label className="text-xs text-muted-foreground">Default {attr.name}</Label>
|
|
648
|
+
<Select value={current?.option || ANY} onValueChange={(v) => setDefault(attr, v)}>
|
|
649
|
+
<SelectTrigger className="h-8 w-36"><SelectValue /></SelectTrigger>
|
|
650
|
+
<SelectContent>
|
|
651
|
+
<SelectItem value={ANY}>None</SelectItem>
|
|
652
|
+
{(attr.options || []).map((o) => (
|
|
653
|
+
<SelectItem key={o} value={o}>{o}</SelectItem>
|
|
654
|
+
))}
|
|
655
|
+
</SelectContent>
|
|
656
|
+
</Select>
|
|
657
|
+
</div>
|
|
658
|
+
);
|
|
659
|
+
})}
|
|
660
|
+
</div>
|
|
661
|
+
)}
|
|
662
|
+
</div>
|
|
663
|
+
|
|
664
|
+
{variations.length > 1 && (
|
|
665
|
+
<div className="flex flex-wrap items-center gap-2 rounded-md border bg-muted/30 p-2 text-sm">
|
|
666
|
+
<Copy className="h-4 w-4 shrink-0 text-muted-foreground" />
|
|
667
|
+
<span className="text-muted-foreground">Copy from</span>
|
|
668
|
+
<Select value={copyFrom} onValueChange={setCopyFrom}>
|
|
669
|
+
<SelectTrigger className="h-8 w-48"><SelectValue /></SelectTrigger>
|
|
670
|
+
<SelectContent>
|
|
671
|
+
{variations.map((v, i) => (
|
|
672
|
+
<SelectItem key={v.id || `new-${i}`} value={String(i)}>
|
|
673
|
+
{variationLabel(v)}
|
|
674
|
+
</SelectItem>
|
|
675
|
+
))}
|
|
676
|
+
</SelectContent>
|
|
677
|
+
</Select>
|
|
678
|
+
{COPY_GROUPS.map((g) => (
|
|
679
|
+
<label key={g.key} className="flex items-center gap-1.5">
|
|
680
|
+
<Checkbox
|
|
681
|
+
checked={copyGroups.includes(g.key)}
|
|
682
|
+
onCheckedChange={(v) =>
|
|
683
|
+
setCopyGroups((prev) => (v ? [...prev, g.key] : prev.filter((k) => k !== g.key)))
|
|
684
|
+
}
|
|
685
|
+
/>
|
|
686
|
+
{g.label}
|
|
687
|
+
</label>
|
|
688
|
+
))}
|
|
689
|
+
<Button type="button" variant="outline" size="sm" disabled={!copyGroups.length} onClick={applyCopy}>
|
|
690
|
+
Apply to the other {variations.length - 1}
|
|
691
|
+
</Button>
|
|
692
|
+
</div>
|
|
693
|
+
)}
|
|
694
|
+
|
|
695
|
+
{variations.length === 0 && (
|
|
696
|
+
<Alert>
|
|
697
|
+
<TriangleAlert className="h-4 w-4" />
|
|
698
|
+
<AlertTitle>No variants yet — customers can't buy this product</AlertTitle>
|
|
699
|
+
<AlertDescription>
|
|
700
|
+
Give an attribute above the values this product comes in and its variants appear
|
|
701
|
+
here automatically.
|
|
702
|
+
</AlertDescription>
|
|
703
|
+
</Alert>
|
|
704
|
+
)}
|
|
705
|
+
|
|
706
|
+
{variations.map((v, i) => (
|
|
707
|
+
<VariantRow
|
|
708
|
+
key={v.id || comboKey(v) || `new-${i}`}
|
|
709
|
+
variation={v}
|
|
710
|
+
refs={refs}
|
|
711
|
+
money={money}
|
|
712
|
+
units={units}
|
|
713
|
+
virtual={product.virtual}
|
|
714
|
+
onChange={(nv) => setVariations(variations.map((x, j) => (j === i ? nv : x)))}
|
|
715
|
+
/>
|
|
716
|
+
))}
|
|
717
|
+
|
|
718
|
+
<p className="text-xs text-muted-foreground">
|
|
719
|
+
Variants follow the attributes above — adding a value adds its variants (copied from the
|
|
720
|
+
first), removing one takes them away. The product's own price is the cheapest variant,
|
|
721
|
+
derived on save.
|
|
722
|
+
</p>
|
|
723
|
+
</>
|
|
724
|
+
)}
|
|
725
|
+
</div>
|
|
726
|
+
</div>
|
|
727
|
+
);
|
|
728
|
+
}
|