@base44/app-plugin-commerce 0.1.3 → 0.1.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -11
- package/base44/agents/commerce/StoreAdmin.jsonc +7 -7
- package/base44/entities/commerce.Order.jsonc +1 -1
- package/base44/entities/commerce.PaymentGateway.jsonc +3 -3
- package/base44/entities/commerce.Product.jsonc +9 -45
- package/base44/entities/commerce.ProductAttribute.jsonc +7 -19
- package/base44/entities/commerce.ProductAttributeTerm.jsonc +4 -10
- package/base44/entities/commerce.ProductTag.jsonc +1 -8
- package/base44/entities/commerce.ProductVariation.jsonc +1 -1
- package/base44/entities/commerce.StoreSettings.jsonc +1 -1
- package/base44/functions/commerce/admin-products/entry.ts +73 -121
- package/base44/functions/commerce/admin-refunds/entry.ts +1 -1
- package/base44/functions/commerce/admin-reports/entry.ts +1 -1
- package/base44/functions/commerce/admin-tools/entry.ts +4 -3
- package/base44/functions/commerce/payments/entry.ts +0 -2
- package/base44/functions/commerce/seed-store/defaults.ts +13 -39
- package/base44/functions/commerce/seed-store/entry.ts +50 -166
- package/base44/functions/commerce/seed-store/sample-data.ts +28 -66
- package/base44/functions/commerce/seed-store/seed-catalog.ts +717 -0
- package/base44/functions/commerce/storefront-cart/entry.ts +0 -7
- package/base44/functions/commerce/storefront-catalog/entry.ts +12 -21
- package/base44/functions/commerce/storefront-checkout/entry.ts +5 -12
- package/base44/shared/commerce/catalog.ts +134 -0
- package/base44/shared/commerce/coupons.ts +4 -7
- package/base44/shared/commerce/email-templates.ts +15 -13
- package/base44/shared/commerce/emails.ts +4 -3
- package/base44/shared/commerce/payments.ts +8 -20
- package/base44/shared/commerce/products.ts +24 -0
- package/base44/shared/commerce/settings.ts +2 -3
- package/base44/shared/commerce/stock.ts +2 -4
- package/base44/shared/commerce/totals.ts +37 -42
- package/package.json +1 -1
- package/scripts/install.js +23 -7
- package/skills/commerce/SKILL.md +9 -7
- package/skills/commerce/docs/api-admin.md +20 -14
- package/skills/commerce/docs/api-storefront.md +39 -35
- package/skills/commerce/installation-guidelines.md +8 -7
- package/skills/commerce/post-installation.md +205 -84
- package/skills/commerce/references/admin-product-form.md +56 -0
- package/skills/commerce/references/emails.md +4 -3
- package/skills/commerce/references/guest-access-security.md +2 -2
- package/skills/commerce/references/limits-and-performance.md +1 -1
- package/skills/commerce/references/online-payments.md +8 -8
- package/skills/commerce/references/product-render.md +25 -23
- package/skills/commerce/references/reviews.md +20 -0
- package/skills/commerce/references/storefront-product-page.md +9 -9
- package/skills/commerce/references/webhooks.md +3 -1
- package/src/commerce/admin/README.md +4 -4
- package/src/commerce/admin/context/BasePathContext.jsx +5 -5
- package/src/commerce/admin/context/SettingsContext.jsx +4 -4
- package/src/commerce/admin/index.jsx +3 -3
- package/src/commerce/admin/layout/Sidebar.jsx +1 -9
- package/src/commerce/admin/lib/constants.js +0 -7
- package/src/commerce/admin/lib/paths.js +6 -6
- package/src/commerce/admin/lib/product-utils.js +18 -15
- package/src/commerce/admin/pages/orders/components/AddProductDialog.jsx +8 -8
- package/src/commerce/admin/pages/products/ProductEditor.jsx +10 -12
- package/src/commerce/admin/pages/products/ProductsList.jsx +3 -15
- package/src/commerce/admin/pages/products/components/AttributesSection.jsx +426 -0
- package/src/commerce/admin/pages/products/components/ProductDataPanel.jsx +33 -69
- package/src/commerce/admin/pages/products/components/TaxonomyPanel.jsx +1 -1
- package/src/commerce/admin/pages/products/components/tabs/LinkedTab.jsx +1 -14
- package/src/commerce/admin/pages/products/components/tabs/ModifiersTab.jsx +19 -0
- package/src/commerce/admin/pages/products/components/tabs/PriceInventoryTab.jsx +728 -0
- package/src/commerce/admin/pages/reports/Reports.jsx +3 -3
- package/src/commerce/admin/pages/settings/EmailsSettings.jsx +12 -14
- package/src/commerce/admin/pages/settings/GeneralSettings.jsx +6 -116
- package/src/commerce/admin/pages/settings/PaymentsSettings.jsx +101 -62
- package/src/commerce/admin/pages/settings/SettingsLayout.jsx +2 -6
- package/src/commerce/admin/pages/settings/TaxSettings.jsx +0 -1
- package/src/commerce/admin/pages/status/WebhookEditor.jsx +3 -3
- package/src/commerce/admin/pages/status/Webhooks.jsx +2 -2
- package/src/commerce/admin/routes.jsx +10 -19
- package/src/commerce/utils/index.js +2 -2
- package/src/commerce/utils/shipping-promos.js +9 -6
- package/src/commerce/utils/variants.js +21 -21
- package/src/commerce/admin/pages/products/AttributeTerms.jsx +0 -180
- package/src/commerce/admin/pages/products/Attributes.jsx +0 -183
- package/src/commerce/admin/pages/products/Tags.jsx +0 -150
- package/src/commerce/admin/pages/products/components/tabs/AdvancedTab.jsx +0 -48
- package/src/commerce/admin/pages/products/components/tabs/AttributesTab.jsx +0 -208
- package/src/commerce/admin/pages/products/components/tabs/ExternalTab.jsx +0 -41
- package/src/commerce/admin/pages/products/components/tabs/GeneralTab.jsx +0 -103
- package/src/commerce/admin/pages/products/components/tabs/InventoryTab.jsx +0 -93
- package/src/commerce/admin/pages/products/components/tabs/ShippingTab.jsx +0 -86
- package/src/commerce/admin/pages/products/components/tabs/VariationsTab.jsx +0 -377
|
@@ -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
|
|
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(
|
|
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 = {
|
|
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 {
|
|
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,
|
|
158
|
+
[usingSearch, debouncedQ, category, stock]
|
|
162
159
|
);
|
|
163
160
|
|
|
164
|
-
const list = usePagedList(fetcher, { deps: [debouncedQ, category?.value,
|
|
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
|
+
}
|