@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,426 @@
1
+ import React, { useCallback, useEffect, useState } from "react";
2
+ import { Button } from "@/components/ui/button";
3
+ import { Input } from "@/components/ui/input";
4
+ import { Label } from "@/components/ui/label";
5
+ import { Card } from "@/components/ui/card";
6
+ import {
7
+ Dialog,
8
+ DialogContent,
9
+ DialogFooter,
10
+ DialogHeader,
11
+ DialogTitle,
12
+ } from "@/components/ui/dialog";
13
+ import { ChevronDown, ChevronRight, ChevronUp, Loader2, Plus, Settings2, Trash2 } from "lucide-react";
14
+
15
+ import { base44, call } from "../../../lib/api";
16
+ import SearchSelect from "../../../components/SearchSelect";
17
+ import { slugify } from "../../../lib/product-utils";
18
+
19
+ /** Sentinel option value for "create the thing I just typed". */
20
+ const CREATE = "__create__";
21
+ const createdName = (label) => label.replace(/^Create "/, "").replace(/"$/, "");
22
+
23
+ const fetchValues = (attributeId) =>
24
+ base44.entities["commerce.ProductAttributeTerm"]
25
+ .filter({ attribute_id: attributeId }, "order", 500)
26
+ .then((rows) => rows || [])
27
+ .catch(() => []);
28
+
29
+ /**
30
+ * Which attributes this product varies by, and which of their values it comes in.
31
+ *
32
+ * Editing this drives the variant list directly — the caller reconciles variants
33
+ * against these options, so there is no generate step. The attribute and value
34
+ * records themselves are shared between products, so edits to those save
35
+ * immediately, while the choice of which values *this* product uses is part of
36
+ * the product form and saves with it.
37
+ */
38
+ export default function AttributesSection({ product, up, refs }) {
39
+ const attributes = product.attributes || [];
40
+ const globalAttributes = refs.attributes || [];
41
+ const [valuesByAttr, setValuesByAttr] = useState({});
42
+ const [manageOpen, setManageOpen] = useState(false);
43
+ const [busy, setBusy] = useState(false);
44
+
45
+ const loadValues = useCallback(async (attributeId) => {
46
+ const rows = await fetchValues(attributeId);
47
+ setValuesByAttr((c) => ({ ...c, [attributeId]: rows }));
48
+ return rows;
49
+ }, []);
50
+
51
+ const attachedIds = attributes.map((a) => a.attribute_id).filter(Boolean).join(",");
52
+ useEffect(() => {
53
+ const missing = attachedIds.split(",").filter((id) => id && !valuesByAttr[id]);
54
+ if (!missing.length) return;
55
+ let cancelled = false;
56
+ Promise.all(missing.map((id) => fetchValues(id).then((rows) => [id, rows]))).then((entries) => {
57
+ if (!cancelled) setValuesByAttr((c) => ({ ...c, ...Object.fromEntries(entries) }));
58
+ });
59
+ return () => {
60
+ cancelled = true;
61
+ };
62
+ // eslint-disable-next-line -- keyed on the id list, not the cache
63
+ }, [attachedIds]);
64
+
65
+ const setRow = (index, patch) =>
66
+ up({ attributes: attributes.map((a, i) => (i === index ? { ...a, ...patch } : a)) });
67
+
68
+ const removeRow = (index) =>
69
+ up({ attributes: attributes.filter((_, i) => i !== index).map((a, i) => ({ ...a, position: i })) });
70
+
71
+ const moveRow = (index, delta) => {
72
+ const target = index + delta;
73
+ if (target < 0 || target >= attributes.length) return;
74
+ const next = [...attributes];
75
+ [next[index], next[target]] = [next[target], next[index]];
76
+ up({ attributes: next.map((a, i) => ({ ...a, position: i })) });
77
+ };
78
+
79
+ const attach = (attr) => {
80
+ if (!attr?.id || attributes.some((a) => a.attribute_id === attr.id)) return;
81
+ up({
82
+ attributes: [
83
+ ...attributes,
84
+ { attribute_id: attr.id, name: attr.name, position: attributes.length, options: [] },
85
+ ],
86
+ });
87
+ };
88
+
89
+ const createAndAttach = async (name) => {
90
+ setBusy(true);
91
+ try {
92
+ const order = globalAttributes.reduce((max, a) => Math.max(max, a.order ?? 0), -1) + 1;
93
+ const created = await call("admin-products", "save-term", {
94
+ taxonomy: "attribute",
95
+ term: { name, code: slugify(name), order },
96
+ });
97
+ await refs.refreshAttributes?.();
98
+ attach(created);
99
+ } catch {
100
+ /* toast handled by call() */
101
+ } finally {
102
+ setBusy(false);
103
+ }
104
+ };
105
+
106
+ const createAndSelectValue = async (row, index, name) => {
107
+ setBusy(true);
108
+ try {
109
+ const existing = valuesByAttr[row.attribute_id] || [];
110
+ const order = existing.reduce((max, v) => Math.max(max, v.order ?? 0), -1) + 1;
111
+ await call("admin-products", "save-term", {
112
+ taxonomy: "attribute-term",
113
+ term: { attribute_id: row.attribute_id, name, order },
114
+ });
115
+ await loadValues(row.attribute_id);
116
+ setRow(index, { options: [...(row.options || []), name] });
117
+ } catch {
118
+ /* toast handled by call() */
119
+ } finally {
120
+ setBusy(false);
121
+ }
122
+ };
123
+
124
+ return (
125
+ <div className="space-y-3">
126
+ <div className="flex items-start justify-between gap-4">
127
+ <div>
128
+ <h3 className="text-sm font-semibold">Attributes</h3>
129
+ <p className="text-xs text-muted-foreground">
130
+ What this product varies by. Every attribute is an axis, and the variants below follow
131
+ the values you pick.
132
+ </p>
133
+ </div>
134
+ <Button type="button" variant="outline" size="sm" onClick={() => setManageOpen(true)}>
135
+ <Settings2 className="mr-2 h-4 w-4" /> Manage attributes…
136
+ </Button>
137
+ </div>
138
+
139
+ {attributes.length === 0 && (
140
+ <p className="text-sm text-muted-foreground">
141
+ None — this product sells as a single item. Add one (Size, Color) to sell variants.
142
+ </p>
143
+ )}
144
+
145
+ {attributes.map((row, i) => {
146
+ const values = valuesByAttr[row.attribute_id] || [];
147
+ const selected = row.options || [];
148
+ return (
149
+ <Card key={`${row.attribute_id}-${i}`} className="space-y-2 p-3">
150
+ <div className="flex items-center gap-2">
151
+ <div className="flex flex-col">
152
+ <button
153
+ type="button"
154
+ className="text-muted-foreground disabled:opacity-30"
155
+ disabled={i === 0}
156
+ onClick={() => moveRow(i, -1)}
157
+ title="Move up"
158
+ >
159
+ <ChevronUp className="h-4 w-4" />
160
+ </button>
161
+ <button
162
+ type="button"
163
+ className="text-muted-foreground disabled:opacity-30"
164
+ disabled={i === attributes.length - 1}
165
+ onClick={() => moveRow(i, 1)}
166
+ title="Move down"
167
+ >
168
+ <ChevronDown className="h-4 w-4" />
169
+ </button>
170
+ </div>
171
+ <span className="flex-1 font-medium">{row.name}</span>
172
+ <Button
173
+ type="button"
174
+ variant="ghost"
175
+ size="icon"
176
+ onClick={() => removeRow(i)}
177
+ title="Remove from this product"
178
+ >
179
+ <Trash2 className="h-4 w-4 text-muted-foreground" />
180
+ </Button>
181
+ </div>
182
+ <SearchSelect
183
+ multiple
184
+ placeholder="Values this product comes in…"
185
+ value={selected.map((o) => ({ value: o, label: o }))}
186
+ onChange={(next) => {
187
+ const created = next.find((o) => o.value === CREATE);
188
+ if (created) createAndSelectValue(row, i, createdName(created.label));
189
+ else setRow(i, { options: next.map((o) => o.value) });
190
+ }}
191
+ search={async (q) => {
192
+ const term = (q || "").trim();
193
+ const opts = values
194
+ .filter((v) => !term || v.name.toLowerCase().includes(term.toLowerCase()))
195
+ .map((v) => ({ value: v.name, label: v.name }));
196
+ const exact = values.some((v) => v.name.toLowerCase() === term.toLowerCase());
197
+ if (term && !exact) opts.push({ value: CREATE, label: `Create "${term}"` });
198
+ return opts;
199
+ }}
200
+ />
201
+ </Card>
202
+ );
203
+ })}
204
+
205
+ <div className="flex items-center gap-2">
206
+ <SearchSelect
207
+ className="w-72"
208
+ placeholder="Add attribute…"
209
+ value={null}
210
+ onChange={(opt) => {
211
+ if (!opt) return;
212
+ if (opt.value === CREATE) createAndAttach(createdName(opt.label));
213
+ else attach(globalAttributes.find((a) => a.id === opt.value));
214
+ }}
215
+ search={async (q) => {
216
+ const term = (q || "").trim();
217
+ const opts = globalAttributes
218
+ .filter((a) => !attributes.some((row) => row.attribute_id === a.id))
219
+ .filter((a) => !term || a.name.toLowerCase().includes(term.toLowerCase()))
220
+ .map((a) => ({ value: a.id, label: a.name, meta: a.code || undefined }));
221
+ const exact = globalAttributes.some((a) => a.name.toLowerCase() === term.toLowerCase());
222
+ if (term && !exact) opts.push({ value: CREATE, label: `Create "${term}"` });
223
+ return opts;
224
+ }}
225
+ />
226
+ {busy && <Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />}
227
+ </div>
228
+
229
+ {manageOpen && (
230
+ <ManageAttributesDialog
231
+ attributes={globalAttributes}
232
+ valuesByAttr={valuesByAttr}
233
+ loadValues={loadValues}
234
+ onClose={() => setManageOpen(false)}
235
+ onChanged={refs.refreshAttributes}
236
+ />
237
+ )}
238
+ </div>
239
+ );
240
+ }
241
+
242
+ /**
243
+ * The only place attributes and their values are edited, reachable from the
244
+ * product form alone. These records are shared between products, so every edit
245
+ * saves immediately rather than waiting for the product's Save.
246
+ *
247
+ * Deleting happens in the entity data, so there is no delete control.
248
+ */
249
+ function ManageAttributesDialog({ attributes, valuesByAttr, loadValues, onClose, onChanged }) {
250
+ const [expanded, setExpanded] = useState(null);
251
+ const [saving, setSaving] = useState(false);
252
+ const [newName, setNewName] = useState("");
253
+
254
+ const saveAttribute = async (attr, patch) => {
255
+ if (Object.entries(patch).every(([k, v]) => (attr[k] ?? "") === v)) return;
256
+ setSaving(true);
257
+ try {
258
+ await call("admin-products", "save-term", {
259
+ taxonomy: "attribute",
260
+ term: { ...attr, ...patch },
261
+ });
262
+ await onChanged?.();
263
+ } catch {
264
+ /* toast handled by call() */
265
+ } finally {
266
+ setSaving(false);
267
+ }
268
+ };
269
+
270
+ const saveValue = async (attributeId, value, patch) => {
271
+ if (Object.entries(patch).every(([k, v]) => (value[k] ?? "") === v)) return;
272
+ setSaving(true);
273
+ try {
274
+ await call("admin-products", "save-term", {
275
+ taxonomy: "attribute-term",
276
+ term: { ...value, attribute_id: attributeId, ...patch },
277
+ });
278
+ await loadValues(attributeId);
279
+ } catch {
280
+ /* toast handled by call() */
281
+ } finally {
282
+ setSaving(false);
283
+ }
284
+ };
285
+
286
+ const addAttribute = async () => {
287
+ const name = newName.trim();
288
+ if (!name) return;
289
+ setSaving(true);
290
+ try {
291
+ const order = attributes.reduce((max, a) => Math.max(max, a.order ?? 0), -1) + 1;
292
+ await call("admin-products", "save-term", {
293
+ taxonomy: "attribute",
294
+ term: { name, code: slugify(name), order },
295
+ });
296
+ setNewName("");
297
+ await onChanged?.();
298
+ } catch {
299
+ /* toast handled by call() */
300
+ } finally {
301
+ setSaving(false);
302
+ }
303
+ };
304
+
305
+ const addValue = async (attributeId) => {
306
+ const existing = valuesByAttr[attributeId] || [];
307
+ const order = existing.reduce((max, v) => Math.max(max, v.order ?? 0), -1) + 1;
308
+ setSaving(true);
309
+ try {
310
+ await call("admin-products", "save-term", {
311
+ taxonomy: "attribute-term",
312
+ term: { attribute_id: attributeId, name: `Value ${order + 1}`, order },
313
+ });
314
+ await loadValues(attributeId);
315
+ } catch {
316
+ /* toast handled by call() */
317
+ } finally {
318
+ setSaving(false);
319
+ }
320
+ };
321
+
322
+ const toggle = async (id) => {
323
+ const opening = expanded !== id;
324
+ setExpanded(opening ? id : null);
325
+ if (opening && !valuesByAttr[id]) await loadValues(id);
326
+ };
327
+
328
+ return (
329
+ <Dialog open onOpenChange={(o) => !o && onClose()}>
330
+ <DialogContent className="max-h-[85vh] overflow-y-auto sm:max-w-2xl">
331
+ <DialogHeader>
332
+ <DialogTitle>Manage attributes</DialogTitle>
333
+ </DialogHeader>
334
+ <p className="-mt-2 text-xs text-muted-foreground">
335
+ Shared across every product — changes save immediately. To delete an attribute or a value,
336
+ remove it from the entity data.
337
+ </p>
338
+
339
+ <div className="space-y-2">
340
+ <div className="grid grid-cols-[1fr_140px_72px_32px] gap-2 text-xs text-muted-foreground">
341
+ <span>Name</span>
342
+ <span>Code</span>
343
+ <span>Order</span>
344
+ <span />
345
+ </div>
346
+ {attributes.map((attr) => (
347
+ <div key={attr.id} className="space-y-2 rounded-md border p-2">
348
+ <div className="grid grid-cols-[1fr_140px_72px_32px] items-center gap-2">
349
+ <Input
350
+ defaultValue={attr.name || ""}
351
+ className="h-8"
352
+ onBlur={(e) => saveAttribute(attr, { name: e.target.value.trim() })}
353
+ />
354
+ <Input
355
+ defaultValue={attr.code || ""}
356
+ className="h-8 font-mono text-xs"
357
+ onBlur={(e) => saveAttribute(attr, { code: e.target.value.trim() })}
358
+ />
359
+ <Input
360
+ type="number"
361
+ defaultValue={attr.order ?? 0}
362
+ className="h-8"
363
+ onBlur={(e) => saveAttribute(attr, { order: Number(e.target.value) || 0 })}
364
+ />
365
+ <button type="button" className="text-muted-foreground" onClick={() => toggle(attr.id)} title="Values">
366
+ {expanded === attr.id ? (
367
+ <ChevronDown className="h-4 w-4" />
368
+ ) : (
369
+ <ChevronRight className="h-4 w-4" />
370
+ )}
371
+ </button>
372
+ </div>
373
+
374
+ {expanded === attr.id && (
375
+ <div className="space-y-1.5 border-t pt-2 pl-4">
376
+ {(valuesByAttr[attr.id] || []).map((v) => (
377
+ <div key={v.id} className="grid grid-cols-[1fr_72px] gap-2">
378
+ <Input
379
+ defaultValue={v.name || ""}
380
+ className="h-8"
381
+ onBlur={(e) => saveValue(attr.id, v, { name: e.target.value.trim() })}
382
+ />
383
+ <Input
384
+ type="number"
385
+ defaultValue={v.order ?? 0}
386
+ className="h-8"
387
+ onBlur={(e) => saveValue(attr.id, v, { order: Number(e.target.value) || 0 })}
388
+ />
389
+ </div>
390
+ ))}
391
+ {!(valuesByAttr[attr.id] || []).length && (
392
+ <p className="text-xs text-muted-foreground">No values yet.</p>
393
+ )}
394
+ <Button type="button" variant="outline" size="sm" onClick={() => addValue(attr.id)}>
395
+ <Plus className="mr-1 h-4 w-4" /> Add value
396
+ </Button>
397
+ </div>
398
+ )}
399
+ </div>
400
+ ))}
401
+ {!attributes.length && <p className="text-sm text-muted-foreground">No attributes yet.</p>}
402
+ </div>
403
+
404
+ <div className="flex items-end gap-2 border-t pt-3">
405
+ <div className="flex-1 space-y-1.5">
406
+ <Label>New attribute</Label>
407
+ <Input
408
+ placeholder="e.g. Fabric"
409
+ value={newName}
410
+ onChange={(e) => setNewName(e.target.value)}
411
+ onKeyDown={(e) => e.key === "Enter" && addAttribute()}
412
+ />
413
+ </div>
414
+ <Button type="button" variant="outline" onClick={addAttribute} disabled={!newName.trim() || saving}>
415
+ <Plus className="mr-1 h-4 w-4" /> Add
416
+ </Button>
417
+ </div>
418
+
419
+ <DialogFooter>
420
+ {saving && <Loader2 className="mr-auto h-4 w-4 animate-spin text-muted-foreground" />}
421
+ <Button onClick={onClose}>Done</Button>
422
+ </DialogFooter>
423
+ </DialogContent>
424
+ </Dialog>
425
+ );
426
+ }
@@ -1,80 +1,51 @@
1
1
  import React, { useMemo, useState } from "react";
2
2
  import { Card, CardContent, CardHeader } from "@/components/ui/card";
3
3
  import { Checkbox } from "@/components/ui/checkbox";
4
- import { Label } from "@/components/ui/label";
5
- import {
6
- Select,
7
- SelectContent,
8
- SelectItem,
9
- SelectTrigger,
10
- SelectValue,
11
- } from "@/components/ui/select";
12
4
  import { Separator } from "@/components/ui/separator";
13
5
 
14
- import { PRODUCT_TYPES } from "../../../lib/constants";
15
- import { isSimple, isGrouped, isExternal, isVariable } from "../../../lib/product-utils";
16
- import GeneralTab from "./tabs/GeneralTab";
17
- import ExternalTab from "./tabs/ExternalTab";
18
- import InventoryTab from "./tabs/InventoryTab";
19
- import ShippingTab from "./tabs/ShippingTab";
6
+ import { isVariable } from "../../../lib/product-utils";
7
+ import PriceInventoryTab from "./tabs/PriceInventoryTab";
8
+ import ModifiersTab from "./tabs/ModifiersTab";
20
9
  import LinkedTab from "./tabs/LinkedTab";
21
- import AttributesTab from "./tabs/AttributesTab";
22
- import VariationsTab from "./tabs/VariationsTab";
23
10
  import DownloadsTab from "./tabs/DownloadsTab";
24
- import AdvancedTab from "./tabs/AdvancedTab";
25
11
 
26
12
  /**
27
- * The "Product data" panel: type selector + vertical tabs.
28
- * Tab visibility follows the product type / virtual / downloadable flags.
13
+ * The "Product data" panel: vertical tabs. There is no product type, and nothing
14
+ * about a purchasable thing is split off into its own tab — Price & Inventory
15
+ * carries tax, the attributes, and a row per variant (or the product itself as
16
+ * its default variant), each row owning its price, stock and shipping.
29
17
  */
30
18
  export default function ProductDataPanel({ product, up, refs, variations, setVariations }) {
31
19
  const tabs = useMemo(() => {
32
- const list = [];
33
- if (!isGrouped(product)) list.push({ id: "general", label: "General" });
34
- list.push({ id: "inventory", label: "Inventory" });
35
- if (!product.virtual && !isExternal(product)) list.push({ id: "shipping", label: "Shipping" });
20
+ const list = [{ id: "price", label: "Price & Inventory" }, { id: "modifiers", label: "Modifiers" }];
21
+ if (product.downloadable) list.push({ id: "downloads", label: "Downloads" });
36
22
  list.push({ id: "linked", label: "Linked products" });
37
- list.push({ id: "attributes", label: "Attributes" });
38
- if (isVariable(product)) list.push({ id: "variations", label: "Variations" });
39
- if (product.downloadable && !isGrouped(product) && !isExternal(product))
40
- list.push({ id: "downloads", label: "Downloads" });
41
- list.push({ id: "advanced", label: "Advanced" });
42
23
  return list;
43
24
  }, [product]);
44
25
 
45
- const [active, setActive] = useState("general");
26
+ const [active, setActive] = useState("price");
46
27
  const activeTab = tabs.some((t) => t.id === active) ? active : tabs[0].id;
47
28
 
48
29
  return (
49
30
  <Card>
50
31
  <CardHeader className="flex-row items-center gap-4 space-y-0 border-b py-3">
51
32
  <span className="text-base font-semibold">Product data</span>
52
- <Select value={product.type} onValueChange={(type) => up({ type })}>
53
- <SelectTrigger className="h-8 w-52"><SelectValue /></SelectTrigger>
54
- <SelectContent>
55
- {PRODUCT_TYPES.map((t) => (
56
- <SelectItem key={t.value} value={t.value}>{t.label}</SelectItem>
57
- ))}
58
- </SelectContent>
59
- </Select>
60
- {isSimple(product) && (
61
- <div className="flex items-center gap-4 text-sm">
62
- <label className="flex items-center gap-1.5">
63
- <Checkbox
64
- checked={product.virtual}
65
- onCheckedChange={(v) => up({ virtual: Boolean(v) })}
66
- />
67
- Virtual
68
- </label>
69
- <label className="flex items-center gap-1.5">
70
- <Checkbox
71
- checked={product.downloadable}
72
- onCheckedChange={(v) => up({ downloadable: Boolean(v) })}
73
- />
74
- Downloadable
75
- </label>
76
- </div>
77
- )}
33
+ <div className="flex items-center gap-4 text-sm">
34
+ <label className="flex items-center gap-1.5">
35
+ <Checkbox
36
+ checked={product.virtual}
37
+ onCheckedChange={(v) => up({ virtual: Boolean(v) })}
38
+ />
39
+ Virtual
40
+ </label>
41
+ <label className="flex items-center gap-1.5">
42
+ <Checkbox
43
+ checked={product.downloadable}
44
+ onCheckedChange={(v) => up({ downloadable: Boolean(v) })}
45
+ />
46
+ Downloadable
47
+ </label>
48
+ </div>
78
49
  </CardHeader>
79
50
  <CardContent className="p-0">
80
51
  <div className="flex min-h-72 flex-col sm:flex-row">
@@ -97,18 +68,8 @@ export default function ProductDataPanel({ product, up, refs, variations, setVar
97
68
  </nav>
98
69
 
99
70
  <div className="min-w-0 flex-1 p-4">
100
- {activeTab === "general" &&
101
- (isExternal(product) ? (
102
- <ExternalTab product={product} up={up} refs={refs} />
103
- ) : (
104
- <GeneralTab product={product} up={up} refs={refs} />
105
- ))}
106
- {activeTab === "inventory" && <InventoryTab product={product} up={up} />}
107
- {activeTab === "shipping" && <ShippingTab product={product} up={up} refs={refs} />}
108
- {activeTab === "linked" && <LinkedTab product={product} up={up} />}
109
- {activeTab === "attributes" && <AttributesTab product={product} up={up} refs={refs} />}
110
- {activeTab === "variations" && (
111
- <VariationsTab
71
+ {activeTab === "price" && (
72
+ <PriceInventoryTab
112
73
  product={product}
113
74
  up={up}
114
75
  refs={refs}
@@ -116,13 +77,16 @@ export default function ProductDataPanel({ product, up, refs, variations, setVar
116
77
  setVariations={setVariations}
117
78
  />
118
79
  )}
80
+ {activeTab === "modifiers" && <ModifiersTab product={product} up={up} />}
119
81
  {activeTab === "downloads" && <DownloadsTab product={product} up={up} />}
120
- {activeTab === "advanced" && <AdvancedTab product={product} up={up} />}
82
+ {activeTab === "linked" && <LinkedTab product={product} up={up} />}
121
83
  </div>
122
84
  </div>
123
85
  <Separator />
124
86
  <div className="px-4 py-2 text-xs text-muted-foreground">
125
- Type: {PRODUCT_TYPES.find((t) => t.value === product.type)?.label}
87
+ {isVariable(product)
88
+ ? `${(product.attributes || []).length} variant attribute(s) · ${(variations || []).length} variant(s)`
89
+ : "No variants"}
126
90
  {product.virtual ? " · Virtual" : ""}
127
91
  {product.downloadable ? " · Downloadable" : ""}
128
92
  </div>
@@ -195,7 +195,7 @@ function TagsCard({ tags, selected, onChange, refresh }) {
195
195
  }
196
196
  setSaving(true);
197
197
  try {
198
- const created = await base44.entities["commerce.ProductTag"].create({ name: label, slug: slugify(label) });
198
+ const created = await base44.entities["commerce.ProductTag"].create({ name: label, count: 0 });
199
199
  toast.success("Tag created");
200
200
  setNewTag("");
201
201
  await refresh?.();
@@ -3,7 +3,6 @@ import { Label } from "@/components/ui/label";
3
3
 
4
4
  import { base44, call } from "../../../../lib/api";
5
5
  import SearchSelect from "../../../../components/SearchSelect";
6
- import { isGrouped } from "../../../../lib/product-utils";
7
6
 
8
7
  /** Search products server-side for the pickers. */
9
8
  const searchProducts = async (q) => {
@@ -60,22 +59,10 @@ function ProductIdsPicker({ ids, onChange, placeholder }) {
60
59
  );
61
60
  }
62
61
 
63
- /** Upsells, cross-sells and (for grouped products) child products. */
62
+ /** Upsells and cross-sells. */
64
63
  export default function LinkedTab({ product, up }) {
65
64
  return (
66
65
  <div className="max-w-md space-y-4">
67
- {isGrouped(product) && (
68
- <div className="space-y-1.5">
69
- <Label>Grouped products</Label>
70
- <ProductIdsPicker
71
- ids={product.grouped_products}
72
- onChange={(grouped_products) => up({ grouped_products })}
73
- placeholder="Search for products to group…"
74
- />
75
- <p className="text-xs text-muted-foreground">Products shown as part of this grouped product.</p>
76
- </div>
77
- )}
78
-
79
66
  <div className="space-y-1.5">
80
67
  <Label>Upsells</Label>
81
68
  <ProductIdsPicker
@@ -0,0 +1,19 @@
1
+ import React from "react";
2
+
3
+ import MetaDataEditor from "../../../../components/MetaDataEditor";
4
+
5
+ /**
6
+ * Descriptive properties — Material, Care, GTIN. They are `meta_data`, never
7
+ * attributes: they don't create variants and never affect price or stock.
8
+ */
9
+ export default function ModifiersTab({ product, up }) {
10
+ return (
11
+ <div className="max-w-xl space-y-2">
12
+ <p className="text-xs text-muted-foreground">
13
+ Shown as product details. They have no effect on price, stock or variants — what a product
14
+ varies by belongs in Attributes, under Price &amp; Inventory.
15
+ </p>
16
+ <MetaDataEditor value={product.meta_data || []} onChange={(meta_data) => up({ meta_data })} />
17
+ </div>
18
+ );
19
+ }