@base44/app-plugin-commerce 0.1.6 → 0.1.7

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 (95) hide show
  1. package/README.md +11 -11
  2. package/base44/agents/commerce/StoreAdmin.jsonc +2 -2
  3. package/base44/entities/commerce.Cart.jsonc +1 -1
  4. package/base44/entities/commerce.Coupon.jsonc +5 -0
  5. package/base44/entities/commerce.Order.jsonc +6 -7
  6. package/base44/entities/commerce.OrderRefund.jsonc +1 -1
  7. package/base44/entities/commerce.PaymentGateway.jsonc +1 -1
  8. package/base44/entities/commerce.Product.jsonc +6 -16
  9. package/base44/entities/{commerce.ProductTag.jsonc → commerce.ProductRibbon.jsonc} +2 -2
  10. package/base44/entities/commerce.ProductVariation.jsonc +1 -9
  11. package/base44/entities/commerce.ShippingTaxLocation.jsonc +85 -0
  12. package/base44/entities/commerce.Webhook.jsonc +1 -1
  13. package/base44/functions/commerce/admin-orders/helpers.ts +7 -13
  14. package/base44/functions/commerce/admin-products/entry.ts +11 -17
  15. package/base44/functions/commerce/admin-refunds/entry.ts +10 -9
  16. package/base44/functions/commerce/admin-reports/entry.ts +3 -3
  17. package/base44/functions/commerce/admin-tools/entry.ts +9 -36
  18. package/base44/functions/commerce/payment-webhook/entry.ts +50 -89
  19. package/base44/functions/commerce/payments/entry.ts +46 -42
  20. package/base44/functions/commerce/seed-store/defaults.ts +35 -42
  21. package/base44/functions/commerce/seed-store/entry.ts +68 -46
  22. package/base44/functions/commerce/seed-store/sample-data.ts +2 -15
  23. package/base44/functions/commerce/seed-store/seed-catalog.ts +105 -55
  24. package/base44/functions/commerce/storefront-cart/cart-pricing.ts +6 -11
  25. package/base44/functions/commerce/storefront-cart/entry.ts +36 -2
  26. package/base44/functions/commerce/storefront-catalog/entry.ts +55 -72
  27. package/base44/functions/commerce/storefront-checkout/cart-pricing.ts +6 -11
  28. package/base44/functions/commerce/storefront-checkout/entry.ts +43 -56
  29. package/base44/shared/commerce/card-payment.ts +80 -0
  30. package/base44/shared/commerce/coupons.ts +16 -10
  31. package/base44/shared/commerce/emails.ts +30 -18
  32. package/base44/shared/commerce/money.ts +11 -24
  33. package/base44/shared/commerce/payments.ts +55 -286
  34. package/base44/shared/commerce/scan.ts +1 -1
  35. package/base44/shared/commerce/sequence.ts +1 -1
  36. package/base44/shared/commerce/settings.ts +4 -9
  37. package/base44/shared/commerce/shipping.ts +65 -133
  38. package/base44/shared/commerce/tax.ts +48 -96
  39. package/base44/shared/commerce/totals.ts +77 -91
  40. package/package.json +1 -1
  41. package/scripts/install.js +28 -7
  42. package/skills/commerce/SKILL.md +14 -14
  43. package/skills/commerce/docs/api-admin.md +23 -26
  44. package/skills/commerce/docs/api-storefront.md +67 -61
  45. package/skills/commerce/installation-guidelines.md +8 -8
  46. package/skills/commerce/post-installation.md +60 -39
  47. package/skills/commerce/references/admin-product-form.md +15 -12
  48. package/skills/commerce/references/emails.md +2 -2
  49. package/skills/commerce/references/guest-access-security.md +2 -2
  50. package/skills/commerce/references/online-payments.md +24 -166
  51. package/skills/commerce/references/product-render.md +18 -18
  52. package/skills/commerce/references/reviews.md +14 -8
  53. package/skills/commerce/references/storefront-product-page.md +1 -1
  54. package/src/commerce/admin/README.md +4 -5
  55. package/src/commerce/admin/bot/Markdown.jsx +1 -1
  56. package/src/commerce/admin/hooks/useMoney.js +13 -22
  57. package/src/commerce/admin/layout/AuthGuard.jsx +1 -1
  58. package/src/commerce/admin/lib/constants.js +2 -29
  59. package/src/commerce/admin/lib/order-utils.js +1 -1
  60. package/src/commerce/admin/pages/coupons/CouponEditor.jsx +131 -140
  61. package/src/commerce/admin/pages/coupons/CouponsList.jsx +14 -7
  62. package/src/commerce/admin/pages/orders/OrderEditor.jsx +3 -3
  63. package/src/commerce/admin/pages/orders/components/PaymentPanel.jsx +17 -28
  64. package/src/commerce/admin/pages/orders/components/RefundPanel.jsx +5 -11
  65. package/src/commerce/admin/pages/products/Categories.jsx +147 -177
  66. package/src/commerce/admin/pages/products/ProductEditor.jsx +23 -18
  67. package/src/commerce/admin/pages/products/Reviews.jsx +37 -1
  68. package/src/commerce/admin/pages/products/components/ProductDataPanel.jsx +33 -47
  69. package/src/commerce/admin/pages/products/components/PublishBox.jsx +13 -34
  70. package/src/commerce/admin/pages/products/components/TaxonomyPanel.jsx +29 -29
  71. package/src/commerce/admin/pages/products/components/tabs/PriceInventoryTab.jsx +14 -44
  72. package/src/commerce/admin/pages/reports/Reports.jsx +2 -2
  73. package/src/commerce/admin/pages/settings/EmailsSettings.jsx +101 -68
  74. package/src/commerce/admin/pages/settings/GeneralSettings.jsx +40 -38
  75. package/src/commerce/admin/pages/settings/InventorySettings.jsx +1 -41
  76. package/src/commerce/admin/pages/settings/LocationEditor.jsx +377 -0
  77. package/src/commerce/admin/pages/settings/PaymentsSettings.jsx +137 -119
  78. package/src/commerce/admin/pages/settings/SettingsLayout.jsx +2 -4
  79. package/src/commerce/admin/pages/settings/ShippingTaxSettings.jsx +191 -0
  80. package/src/commerce/admin/routes.jsx +6 -12
  81. package/src/commerce/utils/index.js +2 -2
  82. package/src/commerce/utils/shipping-promos.js +45 -49
  83. package/src/commerce/utils/variants.js +1 -1
  84. package/base44/entities/commerce.ShippingClass.jsonc +0 -30
  85. package/base44/entities/commerce.ShippingZone.jsonc +0 -41
  86. package/base44/entities/commerce.ShippingZoneMethod.jsonc +0 -84
  87. package/base44/entities/commerce.TaxClass.jsonc +0 -23
  88. package/base44/entities/commerce.TaxRate.jsonc +0 -68
  89. package/base44/shared/commerce/stripe.ts +0 -463
  90. package/src/commerce/admin/hooks/usePaymentProvider.js +0 -27
  91. package/src/commerce/admin/pages/settings/ProductsSettings.jsx +0 -118
  92. package/src/commerce/admin/pages/settings/ShippingSettings.jsx +0 -304
  93. package/src/commerce/admin/pages/settings/ShippingZoneEditor.jsx +0 -514
  94. package/src/commerce/admin/pages/settings/TaxRatesTable.jsx +0 -231
  95. package/src/commerce/admin/pages/settings/TaxSettings.jsx +0 -280
@@ -1,232 +1,202 @@
1
- import React, { useMemo, useState } from "react";
1
+ import React, { useEffect, useState } from "react";
2
2
  import { Button } from "@/components/ui/button";
3
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 {
8
- Select,
9
- SelectContent,
10
- SelectItem,
11
- SelectTrigger,
12
- SelectValue,
13
- } from "@/components/ui/select";
14
- import { FolderTree, ImageIcon, Loader2, Pencil, Trash2 } from "lucide-react";
4
+ import { Card, CardContent } from "@/components/ui/card";
5
+ import { FolderTree, Loader2, Plus, Trash2 } from "lucide-react";
15
6
  import { toast } from "sonner";
16
7
 
17
- import { base44 } from "../../lib/api";
8
+ import { base44, call } from "../../lib/api";
18
9
  import useAsync from "../../hooks/useAsync";
19
10
  import PageHeader from "../../components/PageHeader";
20
- import DataTable from "../../components/DataTable";
21
11
  import ConfirmDialog from "../../components/ConfirmDialog";
22
- import MediaUploader from "../../components/MediaUploader";
23
- import { slugify } from "../../lib/product-utils";
24
-
25
- const NO_PARENT = "__none__";
26
- const BLANK = { id: null, name: "", slug: "", parent_id: "", description: "", image: null };
27
12
 
13
+ /**
14
+ * Categories as an editable list of name / slug / order rows. `+ Add category`
15
+ * appends a fresh row, and each row can be removed (with a warning). Rows save
16
+ * on blur/Enter through admin-products `save-term`. A blank slug is derived
17
+ * from the name (and uniquified) server-side; a filled one is kept — which is
18
+ * why the row always sends the stored slug back: otherwise a rename would
19
+ * silently regenerate it and break storefront links.
20
+ */
28
21
  export default function Categories() {
29
22
  const { data: categories, loading, refetch } = useAsync(
30
23
  () => base44.entities["commerce.ProductCategory"].list("menu_order", 1000),
31
24
  []
32
25
  );
33
- const [form, setForm] = useState({ ...BLANK });
34
- const [slugTouched, setSlugTouched] = useState(false);
35
- const [saving, setSaving] = useState(false);
26
+
27
+ // rows: saved categories + any unsaved new lines (id: null). description and
28
+ // parent_id ride along untouched — save-term writes them, so omitting them
29
+ // would wipe what's stored.
30
+ const [rows, setRows] = useState([]);
31
+ const [savingId, setSavingId] = useState(null);
36
32
  const [confirmDelete, setConfirmDelete] = useState(null);
37
33
  const [deleting, setDeleting] = useState(false);
38
34
 
39
- const list = categories || [];
35
+ const fromCategory = (c) => ({
36
+ id: c.id,
37
+ name: c.name || "",
38
+ slug: c.slug || "",
39
+ menu_order: c.menu_order ?? 0,
40
+ description: c.description ?? "",
41
+ parent_id: c.parent_id ?? "",
42
+ saved: { name: c.name || "", slug: c.slug || "", menu_order: c.menu_order ?? 0 },
43
+ });
40
44
 
41
- // Depth map for indented display.
42
- const rows = useMemo(() => {
43
- const byParent = new Map();
44
- for (const c of list) {
45
- const key = c.parent_id || "";
46
- if (!byParent.has(key)) byParent.set(key, []);
47
- byParent.get(key).push(c);
48
- }
49
- const out = [];
50
- const walk = (parentId, depth) => {
51
- for (const c of byParent.get(parentId) || []) {
52
- out.push({ ...c, depth });
53
- walk(c.id, depth + 1);
54
- }
55
- };
56
- walk("", 0);
57
- return out;
58
- }, [list]);
59
-
60
- const edit = (c) => {
61
- setForm({
62
- id: c.id,
63
- name: c.name || "",
64
- slug: c.slug || "",
65
- parent_id: c.parent_id || "",
66
- description: c.description || "",
67
- image: c.image || null,
45
+ useEffect(() => {
46
+ setRows((prev) => {
47
+ const drafts = prev.filter((r) => !r.id);
48
+ return [...(categories || []).map(fromCategory), ...drafts];
68
49
  });
69
- setSlugTouched(true);
70
- };
71
-
72
- const reset = () => {
73
- setForm({ ...BLANK });
74
- setSlugTouched(false);
75
- };
50
+ }, [categories]);
76
51
 
77
- const setName = (name) => {
78
- setForm((f) => ({ ...f, name, slug: slugTouched ? f.slug : slugify(name) }));
79
- };
52
+ const setField = (index, key, value) =>
53
+ setRows((prev) => prev.map((r, i) => (i === index ? { ...r, [key]: value } : r)));
80
54
 
81
- const save = async () => {
82
- if (!form.name.trim()) {
83
- toast.error("Name is required");
55
+ const commit = async (index) => {
56
+ const row = rows[index];
57
+ const name = row.name.trim();
58
+ const slug = String(row.slug ?? "").trim();
59
+ const menuOrder = Number(row.menu_order) || 0;
60
+ if (!name) {
61
+ if (!row.id) setRows((prev) => prev.filter((_, i) => i !== index)); // abandoned empty line
84
62
  return;
85
63
  }
86
- setSaving(true);
64
+ const dirty =
65
+ !row.id ||
66
+ name !== row.saved.name ||
67
+ slug !== row.saved.slug ||
68
+ menuOrder !== row.saved.menu_order;
69
+ if (!dirty) return;
70
+ setSavingId(row.id || `new-${index}`);
87
71
  try {
88
- const payload = {
89
- name: form.name.trim(),
90
- slug: form.slug || slugify(form.name),
91
- parent_id: form.parent_id === NO_PARENT ? "" : form.parent_id,
92
- description: form.description,
93
- image: form.image,
94
- };
95
- if (form.id) await base44.entities["commerce.ProductCategory"].update(form.id, payload);
96
- else await base44.entities["commerce.ProductCategory"].create(payload);
97
- toast.success(form.id ? "Category updated" : "Category created");
98
- reset();
72
+ await call("admin-products", "save-term", {
73
+ taxonomy: "category",
74
+ term: {
75
+ ...(row.id ? { id: row.id } : {}),
76
+ name,
77
+ slug, // blank = derive from the name server-side
78
+ menu_order: menuOrder,
79
+ description: row.description,
80
+ parent_id: row.parent_id,
81
+ },
82
+ });
83
+ toast.success(row.id ? "Category saved" : "Category added");
99
84
  refetch();
100
- } catch (e) {
101
- toast.error(e.message || "Failed to save category");
85
+ } catch {
86
+ /* toast handled by call() */
102
87
  } finally {
103
- setSaving(false);
88
+ setSavingId(null);
104
89
  }
105
90
  };
106
91
 
107
92
  const doDelete = async () => {
108
93
  setDeleting(true);
109
94
  try {
110
- await base44.entities["commerce.ProductCategory"].delete(confirmDelete.id);
95
+ await call("admin-products", "delete-term", { taxonomy: "category", id: confirmDelete.id });
111
96
  toast.success("Category deleted");
112
- if (form.id === confirmDelete.id) reset();
113
97
  setConfirmDelete(null);
114
98
  refetch();
115
- } catch (e) {
116
- toast.error(e.message || "Failed to delete category");
99
+ } catch {
100
+ /* toast handled by call() */
117
101
  } finally {
118
102
  setDeleting(false);
119
103
  }
120
104
  };
121
105
 
122
- const columns = [
123
- {
124
- key: "image",
125
- label: "",
126
- className: "w-12",
127
- render: (row) =>
128
- row.image?.src ? (
129
- <img src={row.image.src} alt="" className="h-9 w-9 rounded object-cover" />
130
- ) : (
131
- <div className="flex h-9 w-9 items-center justify-center rounded bg-muted">
132
- <ImageIcon className="h-4 w-4 text-muted-foreground" />
133
- </div>
134
- ),
135
- },
136
- {
137
- key: "name",
138
- label: "Name",
139
- render: (row) => (
140
- <span style={{ paddingLeft: `${row.depth * 16}px` }} className="font-medium">
141
- {row.depth > 0 && <span className="text-muted-foreground">— </span>}
142
- {row.name}
143
- </span>
144
- ),
145
- },
146
- { key: "slug", label: "Slug", className: "hidden md:table-cell text-muted-foreground" },
147
- ];
106
+ const removeRow = (index) => {
107
+ const row = rows[index];
108
+ if (!row.id) setRows((prev) => prev.filter((_, i) => i !== index)); // unsaved line — just drop it
109
+ else setConfirmDelete(row);
110
+ };
111
+
112
+ const blurOnEnter = (e) => {
113
+ if (e.key === "Enter") e.currentTarget.blur();
114
+ };
148
115
 
149
116
  return (
150
117
  <div className="space-y-4">
151
- <PageHeader title="Categories" description="Organize products into a hierarchy" />
152
- <div className="grid gap-4 lg:grid-cols-[340px_1fr]">
153
- <Card className="h-fit">
154
- <CardHeader className="pb-2">
155
- <CardTitle className="text-base">{form.id ? "Edit category" : "Add new category"}</CardTitle>
156
- </CardHeader>
157
- <CardContent className="space-y-3">
158
- <div className="space-y-1.5">
159
- <Label>Name</Label>
160
- <Input value={form.name} onChange={(e) => setName(e.target.value)} />
118
+ <PageHeader title="Categories" description="Organize products into categories" />
119
+
120
+ <Card className="max-w-3xl">
121
+ <CardContent className="space-y-2 py-4">
122
+ {loading && !rows.length && <Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />}
123
+
124
+ {!loading && !rows.length && (
125
+ <div className="flex items-center gap-3 py-6 text-muted-foreground">
126
+ <FolderTree className="h-5 w-5" />
127
+ <span className="text-sm">No categories yet — add the first one below.</span>
161
128
  </div>
162
- <div className="space-y-1.5">
163
- <Label>Slug</Label>
129
+ )}
130
+
131
+ {rows.length > 0 && (
132
+ <div className="grid grid-cols-[1fr_1fr_5rem_2.25rem] items-center gap-2 px-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">
133
+ <span>Name</span>
134
+ <span>Slug</span>
135
+ <span>Order</span>
136
+ <span />
137
+ </div>
138
+ )}
139
+
140
+ {rows.map((row, i) => (
141
+ <div key={row.id || `new-${i}`} className="grid grid-cols-[1fr_1fr_5rem_2.25rem] items-center gap-2">
164
142
  <Input
165
- value={form.slug}
166
- onChange={(e) => {
167
- setSlugTouched(true);
168
- setForm((f) => ({ ...f, slug: e.target.value }));
169
- }}
143
+ autoFocus={!row.id && !row.name}
144
+ placeholder="Category name"
145
+ value={row.name}
146
+ onChange={(e) => setField(i, "name", e.target.value)}
147
+ onBlur={() => commit(i)}
148
+ onKeyDown={blurOnEnter}
170
149
  />
171
- </div>
172
- <div className="space-y-1.5">
173
- <Label>Parent</Label>
174
- <Select
175
- value={form.parent_id || NO_PARENT}
176
- onValueChange={(v) => setForm((f) => ({ ...f, parent_id: v === NO_PARENT ? "" : v }))}
177
- >
178
- <SelectTrigger><SelectValue /></SelectTrigger>
179
- <SelectContent>
180
- <SelectItem value={NO_PARENT}>— None —</SelectItem>
181
- {list
182
- .filter((c) => c.id !== form.id)
183
- .map((c) => (
184
- <SelectItem key={c.id} value={c.id}>{c.name}</SelectItem>
185
- ))}
186
- </SelectContent>
187
- </Select>
188
- </div>
189
- <div className="space-y-1.5">
190
- <Label>Description</Label>
191
- <Textarea
192
- rows={3}
193
- value={form.description}
194
- onChange={(e) => setForm((f) => ({ ...f, description: e.target.value }))}
150
+ <Input
151
+ placeholder="auto from name"
152
+ className="font-mono text-xs lowercase"
153
+ value={row.slug}
154
+ onChange={(e) => setField(i, "slug", e.target.value)}
155
+ onBlur={() => commit(i)}
156
+ onKeyDown={blurOnEnter}
195
157
  />
158
+ <Input
159
+ type="number"
160
+ className="text-right"
161
+ value={row.menu_order}
162
+ onChange={(e) => setField(i, "menu_order", e.target.value === "" ? 0 : Number(e.target.value))}
163
+ onBlur={() => commit(i)}
164
+ onKeyDown={blurOnEnter}
165
+ />
166
+ <span className="flex items-center justify-end">
167
+ {savingId === (row.id || `new-${i}`) ? (
168
+ <Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
169
+ ) : (
170
+ <Button type="button" variant="ghost" size="icon" onClick={() => removeRow(i)}>
171
+ <Trash2 className="h-4 w-4 text-muted-foreground" />
172
+ </Button>
173
+ )}
174
+ </span>
196
175
  </div>
197
- <div className="space-y-1.5">
198
- <Label>Image</Label>
199
- <MediaUploader value={form.image} onChange={(img) => setForm((f) => ({ ...f, image: img }))} />
200
- </div>
201
- <div className="flex gap-2">
202
- <Button onClick={save} disabled={saving}>
203
- {saving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
204
- {form.id ? "Update" : "Add category"}
205
- </Button>
206
- {form.id && (
207
- <Button variant="ghost" onClick={reset} disabled={saving}>Cancel</Button>
208
- )}
209
- </div>
210
- </CardContent>
211
- </Card>
212
-
213
- <DataTable
214
- columns={columns}
215
- rows={rows}
216
- loading={loading}
217
- rowActions={(row) => [
218
- { label: "Edit", icon: Pencil, onClick: () => edit(row) },
219
- { label: "Delete", icon: Trash2, destructive: true, onClick: () => setConfirmDelete(row) },
220
- ]}
221
- empty={{ icon: FolderTree, title: "No categories yet", description: "Create your first category." }}
222
- />
223
- </div>
176
+ ))}
177
+
178
+ <Button
179
+ type="button"
180
+ variant="link"
181
+ size="sm"
182
+ className="h-auto p-0"
183
+ onClick={() =>
184
+ setRows((prev) => [
185
+ ...prev,
186
+ { id: null, name: "", slug: "", menu_order: 0, description: "", parent_id: "", saved: { name: "", slug: "", menu_order: 0 } },
187
+ ])
188
+ }
189
+ >
190
+ <Plus className="mr-1 h-3 w-3" /> Add category
191
+ </Button>
192
+ </CardContent>
193
+ </Card>
224
194
 
225
195
  <ConfirmDialog
226
196
  open={Boolean(confirmDelete)}
227
197
  onOpenChange={(o) => !o && setConfirmDelete(null)}
228
198
  title={`Delete "${confirmDelete?.name}"?`}
229
- description="Products in this category are not deleted; they simply lose the category."
199
+ description="Products in this category are not deleted; they simply lose the category. This cannot be undone."
230
200
  confirmLabel="Delete"
231
201
  onConfirm={doDelete}
232
202
  loading={deleting}
@@ -21,7 +21,6 @@ const NEW_PRODUCT = {
21
21
  slug: "",
22
22
  status: "draft",
23
23
  featured: false,
24
- catalog_visibility: "visible",
25
24
  description: "",
26
25
  short_description: "",
27
26
  sku: "",
@@ -35,7 +34,7 @@ const NEW_PRODUCT = {
35
34
  download_limit: -1,
36
35
  download_expiry: -1,
37
36
  tax_status: "taxable",
38
- tax_class: "standard",
37
+ tax_group: "Products",
39
38
  manage_stock: false,
40
39
  stock_quantity: null,
41
40
  stock_status: "instock",
@@ -44,11 +43,10 @@ const NEW_PRODUCT = {
44
43
  sold_individually: false,
45
44
  weight: null,
46
45
  dimensions: { length: null, width: null, height: null },
47
- shipping_class_id: "",
48
46
  upsell_ids: [],
49
47
  cross_sell_ids: [],
50
48
  category_ids: [],
51
- tag_ids: [],
49
+ ribbon_ids: [],
52
50
  images: [],
53
51
  attributes: [],
54
52
  default_attributes: [],
@@ -72,16 +70,23 @@ export default function ProductEditor() {
72
70
  () => base44.entities["commerce.ProductCategory"].list(undefined, 1000),
73
71
  []
74
72
  );
75
- const { data: tags, refetch: refreshTags } = useAsync(
76
- () => base44.entities["commerce.ProductTag"].list(undefined, 1000),
73
+ const { data: ribbons, refetch: refreshRibbons } = useAsync(
74
+ () => base44.entities["commerce.ProductRibbon"].list(undefined, 1000),
77
75
  []
78
76
  );
79
77
  const { data: attributes, refetch: refreshAttributes } = useAsync(
80
78
  () => base44.entities["commerce.ProductAttribute"].list("order", 500),
81
79
  []
82
80
  );
83
- const { data: shippingClasses } = useAsync(() => base44.entities["commerce.ShippingClass"].list(undefined, 500), []);
84
- const { data: taxClasses } = useAsync(() => base44.entities["commerce.TaxClass"].list(undefined, 100), []);
81
+ // Tax groups are named per Shipping & Tax Location; the product picks by name.
82
+ const { data: locations } = useAsync(() => base44.entities["commerce.ShippingTaxLocation"].list("order", 500), []);
83
+ const taxGroups = useMemo(() => {
84
+ const names = new Set(["Products"]);
85
+ for (const loc of locations || []) {
86
+ for (const g of loc.tax_groups || []) if (g?.name) names.add(g.name);
87
+ }
88
+ return [...names];
89
+ }, [locations]);
85
90
 
86
91
  const load = useCallback(async () => {
87
92
  if (isNew) {
@@ -158,8 +163,8 @@ export default function ProductEditor() {
158
163
  }
159
164
 
160
165
  const refs = {
161
- categories, tags, attributes, shippingClasses, taxClasses,
162
- refreshCategories, refreshTags, refreshAttributes,
166
+ categories, ribbons, attributes, taxGroups,
167
+ refreshCategories, refreshRibbons, refreshAttributes,
163
168
  };
164
169
 
165
170
  return (
@@ -205,14 +210,6 @@ export default function ProductEditor() {
205
210
  </CardContent>
206
211
  </Card>
207
212
 
208
- <ProductDataPanel
209
- product={product}
210
- up={up}
211
- refs={refs}
212
- variations={variations}
213
- setVariations={setVariations}
214
- />
215
-
216
213
  <Card>
217
214
  <CardHeader className="pb-2">
218
215
  <CardTitle className="text-base">Short description</CardTitle>
@@ -226,6 +223,14 @@ export default function ProductEditor() {
226
223
  />
227
224
  </CardContent>
228
225
  </Card>
226
+
227
+ <ProductDataPanel
228
+ product={product}
229
+ up={up}
230
+ refs={refs}
231
+ variations={variations}
232
+ setVariations={setVariations}
233
+ />
229
234
  </div>
230
235
 
231
236
  {/* Sidebar */}
@@ -17,10 +17,12 @@ import {
17
17
  SelectTrigger,
18
18
  SelectValue,
19
19
  } from "@/components/ui/select";
20
+ import { Switch } from "@/components/ui/switch";
20
21
  import { Check, MessageSquare, Pencil, ShieldAlert, Star, Trash, Trash2 } from "lucide-react";
21
22
  import { toast } from "sonner";
22
23
 
23
24
  import { base44, call } from "../../lib/api";
25
+ import { useSettings } from "../../context/SettingsContext";
24
26
  import usePagedList from "../../hooks/usePagedList";
25
27
  import PageHeader from "../../components/PageHeader";
26
28
  import DataTable from "../../components/DataTable";
@@ -170,7 +172,11 @@ export default function Reviews() {
170
172
 
171
173
  return (
172
174
  <div className="space-y-4">
173
- <PageHeader title="Reviews" description="Moderate customer product reviews" />
175
+ <PageHeader
176
+ title="Reviews"
177
+ description="Moderate customer product reviews"
178
+ actions={<AutoApproveToggle />}
179
+ />
174
180
 
175
181
  <Tabs value={tab} onValueChange={(v) => { setTab(v); setSelected([]); }}>
176
182
  <TabsList>
@@ -253,3 +259,33 @@ export default function Reviews() {
253
259
  </div>
254
260
  );
255
261
  }
262
+
263
+ /**
264
+ * The one server-enforced review setting: everything else (login-gating,
265
+ * verified-buyers-only, required ratings) is storefront policy — see
266
+ * .agents/skills/commerce/references/reviews.md.
267
+ */
268
+ function AutoApproveToggle() {
269
+ const settings = useSettings();
270
+ const [saving, setSaving] = useState(false);
271
+ const enabled = !!settings?.get("products", "auto_approve_reviews", false);
272
+
273
+ const toggle = async (v) => {
274
+ setSaving(true);
275
+ try {
276
+ await settings.saveGroup("products", { ...(settings.settings?.products || {}), auto_approve_reviews: v });
277
+ toast.success(v ? "New reviews publish immediately" : "New reviews are held for moderation");
278
+ } catch (e) {
279
+ toast.error(e.message || "Failed to save");
280
+ } finally {
281
+ setSaving(false);
282
+ }
283
+ };
284
+
285
+ return (
286
+ <label className="flex items-center gap-2 text-sm">
287
+ <span className="text-muted-foreground">Auto-approve new reviews</span>
288
+ <Switch checked={enabled} disabled={saving} onCheckedChange={toggle} />
289
+ </label>
290
+ );
291
+ }
@@ -1,4 +1,4 @@
1
- import React, { useMemo, useState } from "react";
1
+ import React from "react";
2
2
  import { Card, CardContent, CardHeader } from "@/components/ui/card";
3
3
  import { Checkbox } from "@/components/ui/checkbox";
4
4
  import { Separator } from "@/components/ui/separator";
@@ -10,21 +10,33 @@ import LinkedTab from "./tabs/LinkedTab";
10
10
  import DownloadsTab from "./tabs/DownloadsTab";
11
11
 
12
12
  /**
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.
13
+ * The "Product data" panel: every section stacked one below the other — no
14
+ * side menu. There is no product type, and nothing about a purchasable thing
15
+ * is split off elsewhere — Price & Inventory carries tax, the attributes, and
16
+ * a row per variant (or the product itself as its default variant), each row
17
+ * owning its price, stock and shipping.
17
18
  */
18
19
  export default function ProductDataPanel({ product, up, refs, variations, setVariations }) {
19
- const tabs = useMemo(() => {
20
- const list = [{ id: "price", label: "Price & Inventory" }, { id: "modifiers", label: "Modifiers" }];
21
- if (product.downloadable) list.push({ id: "downloads", label: "Downloads" });
22
- list.push({ id: "linked", label: "Linked products" });
23
- return list;
24
- }, [product]);
25
-
26
- const [active, setActive] = useState("price");
27
- const activeTab = tabs.some((t) => t.id === active) ? active : tabs[0].id;
20
+ const sections = [
21
+ {
22
+ id: "price",
23
+ label: "Price & Inventory",
24
+ body: (
25
+ <PriceInventoryTab
26
+ product={product}
27
+ up={up}
28
+ refs={refs}
29
+ variations={variations}
30
+ setVariations={setVariations}
31
+ />
32
+ ),
33
+ },
34
+ { id: "modifiers", label: "Modifiers", body: <ModifiersTab product={product} up={up} /> },
35
+ ...(product.downloadable
36
+ ? [{ id: "downloads", label: "Downloads", body: <DownloadsTab product={product} up={up} /> }]
37
+ : []),
38
+ { id: "linked", label: "Linked products", body: <LinkedTab product={product} up={up} /> },
39
+ ];
28
40
 
29
41
  return (
30
42
  <Card>
@@ -48,39 +60,13 @@ export default function ProductDataPanel({ product, up, refs, variations, setVar
48
60
  </div>
49
61
  </CardHeader>
50
62
  <CardContent className="p-0">
51
- <div className="flex min-h-72 flex-col sm:flex-row">
52
- {/* Vertical tab nav */}
53
- <nav className="flex shrink-0 flex-row gap-1 overflow-x-auto border-b bg-muted/40 p-2 sm:w-44 sm:flex-col sm:border-b-0 sm:border-r">
54
- {tabs.map((t) => (
55
- <button
56
- key={t.id}
57
- type="button"
58
- onClick={() => setActive(t.id)}
59
- className={`rounded-md px-3 py-2 text-left text-sm transition-colors ${
60
- activeTab === t.id
61
- ? "bg-background font-medium shadow-sm"
62
- : "text-muted-foreground hover:bg-background/60"
63
- }`}
64
- >
65
- {t.label}
66
- </button>
67
- ))}
68
- </nav>
69
-
70
- <div className="min-w-0 flex-1 p-4">
71
- {activeTab === "price" && (
72
- <PriceInventoryTab
73
- product={product}
74
- up={up}
75
- refs={refs}
76
- variations={variations}
77
- setVariations={setVariations}
78
- />
79
- )}
80
- {activeTab === "modifiers" && <ModifiersTab product={product} up={up} />}
81
- {activeTab === "downloads" && <DownloadsTab product={product} up={up} />}
82
- {activeTab === "linked" && <LinkedTab product={product} up={up} />}
83
- </div>
63
+ <div className="divide-y">
64
+ {sections.map((s) => (
65
+ <section key={s.id} className="p-4">
66
+ <h3 className="mb-3 text-sm font-semibold">{s.label}</h3>
67
+ {s.body}
68
+ </section>
69
+ ))}
84
70
  </div>
85
71
  <Separator />
86
72
  <div className="px-4 py-2 text-xs text-muted-foreground">