@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,172 @@
1
+ import React, { useState } from "react";
2
+ import { Link, useNavigate } from "react-router-dom";
3
+ import { toast } from "sonner";
4
+ import { Button } from "@/components/ui/button";
5
+ import { Input } from "@/components/ui/input";
6
+ import { Plus, Search, TicketPercent, Trash2, Pencil } from "lucide-react";
7
+
8
+ import { call, base44 } from "../../lib/api";
9
+ import { COUPON_TYPES } from "../../lib/constants";
10
+ import { formatDate, truncate } from "../../lib/format";
11
+ import usePagedList from "../../hooks/usePagedList";
12
+ import useDebounce from "../../hooks/useDebounce";
13
+ import useMoney from "../../hooks/useMoney";
14
+ import { useAdminHref } from "../../context/BasePathContext";
15
+ import DataTable from "../../components/DataTable";
16
+ import PageHeader from "../../components/PageHeader";
17
+ import ConfirmDialog from "../../components/ConfirmDialog";
18
+
19
+ export default function CouponsList() {
20
+ const href = useAdminHref();
21
+ const navigate = useNavigate();
22
+ const money = useMoney();
23
+ const [query, setQuery] = useState("");
24
+ const debouncedQuery = useDebounce(query, 300);
25
+ const [selected, setSelected] = useState([]);
26
+ const [pendingDelete, setPendingDelete] = useState(null); // coupon | "bulk"
27
+ const [deleting, setDeleting] = useState(false);
28
+
29
+ const list = usePagedList(
30
+ async (limit, skip, sort) => {
31
+ if (debouncedQuery.trim()) {
32
+ const data = await call("admin-coupons", "search", {
33
+ q: debouncedQuery.trim(),
34
+ limit,
35
+ skip,
36
+ });
37
+ return data?.rows || [];
38
+ }
39
+ return base44.entities["commerce.Coupon"].list(sort, limit, skip);
40
+ },
41
+ { deps: [debouncedQuery] }
42
+ );
43
+
44
+ const amountLabel = (c) =>
45
+ c.discount_type === "percent" ? `${c.amount ?? 0}%` : money.format(c.amount ?? 0);
46
+
47
+ const usageLabel = (c) =>
48
+ `${c.usage_count ?? 0} / ${c.usage_limit ?? "∞"}`;
49
+
50
+ const doDelete = async () => {
51
+ setDeleting(true);
52
+ try {
53
+ if (pendingDelete === "bulk") {
54
+ await call("admin-coupons", "batch", { delete: selected });
55
+ toast.success(`${selected.length} coupon(s) deleted`);
56
+ setSelected([]);
57
+ } else {
58
+ await call("admin-coupons", "delete", { id: pendingDelete.id });
59
+ toast.success("Coupon deleted");
60
+ }
61
+ setPendingDelete(null);
62
+ list.refetch();
63
+ } catch {
64
+ /* toast shown by call() */
65
+ } finally {
66
+ setDeleting(false);
67
+ }
68
+ };
69
+
70
+ const columns = [
71
+ {
72
+ key: "code",
73
+ label: "Code",
74
+ sortable: !debouncedQuery,
75
+ render: (c) => (
76
+ <Link
77
+ to={href(`coupons/${c.id}`)}
78
+ className="inline-block rounded bg-muted px-2 py-0.5 font-mono text-xs font-semibold uppercase tracking-wide hover:bg-muted/70"
79
+ onClick={(e) => e.stopPropagation()}
80
+ >
81
+ {c.code}
82
+ </Link>
83
+ ),
84
+ },
85
+ {
86
+ key: "discount_type",
87
+ label: "Type",
88
+ render: (c) =>
89
+ COUPON_TYPES.find((t) => t.value === c.discount_type)?.label || c.discount_type,
90
+ },
91
+ { key: "amount", label: "Amount", render: amountLabel },
92
+ {
93
+ key: "description",
94
+ label: "Description",
95
+ className: "max-w-64",
96
+ render: (c) => (
97
+ <span className="text-muted-foreground">{truncate(c.description, 60) || "—"}</span>
98
+ ),
99
+ },
100
+ { key: "usage", label: "Usage / Limit", render: usageLabel },
101
+ {
102
+ key: "date_expires",
103
+ label: "Expiry date",
104
+ render: (c) => (c.date_expires ? formatDate(c.date_expires) : "—"),
105
+ },
106
+ ];
107
+
108
+ return (
109
+ <div>
110
+ <PageHeader
111
+ title="Coupons"
112
+ description="Create and manage discount codes."
113
+ actions={
114
+ <Button asChild>
115
+ <Link to={href("coupons/new")}>
116
+ <Plus className="mr-1 h-4 w-4" /> Add coupon
117
+ </Link>
118
+ </Button>
119
+ }
120
+ />
121
+
122
+ <div className="mb-3 flex flex-wrap items-center gap-2">
123
+ <div className="relative w-72">
124
+ <Search className="pointer-events-none absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
125
+ <Input
126
+ className="pl-8"
127
+ placeholder="Search coupons…"
128
+ value={query}
129
+ onChange={(e) => setQuery(e.target.value)}
130
+ />
131
+ </div>
132
+ {selected.length > 0 && (
133
+ <Button variant="outline" size="sm" onClick={() => setPendingDelete("bulk")}>
134
+ <Trash2 className="mr-1 h-4 w-4" /> Delete selected ({selected.length})
135
+ </Button>
136
+ )}
137
+ </div>
138
+
139
+ <DataTable
140
+ columns={columns}
141
+ rows={list.rows}
142
+ loading={list.loading}
143
+ sort={list.sort}
144
+ onSort={debouncedQuery ? undefined : list.setSort}
145
+ selectable
146
+ selected={selected}
147
+ onSelectChange={setSelected}
148
+ onRowClick={(c) => navigate(href(`coupons/${c.id}`))}
149
+ rowActions={(c) => [
150
+ { label: "Edit", icon: Pencil, onClick: () => navigate(href(`coupons/${c.id}`)) },
151
+ { label: "Delete", icon: Trash2, destructive: true, onClick: () => setPendingDelete(c) },
152
+ ]}
153
+ pagination={{ page: list.page, hasNext: list.hasNext, onNext: list.next, onPrev: list.prev }}
154
+ empty={{
155
+ icon: TicketPercent,
156
+ title: "No coupons yet",
157
+ description: "Coupons let you offer discounts at checkout.",
158
+ }}
159
+ />
160
+
161
+ <ConfirmDialog
162
+ open={!!pendingDelete}
163
+ onOpenChange={(o) => !o && setPendingDelete(null)}
164
+ title={pendingDelete === "bulk" ? `Delete ${selected.length} coupon(s)?` : "Delete this coupon?"}
165
+ description="Customers will no longer be able to use it. This cannot be undone."
166
+ confirmLabel="Delete"
167
+ loading={deleting}
168
+ onConfirm={doDelete}
169
+ />
170
+ </div>
171
+ );
172
+ }
@@ -0,0 +1,318 @@
1
+ import React, { useEffect, useState } from "react";
2
+ import { Link, useNavigate, useParams } from "react-router-dom";
3
+ import { toast } from "sonner";
4
+ import { Button } from "@/components/ui/button";
5
+ import { Input } from "@/components/ui/input";
6
+ import { Label } from "@/components/ui/label";
7
+ import { Checkbox } from "@/components/ui/checkbox";
8
+ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
9
+ import { Separator } from "@/components/ui/separator";
10
+ import { Copy, Loader2, RefreshCw } from "lucide-react";
11
+
12
+ import { call, base44 } from "../../lib/api";
13
+ import { formatDate } from "../../lib/format";
14
+ import { round2 } from "../../lib/order-utils";
15
+ import useMoney from "../../hooks/useMoney";
16
+ import { useAdminHref } from "../../context/BasePathContext";
17
+ import PageHeader from "../../components/PageHeader";
18
+ import AddressForm from "../../components/AddressForm";
19
+ import MetaDataEditor from "../../components/MetaDataEditor";
20
+ import ConfirmDialog from "../../components/ConfirmDialog";
21
+ import StatusBadge from "../../components/StatusBadge";
22
+
23
+ const EMPTY = {
24
+ email: "",
25
+ first_name: "",
26
+ last_name: "",
27
+ username: "",
28
+ billing: {},
29
+ shipping: {},
30
+ meta_data: [],
31
+ };
32
+
33
+ export default function CustomerEditor() {
34
+ const { id } = useParams();
35
+ const isNew = !id;
36
+ const href = useAdminHref();
37
+ const navigate = useNavigate();
38
+ const money = useMoney();
39
+
40
+ const [customer, setCustomer] = useState(EMPTY);
41
+ const [recentOrders, setRecentOrders] = useState([]);
42
+ const [loading, setLoading] = useState(!isNew);
43
+ const [saving, setSaving] = useState(false);
44
+ const [sendInvite, setSendInvite] = useState(false);
45
+ const [confirmDelete, setConfirmDelete] = useState(false);
46
+ const [deleting, setDeleting] = useState(false);
47
+ const [recalculating, setRecalculating] = useState(false);
48
+
49
+ const set = (key) => (v) => setCustomer((c) => ({ ...c, [key]: v }));
50
+ const setInput = (key) => (e) => set(key)(e.target.value);
51
+
52
+ useEffect(() => {
53
+ if (isNew) return;
54
+ let cancelled = false;
55
+ setLoading(true);
56
+ Promise.all([
57
+ base44.entities["commerce.Customer"].get(id),
58
+ base44.entities["commerce.Order"].filter({ customer_id: id }, "-created_date", 10).catch(() => []),
59
+ ])
60
+ .then(([c, orders]) => {
61
+ if (cancelled) return;
62
+ setCustomer({ ...EMPTY, ...c });
63
+ setRecentOrders(orders || []);
64
+ })
65
+ .catch(() => {})
66
+ .finally(() => !cancelled && setLoading(false));
67
+ return () => {
68
+ cancelled = true;
69
+ };
70
+ }, [id, isNew]);
71
+
72
+ const save = async () => {
73
+ if (!customer.email?.trim()) {
74
+ toast.error("An email address is required");
75
+ return;
76
+ }
77
+ setSaving(true);
78
+ try {
79
+ const saved = await call("admin-customers", "save", { customer });
80
+ toast.success("Customer saved");
81
+ if (isNew && sendInvite && saved?.email) {
82
+ try {
83
+ await call("admin-customers", "invite", { email: saved.email });
84
+ toast.success("Invite sent");
85
+ } catch {
86
+ /* toast shown by call() */
87
+ }
88
+ }
89
+ if (isNew && saved?.id) navigate(href(`customers/${saved.id}`), { replace: true });
90
+ else if (saved) setCustomer((c) => ({ ...c, ...saved }));
91
+ } catch {
92
+ /* toast shown by call() */
93
+ } finally {
94
+ setSaving(false);
95
+ }
96
+ };
97
+
98
+ const recalcStats = async () => {
99
+ setRecalculating(true);
100
+ try {
101
+ const updated = await call("admin-customers", "recalculate-stats", { id });
102
+ toast.success("Stats recalculated");
103
+ if (updated) setCustomer((c) => ({ ...c, ...updated }));
104
+ } catch {
105
+ /* toast shown by call() */
106
+ } finally {
107
+ setRecalculating(false);
108
+ }
109
+ };
110
+
111
+ const doDelete = async () => {
112
+ setDeleting(true);
113
+ try {
114
+ await call("admin-customers", "delete", { id, reassign_orders_to_guest: true });
115
+ toast.success("Customer deleted");
116
+ navigate(href("customers"));
117
+ } catch {
118
+ setDeleting(false);
119
+ }
120
+ };
121
+
122
+ if (loading) {
123
+ return (
124
+ <div className="flex justify-center py-24">
125
+ <Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
126
+ </div>
127
+ );
128
+ }
129
+
130
+ const name = [customer.first_name, customer.last_name].filter(Boolean).join(" ");
131
+ const aov =
132
+ customer.orders_count > 0 ? round2((customer.total_spent || 0) / customer.orders_count) : 0;
133
+
134
+ return (
135
+ <div>
136
+ <PageHeader
137
+ title={isNew ? "Add customer" : name || customer.email}
138
+ backHref={href("customers")}
139
+ actions={
140
+ <>
141
+ {!isNew && (
142
+ <Button
143
+ variant="outline"
144
+ className="text-destructive hover:text-destructive"
145
+ onClick={() => setConfirmDelete(true)}
146
+ >
147
+ Delete
148
+ </Button>
149
+ )}
150
+ <Button onClick={save} disabled={saving}>
151
+ {saving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
152
+ {isNew ? "Create customer" : "Save customer"}
153
+ </Button>
154
+ </>
155
+ }
156
+ />
157
+
158
+ <div className="grid grid-cols-1 gap-6 lg:grid-cols-[1fr_300px]">
159
+ {/* Main column */}
160
+ <div className="space-y-4">
161
+ <Card>
162
+ <CardHeader className="pb-3">
163
+ <CardTitle className="text-base">Account</CardTitle>
164
+ </CardHeader>
165
+ <CardContent className="grid grid-cols-1 gap-3 sm:grid-cols-2">
166
+ <div className="grid gap-1.5 sm:col-span-2">
167
+ <Label>Email address *</Label>
168
+ <Input type="email" value={customer.email} onChange={setInput("email")} />
169
+ </div>
170
+ <div className="grid gap-1.5">
171
+ <Label>First name</Label>
172
+ <Input value={customer.first_name || ""} onChange={setInput("first_name")} />
173
+ </div>
174
+ <div className="grid gap-1.5">
175
+ <Label>Last name</Label>
176
+ <Input value={customer.last_name || ""} onChange={setInput("last_name")} />
177
+ </div>
178
+ <div className="grid gap-1.5 sm:col-span-2">
179
+ <Label>Username</Label>
180
+ <Input value={customer.username || ""} onChange={setInput("username")} />
181
+ </div>
182
+ {isNew && (
183
+ <label className="flex items-center gap-2 text-sm sm:col-span-2">
184
+ <Checkbox checked={sendInvite} onCheckedChange={(v) => setSendInvite(!!v)} />
185
+ Send the new user an account invite
186
+ </label>
187
+ )}
188
+ </CardContent>
189
+ </Card>
190
+
191
+ <Card>
192
+ <CardHeader className="pb-3">
193
+ <CardTitle className="text-base">Billing address</CardTitle>
194
+ </CardHeader>
195
+ <CardContent>
196
+ <AddressForm
197
+ value={customer.billing || {}}
198
+ onChange={set("billing")}
199
+ showEmail
200
+ showPhone
201
+ />
202
+ </CardContent>
203
+ </Card>
204
+
205
+ <Card>
206
+ <CardHeader className="flex-row items-center justify-between space-y-0 pb-3">
207
+ <CardTitle className="text-base">Shipping address</CardTitle>
208
+ <Button
209
+ type="button"
210
+ variant="outline"
211
+ size="sm"
212
+ onClick={() => {
213
+ const { email: _email, ...rest } = customer.billing || {};
214
+ set("shipping")(rest);
215
+ toast.success("Copied from billing");
216
+ }}
217
+ >
218
+ <Copy className="mr-1 h-4 w-4" /> Copy from billing
219
+ </Button>
220
+ </CardHeader>
221
+ <CardContent>
222
+ <AddressForm
223
+ value={customer.shipping || {}}
224
+ onChange={set("shipping")}
225
+ showEmail={false}
226
+ showPhone
227
+ />
228
+ </CardContent>
229
+ </Card>
230
+
231
+ <Card>
232
+ <CardHeader className="pb-3">
233
+ <CardTitle className="text-base">Custom fields</CardTitle>
234
+ </CardHeader>
235
+ <CardContent>
236
+ <MetaDataEditor value={customer.meta_data || []} onChange={set("meta_data")} />
237
+ </CardContent>
238
+ </Card>
239
+ </div>
240
+
241
+ {/* Sidebar */}
242
+ {!isNew && (
243
+ <div className="space-y-4">
244
+ <Card>
245
+ <CardHeader className="flex-row items-center justify-between space-y-0 pb-3">
246
+ <CardTitle className="text-base">Stats</CardTitle>
247
+ <Button
248
+ variant="ghost"
249
+ size="sm"
250
+ className="text-muted-foreground"
251
+ onClick={recalcStats}
252
+ disabled={recalculating}
253
+ >
254
+ <RefreshCw className={`mr-1 h-3.5 w-3.5 ${recalculating ? "animate-spin" : ""}`} />
255
+ Recalculate
256
+ </Button>
257
+ </CardHeader>
258
+ <CardContent className="space-y-2 text-sm">
259
+ <div className="flex justify-between">
260
+ <span className="text-muted-foreground">Total spend</span>
261
+ <span className="font-medium">{money.format(customer.total_spent ?? 0)}</span>
262
+ </div>
263
+ <div className="flex justify-between">
264
+ <span className="text-muted-foreground">Orders</span>
265
+ <span className="font-medium">{customer.orders_count ?? 0}</span>
266
+ </div>
267
+ <div className="flex justify-between">
268
+ <span className="text-muted-foreground">Average order value</span>
269
+ <span className="font-medium">{money.format(aov)}</span>
270
+ </div>
271
+ {customer.is_paying_customer && (
272
+ <p className="pt-1 text-xs text-muted-foreground">Paying customer ✓</p>
273
+ )}
274
+ </CardContent>
275
+ </Card>
276
+
277
+ <Card>
278
+ <CardHeader className="pb-3">
279
+ <CardTitle className="text-base">Recent orders</CardTitle>
280
+ </CardHeader>
281
+ <CardContent className="space-y-1">
282
+ {recentOrders.length === 0 && (
283
+ <p className="text-sm text-muted-foreground">No orders yet.</p>
284
+ )}
285
+ {recentOrders.map((o) => (
286
+ <div key={o.id}>
287
+ <Link
288
+ to={href(`orders/${o.id}`)}
289
+ className="flex items-center justify-between gap-2 rounded px-1.5 py-1.5 text-sm hover:bg-muted"
290
+ >
291
+ <span className="font-medium">#{o.order_number ?? o.id.slice(0, 6)}</span>
292
+ <span className="text-xs text-muted-foreground">
293
+ {formatDate(o.created_date)}
294
+ </span>
295
+ <StatusBadge status={o.status} className="text-[10px]" />
296
+ <span className="font-medium">{money.format(o.total ?? 0)}</span>
297
+ </Link>
298
+ <Separator className="last:hidden" />
299
+ </div>
300
+ ))}
301
+ </CardContent>
302
+ </Card>
303
+ </div>
304
+ )}
305
+ </div>
306
+
307
+ <ConfirmDialog
308
+ open={confirmDelete}
309
+ onOpenChange={setConfirmDelete}
310
+ title="Delete this customer?"
311
+ description="Their orders are kept and reassigned to Guest. This cannot be undone."
312
+ confirmLabel="Delete"
313
+ loading={deleting}
314
+ onConfirm={doDelete}
315
+ />
316
+ </div>
317
+ );
318
+ }
@@ -0,0 +1,169 @@
1
+ import React, { useState } from "react";
2
+ import { Link, useNavigate } from "react-router-dom";
3
+ import { toast } from "sonner";
4
+ import { Button } from "@/components/ui/button";
5
+ import { Input } from "@/components/ui/input";
6
+ import { Plus, Search, Users, Trash2, Pencil } from "lucide-react";
7
+
8
+ import { call, base44 } from "../../lib/api";
9
+ import { countryName } from "../../lib/geo-data";
10
+ import usePagedList from "../../hooks/usePagedList";
11
+ import useDebounce from "../../hooks/useDebounce";
12
+ import useMoney from "../../hooks/useMoney";
13
+ import { useAdminHref } from "../../context/BasePathContext";
14
+ import DataTable from "../../components/DataTable";
15
+ import PageHeader from "../../components/PageHeader";
16
+ import ConfirmDialog from "../../components/ConfirmDialog";
17
+
18
+ export default function CustomersList() {
19
+ const href = useAdminHref();
20
+ const navigate = useNavigate();
21
+ const money = useMoney();
22
+ const [query, setQuery] = useState("");
23
+ const debouncedQuery = useDebounce(query, 300);
24
+ const [pendingDelete, setPendingDelete] = useState(null);
25
+ const [deleting, setDeleting] = useState(false);
26
+
27
+ const list = usePagedList(
28
+ async (limit, skip, sort) => {
29
+ if (debouncedQuery.trim()) {
30
+ const data = await call("admin-customers", "search", {
31
+ q: debouncedQuery.trim(),
32
+ limit,
33
+ skip,
34
+ });
35
+ return data?.rows || [];
36
+ }
37
+ return base44.entities["commerce.Customer"].list(sort, limit, skip);
38
+ },
39
+ { deps: [debouncedQuery] }
40
+ );
41
+
42
+ const doDelete = async () => {
43
+ setDeleting(true);
44
+ try {
45
+ await call("admin-customers", "delete", {
46
+ id: pendingDelete.id,
47
+ reassign_orders_to_guest: true,
48
+ });
49
+ toast.success("Customer deleted");
50
+ setPendingDelete(null);
51
+ list.refetch();
52
+ } catch {
53
+ /* toast shown by call() */
54
+ } finally {
55
+ setDeleting(false);
56
+ }
57
+ };
58
+
59
+ const columns = [
60
+ {
61
+ key: "name",
62
+ label: "Name",
63
+ render: (c) => {
64
+ const name = [c.first_name, c.last_name].filter(Boolean).join(" ");
65
+ return (
66
+ <Link
67
+ to={href(`customers/${c.id}`)}
68
+ className="font-medium hover:underline"
69
+ onClick={(e) => e.stopPropagation()}
70
+ >
71
+ {name || c.email}
72
+ {c.is_guest && (
73
+ <span className="ml-2 rounded bg-muted px-1.5 py-0.5 text-[10px] uppercase text-muted-foreground">
74
+ guest
75
+ </span>
76
+ )}
77
+ </Link>
78
+ );
79
+ },
80
+ },
81
+ {
82
+ key: "email",
83
+ label: "Email / Username",
84
+ render: (c) => (
85
+ <div className="min-w-0">
86
+ <div className="truncate">{c.email}</div>
87
+ {c.username && <div className="truncate text-xs text-muted-foreground">{c.username}</div>}
88
+ </div>
89
+ ),
90
+ },
91
+ {
92
+ key: "location",
93
+ label: "Location",
94
+ render: (c) => {
95
+ const b = c.billing || {};
96
+ return [b.city, countryName(b.country)].filter(Boolean).join(", ") || "—";
97
+ },
98
+ },
99
+ {
100
+ key: "orders_count",
101
+ label: "Orders",
102
+ sortable: !debouncedQuery,
103
+ render: (c) => c.orders_count ?? 0,
104
+ },
105
+ {
106
+ key: "total_spent",
107
+ label: "Total spend",
108
+ sortable: !debouncedQuery,
109
+ render: (c) => money.format(c.total_spent ?? 0),
110
+ },
111
+ ];
112
+
113
+ return (
114
+ <div>
115
+ <PageHeader
116
+ title="Customers"
117
+ description="Registered customers and checkout guests."
118
+ actions={
119
+ <Button asChild>
120
+ <Link to={href("customers/new")}>
121
+ <Plus className="mr-1 h-4 w-4" /> Add customer
122
+ </Link>
123
+ </Button>
124
+ }
125
+ />
126
+
127
+ <div className="mb-3 flex items-center gap-2">
128
+ <div className="relative w-72">
129
+ <Search className="pointer-events-none absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
130
+ <Input
131
+ className="pl-8"
132
+ placeholder="Search by name, email or username…"
133
+ value={query}
134
+ onChange={(e) => setQuery(e.target.value)}
135
+ />
136
+ </div>
137
+ </div>
138
+
139
+ <DataTable
140
+ columns={columns}
141
+ rows={list.rows}
142
+ loading={list.loading}
143
+ sort={list.sort}
144
+ onSort={debouncedQuery ? undefined : list.setSort}
145
+ onRowClick={(c) => navigate(href(`customers/${c.id}`))}
146
+ rowActions={(c) => [
147
+ { label: "Edit", icon: Pencil, onClick: () => navigate(href(`customers/${c.id}`)) },
148
+ { label: "Delete", icon: Trash2, destructive: true, onClick: () => setPendingDelete(c) },
149
+ ]}
150
+ pagination={{ page: list.page, hasNext: list.hasNext, onNext: list.next, onPrev: list.prev }}
151
+ empty={{
152
+ icon: Users,
153
+ title: "No customers yet",
154
+ description: "Customers appear here after checkout or when you add them manually.",
155
+ }}
156
+ />
157
+
158
+ <ConfirmDialog
159
+ open={!!pendingDelete}
160
+ onOpenChange={(o) => !o && setPendingDelete(null)}
161
+ title="Delete this customer?"
162
+ description="Their orders are kept and reassigned to Guest. This cannot be undone."
163
+ confirmLabel="Delete"
164
+ loading={deleting}
165
+ onConfirm={doDelete}
166
+ />
167
+ </div>
168
+ );
169
+ }