@base44/app-plugin-commerce 0.1.6 → 0.1.7

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 (95) hide show
  1. package/README.md +11 -11
  2. package/base44/agents/commerce/StoreAdmin.jsonc +2 -2
  3. package/base44/entities/commerce.Cart.jsonc +1 -1
  4. package/base44/entities/commerce.Coupon.jsonc +5 -0
  5. package/base44/entities/commerce.Order.jsonc +6 -7
  6. package/base44/entities/commerce.OrderRefund.jsonc +1 -1
  7. package/base44/entities/commerce.PaymentGateway.jsonc +1 -1
  8. package/base44/entities/commerce.Product.jsonc +6 -16
  9. package/base44/entities/{commerce.ProductTag.jsonc → commerce.ProductRibbon.jsonc} +2 -2
  10. package/base44/entities/commerce.ProductVariation.jsonc +1 -9
  11. package/base44/entities/commerce.ShippingTaxLocation.jsonc +85 -0
  12. package/base44/entities/commerce.Webhook.jsonc +1 -1
  13. package/base44/functions/commerce/admin-orders/helpers.ts +7 -13
  14. package/base44/functions/commerce/admin-products/entry.ts +11 -17
  15. package/base44/functions/commerce/admin-refunds/entry.ts +10 -9
  16. package/base44/functions/commerce/admin-reports/entry.ts +3 -3
  17. package/base44/functions/commerce/admin-tools/entry.ts +9 -36
  18. package/base44/functions/commerce/payment-webhook/entry.ts +50 -89
  19. package/base44/functions/commerce/payments/entry.ts +46 -42
  20. package/base44/functions/commerce/seed-store/defaults.ts +35 -42
  21. package/base44/functions/commerce/seed-store/entry.ts +68 -46
  22. package/base44/functions/commerce/seed-store/sample-data.ts +2 -15
  23. package/base44/functions/commerce/seed-store/seed-catalog.ts +105 -55
  24. package/base44/functions/commerce/storefront-cart/cart-pricing.ts +6 -11
  25. package/base44/functions/commerce/storefront-cart/entry.ts +36 -2
  26. package/base44/functions/commerce/storefront-catalog/entry.ts +55 -72
  27. package/base44/functions/commerce/storefront-checkout/cart-pricing.ts +6 -11
  28. package/base44/functions/commerce/storefront-checkout/entry.ts +43 -56
  29. package/base44/shared/commerce/card-payment.ts +80 -0
  30. package/base44/shared/commerce/coupons.ts +16 -10
  31. package/base44/shared/commerce/emails.ts +30 -18
  32. package/base44/shared/commerce/money.ts +11 -24
  33. package/base44/shared/commerce/payments.ts +55 -286
  34. package/base44/shared/commerce/scan.ts +1 -1
  35. package/base44/shared/commerce/sequence.ts +1 -1
  36. package/base44/shared/commerce/settings.ts +4 -9
  37. package/base44/shared/commerce/shipping.ts +65 -133
  38. package/base44/shared/commerce/tax.ts +48 -96
  39. package/base44/shared/commerce/totals.ts +77 -91
  40. package/package.json +1 -1
  41. package/scripts/install.js +28 -7
  42. package/skills/commerce/SKILL.md +14 -14
  43. package/skills/commerce/docs/api-admin.md +23 -26
  44. package/skills/commerce/docs/api-storefront.md +67 -61
  45. package/skills/commerce/installation-guidelines.md +8 -8
  46. package/skills/commerce/post-installation.md +60 -39
  47. package/skills/commerce/references/admin-product-form.md +15 -12
  48. package/skills/commerce/references/emails.md +2 -2
  49. package/skills/commerce/references/guest-access-security.md +2 -2
  50. package/skills/commerce/references/online-payments.md +24 -166
  51. package/skills/commerce/references/product-render.md +18 -18
  52. package/skills/commerce/references/reviews.md +14 -8
  53. package/skills/commerce/references/storefront-product-page.md +1 -1
  54. package/src/commerce/admin/README.md +4 -5
  55. package/src/commerce/admin/bot/Markdown.jsx +1 -1
  56. package/src/commerce/admin/hooks/useMoney.js +13 -22
  57. package/src/commerce/admin/layout/AuthGuard.jsx +1 -1
  58. package/src/commerce/admin/lib/constants.js +2 -29
  59. package/src/commerce/admin/lib/order-utils.js +1 -1
  60. package/src/commerce/admin/pages/coupons/CouponEditor.jsx +131 -140
  61. package/src/commerce/admin/pages/coupons/CouponsList.jsx +14 -7
  62. package/src/commerce/admin/pages/orders/OrderEditor.jsx +3 -3
  63. package/src/commerce/admin/pages/orders/components/PaymentPanel.jsx +17 -28
  64. package/src/commerce/admin/pages/orders/components/RefundPanel.jsx +5 -11
  65. package/src/commerce/admin/pages/products/Categories.jsx +147 -177
  66. package/src/commerce/admin/pages/products/ProductEditor.jsx +23 -18
  67. package/src/commerce/admin/pages/products/Reviews.jsx +37 -1
  68. package/src/commerce/admin/pages/products/components/ProductDataPanel.jsx +33 -47
  69. package/src/commerce/admin/pages/products/components/PublishBox.jsx +13 -34
  70. package/src/commerce/admin/pages/products/components/TaxonomyPanel.jsx +29 -29
  71. package/src/commerce/admin/pages/products/components/tabs/PriceInventoryTab.jsx +14 -44
  72. package/src/commerce/admin/pages/reports/Reports.jsx +2 -2
  73. package/src/commerce/admin/pages/settings/EmailsSettings.jsx +101 -68
  74. package/src/commerce/admin/pages/settings/GeneralSettings.jsx +40 -38
  75. package/src/commerce/admin/pages/settings/InventorySettings.jsx +1 -41
  76. package/src/commerce/admin/pages/settings/LocationEditor.jsx +377 -0
  77. package/src/commerce/admin/pages/settings/PaymentsSettings.jsx +137 -119
  78. package/src/commerce/admin/pages/settings/SettingsLayout.jsx +2 -4
  79. package/src/commerce/admin/pages/settings/ShippingTaxSettings.jsx +191 -0
  80. package/src/commerce/admin/routes.jsx +6 -12
  81. package/src/commerce/utils/index.js +2 -2
  82. package/src/commerce/utils/shipping-promos.js +45 -49
  83. package/src/commerce/utils/variants.js +1 -1
  84. package/base44/entities/commerce.ShippingClass.jsonc +0 -30
  85. package/base44/entities/commerce.ShippingZone.jsonc +0 -41
  86. package/base44/entities/commerce.ShippingZoneMethod.jsonc +0 -84
  87. package/base44/entities/commerce.TaxClass.jsonc +0 -23
  88. package/base44/entities/commerce.TaxRate.jsonc +0 -68
  89. package/base44/shared/commerce/stripe.ts +0 -463
  90. package/src/commerce/admin/hooks/usePaymentProvider.js +0 -27
  91. package/src/commerce/admin/pages/settings/ProductsSettings.jsx +0 -118
  92. package/src/commerce/admin/pages/settings/ShippingSettings.jsx +0 -304
  93. package/src/commerce/admin/pages/settings/ShippingZoneEditor.jsx +0 -514
  94. package/src/commerce/admin/pages/settings/TaxRatesTable.jsx +0 -231
  95. package/src/commerce/admin/pages/settings/TaxSettings.jsx +0 -280
@@ -9,16 +9,14 @@ import {
9
9
  SelectTrigger,
10
10
  SelectValue,
11
11
  } from "@/components/ui/select";
12
- import { CURRENCIES, CURRENCY_POSITIONS } from "../../lib/constants";
12
+ import { CURRENCIES, WEIGHT_UNITS, DIMENSION_UNITS } from "../../lib/constants";
13
13
  import useGroupForm from "./useGroupForm";
14
14
 
15
15
  const DEFAULTS = {
16
16
  order_received_path: "/order-received",
17
17
  currency: "USD",
18
- currency_position: "left",
19
- thousand_sep: ",",
20
- decimal_sep: ".",
21
- num_decimals: 2,
18
+ weight_unit: "kg",
19
+ dimension_unit: "cm",
22
20
  };
23
21
 
24
22
  export default function GeneralSettings() {
@@ -53,13 +51,16 @@ export default function GeneralSettings() {
53
51
  <Card>
54
52
  <CardHeader className="flex-row items-start justify-between gap-4 space-y-0">
55
53
  <div className="space-y-1.5">
56
- <CardTitle>Currency options</CardTitle>
57
- <CardDescription>Affects how prices are displayed in the admin and by storefront APIs.</CardDescription>
54
+ <CardTitle>Currency</CardTitle>
55
+ <CardDescription>
56
+ The store's currency. Prices are formatted per the viewer's locale
57
+ (Intl) — there is nothing else to configure.
58
+ </CardDescription>
58
59
  </div>
59
60
  <SaveButton />
60
61
  </CardHeader>
61
- <CardContent className="grid gap-4 sm:grid-cols-2">
62
- <div className="space-y-1.5">
62
+ <CardContent>
63
+ <div className="max-w-xs space-y-1.5">
63
64
  <Label>Currency</Label>
64
65
  <Select value={form.currency} onValueChange={(v) => setField("currency", v)}>
65
66
  <SelectTrigger>
@@ -74,46 +75,47 @@ export default function GeneralSettings() {
74
75
  </SelectContent>
75
76
  </Select>
76
77
  </div>
78
+ </CardContent>
79
+ </Card>
80
+
81
+ <Card>
82
+ <CardHeader className="flex-row items-start justify-between gap-4 space-y-0">
83
+ <div className="space-y-1.5">
84
+ <CardTitle>Measurements</CardTitle>
85
+ <CardDescription>Units used for product weight and dimensions.</CardDescription>
86
+ </div>
87
+ <SaveButton />
88
+ </CardHeader>
89
+ <CardContent className="grid gap-4 sm:grid-cols-2">
77
90
  <div className="space-y-1.5">
78
- <Label>Currency position</Label>
79
- <Select value={form.currency_position} onValueChange={(v) => setField("currency_position", v)}>
91
+ <Label>Weight unit</Label>
92
+ <Select value={form.weight_unit} onValueChange={(v) => setField("weight_unit", v)}>
80
93
  <SelectTrigger>
81
94
  <SelectValue />
82
95
  </SelectTrigger>
83
96
  <SelectContent>
84
- {CURRENCY_POSITIONS.map((p) => (
85
- <SelectItem key={p.value} value={p.value}>
86
- {p.label}
97
+ {WEIGHT_UNITS.map((u) => (
98
+ <SelectItem key={u} value={u}>
99
+ {u}
87
100
  </SelectItem>
88
101
  ))}
89
102
  </SelectContent>
90
103
  </Select>
91
104
  </div>
92
105
  <div className="space-y-1.5">
93
- <Label>Thousand separator</Label>
94
- <Input
95
- value={form.thousand_sep ?? ","}
96
- maxLength={1}
97
- onChange={(e) => setField("thousand_sep", e.target.value)}
98
- />
99
- </div>
100
- <div className="space-y-1.5">
101
- <Label>Decimal separator</Label>
102
- <Input
103
- value={form.decimal_sep ?? "."}
104
- maxLength={1}
105
- onChange={(e) => setField("decimal_sep", e.target.value)}
106
- />
107
- </div>
108
- <div className="space-y-1.5">
109
- <Label>Number of decimals</Label>
110
- <Input
111
- type="number"
112
- min={0}
113
- max={4}
114
- value={form.num_decimals ?? 2}
115
- onChange={(e) => setField("num_decimals", e.target.value === "" ? 2 : Number(e.target.value))}
116
- />
106
+ <Label>Dimensions unit</Label>
107
+ <Select value={form.dimension_unit} onValueChange={(v) => setField("dimension_unit", v)}>
108
+ <SelectTrigger>
109
+ <SelectValue />
110
+ </SelectTrigger>
111
+ <SelectContent>
112
+ {DIMENSION_UNITS.map((u) => (
113
+ <SelectItem key={u} value={u}>
114
+ {u}
115
+ </SelectItem>
116
+ ))}
117
+ </SelectContent>
118
+ </Select>
117
119
  </div>
118
120
  </CardContent>
119
121
  </Card>
@@ -10,9 +10,6 @@ const DEFAULTS = {
10
10
  hold_stock_minutes: 60,
11
11
  low_stock_threshold: 2,
12
12
  out_of_stock_threshold: 0,
13
- notify_low_stock: true,
14
- notify_out_of_stock: true,
15
- notification_recipient: "",
16
13
  hide_out_of_stock: false,
17
14
  };
18
15
 
@@ -54,7 +51,7 @@ export default function InventorySettings() {
54
51
  <p className="text-xs text-muted-foreground">
55
52
  Hold stock for unpaid orders for this many minutes. When the limit is reached, the
56
53
  pending order is cancelled and its stock released. Release runs opportunistically —
57
- see skills/commerce/references/scheduled-work.md.
54
+ see .agents/skills/commerce/references/scheduled-work.md.
58
55
  </p>
59
56
  </div>
60
57
 
@@ -82,43 +79,6 @@ export default function InventorySettings() {
82
79
  </CardContent>
83
80
  </Card>
84
81
 
85
- <Card>
86
- <CardHeader className="flex-row items-start justify-between gap-4 space-y-0">
87
- <div className="space-y-1.5">
88
- <CardTitle>Notifications</CardTitle>
89
- </div>
90
- <SaveButton />
91
- </CardHeader>
92
- <CardContent className="space-y-4">
93
- <div className="flex items-start justify-between gap-4">
94
- <Label className={off ? "opacity-50" : ""}>Enable low stock notifications</Label>
95
- <Switch
96
- disabled={off}
97
- checked={!!form.notify_low_stock}
98
- onCheckedChange={(v) => setField("notify_low_stock", v)}
99
- />
100
- </div>
101
- <div className="flex items-start justify-between gap-4">
102
- <Label className={off ? "opacity-50" : ""}>Enable out of stock notifications</Label>
103
- <Switch
104
- disabled={off}
105
- checked={!!form.notify_out_of_stock}
106
- onCheckedChange={(v) => setField("notify_out_of_stock", v)}
107
- />
108
- </div>
109
- <div className="space-y-1.5">
110
- <Label className={off ? "opacity-50" : ""}>Notification recipient</Label>
111
- <Input
112
- type="email"
113
- placeholder="Defaults to the admin recipients in Emails settings"
114
- disabled={off}
115
- value={form.notification_recipient || ""}
116
- onChange={(e) => setField("notification_recipient", e.target.value)}
117
- />
118
- </div>
119
- </CardContent>
120
- </Card>
121
-
122
82
  <Card>
123
83
  <CardHeader className="flex-row items-start justify-between gap-4 space-y-0">
124
84
  <div className="space-y-1.5">
@@ -0,0 +1,377 @@
1
+ import React, { useCallback, useEffect, useState } from "react";
2
+ import { useNavigate, useParams } from "react-router-dom";
3
+ import { Button } from "@/components/ui/button";
4
+ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
5
+ import { Input } from "@/components/ui/input";
6
+ import { Label } from "@/components/ui/label";
7
+ import {
8
+ Select,
9
+ SelectContent,
10
+ SelectItem,
11
+ SelectTrigger,
12
+ SelectValue,
13
+ } from "@/components/ui/select";
14
+ import { Loader2, Plus, Save, Trash2 } from "lucide-react";
15
+ import { toast } from "sonner";
16
+
17
+ import { base44 } from "../../lib/api";
18
+ import { useAdminHref } from "../../context/BasePathContext";
19
+ import PageHeader from "../../components/PageHeader";
20
+ import CountrySelect from "../../components/CountrySelect";
21
+ import MoneyInput from "../../components/MoneyInput";
22
+ import useMoney from "../../hooks/useMoney";
23
+ import { CONTINENTS } from "../../lib/geo-data";
24
+
25
+ const NEW_LOCATION = {
26
+ name: "",
27
+ order: 0,
28
+ regions: [],
29
+ shipping_rates: [],
30
+ tax_groups: [{ name: "Products", rates: [] }],
31
+ shipping_tax: null,
32
+ };
33
+
34
+ const num = (v) => (v === "" ? null : Number(v));
35
+
36
+ /**
37
+ * One Shipping & Tax Location: the regions it covers, the shipping rates
38
+ * offered there, the tax groups (default group "Products" — products pick a
39
+ * group by name) and the tax on shipping (% or a fixed amount).
40
+ */
41
+ export default function LocationEditor() {
42
+ const { locationId } = useParams();
43
+ const isNew = locationId === "new";
44
+ const navigate = useNavigate();
45
+ const href = useAdminHref();
46
+ const money = useMoney();
47
+
48
+ const [location, setLocation] = useState(null);
49
+ const [saving, setSaving] = useState(false);
50
+
51
+ const load = useCallback(async () => {
52
+ if (isNew) {
53
+ setLocation({ ...NEW_LOCATION, tax_groups: [{ name: "Products", rates: [] }] });
54
+ return;
55
+ }
56
+ const rec = await base44.entities["commerce.ShippingTaxLocation"].get(locationId);
57
+ setLocation({
58
+ ...NEW_LOCATION,
59
+ ...rec,
60
+ tax_groups: (rec.tax_groups || []).length ? rec.tax_groups : [{ name: "Products", rates: [] }],
61
+ });
62
+ }, [locationId, isNew]);
63
+
64
+ useEffect(() => {
65
+ load().catch((e) => toast.error(e.message || "Failed to load location"));
66
+ }, [load]);
67
+
68
+ const up = (patch) => setLocation((l) => ({ ...l, ...patch }));
69
+
70
+ const save = async () => {
71
+ if (!location.name?.trim()) {
72
+ toast.error("Location name is required");
73
+ return;
74
+ }
75
+ setSaving(true);
76
+ try {
77
+ const { id: _id, created_date: _cd, updated_date: _ud, created_by: _cb, ...fields } = location;
78
+ const payload = {
79
+ ...fields,
80
+ name: location.name.trim(),
81
+ // the default group always exists — a product's tax_group falls back to it
82
+ tax_groups: location.tax_groups.some((g) => g.name.toLowerCase() === "products")
83
+ ? location.tax_groups
84
+ : [{ name: "Products", rates: [] }, ...location.tax_groups],
85
+ };
86
+ if (isNew) await base44.entities["commerce.ShippingTaxLocation"].create(payload);
87
+ else await base44.entities["commerce.ShippingTaxLocation"].update(locationId, payload);
88
+ toast.success(isNew ? "Location created" : "Location saved");
89
+ navigate(href("settings/shipping-tax"));
90
+ } catch (e) {
91
+ toast.error(e.message || "Failed to save location");
92
+ } finally {
93
+ setSaving(false);
94
+ }
95
+ };
96
+
97
+ if (!location) return null;
98
+
99
+ const regions = location.regions || [];
100
+ const rates = location.shipping_rates || [];
101
+ const groups = location.tax_groups || [];
102
+ const shippingTax = location.shipping_tax;
103
+
104
+ const setRegion = (i, patch) => up({ regions: regions.map((r, j) => (j === i ? { ...r, ...patch } : r)) });
105
+ const setRate = (i, patch) => up({ shipping_rates: rates.map((r, j) => (j === i ? { ...r, ...patch } : r)) });
106
+ const setGroup = (i, patch) => up({ tax_groups: groups.map((g, j) => (j === i ? { ...g, ...patch } : g)) });
107
+
108
+ return (
109
+ <div className="max-w-3xl space-y-4">
110
+ <PageHeader
111
+ title={isNew ? "Add location" : location.name || "Edit location"}
112
+ description="Manage where you sell, and the shipping and tax charged there."
113
+ backHref={href("settings/shipping-tax")}
114
+ actions={
115
+ <Button onClick={save} disabled={saving}>
116
+ {saving ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Save className="mr-2 h-4 w-4" />}
117
+ Save
118
+ </Button>
119
+ }
120
+ />
121
+
122
+ <Card>
123
+ <CardHeader className="pb-2">
124
+ <CardTitle className="text-base">Location</CardTitle>
125
+ <CardDescription>
126
+ Leave the regions empty to make this the fallback that catches every address no other
127
+ location covers.
128
+ </CardDescription>
129
+ </CardHeader>
130
+ <CardContent className="space-y-3">
131
+ {/* Every row in this editor shares one column system —
132
+ [1fr | 130px | 130px | 40px icon] — so inputs align across cards. */}
133
+ <div className="grid grid-cols-[1fr_130px_130px_40px] items-center gap-2">
134
+ <div className="col-span-2 space-y-1.5">
135
+ <Label>Name</Label>
136
+ <Input value={location.name} onChange={(e) => up({ name: e.target.value })} placeholder="e.g. Israel" />
137
+ </div>
138
+ <div className="space-y-1.5">
139
+ <Label>Match order</Label>
140
+ <Input type="number" value={location.order ?? 0} onChange={(e) => up({ order: Number(e.target.value) || 0 })} />
141
+ </div>
142
+ <div />
143
+ </div>
144
+
145
+ <div className="space-y-2">
146
+ <Label className="block">Regions</Label>
147
+ {regions.map((region, i) => (
148
+ <div key={i} className="grid grid-cols-[130px_1fr_40px] items-center gap-2">
149
+ <Select value={region.type || "country"} onValueChange={(v) => setRegion(i, { type: v, code: "" })}>
150
+ <SelectTrigger><SelectValue /></SelectTrigger>
151
+ <SelectContent>
152
+ <SelectItem value="country">Country</SelectItem>
153
+ <SelectItem value="continent">Continent</SelectItem>
154
+ <SelectItem value="state">State</SelectItem>
155
+ </SelectContent>
156
+ </Select>
157
+ {region.type === "continent" ? (
158
+ <Select value={region.code || ""} onValueChange={(v) => setRegion(i, { code: v })}>
159
+ <SelectTrigger><SelectValue placeholder="Select continent…" /></SelectTrigger>
160
+ <SelectContent>
161
+ {CONTINENTS.map((c) => (
162
+ <SelectItem key={c.code} value={c.code}>{c.name}</SelectItem>
163
+ ))}
164
+ </SelectContent>
165
+ </Select>
166
+ ) : region.type === "state" ? (
167
+ <Input
168
+ placeholder="COUNTRY:STATE — e.g. US:CA"
169
+ value={region.code || ""}
170
+ onChange={(e) => setRegion(i, { code: e.target.value.toUpperCase() })}
171
+ />
172
+ ) : (
173
+ <CountrySelect value={region.code || ""} onChange={(code) => setRegion(i, { code })} />
174
+ )}
175
+ <Button type="button" variant="ghost" size="icon" onClick={() => up({ regions: regions.filter((_, j) => j !== i) })}>
176
+ <Trash2 className="h-4 w-4 text-muted-foreground" />
177
+ </Button>
178
+ </div>
179
+ ))}
180
+ <Button type="button" variant="outline" size="sm" onClick={() => up({ regions: [...regions, { type: "country", code: "" }] })}>
181
+ <Plus className="mr-2 h-4 w-4" />
182
+ Add region
183
+ </Button>
184
+ </div>
185
+ </CardContent>
186
+ </Card>
187
+
188
+ <Card>
189
+ <CardHeader className="pb-2">
190
+ <CardTitle className="text-base">Shipping rates</CardTitle>
191
+ <CardDescription>
192
+ Every rate is a delivery option at checkout. A "Free over" amount makes the rate free once
193
+ the (discounted) items subtotal reaches it. No rates means nothing ships here.
194
+ </CardDescription>
195
+ </CardHeader>
196
+ <CardContent className="space-y-2">
197
+ {rates.length > 0 && (
198
+ <div className="grid grid-cols-[1fr_130px_130px_40px] items-center gap-2 px-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">
199
+ <span>Delivery option</span>
200
+ <span>Cost</span>
201
+ <span>Free over</span>
202
+ <span />
203
+ </div>
204
+ )}
205
+ {rates.map((rate, i) => (
206
+ <div key={rate.id || i} className="grid grid-cols-[1fr_130px_130px_40px] items-center gap-2">
207
+ <Input
208
+ placeholder="e.g. Standard delivery"
209
+ value={rate.name || ""}
210
+ onChange={(e) => setRate(i, { name: e.target.value })}
211
+ />
212
+ <MoneyInput value={rate.cost} onChange={(v) => setRate(i, { cost: v ?? 0 })} placeholder="Cost" />
213
+ <Input
214
+ type="number"
215
+ min={0}
216
+ placeholder="Never"
217
+ title="Items subtotal (after discounts) at which this rate becomes free"
218
+ value={rate.free_over ?? ""}
219
+ onChange={(e) => setRate(i, { free_over: num(e.target.value) })}
220
+ />
221
+ <Button type="button" variant="ghost" size="icon" onClick={() => up({ shipping_rates: rates.filter((_, j) => j !== i) })}>
222
+ <Trash2 className="h-4 w-4 text-muted-foreground" />
223
+ </Button>
224
+ </div>
225
+ ))}
226
+ <Button
227
+ type="button"
228
+ variant="outline"
229
+ size="sm"
230
+ onClick={() => up({ shipping_rates: [...rates, { id: crypto.randomUUID(), name: "", cost: 0, free_over: null }] })}
231
+ >
232
+ <Plus className="mr-2 h-4 w-4" />
233
+ Add rate
234
+ </Button>
235
+ </CardContent>
236
+ </Card>
237
+
238
+ <Card>
239
+ <CardHeader className="pb-2">
240
+ <CardTitle className="text-base">Tax</CardTitle>
241
+ <CardDescription>
242
+ Tax groups bundle percentage rates — a group's tax is the sum of its rates. Products
243
+ charge the <strong>Products</strong> group unless their own Tax group names another;
244
+ add groups for goods taxed differently. Tax on shipping is set separately below.
245
+ </CardDescription>
246
+ </CardHeader>
247
+ <CardContent className="space-y-4">
248
+ {groups.map((group, i) => {
249
+ const isDefault = group.name.toLowerCase() === "products";
250
+ const groupRates = group.rates || [];
251
+ return (
252
+ <div key={i} className="rounded-lg border">
253
+ <div className="flex items-center justify-between gap-2 border-b bg-muted/40 px-3 py-2">
254
+ <div className="flex flex-1 items-center gap-2">
255
+ <Input
256
+ className="h-8 max-w-xs bg-background font-medium"
257
+ value={group.name}
258
+ placeholder="Group name"
259
+ disabled={isDefault}
260
+ onChange={(e) => setGroup(i, { name: e.target.value })}
261
+ />
262
+ <span className="text-xs text-muted-foreground">
263
+ {isDefault ? "Default group — products without a Tax group charge this" : "Tax group"}
264
+ </span>
265
+ </div>
266
+ {!isDefault && (
267
+ <Button type="button" variant="ghost" size="icon" className="h-8 w-8" onClick={() => up({ tax_groups: groups.filter((_, j) => j !== i) })}>
268
+ <Trash2 className="h-4 w-4 text-muted-foreground" />
269
+ </Button>
270
+ )}
271
+ </div>
272
+ <div className="space-y-2 p-3">
273
+ {!groupRates.length && (
274
+ <p className="text-xs text-muted-foreground">
275
+ No rates — {isDefault ? "products" : `${group.name.trim() || "these"} items`} are not taxed at this location.
276
+ </p>
277
+ )}
278
+ {groupRates.map((r, j) => (
279
+ <div key={j} className="grid grid-cols-[1fr_130px_40px] items-center gap-2">
280
+ <Input
281
+ placeholder="Rate name — e.g. Sales tax, VAT"
282
+ value={r.name || ""}
283
+ onChange={(e) => setGroup(i, { rates: groupRates.map((x, k) => (k === j ? { ...x, name: e.target.value } : x)) })}
284
+ />
285
+ <div className="relative">
286
+ <Input
287
+ type="number"
288
+ min={0}
289
+ max={100}
290
+ value={r.rate ?? ""}
291
+ onChange={(e) => setGroup(i, { rates: groupRates.map((x, k) => (k === j ? { ...x, rate: num(e.target.value) ?? 0 } : x)) })}
292
+ />
293
+ <span className="absolute right-3 top-1/2 -translate-y-1/2 text-sm text-muted-foreground">%</span>
294
+ </div>
295
+ <Button
296
+ type="button"
297
+ variant="ghost"
298
+ size="icon"
299
+ onClick={() => setGroup(i, { rates: groupRates.filter((_, k) => k !== j) })}
300
+ >
301
+ <Trash2 className="h-4 w-4 text-muted-foreground" />
302
+ </Button>
303
+ </div>
304
+ ))}
305
+ <Button
306
+ type="button"
307
+ variant="outline"
308
+ size="sm"
309
+ onClick={() => setGroup(i, { rates: [...groupRates, { name: "", rate: 0 }] })}
310
+ >
311
+ <Plus className="mr-2 h-4 w-4" />
312
+ Add rate
313
+ </Button>
314
+ </div>
315
+ </div>
316
+ );
317
+ })}
318
+
319
+ <Button
320
+ type="button"
321
+ variant="outline"
322
+ size="sm"
323
+ onClick={() => up({ tax_groups: [...groups, { name: "", rates: [{ name: "", rate: 0 }] }] })}
324
+ >
325
+ <Plus className="mr-2 h-4 w-4" />
326
+ Add tax group
327
+ </Button>
328
+
329
+ <div className="rounded-lg border">
330
+ <div className="border-b bg-muted/40 px-3 py-2">
331
+ <p className="text-sm font-medium">Tax on shipping</p>
332
+ </div>
333
+ <div className="grid grid-cols-[1fr_130px_130px_40px] items-center gap-2 p-3">
334
+ <p className="text-xs text-muted-foreground">
335
+ Applied to the shipping cost at this location — separate from the product groups above.
336
+ </p>
337
+ <Select
338
+ value={shippingTax?.type || "none"}
339
+ onValueChange={(v) => up({ shipping_tax: v === "none" ? null : { type: v, value: shippingTax?.value ?? 0 } })}
340
+ >
341
+ <SelectTrigger><SelectValue /></SelectTrigger>
342
+ <SelectContent>
343
+ <SelectItem value="none">Not taxed</SelectItem>
344
+ <SelectItem value="percent">Rate %</SelectItem>
345
+ <SelectItem value="fixed">Exact amount</SelectItem>
346
+ </SelectContent>
347
+ </Select>
348
+ {shippingTax ? (
349
+ shippingTax.type === "fixed" ? (
350
+ <MoneyInput value={shippingTax.value} onChange={(v) => up({ shipping_tax: { ...shippingTax, value: v ?? 0 } })} />
351
+ ) : (
352
+ <div className="relative">
353
+ <Input
354
+ type="number"
355
+ min={0}
356
+ max={100}
357
+ value={shippingTax.value ?? ""}
358
+ onChange={(e) => up({ shipping_tax: { ...shippingTax, value: num(e.target.value) ?? 0 } })}
359
+ />
360
+ <span className="absolute right-3 top-1/2 -translate-y-1/2 text-sm text-muted-foreground">%</span>
361
+ </div>
362
+ )
363
+ ) : (
364
+ <div />
365
+ )}
366
+ <div />
367
+ </div>
368
+ </div>
369
+
370
+ <p className="text-xs text-muted-foreground">
371
+ Example: {money.format(100)} in the Products group at 18% charges {money.format(18)} tax.
372
+ </p>
373
+ </CardContent>
374
+ </Card>
375
+ </div>
376
+ );
377
+ }