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