@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,952 @@
1
+ import React, { useCallback, useEffect, useMemo, useState } from "react";
2
+ import { Link, useNavigate, useParams } from "react-router-dom";
3
+ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
4
+ import { Badge } from "@/components/ui/badge";
5
+ import { Button } from "@/components/ui/button";
6
+ import { Input } from "@/components/ui/input";
7
+ import { Label } from "@/components/ui/label";
8
+ import {
9
+ Select,
10
+ SelectContent,
11
+ SelectItem,
12
+ SelectTrigger,
13
+ SelectValue,
14
+ } from "@/components/ui/select";
15
+ import {
16
+ Dialog,
17
+ DialogContent,
18
+ DialogFooter,
19
+ DialogHeader,
20
+ DialogTitle,
21
+ } from "@/components/ui/dialog";
22
+ import { Separator } from "@/components/ui/separator";
23
+ import { Skeleton } from "@/components/ui/skeleton";
24
+ import { Copy, ExternalLink, Loader2, Mail, Pencil, Plus, RotateCw, Ticket, Trash2, Truck, UserPlus } from "lucide-react";
25
+ import { toast } from "sonner";
26
+
27
+ import { call, base44 } from "../../lib/api";
28
+ import useAsync from "../../hooks/useAsync";
29
+ import useDebounce from "../../hooks/useDebounce";
30
+ import { useAdminHref } from "../../context/BasePathContext";
31
+ import { ORDER_STATUSES } from "../../lib/constants";
32
+ import { formatDateTime } from "../../lib/format";
33
+ import {
34
+ buildOrderPatch,
35
+ canEditLines,
36
+ customerName,
37
+ isBlankAddress,
38
+ mergePricedLines,
39
+ pricedPatchOf,
40
+ } from "../../lib/order-utils";
41
+ import PageHeader from "../../components/PageHeader";
42
+ import StatusBadge from "../../components/StatusBadge";
43
+ import AddressForm from "../../components/AddressForm";
44
+ import SearchSelect from "../../components/SearchSelect";
45
+ import ConfirmDialog from "../../components/ConfirmDialog";
46
+ import MetaDataEditor from "../../components/MetaDataEditor";
47
+ import LineItemsTable from "./components/LineItemsTable";
48
+ import AddProductDialog from "./components/AddProductDialog";
49
+ import TotalsBox from "./components/TotalsBox";
50
+ import RefundPanel from "./components/RefundPanel";
51
+ import OrderNotesPanel from "./components/OrderNotesPanel";
52
+ import PaymentPanel from "./components/PaymentPanel";
53
+ import DownloadPermissionsPanel from "./components/DownloadPermissionsPanel";
54
+
55
+ /** Keys of the order that this editor patches. */
56
+ const DRAFT_KEYS = ["status", "customer_id", "billing", "shipping", "line_items", "fee_lines", "shipping_lines", "meta_data"];
57
+
58
+ const draftFrom = (order) =>
59
+ DRAFT_KEYS.reduce((d, k) => {
60
+ d[k] = order?.[k] ?? (k.endsWith("_lines") || k === "line_items" || k === "meta_data" ? [] : k === "billing" || k === "shipping" ? {} : "");
61
+ return d;
62
+ }, {});
63
+
64
+ const uid = () => (crypto.randomUUID ? crypto.randomUUID() : `line_${Math.random().toString(36).slice(2)}`);
65
+
66
+ /** Copy a Customer's saved address onto the order's address shape. */
67
+ const addressFromCustomer = (address, { email } = {}) => {
68
+ const next = { ...(address || {}) };
69
+ if (email !== undefined) next.email = email || next.email || "";
70
+ return next;
71
+ };
72
+
73
+ export default function OrderEditor() {
74
+ const { id } = useParams();
75
+ const href = useAdminHref();
76
+ const navigate = useNavigate();
77
+
78
+ // `/orders/new` → create a draft server-side, then swap to the real id.
79
+ useEffect(() => {
80
+ if (id) return;
81
+ let cancelled = false;
82
+ call("admin-orders", "create-draft").then((data) => {
83
+ const newId = data?.id || data?.order_id || data?.order?.id;
84
+ if (!cancelled && newId) navigate(href(`orders/${newId}`), { replace: true });
85
+ });
86
+ return () => {
87
+ cancelled = true;
88
+ };
89
+ // eslint-disable-next-line -- deps intentionally partial
90
+ }, [id]);
91
+
92
+ const order = useAsync(() => (id ? base44.entities["commerce.Order"].get(id) : Promise.resolve(null)), [id]);
93
+ const notes = useAsync(
94
+ () => (id ? base44.entities["commerce.OrderNote"].filter({ order_id: id }, "-created_date", 100) : Promise.resolve([])),
95
+ [id]
96
+ );
97
+ const refunds = useAsync(
98
+ () => (id ? base44.entities["commerce.OrderRefund"].filter({ order_id: id }, "-created_date", 100) : Promise.resolve([])),
99
+ [id]
100
+ );
101
+ const permissions = useAsync(
102
+ () => (id ? base44.entities["commerce.DownloadPermission"].filter({ order_id: id }, "-created_date", 100) : Promise.resolve([])),
103
+ [id]
104
+ );
105
+
106
+ const [draft, setDraft] = useState(null);
107
+ const [customerOpt, setCustomerOpt] = useState(null);
108
+ const [fillingAddresses, setFillingAddresses] = useState(false);
109
+ const [savingCustomer, setSavingCustomer] = useState(false);
110
+ const [editBilling, setEditBilling] = useState(false);
111
+ const [editShipping, setEditShipping] = useState(false);
112
+ const [showRefund, setShowRefund] = useState(false);
113
+ const [addProductOpen, setAddProductOpen] = useState(false);
114
+ const [feeDialog, setFeeDialog] = useState(false);
115
+ const [shippingDialog, setShippingDialog] = useState(false);
116
+ const [couponDialog, setCouponDialog] = useState(false);
117
+ const [deleteOpen, setDeleteOpen] = useState(false);
118
+ const [saving, setSaving] = useState(false);
119
+ const [working, setWorking] = useState(""); // recalculate | coupon | invoice | delete
120
+
121
+ // (Re)initialize the local draft whenever the order loads.
122
+ useEffect(() => {
123
+ if (!order.data) return;
124
+ setDraft(draftFrom(order.data));
125
+ setCustomerOpt(
126
+ order.data.customer_id
127
+ ? { value: order.data.customer_id, label: customerName(order.data) }
128
+ : null
129
+ );
130
+ setEditBilling(false);
131
+ setEditShipping(false);
132
+ }, [order.data]);
133
+
134
+ const dirty = useMemo(() => {
135
+ if (!order.data || !draft) return false;
136
+ return JSON.stringify(draft) !== JSON.stringify(draftFrom(order.data));
137
+ }, [draft, order.data]);
138
+
139
+ const patchDraft = (patch) => setDraft((d) => ({ ...d, ...patch }));
140
+
141
+ // Contact details typed onto the order itself. With no customer linked this is
142
+ // a guest order, and these two drive the offer to keep the details as a
143
+ // customer record (the email is required — it's the Customer entity's key).
144
+ const billingEmail = String(draft?.billing?.email || "").trim();
145
+ const hasBillingDetails = !isBlankAddress(draft?.billing);
146
+
147
+ // ── live totals for unsaved edits ────────────────────────────────────────
148
+ // Adding a product (or changing a qty, fee, shipping line or address) has to
149
+ // show up in the totals immediately. Tax, coupon and shipping math lives in
150
+ // the backend totals engine, so instead of approximating it here we ask the
151
+ // server to price the *pending* patch read-only (`preview`) and display that.
152
+ const [priceView, setPriceView] = useState(null); // priced Order fields | null
153
+ const [previewing, setPreviewing] = useState(false);
154
+ const [previewError, setPreviewError] = useState("");
155
+
156
+ // Only the keys the server reprices from, so editing the status, the customer
157
+ // or a custom field doesn't trigger a pointless round trip. These are the same
158
+ // values `save` would send, so the preview matches the saved result exactly.
159
+ const pricedPatch = useMemo(() => {
160
+ if (!order.data || !draft) return null;
161
+ const priced = pricedPatchOf(buildOrderPatch(draft, draftFrom(order.data)));
162
+ return Object.keys(priced).length ? priced : null;
163
+ }, [draft, order.data]);
164
+
165
+ // Serialized so the debounce keys off content, not object identity.
166
+ const pricedKey = useMemo(() => (pricedPatch ? JSON.stringify(pricedPatch) : ""), [pricedPatch]);
167
+ const debouncedKey = useDebounce(pricedKey, 400);
168
+
169
+ useEffect(() => {
170
+ if (!id || !debouncedKey) {
171
+ setPriceView(null);
172
+ setPreviewError("");
173
+ return;
174
+ }
175
+ let cancelled = false;
176
+ setPreviewing(true);
177
+ call("admin-orders", "preview", { order_id: id, patch: JSON.parse(debouncedKey) }, { silent: true })
178
+ .then((data) => {
179
+ if (cancelled) return;
180
+ setPriceView(data?.fields || null);
181
+ setPreviewError("");
182
+ })
183
+ .catch((err) => {
184
+ if (cancelled) return;
185
+ setPriceView(null);
186
+ setPreviewError(err.message || "Could not price these changes");
187
+ })
188
+ .finally(() => !cancelled && setPreviewing(false));
189
+ return () => {
190
+ cancelled = true;
191
+ };
192
+ }, [id, debouncedKey]);
193
+
194
+ /** True while the shown totals are older than the current edits. */
195
+ const previewStale = Boolean(pricedKey) && (previewing || pricedKey !== debouncedKey);
196
+
197
+ const refetchAll = () => {
198
+ order.refetch();
199
+ notes.refetch();
200
+ refunds.refetch();
201
+ permissions.refetch();
202
+ };
203
+
204
+ /** Write the pending draft. Returns true when something was sent. */
205
+ const persist = async () => {
206
+ const patch = buildOrderPatch(draft, draftFrom(order.data));
207
+ if (Object.keys(patch).length === 0) return false;
208
+ await call("admin-orders", "update", { order_id: id, patch });
209
+ return true;
210
+ };
211
+
212
+ const save = async () => {
213
+ if (!dirty) return;
214
+ setSaving(true);
215
+ try {
216
+ if (await persist()) toast.success("Order saved");
217
+ refetchAll();
218
+ } catch {
219
+ /* toast handled by call() */
220
+ } finally {
221
+ setSaving(false);
222
+ }
223
+ };
224
+
225
+ /**
226
+ * Run a server-side order operation.
227
+ *
228
+ * `flush` first writes any unsaved draft edits: these operations reprice the
229
+ * *persisted* order and end in a refetch that resets the draft, so without it
230
+ * they would either be dead ends (disabled while dirty) or silently discard
231
+ * the admin's pending changes.
232
+ */
233
+ const serverOp = async (label, fn, { flush = false } = {}) => {
234
+ setWorking(label);
235
+ try {
236
+ if (flush && dirty) await persist();
237
+ await fn();
238
+ refetchAll();
239
+ } catch {
240
+ /* toast handled by call() */
241
+ } finally {
242
+ setWorking("");
243
+ }
244
+ };
245
+
246
+ const recalculate = () =>
247
+ serverOp(
248
+ "recalculate",
249
+ async () => {
250
+ await call("admin-orders", "recalculate", { order_id: id });
251
+ toast.success(dirty ? "Order saved and totals recalculated" : "Totals recalculated");
252
+ },
253
+ { flush: true }
254
+ );
255
+
256
+ const sendInvoice = () =>
257
+ serverOp("invoice", async () => {
258
+ await call("admin-orders", "send-email", { order_id: id, type: "customer_invoice" });
259
+ toast.success("Order details sent to customer");
260
+ });
261
+
262
+ const deleteOrder = () =>
263
+ serverOp("delete", async () => {
264
+ await call("admin-orders", "delete", { order_id: id });
265
+ toast.success("Order deleted");
266
+ navigate(href("orders"));
267
+ });
268
+
269
+ const searchCustomers = useCallback(async (q) => {
270
+ const data = await call("admin-customers", "search", { q, limit: 20 }, { silent: true });
271
+ const rows = data?.rows || data || [];
272
+ return rows.map((c) => ({
273
+ value: c.id,
274
+ label: [c.first_name, c.last_name].filter(Boolean).join(" ") || c.email,
275
+ meta: [c.email, c.orders_count ? `${c.orders_count} order(s)` : null].filter(Boolean).join(" · "),
276
+ record: c,
277
+ }));
278
+ }, []);
279
+
280
+ /**
281
+ * Copy a customer's saved billing/shipping onto the order.
282
+ * `overwrite: false` (picking a customer) only fills addresses that are still
283
+ * empty, so it can never wipe details the admin already typed; the explicit
284
+ * "Use customer's addresses" button passes `overwrite: true`.
285
+ */
286
+ const applyCustomerAddresses = async (customer, { overwrite = false } = {}) => {
287
+ const record = customer;
288
+ if (!record) return { filled: [], skipped: [] };
289
+
290
+ const filled = [];
291
+ const skipped = [];
292
+ const patch = {};
293
+
294
+ const billing = addressFromCustomer(record.billing, { email: record.email });
295
+ if (!isBlankAddress(billing)) {
296
+ if (overwrite || isBlankAddress(draft.billing)) {
297
+ patch.billing = billing;
298
+ filled.push("billing");
299
+ } else {
300
+ skipped.push("billing");
301
+ }
302
+ }
303
+
304
+ const shipping = addressFromCustomer(record.shipping);
305
+ const shippingSource = isBlankAddress(shipping) ? { ...billing, email: undefined } : shipping;
306
+ if (!isBlankAddress(shippingSource)) {
307
+ if (overwrite || isBlankAddress(draft.shipping)) {
308
+ patch.shipping = shippingSource;
309
+ filled.push("shipping");
310
+ } else {
311
+ skipped.push("shipping");
312
+ }
313
+ }
314
+
315
+ if (Object.keys(patch).length) patchDraft(patch);
316
+ return { filled, skipped };
317
+ };
318
+
319
+ const pickCustomer = async (opt) => {
320
+ setCustomerOpt(opt);
321
+ patchDraft({ customer_id: opt?.value || "" });
322
+ if (!opt) return;
323
+
324
+ const record = opt.record ?? (await base44.entities["commerce.Customer"].get(opt.value).catch(() => null));
325
+ const { filled, skipped } = await applyCustomerAddresses(record);
326
+ if (filled.length) {
327
+ toast.success(`Filled ${filled.join(" and ")} from ${opt.label}`);
328
+ } else if (skipped.length) {
329
+ toast.info("Kept the addresses already on this order — use “Use customer's addresses” to replace them.");
330
+ } else {
331
+ toast.info(`${opt.label} has no saved address — enter the details below.`);
332
+ setEditBilling(true);
333
+ }
334
+ };
335
+
336
+ /** Explicit overwrite from the currently linked customer. */
337
+ const refillFromCustomer = async () => {
338
+ if (!draft.customer_id) return;
339
+ setFillingAddresses(true);
340
+ try {
341
+ const record =
342
+ customerOpt?.record ?? (await base44.entities["commerce.Customer"].get(draft.customer_id).catch(() => null));
343
+ if (!record) {
344
+ toast.error("Could not load that customer record");
345
+ return;
346
+ }
347
+ const { filled } = await applyCustomerAddresses(record, { overwrite: true });
348
+ if (filled.length) toast.success(`Replaced ${filled.join(" and ")} from the customer record`);
349
+ else toast.info("That customer has no saved address");
350
+ } finally {
351
+ setFillingAddresses(false);
352
+ }
353
+ };
354
+
355
+ /**
356
+ * Turn the details typed on this order into a customer record and link it.
357
+ *
358
+ * An order with no customer is a guest order; once the admin has filled in the
359
+ * billing details there's a whole contact sitting there, so offer to keep it.
360
+ * Reuses an existing record when that email is already a customer (the email
361
+ * is unique — creating would fail with `duplicate_email`), otherwise creates
362
+ * one. The link itself lands in the draft, so it's saved with the order.
363
+ */
364
+ const saveAsCustomer = async () => {
365
+ const email = billingEmail;
366
+ if (!email) return;
367
+ setSavingCustomer(true);
368
+ try {
369
+ const found = await call("admin-customers", "search", { q: email, limit: 5 }, { silent: true }).catch(() => null);
370
+ const rows = found?.rows || found || [];
371
+ const existing = rows.find((c) => String(c.email || "").toLowerCase() === email.toLowerCase());
372
+
373
+ const record =
374
+ existing ??
375
+ (await call("admin-customers", "save", {
376
+ customer: {
377
+ email,
378
+ first_name: draft.billing?.first_name || "",
379
+ last_name: draft.billing?.last_name || "",
380
+ billing: { ...(draft.billing || {}), email },
381
+ shipping: isBlankAddress(draft.shipping)
382
+ ? { ...(draft.billing || {}), email: undefined }
383
+ : { ...draft.shipping },
384
+ },
385
+ }));
386
+
387
+ if (!record?.id) return;
388
+ const label = [record.first_name, record.last_name].filter(Boolean).join(" ") || record.email;
389
+ setCustomerOpt({ value: record.id, label, meta: record.email, record });
390
+ patchDraft({ customer_id: record.id });
391
+ toast.success(
392
+ existing
393
+ ? `${label} is already a customer — linked. Save the order to keep the link.`
394
+ : `Customer ${label} created. Save the order to keep the link.`
395
+ );
396
+ } catch {
397
+ /* toast handled by call() */
398
+ } finally {
399
+ setSavingCustomer(false);
400
+ }
401
+ };
402
+
403
+ const addProductLine = ({ product, variation, quantity }) => {
404
+ const price = variation?.price ?? variation?.regular_price ?? product.price ?? product.regular_price ?? 0;
405
+ const line = {
406
+ line_id: uid(),
407
+ product_id: product.id,
408
+ variation_id: variation?.id || "",
409
+ name: product.name,
410
+ sku: variation?.sku || product.sku || "",
411
+ quantity,
412
+ price,
413
+ tax_class: variation?.tax_class || product.tax_class || "standard",
414
+ subtotal: price * quantity,
415
+ subtotal_tax: 0,
416
+ total: price * quantity,
417
+ total_tax: 0,
418
+ taxes: [],
419
+ attributes: (variation?.attributes || []).map((a) => ({ name: a.name, option: a.option })),
420
+ meta_data: [],
421
+ };
422
+ patchDraft({ line_items: [...(draft?.line_items || []), line] });
423
+ };
424
+
425
+ if (!id || order.loading || !draft) {
426
+ return (
427
+ <div className="space-y-4">
428
+ <Skeleton className="h-8 w-64" />
429
+ <Skeleton className="h-64 w-full" />
430
+ </div>
431
+ );
432
+ }
433
+ if (!order.data) {
434
+ return <p className="py-12 text-center text-sm text-muted-foreground">Order not found.</p>;
435
+ }
436
+
437
+ const o = order.data;
438
+ const editable = canEditLines(o);
439
+
440
+ // Server-priced view of the pending edits (falls back to the persisted order
441
+ // when there is nothing pending, or while a preview is in flight/failed).
442
+ const totalsView = priceView ? { ...o, ...priceView } : o;
443
+
444
+ // Draft lines carry only local price × qty math; a preview's per-line tax and
445
+ // discount figures are folded in so the table agrees with the totals box.
446
+ const viewLines = mergePricedLines(draft.line_items, priceView?.line_items);
447
+
448
+ // draft lines/addresses over server totals. Fee and shipping lines stay as the
449
+ // draft has them (they are edited in place); only the coupon chips come from
450
+ // the preview, since coupons are applied server-side and their discount
451
+ // changes with the items.
452
+ const view = {
453
+ ...o,
454
+ ...draft,
455
+ line_items: viewLines,
456
+ coupon_lines: priceView?.coupon_lines ?? o.coupon_lines,
457
+ };
458
+
459
+ return (
460
+ <div>
461
+ <PageHeader
462
+ title={
463
+ <span className="flex items-center gap-3">
464
+ Order #{o.order_number || o.id}
465
+ <StatusBadge status={o.status} />
466
+ </span>
467
+ }
468
+ description={`Placed ${formatDateTime(o.created_date)} via ${o.created_via || "checkout"}${
469
+ o.payment_method_title ? ` · ${o.payment_method_title}` : ""
470
+ }`}
471
+ backHref={href("orders")}
472
+ />
473
+
474
+ <div className="grid grid-cols-1 gap-6 xl:grid-cols-3">
475
+ {/* ── Main column ─────────────────────────────────────────────── */}
476
+ <div className="space-y-6 xl:col-span-2">
477
+ {/* General */}
478
+ <Card>
479
+ <CardHeader className="pb-3">
480
+ <CardTitle className="text-base">General</CardTitle>
481
+ </CardHeader>
482
+ {/* items-start: without it the shorter Status cell stretches to the
483
+ row height set by the Customer cell's helper text, and its
484
+ dropdown drifts below the Customer one. */}
485
+ <CardContent className="grid grid-cols-1 items-start gap-4 sm:grid-cols-2">
486
+ <div className="grid gap-1.5">
487
+ <Label className="text-xs text-muted-foreground">Status</Label>
488
+ <Select value={draft.status} onValueChange={(v) => patchDraft({ status: v })}>
489
+ <SelectTrigger>
490
+ <SelectValue />
491
+ </SelectTrigger>
492
+ <SelectContent>
493
+ {ORDER_STATUSES.map((s) => (
494
+ <SelectItem key={s.value} value={s.value}>
495
+ {s.label}
496
+ </SelectItem>
497
+ ))}
498
+ </SelectContent>
499
+ </Select>
500
+ </div>
501
+ <div className="grid gap-1.5">
502
+ <Label className="text-xs text-muted-foreground">Customer</Label>
503
+ <SearchSelect
504
+ search={searchCustomers}
505
+ value={customerOpt}
506
+ onChange={pickCustomer}
507
+ placeholder="Guest — search customers…"
508
+ />
509
+
510
+ {draft.customer_id ? (
511
+ <div className="flex flex-wrap items-center gap-1">
512
+ <Button
513
+ variant="ghost"
514
+ size="sm"
515
+ className="h-7 px-2 text-xs"
516
+ disabled={fillingAddresses}
517
+ onClick={refillFromCustomer}
518
+ title="Replace the order's billing and shipping with the customer's saved addresses"
519
+ >
520
+ {fillingAddresses ? (
521
+ <Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
522
+ ) : (
523
+ <RotateCw className="mr-1 h-3.5 w-3.5" />
524
+ )}
525
+ Use customer's addresses
526
+ </Button>
527
+ <Button variant="ghost" size="sm" className="h-7 px-2 text-xs" asChild>
528
+ <Link to={href(`customers/${draft.customer_id}`)}>
529
+ <ExternalLink className="mr-1 h-3.5 w-3.5" /> Open customer
530
+ </Link>
531
+ </Button>
532
+ </div>
533
+ ) : hasBillingDetails ? (
534
+ // Guest order with details already typed in — offer to keep them.
535
+ <div className="flex flex-wrap items-center gap-1">
536
+ <Button
537
+ variant="ghost"
538
+ size="sm"
539
+ className="h-7 px-2 text-xs"
540
+ disabled={savingCustomer || !billingEmail}
541
+ onClick={saveAsCustomer}
542
+ title={
543
+ billingEmail
544
+ ? "Create a customer from the billing details below and link this order to it"
545
+ : "Add a billing email below to save these details as a customer"
546
+ }
547
+ >
548
+ {savingCustomer ? (
549
+ <Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
550
+ ) : (
551
+ <UserPlus className="mr-1 h-3.5 w-3.5" />
552
+ )}
553
+ Save as customer
554
+ </Button>
555
+ <span className="text-xs text-muted-foreground">
556
+ {billingEmail ? "Guest order" : "Guest order — needs a billing email"}
557
+ </span>
558
+ </div>
559
+ ) : (
560
+ <p className="text-xs text-muted-foreground">
561
+ Guest order. Pick a customer to fill the addresses from their record, or enter the details
562
+ below — the billing email is where this order's emails go.
563
+ </p>
564
+ )}
565
+ </div>
566
+ </CardContent>
567
+ </Card>
568
+
569
+ {/* Addresses */}
570
+ <div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
571
+ {[
572
+ { key: "billing", title: "Billing", edit: editBilling, setEdit: setEditBilling, showEmail: true },
573
+ { key: "shipping", title: "Shipping", edit: editShipping, setEdit: setEditShipping, showEmail: false },
574
+ ].map(({ key, title, edit, setEdit, showEmail }) => (
575
+ <Card key={key}>
576
+ <CardHeader className="flex-row items-center justify-between space-y-0 pb-3">
577
+ <CardTitle className="text-base">{title}</CardTitle>
578
+ <div className="flex gap-1">
579
+ {key === "shipping" && edit && (
580
+ <Button
581
+ variant="ghost"
582
+ size="sm"
583
+ onClick={() => patchDraft({ shipping: { ...draft.billing, email: undefined } })}
584
+ title="Copy from billing"
585
+ >
586
+ <Copy className="mr-1 h-3.5 w-3.5" /> From billing
587
+ </Button>
588
+ )}
589
+ <Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => setEdit(!edit)}>
590
+ <Pencil className="h-3.5 w-3.5" />
591
+ </Button>
592
+ </div>
593
+ </CardHeader>
594
+ <CardContent>
595
+ {edit ? (
596
+ <AddressForm
597
+ value={draft[key]}
598
+ onChange={(v) => patchDraft({ [key]: v })}
599
+ showEmail={showEmail}
600
+ />
601
+ ) : (
602
+ <AddressDisplay address={draft[key]} />
603
+ )}
604
+ </CardContent>
605
+ </Card>
606
+ ))}
607
+ </div>
608
+
609
+ {/* Items */}
610
+ <Card>
611
+ <CardHeader className="pb-3">
612
+ <CardTitle className="text-base">Items</CardTitle>
613
+ </CardHeader>
614
+ <CardContent className="p-0">
615
+ <LineItemsTable
616
+ order={view}
617
+ editable={editable}
618
+ onChange={patchDraft}
619
+ removingCoupon={working === "coupon" ? "*" : ""}
620
+ onRemoveCoupon={(code) =>
621
+ serverOp(
622
+ "coupon",
623
+ async () => {
624
+ await call("admin-orders", "remove-coupon", { order_id: id, code });
625
+ toast.success(`Coupon ${code} removed`);
626
+ },
627
+ { flush: true }
628
+ )
629
+ }
630
+ />
631
+ {editable && (
632
+ <div className="flex flex-wrap gap-2 border-t p-3">
633
+ <Button variant="outline" size="sm" onClick={() => setAddProductOpen(true)}>
634
+ <Plus className="mr-1 h-3.5 w-3.5" /> Add product(s)
635
+ </Button>
636
+ <Button variant="outline" size="sm" onClick={() => setFeeDialog(true)}>
637
+ <Plus className="mr-1 h-3.5 w-3.5" /> Add fee
638
+ </Button>
639
+ <Button variant="outline" size="sm" onClick={() => setShippingDialog(true)}>
640
+ <Truck className="mr-1 h-3.5 w-3.5" /> Add shipping
641
+ </Button>
642
+ <Button
643
+ variant="outline"
644
+ size="sm"
645
+ disabled={Boolean(working)}
646
+ title={dirty ? "Saves your pending changes, then applies the coupon" : undefined}
647
+ onClick={() => setCouponDialog(true)}
648
+ >
649
+ <Ticket className="mr-1 h-3.5 w-3.5" /> Apply coupon
650
+ </Button>
651
+ <Button
652
+ variant="outline"
653
+ size="sm"
654
+ className="ml-auto"
655
+ disabled={Boolean(working) || saving}
656
+ title={
657
+ dirty
658
+ ? "Saves your pending changes, then re-runs tax and totals against the current settings"
659
+ : "Re-run tax and totals against the current settings"
660
+ }
661
+ onClick={recalculate}
662
+ >
663
+ {working === "recalculate" ? (
664
+ <Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
665
+ ) : (
666
+ <RotateCw className="mr-1 h-3.5 w-3.5" />
667
+ )}
668
+ {dirty ? "Save & recalculate" : "Recalculate"}
669
+ </Button>
670
+ </div>
671
+ )}
672
+ <div className="border-t p-4">
673
+ {pricedPatch && (
674
+ <div className="mb-2 flex items-center justify-end gap-1.5 text-xs text-muted-foreground">
675
+ {previewStale ? (
676
+ <>
677
+ <Loader2 className="h-3 w-3 animate-spin" /> Pricing your changes…
678
+ </>
679
+ ) : previewError ? (
680
+ <span className="text-destructive">
681
+ Could not price these changes: {previewError}
682
+ </span>
683
+ ) : (
684
+ <Badge variant="secondary" className="font-normal">
685
+ Unsaved — totals below include your changes
686
+ </Badge>
687
+ )}
688
+ </div>
689
+ )}
690
+ <TotalsBox order={totalsView} />
691
+ <div className="mt-3 flex justify-end">
692
+ <Button variant="outline" size="sm" onClick={() => setShowRefund((v) => !v)}>
693
+ {showRefund ? "Close refund" : "Refund"}
694
+ </Button>
695
+ </div>
696
+ </div>
697
+ </CardContent>
698
+ </Card>
699
+
700
+ {showRefund && (
701
+ <RefundPanel
702
+ order={o}
703
+ refunds={refunds.data || []}
704
+ onDone={() => {
705
+ setShowRefund(false);
706
+ refetchAll();
707
+ }}
708
+ />
709
+ )}
710
+
711
+ <DownloadPermissionsPanel
712
+ orderId={id}
713
+ permissions={permissions.data || []}
714
+ loading={permissions.loading}
715
+ onChanged={() => permissions.refetch()}
716
+ />
717
+
718
+ {/* Custom fields */}
719
+ <Card>
720
+ <CardHeader className="pb-3">
721
+ <CardTitle className="text-base">Custom fields</CardTitle>
722
+ </CardHeader>
723
+ <CardContent>
724
+ <MetaDataEditor value={draft.meta_data || []} onChange={(v) => patchDraft({ meta_data: v })} />
725
+ </CardContent>
726
+ </Card>
727
+ </div>
728
+
729
+ {/* ── Sidebar ─────────────────────────────────────────────────── */}
730
+ <div className="space-y-6">
731
+ <Card>
732
+ <CardHeader className="pb-3">
733
+ <CardTitle className="text-base">Order actions</CardTitle>
734
+ </CardHeader>
735
+ <CardContent className="space-y-2">
736
+ <Button className="w-full" onClick={save} disabled={!dirty || saving}>
737
+ {saving && <Loader2 className="mr-1.5 h-4 w-4 animate-spin" />}
738
+ Save order
739
+ </Button>
740
+ <Button
741
+ variant="outline"
742
+ className="w-full"
743
+ onClick={sendInvoice}
744
+ disabled={working === "invoice"}
745
+ >
746
+ {working === "invoice" ? (
747
+ <Loader2 className="mr-1.5 h-4 w-4 animate-spin" />
748
+ ) : (
749
+ <Mail className="mr-1.5 h-4 w-4" />
750
+ )}
751
+ Send order details to customer
752
+ </Button>
753
+ <Separator />
754
+ <Button
755
+ variant="ghost"
756
+ className="w-full text-destructive hover:text-destructive"
757
+ onClick={() => setDeleteOpen(true)}
758
+ >
759
+ <Trash2 className="mr-1.5 h-4 w-4" /> Delete order
760
+ </Button>
761
+ </CardContent>
762
+ </Card>
763
+
764
+ <PaymentPanel order={o} onChanged={refetchAll} />
765
+
766
+ {o.customer_note && (
767
+ <Card>
768
+ <CardHeader className="pb-3">
769
+ <CardTitle className="text-base">Customer provided note</CardTitle>
770
+ </CardHeader>
771
+ <CardContent>
772
+ <p className="whitespace-pre-wrap rounded-md border border-blue-200 bg-blue-50 p-2.5 text-sm">
773
+ {o.customer_note}
774
+ </p>
775
+ </CardContent>
776
+ </Card>
777
+ )}
778
+
779
+ <OrderNotesPanel
780
+ orderId={id}
781
+ notes={notes.data || []}
782
+ loading={notes.loading}
783
+ onChanged={() => notes.refetch()}
784
+ />
785
+ </div>
786
+ </div>
787
+
788
+ {/* ── Dialogs ───────────────────────────────────────────────────── */}
789
+ <AddProductDialog open={addProductOpen} onOpenChange={setAddProductOpen} onAdd={addProductLine} />
790
+
791
+ <NameAmountDialog
792
+ open={feeDialog}
793
+ onOpenChange={setFeeDialog}
794
+ title="Add fee"
795
+ nameLabel="Fee name"
796
+ onSubmit={({ name, amount }) =>
797
+ patchDraft({
798
+ fee_lines: [
799
+ ...(draft.fee_lines || []),
800
+ { line_id: uid(), name, total: amount, total_tax: 0, tax_status: "taxable", tax_class: "standard" },
801
+ ],
802
+ })
803
+ }
804
+ />
805
+
806
+ <NameAmountDialog
807
+ open={shippingDialog}
808
+ onOpenChange={setShippingDialog}
809
+ title="Add shipping"
810
+ nameLabel="Method title"
811
+ onSubmit={({ name, amount }) =>
812
+ patchDraft({
813
+ shipping_lines: [
814
+ ...(draft.shipping_lines || []),
815
+ { line_id: uid(), method_id: "flat_rate", instance_id: "", method_title: name, total: amount, total_tax: 0, taxes: [] },
816
+ ],
817
+ })
818
+ }
819
+ />
820
+
821
+ <CouponDialog
822
+ open={couponDialog}
823
+ onOpenChange={setCouponDialog}
824
+ onSubmit={(code) =>
825
+ serverOp(
826
+ "coupon",
827
+ async () => {
828
+ await call("admin-orders", "apply-coupon", { order_id: id, code });
829
+ toast.success(`Coupon ${code} applied`);
830
+ },
831
+ { flush: true }
832
+ )
833
+ }
834
+ />
835
+
836
+ <ConfirmDialog
837
+ open={deleteOpen}
838
+ onOpenChange={setDeleteOpen}
839
+ title={`Delete order #${o.order_number || o.id}?`}
840
+ description="Only pending, cancelled or failed orders can be deleted. This cannot be undone."
841
+ confirmLabel="Delete order"
842
+ loading={working === "delete"}
843
+ onConfirm={deleteOrder}
844
+ />
845
+ </div>
846
+ );
847
+ }
848
+
849
+ /* ── Small local pieces ─────────────────────────────────────────────── */
850
+
851
+ function AddressDisplay({ address = {} }) {
852
+ const rows = [
853
+ [address.first_name, address.last_name].filter(Boolean).join(" "),
854
+ address.company,
855
+ address.address_1,
856
+ address.address_2,
857
+ [address.city, address.state, address.postcode].filter(Boolean).join(", "),
858
+ address.country,
859
+ address.email,
860
+ address.phone,
861
+ ].filter(Boolean);
862
+ if (rows.length === 0) return <p className="text-sm text-muted-foreground">No address set.</p>;
863
+ return (
864
+ <address className="text-sm not-italic leading-6 text-muted-foreground">
865
+ {rows.map((r, i) => (
866
+ <div key={i}>{r}</div>
867
+ ))}
868
+ </address>
869
+ );
870
+ }
871
+
872
+ function NameAmountDialog({ open, onOpenChange, title, nameLabel, onSubmit }) {
873
+ const [name, setName] = useState("");
874
+ const [amount, setAmount] = useState(0);
875
+
876
+ useEffect(() => {
877
+ if (!open) {
878
+ setName("");
879
+ setAmount(0);
880
+ }
881
+ }, [open]);
882
+
883
+ return (
884
+ <Dialog open={open} onOpenChange={onOpenChange}>
885
+ <DialogContent className="sm:max-w-sm">
886
+ <DialogHeader>
887
+ <DialogTitle>{title}</DialogTitle>
888
+ </DialogHeader>
889
+ <div className="space-y-3">
890
+ <div className="grid gap-1.5">
891
+ <Label>{nameLabel}</Label>
892
+ <Input value={name} onChange={(e) => setName(e.target.value)} />
893
+ </div>
894
+ <div className="grid gap-1.5">
895
+ <Label>Amount</Label>
896
+ <Input type="number" step="any" value={amount} onChange={(e) => setAmount(Number(e.target.value) || 0)} />
897
+ </div>
898
+ </div>
899
+ <DialogFooter>
900
+ <Button variant="outline" onClick={() => onOpenChange(false)}>
901
+ Cancel
902
+ </Button>
903
+ <Button
904
+ disabled={!name.trim()}
905
+ onClick={() => {
906
+ onSubmit({ name: name.trim(), amount });
907
+ onOpenChange(false);
908
+ }}
909
+ >
910
+ Add
911
+ </Button>
912
+ </DialogFooter>
913
+ </DialogContent>
914
+ </Dialog>
915
+ );
916
+ }
917
+
918
+ function CouponDialog({ open, onOpenChange, onSubmit }) {
919
+ const [code, setCode] = useState("");
920
+
921
+ useEffect(() => {
922
+ if (!open) setCode("");
923
+ }, [open]);
924
+
925
+ return (
926
+ <Dialog open={open} onOpenChange={onOpenChange}>
927
+ <DialogContent className="sm:max-w-sm">
928
+ <DialogHeader>
929
+ <DialogTitle>Apply coupon</DialogTitle>
930
+ </DialogHeader>
931
+ <div className="grid gap-1.5">
932
+ <Label>Coupon code</Label>
933
+ <Input value={code} onChange={(e) => setCode(e.target.value)} placeholder="e.g. welcome10" />
934
+ </div>
935
+ <DialogFooter>
936
+ <Button variant="outline" onClick={() => onOpenChange(false)}>
937
+ Cancel
938
+ </Button>
939
+ <Button
940
+ disabled={!code.trim()}
941
+ onClick={() => {
942
+ onSubmit(code.trim().toLowerCase());
943
+ onOpenChange(false);
944
+ }}
945
+ >
946
+ Apply
947
+ </Button>
948
+ </DialogFooter>
949
+ </DialogContent>
950
+ </Dialog>
951
+ );
952
+ }