@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,239 @@
|
|
|
1
|
+
import React, { useMemo, useState } from "react";
|
|
2
|
+
import { Button } from "@/components/ui/button";
|
|
3
|
+
import { Input } from "@/components/ui/input";
|
|
4
|
+
import { Label } from "@/components/ui/label";
|
|
5
|
+
import { Checkbox } from "@/components/ui/checkbox";
|
|
6
|
+
import {
|
|
7
|
+
Table,
|
|
8
|
+
TableBody,
|
|
9
|
+
TableCell,
|
|
10
|
+
TableHead,
|
|
11
|
+
TableHeader,
|
|
12
|
+
TableRow,
|
|
13
|
+
} from "@/components/ui/table";
|
|
14
|
+
import { Loader2 } from "lucide-react";
|
|
15
|
+
import { toast } from "sonner";
|
|
16
|
+
|
|
17
|
+
import { call } from "../../../lib/api";
|
|
18
|
+
import useMoney from "../../../hooks/useMoney";
|
|
19
|
+
import usePaymentProvider from "../../../hooks/usePaymentProvider";
|
|
20
|
+
import { formatDate } from "../../../lib/format";
|
|
21
|
+
import { refundableQty, refundableAmount, round2 } from "../../../lib/order-utils";
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Refund flow: per-line quantities/amounts, shipping amount, restock flag.
|
|
25
|
+
*
|
|
26
|
+
* Props: { order, refunds, onDone() } — onDone refetches order + refunds.
|
|
27
|
+
*/
|
|
28
|
+
export default function RefundPanel({ order, refunds, onDone }) {
|
|
29
|
+
const { format } = useMoney();
|
|
30
|
+
const [rows, setRows] = useState({}); // line_id → {quantity, amount}
|
|
31
|
+
const [shippingAmount, setShippingAmount] = useState(0);
|
|
32
|
+
const [reason, setReason] = useState("");
|
|
33
|
+
const [restock, setRestock] = useState(true);
|
|
34
|
+
const [viaGateway, setViaGateway] = useState(false);
|
|
35
|
+
const [busy, setBusy] = useState(false);
|
|
36
|
+
const provider = usePaymentProvider();
|
|
37
|
+
|
|
38
|
+
const maxRefund = refundableAmount(order);
|
|
39
|
+
// Refunding through the provider is only possible when the order was actually
|
|
40
|
+
// paid online and a provider is connected — otherwise the money was taken
|
|
41
|
+
// outside the store and has to go back the same way.
|
|
42
|
+
const canRefundOnline = Boolean(
|
|
43
|
+
provider.connected && order.payment_method === provider.gatewaySlug && order.transaction_id,
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
const unitNet = (line) => (line.quantity ? (line.total || 0) / line.quantity : 0);
|
|
47
|
+
|
|
48
|
+
const setQty = (line, qty) => {
|
|
49
|
+
const capped = Math.max(0, Math.min(qty, refundableQty(order, refunds, line.line_id)));
|
|
50
|
+
setRows((r) => ({
|
|
51
|
+
...r,
|
|
52
|
+
[line.line_id]: { quantity: capped, amount: round2(capped * unitNet(line)) },
|
|
53
|
+
}));
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const setAmount = (line, amount) => {
|
|
57
|
+
setRows((r) => ({
|
|
58
|
+
...r,
|
|
59
|
+
[line.line_id]: { quantity: r[line.line_id]?.quantity || 0, amount: Math.max(0, amount) },
|
|
60
|
+
}));
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const lineItems = useMemo(
|
|
64
|
+
() =>
|
|
65
|
+
Object.entries(rows)
|
|
66
|
+
.filter(([, v]) => (v.quantity || 0) > 0 || (v.amount || 0) > 0)
|
|
67
|
+
.map(([line_id, v]) => {
|
|
68
|
+
const line = (order.line_items || []).find((l) => l.line_id === line_id);
|
|
69
|
+
return {
|
|
70
|
+
line_id,
|
|
71
|
+
product_id: line?.product_id,
|
|
72
|
+
variation_id: line?.variation_id,
|
|
73
|
+
quantity: v.quantity || 0,
|
|
74
|
+
refund_total: round2(v.amount || 0),
|
|
75
|
+
};
|
|
76
|
+
}),
|
|
77
|
+
[rows, order.line_items]
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
const total = round2(lineItems.reduce((s, l) => s + l.refund_total, 0) + (shippingAmount || 0));
|
|
81
|
+
const overMax = total > maxRefund;
|
|
82
|
+
|
|
83
|
+
const submit = async () => {
|
|
84
|
+
setBusy(true);
|
|
85
|
+
try {
|
|
86
|
+
const data = await call("admin-refunds", "create", {
|
|
87
|
+
order_id: order.id,
|
|
88
|
+
amount: total,
|
|
89
|
+
reason,
|
|
90
|
+
line_items: lineItems,
|
|
91
|
+
shipping_amount: shippingAmount || 0,
|
|
92
|
+
restock_items: restock,
|
|
93
|
+
refund_payment: canRefundOnline && viaGateway,
|
|
94
|
+
});
|
|
95
|
+
toast.success(
|
|
96
|
+
data?.gateway_refund
|
|
97
|
+
? `Refunded ${format(total)} through the payment provider`
|
|
98
|
+
: `Refunded ${format(total)}`,
|
|
99
|
+
);
|
|
100
|
+
setRows({});
|
|
101
|
+
setShippingAmount(0);
|
|
102
|
+
setReason("");
|
|
103
|
+
onDone();
|
|
104
|
+
} catch {
|
|
105
|
+
/* toast handled by call() */
|
|
106
|
+
} finally {
|
|
107
|
+
setBusy(false);
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
return (
|
|
112
|
+
<div className="space-y-4 rounded-lg border bg-muted/20 p-4">
|
|
113
|
+
<h3 className="text-sm font-semibold">Refund</h3>
|
|
114
|
+
|
|
115
|
+
<Table>
|
|
116
|
+
<TableHeader>
|
|
117
|
+
<TableRow>
|
|
118
|
+
<TableHead>Item</TableHead>
|
|
119
|
+
<TableHead className="w-24 text-right">Refunded</TableHead>
|
|
120
|
+
<TableHead className="w-24 text-right">Qty</TableHead>
|
|
121
|
+
<TableHead className="w-32 text-right">Amount</TableHead>
|
|
122
|
+
</TableRow>
|
|
123
|
+
</TableHeader>
|
|
124
|
+
<TableBody>
|
|
125
|
+
{(order.line_items || []).map((line) => {
|
|
126
|
+
const remaining = refundableQty(order, refunds, line.line_id);
|
|
127
|
+
const row = rows[line.line_id] || { quantity: 0, amount: 0 };
|
|
128
|
+
return (
|
|
129
|
+
<TableRow key={line.line_id}>
|
|
130
|
+
<TableCell className="text-sm">
|
|
131
|
+
{line.name} <span className="text-muted-foreground">× {line.quantity}</span>
|
|
132
|
+
</TableCell>
|
|
133
|
+
<TableCell className="text-right text-xs text-muted-foreground">
|
|
134
|
+
{line.quantity - remaining} / {line.quantity}
|
|
135
|
+
</TableCell>
|
|
136
|
+
<TableCell className="text-right">
|
|
137
|
+
<Input
|
|
138
|
+
type="number"
|
|
139
|
+
min="0"
|
|
140
|
+
max={remaining}
|
|
141
|
+
disabled={remaining === 0}
|
|
142
|
+
className="ml-auto h-8 w-16 text-right"
|
|
143
|
+
value={row.quantity}
|
|
144
|
+
onChange={(e) => setQty(line, parseInt(e.target.value, 10) || 0)}
|
|
145
|
+
/>
|
|
146
|
+
</TableCell>
|
|
147
|
+
<TableCell className="text-right">
|
|
148
|
+
<Input
|
|
149
|
+
type="number"
|
|
150
|
+
step="any"
|
|
151
|
+
min="0"
|
|
152
|
+
className="ml-auto h-8 w-24 text-right"
|
|
153
|
+
value={row.amount}
|
|
154
|
+
onChange={(e) => setAmount(line, Number(e.target.value) || 0)}
|
|
155
|
+
/>
|
|
156
|
+
</TableCell>
|
|
157
|
+
</TableRow>
|
|
158
|
+
);
|
|
159
|
+
})}
|
|
160
|
+
</TableBody>
|
|
161
|
+
</Table>
|
|
162
|
+
|
|
163
|
+
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
|
|
164
|
+
<div className="grid gap-1.5">
|
|
165
|
+
<Label className="text-xs">Refund shipping</Label>
|
|
166
|
+
<Input
|
|
167
|
+
type="number"
|
|
168
|
+
step="any"
|
|
169
|
+
min="0"
|
|
170
|
+
value={shippingAmount}
|
|
171
|
+
onChange={(e) => setShippingAmount(Number(e.target.value) || 0)}
|
|
172
|
+
/>
|
|
173
|
+
</div>
|
|
174
|
+
<div className="grid gap-1.5 sm:col-span-2">
|
|
175
|
+
<Label className="text-xs">Reason for refund (optional)</Label>
|
|
176
|
+
<Input value={reason} onChange={(e) => setReason(e.target.value)} />
|
|
177
|
+
</div>
|
|
178
|
+
</div>
|
|
179
|
+
|
|
180
|
+
<div className="flex flex-wrap items-center gap-4">
|
|
181
|
+
<label className="flex items-center gap-2 text-sm">
|
|
182
|
+
<Checkbox checked={restock} onCheckedChange={(v) => setRestock(Boolean(v))} />
|
|
183
|
+
Restock refunded items
|
|
184
|
+
</label>
|
|
185
|
+
{order.payment_method === provider.gatewaySlug && (
|
|
186
|
+
<label
|
|
187
|
+
className={`flex items-center gap-2 text-sm ${canRefundOnline ? "" : "text-muted-foreground"}`}
|
|
188
|
+
title={
|
|
189
|
+
canRefundOnline
|
|
190
|
+
? undefined
|
|
191
|
+
: provider.connected
|
|
192
|
+
? "This order has no online payment on record to refund"
|
|
193
|
+
: "No payment provider connected"
|
|
194
|
+
}
|
|
195
|
+
>
|
|
196
|
+
<Checkbox
|
|
197
|
+
checked={canRefundOnline && viaGateway}
|
|
198
|
+
disabled={!canRefundOnline}
|
|
199
|
+
onCheckedChange={(v) => setViaGateway(Boolean(v))}
|
|
200
|
+
/>
|
|
201
|
+
Send the money back through the payment provider
|
|
202
|
+
</label>
|
|
203
|
+
)}
|
|
204
|
+
</div>
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
<div className="flex items-center justify-between">
|
|
208
|
+
<p className="text-sm text-muted-foreground">
|
|
209
|
+
Total available to refund: <span className="font-medium text-foreground">{format(maxRefund)}</span>
|
|
210
|
+
</p>
|
|
211
|
+
<Button onClick={submit} disabled={busy || total <= 0 || overMax} variant="destructive">
|
|
212
|
+
{busy && <Loader2 className="mr-1.5 h-4 w-4 animate-spin" />}
|
|
213
|
+
Refund {format(total)}
|
|
214
|
+
</Button>
|
|
215
|
+
</div>
|
|
216
|
+
{overMax && (
|
|
217
|
+
<p className="text-right text-xs text-destructive">Amount exceeds the refundable total.</p>
|
|
218
|
+
)}
|
|
219
|
+
|
|
220
|
+
{(refunds || []).length > 0 && (
|
|
221
|
+
<div className="border-t pt-3">
|
|
222
|
+
<h4 className="mb-2 text-xs font-medium text-muted-foreground">Previous refunds</h4>
|
|
223
|
+
<ul className="space-y-1 text-sm">
|
|
224
|
+
{refunds.map((r) => (
|
|
225
|
+
<li key={r.id} className="flex justify-between">
|
|
226
|
+
<span>
|
|
227
|
+
{formatDate(r.created_date)}
|
|
228
|
+
{r.reason ? ` — ${r.reason}` : ""}
|
|
229
|
+
{r.refunded_by ? <span className="text-muted-foreground"> by {r.refunded_by}</span> : null}
|
|
230
|
+
</span>
|
|
231
|
+
<span className="text-destructive">− {format(r.amount || 0)}</span>
|
|
232
|
+
</li>
|
|
233
|
+
))}
|
|
234
|
+
</ul>
|
|
235
|
+
</div>
|
|
236
|
+
)}
|
|
237
|
+
</div>
|
|
238
|
+
);
|
|
239
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { Separator } from "@/components/ui/separator";
|
|
3
|
+
import useMoney from "../../../hooks/useMoney";
|
|
4
|
+
import { orderTotals } from "../../../lib/order-utils";
|
|
5
|
+
|
|
6
|
+
/** Order totals summary. Props: { order } */
|
|
7
|
+
export default function TotalsBox({ order }) {
|
|
8
|
+
const { format } = useMoney();
|
|
9
|
+
const t = orderTotals(order);
|
|
10
|
+
if (!t) return null;
|
|
11
|
+
|
|
12
|
+
const row = (label, value, className = "") => (
|
|
13
|
+
<div className={`flex items-center justify-between py-1 text-sm ${className}`}>
|
|
14
|
+
<span className="text-muted-foreground">{label}</span>
|
|
15
|
+
<span>{value}</span>
|
|
16
|
+
</div>
|
|
17
|
+
);
|
|
18
|
+
|
|
19
|
+
const couponCodes = (order.coupon_lines || []).map((c) => c.code).join(", ");
|
|
20
|
+
|
|
21
|
+
return (
|
|
22
|
+
<div className="ml-auto w-full max-w-sm">
|
|
23
|
+
{row("Items subtotal", format(t.itemsSubtotal))}
|
|
24
|
+
{t.discount > 0 &&
|
|
25
|
+
row(couponCodes ? `Coupon(s): ${couponCodes}` : "Discount", `− ${format(t.discount)}`)}
|
|
26
|
+
{t.fees !== 0 && row("Fees", format(t.fees))}
|
|
27
|
+
{row("Shipping", format(t.shipping))}
|
|
28
|
+
{(t.taxLines || []).length > 0
|
|
29
|
+
? t.taxLines.map((tl, i) => (
|
|
30
|
+
<div key={tl.rate_id || i} className="flex items-center justify-between py-1 text-sm">
|
|
31
|
+
<span className="text-muted-foreground">{tl.label || "Tax"}</span>
|
|
32
|
+
<span>{format((tl.tax_total || 0) + (tl.shipping_tax_total || 0))}</span>
|
|
33
|
+
</div>
|
|
34
|
+
))
|
|
35
|
+
: row("Tax", format(t.taxTotal))}
|
|
36
|
+
<Separator className="my-1.5" />
|
|
37
|
+
<div className="flex items-center justify-between py-1 font-semibold">
|
|
38
|
+
<span>Order total</span>
|
|
39
|
+
<span>{format(t.total)}</span>
|
|
40
|
+
</div>
|
|
41
|
+
{t.refunded > 0 && (
|
|
42
|
+
<>
|
|
43
|
+
{row("Refunded", `− ${format(t.refunded)}`, "text-destructive")}
|
|
44
|
+
<div className="flex items-center justify-between py-1 text-sm font-medium">
|
|
45
|
+
<span>Net payment</span>
|
|
46
|
+
<span>{format(t.net)}</span>
|
|
47
|
+
</div>
|
|
48
|
+
</>
|
|
49
|
+
)}
|
|
50
|
+
</div>
|
|
51
|
+
);
|
|
52
|
+
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import React, { useEffect, useState } from "react";
|
|
2
|
+
import { useParams } from "react-router-dom";
|
|
3
|
+
import { Button } from "@/components/ui/button";
|
|
4
|
+
import { Input } from "@/components/ui/input";
|
|
5
|
+
import { Label } from "@/components/ui/label";
|
|
6
|
+
import { Textarea } from "@/components/ui/textarea";
|
|
7
|
+
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
8
|
+
import { List, Loader2, Pencil, Trash2 } from "lucide-react";
|
|
9
|
+
import { toast } from "sonner";
|
|
10
|
+
|
|
11
|
+
import { base44 } from "../../lib/api";
|
|
12
|
+
import useAsync from "../../hooks/useAsync";
|
|
13
|
+
import { useAdminHref } from "../../context/BasePathContext";
|
|
14
|
+
import PageHeader from "../../components/PageHeader";
|
|
15
|
+
import DataTable from "../../components/DataTable";
|
|
16
|
+
import ConfirmDialog from "../../components/ConfirmDialog";
|
|
17
|
+
import { slugify } from "../../lib/product-utils";
|
|
18
|
+
|
|
19
|
+
const blank = () => ({ id: null, name: "", slug: "", description: "", menu_order: 0 });
|
|
20
|
+
|
|
21
|
+
export default function AttributeTerms() {
|
|
22
|
+
const { id } = useParams();
|
|
23
|
+
const href = useAdminHref();
|
|
24
|
+
|
|
25
|
+
const { data: attribute } = useAsync(() => base44.entities["commerce.ProductAttribute"].get(id), [id]);
|
|
26
|
+
const { data: terms, loading, refetch } = useAsync(
|
|
27
|
+
() => base44.entities["commerce.ProductAttributeTerm"].filter({ attribute_id: id }, "menu_order", 1000),
|
|
28
|
+
[id]
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
const [form, setForm] = useState(blank());
|
|
32
|
+
const [slugTouched, setSlugTouched] = useState(false);
|
|
33
|
+
const [saving, setSaving] = useState(false);
|
|
34
|
+
const [confirmDelete, setConfirmDelete] = useState(null);
|
|
35
|
+
const [deleting, setDeleting] = useState(false);
|
|
36
|
+
|
|
37
|
+
useEffect(() => {
|
|
38
|
+
setForm(blank());
|
|
39
|
+
setSlugTouched(false);
|
|
40
|
+
}, [id]);
|
|
41
|
+
|
|
42
|
+
const setName = (name) => setForm((f) => ({ ...f, name, slug: slugTouched ? f.slug : slugify(name) }));
|
|
43
|
+
const reset = () => {
|
|
44
|
+
setForm(blank());
|
|
45
|
+
setSlugTouched(false);
|
|
46
|
+
};
|
|
47
|
+
const edit = (t) => {
|
|
48
|
+
setForm({
|
|
49
|
+
id: t.id,
|
|
50
|
+
name: t.name || "",
|
|
51
|
+
slug: t.slug || "",
|
|
52
|
+
description: t.description || "",
|
|
53
|
+
menu_order: t.menu_order || 0,
|
|
54
|
+
});
|
|
55
|
+
setSlugTouched(true);
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const save = async () => {
|
|
59
|
+
if (!form.name.trim()) {
|
|
60
|
+
toast.error("Name is required");
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
setSaving(true);
|
|
64
|
+
try {
|
|
65
|
+
const payload = {
|
|
66
|
+
attribute_id: id,
|
|
67
|
+
name: form.name.trim(),
|
|
68
|
+
slug: form.slug || slugify(form.name),
|
|
69
|
+
description: form.description,
|
|
70
|
+
menu_order: Number(form.menu_order) || 0,
|
|
71
|
+
};
|
|
72
|
+
if (form.id) await base44.entities["commerce.ProductAttributeTerm"].update(form.id, payload);
|
|
73
|
+
else await base44.entities["commerce.ProductAttributeTerm"].create(payload);
|
|
74
|
+
toast.success(form.id ? "Term updated" : "Term created");
|
|
75
|
+
reset();
|
|
76
|
+
refetch();
|
|
77
|
+
} catch (e) {
|
|
78
|
+
toast.error(e.message || "Failed to save term");
|
|
79
|
+
} finally {
|
|
80
|
+
setSaving(false);
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const doDelete = async () => {
|
|
85
|
+
setDeleting(true);
|
|
86
|
+
try {
|
|
87
|
+
await base44.entities["commerce.ProductAttributeTerm"].delete(confirmDelete.id);
|
|
88
|
+
toast.success("Term deleted");
|
|
89
|
+
if (form.id === confirmDelete.id) reset();
|
|
90
|
+
setConfirmDelete(null);
|
|
91
|
+
refetch();
|
|
92
|
+
} catch (e) {
|
|
93
|
+
toast.error(e.message || "Failed to delete term");
|
|
94
|
+
} finally {
|
|
95
|
+
setDeleting(false);
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
const columns = [
|
|
100
|
+
{ key: "name", label: "Name", render: (row) => <span className="font-medium">{row.name}</span> },
|
|
101
|
+
{ key: "slug", label: "Slug", className: "hidden md:table-cell text-muted-foreground" },
|
|
102
|
+
{ key: "menu_order", label: "Order", className: "hidden md:table-cell w-20" },
|
|
103
|
+
];
|
|
104
|
+
|
|
105
|
+
return (
|
|
106
|
+
<div className="space-y-4">
|
|
107
|
+
<PageHeader
|
|
108
|
+
title={attribute ? `${attribute.name} — terms` : "Terms"}
|
|
109
|
+
description="Values for this attribute (e.g. Red, Blue, Green)"
|
|
110
|
+
backHref={href("products/attributes")}
|
|
111
|
+
/>
|
|
112
|
+
<div className="grid gap-4 lg:grid-cols-[340px_1fr]">
|
|
113
|
+
<Card className="h-fit">
|
|
114
|
+
<CardHeader className="pb-2">
|
|
115
|
+
<CardTitle className="text-base">{form.id ? "Edit term" : "Add new term"}</CardTitle>
|
|
116
|
+
</CardHeader>
|
|
117
|
+
<CardContent className="space-y-3">
|
|
118
|
+
<div className="space-y-1.5">
|
|
119
|
+
<Label>Name</Label>
|
|
120
|
+
<Input value={form.name} onChange={(e) => setName(e.target.value)} />
|
|
121
|
+
</div>
|
|
122
|
+
<div className="space-y-1.5">
|
|
123
|
+
<Label>Slug</Label>
|
|
124
|
+
<Input
|
|
125
|
+
value={form.slug}
|
|
126
|
+
onChange={(e) => {
|
|
127
|
+
setSlugTouched(true);
|
|
128
|
+
setForm((f) => ({ ...f, slug: e.target.value }));
|
|
129
|
+
}}
|
|
130
|
+
/>
|
|
131
|
+
</div>
|
|
132
|
+
<div className="space-y-1.5">
|
|
133
|
+
<Label>Description</Label>
|
|
134
|
+
<Textarea
|
|
135
|
+
rows={2}
|
|
136
|
+
value={form.description}
|
|
137
|
+
onChange={(e) => setForm((f) => ({ ...f, description: e.target.value }))}
|
|
138
|
+
/>
|
|
139
|
+
</div>
|
|
140
|
+
<div className="space-y-1.5">
|
|
141
|
+
<Label>Menu order</Label>
|
|
142
|
+
<Input
|
|
143
|
+
type="number"
|
|
144
|
+
value={form.menu_order}
|
|
145
|
+
onChange={(e) => setForm((f) => ({ ...f, menu_order: e.target.value }))}
|
|
146
|
+
/>
|
|
147
|
+
</div>
|
|
148
|
+
<div className="flex gap-2">
|
|
149
|
+
<Button onClick={save} disabled={saving}>
|
|
150
|
+
{saving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
|
151
|
+
{form.id ? "Update" : "Add term"}
|
|
152
|
+
</Button>
|
|
153
|
+
{form.id && <Button variant="ghost" onClick={reset} disabled={saving}>Cancel</Button>}
|
|
154
|
+
</div>
|
|
155
|
+
</CardContent>
|
|
156
|
+
</Card>
|
|
157
|
+
|
|
158
|
+
<DataTable
|
|
159
|
+
columns={columns}
|
|
160
|
+
rows={terms || []}
|
|
161
|
+
loading={loading}
|
|
162
|
+
rowActions={(row) => [
|
|
163
|
+
{ label: "Edit", icon: Pencil, onClick: () => edit(row) },
|
|
164
|
+
{ label: "Delete", icon: Trash2, destructive: true, onClick: () => setConfirmDelete(row) },
|
|
165
|
+
]}
|
|
166
|
+
empty={{ icon: List, title: "No terms yet", description: "Add the first value for this attribute." }}
|
|
167
|
+
/>
|
|
168
|
+
</div>
|
|
169
|
+
|
|
170
|
+
<ConfirmDialog
|
|
171
|
+
open={Boolean(confirmDelete)}
|
|
172
|
+
onOpenChange={(o) => !o && setConfirmDelete(null)}
|
|
173
|
+
title={`Delete "${confirmDelete?.name}"?`}
|
|
174
|
+
confirmLabel="Delete"
|
|
175
|
+
onConfirm={doDelete}
|
|
176
|
+
loading={deleting}
|
|
177
|
+
/>
|
|
178
|
+
</div>
|
|
179
|
+
);
|
|
180
|
+
}
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import React, { useState } from "react";
|
|
2
|
+
import { useNavigate } from "react-router-dom";
|
|
3
|
+
import { Button } from "@/components/ui/button";
|
|
4
|
+
import { Input } from "@/components/ui/input";
|
|
5
|
+
import { Label } from "@/components/ui/label";
|
|
6
|
+
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
7
|
+
import {
|
|
8
|
+
Select,
|
|
9
|
+
SelectContent,
|
|
10
|
+
SelectItem,
|
|
11
|
+
SelectTrigger,
|
|
12
|
+
SelectValue,
|
|
13
|
+
} from "@/components/ui/select";
|
|
14
|
+
import { Layers, Loader2, Pencil, Settings2, Trash2 } from "lucide-react";
|
|
15
|
+
import { toast } from "sonner";
|
|
16
|
+
|
|
17
|
+
import { base44 } from "../../lib/api";
|
|
18
|
+
import useAsync from "../../hooks/useAsync";
|
|
19
|
+
import { useAdminHref } from "../../context/BasePathContext";
|
|
20
|
+
import PageHeader from "../../components/PageHeader";
|
|
21
|
+
import DataTable from "../../components/DataTable";
|
|
22
|
+
import ConfirmDialog from "../../components/ConfirmDialog";
|
|
23
|
+
import { slugify } from "../../lib/product-utils";
|
|
24
|
+
|
|
25
|
+
const ORDER_BY = [
|
|
26
|
+
{ value: "menu_order", label: "Custom ordering" },
|
|
27
|
+
{ value: "name", label: "Name" },
|
|
28
|
+
{ value: "name_num", label: "Name (numeric)" },
|
|
29
|
+
{ value: "id", label: "Term ID" },
|
|
30
|
+
];
|
|
31
|
+
const BLANK = { id: null, name: "", slug: "", order_by: "menu_order" };
|
|
32
|
+
|
|
33
|
+
export default function Attributes() {
|
|
34
|
+
const navigate = useNavigate();
|
|
35
|
+
const href = useAdminHref();
|
|
36
|
+
const { data: attributes, loading, refetch } = useAsync(
|
|
37
|
+
() => base44.entities["commerce.ProductAttribute"].list("name", 500),
|
|
38
|
+
[]
|
|
39
|
+
);
|
|
40
|
+
const [form, setForm] = useState({ ...BLANK });
|
|
41
|
+
const [slugTouched, setSlugTouched] = useState(false);
|
|
42
|
+
const [saving, setSaving] = useState(false);
|
|
43
|
+
const [confirmDelete, setConfirmDelete] = useState(null);
|
|
44
|
+
const [deleting, setDeleting] = useState(false);
|
|
45
|
+
|
|
46
|
+
const setName = (name) => setForm((f) => ({ ...f, name, slug: slugTouched ? f.slug : slugify(name) }));
|
|
47
|
+
const reset = () => {
|
|
48
|
+
setForm({ ...BLANK });
|
|
49
|
+
setSlugTouched(false);
|
|
50
|
+
};
|
|
51
|
+
const edit = (a) => {
|
|
52
|
+
setForm({ id: a.id, name: a.name || "", slug: a.slug || "", order_by: a.order_by || "menu_order" });
|
|
53
|
+
setSlugTouched(true);
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const save = async () => {
|
|
57
|
+
if (!form.name.trim()) {
|
|
58
|
+
toast.error("Name is required");
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
setSaving(true);
|
|
62
|
+
try {
|
|
63
|
+
const payload = {
|
|
64
|
+
name: form.name.trim(),
|
|
65
|
+
slug: form.slug || slugify(form.name),
|
|
66
|
+
order_by: form.order_by,
|
|
67
|
+
type: "select",
|
|
68
|
+
};
|
|
69
|
+
if (form.id) await base44.entities["commerce.ProductAttribute"].update(form.id, payload);
|
|
70
|
+
else await base44.entities["commerce.ProductAttribute"].create(payload);
|
|
71
|
+
toast.success(form.id ? "Attribute updated" : "Attribute created");
|
|
72
|
+
reset();
|
|
73
|
+
refetch();
|
|
74
|
+
} catch (e) {
|
|
75
|
+
toast.error(e.message || "Failed to save attribute");
|
|
76
|
+
} finally {
|
|
77
|
+
setSaving(false);
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
const doDelete = async () => {
|
|
82
|
+
setDeleting(true);
|
|
83
|
+
try {
|
|
84
|
+
await base44.entities["commerce.ProductAttribute"].delete(confirmDelete.id);
|
|
85
|
+
toast.success("Attribute deleted");
|
|
86
|
+
if (form.id === confirmDelete.id) reset();
|
|
87
|
+
setConfirmDelete(null);
|
|
88
|
+
refetch();
|
|
89
|
+
} catch (e) {
|
|
90
|
+
toast.error(e.message || "Failed to delete attribute");
|
|
91
|
+
} finally {
|
|
92
|
+
setDeleting(false);
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
const columns = [
|
|
97
|
+
{ key: "name", label: "Name", render: (row) => <span className="font-medium">{row.name}</span> },
|
|
98
|
+
{ key: "slug", label: "Slug", className: "hidden md:table-cell text-muted-foreground" },
|
|
99
|
+
{
|
|
100
|
+
key: "terms",
|
|
101
|
+
label: "Terms",
|
|
102
|
+
render: (row) => (
|
|
103
|
+
<Button
|
|
104
|
+
variant="link"
|
|
105
|
+
size="sm"
|
|
106
|
+
className="h-auto p-0"
|
|
107
|
+
onClick={() => navigate(href(`products/attributes/${row.id}/terms`))}
|
|
108
|
+
>
|
|
109
|
+
<Settings2 className="mr-1 h-3.5 w-3.5" /> Configure terms
|
|
110
|
+
</Button>
|
|
111
|
+
),
|
|
112
|
+
},
|
|
113
|
+
];
|
|
114
|
+
|
|
115
|
+
return (
|
|
116
|
+
<div className="space-y-4">
|
|
117
|
+
<PageHeader title="Attributes" description="Reusable options such as Color or Size" />
|
|
118
|
+
<div className="grid gap-4 lg:grid-cols-[340px_1fr]">
|
|
119
|
+
<Card className="h-fit">
|
|
120
|
+
<CardHeader className="pb-2">
|
|
121
|
+
<CardTitle className="text-base">{form.id ? "Edit attribute" : "Add new attribute"}</CardTitle>
|
|
122
|
+
</CardHeader>
|
|
123
|
+
<CardContent className="space-y-3">
|
|
124
|
+
<div className="space-y-1.5">
|
|
125
|
+
<Label>Name</Label>
|
|
126
|
+
<Input value={form.name} onChange={(e) => setName(e.target.value)} />
|
|
127
|
+
</div>
|
|
128
|
+
<div className="space-y-1.5">
|
|
129
|
+
<Label>Slug</Label>
|
|
130
|
+
<Input
|
|
131
|
+
value={form.slug}
|
|
132
|
+
onChange={(e) => {
|
|
133
|
+
setSlugTouched(true);
|
|
134
|
+
setForm((f) => ({ ...f, slug: e.target.value }));
|
|
135
|
+
}}
|
|
136
|
+
/>
|
|
137
|
+
</div>
|
|
138
|
+
<div className="space-y-1.5">
|
|
139
|
+
<Label>Default sort order</Label>
|
|
140
|
+
<Select value={form.order_by} onValueChange={(v) => setForm((f) => ({ ...f, order_by: v }))}>
|
|
141
|
+
<SelectTrigger><SelectValue /></SelectTrigger>
|
|
142
|
+
<SelectContent>
|
|
143
|
+
{ORDER_BY.map((o) => (
|
|
144
|
+
<SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>
|
|
145
|
+
))}
|
|
146
|
+
</SelectContent>
|
|
147
|
+
</Select>
|
|
148
|
+
</div>
|
|
149
|
+
<div className="flex gap-2">
|
|
150
|
+
<Button onClick={save} disabled={saving}>
|
|
151
|
+
{saving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
|
152
|
+
{form.id ? "Update" : "Add attribute"}
|
|
153
|
+
</Button>
|
|
154
|
+
{form.id && <Button variant="ghost" onClick={reset} disabled={saving}>Cancel</Button>}
|
|
155
|
+
</div>
|
|
156
|
+
</CardContent>
|
|
157
|
+
</Card>
|
|
158
|
+
|
|
159
|
+
<DataTable
|
|
160
|
+
columns={columns}
|
|
161
|
+
rows={attributes || []}
|
|
162
|
+
loading={loading}
|
|
163
|
+
rowActions={(row) => [
|
|
164
|
+
{ label: "Edit", icon: Pencil, onClick: () => edit(row) },
|
|
165
|
+
{ label: "Configure terms", icon: Settings2, onClick: () => navigate(href(`products/attributes/${row.id}/terms`)) },
|
|
166
|
+
{ label: "Delete", icon: Trash2, destructive: true, onClick: () => setConfirmDelete(row) },
|
|
167
|
+
]}
|
|
168
|
+
empty={{ icon: Layers, title: "No attributes yet", description: "Create an attribute like Color or Size." }}
|
|
169
|
+
/>
|
|
170
|
+
</div>
|
|
171
|
+
|
|
172
|
+
<ConfirmDialog
|
|
173
|
+
open={Boolean(confirmDelete)}
|
|
174
|
+
onOpenChange={(o) => !o && setConfirmDelete(null)}
|
|
175
|
+
title={`Delete "${confirmDelete?.name}"?`}
|
|
176
|
+
description="This deletes the attribute and its terms. Products keep any custom attribute values already saved."
|
|
177
|
+
confirmLabel="Delete"
|
|
178
|
+
onConfirm={doDelete}
|
|
179
|
+
loading={deleting}
|
|
180
|
+
/>
|
|
181
|
+
</div>
|
|
182
|
+
);
|
|
183
|
+
}
|