@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
|
@@ -8,18 +8,21 @@
|
|
|
8
8
|
* exactly when `min_amount`/coupon conditions hold, so anything the UI promises
|
|
9
9
|
* outside these rules is a promise the checkout will not keep.
|
|
10
10
|
*
|
|
11
|
-
* `commerce.ShippingZone` and
|
|
12
|
-
*
|
|
11
|
+
* **Zones are admin-only data.** `commerce.ShippingZone` and
|
|
12
|
+
* `commerce.ShippingZoneMethod` are `read: {role: "admin"}` like every entity, so
|
|
13
|
+
* a visitor cannot list them — these helpers run where the records are already in
|
|
14
|
+
* hand: a backend function, an admin screen, or a storefront action you add that
|
|
15
|
+
* projects the rules itself.
|
|
13
16
|
*
|
|
14
17
|
* ```js
|
|
15
|
-
* const [zones, methods] = await Promise.all([
|
|
16
|
-
* base44.entities["commerce.ShippingZone"].list("order", 100),
|
|
17
|
-
* base44.entities["commerce.ShippingZoneMethod"].list(undefined, 200),
|
|
18
|
-
* ]);
|
|
19
18
|
* const rules = freeShippingRules(zones, methods);
|
|
20
19
|
* // no rules → do not advertise free shipping anywhere.
|
|
21
20
|
* ```
|
|
22
21
|
*
|
|
22
|
+
* For the *live* answer on a real cart, use the cart view's
|
|
23
|
+
* `available_shipping_methods` after `set-shipping-address` — that is computed by
|
|
24
|
+
* the same engine and needs no extra exposure.
|
|
25
|
+
*
|
|
23
26
|
* Framework-free and dependency-free. See
|
|
24
27
|
* `skills/commerce/docs/api-storefront.md` for the surrounding rules.
|
|
25
28
|
*/
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Variant selection helpers for the storefront product page.
|
|
3
3
|
*
|
|
4
|
-
* A
|
|
5
|
-
* entries
|
|
6
|
-
* in `variations[]` is one combination of options on those axes. The
|
|
7
|
-
* page must therefore present **one control per axis** — never a flat
|
|
8
|
-
* `Red-S`, `Red-M`, `Blue-S`, ... — and map the customer's selection
|
|
9
|
-
* variation itself: the cart API does no attribute matching, it takes a
|
|
4
|
+
* A product with variants is defined on two levels: the parent's `attributes[]`
|
|
5
|
+
* entries are the *axes* (Size, Color) — every attribute is one — and each
|
|
6
|
+
* record in `variations[]` is one combination of options on those axes. The
|
|
7
|
+
* product page must therefore present **one control per axis** — never a flat
|
|
8
|
+
* list of `Red-S`, `Red-M`, `Blue-S`, ... — and map the customer's selection
|
|
9
|
+
* onto a variation itself: the cart API does no attribute matching, it takes a
|
|
10
10
|
* `variation_id`. These helpers own that mapping in both directions.
|
|
11
11
|
*
|
|
12
12
|
* Framework-free, dependency-free, no I/O — feed them the `{ product,
|
|
@@ -28,8 +28,9 @@ const PURCHASABLE_STOCK = new Set(["instock", "onbackorder"]);
|
|
|
28
28
|
/**
|
|
29
29
|
* Stable key for an attribute or a variation-attribute entry.
|
|
30
30
|
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
31
|
+
* Attributes carry an `attribute_id`, but variation and `default_attributes`
|
|
32
|
+
* entries are written with a possibly-empty one, so fall back to `name` rather
|
|
33
|
+
* than keying every such entry to "".
|
|
33
34
|
*
|
|
34
35
|
* @param {{attribute_id?: string, name?: string}} attribute
|
|
35
36
|
* @returns {string}
|
|
@@ -39,16 +40,15 @@ export function attributeKey(attribute) {
|
|
|
39
40
|
}
|
|
40
41
|
|
|
41
42
|
/**
|
|
42
|
-
* The product's variation axes: `attributes`
|
|
43
|
-
*
|
|
44
|
-
*
|
|
43
|
+
* The product's variation axes: every `attributes` entry, ordered by `position`.
|
|
44
|
+
* These become the selectors on the product page. Descriptive properties are not
|
|
45
|
+
* attributes — they are `meta_data` entries and belong in a spec table.
|
|
45
46
|
*
|
|
46
47
|
* @param {object} product
|
|
47
|
-
* @returns {Array<object>} the
|
|
48
|
+
* @returns {Array<object>} the `product.attributes` entries
|
|
48
49
|
*/
|
|
49
50
|
export function variationAxes(product) {
|
|
50
51
|
return (product?.attributes ?? [])
|
|
51
|
-
.filter((a) => a?.variation)
|
|
52
52
|
.slice()
|
|
53
53
|
.sort((a, b) => (a.position ?? 0) - (b.position ?? 0));
|
|
54
54
|
}
|
|
@@ -246,9 +246,8 @@ export function defaultSelection(product, variations) {
|
|
|
246
246
|
|
|
247
247
|
/**
|
|
248
248
|
* Price range across the variations still reachable from `selection` (all of
|
|
249
|
-
* them by default). Use this for
|
|
250
|
-
*
|
|
251
|
-
* is not a variable product's price.
|
|
249
|
+
* them by default). Use this for the headline price: the parent's `price` is
|
|
250
|
+
* rolled up from the *cheapest* variant, so it is a from-price, not the range.
|
|
252
251
|
*
|
|
253
252
|
* @returns {{min: number, max: number, on_sale: boolean, count: number}|null} `null` when nothing is priced
|
|
254
253
|
*/
|
|
@@ -317,10 +316,11 @@ export function displayFields(product, variation) {
|
|
|
317
316
|
* // on a click: selection = selectOption(product, variations, selection, axisKey, option)
|
|
318
317
|
* ```
|
|
319
318
|
*
|
|
320
|
-
* Branches on
|
|
321
|
-
* product). A
|
|
322
|
-
* usable variations yields empty `axes[].options`,
|
|
323
|
-
* `addToCart: null` — render it unavailable and guard
|
|
319
|
+
* Branches on whether the product has attributes, not on `variations` (pass `[]`
|
|
320
|
+
* for a product without them). A product with axes never falls back to the
|
|
321
|
+
* parent, so one with no usable variations yields empty `axes[].options`,
|
|
322
|
+
* `purchasable: false` and `addToCart: null` — render it unavailable and guard
|
|
323
|
+
* the "select a …" label.
|
|
324
324
|
*
|
|
325
325
|
* @returns {{
|
|
326
326
|
* isVariable: boolean, axes: Array<object>, selection: Record<string, string>,
|
|
@@ -332,7 +332,7 @@ export function displayFields(product, variation) {
|
|
|
332
332
|
* }}
|
|
333
333
|
*/
|
|
334
334
|
export function resolveSelection(product, variations, selection = {}) {
|
|
335
|
-
const isVariable = (product
|
|
335
|
+
const isVariable = variationAxes(product).length > 0;
|
|
336
336
|
if (!isVariable) {
|
|
337
337
|
const display = displayFields(product, null);
|
|
338
338
|
return {
|
|
@@ -1,180 +0,0 @@
|
|
|
1
|
-
import React, { useEffect, useState } from "react";
|
|
2
|
-
import { useParams } from "react-router-dom";
|
|
3
|
-
import { Button } from "@/components/ui/button";
|
|
4
|
-
import { Input } from "@/components/ui/input";
|
|
5
|
-
import { Label } from "@/components/ui/label";
|
|
6
|
-
import { Textarea } from "@/components/ui/textarea";
|
|
7
|
-
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
8
|
-
import { List, Loader2, Pencil, Trash2 } from "lucide-react";
|
|
9
|
-
import { toast } from "sonner";
|
|
10
|
-
|
|
11
|
-
import { base44 } from "../../lib/api";
|
|
12
|
-
import useAsync from "../../hooks/useAsync";
|
|
13
|
-
import { useAdminHref } from "../../context/BasePathContext";
|
|
14
|
-
import PageHeader from "../../components/PageHeader";
|
|
15
|
-
import DataTable from "../../components/DataTable";
|
|
16
|
-
import ConfirmDialog from "../../components/ConfirmDialog";
|
|
17
|
-
import { slugify } from "../../lib/product-utils";
|
|
18
|
-
|
|
19
|
-
const blank = () => ({ id: null, name: "", slug: "", description: "", menu_order: 0 });
|
|
20
|
-
|
|
21
|
-
export default function AttributeTerms() {
|
|
22
|
-
const { id } = useParams();
|
|
23
|
-
const href = useAdminHref();
|
|
24
|
-
|
|
25
|
-
const { data: attribute } = useAsync(() => base44.entities["commerce.ProductAttribute"].get(id), [id]);
|
|
26
|
-
const { data: terms, loading, refetch } = useAsync(
|
|
27
|
-
() => base44.entities["commerce.ProductAttributeTerm"].filter({ attribute_id: id }, "menu_order", 1000),
|
|
28
|
-
[id]
|
|
29
|
-
);
|
|
30
|
-
|
|
31
|
-
const [form, setForm] = useState(blank());
|
|
32
|
-
const [slugTouched, setSlugTouched] = useState(false);
|
|
33
|
-
const [saving, setSaving] = useState(false);
|
|
34
|
-
const [confirmDelete, setConfirmDelete] = useState(null);
|
|
35
|
-
const [deleting, setDeleting] = useState(false);
|
|
36
|
-
|
|
37
|
-
useEffect(() => {
|
|
38
|
-
setForm(blank());
|
|
39
|
-
setSlugTouched(false);
|
|
40
|
-
}, [id]);
|
|
41
|
-
|
|
42
|
-
const setName = (name) => setForm((f) => ({ ...f, name, slug: slugTouched ? f.slug : slugify(name) }));
|
|
43
|
-
const reset = () => {
|
|
44
|
-
setForm(blank());
|
|
45
|
-
setSlugTouched(false);
|
|
46
|
-
};
|
|
47
|
-
const edit = (t) => {
|
|
48
|
-
setForm({
|
|
49
|
-
id: t.id,
|
|
50
|
-
name: t.name || "",
|
|
51
|
-
slug: t.slug || "",
|
|
52
|
-
description: t.description || "",
|
|
53
|
-
menu_order: t.menu_order || 0,
|
|
54
|
-
});
|
|
55
|
-
setSlugTouched(true);
|
|
56
|
-
};
|
|
57
|
-
|
|
58
|
-
const save = async () => {
|
|
59
|
-
if (!form.name.trim()) {
|
|
60
|
-
toast.error("Name is required");
|
|
61
|
-
return;
|
|
62
|
-
}
|
|
63
|
-
setSaving(true);
|
|
64
|
-
try {
|
|
65
|
-
const payload = {
|
|
66
|
-
attribute_id: id,
|
|
67
|
-
name: form.name.trim(),
|
|
68
|
-
slug: form.slug || slugify(form.name),
|
|
69
|
-
description: form.description,
|
|
70
|
-
menu_order: Number(form.menu_order) || 0,
|
|
71
|
-
};
|
|
72
|
-
if (form.id) await base44.entities["commerce.ProductAttributeTerm"].update(form.id, payload);
|
|
73
|
-
else await base44.entities["commerce.ProductAttributeTerm"].create(payload);
|
|
74
|
-
toast.success(form.id ? "Term updated" : "Term created");
|
|
75
|
-
reset();
|
|
76
|
-
refetch();
|
|
77
|
-
} catch (e) {
|
|
78
|
-
toast.error(e.message || "Failed to save term");
|
|
79
|
-
} finally {
|
|
80
|
-
setSaving(false);
|
|
81
|
-
}
|
|
82
|
-
};
|
|
83
|
-
|
|
84
|
-
const doDelete = async () => {
|
|
85
|
-
setDeleting(true);
|
|
86
|
-
try {
|
|
87
|
-
await base44.entities["commerce.ProductAttributeTerm"].delete(confirmDelete.id);
|
|
88
|
-
toast.success("Term deleted");
|
|
89
|
-
if (form.id === confirmDelete.id) reset();
|
|
90
|
-
setConfirmDelete(null);
|
|
91
|
-
refetch();
|
|
92
|
-
} catch (e) {
|
|
93
|
-
toast.error(e.message || "Failed to delete term");
|
|
94
|
-
} finally {
|
|
95
|
-
setDeleting(false);
|
|
96
|
-
}
|
|
97
|
-
};
|
|
98
|
-
|
|
99
|
-
const columns = [
|
|
100
|
-
{ key: "name", label: "Name", render: (row) => <span className="font-medium">{row.name}</span> },
|
|
101
|
-
{ key: "slug", label: "Slug", className: "hidden md:table-cell text-muted-foreground" },
|
|
102
|
-
{ key: "menu_order", label: "Order", className: "hidden md:table-cell w-20" },
|
|
103
|
-
];
|
|
104
|
-
|
|
105
|
-
return (
|
|
106
|
-
<div className="space-y-4">
|
|
107
|
-
<PageHeader
|
|
108
|
-
title={attribute ? `${attribute.name} — terms` : "Terms"}
|
|
109
|
-
description="Values for this attribute (e.g. Red, Blue, Green)"
|
|
110
|
-
backHref={href("products/attributes")}
|
|
111
|
-
/>
|
|
112
|
-
<div className="grid gap-4 lg:grid-cols-[340px_1fr]">
|
|
113
|
-
<Card className="h-fit">
|
|
114
|
-
<CardHeader className="pb-2">
|
|
115
|
-
<CardTitle className="text-base">{form.id ? "Edit term" : "Add new term"}</CardTitle>
|
|
116
|
-
</CardHeader>
|
|
117
|
-
<CardContent className="space-y-3">
|
|
118
|
-
<div className="space-y-1.5">
|
|
119
|
-
<Label>Name</Label>
|
|
120
|
-
<Input value={form.name} onChange={(e) => setName(e.target.value)} />
|
|
121
|
-
</div>
|
|
122
|
-
<div className="space-y-1.5">
|
|
123
|
-
<Label>Slug</Label>
|
|
124
|
-
<Input
|
|
125
|
-
value={form.slug}
|
|
126
|
-
onChange={(e) => {
|
|
127
|
-
setSlugTouched(true);
|
|
128
|
-
setForm((f) => ({ ...f, slug: e.target.value }));
|
|
129
|
-
}}
|
|
130
|
-
/>
|
|
131
|
-
</div>
|
|
132
|
-
<div className="space-y-1.5">
|
|
133
|
-
<Label>Description</Label>
|
|
134
|
-
<Textarea
|
|
135
|
-
rows={2}
|
|
136
|
-
value={form.description}
|
|
137
|
-
onChange={(e) => setForm((f) => ({ ...f, description: e.target.value }))}
|
|
138
|
-
/>
|
|
139
|
-
</div>
|
|
140
|
-
<div className="space-y-1.5">
|
|
141
|
-
<Label>Menu order</Label>
|
|
142
|
-
<Input
|
|
143
|
-
type="number"
|
|
144
|
-
value={form.menu_order}
|
|
145
|
-
onChange={(e) => setForm((f) => ({ ...f, menu_order: e.target.value }))}
|
|
146
|
-
/>
|
|
147
|
-
</div>
|
|
148
|
-
<div className="flex gap-2">
|
|
149
|
-
<Button onClick={save} disabled={saving}>
|
|
150
|
-
{saving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
|
151
|
-
{form.id ? "Update" : "Add term"}
|
|
152
|
-
</Button>
|
|
153
|
-
{form.id && <Button variant="ghost" onClick={reset} disabled={saving}>Cancel</Button>}
|
|
154
|
-
</div>
|
|
155
|
-
</CardContent>
|
|
156
|
-
</Card>
|
|
157
|
-
|
|
158
|
-
<DataTable
|
|
159
|
-
columns={columns}
|
|
160
|
-
rows={terms || []}
|
|
161
|
-
loading={loading}
|
|
162
|
-
rowActions={(row) => [
|
|
163
|
-
{ label: "Edit", icon: Pencil, onClick: () => edit(row) },
|
|
164
|
-
{ label: "Delete", icon: Trash2, destructive: true, onClick: () => setConfirmDelete(row) },
|
|
165
|
-
]}
|
|
166
|
-
empty={{ icon: List, title: "No terms yet", description: "Add the first value for this attribute." }}
|
|
167
|
-
/>
|
|
168
|
-
</div>
|
|
169
|
-
|
|
170
|
-
<ConfirmDialog
|
|
171
|
-
open={Boolean(confirmDelete)}
|
|
172
|
-
onOpenChange={(o) => !o && setConfirmDelete(null)}
|
|
173
|
-
title={`Delete "${confirmDelete?.name}"?`}
|
|
174
|
-
confirmLabel="Delete"
|
|
175
|
-
onConfirm={doDelete}
|
|
176
|
-
loading={deleting}
|
|
177
|
-
/>
|
|
178
|
-
</div>
|
|
179
|
-
);
|
|
180
|
-
}
|
|
@@ -1,183 +0,0 @@
|
|
|
1
|
-
import React, { useState } from "react";
|
|
2
|
-
import { useNavigate } from "react-router-dom";
|
|
3
|
-
import { Button } from "@/components/ui/button";
|
|
4
|
-
import { Input } from "@/components/ui/input";
|
|
5
|
-
import { Label } from "@/components/ui/label";
|
|
6
|
-
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
7
|
-
import {
|
|
8
|
-
Select,
|
|
9
|
-
SelectContent,
|
|
10
|
-
SelectItem,
|
|
11
|
-
SelectTrigger,
|
|
12
|
-
SelectValue,
|
|
13
|
-
} from "@/components/ui/select";
|
|
14
|
-
import { Layers, Loader2, Pencil, Settings2, Trash2 } from "lucide-react";
|
|
15
|
-
import { toast } from "sonner";
|
|
16
|
-
|
|
17
|
-
import { base44 } from "../../lib/api";
|
|
18
|
-
import useAsync from "../../hooks/useAsync";
|
|
19
|
-
import { useAdminHref } from "../../context/BasePathContext";
|
|
20
|
-
import PageHeader from "../../components/PageHeader";
|
|
21
|
-
import DataTable from "../../components/DataTable";
|
|
22
|
-
import ConfirmDialog from "../../components/ConfirmDialog";
|
|
23
|
-
import { slugify } from "../../lib/product-utils";
|
|
24
|
-
|
|
25
|
-
const ORDER_BY = [
|
|
26
|
-
{ value: "menu_order", label: "Custom ordering" },
|
|
27
|
-
{ value: "name", label: "Name" },
|
|
28
|
-
{ value: "name_num", label: "Name (numeric)" },
|
|
29
|
-
{ value: "id", label: "Term ID" },
|
|
30
|
-
];
|
|
31
|
-
const BLANK = { id: null, name: "", slug: "", order_by: "menu_order" };
|
|
32
|
-
|
|
33
|
-
export default function Attributes() {
|
|
34
|
-
const navigate = useNavigate();
|
|
35
|
-
const href = useAdminHref();
|
|
36
|
-
const { data: attributes, loading, refetch } = useAsync(
|
|
37
|
-
() => base44.entities["commerce.ProductAttribute"].list("name", 500),
|
|
38
|
-
[]
|
|
39
|
-
);
|
|
40
|
-
const [form, setForm] = useState({ ...BLANK });
|
|
41
|
-
const [slugTouched, setSlugTouched] = useState(false);
|
|
42
|
-
const [saving, setSaving] = useState(false);
|
|
43
|
-
const [confirmDelete, setConfirmDelete] = useState(null);
|
|
44
|
-
const [deleting, setDeleting] = useState(false);
|
|
45
|
-
|
|
46
|
-
const setName = (name) => setForm((f) => ({ ...f, name, slug: slugTouched ? f.slug : slugify(name) }));
|
|
47
|
-
const reset = () => {
|
|
48
|
-
setForm({ ...BLANK });
|
|
49
|
-
setSlugTouched(false);
|
|
50
|
-
};
|
|
51
|
-
const edit = (a) => {
|
|
52
|
-
setForm({ id: a.id, name: a.name || "", slug: a.slug || "", order_by: a.order_by || "menu_order" });
|
|
53
|
-
setSlugTouched(true);
|
|
54
|
-
};
|
|
55
|
-
|
|
56
|
-
const save = async () => {
|
|
57
|
-
if (!form.name.trim()) {
|
|
58
|
-
toast.error("Name is required");
|
|
59
|
-
return;
|
|
60
|
-
}
|
|
61
|
-
setSaving(true);
|
|
62
|
-
try {
|
|
63
|
-
const payload = {
|
|
64
|
-
name: form.name.trim(),
|
|
65
|
-
slug: form.slug || slugify(form.name),
|
|
66
|
-
order_by: form.order_by,
|
|
67
|
-
type: "select",
|
|
68
|
-
};
|
|
69
|
-
if (form.id) await base44.entities["commerce.ProductAttribute"].update(form.id, payload);
|
|
70
|
-
else await base44.entities["commerce.ProductAttribute"].create(payload);
|
|
71
|
-
toast.success(form.id ? "Attribute updated" : "Attribute created");
|
|
72
|
-
reset();
|
|
73
|
-
refetch();
|
|
74
|
-
} catch (e) {
|
|
75
|
-
toast.error(e.message || "Failed to save attribute");
|
|
76
|
-
} finally {
|
|
77
|
-
setSaving(false);
|
|
78
|
-
}
|
|
79
|
-
};
|
|
80
|
-
|
|
81
|
-
const doDelete = async () => {
|
|
82
|
-
setDeleting(true);
|
|
83
|
-
try {
|
|
84
|
-
await base44.entities["commerce.ProductAttribute"].delete(confirmDelete.id);
|
|
85
|
-
toast.success("Attribute deleted");
|
|
86
|
-
if (form.id === confirmDelete.id) reset();
|
|
87
|
-
setConfirmDelete(null);
|
|
88
|
-
refetch();
|
|
89
|
-
} catch (e) {
|
|
90
|
-
toast.error(e.message || "Failed to delete attribute");
|
|
91
|
-
} finally {
|
|
92
|
-
setDeleting(false);
|
|
93
|
-
}
|
|
94
|
-
};
|
|
95
|
-
|
|
96
|
-
const columns = [
|
|
97
|
-
{ key: "name", label: "Name", render: (row) => <span className="font-medium">{row.name}</span> },
|
|
98
|
-
{ key: "slug", label: "Slug", className: "hidden md:table-cell text-muted-foreground" },
|
|
99
|
-
{
|
|
100
|
-
key: "terms",
|
|
101
|
-
label: "Terms",
|
|
102
|
-
render: (row) => (
|
|
103
|
-
<Button
|
|
104
|
-
variant="link"
|
|
105
|
-
size="sm"
|
|
106
|
-
className="h-auto p-0"
|
|
107
|
-
onClick={() => navigate(href(`products/attributes/${row.id}/terms`))}
|
|
108
|
-
>
|
|
109
|
-
<Settings2 className="mr-1 h-3.5 w-3.5" /> Configure terms
|
|
110
|
-
</Button>
|
|
111
|
-
),
|
|
112
|
-
},
|
|
113
|
-
];
|
|
114
|
-
|
|
115
|
-
return (
|
|
116
|
-
<div className="space-y-4">
|
|
117
|
-
<PageHeader title="Attributes" description="Reusable options such as Color or Size" />
|
|
118
|
-
<div className="grid gap-4 lg:grid-cols-[340px_1fr]">
|
|
119
|
-
<Card className="h-fit">
|
|
120
|
-
<CardHeader className="pb-2">
|
|
121
|
-
<CardTitle className="text-base">{form.id ? "Edit attribute" : "Add new attribute"}</CardTitle>
|
|
122
|
-
</CardHeader>
|
|
123
|
-
<CardContent className="space-y-3">
|
|
124
|
-
<div className="space-y-1.5">
|
|
125
|
-
<Label>Name</Label>
|
|
126
|
-
<Input value={form.name} onChange={(e) => setName(e.target.value)} />
|
|
127
|
-
</div>
|
|
128
|
-
<div className="space-y-1.5">
|
|
129
|
-
<Label>Slug</Label>
|
|
130
|
-
<Input
|
|
131
|
-
value={form.slug}
|
|
132
|
-
onChange={(e) => {
|
|
133
|
-
setSlugTouched(true);
|
|
134
|
-
setForm((f) => ({ ...f, slug: e.target.value }));
|
|
135
|
-
}}
|
|
136
|
-
/>
|
|
137
|
-
</div>
|
|
138
|
-
<div className="space-y-1.5">
|
|
139
|
-
<Label>Default sort order</Label>
|
|
140
|
-
<Select value={form.order_by} onValueChange={(v) => setForm((f) => ({ ...f, order_by: v }))}>
|
|
141
|
-
<SelectTrigger><SelectValue /></SelectTrigger>
|
|
142
|
-
<SelectContent>
|
|
143
|
-
{ORDER_BY.map((o) => (
|
|
144
|
-
<SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>
|
|
145
|
-
))}
|
|
146
|
-
</SelectContent>
|
|
147
|
-
</Select>
|
|
148
|
-
</div>
|
|
149
|
-
<div className="flex gap-2">
|
|
150
|
-
<Button onClick={save} disabled={saving}>
|
|
151
|
-
{saving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
|
152
|
-
{form.id ? "Update" : "Add attribute"}
|
|
153
|
-
</Button>
|
|
154
|
-
{form.id && <Button variant="ghost" onClick={reset} disabled={saving}>Cancel</Button>}
|
|
155
|
-
</div>
|
|
156
|
-
</CardContent>
|
|
157
|
-
</Card>
|
|
158
|
-
|
|
159
|
-
<DataTable
|
|
160
|
-
columns={columns}
|
|
161
|
-
rows={attributes || []}
|
|
162
|
-
loading={loading}
|
|
163
|
-
rowActions={(row) => [
|
|
164
|
-
{ label: "Edit", icon: Pencil, onClick: () => edit(row) },
|
|
165
|
-
{ label: "Configure terms", icon: Settings2, onClick: () => navigate(href(`products/attributes/${row.id}/terms`)) },
|
|
166
|
-
{ label: "Delete", icon: Trash2, destructive: true, onClick: () => setConfirmDelete(row) },
|
|
167
|
-
]}
|
|
168
|
-
empty={{ icon: Layers, title: "No attributes yet", description: "Create an attribute like Color or Size." }}
|
|
169
|
-
/>
|
|
170
|
-
</div>
|
|
171
|
-
|
|
172
|
-
<ConfirmDialog
|
|
173
|
-
open={Boolean(confirmDelete)}
|
|
174
|
-
onOpenChange={(o) => !o && setConfirmDelete(null)}
|
|
175
|
-
title={`Delete "${confirmDelete?.name}"?`}
|
|
176
|
-
description="This deletes the attribute and its terms. Products keep any custom attribute values already saved."
|
|
177
|
-
confirmLabel="Delete"
|
|
178
|
-
onConfirm={doDelete}
|
|
179
|
-
loading={deleting}
|
|
180
|
-
/>
|
|
181
|
-
</div>
|
|
182
|
-
);
|
|
183
|
-
}
|
|
@@ -1,150 +0,0 @@
|
|
|
1
|
-
import React, { 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 { Textarea } from "@/components/ui/textarea";
|
|
6
|
-
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
7
|
-
import { Loader2, Pencil, Tag as TagIcon, Trash2 } from "lucide-react";
|
|
8
|
-
import { toast } from "sonner";
|
|
9
|
-
|
|
10
|
-
import { base44 } from "../../lib/api";
|
|
11
|
-
import useAsync from "../../hooks/useAsync";
|
|
12
|
-
import PageHeader from "../../components/PageHeader";
|
|
13
|
-
import DataTable from "../../components/DataTable";
|
|
14
|
-
import ConfirmDialog from "../../components/ConfirmDialog";
|
|
15
|
-
import { slugify } from "../../lib/product-utils";
|
|
16
|
-
|
|
17
|
-
const BLANK = { id: null, name: "", slug: "", description: "" };
|
|
18
|
-
|
|
19
|
-
export default function Tags() {
|
|
20
|
-
const { data: tags, loading, refetch } = useAsync(
|
|
21
|
-
() => base44.entities["commerce.ProductTag"].list("name", 1000),
|
|
22
|
-
[]
|
|
23
|
-
);
|
|
24
|
-
const [form, setForm] = useState({ ...BLANK });
|
|
25
|
-
const [slugTouched, setSlugTouched] = useState(false);
|
|
26
|
-
const [saving, setSaving] = useState(false);
|
|
27
|
-
const [confirmDelete, setConfirmDelete] = useState(null);
|
|
28
|
-
const [deleting, setDeleting] = useState(false);
|
|
29
|
-
|
|
30
|
-
const setName = (name) => setForm((f) => ({ ...f, name, slug: slugTouched ? f.slug : slugify(name) }));
|
|
31
|
-
const reset = () => {
|
|
32
|
-
setForm({ ...BLANK });
|
|
33
|
-
setSlugTouched(false);
|
|
34
|
-
};
|
|
35
|
-
const edit = (t) => {
|
|
36
|
-
setForm({ id: t.id, name: t.name || "", slug: t.slug || "", description: t.description || "" });
|
|
37
|
-
setSlugTouched(true);
|
|
38
|
-
};
|
|
39
|
-
|
|
40
|
-
const save = async () => {
|
|
41
|
-
if (!form.name.trim()) {
|
|
42
|
-
toast.error("Name is required");
|
|
43
|
-
return;
|
|
44
|
-
}
|
|
45
|
-
setSaving(true);
|
|
46
|
-
try {
|
|
47
|
-
const payload = { name: form.name.trim(), slug: form.slug || slugify(form.name), description: form.description };
|
|
48
|
-
if (form.id) await base44.entities["commerce.ProductTag"].update(form.id, payload);
|
|
49
|
-
else await base44.entities["commerce.ProductTag"].create(payload);
|
|
50
|
-
toast.success(form.id ? "Tag updated" : "Tag created");
|
|
51
|
-
reset();
|
|
52
|
-
refetch();
|
|
53
|
-
} catch (e) {
|
|
54
|
-
toast.error(e.message || "Failed to save tag");
|
|
55
|
-
} finally {
|
|
56
|
-
setSaving(false);
|
|
57
|
-
}
|
|
58
|
-
};
|
|
59
|
-
|
|
60
|
-
const doDelete = async () => {
|
|
61
|
-
setDeleting(true);
|
|
62
|
-
try {
|
|
63
|
-
await base44.entities["commerce.ProductTag"].delete(confirmDelete.id);
|
|
64
|
-
toast.success("Tag deleted");
|
|
65
|
-
if (form.id === confirmDelete.id) reset();
|
|
66
|
-
setConfirmDelete(null);
|
|
67
|
-
refetch();
|
|
68
|
-
} catch (e) {
|
|
69
|
-
toast.error(e.message || "Failed to delete tag");
|
|
70
|
-
} finally {
|
|
71
|
-
setDeleting(false);
|
|
72
|
-
}
|
|
73
|
-
};
|
|
74
|
-
|
|
75
|
-
const columns = [
|
|
76
|
-
{ key: "name", label: "Name", render: (row) => <span className="font-medium">{row.name}</span> },
|
|
77
|
-
{ key: "slug", label: "Slug", className: "hidden md:table-cell text-muted-foreground" },
|
|
78
|
-
{
|
|
79
|
-
key: "description",
|
|
80
|
-
label: "Description",
|
|
81
|
-
className: "hidden lg:table-cell text-muted-foreground",
|
|
82
|
-
render: (row) => row.description || "—",
|
|
83
|
-
},
|
|
84
|
-
];
|
|
85
|
-
|
|
86
|
-
return (
|
|
87
|
-
<div className="space-y-4">
|
|
88
|
-
<PageHeader title="Tags" description="Freeform labels for products" />
|
|
89
|
-
<div className="grid gap-4 lg:grid-cols-[340px_1fr]">
|
|
90
|
-
<Card className="h-fit">
|
|
91
|
-
<CardHeader className="pb-2">
|
|
92
|
-
<CardTitle className="text-base">{form.id ? "Edit tag" : "Add new tag"}</CardTitle>
|
|
93
|
-
</CardHeader>
|
|
94
|
-
<CardContent className="space-y-3">
|
|
95
|
-
<div className="space-y-1.5">
|
|
96
|
-
<Label>Name</Label>
|
|
97
|
-
<Input value={form.name} onChange={(e) => setName(e.target.value)} />
|
|
98
|
-
</div>
|
|
99
|
-
<div className="space-y-1.5">
|
|
100
|
-
<Label>Slug</Label>
|
|
101
|
-
<Input
|
|
102
|
-
value={form.slug}
|
|
103
|
-
onChange={(e) => {
|
|
104
|
-
setSlugTouched(true);
|
|
105
|
-
setForm((f) => ({ ...f, slug: e.target.value }));
|
|
106
|
-
}}
|
|
107
|
-
/>
|
|
108
|
-
</div>
|
|
109
|
-
<div className="space-y-1.5">
|
|
110
|
-
<Label>Description</Label>
|
|
111
|
-
<Textarea
|
|
112
|
-
rows={3}
|
|
113
|
-
value={form.description}
|
|
114
|
-
onChange={(e) => setForm((f) => ({ ...f, description: e.target.value }))}
|
|
115
|
-
/>
|
|
116
|
-
</div>
|
|
117
|
-
<div className="flex gap-2">
|
|
118
|
-
<Button onClick={save} disabled={saving}>
|
|
119
|
-
{saving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
|
120
|
-
{form.id ? "Update" : "Add tag"}
|
|
121
|
-
</Button>
|
|
122
|
-
{form.id && <Button variant="ghost" onClick={reset} disabled={saving}>Cancel</Button>}
|
|
123
|
-
</div>
|
|
124
|
-
</CardContent>
|
|
125
|
-
</Card>
|
|
126
|
-
|
|
127
|
-
<DataTable
|
|
128
|
-
columns={columns}
|
|
129
|
-
rows={tags || []}
|
|
130
|
-
loading={loading}
|
|
131
|
-
rowActions={(row) => [
|
|
132
|
-
{ label: "Edit", icon: Pencil, onClick: () => edit(row) },
|
|
133
|
-
{ label: "Delete", icon: Trash2, destructive: true, onClick: () => setConfirmDelete(row) },
|
|
134
|
-
]}
|
|
135
|
-
empty={{ icon: TagIcon, title: "No tags yet", description: "Create your first tag." }}
|
|
136
|
-
/>
|
|
137
|
-
</div>
|
|
138
|
-
|
|
139
|
-
<ConfirmDialog
|
|
140
|
-
open={Boolean(confirmDelete)}
|
|
141
|
-
onOpenChange={(o) => !o && setConfirmDelete(null)}
|
|
142
|
-
title={`Delete "${confirmDelete?.name}"?`}
|
|
143
|
-
description="Products with this tag are not deleted; they simply lose the tag."
|
|
144
|
-
confirmLabel="Delete"
|
|
145
|
-
onConfirm={doDelete}
|
|
146
|
-
loading={deleting}
|
|
147
|
-
/>
|
|
148
|
-
</div>
|
|
149
|
-
);
|
|
150
|
-
}
|
|
@@ -1,48 +0,0 @@
|
|
|
1
|
-
import React from "react";
|
|
2
|
-
import { Input } from "@/components/ui/input";
|
|
3
|
-
import { Label } from "@/components/ui/label";
|
|
4
|
-
import { Switch } from "@/components/ui/switch";
|
|
5
|
-
import { Textarea } from "@/components/ui/textarea";
|
|
6
|
-
|
|
7
|
-
import MetaDataEditor from "../../../../components/MetaDataEditor";
|
|
8
|
-
|
|
9
|
-
/** Purchase note, menu order, reviews toggle and custom meta data. */
|
|
10
|
-
export default function AdvancedTab({ product, up }) {
|
|
11
|
-
return (
|
|
12
|
-
<div className="max-w-xl space-y-4">
|
|
13
|
-
<div className="space-y-1.5">
|
|
14
|
-
<Label>Purchase note</Label>
|
|
15
|
-
<Textarea
|
|
16
|
-
rows={3}
|
|
17
|
-
placeholder="Optional note sent to the customer after purchase"
|
|
18
|
-
value={product.purchase_note || ""}
|
|
19
|
-
onChange={(e) => up({ purchase_note: e.target.value })}
|
|
20
|
-
/>
|
|
21
|
-
</div>
|
|
22
|
-
|
|
23
|
-
<div className="space-y-1.5">
|
|
24
|
-
<Label>Menu order</Label>
|
|
25
|
-
<Input
|
|
26
|
-
type="number"
|
|
27
|
-
className="w-32"
|
|
28
|
-
value={product.menu_order ?? 0}
|
|
29
|
-
onChange={(e) => up({ menu_order: e.target.value === "" ? 0 : Number(e.target.value) })}
|
|
30
|
-
/>
|
|
31
|
-
<p className="text-xs text-muted-foreground">Custom ordering position (lower shows first).</p>
|
|
32
|
-
</div>
|
|
33
|
-
|
|
34
|
-
<div className="flex items-center gap-2">
|
|
35
|
-
<Switch
|
|
36
|
-
checked={product.reviews_allowed !== false}
|
|
37
|
-
onCheckedChange={(v) => up({ reviews_allowed: Boolean(v) })}
|
|
38
|
-
/>
|
|
39
|
-
<Label>Enable reviews</Label>
|
|
40
|
-
</div>
|
|
41
|
-
|
|
42
|
-
<div className="space-y-1.5">
|
|
43
|
-
<Label>Custom fields</Label>
|
|
44
|
-
<MetaDataEditor value={product.meta_data || []} onChange={(meta_data) => up({ meta_data })} />
|
|
45
|
-
</div>
|
|
46
|
-
</div>
|
|
47
|
-
);
|
|
48
|
-
}
|