@base44/app-plugin-commerce 0.1.3 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/README.md +8 -8
  2. package/base44/agents/commerce/StoreAdmin.jsonc +7 -7
  3. package/base44/entities/commerce.Order.jsonc +1 -1
  4. package/base44/entities/commerce.PaymentGateway.jsonc +3 -3
  5. package/base44/entities/commerce.Product.jsonc +9 -45
  6. package/base44/entities/commerce.ProductAttribute.jsonc +7 -19
  7. package/base44/entities/commerce.ProductAttributeTerm.jsonc +4 -10
  8. package/base44/entities/commerce.ProductTag.jsonc +1 -8
  9. package/base44/entities/commerce.ProductVariation.jsonc +1 -1
  10. package/base44/entities/commerce.StoreSettings.jsonc +1 -1
  11. package/base44/functions/commerce/admin-products/entry.ts +107 -35
  12. package/base44/functions/commerce/admin-refunds/entry.ts +1 -1
  13. package/base44/functions/commerce/admin-reports/entry.ts +1 -1
  14. package/base44/functions/commerce/admin-tools/entry.ts +4 -3
  15. package/base44/functions/commerce/payments/entry.ts +0 -2
  16. package/base44/functions/commerce/seed-store/defaults.ts +13 -39
  17. package/base44/functions/commerce/seed-store/entry.ts +47 -35
  18. package/base44/functions/commerce/seed-store/sample-data.ts +28 -66
  19. package/base44/functions/commerce/storefront-cart/entry.ts +0 -7
  20. package/base44/functions/commerce/storefront-catalog/entry.ts +12 -21
  21. package/base44/functions/commerce/storefront-checkout/entry.ts +5 -12
  22. package/base44/shared/commerce/coupons.ts +4 -7
  23. package/base44/shared/commerce/email-templates.ts +15 -13
  24. package/base44/shared/commerce/emails.ts +4 -3
  25. package/base44/shared/commerce/payments.ts +8 -20
  26. package/base44/shared/commerce/products.ts +24 -0
  27. package/base44/shared/commerce/settings.ts +2 -3
  28. package/base44/shared/commerce/stock.ts +2 -4
  29. package/base44/shared/commerce/totals.ts +37 -42
  30. package/package.json +1 -1
  31. package/scripts/install.js +21 -4
  32. package/skills/commerce/SKILL.md +6 -5
  33. package/skills/commerce/docs/api-admin.md +16 -14
  34. package/skills/commerce/docs/api-storefront.md +39 -35
  35. package/skills/commerce/installation-guidelines.md +4 -3
  36. package/skills/commerce/post-installation.md +22 -25
  37. package/skills/commerce/references/admin-product-form.md +56 -0
  38. package/skills/commerce/references/emails.md +4 -3
  39. package/skills/commerce/references/guest-access-security.md +2 -2
  40. package/skills/commerce/references/limits-and-performance.md +1 -1
  41. package/skills/commerce/references/online-payments.md +8 -8
  42. package/skills/commerce/references/product-render.md +25 -23
  43. package/skills/commerce/references/storefront-product-page.md +9 -9
  44. package/skills/commerce/references/webhooks.md +3 -1
  45. package/src/commerce/admin/README.md +4 -4
  46. package/src/commerce/admin/context/BasePathContext.jsx +5 -5
  47. package/src/commerce/admin/context/SettingsContext.jsx +4 -4
  48. package/src/commerce/admin/index.jsx +3 -3
  49. package/src/commerce/admin/layout/Sidebar.jsx +1 -9
  50. package/src/commerce/admin/lib/constants.js +0 -7
  51. package/src/commerce/admin/lib/paths.js +6 -6
  52. package/src/commerce/admin/lib/product-utils.js +18 -15
  53. package/src/commerce/admin/pages/orders/components/AddProductDialog.jsx +8 -8
  54. package/src/commerce/admin/pages/products/ProductEditor.jsx +10 -12
  55. package/src/commerce/admin/pages/products/ProductsList.jsx +3 -15
  56. package/src/commerce/admin/pages/products/components/AttributesSection.jsx +426 -0
  57. package/src/commerce/admin/pages/products/components/ProductDataPanel.jsx +33 -69
  58. package/src/commerce/admin/pages/products/components/TaxonomyPanel.jsx +1 -1
  59. package/src/commerce/admin/pages/products/components/tabs/LinkedTab.jsx +1 -14
  60. package/src/commerce/admin/pages/products/components/tabs/ModifiersTab.jsx +19 -0
  61. package/src/commerce/admin/pages/products/components/tabs/PriceInventoryTab.jsx +728 -0
  62. package/src/commerce/admin/pages/reports/Reports.jsx +3 -3
  63. package/src/commerce/admin/pages/settings/EmailsSettings.jsx +12 -14
  64. package/src/commerce/admin/pages/settings/GeneralSettings.jsx +6 -116
  65. package/src/commerce/admin/pages/settings/PaymentsSettings.jsx +101 -62
  66. package/src/commerce/admin/pages/settings/SettingsLayout.jsx +2 -6
  67. package/src/commerce/admin/pages/settings/TaxSettings.jsx +0 -1
  68. package/src/commerce/admin/pages/status/WebhookEditor.jsx +3 -3
  69. package/src/commerce/admin/pages/status/Webhooks.jsx +2 -2
  70. package/src/commerce/admin/routes.jsx +10 -19
  71. package/src/commerce/utils/index.js +2 -2
  72. package/src/commerce/utils/shipping-promos.js +9 -6
  73. package/src/commerce/utils/variants.js +21 -21
  74. package/src/commerce/admin/pages/products/AttributeTerms.jsx +0 -180
  75. package/src/commerce/admin/pages/products/Attributes.jsx +0 -183
  76. package/src/commerce/admin/pages/products/Tags.jsx +0 -150
  77. package/src/commerce/admin/pages/products/components/tabs/AdvancedTab.jsx +0 -48
  78. package/src/commerce/admin/pages/products/components/tabs/AttributesTab.jsx +0 -208
  79. package/src/commerce/admin/pages/products/components/tabs/ExternalTab.jsx +0 -41
  80. package/src/commerce/admin/pages/products/components/tabs/GeneralTab.jsx +0 -103
  81. package/src/commerce/admin/pages/products/components/tabs/InventoryTab.jsx +0 -93
  82. package/src/commerce/admin/pages/products/components/tabs/ShippingTab.jsx +0 -86
  83. package/src/commerce/admin/pages/products/components/tabs/VariationsTab.jsx +0 -377
@@ -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 `commerce.ShippingZoneMethod` are public-read, so
12
- * the storefront can load them directly:
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 variable product is defined on two levels: the parent's `attributes[]`
5
- * entries with `variation: true` are the *axes* (Size, Color), and each record
6
- * in `variations[]` is one combination of options on those axes. The product
7
- * page must therefore present **one control per axis** — never a flat list of
8
- * `Red-S`, `Red-M`, `Blue-S`, ... — and map the customer's selection onto a
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
- * Global attributes carry an `attribute_id`; custom (product-local) ones have
32
- * it empty, so an id-only key would collide across them — fall back to `name`.
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` with `variation: true`, ordered by
43
- * `position`. These — and only these — become selectors on the product page;
44
- * `visible: true` non-variation attributes belong in a spec table.
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 matching `product.attributes` entries
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 a variable product's headline price: the
250
- * backend rolls stock up to the parent but **not** price, so `product.price`
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 `product.type`, not on `variations` (pass `[]` for a simple
321
- * product). A `variable` product never falls back to the parent, so one with no
322
- * usable variations yields empty `axes[].options`, `purchasable: false` and
323
- * `addToCart: null` — render it unavailable and guard the "select a …" label.
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?.type ?? "simple") === "variable";
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
- }