@base44/app-plugin-commerce 0.1.3 → 0.1.4

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.
Files changed (83) hide show
  1. package/README.md +8 -8
  2. package/base44/agents/commerce/StoreAdmin.jsonc +7 -7
  3. package/base44/entities/commerce.Order.jsonc +1 -1
  4. package/base44/entities/commerce.PaymentGateway.jsonc +3 -3
  5. package/base44/entities/commerce.Product.jsonc +9 -45
  6. package/base44/entities/commerce.ProductAttribute.jsonc +7 -19
  7. package/base44/entities/commerce.ProductAttributeTerm.jsonc +4 -10
  8. package/base44/entities/commerce.ProductTag.jsonc +1 -8
  9. package/base44/entities/commerce.ProductVariation.jsonc +1 -1
  10. package/base44/entities/commerce.StoreSettings.jsonc +1 -1
  11. package/base44/functions/commerce/admin-products/entry.ts +107 -35
  12. package/base44/functions/commerce/admin-refunds/entry.ts +1 -1
  13. package/base44/functions/commerce/admin-reports/entry.ts +1 -1
  14. package/base44/functions/commerce/admin-tools/entry.ts +4 -3
  15. package/base44/functions/commerce/payments/entry.ts +0 -2
  16. package/base44/functions/commerce/seed-store/defaults.ts +13 -39
  17. package/base44/functions/commerce/seed-store/entry.ts +47 -35
  18. package/base44/functions/commerce/seed-store/sample-data.ts +28 -66
  19. package/base44/functions/commerce/storefront-cart/entry.ts +0 -7
  20. package/base44/functions/commerce/storefront-catalog/entry.ts +12 -21
  21. package/base44/functions/commerce/storefront-checkout/entry.ts +5 -12
  22. package/base44/shared/commerce/coupons.ts +4 -7
  23. package/base44/shared/commerce/email-templates.ts +15 -13
  24. package/base44/shared/commerce/emails.ts +4 -3
  25. package/base44/shared/commerce/payments.ts +8 -20
  26. package/base44/shared/commerce/products.ts +24 -0
  27. package/base44/shared/commerce/settings.ts +2 -3
  28. package/base44/shared/commerce/stock.ts +2 -4
  29. package/base44/shared/commerce/totals.ts +37 -42
  30. package/package.json +1 -1
  31. package/scripts/install.js +21 -4
  32. package/skills/commerce/SKILL.md +6 -5
  33. package/skills/commerce/docs/api-admin.md +16 -14
  34. package/skills/commerce/docs/api-storefront.md +39 -35
  35. package/skills/commerce/installation-guidelines.md +4 -3
  36. package/skills/commerce/post-installation.md +22 -25
  37. package/skills/commerce/references/admin-product-form.md +56 -0
  38. package/skills/commerce/references/emails.md +4 -3
  39. package/skills/commerce/references/guest-access-security.md +2 -2
  40. package/skills/commerce/references/limits-and-performance.md +1 -1
  41. package/skills/commerce/references/online-payments.md +8 -8
  42. package/skills/commerce/references/product-render.md +25 -23
  43. package/skills/commerce/references/storefront-product-page.md +9 -9
  44. package/skills/commerce/references/webhooks.md +3 -1
  45. package/src/commerce/admin/README.md +4 -4
  46. package/src/commerce/admin/context/BasePathContext.jsx +5 -5
  47. package/src/commerce/admin/context/SettingsContext.jsx +4 -4
  48. package/src/commerce/admin/index.jsx +3 -3
  49. package/src/commerce/admin/layout/Sidebar.jsx +1 -9
  50. package/src/commerce/admin/lib/constants.js +0 -7
  51. package/src/commerce/admin/lib/paths.js +6 -6
  52. package/src/commerce/admin/lib/product-utils.js +18 -15
  53. package/src/commerce/admin/pages/orders/components/AddProductDialog.jsx +8 -8
  54. package/src/commerce/admin/pages/products/ProductEditor.jsx +10 -12
  55. package/src/commerce/admin/pages/products/ProductsList.jsx +3 -15
  56. package/src/commerce/admin/pages/products/components/AttributesSection.jsx +426 -0
  57. package/src/commerce/admin/pages/products/components/ProductDataPanel.jsx +33 -69
  58. package/src/commerce/admin/pages/products/components/TaxonomyPanel.jsx +1 -1
  59. package/src/commerce/admin/pages/products/components/tabs/LinkedTab.jsx +1 -14
  60. package/src/commerce/admin/pages/products/components/tabs/ModifiersTab.jsx +19 -0
  61. package/src/commerce/admin/pages/products/components/tabs/PriceInventoryTab.jsx +728 -0
  62. package/src/commerce/admin/pages/reports/Reports.jsx +3 -3
  63. package/src/commerce/admin/pages/settings/EmailsSettings.jsx +12 -14
  64. package/src/commerce/admin/pages/settings/GeneralSettings.jsx +6 -116
  65. package/src/commerce/admin/pages/settings/PaymentsSettings.jsx +101 -62
  66. package/src/commerce/admin/pages/settings/SettingsLayout.jsx +2 -6
  67. package/src/commerce/admin/pages/settings/TaxSettings.jsx +0 -1
  68. package/src/commerce/admin/pages/status/WebhookEditor.jsx +3 -3
  69. package/src/commerce/admin/pages/status/Webhooks.jsx +2 -2
  70. package/src/commerce/admin/routes.jsx +10 -19
  71. package/src/commerce/utils/index.js +2 -2
  72. package/src/commerce/utils/shipping-promos.js +9 -6
  73. package/src/commerce/utils/variants.js +21 -21
  74. package/src/commerce/admin/pages/products/AttributeTerms.jsx +0 -180
  75. package/src/commerce/admin/pages/products/Attributes.jsx +0 -183
  76. package/src/commerce/admin/pages/products/Tags.jsx +0 -150
  77. package/src/commerce/admin/pages/products/components/tabs/AdvancedTab.jsx +0 -48
  78. package/src/commerce/admin/pages/products/components/tabs/AttributesTab.jsx +0 -208
  79. package/src/commerce/admin/pages/products/components/tabs/ExternalTab.jsx +0 -41
  80. package/src/commerce/admin/pages/products/components/tabs/GeneralTab.jsx +0 -103
  81. package/src/commerce/admin/pages/products/components/tabs/InventoryTab.jsx +0 -93
  82. package/src/commerce/admin/pages/products/components/tabs/ShippingTab.jsx +0 -86
  83. 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
+ }