@base44/app-plugin-commerce 0.1.3 → 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. package/README.md +11 -11
  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 +73 -121
  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 +50 -166
  18. package/base44/functions/commerce/seed-store/sample-data.ts +28 -66
  19. package/base44/functions/commerce/seed-store/seed-catalog.ts +717 -0
  20. package/base44/functions/commerce/storefront-cart/entry.ts +0 -7
  21. package/base44/functions/commerce/storefront-catalog/entry.ts +12 -21
  22. package/base44/functions/commerce/storefront-checkout/entry.ts +5 -12
  23. package/base44/shared/commerce/catalog.ts +134 -0
  24. package/base44/shared/commerce/coupons.ts +4 -7
  25. package/base44/shared/commerce/email-templates.ts +15 -13
  26. package/base44/shared/commerce/emails.ts +4 -3
  27. package/base44/shared/commerce/payments.ts +8 -20
  28. package/base44/shared/commerce/products.ts +24 -0
  29. package/base44/shared/commerce/settings.ts +2 -3
  30. package/base44/shared/commerce/stock.ts +2 -4
  31. package/base44/shared/commerce/totals.ts +37 -42
  32. package/package.json +1 -1
  33. package/scripts/install.js +23 -7
  34. package/skills/commerce/SKILL.md +9 -7
  35. package/skills/commerce/docs/api-admin.md +20 -14
  36. package/skills/commerce/docs/api-storefront.md +39 -35
  37. package/skills/commerce/installation-guidelines.md +8 -7
  38. package/skills/commerce/post-installation.md +205 -84
  39. package/skills/commerce/references/admin-product-form.md +56 -0
  40. package/skills/commerce/references/emails.md +4 -3
  41. package/skills/commerce/references/guest-access-security.md +2 -2
  42. package/skills/commerce/references/limits-and-performance.md +1 -1
  43. package/skills/commerce/references/online-payments.md +8 -8
  44. package/skills/commerce/references/product-render.md +25 -23
  45. package/skills/commerce/references/reviews.md +20 -0
  46. package/skills/commerce/references/storefront-product-page.md +9 -9
  47. package/skills/commerce/references/webhooks.md +3 -1
  48. package/src/commerce/admin/README.md +4 -4
  49. package/src/commerce/admin/context/BasePathContext.jsx +5 -5
  50. package/src/commerce/admin/context/SettingsContext.jsx +4 -4
  51. package/src/commerce/admin/index.jsx +3 -3
  52. package/src/commerce/admin/layout/Sidebar.jsx +1 -9
  53. package/src/commerce/admin/lib/constants.js +0 -7
  54. package/src/commerce/admin/lib/paths.js +6 -6
  55. package/src/commerce/admin/lib/product-utils.js +18 -15
  56. package/src/commerce/admin/pages/orders/components/AddProductDialog.jsx +8 -8
  57. package/src/commerce/admin/pages/products/ProductEditor.jsx +10 -12
  58. package/src/commerce/admin/pages/products/ProductsList.jsx +3 -15
  59. package/src/commerce/admin/pages/products/components/AttributesSection.jsx +426 -0
  60. package/src/commerce/admin/pages/products/components/ProductDataPanel.jsx +33 -69
  61. package/src/commerce/admin/pages/products/components/TaxonomyPanel.jsx +1 -1
  62. package/src/commerce/admin/pages/products/components/tabs/LinkedTab.jsx +1 -14
  63. package/src/commerce/admin/pages/products/components/tabs/ModifiersTab.jsx +19 -0
  64. package/src/commerce/admin/pages/products/components/tabs/PriceInventoryTab.jsx +728 -0
  65. package/src/commerce/admin/pages/reports/Reports.jsx +3 -3
  66. package/src/commerce/admin/pages/settings/EmailsSettings.jsx +12 -14
  67. package/src/commerce/admin/pages/settings/GeneralSettings.jsx +6 -116
  68. package/src/commerce/admin/pages/settings/PaymentsSettings.jsx +101 -62
  69. package/src/commerce/admin/pages/settings/SettingsLayout.jsx +2 -6
  70. package/src/commerce/admin/pages/settings/TaxSettings.jsx +0 -1
  71. package/src/commerce/admin/pages/status/WebhookEditor.jsx +3 -3
  72. package/src/commerce/admin/pages/status/Webhooks.jsx +2 -2
  73. package/src/commerce/admin/routes.jsx +10 -19
  74. package/src/commerce/utils/index.js +2 -2
  75. package/src/commerce/utils/shipping-promos.js +9 -6
  76. package/src/commerce/utils/variants.js +21 -21
  77. package/src/commerce/admin/pages/products/AttributeTerms.jsx +0 -180
  78. package/src/commerce/admin/pages/products/Attributes.jsx +0 -183
  79. package/src/commerce/admin/pages/products/Tags.jsx +0 -150
  80. package/src/commerce/admin/pages/products/components/tabs/AdvancedTab.jsx +0 -48
  81. package/src/commerce/admin/pages/products/components/tabs/AttributesTab.jsx +0 -208
  82. package/src/commerce/admin/pages/products/components/tabs/ExternalTab.jsx +0 -41
  83. package/src/commerce/admin/pages/products/components/tabs/GeneralTab.jsx +0 -103
  84. package/src/commerce/admin/pages/products/components/tabs/InventoryTab.jsx +0 -93
  85. package/src/commerce/admin/pages/products/components/tabs/ShippingTab.jsx +0 -86
  86. package/src/commerce/admin/pages/products/components/tabs/VariationsTab.jsx +0 -377
@@ -11,7 +11,7 @@ import { useAdminHref } from "../../context/BasePathContext";
11
11
  import PageHeader from "../../components/PageHeader";
12
12
  import MediaUploader from "../../components/MediaUploader";
13
13
  import RichTextarea from "../../components/RichTextarea";
14
- import { slugify, isVariable } from "../../lib/product-utils";
14
+ import { slugify } from "../../lib/product-utils";
15
15
  import ProductDataPanel from "./components/ProductDataPanel";
16
16
  import TaxonomyPanel from "./components/TaxonomyPanel";
17
17
  import PublishBox from "./components/PublishBox";
@@ -19,7 +19,6 @@ import PublishBox from "./components/PublishBox";
19
19
  const NEW_PRODUCT = {
20
20
  name: "",
21
21
  slug: "",
22
- type: "simple",
23
22
  status: "draft",
24
23
  featured: false,
25
24
  catalog_visibility: "visible",
@@ -35,8 +34,6 @@ const NEW_PRODUCT = {
35
34
  downloads: [],
36
35
  download_limit: -1,
37
36
  download_expiry: -1,
38
- external_url: "",
39
- button_text: "",
40
37
  tax_status: "taxable",
41
38
  tax_class: "standard",
42
39
  manage_stock: false,
@@ -48,17 +45,13 @@ const NEW_PRODUCT = {
48
45
  weight: null,
49
46
  dimensions: { length: null, width: null, height: null },
50
47
  shipping_class_id: "",
51
- reviews_allowed: true,
52
48
  upsell_ids: [],
53
49
  cross_sell_ids: [],
54
- grouped_products: [],
55
- purchase_note: "",
56
50
  category_ids: [],
57
51
  tag_ids: [],
58
52
  images: [],
59
53
  attributes: [],
60
54
  default_attributes: [],
61
- menu_order: 0,
62
55
  meta_data: [],
63
56
  };
64
57
 
@@ -83,7 +76,10 @@ export default function ProductEditor() {
83
76
  () => base44.entities["commerce.ProductTag"].list(undefined, 1000),
84
77
  []
85
78
  );
86
- const { data: attributes } = useAsync(() => base44.entities["commerce.ProductAttribute"].list(undefined, 500), []);
79
+ const { data: attributes, refetch: refreshAttributes } = useAsync(
80
+ () => base44.entities["commerce.ProductAttribute"].list("order", 500),
81
+ []
82
+ );
87
83
  const { data: shippingClasses } = useAsync(() => base44.entities["commerce.ShippingClass"].list(undefined, 500), []);
88
84
  const { data: taxClasses } = useAsync(() => base44.entities["commerce.TaxClass"].list(undefined, 100), []);
89
85
 
@@ -127,8 +123,7 @@ export default function ProductEditor() {
127
123
  }
128
124
  setSaving(true);
129
125
  try {
130
- const payload = { product: { ...product, name: product.name.trim() } };
131
- if (isVariable(product)) payload.variations = variations;
126
+ const payload = { product: { ...product, name: product.name.trim() }, variations };
132
127
  const data = await call("admin-products", "save", payload);
133
128
  const savedId = data?.product?.id || data?.id || id;
134
129
  toast.success(isNew ? "Product created" : "Product saved");
@@ -162,7 +157,10 @@ export default function ProductEditor() {
162
157
  );
163
158
  }
164
159
 
165
- const refs = { categories, tags, attributes, shippingClasses, taxClasses, refreshCategories, refreshTags };
160
+ const refs = {
161
+ categories, tags, attributes, shippingClasses, taxClasses,
162
+ refreshCategories, refreshTags, refreshAttributes,
163
+ };
166
164
 
167
165
  return (
168
166
  <div className="space-y-4">
@@ -32,7 +32,7 @@ import SearchSelect from "../../components/SearchSelect";
32
32
  import StatusBadge from "../../components/StatusBadge";
33
33
  import MoneyInput from "../../components/MoneyInput";
34
34
  import ConfirmDialog from "../../components/ConfirmDialog";
35
- import { PRODUCT_TYPES, PRODUCT_STATUSES, STOCK_STATUSES, statusMeta } from "../../lib/constants";
35
+ import { PRODUCT_STATUSES, STOCK_STATUSES, statusMeta } from "../../lib/constants";
36
36
  import { formatDate } from "../../lib/format";
37
37
 
38
38
  const ALL = "all";
@@ -123,7 +123,6 @@ export default function ProductsList() {
123
123
 
124
124
  const [q, setQ] = useState("");
125
125
  const [category, setCategory] = useState(null);
126
- const [type, setType] = useState(ALL);
127
126
  const [stock, setStock] = useState(ALL);
128
127
  const [selected, setSelected] = useState([]);
129
128
  const [quickEdit, setQuickEdit] = useState(null);
@@ -145,7 +144,6 @@ export default function ProductsList() {
145
144
  const data = await call("admin-products", "search", {
146
145
  q: debouncedQ || undefined,
147
146
  category_id: category?.value || undefined,
148
- type: type === ALL ? undefined : type,
149
147
  stock_status: stock === ALL ? undefined : stock,
150
148
  sort,
151
149
  limit,
@@ -154,14 +152,13 @@ export default function ProductsList() {
154
152
  return data?.rows || data || [];
155
153
  }
156
154
  const query = {};
157
- if (type !== ALL) query.type = type;
158
155
  if (stock !== ALL) query.stock_status = stock;
159
156
  return base44.entities["commerce.Product"].filter(query, sort, limit, skip);
160
157
  },
161
- [usingSearch, debouncedQ, category, type, stock]
158
+ [usingSearch, debouncedQ, category, stock]
162
159
  );
163
160
 
164
- const list = usePagedList(fetcher, { deps: [debouncedQ, category?.value, type, stock] });
161
+ const list = usePagedList(fetcher, { deps: [debouncedQ, category?.value, stock] });
165
162
 
166
163
  const toggleFeatured = async (row) => {
167
164
  await call("admin-products", "save", { product: { id: row.id, featured: !row.featured } });
@@ -322,15 +319,6 @@ export default function ProductsList() {
322
319
  .map((c) => ({ value: c.id, label: c.name }))
323
320
  }
324
321
  />
325
- <Select value={type} onValueChange={setType}>
326
- <SelectTrigger className="w-44"><SelectValue /></SelectTrigger>
327
- <SelectContent>
328
- <SelectItem value={ALL}>All types</SelectItem>
329
- {PRODUCT_TYPES.map((t) => (
330
- <SelectItem key={t.value} value={t.value}>{t.label}</SelectItem>
331
- ))}
332
- </SelectContent>
333
- </Select>
334
322
  <Select value={stock} onValueChange={setStock}>
335
323
  <SelectTrigger className="w-40"><SelectValue /></SelectTrigger>
336
324
  <SelectContent>
@@ -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
+ }