@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,416 @@
|
|
|
1
|
+
import React, { useMemo, useState } from "react";
|
|
2
|
+
import { Link } from "react-router-dom";
|
|
3
|
+
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
4
|
+
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
|
5
|
+
import { Skeleton } from "@/components/ui/skeleton";
|
|
6
|
+
import { BarChart3 } from "lucide-react";
|
|
7
|
+
import {
|
|
8
|
+
Bar,
|
|
9
|
+
BarChart,
|
|
10
|
+
CartesianGrid,
|
|
11
|
+
Legend,
|
|
12
|
+
Line,
|
|
13
|
+
LineChart,
|
|
14
|
+
ResponsiveContainer,
|
|
15
|
+
Tooltip,
|
|
16
|
+
XAxis,
|
|
17
|
+
YAxis,
|
|
18
|
+
} from "recharts";
|
|
19
|
+
|
|
20
|
+
import { call } from "../../lib/api";
|
|
21
|
+
import useAsync from "../../hooks/useAsync";
|
|
22
|
+
import useRealtime from "../../hooks/useRealtime";
|
|
23
|
+
import useMoney from "../../hooks/useMoney";
|
|
24
|
+
import { useAdminHref } from "../../context/BasePathContext";
|
|
25
|
+
import PageHeader from "../../components/PageHeader";
|
|
26
|
+
import DateRangePicker from "../../components/DateRangePicker";
|
|
27
|
+
import DataTable from "../../components/DataTable";
|
|
28
|
+
import StatusBadge from "../../components/StatusBadge";
|
|
29
|
+
import {
|
|
30
|
+
COUPON_TYPES,
|
|
31
|
+
PRODUCT_TYPES,
|
|
32
|
+
REVIEW_STATUSES,
|
|
33
|
+
} from "../../lib/constants";
|
|
34
|
+
|
|
35
|
+
const toISO = (d) => d.toISOString().slice(0, 10);
|
|
36
|
+
|
|
37
|
+
function thisMonth() {
|
|
38
|
+
const now = new Date();
|
|
39
|
+
return { from: toISO(new Date(now.getFullYear(), now.getMonth(), 1)), to: toISO(now) };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** day for ≤31d ranges, week for ≤120d, else month. */
|
|
43
|
+
function autoInterval(range) {
|
|
44
|
+
if (!range?.from || !range?.to) return "month";
|
|
45
|
+
const days = (new Date(range.to) - new Date(range.from)) / 86400000;
|
|
46
|
+
if (days <= 31) return "day";
|
|
47
|
+
if (days <= 120) return "week";
|
|
48
|
+
return "month";
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/* Tolerant readers: the reports function is the source of truth, but we accept
|
|
52
|
+
near-synonym keys so a small backend rename doesn't blank the page. */
|
|
53
|
+
const num = (...vals) => {
|
|
54
|
+
for (const v of vals) if (typeof v === "number" && !isNaN(v)) return v;
|
|
55
|
+
return 0;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
function StatCard({ label, value, loading }) {
|
|
59
|
+
return (
|
|
60
|
+
<Card>
|
|
61
|
+
<CardContent className="pt-5">
|
|
62
|
+
{loading ? (
|
|
63
|
+
<Skeleton className="h-7 w-24" />
|
|
64
|
+
) : (
|
|
65
|
+
<div className="text-xl font-semibold tracking-tight">{value}</div>
|
|
66
|
+
)}
|
|
67
|
+
<p className="mt-0.5 text-xs text-muted-foreground">{label}</p>
|
|
68
|
+
</CardContent>
|
|
69
|
+
</Card>
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/* ---------------------------------- Sales ---------------------------------- */
|
|
74
|
+
|
|
75
|
+
function SalesTab({ range, setRange }) {
|
|
76
|
+
const money = useMoney();
|
|
77
|
+
const interval = autoInterval(range);
|
|
78
|
+
|
|
79
|
+
const { data, loading, refetchQuiet } = useAsync(
|
|
80
|
+
() =>
|
|
81
|
+
call("admin-reports", "sales", {
|
|
82
|
+
date_min: range.from,
|
|
83
|
+
date_max: range.to,
|
|
84
|
+
interval,
|
|
85
|
+
}),
|
|
86
|
+
[range.from, range.to, interval]
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
// Sales figures are derived from orders and refunds — recompute when either
|
|
90
|
+
// moves. Report aggregation scans orders, hence the slow fallback poll.
|
|
91
|
+
useRealtime(["commerce.Order", "commerce.OrderRefund"], refetchQuiet, {
|
|
92
|
+
fallbackPollMs: 60000,
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
const totals = data?.totals || data || {};
|
|
96
|
+
const series = useMemo(
|
|
97
|
+
() =>
|
|
98
|
+
(data?.series || data?.rows || []).map((r) => ({
|
|
99
|
+
bucket: r.bucket || r.date || r.period || "",
|
|
100
|
+
net: num(r.net_sales, r.net),
|
|
101
|
+
gross: num(r.gross_sales, r.gross),
|
|
102
|
+
orders: num(r.orders, r.orders_count),
|
|
103
|
+
})),
|
|
104
|
+
[data]
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
const stats = [
|
|
108
|
+
{ label: "Gross sales", value: money.format(num(totals.gross_sales, totals.gross)) },
|
|
109
|
+
{ label: "Net sales", value: money.format(num(totals.net_sales, totals.net)) },
|
|
110
|
+
{ label: "Orders", value: num(totals.orders, totals.orders_count) },
|
|
111
|
+
{ label: "Items sold", value: num(totals.items, totals.items_sold) },
|
|
112
|
+
{ label: "Refunded", value: money.format(num(totals.refunds, totals.refunded)) },
|
|
113
|
+
{ label: "Discounted", value: money.format(num(totals.discount, totals.discounted)) },
|
|
114
|
+
{ label: "Shipping", value: money.format(num(totals.shipping, totals.shipping_total)) },
|
|
115
|
+
];
|
|
116
|
+
|
|
117
|
+
return (
|
|
118
|
+
<div className="space-y-4">
|
|
119
|
+
<DateRangePicker value={range} onChange={(r) => r && setRange(r)} />
|
|
120
|
+
|
|
121
|
+
<div className="grid grid-cols-2 gap-3 md:grid-cols-4 xl:grid-cols-7">
|
|
122
|
+
{stats.map((s) => (
|
|
123
|
+
<StatCard key={s.label} label={s.label} value={s.value} loading={loading} />
|
|
124
|
+
))}
|
|
125
|
+
</div>
|
|
126
|
+
|
|
127
|
+
<Card>
|
|
128
|
+
<CardHeader className="pb-2">
|
|
129
|
+
<CardTitle className="text-base">
|
|
130
|
+
Sales by {interval}
|
|
131
|
+
</CardTitle>
|
|
132
|
+
</CardHeader>
|
|
133
|
+
<CardContent>
|
|
134
|
+
{loading ? (
|
|
135
|
+
<Skeleton className="h-72 w-full" />
|
|
136
|
+
) : series.length === 0 ? (
|
|
137
|
+
<p className="py-16 text-center text-sm text-muted-foreground">
|
|
138
|
+
No sales in this period.
|
|
139
|
+
</p>
|
|
140
|
+
) : (
|
|
141
|
+
<ResponsiveContainer width="100%" height={300}>
|
|
142
|
+
<LineChart data={series} margin={{ top: 8, right: 8, left: 8, bottom: 0 }}>
|
|
143
|
+
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
|
|
144
|
+
<XAxis dataKey="bucket" tick={{ fontSize: 12 }} />
|
|
145
|
+
<YAxis
|
|
146
|
+
yAxisId="money"
|
|
147
|
+
tick={{ fontSize: 12 }}
|
|
148
|
+
tickFormatter={(v) => money.format(v)}
|
|
149
|
+
width={80}
|
|
150
|
+
/>
|
|
151
|
+
<YAxis yAxisId="count" orientation="right" tick={{ fontSize: 12 }} allowDecimals={false} />
|
|
152
|
+
<Tooltip
|
|
153
|
+
formatter={(value, name) =>
|
|
154
|
+
name === "Orders" ? [value, name] : [money.format(value), name]
|
|
155
|
+
}
|
|
156
|
+
/>
|
|
157
|
+
<Legend />
|
|
158
|
+
<Line
|
|
159
|
+
yAxisId="money"
|
|
160
|
+
type="monotone"
|
|
161
|
+
dataKey="net"
|
|
162
|
+
name="Net sales"
|
|
163
|
+
stroke="#6366f1"
|
|
164
|
+
strokeWidth={2}
|
|
165
|
+
dot={false}
|
|
166
|
+
/>
|
|
167
|
+
<Line
|
|
168
|
+
yAxisId="count"
|
|
169
|
+
type="monotone"
|
|
170
|
+
dataKey="orders"
|
|
171
|
+
name="Orders"
|
|
172
|
+
stroke="#22c55e"
|
|
173
|
+
strokeWidth={2}
|
|
174
|
+
dot={false}
|
|
175
|
+
/>
|
|
176
|
+
</LineChart>
|
|
177
|
+
</ResponsiveContainer>
|
|
178
|
+
)}
|
|
179
|
+
</CardContent>
|
|
180
|
+
</Card>
|
|
181
|
+
</div>
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/* ------------------------------- Top sellers ------------------------------- */
|
|
186
|
+
|
|
187
|
+
function TopSellersTab({ range, setRange }) {
|
|
188
|
+
const money = useMoney();
|
|
189
|
+
const href = useAdminHref();
|
|
190
|
+
|
|
191
|
+
const { data, loading, refetchQuiet } = useAsync(
|
|
192
|
+
() =>
|
|
193
|
+
call("admin-reports", "top-sellers", {
|
|
194
|
+
date_min: range.from,
|
|
195
|
+
date_max: range.to,
|
|
196
|
+
}),
|
|
197
|
+
[range.from, range.to]
|
|
198
|
+
);
|
|
199
|
+
|
|
200
|
+
useRealtime(["commerce.Order", "commerce.OrderRefund"], refetchQuiet, {
|
|
201
|
+
fallbackPollMs: 60000,
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
const rows = useMemo(
|
|
205
|
+
() =>
|
|
206
|
+
(data?.rows || data?.sellers || (Array.isArray(data) ? data : [])).map((r, i) => ({
|
|
207
|
+
id: r.product_id || i,
|
|
208
|
+
product_id: r.product_id,
|
|
209
|
+
name: r.name || r.product_name || r.product_id,
|
|
210
|
+
quantity: num(r.quantity, r.qty),
|
|
211
|
+
total: num(r.total, r.net_revenue, r.revenue),
|
|
212
|
+
})),
|
|
213
|
+
[data]
|
|
214
|
+
);
|
|
215
|
+
|
|
216
|
+
return (
|
|
217
|
+
<div className="space-y-4">
|
|
218
|
+
<DateRangePicker value={range} onChange={(r) => r && setRange(r)} />
|
|
219
|
+
|
|
220
|
+
<Card>
|
|
221
|
+
<CardHeader className="pb-2">
|
|
222
|
+
<CardTitle className="text-base">Top sellers by quantity</CardTitle>
|
|
223
|
+
</CardHeader>
|
|
224
|
+
<CardContent>
|
|
225
|
+
{loading ? (
|
|
226
|
+
<Skeleton className="h-64 w-full" />
|
|
227
|
+
) : rows.length === 0 ? (
|
|
228
|
+
<p className="py-16 text-center text-sm text-muted-foreground">
|
|
229
|
+
No sales in this period.
|
|
230
|
+
</p>
|
|
231
|
+
) : (
|
|
232
|
+
<ResponsiveContainer width="100%" height={Math.max(220, rows.length * 36)}>
|
|
233
|
+
<BarChart data={rows} layout="vertical" margin={{ left: 24, right: 24 }}>
|
|
234
|
+
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
|
|
235
|
+
<XAxis type="number" allowDecimals={false} tick={{ fontSize: 12 }} />
|
|
236
|
+
<YAxis
|
|
237
|
+
type="category"
|
|
238
|
+
dataKey="name"
|
|
239
|
+
width={180}
|
|
240
|
+
tick={{ fontSize: 12 }}
|
|
241
|
+
/>
|
|
242
|
+
<Tooltip formatter={(v, name) => (name === "Quantity" ? [v, name] : [money.format(v), name])} />
|
|
243
|
+
<Bar dataKey="quantity" name="Quantity" fill="#6366f1" radius={[0, 4, 4, 0]} />
|
|
244
|
+
</BarChart>
|
|
245
|
+
</ResponsiveContainer>
|
|
246
|
+
)}
|
|
247
|
+
</CardContent>
|
|
248
|
+
</Card>
|
|
249
|
+
|
|
250
|
+
<DataTable
|
|
251
|
+
columns={[
|
|
252
|
+
{
|
|
253
|
+
key: "name",
|
|
254
|
+
label: "Product",
|
|
255
|
+
render: (r) =>
|
|
256
|
+
r.product_id ? (
|
|
257
|
+
<Link to={href(`products/${r.product_id}`)} className="font-medium hover:underline">
|
|
258
|
+
{r.name}
|
|
259
|
+
</Link>
|
|
260
|
+
) : (
|
|
261
|
+
r.name
|
|
262
|
+
),
|
|
263
|
+
},
|
|
264
|
+
{ key: "quantity", label: "Quantity sold" },
|
|
265
|
+
{ key: "total", label: "Net revenue", render: (r) => money.format(r.total) },
|
|
266
|
+
]}
|
|
267
|
+
rows={rows}
|
|
268
|
+
loading={loading}
|
|
269
|
+
empty={{ icon: BarChart3, title: "No sales in this period" }}
|
|
270
|
+
/>
|
|
271
|
+
</div>
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/* ---------------------------------- Totals --------------------------------- */
|
|
276
|
+
|
|
277
|
+
/** Normalize a *-totals payload into [{label, count}] rows. */
|
|
278
|
+
function normalizeTotals(data, labelMap) {
|
|
279
|
+
if (!data) return [];
|
|
280
|
+
const rows = Array.isArray(data) ? data : Array.isArray(data.rows) ? data.rows : null;
|
|
281
|
+
if (rows) {
|
|
282
|
+
return rows.map((r) => ({
|
|
283
|
+
key: r.slug || r.value || r.name || r.label,
|
|
284
|
+
label: r.name || r.label || r.slug || r.value,
|
|
285
|
+
count: num(r.total, r.count),
|
|
286
|
+
}));
|
|
287
|
+
}
|
|
288
|
+
// Plain map: { pending: 3, completed: 8, ... } (possibly under .totals / .counts)
|
|
289
|
+
const map = data.totals || data.counts || data;
|
|
290
|
+
return Object.entries(map)
|
|
291
|
+
.filter(([, v]) => typeof v === "number")
|
|
292
|
+
.map(([k, v]) => ({
|
|
293
|
+
key: k,
|
|
294
|
+
label: labelMap?.find?.((l) => l.value === k)?.label || k,
|
|
295
|
+
count: v,
|
|
296
|
+
}));
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function TotalsCard({ title, rows, loading, renderLabel }) {
|
|
300
|
+
return (
|
|
301
|
+
<Card>
|
|
302
|
+
<CardHeader className="pb-2">
|
|
303
|
+
<CardTitle className="text-sm">{title}</CardTitle>
|
|
304
|
+
</CardHeader>
|
|
305
|
+
<CardContent className="space-y-1.5">
|
|
306
|
+
{loading ? (
|
|
307
|
+
<Skeleton className="h-20 w-full" />
|
|
308
|
+
) : rows.length === 0 ? (
|
|
309
|
+
<p className="text-sm text-muted-foreground">—</p>
|
|
310
|
+
) : (
|
|
311
|
+
rows.map((r) => (
|
|
312
|
+
<div key={r.key} className="flex items-center justify-between gap-2 text-sm">
|
|
313
|
+
{renderLabel ? renderLabel(r) : <span className="text-muted-foreground">{r.label}</span>}
|
|
314
|
+
<span className="font-medium tabular-nums">{r.count}</span>
|
|
315
|
+
</div>
|
|
316
|
+
))
|
|
317
|
+
)}
|
|
318
|
+
</CardContent>
|
|
319
|
+
</Card>
|
|
320
|
+
);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const TOTALS_ACTIONS = [
|
|
324
|
+
"orders-totals",
|
|
325
|
+
"products-totals",
|
|
326
|
+
"customers-totals",
|
|
327
|
+
"coupons-totals",
|
|
328
|
+
"reviews-totals",
|
|
329
|
+
"categories-totals",
|
|
330
|
+
"tags-totals",
|
|
331
|
+
"attributes-totals",
|
|
332
|
+
];
|
|
333
|
+
|
|
334
|
+
function TotalsTab() {
|
|
335
|
+
const { data, loading, refetchQuiet } = useAsync(async () => {
|
|
336
|
+
const results = await Promise.all(
|
|
337
|
+
TOTALS_ACTIONS.map((action) =>
|
|
338
|
+
call("admin-reports", action, {}, { silent: true }).catch(() => null)
|
|
339
|
+
)
|
|
340
|
+
);
|
|
341
|
+
return Object.fromEntries(TOTALS_ACTIONS.map((a, i) => [a, results[i]]));
|
|
342
|
+
}, []);
|
|
343
|
+
|
|
344
|
+
// These cards count across most of the store, so watch the entities they total.
|
|
345
|
+
useRealtime(
|
|
346
|
+
[
|
|
347
|
+
"commerce.Order",
|
|
348
|
+
"commerce.Product",
|
|
349
|
+
"commerce.Coupon",
|
|
350
|
+
"commerce.Customer",
|
|
351
|
+
"commerce.ProductReview",
|
|
352
|
+
],
|
|
353
|
+
refetchQuiet,
|
|
354
|
+
{ fallbackPollMs: 60000 },
|
|
355
|
+
);
|
|
356
|
+
|
|
357
|
+
const d = data || {};
|
|
358
|
+
|
|
359
|
+
return (
|
|
360
|
+
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
|
361
|
+
<TotalsCard
|
|
362
|
+
title="Orders by status"
|
|
363
|
+
loading={loading}
|
|
364
|
+
rows={normalizeTotals(d["orders-totals"])}
|
|
365
|
+
renderLabel={(r) => <StatusBadge status={r.key} className="text-[10px]" />}
|
|
366
|
+
/>
|
|
367
|
+
<TotalsCard
|
|
368
|
+
title="Products by type"
|
|
369
|
+
loading={loading}
|
|
370
|
+
rows={normalizeTotals(d["products-totals"], PRODUCT_TYPES)}
|
|
371
|
+
/>
|
|
372
|
+
<TotalsCard title="Customers" loading={loading} rows={normalizeTotals(d["customers-totals"])} />
|
|
373
|
+
<TotalsCard
|
|
374
|
+
title="Coupons by type"
|
|
375
|
+
loading={loading}
|
|
376
|
+
rows={normalizeTotals(d["coupons-totals"], COUPON_TYPES)}
|
|
377
|
+
/>
|
|
378
|
+
<TotalsCard
|
|
379
|
+
title="Reviews by status"
|
|
380
|
+
loading={loading}
|
|
381
|
+
rows={normalizeTotals(d["reviews-totals"], REVIEW_STATUSES)}
|
|
382
|
+
/>
|
|
383
|
+
<TotalsCard title="Categories" loading={loading} rows={normalizeTotals(d["categories-totals"])} />
|
|
384
|
+
<TotalsCard title="Tags" loading={loading} rows={normalizeTotals(d["tags-totals"])} />
|
|
385
|
+
<TotalsCard title="Attributes" loading={loading} rows={normalizeTotals(d["attributes-totals"])} />
|
|
386
|
+
</div>
|
|
387
|
+
);
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/* ----------------------------------- Page ---------------------------------- */
|
|
391
|
+
|
|
392
|
+
export default function Reports() {
|
|
393
|
+
const [range, setRange] = useState(thisMonth());
|
|
394
|
+
|
|
395
|
+
return (
|
|
396
|
+
<div>
|
|
397
|
+
<PageHeader title="Reports" description="Sales performance and store totals." />
|
|
398
|
+
<Tabs defaultValue="sales">
|
|
399
|
+
<TabsList className="mb-4">
|
|
400
|
+
<TabsTrigger value="sales">Sales</TabsTrigger>
|
|
401
|
+
<TabsTrigger value="top-sellers">Top sellers</TabsTrigger>
|
|
402
|
+
<TabsTrigger value="totals">Totals</TabsTrigger>
|
|
403
|
+
</TabsList>
|
|
404
|
+
<TabsContent value="sales">
|
|
405
|
+
<SalesTab range={range} setRange={setRange} />
|
|
406
|
+
</TabsContent>
|
|
407
|
+
<TabsContent value="top-sellers">
|
|
408
|
+
<TopSellersTab range={range} setRange={setRange} />
|
|
409
|
+
</TabsContent>
|
|
410
|
+
<TabsContent value="totals">
|
|
411
|
+
<TotalsTab />
|
|
412
|
+
</TabsContent>
|
|
413
|
+
</Tabs>
|
|
414
|
+
</div>
|
|
415
|
+
);
|
|
416
|
+
}
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
import React, { useEffect, useState } from "react";
|
|
2
|
+
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
|
3
|
+
import { Button } from "@/components/ui/button";
|
|
4
|
+
import { Input } from "@/components/ui/input";
|
|
5
|
+
import { Label } from "@/components/ui/label";
|
|
6
|
+
import { Textarea } from "@/components/ui/textarea";
|
|
7
|
+
import { Switch } from "@/components/ui/switch";
|
|
8
|
+
import { Badge } from "@/components/ui/badge";
|
|
9
|
+
import {
|
|
10
|
+
Table,
|
|
11
|
+
TableBody,
|
|
12
|
+
TableCell,
|
|
13
|
+
TableHead,
|
|
14
|
+
TableHeader,
|
|
15
|
+
TableRow,
|
|
16
|
+
} from "@/components/ui/table";
|
|
17
|
+
import {
|
|
18
|
+
Dialog,
|
|
19
|
+
DialogContent,
|
|
20
|
+
DialogFooter,
|
|
21
|
+
DialogHeader,
|
|
22
|
+
DialogTitle,
|
|
23
|
+
} from "@/components/ui/dialog";
|
|
24
|
+
import { Settings2 } from "lucide-react";
|
|
25
|
+
import { EMAIL_TYPES } from "../../lib/constants";
|
|
26
|
+
import { call } from "../../lib/api";
|
|
27
|
+
import { useSettings } from "../../context/SettingsContext";
|
|
28
|
+
import useGroupForm from "./useGroupForm";
|
|
29
|
+
|
|
30
|
+
const DEFAULTS = {
|
|
31
|
+
from_name: "", // blank: the backend inherits general.store_name
|
|
32
|
+
admin_recipients: [],
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export default function EmailsSettings() {
|
|
36
|
+
const { form, setField, SaveButton } = useGroupForm("emails", DEFAULTS);
|
|
37
|
+
const { get } = useSettings();
|
|
38
|
+
const storeName = get("general", "store_name", "");
|
|
39
|
+
const [activeId, setActiveId] = useState(null);
|
|
40
|
+
// Resolved by the backend — listing admin users needs service role.
|
|
41
|
+
const [effective, setEffective] = useState(null);
|
|
42
|
+
|
|
43
|
+
useEffect(() => {
|
|
44
|
+
call("admin-tools", "admin-email-recipients", {}, { silent: true })
|
|
45
|
+
.then(setEffective)
|
|
46
|
+
.catch(() => setEffective(null));
|
|
47
|
+
}, []);
|
|
48
|
+
|
|
49
|
+
// Same shape as the "From" name field: the fallback is the placeholder, and the
|
|
50
|
+
// hint bolds the value actually in use.
|
|
51
|
+
const adminUsers = (effective?.admin_users ?? []).join(", ");
|
|
52
|
+
|
|
53
|
+
// Per-type config lives at the top level of the `emails` group, keyed by type
|
|
54
|
+
// id (emails.new_order.enabled) — that is where shared/commerce/emails.ts reads
|
|
55
|
+
// it. Nesting it under a `types` object silently discarded every change.
|
|
56
|
+
const typeCfg = (id) => form[id] || {};
|
|
57
|
+
const isEnabled = (id) => typeCfg(id).enabled !== false;
|
|
58
|
+
|
|
59
|
+
const setTypeField = (id, key, value) =>
|
|
60
|
+
setField(id, { ...typeCfg(id), [key]: value });
|
|
61
|
+
|
|
62
|
+
const recipientsText = (form.admin_recipients || []).join(", ");
|
|
63
|
+
const setRecipients = (text) =>
|
|
64
|
+
setField(
|
|
65
|
+
"admin_recipients",
|
|
66
|
+
text.split(",").map((s) => s.trim()).filter(Boolean)
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
const active = activeId ? EMAIL_TYPES.find((e) => e.id === activeId) : null;
|
|
70
|
+
|
|
71
|
+
return (
|
|
72
|
+
<div className="max-w-4xl space-y-4">
|
|
73
|
+
<Card>
|
|
74
|
+
<CardHeader className="flex-row items-start justify-between gap-4 space-y-0">
|
|
75
|
+
<div className="space-y-1.5">
|
|
76
|
+
<CardTitle>Sender options</CardTitle>
|
|
77
|
+
<CardDescription>
|
|
78
|
+
The name transactional emails are sent from. The sending address is controlled by your Base44
|
|
79
|
+
email configuration, not here.
|
|
80
|
+
</CardDescription>
|
|
81
|
+
</div>
|
|
82
|
+
<SaveButton />
|
|
83
|
+
</CardHeader>
|
|
84
|
+
<CardContent className="grid gap-4 sm:grid-cols-2">
|
|
85
|
+
<div className="space-y-1.5 sm:col-span-2">
|
|
86
|
+
<Label>“From” name</Label>
|
|
87
|
+
<Input
|
|
88
|
+
value={form.from_name || ""}
|
|
89
|
+
placeholder={storeName || "Your store name"}
|
|
90
|
+
onChange={(e) => setField("from_name", e.target.value)}
|
|
91
|
+
/>
|
|
92
|
+
<p className="text-xs text-muted-foreground">
|
|
93
|
+
Leave blank to send as {storeName ? <strong>{storeName}</strong> : "your store name"} —
|
|
94
|
+
the store name from Settings → General.
|
|
95
|
+
</p>
|
|
96
|
+
</div>
|
|
97
|
+
<div className="space-y-1.5 sm:col-span-2">
|
|
98
|
+
<Label>Admin notification recipients</Label>
|
|
99
|
+
<Input
|
|
100
|
+
value={recipientsText}
|
|
101
|
+
placeholder={adminUsers || "admin@example.com, ops@example.com"}
|
|
102
|
+
onChange={(e) => setRecipients(e.target.value)}
|
|
103
|
+
/>
|
|
104
|
+
<p className="text-xs text-muted-foreground">
|
|
105
|
+
Comma-separated; receives new/cancelled/failed order emails. Leave blank to use{" "}
|
|
106
|
+
{adminUsers ? <strong>{adminUsers}</strong> : "your app's admin users"} — the app's
|
|
107
|
+
users with the admin role.
|
|
108
|
+
{effective && !adminUsers && (
|
|
109
|
+
<span className="block">
|
|
110
|
+
No app user has the admin role, so leaving this blank sends these emails nowhere.
|
|
111
|
+
</span>
|
|
112
|
+
)}
|
|
113
|
+
</p>
|
|
114
|
+
</div>
|
|
115
|
+
</CardContent>
|
|
116
|
+
</Card>
|
|
117
|
+
|
|
118
|
+
<Card>
|
|
119
|
+
<CardHeader className="flex-row items-start justify-between gap-4 space-y-0">
|
|
120
|
+
<div className="space-y-1.5">
|
|
121
|
+
<CardTitle>Email notifications</CardTitle>
|
|
122
|
+
<CardDescription>Enable, disable and customize each transactional email.</CardDescription>
|
|
123
|
+
</div>
|
|
124
|
+
<SaveButton />
|
|
125
|
+
</CardHeader>
|
|
126
|
+
<CardContent className="p-0">
|
|
127
|
+
<Table>
|
|
128
|
+
<TableHeader>
|
|
129
|
+
<TableRow>
|
|
130
|
+
<TableHead>Email</TableHead>
|
|
131
|
+
<TableHead>Recipient</TableHead>
|
|
132
|
+
<TableHead className="w-24">Enabled</TableHead>
|
|
133
|
+
<TableHead className="w-24" />
|
|
134
|
+
</TableRow>
|
|
135
|
+
</TableHeader>
|
|
136
|
+
<TableBody>
|
|
137
|
+
{EMAIL_TYPES.map((e) => {
|
|
138
|
+
const managed = !!e.managed_by_auth;
|
|
139
|
+
return (
|
|
140
|
+
<TableRow key={e.id}>
|
|
141
|
+
<TableCell>
|
|
142
|
+
<div className="flex items-center gap-2">
|
|
143
|
+
<span className="font-medium">{e.label}</span>
|
|
144
|
+
{e.manual && <Badge variant="outline" className="font-normal">Manual</Badge>}
|
|
145
|
+
{managed && (
|
|
146
|
+
<Badge variant="secondary" className="font-normal text-muted-foreground">
|
|
147
|
+
Handled by Base44 auth
|
|
148
|
+
</Badge>
|
|
149
|
+
)}
|
|
150
|
+
</div>
|
|
151
|
+
</TableCell>
|
|
152
|
+
<TableCell className="text-sm text-muted-foreground">
|
|
153
|
+
{managed
|
|
154
|
+
? "—"
|
|
155
|
+
: e.recipient === "admin"
|
|
156
|
+
? typeCfg(e.id).recipient || recipientsText ||
|
|
157
|
+
effective?.recipients.join(", ") || "Admin"
|
|
158
|
+
: "Customer"}
|
|
159
|
+
</TableCell>
|
|
160
|
+
<TableCell>
|
|
161
|
+
{managed ? (
|
|
162
|
+
<span className="text-xs text-muted-foreground">—</span>
|
|
163
|
+
) : (
|
|
164
|
+
<Switch checked={isEnabled(e.id)} onCheckedChange={(v) => setTypeField(e.id, "enabled", !!v)} />
|
|
165
|
+
)}
|
|
166
|
+
</TableCell>
|
|
167
|
+
<TableCell>
|
|
168
|
+
{!managed && (
|
|
169
|
+
<Button variant="ghost" size="sm" onClick={() => setActiveId(e.id)}>
|
|
170
|
+
<Settings2 className="mr-2 h-4 w-4" />
|
|
171
|
+
Manage
|
|
172
|
+
</Button>
|
|
173
|
+
)}
|
|
174
|
+
</TableCell>
|
|
175
|
+
</TableRow>
|
|
176
|
+
);
|
|
177
|
+
})}
|
|
178
|
+
</TableBody>
|
|
179
|
+
</Table>
|
|
180
|
+
</CardContent>
|
|
181
|
+
</Card>
|
|
182
|
+
|
|
183
|
+
{active && (
|
|
184
|
+
<Dialog open onOpenChange={(o) => !o && setActiveId(null)}>
|
|
185
|
+
<DialogContent>
|
|
186
|
+
<DialogHeader>
|
|
187
|
+
<DialogTitle>{active.label} email</DialogTitle>
|
|
188
|
+
</DialogHeader>
|
|
189
|
+
<div className="space-y-4">
|
|
190
|
+
{active.recipient === "admin" && (
|
|
191
|
+
<div className="space-y-1.5">
|
|
192
|
+
<Label>Recipient(s)</Label>
|
|
193
|
+
<Input
|
|
194
|
+
placeholder="Leave blank to use the default admin recipients"
|
|
195
|
+
value={typeCfg(active.id).recipient || ""}
|
|
196
|
+
onChange={(e) => setTypeField(active.id, "recipient", e.target.value)}
|
|
197
|
+
/>
|
|
198
|
+
<p className="text-xs text-muted-foreground">
|
|
199
|
+
Comma-separated. Overrides the admin recipients above for this email only.
|
|
200
|
+
</p>
|
|
201
|
+
</div>
|
|
202
|
+
)}
|
|
203
|
+
<div className="space-y-1.5">
|
|
204
|
+
<Label>Subject</Label>
|
|
205
|
+
<Input
|
|
206
|
+
placeholder="Leave blank for the default"
|
|
207
|
+
value={typeCfg(active.id).subject || ""}
|
|
208
|
+
onChange={(e) => setTypeField(active.id, "subject", e.target.value)}
|
|
209
|
+
/>
|
|
210
|
+
</div>
|
|
211
|
+
<div className="space-y-1.5">
|
|
212
|
+
<Label>Email heading</Label>
|
|
213
|
+
<Input
|
|
214
|
+
placeholder="Leave blank for the default"
|
|
215
|
+
value={typeCfg(active.id).heading || ""}
|
|
216
|
+
onChange={(e) => setTypeField(active.id, "heading", e.target.value)}
|
|
217
|
+
/>
|
|
218
|
+
</div>
|
|
219
|
+
<div className="space-y-1.5">
|
|
220
|
+
<Label>Additional content</Label>
|
|
221
|
+
<Textarea
|
|
222
|
+
rows={3}
|
|
223
|
+
placeholder="Text appended below the email body."
|
|
224
|
+
value={typeCfg(active.id).additional_content || ""}
|
|
225
|
+
onChange={(e) => setTypeField(active.id, "additional_content", e.target.value)}
|
|
226
|
+
/>
|
|
227
|
+
</div>
|
|
228
|
+
<p className="text-xs text-muted-foreground">
|
|
229
|
+
Placeholders: {"{order_number}"}, {"{customer_name}"}, {"{store_name}"}, {"{order_total}"}.
|
|
230
|
+
</p>
|
|
231
|
+
</div>
|
|
232
|
+
<DialogFooter>
|
|
233
|
+
<Button onClick={() => setActiveId(null)}>Done</Button>
|
|
234
|
+
</DialogFooter>
|
|
235
|
+
</DialogContent>
|
|
236
|
+
</Dialog>
|
|
237
|
+
)}
|
|
238
|
+
</div>
|
|
239
|
+
);
|
|
240
|
+
}
|