@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,565 @@
|
|
|
1
|
+
import React, { useEffect, useMemo, useState } from "react";
|
|
2
|
+
import { 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 { Textarea } from "@/components/ui/textarea";
|
|
7
|
+
import { Label } from "@/components/ui/label";
|
|
8
|
+
import { Checkbox } from "@/components/ui/checkbox";
|
|
9
|
+
import { Badge } from "@/components/ui/badge";
|
|
10
|
+
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
11
|
+
import { Separator } from "@/components/ui/separator";
|
|
12
|
+
import {
|
|
13
|
+
Select,
|
|
14
|
+
SelectContent,
|
|
15
|
+
SelectItem,
|
|
16
|
+
SelectTrigger,
|
|
17
|
+
SelectValue,
|
|
18
|
+
} from "@/components/ui/select";
|
|
19
|
+
import { Loader2, Wand2, X } from "lucide-react";
|
|
20
|
+
|
|
21
|
+
import { call, base44 } from "../../lib/api";
|
|
22
|
+
import { COUPON_TYPES } from "../../lib/constants";
|
|
23
|
+
import useMoney from "../../hooks/useMoney";
|
|
24
|
+
import { useAdminHref } from "../../context/BasePathContext";
|
|
25
|
+
import PageHeader from "../../components/PageHeader";
|
|
26
|
+
import SearchSelect from "../../components/SearchSelect";
|
|
27
|
+
import MoneyInput from "../../components/MoneyInput";
|
|
28
|
+
import ConfirmDialog from "../../components/ConfirmDialog";
|
|
29
|
+
|
|
30
|
+
const EMPTY = {
|
|
31
|
+
code: "",
|
|
32
|
+
discount_type: "fixed_cart",
|
|
33
|
+
amount: null,
|
|
34
|
+
description: "",
|
|
35
|
+
date_expires: null,
|
|
36
|
+
free_shipping: false,
|
|
37
|
+
individual_use: false,
|
|
38
|
+
exclude_sale_items: false,
|
|
39
|
+
product_ids: [],
|
|
40
|
+
excluded_product_ids: [],
|
|
41
|
+
product_category_ids: [],
|
|
42
|
+
excluded_product_category_ids: [],
|
|
43
|
+
minimum_amount: null,
|
|
44
|
+
maximum_amount: null,
|
|
45
|
+
email_restrictions: [],
|
|
46
|
+
usage_limit: null,
|
|
47
|
+
usage_limit_per_user: null,
|
|
48
|
+
limit_usage_to_x_items: null,
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const TABS = [
|
|
52
|
+
{ id: "general", label: "General" },
|
|
53
|
+
{ id: "restriction", label: "Usage restriction" },
|
|
54
|
+
{ id: "limits", label: "Usage limits" },
|
|
55
|
+
];
|
|
56
|
+
|
|
57
|
+
const randomCode = () =>
|
|
58
|
+
Array.from(crypto.getRandomValues(new Uint8Array(8)))
|
|
59
|
+
.map((b) => "abcdefghijklmnopqrstuvwxyz0123456789"[b % 36])
|
|
60
|
+
.join("");
|
|
61
|
+
|
|
62
|
+
/** Products picker option loader (server-side search). */
|
|
63
|
+
const searchProducts = async (q) => {
|
|
64
|
+
const data = await call("admin-products", "search", { q: q || "", limit: 20 }, { silent: true });
|
|
65
|
+
return (data?.rows || []).map((p) => ({ value: p.id, label: p.name, meta: p.sku }));
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
/** Chips input for allowed emails (supports *@domain.com wildcards). */
|
|
69
|
+
function EmailChips({ value = [], onChange }) {
|
|
70
|
+
const [text, setText] = useState("");
|
|
71
|
+
const add = () => {
|
|
72
|
+
const v = text.trim().toLowerCase().replace(/,+$/, "");
|
|
73
|
+
if (!v) return;
|
|
74
|
+
if (!value.includes(v)) onChange([...value, v]);
|
|
75
|
+
setText("");
|
|
76
|
+
};
|
|
77
|
+
return (
|
|
78
|
+
<div>
|
|
79
|
+
<Input
|
|
80
|
+
value={text}
|
|
81
|
+
onChange={(e) => setText(e.target.value)}
|
|
82
|
+
onKeyDown={(e) => {
|
|
83
|
+
if (e.key === "Enter" || e.key === ",") {
|
|
84
|
+
e.preventDefault();
|
|
85
|
+
add();
|
|
86
|
+
}
|
|
87
|
+
}}
|
|
88
|
+
onBlur={add}
|
|
89
|
+
placeholder="email@example.com or *@example.com — press Enter"
|
|
90
|
+
/>
|
|
91
|
+
{value.length > 0 && (
|
|
92
|
+
<div className="mt-2 flex flex-wrap gap-1">
|
|
93
|
+
{value.map((v) => (
|
|
94
|
+
<Badge key={v} variant="secondary" className="gap-1 pr-1">
|
|
95
|
+
{v}
|
|
96
|
+
<button
|
|
97
|
+
type="button"
|
|
98
|
+
className="rounded-full p-0.5 hover:bg-muted-foreground/20"
|
|
99
|
+
onClick={() => onChange(value.filter((x) => x !== v))}
|
|
100
|
+
>
|
|
101
|
+
<X className="h-3 w-3" />
|
|
102
|
+
</button>
|
|
103
|
+
</Badge>
|
|
104
|
+
))}
|
|
105
|
+
</div>
|
|
106
|
+
)}
|
|
107
|
+
</div>
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function NumberField({ value, onChange, placeholder = "Unlimited" }) {
|
|
112
|
+
return (
|
|
113
|
+
<Input
|
|
114
|
+
type="number"
|
|
115
|
+
min="0"
|
|
116
|
+
value={value ?? ""}
|
|
117
|
+
placeholder={placeholder}
|
|
118
|
+
onChange={(e) => onChange(e.target.value === "" ? null : Number(e.target.value))}
|
|
119
|
+
/>
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export default function CouponEditor() {
|
|
124
|
+
const { id } = useParams();
|
|
125
|
+
const isNew = !id;
|
|
126
|
+
const href = useAdminHref();
|
|
127
|
+
const navigate = useNavigate();
|
|
128
|
+
const money = useMoney();
|
|
129
|
+
|
|
130
|
+
const [coupon, setCoupon] = useState(EMPTY);
|
|
131
|
+
const [loading, setLoading] = useState(!isNew);
|
|
132
|
+
const [saving, setSaving] = useState(false);
|
|
133
|
+
const [confirmDelete, setConfirmDelete] = useState(false);
|
|
134
|
+
const [deleting, setDeleting] = useState(false);
|
|
135
|
+
const [tab, setTab] = useState("general");
|
|
136
|
+
|
|
137
|
+
// Selected options with labels for the pickers.
|
|
138
|
+
const [productOpts, setProductOpts] = useState({ include: [], exclude: [] });
|
|
139
|
+
const [categories, setCategories] = useState([]);
|
|
140
|
+
|
|
141
|
+
const set = (key) => (v) => setCoupon((c) => ({ ...c, [key]: v }));
|
|
142
|
+
|
|
143
|
+
// Load categories once (small list, client-filtered in the picker).
|
|
144
|
+
useEffect(() => {
|
|
145
|
+
base44.entities["commerce.ProductCategory"].list("name", 500)
|
|
146
|
+
.then(setCategories)
|
|
147
|
+
.catch(() => setCategories([]));
|
|
148
|
+
}, []);
|
|
149
|
+
|
|
150
|
+
const searchCategories = useMemo(
|
|
151
|
+
() => async (q) =>
|
|
152
|
+
categories
|
|
153
|
+
.filter((c) => !q || c.name.toLowerCase().includes(q.toLowerCase()))
|
|
154
|
+
.slice(0, 20)
|
|
155
|
+
.map((c) => ({ value: c.id, label: c.name })),
|
|
156
|
+
[categories]
|
|
157
|
+
);
|
|
158
|
+
|
|
159
|
+
const categoryOpts = (ids) =>
|
|
160
|
+
(ids || []).map((cid) => ({
|
|
161
|
+
value: cid,
|
|
162
|
+
label: categories.find((c) => c.id === cid)?.name || cid,
|
|
163
|
+
}));
|
|
164
|
+
|
|
165
|
+
// Load the coupon + resolve product labels for stored ids.
|
|
166
|
+
useEffect(() => {
|
|
167
|
+
if (isNew) return;
|
|
168
|
+
let cancelled = false;
|
|
169
|
+
setLoading(true);
|
|
170
|
+
base44.entities["commerce.Coupon"].get(id)
|
|
171
|
+
.then(async (c) => {
|
|
172
|
+
if (cancelled) return;
|
|
173
|
+
setCoupon({ ...EMPTY, ...c });
|
|
174
|
+
const resolve = (ids) =>
|
|
175
|
+
Promise.all(
|
|
176
|
+
(ids || []).map((pid) =>
|
|
177
|
+
base44.entities["commerce.Product"].get(pid)
|
|
178
|
+
.then((p) => ({ value: p.id, label: p.name, meta: p.sku }))
|
|
179
|
+
.catch(() => ({ value: pid, label: pid }))
|
|
180
|
+
)
|
|
181
|
+
);
|
|
182
|
+
const [include, exclude] = await Promise.all([
|
|
183
|
+
resolve(c.product_ids),
|
|
184
|
+
resolve(c.excluded_product_ids),
|
|
185
|
+
]);
|
|
186
|
+
if (!cancelled) setProductOpts({ include, exclude });
|
|
187
|
+
})
|
|
188
|
+
.catch(() => {})
|
|
189
|
+
.finally(() => !cancelled && setLoading(false));
|
|
190
|
+
return () => {
|
|
191
|
+
cancelled = true;
|
|
192
|
+
};
|
|
193
|
+
}, [id, isNew]);
|
|
194
|
+
|
|
195
|
+
const save = async () => {
|
|
196
|
+
if (!coupon.code?.trim()) {
|
|
197
|
+
toast.error("A coupon code is required");
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
if (coupon.amount === null || coupon.amount === undefined) {
|
|
201
|
+
toast.error("A discount amount is required");
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
setSaving(true);
|
|
205
|
+
try {
|
|
206
|
+
const payload = {
|
|
207
|
+
...coupon,
|
|
208
|
+
code: coupon.code.trim().toLowerCase(),
|
|
209
|
+
// Store the expiry as end-of-day so the coupon works through its last day.
|
|
210
|
+
date_expires: coupon.date_expires
|
|
211
|
+
? new Date(`${coupon.date_expires.slice(0, 10)}T23:59:59Z`).toISOString()
|
|
212
|
+
: null,
|
|
213
|
+
};
|
|
214
|
+
const saved = await call("admin-coupons", "save", { coupon: payload });
|
|
215
|
+
toast.success("Coupon saved");
|
|
216
|
+
if (isNew && saved?.id) navigate(href(`coupons/${saved.id}`), { replace: true });
|
|
217
|
+
else if (saved) setCoupon((c) => ({ ...c, ...saved }));
|
|
218
|
+
} catch {
|
|
219
|
+
/* toast shown by call() */
|
|
220
|
+
} finally {
|
|
221
|
+
setSaving(false);
|
|
222
|
+
}
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
const doDelete = async () => {
|
|
226
|
+
setDeleting(true);
|
|
227
|
+
try {
|
|
228
|
+
await call("admin-coupons", "delete", { id });
|
|
229
|
+
toast.success("Coupon deleted");
|
|
230
|
+
navigate(href("coupons"));
|
|
231
|
+
} catch {
|
|
232
|
+
setDeleting(false);
|
|
233
|
+
}
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
if (loading) {
|
|
237
|
+
return (
|
|
238
|
+
<div className="flex justify-center py-24">
|
|
239
|
+
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
|
240
|
+
</div>
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const isPercent = coupon.discount_type === "percent";
|
|
245
|
+
|
|
246
|
+
return (
|
|
247
|
+
<div>
|
|
248
|
+
<PageHeader
|
|
249
|
+
title={isNew ? "Add coupon" : `Coupon: ${coupon.code}`}
|
|
250
|
+
backHref={href("coupons")}
|
|
251
|
+
/>
|
|
252
|
+
|
|
253
|
+
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[1fr_280px]">
|
|
254
|
+
{/* Main column */}
|
|
255
|
+
<div className="space-y-4">
|
|
256
|
+
<Card>
|
|
257
|
+
<CardContent className="space-y-4 pt-6">
|
|
258
|
+
<div className="grid gap-1.5">
|
|
259
|
+
<Label>Coupon code</Label>
|
|
260
|
+
<div className="flex gap-2">
|
|
261
|
+
<Input
|
|
262
|
+
value={coupon.code}
|
|
263
|
+
onChange={(e) => set("code")(e.target.value)}
|
|
264
|
+
placeholder="e.g. welcome10"
|
|
265
|
+
className="font-mono lowercase"
|
|
266
|
+
/>
|
|
267
|
+
<Button
|
|
268
|
+
type="button"
|
|
269
|
+
variant="outline"
|
|
270
|
+
onClick={() => set("code")(randomCode())}
|
|
271
|
+
>
|
|
272
|
+
<Wand2 className="mr-1 h-4 w-4" /> Generate
|
|
273
|
+
</Button>
|
|
274
|
+
</div>
|
|
275
|
+
</div>
|
|
276
|
+
<div className="grid gap-1.5">
|
|
277
|
+
<Label>Description (optional)</Label>
|
|
278
|
+
<Textarea
|
|
279
|
+
rows={2}
|
|
280
|
+
value={coupon.description || ""}
|
|
281
|
+
onChange={(e) => set("description")(e.target.value)}
|
|
282
|
+
/>
|
|
283
|
+
</div>
|
|
284
|
+
</CardContent>
|
|
285
|
+
</Card>
|
|
286
|
+
|
|
287
|
+
{/* Coupon data: vertical tabs */}
|
|
288
|
+
<Card>
|
|
289
|
+
<CardHeader className="pb-3">
|
|
290
|
+
<CardTitle className="text-base">Coupon data</CardTitle>
|
|
291
|
+
</CardHeader>
|
|
292
|
+
<CardContent className="p-0">
|
|
293
|
+
<div className="flex min-h-72 flex-col sm:flex-row">
|
|
294
|
+
<div className="flex shrink-0 flex-row gap-1 border-b p-2 sm:w-44 sm:flex-col sm:border-b-0 sm:border-r">
|
|
295
|
+
{TABS.map((t) => (
|
|
296
|
+
<Button
|
|
297
|
+
key={t.id}
|
|
298
|
+
variant={tab === t.id ? "secondary" : "ghost"}
|
|
299
|
+
size="sm"
|
|
300
|
+
className="justify-start"
|
|
301
|
+
onClick={() => setTab(t.id)}
|
|
302
|
+
>
|
|
303
|
+
{t.label}
|
|
304
|
+
</Button>
|
|
305
|
+
))}
|
|
306
|
+
</div>
|
|
307
|
+
|
|
308
|
+
<div className="flex-1 space-y-4 p-4">
|
|
309
|
+
{tab === "general" && (
|
|
310
|
+
<>
|
|
311
|
+
<div className="grid gap-1.5 sm:max-w-sm">
|
|
312
|
+
<Label>Discount type</Label>
|
|
313
|
+
<Select value={coupon.discount_type} onValueChange={set("discount_type")}>
|
|
314
|
+
<SelectTrigger>
|
|
315
|
+
<SelectValue />
|
|
316
|
+
</SelectTrigger>
|
|
317
|
+
<SelectContent>
|
|
318
|
+
{COUPON_TYPES.map((t) => (
|
|
319
|
+
<SelectItem key={t.value} value={t.value}>
|
|
320
|
+
{t.label}
|
|
321
|
+
</SelectItem>
|
|
322
|
+
))}
|
|
323
|
+
</SelectContent>
|
|
324
|
+
</Select>
|
|
325
|
+
</div>
|
|
326
|
+
<div className="grid gap-1.5 sm:max-w-sm">
|
|
327
|
+
<Label>Coupon amount {isPercent ? "(%)" : ""}</Label>
|
|
328
|
+
{isPercent ? (
|
|
329
|
+
<div className="relative">
|
|
330
|
+
<Input
|
|
331
|
+
type="number"
|
|
332
|
+
min="0"
|
|
333
|
+
max="100"
|
|
334
|
+
step="any"
|
|
335
|
+
value={coupon.amount ?? ""}
|
|
336
|
+
onChange={(e) =>
|
|
337
|
+
set("amount")(e.target.value === "" ? null : Number(e.target.value))
|
|
338
|
+
}
|
|
339
|
+
className="pr-8"
|
|
340
|
+
/>
|
|
341
|
+
<span className="pointer-events-none absolute inset-y-0 right-3 flex items-center text-sm text-muted-foreground">
|
|
342
|
+
%
|
|
343
|
+
</span>
|
|
344
|
+
</div>
|
|
345
|
+
) : (
|
|
346
|
+
<MoneyInput value={coupon.amount} onChange={set("amount")} />
|
|
347
|
+
)}
|
|
348
|
+
</div>
|
|
349
|
+
<label className="flex items-start gap-2 text-sm">
|
|
350
|
+
<Checkbox
|
|
351
|
+
checked={!!coupon.free_shipping}
|
|
352
|
+
onCheckedChange={(v) => set("free_shipping")(!!v)}
|
|
353
|
+
className="mt-0.5"
|
|
354
|
+
/>
|
|
355
|
+
<span>
|
|
356
|
+
Allow free shipping
|
|
357
|
+
<span className="block text-xs text-muted-foreground">
|
|
358
|
+
Grants access to free-shipping methods that require a coupon.
|
|
359
|
+
</span>
|
|
360
|
+
</span>
|
|
361
|
+
</label>
|
|
362
|
+
<div className="grid gap-1.5 sm:max-w-sm">
|
|
363
|
+
<Label>Coupon expiry date</Label>
|
|
364
|
+
<Input
|
|
365
|
+
type="date"
|
|
366
|
+
value={coupon.date_expires ? coupon.date_expires.slice(0, 10) : ""}
|
|
367
|
+
onChange={(e) => set("date_expires")(e.target.value || null)}
|
|
368
|
+
/>
|
|
369
|
+
</div>
|
|
370
|
+
</>
|
|
371
|
+
)}
|
|
372
|
+
|
|
373
|
+
{tab === "restriction" && (
|
|
374
|
+
<>
|
|
375
|
+
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
|
376
|
+
<div className="grid gap-1.5">
|
|
377
|
+
<Label>Minimum spend ({money.code})</Label>
|
|
378
|
+
<MoneyInput
|
|
379
|
+
value={coupon.minimum_amount}
|
|
380
|
+
onChange={set("minimum_amount")}
|
|
381
|
+
placeholder="No minimum"
|
|
382
|
+
/>
|
|
383
|
+
</div>
|
|
384
|
+
<div className="grid gap-1.5">
|
|
385
|
+
<Label>Maximum spend ({money.code})</Label>
|
|
386
|
+
<MoneyInput
|
|
387
|
+
value={coupon.maximum_amount}
|
|
388
|
+
onChange={set("maximum_amount")}
|
|
389
|
+
placeholder="No maximum"
|
|
390
|
+
/>
|
|
391
|
+
</div>
|
|
392
|
+
</div>
|
|
393
|
+
<label className="flex items-start gap-2 text-sm">
|
|
394
|
+
<Checkbox
|
|
395
|
+
checked={!!coupon.individual_use}
|
|
396
|
+
onCheckedChange={(v) => set("individual_use")(!!v)}
|
|
397
|
+
className="mt-0.5"
|
|
398
|
+
/>
|
|
399
|
+
<span>
|
|
400
|
+
Individual use only
|
|
401
|
+
<span className="block text-xs text-muted-foreground">
|
|
402
|
+
Cannot be used in conjunction with other coupons.
|
|
403
|
+
</span>
|
|
404
|
+
</span>
|
|
405
|
+
</label>
|
|
406
|
+
<label className="flex items-start gap-2 text-sm">
|
|
407
|
+
<Checkbox
|
|
408
|
+
checked={!!coupon.exclude_sale_items}
|
|
409
|
+
onCheckedChange={(v) => set("exclude_sale_items")(!!v)}
|
|
410
|
+
className="mt-0.5"
|
|
411
|
+
/>
|
|
412
|
+
<span>
|
|
413
|
+
Exclude sale items
|
|
414
|
+
<span className="block text-xs text-muted-foreground">
|
|
415
|
+
The coupon will not apply to items on sale.
|
|
416
|
+
</span>
|
|
417
|
+
</span>
|
|
418
|
+
</label>
|
|
419
|
+
<Separator />
|
|
420
|
+
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
|
421
|
+
<div className="grid gap-1.5">
|
|
422
|
+
<Label>Products</Label>
|
|
423
|
+
<SearchSelect
|
|
424
|
+
multiple
|
|
425
|
+
search={searchProducts}
|
|
426
|
+
value={productOpts.include}
|
|
427
|
+
placeholder="Any product"
|
|
428
|
+
onChange={(opts) => {
|
|
429
|
+
setProductOpts((o) => ({ ...o, include: opts }));
|
|
430
|
+
set("product_ids")(opts.map((o) => o.value));
|
|
431
|
+
}}
|
|
432
|
+
/>
|
|
433
|
+
</div>
|
|
434
|
+
<div className="grid gap-1.5">
|
|
435
|
+
<Label>Exclude products</Label>
|
|
436
|
+
<SearchSelect
|
|
437
|
+
multiple
|
|
438
|
+
search={searchProducts}
|
|
439
|
+
value={productOpts.exclude}
|
|
440
|
+
placeholder="No exclusions"
|
|
441
|
+
onChange={(opts) => {
|
|
442
|
+
setProductOpts((o) => ({ ...o, exclude: opts }));
|
|
443
|
+
set("excluded_product_ids")(opts.map((o) => o.value));
|
|
444
|
+
}}
|
|
445
|
+
/>
|
|
446
|
+
</div>
|
|
447
|
+
<div className="grid gap-1.5">
|
|
448
|
+
<Label>Product categories</Label>
|
|
449
|
+
<SearchSelect
|
|
450
|
+
multiple
|
|
451
|
+
search={searchCategories}
|
|
452
|
+
value={categoryOpts(coupon.product_category_ids)}
|
|
453
|
+
placeholder="Any category"
|
|
454
|
+
onChange={(opts) => set("product_category_ids")(opts.map((o) => o.value))}
|
|
455
|
+
/>
|
|
456
|
+
</div>
|
|
457
|
+
<div className="grid gap-1.5">
|
|
458
|
+
<Label>Exclude categories</Label>
|
|
459
|
+
<SearchSelect
|
|
460
|
+
multiple
|
|
461
|
+
search={searchCategories}
|
|
462
|
+
value={categoryOpts(coupon.excluded_product_category_ids)}
|
|
463
|
+
placeholder="No exclusions"
|
|
464
|
+
onChange={(opts) =>
|
|
465
|
+
set("excluded_product_category_ids")(opts.map((o) => o.value))
|
|
466
|
+
}
|
|
467
|
+
/>
|
|
468
|
+
</div>
|
|
469
|
+
</div>
|
|
470
|
+
<div className="grid gap-1.5">
|
|
471
|
+
<Label>Allowed emails</Label>
|
|
472
|
+
<EmailChips
|
|
473
|
+
value={coupon.email_restrictions || []}
|
|
474
|
+
onChange={set("email_restrictions")}
|
|
475
|
+
/>
|
|
476
|
+
<p className="text-xs text-muted-foreground">
|
|
477
|
+
Only these billing emails can use the coupon. Wildcards like{" "}
|
|
478
|
+
<code>*@example.com</code> are allowed.
|
|
479
|
+
</p>
|
|
480
|
+
</div>
|
|
481
|
+
</>
|
|
482
|
+
)}
|
|
483
|
+
|
|
484
|
+
{tab === "limits" && (
|
|
485
|
+
<div className="max-w-sm space-y-4">
|
|
486
|
+
<div className="grid gap-1.5">
|
|
487
|
+
<Label>Usage limit per coupon</Label>
|
|
488
|
+
<NumberField value={coupon.usage_limit} onChange={set("usage_limit")} />
|
|
489
|
+
<p className="text-xs text-muted-foreground">
|
|
490
|
+
How many times the coupon can be used in total.
|
|
491
|
+
</p>
|
|
492
|
+
</div>
|
|
493
|
+
<div className="grid gap-1.5">
|
|
494
|
+
<Label>Limit usage to X items</Label>
|
|
495
|
+
<NumberField
|
|
496
|
+
value={coupon.limit_usage_to_x_items}
|
|
497
|
+
onChange={set("limit_usage_to_x_items")}
|
|
498
|
+
placeholder="Apply to all qualifying items"
|
|
499
|
+
/>
|
|
500
|
+
<p className="text-xs text-muted-foreground">
|
|
501
|
+
Maximum number of individual items the coupon applies to (product
|
|
502
|
+
discounts).
|
|
503
|
+
</p>
|
|
504
|
+
</div>
|
|
505
|
+
<div className="grid gap-1.5">
|
|
506
|
+
<Label>Usage limit per user</Label>
|
|
507
|
+
<NumberField
|
|
508
|
+
value={coupon.usage_limit_per_user}
|
|
509
|
+
onChange={set("usage_limit_per_user")}
|
|
510
|
+
/>
|
|
511
|
+
<p className="text-xs text-muted-foreground">
|
|
512
|
+
How many times a single customer (by billing email) can use it.
|
|
513
|
+
</p>
|
|
514
|
+
</div>
|
|
515
|
+
</div>
|
|
516
|
+
)}
|
|
517
|
+
</div>
|
|
518
|
+
</div>
|
|
519
|
+
</CardContent>
|
|
520
|
+
</Card>
|
|
521
|
+
</div>
|
|
522
|
+
|
|
523
|
+
{/* Sidebar */}
|
|
524
|
+
<div className="space-y-4">
|
|
525
|
+
<Card>
|
|
526
|
+
<CardHeader className="pb-3">
|
|
527
|
+
<CardTitle className="text-base">Publish</CardTitle>
|
|
528
|
+
</CardHeader>
|
|
529
|
+
<CardContent className="space-y-3">
|
|
530
|
+
{!isNew && (
|
|
531
|
+
<p className="text-sm text-muted-foreground">
|
|
532
|
+
Used: <span className="font-medium text-foreground">{coupon.usage_count ?? 0}</span>{" "}
|
|
533
|
+
time{(coupon.usage_count ?? 0) === 1 ? "" : "s"}
|
|
534
|
+
</p>
|
|
535
|
+
)}
|
|
536
|
+
<Button className="w-full" onClick={save} disabled={saving}>
|
|
537
|
+
{saving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
|
538
|
+
{isNew ? "Create coupon" : "Save coupon"}
|
|
539
|
+
</Button>
|
|
540
|
+
{!isNew && (
|
|
541
|
+
<Button
|
|
542
|
+
variant="outline"
|
|
543
|
+
className="w-full text-destructive hover:text-destructive"
|
|
544
|
+
onClick={() => setConfirmDelete(true)}
|
|
545
|
+
>
|
|
546
|
+
Delete
|
|
547
|
+
</Button>
|
|
548
|
+
)}
|
|
549
|
+
</CardContent>
|
|
550
|
+
</Card>
|
|
551
|
+
</div>
|
|
552
|
+
</div>
|
|
553
|
+
|
|
554
|
+
<ConfirmDialog
|
|
555
|
+
open={confirmDelete}
|
|
556
|
+
onOpenChange={setConfirmDelete}
|
|
557
|
+
title="Delete this coupon?"
|
|
558
|
+
description="Customers will no longer be able to use it. This cannot be undone."
|
|
559
|
+
confirmLabel="Delete"
|
|
560
|
+
loading={deleting}
|
|
561
|
+
onConfirm={doDelete}
|
|
562
|
+
/>
|
|
563
|
+
</div>
|
|
564
|
+
);
|
|
565
|
+
}
|