@base44/app-plugin-commerce 0.1.6 → 0.1.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +12 -12
- package/base44/agents/commerce/StoreAdmin.jsonc +2 -2
- package/base44/entities/commerce.Cart.jsonc +1 -1
- package/base44/entities/commerce.Coupon.jsonc +5 -0
- package/base44/entities/commerce.Order.jsonc +6 -7
- package/base44/entities/commerce.OrderRefund.jsonc +1 -1
- package/base44/entities/commerce.PaymentGateway.jsonc +1 -1
- package/base44/entities/commerce.Product.jsonc +6 -16
- package/base44/entities/{commerce.ProductTag.jsonc → commerce.ProductRibbon.jsonc} +2 -2
- package/base44/entities/commerce.ProductVariation.jsonc +1 -9
- package/base44/entities/commerce.ShippingTaxLocation.jsonc +85 -0
- package/base44/entities/commerce.Webhook.jsonc +1 -1
- package/base44/functions/commerce/admin-orders/helpers.ts +7 -13
- package/base44/functions/commerce/admin-products/entry.ts +11 -17
- package/base44/functions/commerce/admin-refunds/entry.ts +10 -9
- package/base44/functions/commerce/admin-reports/entry.ts +3 -3
- package/base44/functions/commerce/admin-tools/entry.ts +9 -36
- package/base44/functions/commerce/payment-webhook/entry.ts +50 -89
- package/base44/functions/commerce/payments/entry.ts +46 -42
- package/base44/functions/commerce/seed-store/defaults.ts +35 -42
- package/base44/functions/commerce/seed-store/entry.ts +68 -46
- package/base44/functions/commerce/seed-store/sample-data.ts +2 -15
- package/base44/functions/commerce/seed-store/seed-catalog.ts +105 -55
- package/base44/functions/commerce/storefront-cart/cart-pricing.ts +6 -11
- package/base44/functions/commerce/storefront-cart/entry.ts +36 -2
- package/base44/functions/commerce/storefront-catalog/entry.ts +55 -72
- package/base44/functions/commerce/storefront-checkout/cart-pricing.ts +6 -11
- package/base44/functions/commerce/storefront-checkout/entry.ts +43 -56
- package/base44/shared/commerce/card-payment.ts +80 -0
- package/base44/shared/commerce/coupons.ts +16 -10
- package/base44/shared/commerce/emails.ts +30 -18
- package/base44/shared/commerce/money.ts +11 -24
- package/base44/shared/commerce/payments.ts +55 -286
- package/base44/shared/commerce/scan.ts +1 -1
- package/base44/shared/commerce/sequence.ts +1 -1
- package/base44/shared/commerce/settings.ts +4 -9
- package/base44/shared/commerce/shipping.ts +65 -133
- package/base44/shared/commerce/tax.ts +48 -96
- package/base44/shared/commerce/totals.ts +77 -91
- package/package.json +1 -1
- package/scripts/install.js +28 -7
- package/skills/commerce/SKILL.md +14 -14
- package/skills/commerce/docs/api-admin.md +23 -26
- package/skills/commerce/docs/api-storefront.md +67 -61
- package/skills/commerce/installation-guidelines.md +8 -8
- package/skills/commerce/post-installation.md +60 -39
- package/skills/commerce/references/admin-product-form.md +15 -12
- package/skills/commerce/references/emails.md +2 -2
- package/skills/commerce/references/guest-access-security.md +2 -2
- package/skills/commerce/references/online-payments.md +24 -166
- package/skills/commerce/references/product-render.md +18 -18
- package/skills/commerce/references/reviews.md +14 -8
- package/skills/commerce/references/storefront-product-page.md +1 -1
- package/src/commerce/admin/README.md +5 -6
- package/src/commerce/admin/bot/Markdown.jsx +1 -1
- package/src/commerce/admin/hooks/useMoney.js +13 -22
- package/src/commerce/admin/layout/AuthGuard.jsx +1 -1
- package/src/commerce/admin/lib/constants.js +2 -29
- package/src/commerce/admin/lib/order-utils.js +1 -1
- package/src/commerce/admin/pages/coupons/CouponEditor.jsx +131 -140
- package/src/commerce/admin/pages/coupons/CouponsList.jsx +14 -7
- package/src/commerce/admin/pages/orders/OrderEditor.jsx +3 -3
- package/src/commerce/admin/pages/orders/components/PaymentPanel.jsx +17 -28
- package/src/commerce/admin/pages/orders/components/RefundPanel.jsx +5 -11
- package/src/commerce/admin/pages/products/Categories.jsx +147 -177
- package/src/commerce/admin/pages/products/ProductEditor.jsx +23 -18
- package/src/commerce/admin/pages/products/Reviews.jsx +37 -1
- package/src/commerce/admin/pages/products/components/ProductDataPanel.jsx +33 -47
- package/src/commerce/admin/pages/products/components/PublishBox.jsx +13 -34
- package/src/commerce/admin/pages/products/components/TaxonomyPanel.jsx +29 -29
- package/src/commerce/admin/pages/products/components/tabs/PriceInventoryTab.jsx +14 -44
- package/src/commerce/admin/pages/reports/Reports.jsx +2 -2
- package/src/commerce/admin/pages/settings/EmailsSettings.jsx +101 -68
- package/src/commerce/admin/pages/settings/GeneralSettings.jsx +40 -38
- package/src/commerce/admin/pages/settings/InventorySettings.jsx +1 -41
- package/src/commerce/admin/pages/settings/LocationEditor.jsx +377 -0
- package/src/commerce/admin/pages/settings/PaymentsSettings.jsx +137 -119
- package/src/commerce/admin/pages/settings/SettingsLayout.jsx +2 -4
- package/src/commerce/admin/pages/settings/ShippingTaxSettings.jsx +191 -0
- package/src/commerce/admin/routes.jsx +6 -12
- package/src/commerce/utils/index.js +2 -2
- package/src/commerce/utils/shipping-promos.js +45 -49
- package/src/commerce/utils/variants.js +1 -1
- package/base44/entities/commerce.ShippingClass.jsonc +0 -30
- package/base44/entities/commerce.ShippingZone.jsonc +0 -41
- package/base44/entities/commerce.ShippingZoneMethod.jsonc +0 -84
- package/base44/entities/commerce.TaxClass.jsonc +0 -23
- package/base44/entities/commerce.TaxRate.jsonc +0 -68
- package/base44/shared/commerce/stripe.ts +0 -463
- package/src/commerce/admin/hooks/usePaymentProvider.js +0 -27
- package/src/commerce/admin/pages/settings/ProductsSettings.jsx +0 -118
- package/src/commerce/admin/pages/settings/ShippingSettings.jsx +0 -304
- package/src/commerce/admin/pages/settings/ShippingZoneEditor.jsx +0 -514
- package/src/commerce/admin/pages/settings/TaxRatesTable.jsx +0 -231
- 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,
|
|
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
|
-
|
|
19
|
-
|
|
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
|
|
57
|
-
<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
|
|
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>
|
|
79
|
-
<Select value={form.
|
|
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
|
-
{
|
|
85
|
-
<SelectItem key={
|
|
86
|
-
{
|
|
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>
|
|
94
|
-
<
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
+
}
|