@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.
- package/LICENSE +21 -0
- package/README.md +117 -0
- package/base44/agents/commerce/StoreAdmin.jsonc +64 -0
- package/base44/entities/commerce.Cart.jsonc +73 -0
- package/base44/entities/commerce.Coupon.jsonc +113 -0
- package/base44/entities/commerce.Customer.jsonc +96 -0
- package/base44/entities/commerce.DownloadPermission.jsonc +54 -0
- package/base44/entities/commerce.EmailLog.jsonc +43 -0
- package/base44/entities/commerce.Order.jsonc +287 -0
- package/base44/entities/commerce.OrderNote.jsonc +31 -0
- package/base44/entities/commerce.OrderRefund.jsonc +64 -0
- package/base44/entities/commerce.PaymentGateway.jsonc +48 -0
- package/base44/entities/commerce.Product.jsonc +291 -0
- package/base44/entities/commerce.ProductAttribute.jsonc +39 -0
- package/base44/entities/commerce.ProductAttributeTerm.jsonc +38 -0
- package/base44/entities/commerce.ProductCategory.jsonc +51 -0
- package/base44/entities/commerce.ProductReview.jsonc +48 -0
- package/base44/entities/commerce.ProductTag.jsonc +30 -0
- package/base44/entities/commerce.ProductVariation.jsonc +167 -0
- package/base44/entities/commerce.ShippingClass.jsonc +30 -0
- package/base44/entities/commerce.ShippingZone.jsonc +41 -0
- package/base44/entities/commerce.ShippingZoneMethod.jsonc +84 -0
- package/base44/entities/commerce.StoreSettings.jsonc +23 -0
- package/base44/entities/commerce.TaxClass.jsonc +23 -0
- package/base44/entities/commerce.TaxRate.jsonc +68 -0
- package/base44/entities/commerce.Webhook.jsonc +57 -0
- package/base44/entities/commerce.WebhookDelivery.jsonc +45 -0
- package/base44/functions/commerce/admin-coupons/entry.ts +100 -0
- package/base44/functions/commerce/admin-customers/entry.ts +141 -0
- package/base44/functions/commerce/admin-orders/entry.ts +396 -0
- package/base44/functions/commerce/admin-orders/helpers.ts +246 -0
- package/base44/functions/commerce/admin-products/entry.ts +506 -0
- package/base44/functions/commerce/admin-refunds/entry.ts +158 -0
- package/base44/functions/commerce/admin-reports/entry.ts +283 -0
- package/base44/functions/commerce/admin-reviews/entry.ts +66 -0
- package/base44/functions/commerce/admin-tools/entry.ts +261 -0
- package/base44/functions/commerce/admin-webhooks/entry.ts +52 -0
- package/base44/functions/commerce/payment-webhook/entry.ts +135 -0
- package/base44/functions/commerce/payments/entry.ts +238 -0
- package/base44/functions/commerce/seed-store/defaults.ts +162 -0
- package/base44/functions/commerce/seed-store/entry.ts +310 -0
- package/base44/functions/commerce/seed-store/sample-data.ts +349 -0
- package/base44/functions/commerce/storefront-account/entry.ts +207 -0
- package/base44/functions/commerce/storefront-cart/cart-pricing.ts +258 -0
- package/base44/functions/commerce/storefront-cart/entry.ts +283 -0
- package/base44/functions/commerce/storefront-catalog/entry.ts +459 -0
- package/base44/functions/commerce/storefront-checkout/cart-pricing.ts +258 -0
- package/base44/functions/commerce/storefront-checkout/entry.ts +485 -0
- package/base44/shared/commerce/auth.ts +60 -0
- package/base44/shared/commerce/coupons.ts +257 -0
- package/base44/shared/commerce/data/continents.ts +75 -0
- package/base44/shared/commerce/data/countries.ts +307 -0
- package/base44/shared/commerce/data/currencies.ts +46 -0
- package/base44/shared/commerce/email-templates.ts +240 -0
- package/base44/shared/commerce/emails.ts +225 -0
- package/base44/shared/commerce/money.ts +66 -0
- package/base44/shared/commerce/orders.ts +251 -0
- package/base44/shared/commerce/payments.ts +495 -0
- package/base44/shared/commerce/reviews.ts +36 -0
- package/base44/shared/commerce/scan.ts +57 -0
- package/base44/shared/commerce/sequence.ts +35 -0
- package/base44/shared/commerce/settings.ts +57 -0
- package/base44/shared/commerce/shipping.ts +215 -0
- package/base44/shared/commerce/stock.ts +227 -0
- package/base44/shared/commerce/stripe.ts +463 -0
- package/base44/shared/commerce/tax.ts +136 -0
- package/base44/shared/commerce/totals.ts +314 -0
- package/base44/shared/commerce/webhooks.ts +116 -0
- package/package.json +37 -0
- package/scripts/install.js +156 -0
- package/skills/commerce/SKILL.md +62 -0
- package/skills/commerce/docs/api-admin.md +186 -0
- package/skills/commerce/docs/api-storefront.md +408 -0
- package/skills/commerce/installation-guidelines.md +91 -0
- package/skills/commerce/post-installation.md +157 -0
- package/skills/commerce/references/emails.md +13 -0
- package/skills/commerce/references/guest-access-security.md +18 -0
- package/skills/commerce/references/limits-and-performance.md +16 -0
- package/skills/commerce/references/media-and-downloads.md +4 -0
- package/skills/commerce/references/online-payments.md +201 -0
- package/skills/commerce/references/product-render.md +87 -0
- package/skills/commerce/references/scheduled-work.md +19 -0
- package/skills/commerce/references/storefront-product-page.md +83 -0
- package/skills/commerce/references/webhooks.md +8 -0
- package/src/commerce/admin/README.md +107 -0
- package/src/commerce/admin/bot/Markdown.jsx +138 -0
- package/src/commerce/admin/bot/StoreAdminBot.jsx +249 -0
- package/src/commerce/admin/bot/pipe-tables.js +116 -0
- package/src/commerce/admin/components/AddressForm.jsx +78 -0
- package/src/commerce/admin/components/ConfirmDialog.jsx +52 -0
- package/src/commerce/admin/components/CountrySelect.jsx +81 -0
- package/src/commerce/admin/components/DataTable.jsx +192 -0
- package/src/commerce/admin/components/DateRangePicker.jsx +91 -0
- package/src/commerce/admin/components/EmptyState.jsx +17 -0
- package/src/commerce/admin/components/MediaUploader.jsx +116 -0
- package/src/commerce/admin/components/MetaDataEditor.jsx +45 -0
- package/src/commerce/admin/components/MoneyInput.jsx +50 -0
- package/src/commerce/admin/components/PageHeader.jsx +29 -0
- package/src/commerce/admin/components/RichTextarea.jsx +21 -0
- package/src/commerce/admin/components/SearchSelect.jsx +142 -0
- package/src/commerce/admin/components/StatusBadge.jsx +17 -0
- package/src/commerce/admin/context/BasePathContext.jsx +26 -0
- package/src/commerce/admin/context/SettingsContext.jsx +207 -0
- package/src/commerce/admin/hooks/useAsync.js +46 -0
- package/src/commerce/admin/hooks/useDebounce.js +11 -0
- package/src/commerce/admin/hooks/useMoney.js +52 -0
- package/src/commerce/admin/hooks/usePagedList.js +83 -0
- package/src/commerce/admin/hooks/usePaymentProvider.js +27 -0
- package/src/commerce/admin/hooks/useRealtime.js +129 -0
- package/src/commerce/admin/index.jsx +34 -0
- package/src/commerce/admin/layout/AccessDenied.jsx +54 -0
- package/src/commerce/admin/layout/AdminLayout.jsx +33 -0
- package/src/commerce/admin/layout/AuthGuard.jsx +84 -0
- package/src/commerce/admin/layout/Sidebar.jsx +130 -0
- package/src/commerce/admin/layout/Topbar.jsx +94 -0
- package/src/commerce/admin/lib/api.js +55 -0
- package/src/commerce/admin/lib/constants.js +157 -0
- package/src/commerce/admin/lib/format.js +27 -0
- package/src/commerce/admin/lib/geo-data.js +125 -0
- package/src/commerce/admin/lib/order-utils.js +147 -0
- package/src/commerce/admin/lib/paths.js +35 -0
- package/src/commerce/admin/lib/product-utils.js +55 -0
- package/src/commerce/admin/pages/Dashboard.jsx +245 -0
- package/src/commerce/admin/pages/coupons/CouponEditor.jsx +565 -0
- package/src/commerce/admin/pages/coupons/CouponsList.jsx +172 -0
- package/src/commerce/admin/pages/customers/CustomerEditor.jsx +318 -0
- package/src/commerce/admin/pages/customers/CustomersList.jsx +169 -0
- package/src/commerce/admin/pages/orders/OrderEditor.jsx +952 -0
- package/src/commerce/admin/pages/orders/OrdersList.jsx +227 -0
- package/src/commerce/admin/pages/orders/components/AddProductDialog.jsx +149 -0
- package/src/commerce/admin/pages/orders/components/DownloadPermissionsPanel.jsx +119 -0
- package/src/commerce/admin/pages/orders/components/LineItemsTable.jsx +208 -0
- package/src/commerce/admin/pages/orders/components/OrderNotesPanel.jsx +123 -0
- package/src/commerce/admin/pages/orders/components/PaymentPanel.jsx +199 -0
- package/src/commerce/admin/pages/orders/components/RefundPanel.jsx +239 -0
- package/src/commerce/admin/pages/orders/components/TotalsBox.jsx +52 -0
- package/src/commerce/admin/pages/products/AttributeTerms.jsx +180 -0
- package/src/commerce/admin/pages/products/Attributes.jsx +183 -0
- package/src/commerce/admin/pages/products/Categories.jsx +236 -0
- package/src/commerce/admin/pages/products/ProductEditor.jsx +267 -0
- package/src/commerce/admin/pages/products/ProductsList.jsx +391 -0
- package/src/commerce/admin/pages/products/Reviews.jsx +255 -0
- package/src/commerce/admin/pages/products/Tags.jsx +150 -0
- package/src/commerce/admin/pages/products/components/ProductDataPanel.jsx +132 -0
- package/src/commerce/admin/pages/products/components/PublishBox.jsx +101 -0
- package/src/commerce/admin/pages/products/components/TaxonomyPanel.jsx +243 -0
- package/src/commerce/admin/pages/products/components/tabs/AdvancedTab.jsx +48 -0
- package/src/commerce/admin/pages/products/components/tabs/AttributesTab.jsx +208 -0
- package/src/commerce/admin/pages/products/components/tabs/DownloadsTab.jsx +91 -0
- package/src/commerce/admin/pages/products/components/tabs/ExternalTab.jsx +41 -0
- package/src/commerce/admin/pages/products/components/tabs/GeneralTab.jsx +103 -0
- package/src/commerce/admin/pages/products/components/tabs/InventoryTab.jsx +93 -0
- package/src/commerce/admin/pages/products/components/tabs/LinkedTab.jsx +102 -0
- package/src/commerce/admin/pages/products/components/tabs/ShippingTab.jsx +86 -0
- package/src/commerce/admin/pages/products/components/tabs/VariationsTab.jsx +377 -0
- package/src/commerce/admin/pages/reports/Reports.jsx +416 -0
- package/src/commerce/admin/pages/settings/EmailsSettings.jsx +240 -0
- package/src/commerce/admin/pages/settings/GeneralSettings.jsx +232 -0
- package/src/commerce/admin/pages/settings/InventorySettings.jsx +146 -0
- package/src/commerce/admin/pages/settings/PaymentsSettings.jsx +260 -0
- package/src/commerce/admin/pages/settings/ProductsSettings.jsx +118 -0
- package/src/commerce/admin/pages/settings/SettingsLayout.jsx +53 -0
- package/src/commerce/admin/pages/settings/ShippingSettings.jsx +304 -0
- package/src/commerce/admin/pages/settings/ShippingZoneEditor.jsx +514 -0
- package/src/commerce/admin/pages/settings/TaxRatesTable.jsx +231 -0
- package/src/commerce/admin/pages/settings/TaxSettings.jsx +281 -0
- package/src/commerce/admin/pages/settings/useGroupForm.jsx +76 -0
- package/src/commerce/admin/pages/status/WebhookEditor.jsx +296 -0
- package/src/commerce/admin/pages/status/Webhooks.jsx +53 -0
- package/src/commerce/admin/routes.jsx +151 -0
- package/src/commerce/utils/index.js +19 -0
- package/src/commerce/utils/shipping-promos.js +99 -0
- 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
|
+
}
|