@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,231 @@
1
+ import React, { useEffect, useState } from "react";
2
+ import { Button } from "@/components/ui/button";
3
+ import { Input } from "@/components/ui/input";
4
+ import { Checkbox } from "@/components/ui/checkbox";
5
+ import {
6
+ Table,
7
+ TableBody,
8
+ TableCell,
9
+ TableHead,
10
+ TableHeader,
11
+ TableRow,
12
+ } from "@/components/ui/table";
13
+ import { Loader2, Plus, Trash2, Undo2 } from "lucide-react";
14
+ import { toast } from "sonner";
15
+ import { base44 } from "../../lib/api";
16
+ import useAsync from "../../hooks/useAsync";
17
+
18
+ let tmpSeq = 0;
19
+ const tmpId = () => `tmp_${++tmpSeq}`;
20
+
21
+ const joinList = (arr) => (arr || []).join(", ");
22
+ const splitList = (text) =>
23
+ text
24
+ .split(",")
25
+ .map((s) => s.trim())
26
+ .filter(Boolean);
27
+
28
+ const newRow = (taxClass, order) => ({
29
+ _tmpId: tmpId(),
30
+ _state: "new",
31
+ country: "",
32
+ state: "",
33
+ postcodes: [],
34
+ cities: [],
35
+ rate: 0,
36
+ name: "Tax",
37
+ priority: 1,
38
+ compound: false,
39
+ shipping: true,
40
+ tax_class: taxClass,
41
+ menu_order: order,
42
+ });
43
+
44
+ /**
45
+ * Editable tax-rate grid for one tax class ("Standard rates" table).
46
+ * Tracks new / modified / deleted rows locally; "Save changes" batches the
47
+ * TaxRate create/update/delete calls.
48
+ */
49
+ export default function TaxRatesTable({ taxClass }) {
50
+ const { data, loading, refetch } = useAsync(
51
+ () => base44.entities["commerce.TaxRate"].filter({ tax_class: taxClass }, "menu_order", 500),
52
+ [taxClass]
53
+ );
54
+
55
+ const [rows, setRows] = useState([]);
56
+ const [saving, setSaving] = useState(false);
57
+
58
+ useEffect(() => {
59
+ setRows((data || []).map((r) => ({ ...r, _tmpId: r.id, _state: "clean" })));
60
+ }, [data]);
61
+
62
+ const setCell = (id, key, value) =>
63
+ setRows((rs) =>
64
+ rs.map((r) =>
65
+ r._tmpId === id
66
+ ? { ...r, [key]: value, _state: r._state === "new" ? "new" : "modified" }
67
+ : r
68
+ )
69
+ );
70
+
71
+ const removeRow = (id) =>
72
+ setRows((rs) =>
73
+ rs
74
+ .map((r) => {
75
+ if (r._tmpId !== id) return r;
76
+ if (r._state === "new") return null;
77
+ return { ...r, _state: r._state === "deleted" ? "modified" : "deleted" };
78
+ })
79
+ .filter(Boolean)
80
+ );
81
+
82
+ const insertRow = () => setRows((rs) => [...rs, newRow(taxClass, rs.length)]);
83
+
84
+ const dirty = rows.some((r) => r._state !== "clean");
85
+
86
+ const saveAll = async () => {
87
+ setSaving(true);
88
+ try {
89
+ for (const r of rows) {
90
+ const payload = {
91
+ country: (r.country || "").toUpperCase(),
92
+ state: (r.state || "").toUpperCase(),
93
+ postcodes: r.postcodes || [],
94
+ cities: r.cities || [],
95
+ rate: Number(r.rate) || 0,
96
+ name: r.name || "Tax",
97
+ priority: Number(r.priority) || 1,
98
+ compound: !!r.compound,
99
+ shipping: !!r.shipping,
100
+ tax_class: taxClass,
101
+ menu_order: rows.indexOf(r),
102
+ };
103
+ if (r._state === "new") await base44.entities["commerce.TaxRate"].create(payload);
104
+ else if (r._state === "modified") await base44.entities["commerce.TaxRate"].update(r.id, payload);
105
+ else if (r._state === "deleted") await base44.entities["commerce.TaxRate"].delete(r.id);
106
+ }
107
+ toast.success("Tax rates saved");
108
+ await refetch();
109
+ } catch (err) {
110
+ toast.error(err.message || "Failed to save tax rates");
111
+ } finally {
112
+ setSaving(false);
113
+ }
114
+ };
115
+
116
+ const cell = (r, key, props = {}) => (
117
+ <Input
118
+ className="h-8"
119
+ disabled={r._state === "deleted"}
120
+ value={r[key] ?? ""}
121
+ onChange={(e) => setCell(r._tmpId, key, e.target.value)}
122
+ {...props}
123
+ />
124
+ );
125
+
126
+ return (
127
+ <div className="space-y-3">
128
+ <div className="overflow-x-auto rounded-lg border bg-background">
129
+ <Table>
130
+ <TableHeader>
131
+ <TableRow>
132
+ <TableHead className="min-w-20">Country&nbsp;code</TableHead>
133
+ <TableHead className="min-w-20">State&nbsp;code</TableHead>
134
+ <TableHead className="min-w-32">Postcode / ZIP</TableHead>
135
+ <TableHead className="min-w-32">City</TableHead>
136
+ <TableHead className="min-w-24">Rate&nbsp;%</TableHead>
137
+ <TableHead className="min-w-28">Tax name</TableHead>
138
+ <TableHead className="w-20">Priority</TableHead>
139
+ <TableHead className="w-20 text-center">Compound</TableHead>
140
+ <TableHead className="w-20 text-center">Shipping</TableHead>
141
+ <TableHead className="w-12" />
142
+ </TableRow>
143
+ </TableHeader>
144
+ <TableBody>
145
+ {loading ? (
146
+ <TableRow>
147
+ <TableCell colSpan={10} className="py-8 text-center text-muted-foreground">
148
+ <Loader2 className="mx-auto h-5 w-5 animate-spin" />
149
+ </TableCell>
150
+ </TableRow>
151
+ ) : rows.length === 0 ? (
152
+ <TableRow>
153
+ <TableCell colSpan={10} className="py-8 text-center text-sm text-muted-foreground">
154
+ No rates for this class yet. Insert a row to get started. Empty country/state
155
+ matches everywhere; postcodes support wildcards (90*) and ranges (1000...2000).
156
+ </TableCell>
157
+ </TableRow>
158
+ ) : (
159
+ rows.map((r) => (
160
+ <TableRow key={r._tmpId} className={r._state === "deleted" ? "opacity-50 line-through" : ""}>
161
+ <TableCell>{cell(r, "country", { placeholder: "*", maxLength: 2 })}</TableCell>
162
+ <TableCell>{cell(r, "state", { placeholder: "*" })}</TableCell>
163
+ <TableCell>
164
+ <Input
165
+ className="h-8"
166
+ placeholder="*"
167
+ disabled={r._state === "deleted"}
168
+ value={joinList(r.postcodes)}
169
+ onChange={(e) => setCell(r._tmpId, "postcodes", splitList(e.target.value))}
170
+ />
171
+ </TableCell>
172
+ <TableCell>
173
+ <Input
174
+ className="h-8"
175
+ placeholder="*"
176
+ disabled={r._state === "deleted"}
177
+ value={joinList(r.cities)}
178
+ onChange={(e) => setCell(r._tmpId, "cities", splitList(e.target.value))}
179
+ />
180
+ </TableCell>
181
+ <TableCell>{cell(r, "rate", { type: "number", step: "any" })}</TableCell>
182
+ <TableCell>{cell(r, "name")}</TableCell>
183
+ <TableCell>{cell(r, "priority", { type: "number", min: 1 })}</TableCell>
184
+ <TableCell className="text-center">
185
+ <Checkbox
186
+ disabled={r._state === "deleted"}
187
+ checked={!!r.compound}
188
+ onCheckedChange={(v) => setCell(r._tmpId, "compound", !!v)}
189
+ />
190
+ </TableCell>
191
+ <TableCell className="text-center">
192
+ <Checkbox
193
+ disabled={r._state === "deleted"}
194
+ checked={!!r.shipping}
195
+ onCheckedChange={(v) => setCell(r._tmpId, "shipping", !!v)}
196
+ />
197
+ </TableCell>
198
+ <TableCell>
199
+ <Button
200
+ variant="ghost"
201
+ size="icon"
202
+ className="h-8 w-8"
203
+ title={r._state === "deleted" ? "Undo delete" : "Delete row"}
204
+ onClick={() => removeRow(r._tmpId)}
205
+ >
206
+ {r._state === "deleted" ? (
207
+ <Undo2 className="h-4 w-4" />
208
+ ) : (
209
+ <Trash2 className="h-4 w-4 text-destructive" />
210
+ )}
211
+ </Button>
212
+ </TableCell>
213
+ </TableRow>
214
+ ))
215
+ )}
216
+ </TableBody>
217
+ </Table>
218
+ </div>
219
+ <div className="flex items-center gap-2">
220
+ <Button variant="outline" size="sm" onClick={insertRow}>
221
+ <Plus className="mr-1 h-4 w-4" />
222
+ Insert row
223
+ </Button>
224
+ <Button size="sm" onClick={saveAll} disabled={!dirty || saving}>
225
+ {saving && <Loader2 className="mr-1 h-4 w-4 animate-spin" />}
226
+ Save changes
227
+ </Button>
228
+ </div>
229
+ </div>
230
+ );
231
+ }
@@ -0,0 +1,281 @@
1
+ import React, { useState } from "react";
2
+ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
3
+ import { Button } from "@/components/ui/button";
4
+ import { Input } from "@/components/ui/input";
5
+ import { Label } from "@/components/ui/label";
6
+ import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
7
+ import {
8
+ Dialog,
9
+ DialogContent,
10
+ DialogFooter,
11
+ DialogHeader,
12
+ DialogTitle,
13
+ } from "@/components/ui/dialog";
14
+ import {
15
+ Select,
16
+ SelectContent,
17
+ SelectItem,
18
+ SelectTrigger,
19
+ SelectValue,
20
+ } from "@/components/ui/select";
21
+ import { Loader2, Plus, Trash2 } from "lucide-react";
22
+ import { toast } from "sonner";
23
+ import { base44 } from "../../lib/api";
24
+ import useAsync from "../../hooks/useAsync";
25
+ import ConfirmDialog from "../../components/ConfirmDialog";
26
+ import { slugify } from "../../lib/product-utils";
27
+ import useGroupForm from "./useGroupForm";
28
+ import TaxRatesTable from "./TaxRatesTable";
29
+
30
+ const DEFAULTS = {
31
+ prices_include_tax: false,
32
+ tax_based_on: "shipping",
33
+ shipping_tax_class: "inherit",
34
+ display_prices_shop: "excl",
35
+ display_prices_cart: "excl",
36
+ };
37
+
38
+ function TaxOptions({ classes }) {
39
+ const { form, setField, SaveButton } = useGroupForm("tax", DEFAULTS);
40
+
41
+ return (
42
+ <Card className="max-w-3xl">
43
+ <CardHeader className="flex-row items-start justify-between gap-4 space-y-0">
44
+ <div className="space-y-1.5">
45
+ <CardTitle>Tax options</CardTitle>
46
+ </div>
47
+ <SaveButton />
48
+ </CardHeader>
49
+ <CardContent className="space-y-4">
50
+ <div className="space-y-1.5">
51
+ <Label>Prices entered with tax</Label>
52
+ <RadioGroup
53
+ value={form.prices_include_tax ? "yes" : "no"}
54
+ onValueChange={(v) => setField("prices_include_tax", v === "yes")}
55
+ className="gap-2"
56
+ >
57
+ <label className="flex items-center gap-2 text-sm">
58
+ <RadioGroupItem value="no" />
59
+ No, I will enter prices exclusive of tax
60
+ </label>
61
+ <label className="flex items-center gap-2 text-sm">
62
+ <RadioGroupItem value="yes" />
63
+ Yes, I will enter prices inclusive of tax
64
+ </label>
65
+ </RadioGroup>
66
+ </div>
67
+
68
+ <div className="grid gap-4 sm:grid-cols-2">
69
+ <div className="space-y-1.5">
70
+ <Label>Calculate tax based on</Label>
71
+ <Select value={form.tax_based_on} onValueChange={(v) => setField("tax_based_on", v)}>
72
+ <SelectTrigger>
73
+ <SelectValue />
74
+ </SelectTrigger>
75
+ <SelectContent>
76
+ <SelectItem value="shipping">Customer shipping address</SelectItem>
77
+ <SelectItem value="billing">Customer billing address</SelectItem>
78
+ <SelectItem value="base">Shop base address</SelectItem>
79
+ </SelectContent>
80
+ </Select>
81
+ </div>
82
+ <div className="space-y-1.5">
83
+ <Label>Shipping tax class</Label>
84
+ <Select
85
+ value={form.shipping_tax_class}
86
+ onValueChange={(v) => setField("shipping_tax_class", v)}
87
+ >
88
+ <SelectTrigger>
89
+ <SelectValue />
90
+ </SelectTrigger>
91
+ <SelectContent>
92
+ <SelectItem value="inherit">Shipping tax class based on cart items</SelectItem>
93
+ {(classes || []).map((c) => (
94
+ <SelectItem key={c.slug} value={c.slug}>
95
+ {c.name}
96
+ </SelectItem>
97
+ ))}
98
+ </SelectContent>
99
+ </Select>
100
+ </div>
101
+ <div className="space-y-1.5">
102
+ <Label>Display prices in the shop</Label>
103
+ <Select
104
+ value={form.display_prices_shop}
105
+ onValueChange={(v) => setField("display_prices_shop", v)}
106
+ >
107
+ <SelectTrigger>
108
+ <SelectValue />
109
+ </SelectTrigger>
110
+ <SelectContent>
111
+ <SelectItem value="excl">Excluding tax</SelectItem>
112
+ <SelectItem value="incl">Including tax</SelectItem>
113
+ </SelectContent>
114
+ </Select>
115
+ </div>
116
+ <div className="space-y-1.5">
117
+ <Label>Display prices during cart and checkout</Label>
118
+ <Select
119
+ value={form.display_prices_cart}
120
+ onValueChange={(v) => setField("display_prices_cart", v)}
121
+ >
122
+ <SelectTrigger>
123
+ <SelectValue />
124
+ </SelectTrigger>
125
+ <SelectContent>
126
+ <SelectItem value="excl">Excluding tax</SelectItem>
127
+ <SelectItem value="incl">Including tax</SelectItem>
128
+ </SelectContent>
129
+ </Select>
130
+ </div>
131
+ </div>
132
+ </CardContent>
133
+ </Card>
134
+ );
135
+ }
136
+
137
+ export default function TaxSettings() {
138
+ const { data: classes, loading, refetch } = useAsync(
139
+ () => base44.entities["commerce.TaxClass"].list("created_date", 100),
140
+ []
141
+ );
142
+ const [tab, setTab] = useState("options");
143
+ const [addOpen, setAddOpen] = useState(false);
144
+ const [newName, setNewName] = useState("");
145
+ const [creating, setCreating] = useState(false);
146
+ const [deleteTarget, setDeleteTarget] = useState(null);
147
+ const [deleting, setDeleting] = useState(false);
148
+
149
+ const createClass = async () => {
150
+ const name = newName.trim();
151
+ if (!name) return;
152
+ setCreating(true);
153
+ try {
154
+ const slug = slugify(name);
155
+ await base44.entities["commerce.TaxClass"].create({ slug, name });
156
+ toast.success(`Tax class "${name}" added`);
157
+ setAddOpen(false);
158
+ setNewName("");
159
+ await refetch();
160
+ setTab(slug);
161
+ } catch (err) {
162
+ toast.error(err.message || "Failed to add tax class");
163
+ } finally {
164
+ setCreating(false);
165
+ }
166
+ };
167
+
168
+ const deleteClass = async () => {
169
+ setDeleting(true);
170
+ try {
171
+ await base44.entities["commerce.TaxClass"].delete(deleteTarget.id);
172
+ toast.success(`Tax class "${deleteTarget.name}" removed. Its tax rates were kept.`);
173
+ setDeleteTarget(null);
174
+ if (tab === deleteTarget.slug) setTab("options");
175
+ await refetch();
176
+ } catch (err) {
177
+ toast.error(err.message || "Failed to delete tax class");
178
+ } finally {
179
+ setDeleting(false);
180
+ }
181
+ };
182
+
183
+ const current = (classes || []).find((c) => c.slug === tab);
184
+
185
+ return (
186
+ <div className="space-y-4">
187
+ <div className="flex flex-wrap items-center gap-1 text-sm">
188
+ <button
189
+ type="button"
190
+ onClick={() => setTab("options")}
191
+ className={`rounded-md px-3 py-1.5 ${
192
+ tab === "options" ? "bg-muted font-medium" : "text-muted-foreground hover:text-foreground"
193
+ }`}
194
+ >
195
+ Tax options
196
+ </button>
197
+ <span className="text-muted-foreground">|</span>
198
+ {loading ? (
199
+ <Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
200
+ ) : (
201
+ (classes || []).map((c) => (
202
+ <button
203
+ key={c.slug}
204
+ type="button"
205
+ onClick={() => setTab(c.slug)}
206
+ className={`rounded-md px-3 py-1.5 ${
207
+ tab === c.slug ? "bg-muted font-medium" : "text-muted-foreground hover:text-foreground"
208
+ }`}
209
+ >
210
+ {c.name} rates
211
+ </button>
212
+ ))
213
+ )}
214
+ <Button variant="ghost" size="sm" className="text-muted-foreground" onClick={() => setAddOpen(true)}>
215
+ <Plus className="mr-1 h-4 w-4" />
216
+ Add class
217
+ </Button>
218
+ </div>
219
+
220
+ {tab === "options" ? (
221
+ <TaxOptions classes={classes} />
222
+ ) : (
223
+ <div className="space-y-3">
224
+ {current && current.slug !== "standard" && (
225
+ <div className="flex justify-end">
226
+ <Button
227
+ variant="outline"
228
+ size="sm"
229
+ className="text-destructive"
230
+ onClick={() => setDeleteTarget(current)}
231
+ >
232
+ <Trash2 className="mr-1 h-4 w-4" />
233
+ Remove tax class
234
+ </Button>
235
+ </div>
236
+ )}
237
+ <TaxRatesTable taxClass={tab} />
238
+ </div>
239
+ )}
240
+
241
+ <Dialog open={addOpen} onOpenChange={setAddOpen}>
242
+ <DialogContent className="sm:max-w-sm">
243
+ <DialogHeader>
244
+ <DialogTitle>Add tax class</DialogTitle>
245
+ </DialogHeader>
246
+ <div className="space-y-1.5">
247
+ <Label>Class name</Label>
248
+ <Input
249
+ value={newName}
250
+ onChange={(e) => setNewName(e.target.value)}
251
+ placeholder="e.g. Reduced rate"
252
+ onKeyDown={(e) => e.key === "Enter" && createClass()}
253
+ />
254
+ {newName.trim() && (
255
+ <p className="text-xs text-muted-foreground">Slug: {slugify(newName)}</p>
256
+ )}
257
+ </div>
258
+ <DialogFooter>
259
+ <Button variant="outline" onClick={() => setAddOpen(false)}>
260
+ Cancel
261
+ </Button>
262
+ <Button onClick={createClass} disabled={creating || !newName.trim()}>
263
+ {creating && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
264
+ Add class
265
+ </Button>
266
+ </DialogFooter>
267
+ </DialogContent>
268
+ </Dialog>
269
+
270
+ <ConfirmDialog
271
+ open={!!deleteTarget}
272
+ onOpenChange={(v) => !v && setDeleteTarget(null)}
273
+ title={`Remove tax class "${deleteTarget?.name}"?`}
274
+ description="Products assigned to this class fall back to Standard. Existing tax rates for the class are not deleted."
275
+ confirmLabel="Remove class"
276
+ onConfirm={deleteClass}
277
+ loading={deleting}
278
+ />
279
+ </div>
280
+ );
281
+ }
@@ -0,0 +1,76 @@
1
+ import React, { useEffect, useMemo, useRef, useState } from "react";
2
+ import { toast } from "sonner";
3
+ import { Button } from "@/components/ui/button";
4
+ import { Loader2 } from "lucide-react";
5
+ import { useSettings } from "../../context/SettingsContext";
6
+
7
+ /**
8
+ * Standard form state for a StoreSettings group page.
9
+ * Initializes from `settings[group]` spread over `defaults` (missing keys safe),
10
+ * tracks dirtiness, and persists through `useSettings().saveGroup`.
11
+ *
12
+ * Returns { form, setField(key, value), setForm(next), dirty, saving, save, SaveButton }.
13
+ * `SaveButton` goes in every CardHeader on the page — one Save at the bottom of a
14
+ * long settings page sits below the fold and gets missed.
15
+ */
16
+ export default function useGroupForm(group, defaults) {
17
+ const { settings, saveGroup } = useSettings();
18
+
19
+ const initial = useMemo(
20
+ () => ({ ...defaults, ...(settings?.[group] || {}) }),
21
+ // eslint-disable-next-line -- deps intentionally partial
22
+ [settings, group]
23
+ );
24
+
25
+ const [form, setFormState] = useState(initial);
26
+ const [dirty, setDirty] = useState(false);
27
+ const [saving, setSaving] = useState(false);
28
+
29
+ // Re-sync when settings refresh (e.g. after save).
30
+ useEffect(() => {
31
+ setFormState(initial);
32
+ setDirty(false);
33
+ }, [initial]);
34
+
35
+ const setField = (key, value) => {
36
+ setFormState((f) => ({ ...f, [key]: value }));
37
+ setDirty(true);
38
+ };
39
+
40
+ const setForm = (next) => {
41
+ setFormState(next);
42
+ setDirty(true);
43
+ };
44
+
45
+ const save = async (override) => {
46
+ setSaving(true);
47
+ try {
48
+ await saveGroup(group, override || form);
49
+ toast.success("Settings saved");
50
+ setDirty(false);
51
+ } catch (err) {
52
+ toast.error(err.message || "Failed to save settings");
53
+ } finally {
54
+ setSaving(false);
55
+ }
56
+ };
57
+
58
+ // Stable component identity (a fresh one each render would remount the button
59
+ // mid-click); current values are read through the ref on every parent render.
60
+ const live = useRef(null);
61
+ live.current = { dirty, saving, save };
62
+ const SaveButton = useMemo(
63
+ () => function SaveButton() {
64
+ const { dirty: d, saving: s, save: doSave } = live.current;
65
+ return (
66
+ <Button size="sm" onClick={() => doSave()} disabled={!d || s}>
67
+ {s && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
68
+ Save changes
69
+ </Button>
70
+ );
71
+ },
72
+ []
73
+ );
74
+
75
+ return { form, setField, setForm, dirty, saving, save, SaveButton };
76
+ }