@base44/app-plugin-commerce 0.1.13 → 0.1.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
- <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>
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
- * The product itself — what a product with no attributes is sold as, and the
243
- * values its variants inherit when it gains attributes. The fields are the
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 DefaultRow({ product, up, money, units, refs }) {
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
- <Row title="Base price" summary={summary}>
259
- <div className="grid gap-3 sm:grid-cols-2">
260
- <Section label="Price">
261
- <PriceFields values={product} set={up} />
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
- <Section label="Inventory">
265
- <div className="space-y-1.5">
266
- <Label className="text-xs">SKU</Label>
267
- <Input
268
- placeholder="Unique stock keeping unit"
269
- value={product.sku || ""}
270
- onChange={(e) => up({ sku: e.target.value })}
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
- </div>
273
- <div />
274
- <div className="sm:col-span-2">
275
- <label className="flex items-center gap-2 text-sm">
276
- <Switch
277
- checked={product.manage_stock}
278
- onCheckedChange={(v) => up({ manage_stock: Boolean(v) })}
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
- Track stock quantity
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">Stock status</Label>
317
- <Select value={product.stock_status || "instock"} onValueChange={(v) => up({ stock_status: v })}>
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
- {STOCK_STATUSES.map((s) => (
321
- <SelectItem key={s.value} value={s.value}>{s.label}</SelectItem>
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
- </Section>
328
-
329
- {!product.virtual && (
330
- <Section label="Shipping">
331
- <ShippingFields values={product} set={up} units={units} />
332
- </Section>
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
- </div>
335
- </Row>
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
- /** One variant. Removing it is done by removing the attribute value it came from. */
340
- function VariantRow({ variation, onChange, refs, money, units, virtual }) {
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
- <Row
354
- title={variationLabel(variation)}
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
- <Section label="Inventory">
375
- <div className="space-y-1.5">
376
- <Label className="text-xs">SKU</Label>
377
- <Input value={variation.sku || ""} onChange={(e) => set({ sku: e.target.value })} />
378
- </div>
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">Stock management</Label>
381
- <Select value={variation.manage_stock || "parent"} onValueChange={(v) => set({ manage_stock: v })}>
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
- <SelectItem value="parent">Same as product</SelectItem>
385
- <SelectItem value="yes">Track quantity</SelectItem>
386
- <SelectItem value="no">Don't track</SelectItem>
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
- {variation.manage_stock === "yes" && (
391
- <>
392
- <div className="space-y-1.5">
393
- <Label className="text-xs">Stock quantity</Label>
394
- <Input
395
- type="number"
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
- {!virtual && (
416
- <Section label="Shipping">
417
- <ShippingFields values={variation} set={set} units={units} />
418
- </Section>
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
- <Section label="Type">
422
- <div className="flex items-end gap-4 text-sm">
423
- <label className="flex items-center gap-1.5">
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
- {variation.downloadable && (
438
- <Section label="Files">
439
- <DownloadsFields values={variation} set={set} />
440
- </Section>
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 &amp; 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
- </div>
444
- </Row>
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
- * Tax applies to the whole product, so it sits at the top. Then the attributes,
452
- * because they are what the variant list below is made of. Then one row per thing
453
- * that can be bought: a product with no attributes has a single **Base price**
454
- * row, which is the product itself; one with attributes has a row per variant.
455
- * Both edit the same fields, which is why the price, inventory, shipping and
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 axes = useMemo(
474
- () => (product.attributes || []).filter((a) => (a.options || []).length > 0),
475
- [product.attributes]
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 attributes — no generate step, no per-row delete.
466
+ * Variants follow the options — no generate step, no per-row delete.
480
467
  *
481
- * Whenever the attribute options change, every missing combination is added and
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 attributes must not be silently rewritten before
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 attribute options change
515
+ // eslint-disable-next-line -- reconcile only when the option values change
529
516
  }, [attrSignature]);
530
517
 
531
- /** Push one variant's values onto every other, so a 12-variant grid is one action. */
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 source = variations[Number(copyFrom)];
534
- if (!source || !copyGroups.length) return;
535
- const fields = COPY_GROUPS.filter((g) => copyGroups.includes(g.key)).flatMap((g) => g.fields);
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
- const what = COPY_GROUPS.filter((g) => copyGroups.includes(g.key)).map((g) => g.label.toLowerCase());
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 setDefault = (attr, option) => {
552
- const key = (d) => d.attribute_id || d.name;
553
- const rest = (product.default_attributes || []).filter((d) => key(d) !== key(attr));
554
- up({
555
- default_attributes:
556
- option === ANY ? rest : [...rest, { attribute_id: attr.attribute_id || "", name: attr.name, option }],
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
- {/* Product-wide, so it sits above everything the customer picks between. */}
563
- <div className="grid max-w-md gap-3 sm:grid-cols-2">
564
- <div className="space-y-1.5">
565
- <Label>Tax status</Label>
566
- <Select value={product.tax_status} onValueChange={(v) => up({ tax_status: v })}>
567
- <SelectTrigger><SelectValue /></SelectTrigger>
568
- <SelectContent>
569
- {TAX_STATUSES.map((t) => (
570
- <SelectItem key={t.value} value={t.value}>{t.label}</SelectItem>
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
- <label className="flex items-center gap-2 text-sm sm:col-span-2">
587
- <Checkbox
588
- checked={product.sold_individually}
589
- onCheckedChange={(v) => up({ sold_individually: Boolean(v) })}
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
- <div className="border-t pt-4">
596
- <AttributesSection product={product} up={up} refs={refs} />
597
- </div>
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
- <div className="space-y-2 border-t pt-4">
600
- {!hasAttributes && <DefaultRow product={product} up={up} money={money} units={units} refs={refs} />}
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
- {hasAttributes && (
603
- <>
604
- <div className="flex flex-wrap items-center justify-between gap-2">
605
- <span className="text-sm font-semibold">
606
- {variations.length} variant{variations.length === 1 ? "" : "s"}
607
- </span>
608
- {axes.length > 0 && (
609
- <div className="flex flex-wrap gap-3">
610
- {axes.map((attr) => {
611
- const key = attr.attribute_id || attr.name;
612
- const current = (product.default_attributes || []).find(
613
- (d) => (d.attribute_id || d.name) === key
614
- );
615
- return (
616
- <div key={key} className="flex items-center gap-1.5">
617
- <Label className="text-xs text-muted-foreground">Default {attr.name}</Label>
618
- <Select value={current?.option || ANY} onValueChange={(v) => setDefault(attr, v)}>
619
- <SelectTrigger className="h-8 w-36"><SelectValue /></SelectTrigger>
620
- <SelectContent>
621
- <SelectItem value={ANY}>None</SelectItem>
622
- {(attr.options || []).map((o) => (
623
- <SelectItem key={o} value={o}>{o}</SelectItem>
624
- ))}
625
- </SelectContent>
626
- </Select>
627
- </div>
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
- {variations.length > 1 && (
635
- <div className="flex flex-wrap items-center gap-2 rounded-md border bg-muted/30 p-2 text-sm">
636
- <Copy className="h-4 w-4 shrink-0 text-muted-foreground" />
637
- <span className="text-muted-foreground">Copy from</span>
638
- <Select value={copyFrom} onValueChange={setCopyFrom}>
639
- <SelectTrigger className="h-8 w-48"><SelectValue /></SelectTrigger>
640
- <SelectContent>
641
- {variations.map((v, i) => (
642
- <SelectItem key={v.id || `new-${i}`} value={String(i)}>
643
- {variationLabel(v)}
644
- </SelectItem>
645
- ))}
646
- </SelectContent>
647
- </Select>
648
- {COPY_GROUPS.map((g) => (
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
  }