@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.
- package/README.md +8 -8
- 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 +107 -35
- 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 +47 -35
- package/base44/functions/commerce/seed-store/sample-data.ts +28 -66
- 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/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 +21 -4
- package/skills/commerce/SKILL.md +6 -5
- package/skills/commerce/docs/api-admin.md +16 -14
- package/skills/commerce/docs/api-storefront.md +39 -35
- package/skills/commerce/installation-guidelines.md +4 -3
- package/skills/commerce/post-installation.md +22 -25
- 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/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
|
@@ -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 {
|
|
15
|
-
import
|
|
16
|
-
import
|
|
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:
|
|
28
|
-
*
|
|
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 (
|
|
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("
|
|
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
|
-
<
|
|
53
|
-
<
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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 === "
|
|
101
|
-
|
|
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 === "
|
|
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
|
-
|
|
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,
|
|
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
|
|
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 & Inventory.
|
|
15
|
+
</p>
|
|
16
|
+
<MetaDataEditor value={product.meta_data || []} onChange={(meta_data) => up({ meta_data })} />
|
|
17
|
+
</div>
|
|
18
|
+
);
|
|
19
|
+
}
|