@base44/app-plugin-commerce 0.1.0

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 (173) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +117 -0
  3. package/base44/agents/commerce/StoreAdmin.jsonc +64 -0
  4. package/base44/entities/commerce.Cart.jsonc +73 -0
  5. package/base44/entities/commerce.Coupon.jsonc +113 -0
  6. package/base44/entities/commerce.Customer.jsonc +96 -0
  7. package/base44/entities/commerce.DownloadPermission.jsonc +54 -0
  8. package/base44/entities/commerce.EmailLog.jsonc +43 -0
  9. package/base44/entities/commerce.Order.jsonc +287 -0
  10. package/base44/entities/commerce.OrderNote.jsonc +31 -0
  11. package/base44/entities/commerce.OrderRefund.jsonc +64 -0
  12. package/base44/entities/commerce.PaymentGateway.jsonc +48 -0
  13. package/base44/entities/commerce.Product.jsonc +291 -0
  14. package/base44/entities/commerce.ProductAttribute.jsonc +39 -0
  15. package/base44/entities/commerce.ProductAttributeTerm.jsonc +38 -0
  16. package/base44/entities/commerce.ProductCategory.jsonc +51 -0
  17. package/base44/entities/commerce.ProductReview.jsonc +48 -0
  18. package/base44/entities/commerce.ProductTag.jsonc +30 -0
  19. package/base44/entities/commerce.ProductVariation.jsonc +167 -0
  20. package/base44/entities/commerce.ShippingClass.jsonc +30 -0
  21. package/base44/entities/commerce.ShippingZone.jsonc +41 -0
  22. package/base44/entities/commerce.ShippingZoneMethod.jsonc +84 -0
  23. package/base44/entities/commerce.StoreSettings.jsonc +23 -0
  24. package/base44/entities/commerce.TaxClass.jsonc +23 -0
  25. package/base44/entities/commerce.TaxRate.jsonc +68 -0
  26. package/base44/entities/commerce.Webhook.jsonc +57 -0
  27. package/base44/entities/commerce.WebhookDelivery.jsonc +45 -0
  28. package/base44/functions/commerce/admin-coupons/entry.ts +100 -0
  29. package/base44/functions/commerce/admin-customers/entry.ts +141 -0
  30. package/base44/functions/commerce/admin-orders/entry.ts +396 -0
  31. package/base44/functions/commerce/admin-orders/helpers.ts +246 -0
  32. package/base44/functions/commerce/admin-products/entry.ts +506 -0
  33. package/base44/functions/commerce/admin-refunds/entry.ts +158 -0
  34. package/base44/functions/commerce/admin-reports/entry.ts +283 -0
  35. package/base44/functions/commerce/admin-reviews/entry.ts +66 -0
  36. package/base44/functions/commerce/admin-tools/entry.ts +261 -0
  37. package/base44/functions/commerce/admin-webhooks/entry.ts +52 -0
  38. package/base44/functions/commerce/payment-webhook/entry.ts +135 -0
  39. package/base44/functions/commerce/payments/entry.ts +238 -0
  40. package/base44/functions/commerce/seed-store/defaults.ts +162 -0
  41. package/base44/functions/commerce/seed-store/entry.ts +310 -0
  42. package/base44/functions/commerce/seed-store/sample-data.ts +349 -0
  43. package/base44/functions/commerce/storefront-account/entry.ts +207 -0
  44. package/base44/functions/commerce/storefront-cart/cart-pricing.ts +258 -0
  45. package/base44/functions/commerce/storefront-cart/entry.ts +283 -0
  46. package/base44/functions/commerce/storefront-catalog/entry.ts +459 -0
  47. package/base44/functions/commerce/storefront-checkout/cart-pricing.ts +258 -0
  48. package/base44/functions/commerce/storefront-checkout/entry.ts +485 -0
  49. package/base44/shared/commerce/auth.ts +60 -0
  50. package/base44/shared/commerce/coupons.ts +257 -0
  51. package/base44/shared/commerce/data/continents.ts +75 -0
  52. package/base44/shared/commerce/data/countries.ts +307 -0
  53. package/base44/shared/commerce/data/currencies.ts +46 -0
  54. package/base44/shared/commerce/email-templates.ts +240 -0
  55. package/base44/shared/commerce/emails.ts +225 -0
  56. package/base44/shared/commerce/money.ts +66 -0
  57. package/base44/shared/commerce/orders.ts +251 -0
  58. package/base44/shared/commerce/payments.ts +495 -0
  59. package/base44/shared/commerce/reviews.ts +36 -0
  60. package/base44/shared/commerce/scan.ts +57 -0
  61. package/base44/shared/commerce/sequence.ts +35 -0
  62. package/base44/shared/commerce/settings.ts +57 -0
  63. package/base44/shared/commerce/shipping.ts +215 -0
  64. package/base44/shared/commerce/stock.ts +227 -0
  65. package/base44/shared/commerce/stripe.ts +463 -0
  66. package/base44/shared/commerce/tax.ts +136 -0
  67. package/base44/shared/commerce/totals.ts +314 -0
  68. package/base44/shared/commerce/webhooks.ts +116 -0
  69. package/package.json +37 -0
  70. package/scripts/install.js +156 -0
  71. package/skills/commerce/SKILL.md +62 -0
  72. package/skills/commerce/docs/api-admin.md +186 -0
  73. package/skills/commerce/docs/api-storefront.md +408 -0
  74. package/skills/commerce/installation-guidelines.md +91 -0
  75. package/skills/commerce/post-installation.md +157 -0
  76. package/skills/commerce/references/emails.md +13 -0
  77. package/skills/commerce/references/guest-access-security.md +18 -0
  78. package/skills/commerce/references/limits-and-performance.md +16 -0
  79. package/skills/commerce/references/media-and-downloads.md +4 -0
  80. package/skills/commerce/references/online-payments.md +201 -0
  81. package/skills/commerce/references/product-render.md +87 -0
  82. package/skills/commerce/references/scheduled-work.md +19 -0
  83. package/skills/commerce/references/storefront-product-page.md +83 -0
  84. package/skills/commerce/references/webhooks.md +8 -0
  85. package/src/commerce/admin/README.md +107 -0
  86. package/src/commerce/admin/bot/Markdown.jsx +138 -0
  87. package/src/commerce/admin/bot/StoreAdminBot.jsx +249 -0
  88. package/src/commerce/admin/bot/pipe-tables.js +116 -0
  89. package/src/commerce/admin/components/AddressForm.jsx +78 -0
  90. package/src/commerce/admin/components/ConfirmDialog.jsx +52 -0
  91. package/src/commerce/admin/components/CountrySelect.jsx +81 -0
  92. package/src/commerce/admin/components/DataTable.jsx +192 -0
  93. package/src/commerce/admin/components/DateRangePicker.jsx +91 -0
  94. package/src/commerce/admin/components/EmptyState.jsx +17 -0
  95. package/src/commerce/admin/components/MediaUploader.jsx +116 -0
  96. package/src/commerce/admin/components/MetaDataEditor.jsx +45 -0
  97. package/src/commerce/admin/components/MoneyInput.jsx +50 -0
  98. package/src/commerce/admin/components/PageHeader.jsx +29 -0
  99. package/src/commerce/admin/components/RichTextarea.jsx +21 -0
  100. package/src/commerce/admin/components/SearchSelect.jsx +142 -0
  101. package/src/commerce/admin/components/StatusBadge.jsx +17 -0
  102. package/src/commerce/admin/context/BasePathContext.jsx +26 -0
  103. package/src/commerce/admin/context/SettingsContext.jsx +207 -0
  104. package/src/commerce/admin/hooks/useAsync.js +46 -0
  105. package/src/commerce/admin/hooks/useDebounce.js +11 -0
  106. package/src/commerce/admin/hooks/useMoney.js +52 -0
  107. package/src/commerce/admin/hooks/usePagedList.js +83 -0
  108. package/src/commerce/admin/hooks/usePaymentProvider.js +27 -0
  109. package/src/commerce/admin/hooks/useRealtime.js +129 -0
  110. package/src/commerce/admin/index.jsx +34 -0
  111. package/src/commerce/admin/layout/AccessDenied.jsx +54 -0
  112. package/src/commerce/admin/layout/AdminLayout.jsx +33 -0
  113. package/src/commerce/admin/layout/AuthGuard.jsx +84 -0
  114. package/src/commerce/admin/layout/Sidebar.jsx +130 -0
  115. package/src/commerce/admin/layout/Topbar.jsx +94 -0
  116. package/src/commerce/admin/lib/api.js +55 -0
  117. package/src/commerce/admin/lib/constants.js +157 -0
  118. package/src/commerce/admin/lib/format.js +27 -0
  119. package/src/commerce/admin/lib/geo-data.js +125 -0
  120. package/src/commerce/admin/lib/order-utils.js +147 -0
  121. package/src/commerce/admin/lib/paths.js +35 -0
  122. package/src/commerce/admin/lib/product-utils.js +55 -0
  123. package/src/commerce/admin/pages/Dashboard.jsx +245 -0
  124. package/src/commerce/admin/pages/coupons/CouponEditor.jsx +565 -0
  125. package/src/commerce/admin/pages/coupons/CouponsList.jsx +172 -0
  126. package/src/commerce/admin/pages/customers/CustomerEditor.jsx +318 -0
  127. package/src/commerce/admin/pages/customers/CustomersList.jsx +169 -0
  128. package/src/commerce/admin/pages/orders/OrderEditor.jsx +952 -0
  129. package/src/commerce/admin/pages/orders/OrdersList.jsx +227 -0
  130. package/src/commerce/admin/pages/orders/components/AddProductDialog.jsx +149 -0
  131. package/src/commerce/admin/pages/orders/components/DownloadPermissionsPanel.jsx +119 -0
  132. package/src/commerce/admin/pages/orders/components/LineItemsTable.jsx +208 -0
  133. package/src/commerce/admin/pages/orders/components/OrderNotesPanel.jsx +123 -0
  134. package/src/commerce/admin/pages/orders/components/PaymentPanel.jsx +199 -0
  135. package/src/commerce/admin/pages/orders/components/RefundPanel.jsx +239 -0
  136. package/src/commerce/admin/pages/orders/components/TotalsBox.jsx +52 -0
  137. package/src/commerce/admin/pages/products/AttributeTerms.jsx +180 -0
  138. package/src/commerce/admin/pages/products/Attributes.jsx +183 -0
  139. package/src/commerce/admin/pages/products/Categories.jsx +236 -0
  140. package/src/commerce/admin/pages/products/ProductEditor.jsx +267 -0
  141. package/src/commerce/admin/pages/products/ProductsList.jsx +391 -0
  142. package/src/commerce/admin/pages/products/Reviews.jsx +255 -0
  143. package/src/commerce/admin/pages/products/Tags.jsx +150 -0
  144. package/src/commerce/admin/pages/products/components/ProductDataPanel.jsx +132 -0
  145. package/src/commerce/admin/pages/products/components/PublishBox.jsx +101 -0
  146. package/src/commerce/admin/pages/products/components/TaxonomyPanel.jsx +243 -0
  147. package/src/commerce/admin/pages/products/components/tabs/AdvancedTab.jsx +48 -0
  148. package/src/commerce/admin/pages/products/components/tabs/AttributesTab.jsx +208 -0
  149. package/src/commerce/admin/pages/products/components/tabs/DownloadsTab.jsx +91 -0
  150. package/src/commerce/admin/pages/products/components/tabs/ExternalTab.jsx +41 -0
  151. package/src/commerce/admin/pages/products/components/tabs/GeneralTab.jsx +103 -0
  152. package/src/commerce/admin/pages/products/components/tabs/InventoryTab.jsx +93 -0
  153. package/src/commerce/admin/pages/products/components/tabs/LinkedTab.jsx +102 -0
  154. package/src/commerce/admin/pages/products/components/tabs/ShippingTab.jsx +86 -0
  155. package/src/commerce/admin/pages/products/components/tabs/VariationsTab.jsx +377 -0
  156. package/src/commerce/admin/pages/reports/Reports.jsx +416 -0
  157. package/src/commerce/admin/pages/settings/EmailsSettings.jsx +240 -0
  158. package/src/commerce/admin/pages/settings/GeneralSettings.jsx +232 -0
  159. package/src/commerce/admin/pages/settings/InventorySettings.jsx +146 -0
  160. package/src/commerce/admin/pages/settings/PaymentsSettings.jsx +260 -0
  161. package/src/commerce/admin/pages/settings/ProductsSettings.jsx +118 -0
  162. package/src/commerce/admin/pages/settings/SettingsLayout.jsx +53 -0
  163. package/src/commerce/admin/pages/settings/ShippingSettings.jsx +304 -0
  164. package/src/commerce/admin/pages/settings/ShippingZoneEditor.jsx +514 -0
  165. package/src/commerce/admin/pages/settings/TaxRatesTable.jsx +231 -0
  166. package/src/commerce/admin/pages/settings/TaxSettings.jsx +281 -0
  167. package/src/commerce/admin/pages/settings/useGroupForm.jsx +76 -0
  168. package/src/commerce/admin/pages/status/WebhookEditor.jsx +296 -0
  169. package/src/commerce/admin/pages/status/Webhooks.jsx +53 -0
  170. package/src/commerce/admin/routes.jsx +151 -0
  171. package/src/commerce/utils/index.js +19 -0
  172. package/src/commerce/utils/shipping-promos.js +99 -0
  173. package/src/commerce/utils/variants.js +411 -0
@@ -0,0 +1,391 @@
1
+ import React, { useCallback, useMemo, 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 {
7
+ Select,
8
+ SelectContent,
9
+ SelectItem,
10
+ SelectTrigger,
11
+ SelectValue,
12
+ } from "@/components/ui/select";
13
+ import {
14
+ Dialog,
15
+ DialogContent,
16
+ DialogFooter,
17
+ DialogHeader,
18
+ DialogTitle,
19
+ } from "@/components/ui/dialog";
20
+ import { Copy, ImageIcon, Package, Pencil, Plus, Star, Trash2, Zap } from "lucide-react";
21
+ import { toast } from "sonner";
22
+
23
+ import { base44, call } from "../../lib/api";
24
+ import usePagedList from "../../hooks/usePagedList";
25
+ import useAsync from "../../hooks/useAsync";
26
+ import useDebounce from "../../hooks/useDebounce";
27
+ import useMoney from "../../hooks/useMoney";
28
+ import { useAdminHref } from "../../context/BasePathContext";
29
+ import DataTable from "../../components/DataTable";
30
+ import PageHeader from "../../components/PageHeader";
31
+ import SearchSelect from "../../components/SearchSelect";
32
+ import StatusBadge from "../../components/StatusBadge";
33
+ import MoneyInput from "../../components/MoneyInput";
34
+ import ConfirmDialog from "../../components/ConfirmDialog";
35
+ import { PRODUCT_TYPES, PRODUCT_STATUSES, STOCK_STATUSES, statusMeta } from "../../lib/constants";
36
+ import { formatDate } from "../../lib/format";
37
+
38
+ const ALL = "all";
39
+
40
+ /** Quick-edit dialog: price, sale price, stock, status. */
41
+ function QuickEdit({ product, open, onOpenChange, onSaved }) {
42
+ const [form, setForm] = useState(null);
43
+ const [saving, setSaving] = useState(false);
44
+
45
+ React.useEffect(() => {
46
+ if (open && product) {
47
+ setForm({
48
+ regular_price: product.regular_price ?? null,
49
+ sale_price: product.sale_price ?? null,
50
+ stock_quantity: product.stock_quantity ?? null,
51
+ status: product.status || "draft",
52
+ });
53
+ }
54
+ }, [open, product]);
55
+
56
+ if (!form) return null;
57
+
58
+ const save = async () => {
59
+ setSaving(true);
60
+ try {
61
+ await call("admin-products", "save", { product: { id: product.id, ...form } });
62
+ toast.success("Product updated");
63
+ onOpenChange(false);
64
+ onSaved();
65
+ } catch {
66
+ /* toast handled by call() */
67
+ } finally {
68
+ setSaving(false);
69
+ }
70
+ };
71
+
72
+ return (
73
+ <Dialog open={open} onOpenChange={onOpenChange}>
74
+ <DialogContent className="sm:max-w-sm">
75
+ <DialogHeader>
76
+ <DialogTitle className="flex items-center gap-2">
77
+ <Zap className="h-4 w-4" /> Quick edit
78
+ </DialogTitle>
79
+ </DialogHeader>
80
+ <div className="space-y-3">
81
+ <div className="space-y-1.5">
82
+ <Label>Regular price</Label>
83
+ <MoneyInput value={form.regular_price} onChange={(v) => setForm((f) => ({ ...f, regular_price: v }))} />
84
+ </div>
85
+ <div className="space-y-1.5">
86
+ <Label>Sale price</Label>
87
+ <MoneyInput value={form.sale_price} onChange={(v) => setForm((f) => ({ ...f, sale_price: v }))} />
88
+ </div>
89
+ <div className="space-y-1.5">
90
+ <Label>Stock quantity</Label>
91
+ <Input
92
+ type="number"
93
+ value={form.stock_quantity ?? ""}
94
+ onChange={(e) =>
95
+ setForm((f) => ({ ...f, stock_quantity: e.target.value === "" ? null : Number(e.target.value) }))
96
+ }
97
+ />
98
+ </div>
99
+ <div className="space-y-1.5">
100
+ <Label>Status</Label>
101
+ <Select value={form.status} onValueChange={(v) => setForm((f) => ({ ...f, status: v }))}>
102
+ <SelectTrigger><SelectValue /></SelectTrigger>
103
+ <SelectContent>
104
+ {PRODUCT_STATUSES.map((s) => (
105
+ <SelectItem key={s.value} value={s.value}>{s.label}</SelectItem>
106
+ ))}
107
+ </SelectContent>
108
+ </Select>
109
+ </div>
110
+ </div>
111
+ <DialogFooter>
112
+ <Button onClick={save} disabled={saving}>Save</Button>
113
+ </DialogFooter>
114
+ </DialogContent>
115
+ </Dialog>
116
+ );
117
+ }
118
+
119
+ export default function ProductsList() {
120
+ const navigate = useNavigate();
121
+ const href = useAdminHref();
122
+ const money = useMoney();
123
+
124
+ const [q, setQ] = useState("");
125
+ const [category, setCategory] = useState(null);
126
+ const [type, setType] = useState(ALL);
127
+ const [stock, setStock] = useState(ALL);
128
+ const [selected, setSelected] = useState([]);
129
+ const [quickEdit, setQuickEdit] = useState(null);
130
+ const [confirmDelete, setConfirmDelete] = useState(null); // product | "bulk"
131
+ const [deleting, setDeleting] = useState(false);
132
+ const debouncedQ = useDebounce(q, 300);
133
+
134
+ const { data: categories } = useAsync(() => base44.entities["commerce.ProductCategory"].list(undefined, 1000), []);
135
+ const categoryName = useMemo(() => {
136
+ const map = new Map((categories || []).map((c) => [c.id, c.name]));
137
+ return (id) => map.get(id) || null;
138
+ }, [categories]);
139
+
140
+ const usingSearch = Boolean(debouncedQ || category);
141
+
142
+ const fetcher = useCallback(
143
+ async (limit, skip, sort) => {
144
+ if (usingSearch) {
145
+ const data = await call("admin-products", "search", {
146
+ q: debouncedQ || undefined,
147
+ category_id: category?.value || undefined,
148
+ type: type === ALL ? undefined : type,
149
+ stock_status: stock === ALL ? undefined : stock,
150
+ sort,
151
+ limit,
152
+ skip,
153
+ });
154
+ return data?.rows || data || [];
155
+ }
156
+ const query = {};
157
+ if (type !== ALL) query.type = type;
158
+ if (stock !== ALL) query.stock_status = stock;
159
+ return base44.entities["commerce.Product"].filter(query, sort, limit, skip);
160
+ },
161
+ [usingSearch, debouncedQ, category, type, stock]
162
+ );
163
+
164
+ const list = usePagedList(fetcher, { deps: [debouncedQ, category?.value, type, stock] });
165
+
166
+ const toggleFeatured = async (row) => {
167
+ await call("admin-products", "save", { product: { id: row.id, featured: !row.featured } });
168
+ list.refetch();
169
+ };
170
+
171
+ const duplicate = async (row) => {
172
+ const data = await call("admin-products", "duplicate", { id: row.id });
173
+ const newId = data?.product?.id || data?.id;
174
+ toast.success("Product duplicated");
175
+ if (newId) navigate(href(`products/${newId}`));
176
+ else list.refetch();
177
+ };
178
+
179
+ const doDelete = async () => {
180
+ setDeleting(true);
181
+ try {
182
+ if (confirmDelete === "bulk") {
183
+ for (const id of selected) await call("admin-products", "delete", { id });
184
+ toast.success(`${selected.length} product(s) deleted`);
185
+ setSelected([]);
186
+ } else {
187
+ await call("admin-products", "delete", { id: confirmDelete.id });
188
+ toast.success("Product deleted");
189
+ }
190
+ setConfirmDelete(null);
191
+ list.refetch();
192
+ } catch {
193
+ /* toast handled */
194
+ } finally {
195
+ setDeleting(false);
196
+ }
197
+ };
198
+
199
+ const columns = [
200
+ {
201
+ key: "image",
202
+ label: "",
203
+ className: "w-12",
204
+ render: (row) =>
205
+ row.images?.[0]?.src ? (
206
+ <img src={row.images[0].src} alt="" className="h-10 w-10 rounded object-cover" />
207
+ ) : (
208
+ <div className="flex h-10 w-10 items-center justify-center rounded bg-muted">
209
+ <ImageIcon className="h-4 w-4 text-muted-foreground" />
210
+ </div>
211
+ ),
212
+ },
213
+ {
214
+ key: "name",
215
+ label: "Name",
216
+ sortable: true,
217
+ render: (row) => (
218
+ <div>
219
+ <button
220
+ type="button"
221
+ className="font-medium hover:underline"
222
+ onClick={() => navigate(href(`products/${row.id}`))}
223
+ >
224
+ {row.name || "(no title)"}
225
+ </button>
226
+ {row.sku && <div className="text-xs text-muted-foreground">SKU: {row.sku}</div>}
227
+ </div>
228
+ ),
229
+ },
230
+ { key: "sku", label: "SKU", className: "hidden md:table-cell", render: (row) => row.sku || "—" },
231
+ {
232
+ key: "stock_status",
233
+ label: "Stock",
234
+ render: (row) => (
235
+ <div className="flex items-center gap-1.5">
236
+ <StatusBadge status={row.stock_status} map={STOCK_STATUSES} />
237
+ {row.manage_stock && row.stock_quantity != null && (
238
+ <span className="text-xs text-muted-foreground">({row.stock_quantity})</span>
239
+ )}
240
+ </div>
241
+ ),
242
+ },
243
+ {
244
+ key: "price",
245
+ label: "Price",
246
+ sortable: true,
247
+ render: (row) =>
248
+ row.on_sale && row.sale_price != null ? (
249
+ <span>
250
+ <span className="text-muted-foreground line-through">{money.format(row.regular_price || 0)}</span>{" "}
251
+ {money.format(row.sale_price)}
252
+ </span>
253
+ ) : row.price != null || row.regular_price != null ? (
254
+ money.format(row.price ?? row.regular_price)
255
+ ) : (
256
+ "—"
257
+ ),
258
+ },
259
+ {
260
+ key: "categories",
261
+ label: "Categories",
262
+ className: "hidden lg:table-cell",
263
+ render: (row) => {
264
+ const names = (row.category_ids || []).map(categoryName).filter(Boolean);
265
+ return names.length ? names.join(", ") : "—";
266
+ },
267
+ },
268
+ {
269
+ key: "featured",
270
+ label: "★",
271
+ className: "w-10",
272
+ render: (row) => (
273
+ <button type="button" onClick={() => toggleFeatured(row)} title="Toggle featured">
274
+ <Star
275
+ className={`h-4 w-4 ${row.featured ? "fill-amber-400 text-amber-400" : "text-muted-foreground/40"}`}
276
+ />
277
+ </button>
278
+ ),
279
+ },
280
+ {
281
+ key: "created_date",
282
+ label: "Date",
283
+ sortable: true,
284
+ className: "hidden md:table-cell",
285
+ render: (row) => formatDate(row.created_date),
286
+ },
287
+ {
288
+ key: "status",
289
+ label: "Status",
290
+ render: (row) => <StatusBadge status={row.status} map={PRODUCT_STATUSES} />,
291
+ },
292
+ ];
293
+
294
+ return (
295
+ <div className="space-y-4">
296
+ <PageHeader
297
+ title="Products"
298
+ description="Manage your catalog"
299
+ actions={
300
+ <Button onClick={() => navigate(href("products/new"))}>
301
+ <Plus className="mr-2 h-4 w-4" /> Add new
302
+ </Button>
303
+ }
304
+ />
305
+
306
+ <div className="flex flex-wrap items-end gap-2">
307
+ <Input
308
+ placeholder="Search products…"
309
+ value={q}
310
+ onChange={(e) => setQ(e.target.value)}
311
+ className="w-64"
312
+ />
313
+ <SearchSelect
314
+ className="w-56"
315
+ placeholder="All categories"
316
+ value={category}
317
+ onChange={setCategory}
318
+ search={async (query) =>
319
+ (categories || [])
320
+ .filter((c) => !query || c.name.toLowerCase().includes(query.toLowerCase()))
321
+ .slice(0, 20)
322
+ .map((c) => ({ value: c.id, label: c.name }))
323
+ }
324
+ />
325
+ <Select value={type} onValueChange={setType}>
326
+ <SelectTrigger className="w-44"><SelectValue /></SelectTrigger>
327
+ <SelectContent>
328
+ <SelectItem value={ALL}>All types</SelectItem>
329
+ {PRODUCT_TYPES.map((t) => (
330
+ <SelectItem key={t.value} value={t.value}>{t.label}</SelectItem>
331
+ ))}
332
+ </SelectContent>
333
+ </Select>
334
+ <Select value={stock} onValueChange={setStock}>
335
+ <SelectTrigger className="w-40"><SelectValue /></SelectTrigger>
336
+ <SelectContent>
337
+ <SelectItem value={ALL}>All stock</SelectItem>
338
+ {STOCK_STATUSES.map((s) => (
339
+ <SelectItem key={s.value} value={s.value}>{s.label}</SelectItem>
340
+ ))}
341
+ </SelectContent>
342
+ </Select>
343
+ {selected.length > 0 && (
344
+ <Button variant="destructive" size="sm" onClick={() => setConfirmDelete("bulk")}>
345
+ <Trash2 className="mr-2 h-4 w-4" /> Delete ({selected.length})
346
+ </Button>
347
+ )}
348
+ </div>
349
+
350
+ <DataTable
351
+ columns={columns}
352
+ rows={list.rows}
353
+ loading={list.loading}
354
+ sort={list.sort}
355
+ onSort={list.setSort}
356
+ selectable
357
+ selected={selected}
358
+ onSelectChange={setSelected}
359
+ pagination={{ page: list.page, hasNext: list.hasNext, onNext: list.next, onPrev: list.prev }}
360
+ rowActions={(row) => [
361
+ { label: "Edit", icon: Pencil, onClick: () => navigate(href(`products/${row.id}`)) },
362
+ { label: "Quick edit", icon: Zap, onClick: () => setQuickEdit(row) },
363
+ { label: "Duplicate", icon: Copy, onClick: () => duplicate(row) },
364
+ { label: "Delete", icon: Trash2, destructive: true, onClick: () => setConfirmDelete(row) },
365
+ ]}
366
+ empty={{
367
+ icon: Package,
368
+ title: "No products found",
369
+ description: usingSearch ? "Try a different search or filter." : "Create your first product to get started.",
370
+ }}
371
+ />
372
+
373
+ <QuickEdit
374
+ product={quickEdit}
375
+ open={Boolean(quickEdit)}
376
+ onOpenChange={(o) => !o && setQuickEdit(null)}
377
+ onSaved={list.refetch}
378
+ />
379
+
380
+ <ConfirmDialog
381
+ open={Boolean(confirmDelete)}
382
+ onOpenChange={(o) => !o && setConfirmDelete(null)}
383
+ title={confirmDelete === "bulk" ? `Delete ${selected.length} product(s)?` : `Delete "${confirmDelete?.name}"?`}
384
+ description="This permanently deletes the product and its variations."
385
+ confirmLabel="Delete"
386
+ onConfirm={doDelete}
387
+ loading={deleting}
388
+ />
389
+ </div>
390
+ );
391
+ }
@@ -0,0 +1,255 @@
1
+ import React, { useCallback, useEffect, useMemo, useState } from "react";
2
+ import { Button } from "@/components/ui/button";
3
+ import { Label } from "@/components/ui/label";
4
+ import { Textarea } from "@/components/ui/textarea";
5
+ import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
6
+ import {
7
+ Dialog,
8
+ DialogContent,
9
+ DialogFooter,
10
+ DialogHeader,
11
+ DialogTitle,
12
+ } from "@/components/ui/dialog";
13
+ import {
14
+ Select,
15
+ SelectContent,
16
+ SelectItem,
17
+ SelectTrigger,
18
+ SelectValue,
19
+ } from "@/components/ui/select";
20
+ import { Check, MessageSquare, Pencil, ShieldAlert, Star, Trash, Trash2 } from "lucide-react";
21
+ import { toast } from "sonner";
22
+
23
+ import { base44, call } from "../../lib/api";
24
+ import usePagedList from "../../hooks/usePagedList";
25
+ import PageHeader from "../../components/PageHeader";
26
+ import DataTable from "../../components/DataTable";
27
+ import StatusBadge from "../../components/StatusBadge";
28
+ import ConfirmDialog from "../../components/ConfirmDialog";
29
+ import { REVIEW_STATUSES } from "../../lib/constants";
30
+ import { formatDate } from "../../lib/format";
31
+
32
+ const TABS = [
33
+ { value: "all", label: "All" },
34
+ { value: "approved", label: "Approved" },
35
+ { value: "hold", label: "Pending" },
36
+ { value: "spam", label: "Spam" },
37
+ { value: "trash", label: "Trash" },
38
+ ];
39
+
40
+ function Stars({ rating = 0 }) {
41
+ return (
42
+ <span className="inline-flex" title={`${rating} / 5`}>
43
+ {Array.from({ length: 5 }).map((_, i) => (
44
+ <Star
45
+ key={i}
46
+ className={`h-3.5 w-3.5 ${i < rating ? "fill-amber-400 text-amber-400" : "text-muted-foreground/30"}`}
47
+ />
48
+ ))}
49
+ </span>
50
+ );
51
+ }
52
+
53
+ export default function Reviews() {
54
+ const [tab, setTab] = useState("all");
55
+ const [selected, setSelected] = useState([]);
56
+ const [editing, setEditing] = useState(null);
57
+ const [confirmDelete, setConfirmDelete] = useState(null);
58
+ const [deleting, setDeleting] = useState(false);
59
+ const [productNames, setProductNames] = useState({});
60
+
61
+ const fetcher = useCallback(
62
+ (limit, skip, sort) => {
63
+ const query = tab === "all" ? {} : { status: tab };
64
+ return base44.entities["commerce.ProductReview"].filter(query, sort, limit, skip);
65
+ },
66
+ [tab]
67
+ );
68
+
69
+ const list = usePagedList(fetcher, { deps: [tab] });
70
+
71
+ // Resolve product names for the visible page (cached).
72
+ useEffect(() => {
73
+ const ids = [...new Set((list.rows || []).map((r) => r.product_id).filter((id) => id && !(id in productNames)))];
74
+ if (!ids.length) return;
75
+ let cancelled = false;
76
+ Promise.all(
77
+ ids.map((id) =>
78
+ base44.entities["commerce.Product"].get(id)
79
+ .then((p) => [id, p?.name || "(deleted product)"])
80
+ .catch(() => [id, "(deleted product)"])
81
+ )
82
+ ).then((pairs) => {
83
+ if (!cancelled) setProductNames((prev) => ({ ...prev, ...Object.fromEntries(pairs) }));
84
+ });
85
+ return () => {
86
+ cancelled = true;
87
+ };
88
+ }, [list.rows, productNames]);
89
+
90
+ const setStatus = async (ids, status) => {
91
+ for (const id of ids) await call("admin-reviews", "set-status", { id, status });
92
+ toast.success(`Review${ids.length > 1 ? "s" : ""} updated`);
93
+ setSelected([]);
94
+ list.refetch();
95
+ };
96
+
97
+ const saveEdit = async () => {
98
+ await call("admin-reviews", "update", { id: editing.id, rating: editing.rating, review: editing.review });
99
+ toast.success("Review updated");
100
+ setEditing(null);
101
+ list.refetch();
102
+ };
103
+
104
+ const doDelete = async () => {
105
+ setDeleting(true);
106
+ try {
107
+ const ids = confirmDelete === "bulk" ? selected : [confirmDelete.id];
108
+ for (const id of ids) await call("admin-reviews", "delete", { id });
109
+ toast.success(`Review${ids.length > 1 ? "s" : ""} deleted`);
110
+ setSelected([]);
111
+ setConfirmDelete(null);
112
+ list.refetch();
113
+ } catch {
114
+ /* toast handled */
115
+ } finally {
116
+ setDeleting(false);
117
+ }
118
+ };
119
+
120
+ const columns = useMemo(
121
+ () => [
122
+ {
123
+ key: "reviewer",
124
+ label: "Author",
125
+ render: (row) => (
126
+ <div>
127
+ <div className="font-medium">{row.reviewer || "Anonymous"}</div>
128
+ {row.reviewer_email && <div className="text-xs text-muted-foreground">{row.reviewer_email}</div>}
129
+ </div>
130
+ ),
131
+ },
132
+ { key: "rating", label: "Rating", render: (row) => <Stars rating={row.rating || 0} /> },
133
+ {
134
+ key: "review",
135
+ label: "Review",
136
+ render: (row) => (
137
+ <div className="max-w-md">
138
+ <p className="line-clamp-2 text-sm">{row.review}</p>
139
+ {row.verified && <span className="text-xs text-green-700">✓ Verified owner</span>}
140
+ </div>
141
+ ),
142
+ },
143
+ {
144
+ key: "product_id",
145
+ label: "Product",
146
+ className: "hidden md:table-cell",
147
+ render: (row) => productNames[row.product_id] || "…",
148
+ },
149
+ {
150
+ key: "created_date",
151
+ label: "Date",
152
+ className: "hidden lg:table-cell",
153
+ render: (row) => formatDate(row.created_date),
154
+ },
155
+ { key: "status", label: "Status", render: (row) => <StatusBadge status={row.status} map={REVIEW_STATUSES} /> },
156
+ ],
157
+ [productNames]
158
+ );
159
+
160
+ const rowActions = (row) => {
161
+ const actions = [];
162
+ if (row.status !== "approved") actions.push({ label: "Approve", icon: Check, onClick: () => setStatus([row.id], "approved") });
163
+ if (row.status !== "hold") actions.push({ label: "Hold", icon: MessageSquare, onClick: () => setStatus([row.id], "hold") });
164
+ if (row.status !== "spam") actions.push({ label: "Spam", icon: ShieldAlert, onClick: () => setStatus([row.id], "spam") });
165
+ if (row.status !== "trash") actions.push({ label: "Trash", icon: Trash, onClick: () => setStatus([row.id], "trash") });
166
+ actions.push({ label: "Edit", icon: Pencil, onClick: () => setEditing({ id: row.id, rating: row.rating || 5, review: row.review || "" }) });
167
+ actions.push({ label: "Delete permanently", icon: Trash2, destructive: true, onClick: () => setConfirmDelete(row) });
168
+ return actions;
169
+ };
170
+
171
+ return (
172
+ <div className="space-y-4">
173
+ <PageHeader title="Reviews" description="Moderate customer product reviews" />
174
+
175
+ <Tabs value={tab} onValueChange={(v) => { setTab(v); setSelected([]); }}>
176
+ <TabsList>
177
+ {TABS.map((t) => (
178
+ <TabsTrigger key={t.value} value={t.value}>{t.label}</TabsTrigger>
179
+ ))}
180
+ </TabsList>
181
+ </Tabs>
182
+
183
+ {selected.length > 0 && (
184
+ <div className="flex flex-wrap items-center gap-2 rounded-md border bg-muted/40 px-3 py-2">
185
+ <span className="text-sm text-muted-foreground">{selected.length} selected</span>
186
+ <Button size="sm" variant="outline" onClick={() => setStatus(selected, "approved")}>Approve</Button>
187
+ <Button size="sm" variant="outline" onClick={() => setStatus(selected, "hold")}>Hold</Button>
188
+ <Button size="sm" variant="outline" onClick={() => setStatus(selected, "spam")}>Spam</Button>
189
+ <Button size="sm" variant="outline" onClick={() => setStatus(selected, "trash")}>Trash</Button>
190
+ <Button size="sm" variant="destructive" onClick={() => setConfirmDelete("bulk")}>Delete</Button>
191
+ </div>
192
+ )}
193
+
194
+ <DataTable
195
+ columns={columns}
196
+ rows={list.rows}
197
+ loading={list.loading}
198
+ selectable
199
+ selected={selected}
200
+ onSelectChange={setSelected}
201
+ pagination={{ page: list.page, hasNext: list.hasNext, onNext: list.next, onPrev: list.prev }}
202
+ rowActions={rowActions}
203
+ empty={{ icon: MessageSquare, title: "No reviews", description: "Reviews submitted by customers appear here." }}
204
+ />
205
+
206
+ <Dialog open={Boolean(editing)} onOpenChange={(o) => !o && setEditing(null)}>
207
+ <DialogContent>
208
+ <DialogHeader>
209
+ <DialogTitle>Edit review</DialogTitle>
210
+ </DialogHeader>
211
+ {editing && (
212
+ <div className="space-y-3">
213
+ <div className="space-y-1.5">
214
+ <Label>Rating</Label>
215
+ <Select
216
+ value={String(editing.rating)}
217
+ onValueChange={(v) => setEditing((e) => ({ ...e, rating: Number(v) }))}
218
+ >
219
+ <SelectTrigger className="w-32"><SelectValue /></SelectTrigger>
220
+ <SelectContent>
221
+ {[1, 2, 3, 4, 5].map((n) => (
222
+ <SelectItem key={n} value={String(n)}>{n} star{n > 1 ? "s" : ""}</SelectItem>
223
+ ))}
224
+ </SelectContent>
225
+ </Select>
226
+ </div>
227
+ <div className="space-y-1.5">
228
+ <Label>Review</Label>
229
+ <Textarea
230
+ rows={5}
231
+ value={editing.review}
232
+ onChange={(e) => setEditing((prev) => ({ ...prev, review: e.target.value }))}
233
+ />
234
+ </div>
235
+ </div>
236
+ )}
237
+ <DialogFooter>
238
+ <Button variant="ghost" onClick={() => setEditing(null)}>Cancel</Button>
239
+ <Button onClick={saveEdit}>Save</Button>
240
+ </DialogFooter>
241
+ </DialogContent>
242
+ </Dialog>
243
+
244
+ <ConfirmDialog
245
+ open={Boolean(confirmDelete)}
246
+ onOpenChange={(o) => !o && setConfirmDelete(null)}
247
+ title={confirmDelete === "bulk" ? `Delete ${selected.length} review(s)?` : "Delete this review?"}
248
+ description="This permanently removes the review."
249
+ confirmLabel="Delete"
250
+ onConfirm={doDelete}
251
+ loading={deleting}
252
+ />
253
+ </div>
254
+ );
255
+ }