@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.
- package/LICENSE +21 -0
- package/README.md +117 -0
- package/base44/agents/commerce/StoreAdmin.jsonc +64 -0
- package/base44/entities/commerce.Cart.jsonc +73 -0
- package/base44/entities/commerce.Coupon.jsonc +113 -0
- package/base44/entities/commerce.Customer.jsonc +96 -0
- package/base44/entities/commerce.DownloadPermission.jsonc +54 -0
- package/base44/entities/commerce.EmailLog.jsonc +43 -0
- package/base44/entities/commerce.Order.jsonc +287 -0
- package/base44/entities/commerce.OrderNote.jsonc +31 -0
- package/base44/entities/commerce.OrderRefund.jsonc +64 -0
- package/base44/entities/commerce.PaymentGateway.jsonc +48 -0
- package/base44/entities/commerce.Product.jsonc +291 -0
- package/base44/entities/commerce.ProductAttribute.jsonc +39 -0
- package/base44/entities/commerce.ProductAttributeTerm.jsonc +38 -0
- package/base44/entities/commerce.ProductCategory.jsonc +51 -0
- package/base44/entities/commerce.ProductReview.jsonc +48 -0
- package/base44/entities/commerce.ProductTag.jsonc +30 -0
- package/base44/entities/commerce.ProductVariation.jsonc +167 -0
- package/base44/entities/commerce.ShippingClass.jsonc +30 -0
- package/base44/entities/commerce.ShippingZone.jsonc +41 -0
- package/base44/entities/commerce.ShippingZoneMethod.jsonc +84 -0
- package/base44/entities/commerce.StoreSettings.jsonc +23 -0
- package/base44/entities/commerce.TaxClass.jsonc +23 -0
- package/base44/entities/commerce.TaxRate.jsonc +68 -0
- package/base44/entities/commerce.Webhook.jsonc +57 -0
- package/base44/entities/commerce.WebhookDelivery.jsonc +45 -0
- package/base44/functions/commerce/admin-coupons/entry.ts +100 -0
- package/base44/functions/commerce/admin-customers/entry.ts +141 -0
- package/base44/functions/commerce/admin-orders/entry.ts +396 -0
- package/base44/functions/commerce/admin-orders/helpers.ts +246 -0
- package/base44/functions/commerce/admin-products/entry.ts +506 -0
- package/base44/functions/commerce/admin-refunds/entry.ts +158 -0
- package/base44/functions/commerce/admin-reports/entry.ts +283 -0
- package/base44/functions/commerce/admin-reviews/entry.ts +66 -0
- package/base44/functions/commerce/admin-tools/entry.ts +261 -0
- package/base44/functions/commerce/admin-webhooks/entry.ts +52 -0
- package/base44/functions/commerce/payment-webhook/entry.ts +135 -0
- package/base44/functions/commerce/payments/entry.ts +238 -0
- package/base44/functions/commerce/seed-store/defaults.ts +162 -0
- package/base44/functions/commerce/seed-store/entry.ts +310 -0
- package/base44/functions/commerce/seed-store/sample-data.ts +349 -0
- package/base44/functions/commerce/storefront-account/entry.ts +207 -0
- package/base44/functions/commerce/storefront-cart/cart-pricing.ts +258 -0
- package/base44/functions/commerce/storefront-cart/entry.ts +283 -0
- package/base44/functions/commerce/storefront-catalog/entry.ts +459 -0
- package/base44/functions/commerce/storefront-checkout/cart-pricing.ts +258 -0
- package/base44/functions/commerce/storefront-checkout/entry.ts +485 -0
- package/base44/shared/commerce/auth.ts +60 -0
- package/base44/shared/commerce/coupons.ts +257 -0
- package/base44/shared/commerce/data/continents.ts +75 -0
- package/base44/shared/commerce/data/countries.ts +307 -0
- package/base44/shared/commerce/data/currencies.ts +46 -0
- package/base44/shared/commerce/email-templates.ts +240 -0
- package/base44/shared/commerce/emails.ts +225 -0
- package/base44/shared/commerce/money.ts +66 -0
- package/base44/shared/commerce/orders.ts +251 -0
- package/base44/shared/commerce/payments.ts +495 -0
- package/base44/shared/commerce/reviews.ts +36 -0
- package/base44/shared/commerce/scan.ts +57 -0
- package/base44/shared/commerce/sequence.ts +35 -0
- package/base44/shared/commerce/settings.ts +57 -0
- package/base44/shared/commerce/shipping.ts +215 -0
- package/base44/shared/commerce/stock.ts +227 -0
- package/base44/shared/commerce/stripe.ts +463 -0
- package/base44/shared/commerce/tax.ts +136 -0
- package/base44/shared/commerce/totals.ts +314 -0
- package/base44/shared/commerce/webhooks.ts +116 -0
- package/package.json +37 -0
- package/scripts/install.js +156 -0
- package/skills/commerce/SKILL.md +62 -0
- package/skills/commerce/docs/api-admin.md +186 -0
- package/skills/commerce/docs/api-storefront.md +408 -0
- package/skills/commerce/installation-guidelines.md +91 -0
- package/skills/commerce/post-installation.md +157 -0
- package/skills/commerce/references/emails.md +13 -0
- package/skills/commerce/references/guest-access-security.md +18 -0
- package/skills/commerce/references/limits-and-performance.md +16 -0
- package/skills/commerce/references/media-and-downloads.md +4 -0
- package/skills/commerce/references/online-payments.md +201 -0
- package/skills/commerce/references/product-render.md +87 -0
- package/skills/commerce/references/scheduled-work.md +19 -0
- package/skills/commerce/references/storefront-product-page.md +83 -0
- package/skills/commerce/references/webhooks.md +8 -0
- package/src/commerce/admin/README.md +107 -0
- package/src/commerce/admin/bot/Markdown.jsx +138 -0
- package/src/commerce/admin/bot/StoreAdminBot.jsx +249 -0
- package/src/commerce/admin/bot/pipe-tables.js +116 -0
- package/src/commerce/admin/components/AddressForm.jsx +78 -0
- package/src/commerce/admin/components/ConfirmDialog.jsx +52 -0
- package/src/commerce/admin/components/CountrySelect.jsx +81 -0
- package/src/commerce/admin/components/DataTable.jsx +192 -0
- package/src/commerce/admin/components/DateRangePicker.jsx +91 -0
- package/src/commerce/admin/components/EmptyState.jsx +17 -0
- package/src/commerce/admin/components/MediaUploader.jsx +116 -0
- package/src/commerce/admin/components/MetaDataEditor.jsx +45 -0
- package/src/commerce/admin/components/MoneyInput.jsx +50 -0
- package/src/commerce/admin/components/PageHeader.jsx +29 -0
- package/src/commerce/admin/components/RichTextarea.jsx +21 -0
- package/src/commerce/admin/components/SearchSelect.jsx +142 -0
- package/src/commerce/admin/components/StatusBadge.jsx +17 -0
- package/src/commerce/admin/context/BasePathContext.jsx +26 -0
- package/src/commerce/admin/context/SettingsContext.jsx +207 -0
- package/src/commerce/admin/hooks/useAsync.js +46 -0
- package/src/commerce/admin/hooks/useDebounce.js +11 -0
- package/src/commerce/admin/hooks/useMoney.js +52 -0
- package/src/commerce/admin/hooks/usePagedList.js +83 -0
- package/src/commerce/admin/hooks/usePaymentProvider.js +27 -0
- package/src/commerce/admin/hooks/useRealtime.js +129 -0
- package/src/commerce/admin/index.jsx +34 -0
- package/src/commerce/admin/layout/AccessDenied.jsx +54 -0
- package/src/commerce/admin/layout/AdminLayout.jsx +33 -0
- package/src/commerce/admin/layout/AuthGuard.jsx +84 -0
- package/src/commerce/admin/layout/Sidebar.jsx +130 -0
- package/src/commerce/admin/layout/Topbar.jsx +94 -0
- package/src/commerce/admin/lib/api.js +55 -0
- package/src/commerce/admin/lib/constants.js +157 -0
- package/src/commerce/admin/lib/format.js +27 -0
- package/src/commerce/admin/lib/geo-data.js +125 -0
- package/src/commerce/admin/lib/order-utils.js +147 -0
- package/src/commerce/admin/lib/paths.js +35 -0
- package/src/commerce/admin/lib/product-utils.js +55 -0
- package/src/commerce/admin/pages/Dashboard.jsx +245 -0
- package/src/commerce/admin/pages/coupons/CouponEditor.jsx +565 -0
- package/src/commerce/admin/pages/coupons/CouponsList.jsx +172 -0
- package/src/commerce/admin/pages/customers/CustomerEditor.jsx +318 -0
- package/src/commerce/admin/pages/customers/CustomersList.jsx +169 -0
- package/src/commerce/admin/pages/orders/OrderEditor.jsx +952 -0
- package/src/commerce/admin/pages/orders/OrdersList.jsx +227 -0
- package/src/commerce/admin/pages/orders/components/AddProductDialog.jsx +149 -0
- package/src/commerce/admin/pages/orders/components/DownloadPermissionsPanel.jsx +119 -0
- package/src/commerce/admin/pages/orders/components/LineItemsTable.jsx +208 -0
- package/src/commerce/admin/pages/orders/components/OrderNotesPanel.jsx +123 -0
- package/src/commerce/admin/pages/orders/components/PaymentPanel.jsx +199 -0
- package/src/commerce/admin/pages/orders/components/RefundPanel.jsx +239 -0
- package/src/commerce/admin/pages/orders/components/TotalsBox.jsx +52 -0
- package/src/commerce/admin/pages/products/AttributeTerms.jsx +180 -0
- package/src/commerce/admin/pages/products/Attributes.jsx +183 -0
- package/src/commerce/admin/pages/products/Categories.jsx +236 -0
- package/src/commerce/admin/pages/products/ProductEditor.jsx +267 -0
- package/src/commerce/admin/pages/products/ProductsList.jsx +391 -0
- package/src/commerce/admin/pages/products/Reviews.jsx +255 -0
- package/src/commerce/admin/pages/products/Tags.jsx +150 -0
- package/src/commerce/admin/pages/products/components/ProductDataPanel.jsx +132 -0
- package/src/commerce/admin/pages/products/components/PublishBox.jsx +101 -0
- package/src/commerce/admin/pages/products/components/TaxonomyPanel.jsx +243 -0
- package/src/commerce/admin/pages/products/components/tabs/AdvancedTab.jsx +48 -0
- package/src/commerce/admin/pages/products/components/tabs/AttributesTab.jsx +208 -0
- package/src/commerce/admin/pages/products/components/tabs/DownloadsTab.jsx +91 -0
- package/src/commerce/admin/pages/products/components/tabs/ExternalTab.jsx +41 -0
- package/src/commerce/admin/pages/products/components/tabs/GeneralTab.jsx +103 -0
- package/src/commerce/admin/pages/products/components/tabs/InventoryTab.jsx +93 -0
- package/src/commerce/admin/pages/products/components/tabs/LinkedTab.jsx +102 -0
- package/src/commerce/admin/pages/products/components/tabs/ShippingTab.jsx +86 -0
- package/src/commerce/admin/pages/products/components/tabs/VariationsTab.jsx +377 -0
- package/src/commerce/admin/pages/reports/Reports.jsx +416 -0
- package/src/commerce/admin/pages/settings/EmailsSettings.jsx +240 -0
- package/src/commerce/admin/pages/settings/GeneralSettings.jsx +232 -0
- package/src/commerce/admin/pages/settings/InventorySettings.jsx +146 -0
- package/src/commerce/admin/pages/settings/PaymentsSettings.jsx +260 -0
- package/src/commerce/admin/pages/settings/ProductsSettings.jsx +118 -0
- package/src/commerce/admin/pages/settings/SettingsLayout.jsx +53 -0
- package/src/commerce/admin/pages/settings/ShippingSettings.jsx +304 -0
- package/src/commerce/admin/pages/settings/ShippingZoneEditor.jsx +514 -0
- package/src/commerce/admin/pages/settings/TaxRatesTable.jsx +231 -0
- package/src/commerce/admin/pages/settings/TaxSettings.jsx +281 -0
- package/src/commerce/admin/pages/settings/useGroupForm.jsx +76 -0
- package/src/commerce/admin/pages/status/WebhookEditor.jsx +296 -0
- package/src/commerce/admin/pages/status/Webhooks.jsx +53 -0
- package/src/commerce/admin/routes.jsx +151 -0
- package/src/commerce/utils/index.js +19 -0
- package/src/commerce/utils/shipping-promos.js +99 -0
- package/src/commerce/utils/variants.js +411 -0
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
import React, { useEffect, useState } from "react";
|
|
2
|
+
import { useNavigate, useParams } from "react-router-dom";
|
|
3
|
+
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
4
|
+
import { Button } from "@/components/ui/button";
|
|
5
|
+
import { Input } from "@/components/ui/input";
|
|
6
|
+
import { Label } from "@/components/ui/label";
|
|
7
|
+
import { Badge } from "@/components/ui/badge";
|
|
8
|
+
import {
|
|
9
|
+
Select,
|
|
10
|
+
SelectContent,
|
|
11
|
+
SelectItem,
|
|
12
|
+
SelectTrigger,
|
|
13
|
+
SelectValue,
|
|
14
|
+
} from "@/components/ui/select";
|
|
15
|
+
import { ChevronDown, ChevronRight, Loader2, Send } from "lucide-react";
|
|
16
|
+
import { toast } from "sonner";
|
|
17
|
+
import PageHeader from "../../components/PageHeader";
|
|
18
|
+
import ConfirmDialog from "../../components/ConfirmDialog";
|
|
19
|
+
import { base44, call } from "../../lib/api";
|
|
20
|
+
import useAsync from "../../hooks/useAsync";
|
|
21
|
+
import { useAdminHref } from "../../context/BasePathContext";
|
|
22
|
+
import { WEBHOOK_STATUSES, WEBHOOK_TOPICS } from "../../lib/constants";
|
|
23
|
+
import { formatDateTime } from "../../lib/format";
|
|
24
|
+
|
|
25
|
+
const blank = {
|
|
26
|
+
name: "",
|
|
27
|
+
status: "active",
|
|
28
|
+
topic: "order.created",
|
|
29
|
+
delivery_url: "",
|
|
30
|
+
secret: "",
|
|
31
|
+
api_version: "v3",
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const genSecret = () => {
|
|
35
|
+
const bytes = new Uint8Array(24);
|
|
36
|
+
crypto.getRandomValues(bytes);
|
|
37
|
+
return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export default function WebhookEditor() {
|
|
41
|
+
const { id } = useParams();
|
|
42
|
+
const isNew = !id || id === "new";
|
|
43
|
+
const navigate = useNavigate();
|
|
44
|
+
const href = useAdminHref();
|
|
45
|
+
|
|
46
|
+
const [form, setForm] = useState(blank);
|
|
47
|
+
const [loading, setLoading] = useState(!isNew);
|
|
48
|
+
const [saving, setSaving] = useState(false);
|
|
49
|
+
const [confirmDelete, setConfirmDelete] = useState(false);
|
|
50
|
+
const [deleting, setDeleting] = useState(false);
|
|
51
|
+
const [testing, setTesting] = useState(false);
|
|
52
|
+
|
|
53
|
+
useEffect(() => {
|
|
54
|
+
if (isNew) return;
|
|
55
|
+
let cancelled = false;
|
|
56
|
+
setLoading(true);
|
|
57
|
+
base44.entities["commerce.Webhook"].get(id)
|
|
58
|
+
.then((w) => !cancelled && w && setForm({ ...blank, ...w }))
|
|
59
|
+
.catch((err) => toast.error(err.message || "Failed to load webhook"))
|
|
60
|
+
.finally(() => !cancelled && setLoading(false));
|
|
61
|
+
return () => {
|
|
62
|
+
cancelled = true;
|
|
63
|
+
};
|
|
64
|
+
}, [id, isNew]);
|
|
65
|
+
|
|
66
|
+
const set = (key, value) => setForm((f) => ({ ...f, [key]: value }));
|
|
67
|
+
|
|
68
|
+
const save = async () => {
|
|
69
|
+
if (!form.name.trim() || !form.delivery_url.trim()) {
|
|
70
|
+
toast.error("Name and delivery URL are required");
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
setSaving(true);
|
|
74
|
+
try {
|
|
75
|
+
const [resource, event] = form.topic.split(".");
|
|
76
|
+
const payload = {
|
|
77
|
+
name: form.name,
|
|
78
|
+
status: form.status,
|
|
79
|
+
topic: form.topic,
|
|
80
|
+
resource,
|
|
81
|
+
event,
|
|
82
|
+
delivery_url: form.delivery_url,
|
|
83
|
+
secret: form.secret,
|
|
84
|
+
api_version: form.api_version || "v3",
|
|
85
|
+
};
|
|
86
|
+
if (isNew) {
|
|
87
|
+
const created = await base44.entities["commerce.Webhook"].create(payload);
|
|
88
|
+
toast.success("Webhook created");
|
|
89
|
+
navigate(href(`webhooks/${created.id}`));
|
|
90
|
+
} else {
|
|
91
|
+
await base44.entities["commerce.Webhook"].update(id, payload);
|
|
92
|
+
toast.success("Webhook saved");
|
|
93
|
+
}
|
|
94
|
+
} catch (err) {
|
|
95
|
+
toast.error(err.message || "Failed to save webhook");
|
|
96
|
+
} finally {
|
|
97
|
+
setSaving(false);
|
|
98
|
+
}
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
const remove = async () => {
|
|
102
|
+
setDeleting(true);
|
|
103
|
+
try {
|
|
104
|
+
await base44.entities["commerce.Webhook"].delete(id);
|
|
105
|
+
toast.success("Webhook deleted");
|
|
106
|
+
navigate(href("webhooks"));
|
|
107
|
+
} catch (err) {
|
|
108
|
+
toast.error(err.message || "Failed to delete");
|
|
109
|
+
setDeleting(false);
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
const sendTest = async () => {
|
|
114
|
+
setTesting(true);
|
|
115
|
+
try {
|
|
116
|
+
const res = await call("admin-webhooks", "test", { webhook_id: id });
|
|
117
|
+
const code = res?.response_code ?? res?.delivery?.response_code;
|
|
118
|
+
toast.success(code ? `Test delivered — HTTP ${code}` : "Test delivered");
|
|
119
|
+
} catch {
|
|
120
|
+
/* call() already toasts */
|
|
121
|
+
} finally {
|
|
122
|
+
setTesting(false);
|
|
123
|
+
}
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
if (loading) {
|
|
127
|
+
return (
|
|
128
|
+
<div className="flex justify-center py-12">
|
|
129
|
+
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
|
130
|
+
</div>
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return (
|
|
135
|
+
<div className="max-w-3xl space-y-4">
|
|
136
|
+
<PageHeader
|
|
137
|
+
title={isNew ? "Add webhook" : "Edit webhook"}
|
|
138
|
+
backHref={href("webhooks")}
|
|
139
|
+
actions={
|
|
140
|
+
!isNew && (
|
|
141
|
+
<>
|
|
142
|
+
<Button variant="outline" onClick={sendTest} disabled={testing}>
|
|
143
|
+
{testing ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Send className="mr-2 h-4 w-4" />}
|
|
144
|
+
Send test
|
|
145
|
+
</Button>
|
|
146
|
+
<Button variant="outline" className="text-destructive" onClick={() => setConfirmDelete(true)}>
|
|
147
|
+
Delete
|
|
148
|
+
</Button>
|
|
149
|
+
</>
|
|
150
|
+
)
|
|
151
|
+
}
|
|
152
|
+
/>
|
|
153
|
+
|
|
154
|
+
<Card>
|
|
155
|
+
<CardHeader>
|
|
156
|
+
<CardTitle>Webhook details</CardTitle>
|
|
157
|
+
</CardHeader>
|
|
158
|
+
<CardContent className="grid gap-4 sm:grid-cols-2">
|
|
159
|
+
<div className="space-y-1.5 sm:col-span-2">
|
|
160
|
+
<Label>Name</Label>
|
|
161
|
+
<Input value={form.name} onChange={(e) => set("name", e.target.value)} />
|
|
162
|
+
</div>
|
|
163
|
+
<div className="space-y-1.5">
|
|
164
|
+
<Label>Status</Label>
|
|
165
|
+
<Select value={form.status} onValueChange={(v) => set("status", v)}>
|
|
166
|
+
<SelectTrigger><SelectValue /></SelectTrigger>
|
|
167
|
+
<SelectContent>
|
|
168
|
+
{WEBHOOK_STATUSES.map((s) => (
|
|
169
|
+
<SelectItem key={s.value} value={s.value}>{s.label}</SelectItem>
|
|
170
|
+
))}
|
|
171
|
+
</SelectContent>
|
|
172
|
+
</Select>
|
|
173
|
+
</div>
|
|
174
|
+
<div className="space-y-1.5">
|
|
175
|
+
<Label>Topic</Label>
|
|
176
|
+
<Select value={form.topic} onValueChange={(v) => set("topic", v)}>
|
|
177
|
+
<SelectTrigger><SelectValue /></SelectTrigger>
|
|
178
|
+
<SelectContent>
|
|
179
|
+
{WEBHOOK_TOPICS.map((t) => (
|
|
180
|
+
<SelectItem key={t} value={t}>{t}</SelectItem>
|
|
181
|
+
))}
|
|
182
|
+
</SelectContent>
|
|
183
|
+
</Select>
|
|
184
|
+
</div>
|
|
185
|
+
<div className="space-y-1.5 sm:col-span-2">
|
|
186
|
+
<Label>Delivery URL</Label>
|
|
187
|
+
<Input placeholder="https://example.com/webhooks/incoming" value={form.delivery_url} onChange={(e) => set("delivery_url", e.target.value)} />
|
|
188
|
+
</div>
|
|
189
|
+
<div className="space-y-1.5 sm:col-span-2">
|
|
190
|
+
<Label>Secret</Label>
|
|
191
|
+
<div className="flex gap-2">
|
|
192
|
+
<Input value={form.secret} onChange={(e) => set("secret", e.target.value)} placeholder="Used to sign the payload (HMAC-SHA256)" />
|
|
193
|
+
<Button variant="outline" type="button" onClick={() => set("secret", genSecret())}>Generate</Button>
|
|
194
|
+
</div>
|
|
195
|
+
</div>
|
|
196
|
+
<div className="space-y-1.5">
|
|
197
|
+
<Label>API version</Label>
|
|
198
|
+
<Input value={form.api_version} disabled />
|
|
199
|
+
</div>
|
|
200
|
+
</CardContent>
|
|
201
|
+
</Card>
|
|
202
|
+
|
|
203
|
+
<div>
|
|
204
|
+
<Button onClick={save} disabled={saving}>
|
|
205
|
+
{saving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
|
206
|
+
{isNew ? "Create webhook" : "Save changes"}
|
|
207
|
+
</Button>
|
|
208
|
+
</div>
|
|
209
|
+
|
|
210
|
+
{!isNew && <DeliveriesCard webhookId={id} />}
|
|
211
|
+
|
|
212
|
+
<ConfirmDialog
|
|
213
|
+
open={confirmDelete}
|
|
214
|
+
onOpenChange={setConfirmDelete}
|
|
215
|
+
title="Delete webhook?"
|
|
216
|
+
confirmLabel="Delete"
|
|
217
|
+
loading={deleting}
|
|
218
|
+
onConfirm={remove}
|
|
219
|
+
/>
|
|
220
|
+
</div>
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function DeliveriesCard({ webhookId }) {
|
|
225
|
+
const deliveries = useAsync(
|
|
226
|
+
() => base44.entities["commerce.WebhookDelivery"].filter({ webhook_id: webhookId }, "-created_date", 20),
|
|
227
|
+
[webhookId]
|
|
228
|
+
);
|
|
229
|
+
const [expanded, setExpanded] = useState(null);
|
|
230
|
+
const [redelivering, setRedelivering] = useState(null);
|
|
231
|
+
|
|
232
|
+
const redeliver = async (deliveryId) => {
|
|
233
|
+
setRedelivering(deliveryId);
|
|
234
|
+
try {
|
|
235
|
+
await call("admin-webhooks", "redeliver", { delivery_id: deliveryId });
|
|
236
|
+
toast.success("Redelivered");
|
|
237
|
+
deliveries.refetch();
|
|
238
|
+
} catch {
|
|
239
|
+
/* toasted */
|
|
240
|
+
} finally {
|
|
241
|
+
setRedelivering(null);
|
|
242
|
+
}
|
|
243
|
+
};
|
|
244
|
+
|
|
245
|
+
return (
|
|
246
|
+
<Card>
|
|
247
|
+
<CardHeader>
|
|
248
|
+
<CardTitle>Recent deliveries</CardTitle>
|
|
249
|
+
</CardHeader>
|
|
250
|
+
<CardContent className="space-y-2">
|
|
251
|
+
{deliveries.loading && <Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />}
|
|
252
|
+
{!deliveries.loading && (deliveries.data || []).length === 0 && (
|
|
253
|
+
<p className="text-sm text-muted-foreground">No deliveries yet.</p>
|
|
254
|
+
)}
|
|
255
|
+
{(deliveries.data || []).map((d) => {
|
|
256
|
+
const open = expanded === d.id;
|
|
257
|
+
return (
|
|
258
|
+
<div key={d.id} className="rounded-md border">
|
|
259
|
+
<button
|
|
260
|
+
type="button"
|
|
261
|
+
className="flex w-full items-center gap-3 px-3 py-2 text-left text-sm"
|
|
262
|
+
onClick={() => setExpanded(open ? null : d.id)}
|
|
263
|
+
>
|
|
264
|
+
{open ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
|
|
265
|
+
<Badge
|
|
266
|
+
variant="outline"
|
|
267
|
+
className={d.success ? "bg-green-100 text-green-800 border-green-200" : "bg-red-100 text-red-800 border-red-200"}
|
|
268
|
+
>
|
|
269
|
+
{d.response_code || (d.success ? "OK" : "ERR")}
|
|
270
|
+
</Badge>
|
|
271
|
+
<span className="text-muted-foreground">{formatDateTime(d.created_date)}</span>
|
|
272
|
+
<span className="ml-auto text-xs text-muted-foreground">{d.duration_ms != null ? `${d.duration_ms} ms` : ""}</span>
|
|
273
|
+
</button>
|
|
274
|
+
{open && (
|
|
275
|
+
<div className="space-y-2 border-t px-3 py-2 text-xs">
|
|
276
|
+
<div>
|
|
277
|
+
<div className="mb-1 font-medium">Request</div>
|
|
278
|
+
<pre className="max-h-48 overflow-auto rounded bg-muted p-2">{d.request_body || "—"}</pre>
|
|
279
|
+
</div>
|
|
280
|
+
<div>
|
|
281
|
+
<div className="mb-1 font-medium">Response</div>
|
|
282
|
+
<pre className="max-h-48 overflow-auto rounded bg-muted p-2">{d.response_body || "—"}</pre>
|
|
283
|
+
</div>
|
|
284
|
+
<Button size="sm" variant="outline" disabled={redelivering === d.id} onClick={() => redeliver(d.id)}>
|
|
285
|
+
{redelivering === d.id && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
|
286
|
+
Redeliver
|
|
287
|
+
</Button>
|
|
288
|
+
</div>
|
|
289
|
+
)}
|
|
290
|
+
</div>
|
|
291
|
+
);
|
|
292
|
+
})}
|
|
293
|
+
</CardContent>
|
|
294
|
+
</Card>
|
|
295
|
+
);
|
|
296
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { useNavigate } from "react-router-dom";
|
|
3
|
+
import { Button } from "@/components/ui/button";
|
|
4
|
+
import { Plus, Webhook as WebhookIcon } from "lucide-react";
|
|
5
|
+
import PageHeader from "../../components/PageHeader";
|
|
6
|
+
import DataTable from "../../components/DataTable";
|
|
7
|
+
import StatusBadge from "../../components/StatusBadge";
|
|
8
|
+
import { base44 } from "../../lib/api";
|
|
9
|
+
import useAsync from "../../hooks/useAsync";
|
|
10
|
+
import { useAdminHref } from "../../context/BasePathContext";
|
|
11
|
+
import { WEBHOOK_STATUSES } from "../../lib/constants";
|
|
12
|
+
import { formatDate } from "../../lib/format";
|
|
13
|
+
|
|
14
|
+
export default function Webhooks() {
|
|
15
|
+
const navigate = useNavigate();
|
|
16
|
+
const href = useAdminHref();
|
|
17
|
+
const hooks = useAsync(() => base44.entities["commerce.Webhook"].list("-created_date", 200), []);
|
|
18
|
+
|
|
19
|
+
const columns = [
|
|
20
|
+
{ key: "name", label: "Name", render: (w) => <span className="font-medium">{w.name}</span> },
|
|
21
|
+
{ key: "status", label: "Status", render: (w) => <StatusBadge status={w.status} map={WEBHOOK_STATUSES} /> },
|
|
22
|
+
{ key: "topic", label: "Topic", render: (w) => <code className="text-xs">{w.topic}</code> },
|
|
23
|
+
{
|
|
24
|
+
key: "delivery_url",
|
|
25
|
+
label: "Delivery URL",
|
|
26
|
+
render: (w) => <span className="block max-w-xs truncate text-sm text-muted-foreground">{w.delivery_url}</span>,
|
|
27
|
+
},
|
|
28
|
+
{ key: "failure_count", label: "Failures", render: (w) => w.failure_count ?? 0 },
|
|
29
|
+
{ key: "created_date", label: "Created", render: (w) => formatDate(w.created_date) },
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
return (
|
|
33
|
+
<div className="space-y-4">
|
|
34
|
+
<PageHeader
|
|
35
|
+
title="Webhooks"
|
|
36
|
+
description="Notify external services when store events occur."
|
|
37
|
+
actions={
|
|
38
|
+
<Button onClick={() => navigate(href("webhooks/new"))}>
|
|
39
|
+
<Plus className="mr-2 h-4 w-4" />
|
|
40
|
+
Add webhook
|
|
41
|
+
</Button>
|
|
42
|
+
}
|
|
43
|
+
/>
|
|
44
|
+
<DataTable
|
|
45
|
+
columns={columns}
|
|
46
|
+
rows={hooks.data || []}
|
|
47
|
+
loading={hooks.loading}
|
|
48
|
+
onRowClick={(w) => navigate(href(`webhooks/${w.id}`))}
|
|
49
|
+
empty={{ icon: WebhookIcon, title: "No webhooks", description: "Create a webhook to POST event payloads to an external URL." }}
|
|
50
|
+
/>
|
|
51
|
+
</div>
|
|
52
|
+
);
|
|
53
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { Link, Navigate, Route, Routes, useLocation, useParams } from "react-router-dom";
|
|
3
|
+
import { PackageX } from "lucide-react";
|
|
4
|
+
|
|
5
|
+
import { useAdminHref } from "./context/BasePathContext";
|
|
6
|
+
import { isMountPatternPath } from "./lib/paths";
|
|
7
|
+
|
|
8
|
+
import Dashboard from "./pages/Dashboard";
|
|
9
|
+
import OrdersList from "./pages/orders/OrdersList";
|
|
10
|
+
import OrderEditor from "./pages/orders/OrderEditor";
|
|
11
|
+
import ProductsList from "./pages/products/ProductsList";
|
|
12
|
+
import ProductEditor from "./pages/products/ProductEditor";
|
|
13
|
+
import Categories from "./pages/products/Categories";
|
|
14
|
+
import Tags from "./pages/products/Tags";
|
|
15
|
+
import Attributes from "./pages/products/Attributes";
|
|
16
|
+
import AttributeTerms from "./pages/products/AttributeTerms";
|
|
17
|
+
import Reviews from "./pages/products/Reviews";
|
|
18
|
+
import CouponsList from "./pages/coupons/CouponsList";
|
|
19
|
+
import CouponEditor from "./pages/coupons/CouponEditor";
|
|
20
|
+
import CustomersList from "./pages/customers/CustomersList";
|
|
21
|
+
import CustomerEditor from "./pages/customers/CustomerEditor";
|
|
22
|
+
import Reports from "./pages/reports/Reports";
|
|
23
|
+
import SettingsLayout from "./pages/settings/SettingsLayout";
|
|
24
|
+
import GeneralSettings from "./pages/settings/GeneralSettings";
|
|
25
|
+
import ProductsSettings from "./pages/settings/ProductsSettings";
|
|
26
|
+
import InventorySettings from "./pages/settings/InventorySettings";
|
|
27
|
+
import TaxSettings from "./pages/settings/TaxSettings";
|
|
28
|
+
import ShippingSettings from "./pages/settings/ShippingSettings";
|
|
29
|
+
import ShippingZoneEditor from "./pages/settings/ShippingZoneEditor";
|
|
30
|
+
import PaymentsSettings from "./pages/settings/PaymentsSettings";
|
|
31
|
+
import EmailsSettings from "./pages/settings/EmailsSettings";
|
|
32
|
+
import Webhooks from "./pages/status/Webhooks";
|
|
33
|
+
import WebhookEditor from "./pages/status/WebhookEditor";
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Internal route table (paths relative to the mount point).
|
|
37
|
+
* Used by the router below and by Topbar breadcrumbs.
|
|
38
|
+
*/
|
|
39
|
+
export const ADMIN_ROUTES = [
|
|
40
|
+
{ path: "", label: "Dashboard" },
|
|
41
|
+
{ path: "orders", label: "Orders" },
|
|
42
|
+
{ path: "orders/new", label: "Add order" },
|
|
43
|
+
{ path: "orders/:id", label: "Order" },
|
|
44
|
+
{ path: "products", label: "Products" },
|
|
45
|
+
{ path: "products/new", label: "Add product" },
|
|
46
|
+
{ path: "products/categories", label: "Categories" },
|
|
47
|
+
{ path: "products/tags", label: "Tags" },
|
|
48
|
+
{ path: "products/attributes", label: "Attributes" },
|
|
49
|
+
{ path: "products/attributes/:id/terms", label: "Terms" },
|
|
50
|
+
{ path: "products/reviews", label: "Reviews" },
|
|
51
|
+
{ path: "products/:id", label: "Edit product" },
|
|
52
|
+
{ path: "coupons", label: "Coupons" },
|
|
53
|
+
{ path: "coupons/new", label: "Add coupon" },
|
|
54
|
+
{ path: "coupons/:id", label: "Edit coupon" },
|
|
55
|
+
{ path: "customers", label: "Customers" },
|
|
56
|
+
{ path: "customers/new", label: "Add customer" },
|
|
57
|
+
{ path: "customers/:id", label: "Customer" },
|
|
58
|
+
{ path: "reports", label: "Reports" },
|
|
59
|
+
{ path: "settings", label: "Settings" },
|
|
60
|
+
{ path: "settings/general", label: "General" },
|
|
61
|
+
{ path: "settings/products", label: "Products" },
|
|
62
|
+
{ path: "settings/inventory", label: "Inventory" },
|
|
63
|
+
{ path: "settings/tax", label: "Tax" },
|
|
64
|
+
{ path: "settings/shipping", label: "Shipping" },
|
|
65
|
+
{ path: "settings/shipping/zones/:zoneId", label: "Shipping zone" },
|
|
66
|
+
{ path: "settings/payments", label: "Payments" },
|
|
67
|
+
{ path: "settings/emails", label: "Emails" },
|
|
68
|
+
{ path: "webhooks", label: "Webhooks" },
|
|
69
|
+
{ path: "webhooks/new", label: "Add webhook" },
|
|
70
|
+
{ path: "webhooks/:id", label: "Webhook" },
|
|
71
|
+
];
|
|
72
|
+
|
|
73
|
+
function NotFound() {
|
|
74
|
+
const href = useAdminHref();
|
|
75
|
+
const { pathname } = useLocation();
|
|
76
|
+
return (
|
|
77
|
+
<div className="flex flex-col items-center justify-center py-24 text-center">
|
|
78
|
+
<PackageX className="mb-3 h-10 w-10 text-muted-foreground/50" />
|
|
79
|
+
<h2 className="text-lg font-semibold">Page not found</h2>
|
|
80
|
+
<p className="text-sm text-muted-foreground">
|
|
81
|
+
<span className="font-mono">{pathname}</span> is not an admin page.
|
|
82
|
+
</p>
|
|
83
|
+
<Link to={href()} className="mt-4 text-sm font-medium underline">
|
|
84
|
+
Back to the dashboard
|
|
85
|
+
</Link>
|
|
86
|
+
</div>
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Catch-all. A URL that still contains the *mount pattern* rather than a real
|
|
92
|
+
* path — `/admin/*` (the `<Route path="/admin/*">` pattern pasted into the
|
|
93
|
+
* address bar or copied into a nav link), or a leftover `/admin/:id` — is a
|
|
94
|
+
* wiring artifact, not a page the admin asked for: land on the dashboard
|
|
95
|
+
* instead of showing a dead end. Anything else is a genuine 404.
|
|
96
|
+
*/
|
|
97
|
+
function UnmatchedRoute() {
|
|
98
|
+
const href = useAdminHref();
|
|
99
|
+
const params = useParams();
|
|
100
|
+
return isMountPatternPath(params["*"]) ? <Navigate to={href()} replace /> : <NotFound />;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** All admin routes. Rendered inside AdminLayout; mount AdminApp at `/admin/*`. */
|
|
104
|
+
export default function AdminRoutes() {
|
|
105
|
+
return (
|
|
106
|
+
<Routes>
|
|
107
|
+
<Route index element={<Dashboard />} />
|
|
108
|
+
|
|
109
|
+
<Route path="orders" element={<OrdersList />} />
|
|
110
|
+
<Route path="orders/new" element={<OrderEditor />} />
|
|
111
|
+
<Route path="orders/:id" element={<OrderEditor />} />
|
|
112
|
+
|
|
113
|
+
<Route path="products" element={<ProductsList />} />
|
|
114
|
+
<Route path="products/new" element={<ProductEditor />} />
|
|
115
|
+
<Route path="products/categories" element={<Categories />} />
|
|
116
|
+
<Route path="products/tags" element={<Tags />} />
|
|
117
|
+
<Route path="products/attributes" element={<Attributes />} />
|
|
118
|
+
<Route path="products/attributes/:id/terms" element={<AttributeTerms />} />
|
|
119
|
+
<Route path="products/reviews" element={<Reviews />} />
|
|
120
|
+
<Route path="products/:id" element={<ProductEditor />} />
|
|
121
|
+
|
|
122
|
+
<Route path="coupons" element={<CouponsList />} />
|
|
123
|
+
<Route path="coupons/new" element={<CouponEditor />} />
|
|
124
|
+
<Route path="coupons/:id" element={<CouponEditor />} />
|
|
125
|
+
|
|
126
|
+
<Route path="customers" element={<CustomersList />} />
|
|
127
|
+
<Route path="customers/new" element={<CustomerEditor />} />
|
|
128
|
+
<Route path="customers/:id" element={<CustomerEditor />} />
|
|
129
|
+
|
|
130
|
+
<Route path="reports" element={<Reports />} />
|
|
131
|
+
|
|
132
|
+
<Route path="settings" element={<SettingsLayout />}>
|
|
133
|
+
<Route index element={<Navigate to="general" replace />} />
|
|
134
|
+
<Route path="general" element={<GeneralSettings />} />
|
|
135
|
+
<Route path="products" element={<ProductsSettings />} />
|
|
136
|
+
<Route path="inventory" element={<InventorySettings />} />
|
|
137
|
+
<Route path="tax" element={<TaxSettings />} />
|
|
138
|
+
<Route path="shipping" element={<ShippingSettings />} />
|
|
139
|
+
<Route path="shipping/zones/:zoneId" element={<ShippingZoneEditor />} />
|
|
140
|
+
<Route path="payments" element={<PaymentsSettings />} />
|
|
141
|
+
<Route path="emails" element={<EmailsSettings />} />
|
|
142
|
+
</Route>
|
|
143
|
+
|
|
144
|
+
<Route path="webhooks" element={<Webhooks />} />
|
|
145
|
+
<Route path="webhooks/new" element={<WebhookEditor />} />
|
|
146
|
+
<Route path="webhooks/:id" element={<WebhookEditor />} />
|
|
147
|
+
|
|
148
|
+
<Route path="*" element={<UnmatchedRoute />} />
|
|
149
|
+
</Routes>
|
|
150
|
+
);
|
|
151
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Storefront helpers shipped with the Base44 Commerce Template.
|
|
3
|
+
*
|
|
4
|
+
* Framework-free, dependency-free pure functions for the customer-facing UI you
|
|
5
|
+
* build on top of the `commerce/storefront-*` API. No visitor UI ships with the
|
|
6
|
+
* template, but this logic does — it is the part that is easy to get subtly
|
|
7
|
+
* wrong.
|
|
8
|
+
*
|
|
9
|
+
* import { resolveSelection, defaultSelection, selectOption } from "@/commerce/utils";
|
|
10
|
+
*
|
|
11
|
+
* - `variants.js` — variant selection: map attribute selections (Size, Color)
|
|
12
|
+
* to a `ProductVariation` and back, per-option availability, variable-product
|
|
13
|
+
* price ranges. See `skills/commerce/references/storefront-product-page.md`.
|
|
14
|
+
* - `shipping-promos.js` — read the store's real free-shipping configuration so
|
|
15
|
+
* "Free shipping over €150" copy states a configured rule, not an invented
|
|
16
|
+
* number. See `skills/commerce/docs/api-storefront.md`.
|
|
17
|
+
*/
|
|
18
|
+
export * from "./variants.js";
|
|
19
|
+
export * from "./shipping-promos.js";
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read the store's *actual* free-shipping configuration, so storefront copy
|
|
3
|
+
* ("Free shipping over €150", "You're €30 away from free shipping") states a
|
|
4
|
+
* real rule instead of an invented number.
|
|
5
|
+
*
|
|
6
|
+
* A free-shipping claim is only true if a `free_shipping` shipping-zone method
|
|
7
|
+
* exists and its requirement is met — the totals engine offers that method
|
|
8
|
+
* exactly when `min_amount`/coupon conditions hold, so anything the UI promises
|
|
9
|
+
* outside these rules is a promise the checkout will not keep.
|
|
10
|
+
*
|
|
11
|
+
* `commerce.ShippingZone` and `commerce.ShippingZoneMethod` are public-read, so
|
|
12
|
+
* the storefront can load them directly:
|
|
13
|
+
*
|
|
14
|
+
* ```js
|
|
15
|
+
* const [zones, methods] = await Promise.all([
|
|
16
|
+
* base44.entities["commerce.ShippingZone"].list("order", 100),
|
|
17
|
+
* base44.entities["commerce.ShippingZoneMethod"].list(undefined, 200),
|
|
18
|
+
* ]);
|
|
19
|
+
* const rules = freeShippingRules(zones, methods);
|
|
20
|
+
* // no rules → do not advertise free shipping anywhere.
|
|
21
|
+
* ```
|
|
22
|
+
*
|
|
23
|
+
* Framework-free and dependency-free. See
|
|
24
|
+
* `skills/commerce/docs/api-storefront.md` for the surrounding rules.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Normalized free-shipping rules, one per configured `free_shipping` method.
|
|
29
|
+
*
|
|
30
|
+
* @returns {Array<{
|
|
31
|
+
* zone_id: string, zone_name: string, method_id: string, title: string,
|
|
32
|
+
* requires: "" | "coupon" | "min_amount" | "either" | "both",
|
|
33
|
+
* min_amount: number|null, needs_coupon: boolean, ignore_discounts: boolean,
|
|
34
|
+
* unconditional: boolean
|
|
35
|
+
* }>}
|
|
36
|
+
*/
|
|
37
|
+
export function freeShippingRules(zones, methods) {
|
|
38
|
+
const zoneById = new Map((zones ?? []).map((z) => [z.id, z]));
|
|
39
|
+
return (methods ?? [])
|
|
40
|
+
.filter((m) => m?.method_id === "free_shipping" && m.enabled !== false)
|
|
41
|
+
.map((m) => {
|
|
42
|
+
const s = m.settings ?? {};
|
|
43
|
+
const requires = s.requires ?? "";
|
|
44
|
+
const min = Number(s.min_amount);
|
|
45
|
+
return {
|
|
46
|
+
zone_id: m.zone_id ?? "",
|
|
47
|
+
zone_name: zoneById.get(m.zone_id)?.name ?? "",
|
|
48
|
+
method_id: m.id,
|
|
49
|
+
title: m.title || "Free shipping",
|
|
50
|
+
requires,
|
|
51
|
+
min_amount: ["min_amount", "either", "both"].includes(requires) && Number.isFinite(min) ? min : null,
|
|
52
|
+
needs_coupon: ["coupon", "either", "both"].includes(requires),
|
|
53
|
+
ignore_discounts: !!s.ignore_discounts,
|
|
54
|
+
unconditional: !requires,
|
|
55
|
+
};
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The threshold to advertise, or `null` when the store has none — in which case
|
|
61
|
+
* **say nothing about free shipping**. Picks the lowest configured threshold
|
|
62
|
+
* (the one a shopper can actually reach); an unconditional free-shipping method
|
|
63
|
+
* yields `0`, i.e. "free shipping" with no threshold at all.
|
|
64
|
+
*
|
|
65
|
+
* Thresholds are per shipping zone, so a global banner is only honest when the
|
|
66
|
+
* rule covers the customer's zone — pass `zoneId` once you know it (after
|
|
67
|
+
* `set-shipping-address`, the cart's `available_shipping_methods` is the
|
|
68
|
+
* authoritative answer for that address).
|
|
69
|
+
*/
|
|
70
|
+
export function freeShippingThreshold(rules, zoneId = null) {
|
|
71
|
+
const scoped = (rules ?? []).filter((r) => (zoneId ? r.zone_id === zoneId : true));
|
|
72
|
+
if (!scoped.length) return null;
|
|
73
|
+
if (scoped.some((r) => r.unconditional)) return 0;
|
|
74
|
+
const amounts = scoped
|
|
75
|
+
.filter((r) => !r.needs_coupon && r.min_amount != null)
|
|
76
|
+
.map((r) => r.min_amount);
|
|
77
|
+
return amounts.length ? Math.min(...amounts) : null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Progress toward a threshold, for a "spend X more" nudge. `subtotal` should be
|
|
82
|
+
* the cart's `totals.subtotal` — or `subtotal - discount_total` when the rule
|
|
83
|
+
* does not `ignore_discounts`, matching how the engine evaluates it.
|
|
84
|
+
*
|
|
85
|
+
* @returns {{threshold: number, qualifies: boolean, remaining: number}|null}
|
|
86
|
+
* `null` when there is no threshold to talk about.
|
|
87
|
+
*/
|
|
88
|
+
export function freeShippingProgress(threshold, subtotal) {
|
|
89
|
+
if (threshold == null) return null;
|
|
90
|
+
const spent = Number(subtotal) || 0;
|
|
91
|
+
const qualifies = spent >= threshold;
|
|
92
|
+
return {
|
|
93
|
+
threshold,
|
|
94
|
+
qualifies,
|
|
95
|
+
// Rounded *up* to the cent: a sub-cent gap must not render as
|
|
96
|
+
// "0.00 away from free shipping" while the order still doesn't qualify.
|
|
97
|
+
remaining: qualifies ? 0 : Math.ceil((threshold - spent) * 100) / 100,
|
|
98
|
+
};
|
|
99
|
+
}
|