@base44/app-plugin-commerce 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (173) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +117 -0
  3. package/base44/agents/commerce/StoreAdmin.jsonc +64 -0
  4. package/base44/entities/commerce.Cart.jsonc +73 -0
  5. package/base44/entities/commerce.Coupon.jsonc +113 -0
  6. package/base44/entities/commerce.Customer.jsonc +96 -0
  7. package/base44/entities/commerce.DownloadPermission.jsonc +54 -0
  8. package/base44/entities/commerce.EmailLog.jsonc +43 -0
  9. package/base44/entities/commerce.Order.jsonc +287 -0
  10. package/base44/entities/commerce.OrderNote.jsonc +31 -0
  11. package/base44/entities/commerce.OrderRefund.jsonc +64 -0
  12. package/base44/entities/commerce.PaymentGateway.jsonc +48 -0
  13. package/base44/entities/commerce.Product.jsonc +291 -0
  14. package/base44/entities/commerce.ProductAttribute.jsonc +39 -0
  15. package/base44/entities/commerce.ProductAttributeTerm.jsonc +38 -0
  16. package/base44/entities/commerce.ProductCategory.jsonc +51 -0
  17. package/base44/entities/commerce.ProductReview.jsonc +48 -0
  18. package/base44/entities/commerce.ProductTag.jsonc +30 -0
  19. package/base44/entities/commerce.ProductVariation.jsonc +167 -0
  20. package/base44/entities/commerce.ShippingClass.jsonc +30 -0
  21. package/base44/entities/commerce.ShippingZone.jsonc +41 -0
  22. package/base44/entities/commerce.ShippingZoneMethod.jsonc +84 -0
  23. package/base44/entities/commerce.StoreSettings.jsonc +23 -0
  24. package/base44/entities/commerce.TaxClass.jsonc +23 -0
  25. package/base44/entities/commerce.TaxRate.jsonc +68 -0
  26. package/base44/entities/commerce.Webhook.jsonc +57 -0
  27. package/base44/entities/commerce.WebhookDelivery.jsonc +45 -0
  28. package/base44/functions/commerce/admin-coupons/entry.ts +100 -0
  29. package/base44/functions/commerce/admin-customers/entry.ts +141 -0
  30. package/base44/functions/commerce/admin-orders/entry.ts +396 -0
  31. package/base44/functions/commerce/admin-orders/helpers.ts +246 -0
  32. package/base44/functions/commerce/admin-products/entry.ts +506 -0
  33. package/base44/functions/commerce/admin-refunds/entry.ts +158 -0
  34. package/base44/functions/commerce/admin-reports/entry.ts +283 -0
  35. package/base44/functions/commerce/admin-reviews/entry.ts +66 -0
  36. package/base44/functions/commerce/admin-tools/entry.ts +261 -0
  37. package/base44/functions/commerce/admin-webhooks/entry.ts +52 -0
  38. package/base44/functions/commerce/payment-webhook/entry.ts +135 -0
  39. package/base44/functions/commerce/payments/entry.ts +238 -0
  40. package/base44/functions/commerce/seed-store/defaults.ts +162 -0
  41. package/base44/functions/commerce/seed-store/entry.ts +310 -0
  42. package/base44/functions/commerce/seed-store/sample-data.ts +349 -0
  43. package/base44/functions/commerce/storefront-account/entry.ts +207 -0
  44. package/base44/functions/commerce/storefront-cart/cart-pricing.ts +258 -0
  45. package/base44/functions/commerce/storefront-cart/entry.ts +283 -0
  46. package/base44/functions/commerce/storefront-catalog/entry.ts +459 -0
  47. package/base44/functions/commerce/storefront-checkout/cart-pricing.ts +258 -0
  48. package/base44/functions/commerce/storefront-checkout/entry.ts +485 -0
  49. package/base44/shared/commerce/auth.ts +60 -0
  50. package/base44/shared/commerce/coupons.ts +257 -0
  51. package/base44/shared/commerce/data/continents.ts +75 -0
  52. package/base44/shared/commerce/data/countries.ts +307 -0
  53. package/base44/shared/commerce/data/currencies.ts +46 -0
  54. package/base44/shared/commerce/email-templates.ts +240 -0
  55. package/base44/shared/commerce/emails.ts +225 -0
  56. package/base44/shared/commerce/money.ts +66 -0
  57. package/base44/shared/commerce/orders.ts +251 -0
  58. package/base44/shared/commerce/payments.ts +495 -0
  59. package/base44/shared/commerce/reviews.ts +36 -0
  60. package/base44/shared/commerce/scan.ts +57 -0
  61. package/base44/shared/commerce/sequence.ts +35 -0
  62. package/base44/shared/commerce/settings.ts +57 -0
  63. package/base44/shared/commerce/shipping.ts +215 -0
  64. package/base44/shared/commerce/stock.ts +227 -0
  65. package/base44/shared/commerce/stripe.ts +463 -0
  66. package/base44/shared/commerce/tax.ts +136 -0
  67. package/base44/shared/commerce/totals.ts +314 -0
  68. package/base44/shared/commerce/webhooks.ts +116 -0
  69. package/package.json +37 -0
  70. package/scripts/install.js +156 -0
  71. package/skills/commerce/SKILL.md +62 -0
  72. package/skills/commerce/docs/api-admin.md +186 -0
  73. package/skills/commerce/docs/api-storefront.md +408 -0
  74. package/skills/commerce/installation-guidelines.md +91 -0
  75. package/skills/commerce/post-installation.md +157 -0
  76. package/skills/commerce/references/emails.md +13 -0
  77. package/skills/commerce/references/guest-access-security.md +18 -0
  78. package/skills/commerce/references/limits-and-performance.md +16 -0
  79. package/skills/commerce/references/media-and-downloads.md +4 -0
  80. package/skills/commerce/references/online-payments.md +201 -0
  81. package/skills/commerce/references/product-render.md +87 -0
  82. package/skills/commerce/references/scheduled-work.md +19 -0
  83. package/skills/commerce/references/storefront-product-page.md +83 -0
  84. package/skills/commerce/references/webhooks.md +8 -0
  85. package/src/commerce/admin/README.md +107 -0
  86. package/src/commerce/admin/bot/Markdown.jsx +138 -0
  87. package/src/commerce/admin/bot/StoreAdminBot.jsx +249 -0
  88. package/src/commerce/admin/bot/pipe-tables.js +116 -0
  89. package/src/commerce/admin/components/AddressForm.jsx +78 -0
  90. package/src/commerce/admin/components/ConfirmDialog.jsx +52 -0
  91. package/src/commerce/admin/components/CountrySelect.jsx +81 -0
  92. package/src/commerce/admin/components/DataTable.jsx +192 -0
  93. package/src/commerce/admin/components/DateRangePicker.jsx +91 -0
  94. package/src/commerce/admin/components/EmptyState.jsx +17 -0
  95. package/src/commerce/admin/components/MediaUploader.jsx +116 -0
  96. package/src/commerce/admin/components/MetaDataEditor.jsx +45 -0
  97. package/src/commerce/admin/components/MoneyInput.jsx +50 -0
  98. package/src/commerce/admin/components/PageHeader.jsx +29 -0
  99. package/src/commerce/admin/components/RichTextarea.jsx +21 -0
  100. package/src/commerce/admin/components/SearchSelect.jsx +142 -0
  101. package/src/commerce/admin/components/StatusBadge.jsx +17 -0
  102. package/src/commerce/admin/context/BasePathContext.jsx +26 -0
  103. package/src/commerce/admin/context/SettingsContext.jsx +207 -0
  104. package/src/commerce/admin/hooks/useAsync.js +46 -0
  105. package/src/commerce/admin/hooks/useDebounce.js +11 -0
  106. package/src/commerce/admin/hooks/useMoney.js +52 -0
  107. package/src/commerce/admin/hooks/usePagedList.js +83 -0
  108. package/src/commerce/admin/hooks/usePaymentProvider.js +27 -0
  109. package/src/commerce/admin/hooks/useRealtime.js +129 -0
  110. package/src/commerce/admin/index.jsx +34 -0
  111. package/src/commerce/admin/layout/AccessDenied.jsx +54 -0
  112. package/src/commerce/admin/layout/AdminLayout.jsx +33 -0
  113. package/src/commerce/admin/layout/AuthGuard.jsx +84 -0
  114. package/src/commerce/admin/layout/Sidebar.jsx +130 -0
  115. package/src/commerce/admin/layout/Topbar.jsx +94 -0
  116. package/src/commerce/admin/lib/api.js +55 -0
  117. package/src/commerce/admin/lib/constants.js +157 -0
  118. package/src/commerce/admin/lib/format.js +27 -0
  119. package/src/commerce/admin/lib/geo-data.js +125 -0
  120. package/src/commerce/admin/lib/order-utils.js +147 -0
  121. package/src/commerce/admin/lib/paths.js +35 -0
  122. package/src/commerce/admin/lib/product-utils.js +55 -0
  123. package/src/commerce/admin/pages/Dashboard.jsx +245 -0
  124. package/src/commerce/admin/pages/coupons/CouponEditor.jsx +565 -0
  125. package/src/commerce/admin/pages/coupons/CouponsList.jsx +172 -0
  126. package/src/commerce/admin/pages/customers/CustomerEditor.jsx +318 -0
  127. package/src/commerce/admin/pages/customers/CustomersList.jsx +169 -0
  128. package/src/commerce/admin/pages/orders/OrderEditor.jsx +952 -0
  129. package/src/commerce/admin/pages/orders/OrdersList.jsx +227 -0
  130. package/src/commerce/admin/pages/orders/components/AddProductDialog.jsx +149 -0
  131. package/src/commerce/admin/pages/orders/components/DownloadPermissionsPanel.jsx +119 -0
  132. package/src/commerce/admin/pages/orders/components/LineItemsTable.jsx +208 -0
  133. package/src/commerce/admin/pages/orders/components/OrderNotesPanel.jsx +123 -0
  134. package/src/commerce/admin/pages/orders/components/PaymentPanel.jsx +199 -0
  135. package/src/commerce/admin/pages/orders/components/RefundPanel.jsx +239 -0
  136. package/src/commerce/admin/pages/orders/components/TotalsBox.jsx +52 -0
  137. package/src/commerce/admin/pages/products/AttributeTerms.jsx +180 -0
  138. package/src/commerce/admin/pages/products/Attributes.jsx +183 -0
  139. package/src/commerce/admin/pages/products/Categories.jsx +236 -0
  140. package/src/commerce/admin/pages/products/ProductEditor.jsx +267 -0
  141. package/src/commerce/admin/pages/products/ProductsList.jsx +391 -0
  142. package/src/commerce/admin/pages/products/Reviews.jsx +255 -0
  143. package/src/commerce/admin/pages/products/Tags.jsx +150 -0
  144. package/src/commerce/admin/pages/products/components/ProductDataPanel.jsx +132 -0
  145. package/src/commerce/admin/pages/products/components/PublishBox.jsx +101 -0
  146. package/src/commerce/admin/pages/products/components/TaxonomyPanel.jsx +243 -0
  147. package/src/commerce/admin/pages/products/components/tabs/AdvancedTab.jsx +48 -0
  148. package/src/commerce/admin/pages/products/components/tabs/AttributesTab.jsx +208 -0
  149. package/src/commerce/admin/pages/products/components/tabs/DownloadsTab.jsx +91 -0
  150. package/src/commerce/admin/pages/products/components/tabs/ExternalTab.jsx +41 -0
  151. package/src/commerce/admin/pages/products/components/tabs/GeneralTab.jsx +103 -0
  152. package/src/commerce/admin/pages/products/components/tabs/InventoryTab.jsx +93 -0
  153. package/src/commerce/admin/pages/products/components/tabs/LinkedTab.jsx +102 -0
  154. package/src/commerce/admin/pages/products/components/tabs/ShippingTab.jsx +86 -0
  155. package/src/commerce/admin/pages/products/components/tabs/VariationsTab.jsx +377 -0
  156. package/src/commerce/admin/pages/reports/Reports.jsx +416 -0
  157. package/src/commerce/admin/pages/settings/EmailsSettings.jsx +240 -0
  158. package/src/commerce/admin/pages/settings/GeneralSettings.jsx +232 -0
  159. package/src/commerce/admin/pages/settings/InventorySettings.jsx +146 -0
  160. package/src/commerce/admin/pages/settings/PaymentsSettings.jsx +260 -0
  161. package/src/commerce/admin/pages/settings/ProductsSettings.jsx +118 -0
  162. package/src/commerce/admin/pages/settings/SettingsLayout.jsx +53 -0
  163. package/src/commerce/admin/pages/settings/ShippingSettings.jsx +304 -0
  164. package/src/commerce/admin/pages/settings/ShippingZoneEditor.jsx +514 -0
  165. package/src/commerce/admin/pages/settings/TaxRatesTable.jsx +231 -0
  166. package/src/commerce/admin/pages/settings/TaxSettings.jsx +281 -0
  167. package/src/commerce/admin/pages/settings/useGroupForm.jsx +76 -0
  168. package/src/commerce/admin/pages/status/WebhookEditor.jsx +296 -0
  169. package/src/commerce/admin/pages/status/Webhooks.jsx +53 -0
  170. package/src/commerce/admin/routes.jsx +151 -0
  171. package/src/commerce/utils/index.js +19 -0
  172. package/src/commerce/utils/shipping-promos.js +99 -0
  173. package/src/commerce/utils/variants.js +411 -0
@@ -0,0 +1,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
+ }