@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,208 @@
|
|
|
1
|
+
import React, { useEffect, useState } from "react";
|
|
2
|
+
import { Button } from "@/components/ui/button";
|
|
3
|
+
import { Input } from "@/components/ui/input";
|
|
4
|
+
import { Label } from "@/components/ui/label";
|
|
5
|
+
import { Badge } from "@/components/ui/badge";
|
|
6
|
+
import { Checkbox } from "@/components/ui/checkbox";
|
|
7
|
+
import { Card } from "@/components/ui/card";
|
|
8
|
+
import {
|
|
9
|
+
Select,
|
|
10
|
+
SelectContent,
|
|
11
|
+
SelectItem,
|
|
12
|
+
SelectTrigger,
|
|
13
|
+
SelectValue,
|
|
14
|
+
} from "@/components/ui/select";
|
|
15
|
+
import { Globe, Plus, Trash2 } from "lucide-react";
|
|
16
|
+
|
|
17
|
+
import { base44 } from "../../../../lib/api";
|
|
18
|
+
import SearchSelect from "../../../../components/SearchSelect";
|
|
19
|
+
import { isVariable } from "../../../../lib/product-utils";
|
|
20
|
+
|
|
21
|
+
const CUSTOM = "__custom__";
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Assign global attributes (with term multi-select) or ad-hoc custom
|
|
25
|
+
* attributes ("Red | Blue | Green"). `variation: true` rows drive the
|
|
26
|
+
* Variations tab for variable products.
|
|
27
|
+
*/
|
|
28
|
+
export default function AttributesTab({ product, up, refs }) {
|
|
29
|
+
const globalAttributes = refs.attributes || [];
|
|
30
|
+
const attributes = product.attributes || [];
|
|
31
|
+
const [addValue, setAddValue] = useState("");
|
|
32
|
+
const [termsCache, setTermsCache] = useState({}); // attribute_id -> [{id, name}]
|
|
33
|
+
|
|
34
|
+
// Load terms for every global attribute in use.
|
|
35
|
+
useEffect(() => {
|
|
36
|
+
const wanted = attributes.map((a) => a.attribute_id).filter(Boolean);
|
|
37
|
+
const missing = wanted.filter((id) => !termsCache[id]);
|
|
38
|
+
if (!missing.length) return;
|
|
39
|
+
let cancelled = false;
|
|
40
|
+
Promise.all(
|
|
41
|
+
missing.map((id) =>
|
|
42
|
+
base44.entities["commerce.ProductAttributeTerm"].filter({ attribute_id: id }, "menu_order", 500)
|
|
43
|
+
.then((terms) => [id, terms || []])
|
|
44
|
+
.catch(() => [id, []])
|
|
45
|
+
)
|
|
46
|
+
).then((entries) => {
|
|
47
|
+
if (!cancelled) setTermsCache((c) => ({ ...c, ...Object.fromEntries(entries) }));
|
|
48
|
+
});
|
|
49
|
+
return () => {
|
|
50
|
+
cancelled = true;
|
|
51
|
+
};
|
|
52
|
+
// eslint-disable-next-line -- deps intentionally partial
|
|
53
|
+
}, [JSON.stringify(attributes.map((a) => a.attribute_id))]);
|
|
54
|
+
|
|
55
|
+
const setRow = (index, patch) => {
|
|
56
|
+
const next = attributes.map((a, i) => (i === index ? { ...a, ...patch } : a));
|
|
57
|
+
up({ attributes: next });
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const removeRow = (index) => {
|
|
61
|
+
up({ attributes: attributes.filter((_, i) => i !== index) });
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const addAttribute = () => {
|
|
65
|
+
if (!addValue) return;
|
|
66
|
+
if (addValue === CUSTOM) {
|
|
67
|
+
up({
|
|
68
|
+
attributes: [
|
|
69
|
+
...attributes,
|
|
70
|
+
{ attribute_id: "", name: "", position: attributes.length, visible: true, variation: false, options: [] },
|
|
71
|
+
],
|
|
72
|
+
});
|
|
73
|
+
} else {
|
|
74
|
+
const attr = globalAttributes.find((a) => a.id === addValue);
|
|
75
|
+
if (!attr || attributes.some((a) => a.attribute_id === attr.id)) return;
|
|
76
|
+
up({
|
|
77
|
+
attributes: [
|
|
78
|
+
...attributes,
|
|
79
|
+
{
|
|
80
|
+
attribute_id: attr.id,
|
|
81
|
+
name: attr.name,
|
|
82
|
+
position: attributes.length,
|
|
83
|
+
visible: true,
|
|
84
|
+
variation: false,
|
|
85
|
+
options: [],
|
|
86
|
+
},
|
|
87
|
+
],
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
setAddValue("");
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
return (
|
|
94
|
+
<div className="space-y-4">
|
|
95
|
+
<div className="flex items-end gap-2">
|
|
96
|
+
<div className="space-y-1.5">
|
|
97
|
+
<Label>Add attribute</Label>
|
|
98
|
+
<Select value={addValue} onValueChange={setAddValue}>
|
|
99
|
+
<SelectTrigger className="w-64">
|
|
100
|
+
<SelectValue placeholder="Select an attribute…" />
|
|
101
|
+
</SelectTrigger>
|
|
102
|
+
<SelectContent>
|
|
103
|
+
<SelectItem value={CUSTOM}>Custom product attribute</SelectItem>
|
|
104
|
+
{globalAttributes
|
|
105
|
+
.filter((a) => !attributes.some((row) => row.attribute_id === a.id))
|
|
106
|
+
.map((a) => (
|
|
107
|
+
<SelectItem key={a.id} value={a.id}>{a.name}</SelectItem>
|
|
108
|
+
))}
|
|
109
|
+
</SelectContent>
|
|
110
|
+
</Select>
|
|
111
|
+
</div>
|
|
112
|
+
<Button type="button" variant="outline" onClick={addAttribute} disabled={!addValue}>
|
|
113
|
+
<Plus className="mr-1 h-4 w-4" /> Add
|
|
114
|
+
</Button>
|
|
115
|
+
</div>
|
|
116
|
+
|
|
117
|
+
{attributes.length === 0 && (
|
|
118
|
+
<p className="text-sm text-muted-foreground">
|
|
119
|
+
No attributes yet. Attributes describe the product (e.g. Color, Size) and can power variations.
|
|
120
|
+
</p>
|
|
121
|
+
)}
|
|
122
|
+
|
|
123
|
+
<div className="space-y-3">
|
|
124
|
+
{attributes.map((row, i) => {
|
|
125
|
+
const isGlobal = Boolean(row.attribute_id);
|
|
126
|
+
const terms = termsCache[row.attribute_id] || [];
|
|
127
|
+
return (
|
|
128
|
+
<Card key={`${row.attribute_id || "custom"}-${i}`} className="p-3">
|
|
129
|
+
<div className="flex items-start justify-between gap-2">
|
|
130
|
+
<div className="min-w-0 flex-1 space-y-3">
|
|
131
|
+
<div className="flex items-center gap-2">
|
|
132
|
+
{isGlobal ? (
|
|
133
|
+
<>
|
|
134
|
+
<span className="font-medium">{row.name}</span>
|
|
135
|
+
<Badge variant="secondary" className="gap-1">
|
|
136
|
+
<Globe className="h-3 w-3" /> Global
|
|
137
|
+
</Badge>
|
|
138
|
+
</>
|
|
139
|
+
) : (
|
|
140
|
+
<Input
|
|
141
|
+
className="h-8 w-56"
|
|
142
|
+
placeholder="Attribute name (e.g. Material)"
|
|
143
|
+
value={row.name}
|
|
144
|
+
onChange={(e) => setRow(i, { name: e.target.value })}
|
|
145
|
+
/>
|
|
146
|
+
)}
|
|
147
|
+
</div>
|
|
148
|
+
|
|
149
|
+
{isGlobal ? (
|
|
150
|
+
<SearchSelect
|
|
151
|
+
multiple
|
|
152
|
+
placeholder="Select terms…"
|
|
153
|
+
value={(row.options || []).map((o) => ({ value: o, label: o }))}
|
|
154
|
+
onChange={(next) => setRow(i, { options: next.map((o) => o.value) })}
|
|
155
|
+
search={async (q) =>
|
|
156
|
+
terms
|
|
157
|
+
.filter((t) => !q || t.name.toLowerCase().includes(q.toLowerCase()))
|
|
158
|
+
.map((t) => ({ value: t.name, label: t.name }))
|
|
159
|
+
}
|
|
160
|
+
/>
|
|
161
|
+
) : (
|
|
162
|
+
<div className="space-y-1">
|
|
163
|
+
<Input
|
|
164
|
+
placeholder="Values separated by | (e.g. Cotton | Wool | Silk)"
|
|
165
|
+
value={(row.options || []).join(" | ")}
|
|
166
|
+
onChange={(e) =>
|
|
167
|
+
setRow(i, {
|
|
168
|
+
options: e.target.value
|
|
169
|
+
.split("|")
|
|
170
|
+
.map((s) => s.trim())
|
|
171
|
+
.filter(Boolean),
|
|
172
|
+
})
|
|
173
|
+
}
|
|
174
|
+
/>
|
|
175
|
+
<p className="text-xs text-muted-foreground">Separate values with “|”.</p>
|
|
176
|
+
</div>
|
|
177
|
+
)}
|
|
178
|
+
|
|
179
|
+
<div className="flex items-center gap-4 text-sm">
|
|
180
|
+
<label className="flex items-center gap-1.5">
|
|
181
|
+
<Checkbox
|
|
182
|
+
checked={row.visible !== false}
|
|
183
|
+
onCheckedChange={(v) => setRow(i, { visible: Boolean(v) })}
|
|
184
|
+
/>
|
|
185
|
+
Visible on the product page
|
|
186
|
+
</label>
|
|
187
|
+
{isVariable(product) && (
|
|
188
|
+
<label className="flex items-center gap-1.5">
|
|
189
|
+
<Checkbox
|
|
190
|
+
checked={Boolean(row.variation)}
|
|
191
|
+
onCheckedChange={(v) => setRow(i, { variation: Boolean(v) })}
|
|
192
|
+
/>
|
|
193
|
+
Used for variations
|
|
194
|
+
</label>
|
|
195
|
+
)}
|
|
196
|
+
</div>
|
|
197
|
+
</div>
|
|
198
|
+
<Button type="button" variant="ghost" size="icon" onClick={() => removeRow(i)}>
|
|
199
|
+
<Trash2 className="h-4 w-4 text-muted-foreground" />
|
|
200
|
+
</Button>
|
|
201
|
+
</div>
|
|
202
|
+
</Card>
|
|
203
|
+
);
|
|
204
|
+
})}
|
|
205
|
+
</div>
|
|
206
|
+
</div>
|
|
207
|
+
);
|
|
208
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { Button } from "@/components/ui/button";
|
|
3
|
+
import { Input } from "@/components/ui/input";
|
|
4
|
+
import { Label } from "@/components/ui/label";
|
|
5
|
+
import { Plus, Trash2 } from "lucide-react";
|
|
6
|
+
|
|
7
|
+
import MediaUploader from "../../../../components/MediaUploader";
|
|
8
|
+
|
|
9
|
+
const num = (v, fallback) => (v === "" ? fallback : Number(v));
|
|
10
|
+
|
|
11
|
+
/** Downloadable files, download limit and expiry. */
|
|
12
|
+
export default function DownloadsTab({ product, up }) {
|
|
13
|
+
const downloads = product.downloads || [];
|
|
14
|
+
|
|
15
|
+
const setRow = (i, patch) =>
|
|
16
|
+
up({ downloads: downloads.map((d, j) => (j === i ? { ...d, ...patch } : d)) });
|
|
17
|
+
|
|
18
|
+
return (
|
|
19
|
+
<div className="max-w-xl space-y-4">
|
|
20
|
+
<div className="space-y-3">
|
|
21
|
+
<Label>Downloadable files</Label>
|
|
22
|
+
{downloads.map((d, i) => (
|
|
23
|
+
<div key={i} className="grid gap-2 rounded-md border p-3 sm:grid-cols-[1fr_1fr_auto]">
|
|
24
|
+
<div className="space-y-1.5">
|
|
25
|
+
<Label className="text-xs">Name</Label>
|
|
26
|
+
<Input
|
|
27
|
+
placeholder="e.g. User manual"
|
|
28
|
+
value={d.name || ""}
|
|
29
|
+
onChange={(e) => setRow(i, { name: e.target.value })}
|
|
30
|
+
/>
|
|
31
|
+
</div>
|
|
32
|
+
<div className="space-y-1.5">
|
|
33
|
+
<Label className="text-xs">File</Label>
|
|
34
|
+
{d.file_url ? (
|
|
35
|
+
<div className="flex items-center gap-2">
|
|
36
|
+
<Input value={d.file_url} onChange={(e) => setRow(i, { file_url: e.target.value })} />
|
|
37
|
+
</div>
|
|
38
|
+
) : (
|
|
39
|
+
<MediaUploader
|
|
40
|
+
accept="*/*"
|
|
41
|
+
label="Upload file"
|
|
42
|
+
value={null}
|
|
43
|
+
onChange={(img) => img && setRow(i, { file_url: img.src, name: d.name || img.name })}
|
|
44
|
+
/>
|
|
45
|
+
)}
|
|
46
|
+
</div>
|
|
47
|
+
<div className="flex items-end">
|
|
48
|
+
<Button
|
|
49
|
+
type="button"
|
|
50
|
+
variant="ghost"
|
|
51
|
+
size="icon"
|
|
52
|
+
onClick={() => up({ downloads: downloads.filter((_, j) => j !== i) })}
|
|
53
|
+
>
|
|
54
|
+
<Trash2 className="h-4 w-4 text-muted-foreground" />
|
|
55
|
+
</Button>
|
|
56
|
+
</div>
|
|
57
|
+
</div>
|
|
58
|
+
))}
|
|
59
|
+
<Button
|
|
60
|
+
type="button"
|
|
61
|
+
variant="outline"
|
|
62
|
+
size="sm"
|
|
63
|
+
onClick={() => up({ downloads: [...downloads, { name: "", file_url: "" }] })}
|
|
64
|
+
>
|
|
65
|
+
<Plus className="mr-1 h-4 w-4" /> Add file
|
|
66
|
+
</Button>
|
|
67
|
+
</div>
|
|
68
|
+
|
|
69
|
+
<div className="grid max-w-md grid-cols-2 gap-2">
|
|
70
|
+
<div className="space-y-1.5">
|
|
71
|
+
<Label>Download limit</Label>
|
|
72
|
+
<Input
|
|
73
|
+
type="number"
|
|
74
|
+
value={product.download_limit ?? -1}
|
|
75
|
+
onChange={(e) => up({ download_limit: num(e.target.value, -1) })}
|
|
76
|
+
/>
|
|
77
|
+
<p className="text-xs text-muted-foreground">-1 for unlimited downloads.</p>
|
|
78
|
+
</div>
|
|
79
|
+
<div className="space-y-1.5">
|
|
80
|
+
<Label>Download expiry (days)</Label>
|
|
81
|
+
<Input
|
|
82
|
+
type="number"
|
|
83
|
+
value={product.download_expiry ?? -1}
|
|
84
|
+
onChange={(e) => up({ download_expiry: num(e.target.value, -1) })}
|
|
85
|
+
/>
|
|
86
|
+
<p className="text-xs text-muted-foreground">-1 for no expiry.</p>
|
|
87
|
+
</div>
|
|
88
|
+
</div>
|
|
89
|
+
</div>
|
|
90
|
+
);
|
|
91
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { Input } from "@/components/ui/input";
|
|
3
|
+
import { Label } from "@/components/ui/label";
|
|
4
|
+
|
|
5
|
+
import MoneyInput from "../../../../components/MoneyInput";
|
|
6
|
+
|
|
7
|
+
/** External/affiliate product: outbound URL + button text + pricing. */
|
|
8
|
+
export default function ExternalTab({ product, up }) {
|
|
9
|
+
return (
|
|
10
|
+
<div className="max-w-md space-y-4">
|
|
11
|
+
<div className="space-y-1.5">
|
|
12
|
+
<Label>Product URL</Label>
|
|
13
|
+
<Input
|
|
14
|
+
type="url"
|
|
15
|
+
placeholder="https://example.com/product"
|
|
16
|
+
value={product.external_url || ""}
|
|
17
|
+
onChange={(e) => up({ external_url: e.target.value })}
|
|
18
|
+
/>
|
|
19
|
+
<p className="text-xs text-muted-foreground">Where customers are sent to buy this product.</p>
|
|
20
|
+
</div>
|
|
21
|
+
<div className="space-y-1.5">
|
|
22
|
+
<Label>Button text</Label>
|
|
23
|
+
<Input
|
|
24
|
+
placeholder="Buy product"
|
|
25
|
+
value={product.button_text || ""}
|
|
26
|
+
onChange={(e) => up({ button_text: e.target.value })}
|
|
27
|
+
/>
|
|
28
|
+
</div>
|
|
29
|
+
<div className="grid grid-cols-2 gap-2">
|
|
30
|
+
<div className="space-y-1.5">
|
|
31
|
+
<Label>Regular price</Label>
|
|
32
|
+
<MoneyInput value={product.regular_price} onChange={(v) => up({ regular_price: v })} />
|
|
33
|
+
</div>
|
|
34
|
+
<div className="space-y-1.5">
|
|
35
|
+
<Label>Sale price</Label>
|
|
36
|
+
<MoneyInput value={product.sale_price} onChange={(v) => up({ sale_price: v })} />
|
|
37
|
+
</div>
|
|
38
|
+
</div>
|
|
39
|
+
</div>
|
|
40
|
+
);
|
|
41
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { Input } from "@/components/ui/input";
|
|
3
|
+
import { Label } from "@/components/ui/label";
|
|
4
|
+
import { Switch } from "@/components/ui/switch";
|
|
5
|
+
import {
|
|
6
|
+
Select,
|
|
7
|
+
SelectContent,
|
|
8
|
+
SelectItem,
|
|
9
|
+
SelectTrigger,
|
|
10
|
+
SelectValue,
|
|
11
|
+
} from "@/components/ui/select";
|
|
12
|
+
|
|
13
|
+
import MoneyInput from "../../../../components/MoneyInput";
|
|
14
|
+
import { TAX_STATUSES } from "../../../../lib/constants";
|
|
15
|
+
import { isVariable } from "../../../../lib/product-utils";
|
|
16
|
+
|
|
17
|
+
/** Convert an ISO datetime to the value a date input expects (yyyy-mm-dd). */
|
|
18
|
+
const toDateInput = (iso) => (iso ? String(iso).slice(0, 10) : "");
|
|
19
|
+
const fromDateInput = (v) => (v ? new Date(`${v}T00:00:00Z`).toISOString() : null);
|
|
20
|
+
|
|
21
|
+
/** Pricing + tax. For variable products only tax fields apply (prices per variation). */
|
|
22
|
+
export default function GeneralTab({ product, up, refs }) {
|
|
23
|
+
const scheduled = Boolean(product.date_on_sale_from || product.date_on_sale_to);
|
|
24
|
+
const taxClasses = refs.taxClasses || [];
|
|
25
|
+
|
|
26
|
+
return (
|
|
27
|
+
<div className="max-w-md space-y-4">
|
|
28
|
+
{isVariable(product) ? (
|
|
29
|
+
<p className="text-sm text-muted-foreground">
|
|
30
|
+
Prices for variable products are set per variation on the Variations tab.
|
|
31
|
+
</p>
|
|
32
|
+
) : (
|
|
33
|
+
<>
|
|
34
|
+
<div className="space-y-1.5">
|
|
35
|
+
<Label>Regular price</Label>
|
|
36
|
+
<MoneyInput
|
|
37
|
+
value={product.regular_price}
|
|
38
|
+
onChange={(v) => up({ regular_price: v })}
|
|
39
|
+
/>
|
|
40
|
+
</div>
|
|
41
|
+
<div className="space-y-1.5">
|
|
42
|
+
<Label>Sale price</Label>
|
|
43
|
+
<MoneyInput value={product.sale_price} onChange={(v) => up({ sale_price: v })} />
|
|
44
|
+
</div>
|
|
45
|
+
<div className="flex items-center gap-2">
|
|
46
|
+
<Switch
|
|
47
|
+
checked={scheduled}
|
|
48
|
+
onCheckedChange={(v) =>
|
|
49
|
+
up(v ? { date_on_sale_from: new Date().toISOString() } : { date_on_sale_from: null, date_on_sale_to: null })
|
|
50
|
+
}
|
|
51
|
+
/>
|
|
52
|
+
<Label>Schedule sale</Label>
|
|
53
|
+
</div>
|
|
54
|
+
{scheduled && (
|
|
55
|
+
<div className="grid grid-cols-2 gap-2">
|
|
56
|
+
<div className="space-y-1.5">
|
|
57
|
+
<Label className="text-xs">Sale start</Label>
|
|
58
|
+
<Input
|
|
59
|
+
type="date"
|
|
60
|
+
value={toDateInput(product.date_on_sale_from)}
|
|
61
|
+
onChange={(e) => up({ date_on_sale_from: fromDateInput(e.target.value) })}
|
|
62
|
+
/>
|
|
63
|
+
</div>
|
|
64
|
+
<div className="space-y-1.5">
|
|
65
|
+
<Label className="text-xs">Sale end</Label>
|
|
66
|
+
<Input
|
|
67
|
+
type="date"
|
|
68
|
+
value={toDateInput(product.date_on_sale_to)}
|
|
69
|
+
onChange={(e) => up({ date_on_sale_to: fromDateInput(e.target.value) })}
|
|
70
|
+
/>
|
|
71
|
+
</div>
|
|
72
|
+
</div>
|
|
73
|
+
)}
|
|
74
|
+
</>
|
|
75
|
+
)}
|
|
76
|
+
|
|
77
|
+
<div className="grid grid-cols-2 gap-2">
|
|
78
|
+
<div className="space-y-1.5">
|
|
79
|
+
<Label>Tax status</Label>
|
|
80
|
+
<Select value={product.tax_status} onValueChange={(v) => up({ tax_status: v })}>
|
|
81
|
+
<SelectTrigger><SelectValue /></SelectTrigger>
|
|
82
|
+
<SelectContent>
|
|
83
|
+
{TAX_STATUSES.map((t) => (
|
|
84
|
+
<SelectItem key={t.value} value={t.value}>{t.label}</SelectItem>
|
|
85
|
+
))}
|
|
86
|
+
</SelectContent>
|
|
87
|
+
</Select>
|
|
88
|
+
</div>
|
|
89
|
+
<div className="space-y-1.5">
|
|
90
|
+
<Label>Tax class</Label>
|
|
91
|
+
<Select value={product.tax_class || "standard"} onValueChange={(v) => up({ tax_class: v })}>
|
|
92
|
+
<SelectTrigger><SelectValue /></SelectTrigger>
|
|
93
|
+
<SelectContent>
|
|
94
|
+
{(taxClasses.length ? taxClasses : [{ slug: "standard", name: "Standard" }]).map((c) => (
|
|
95
|
+
<SelectItem key={c.slug} value={c.slug}>{c.name}</SelectItem>
|
|
96
|
+
))}
|
|
97
|
+
</SelectContent>
|
|
98
|
+
</Select>
|
|
99
|
+
</div>
|
|
100
|
+
</div>
|
|
101
|
+
</div>
|
|
102
|
+
);
|
|
103
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { Input } from "@/components/ui/input";
|
|
3
|
+
import { Label } from "@/components/ui/label";
|
|
4
|
+
import { Switch } from "@/components/ui/switch";
|
|
5
|
+
import { Checkbox } from "@/components/ui/checkbox";
|
|
6
|
+
import {
|
|
7
|
+
Select,
|
|
8
|
+
SelectContent,
|
|
9
|
+
SelectItem,
|
|
10
|
+
SelectTrigger,
|
|
11
|
+
SelectValue,
|
|
12
|
+
} from "@/components/ui/select";
|
|
13
|
+
|
|
14
|
+
import { BACKORDER_OPTIONS, STOCK_STATUSES } from "../../../../lib/constants";
|
|
15
|
+
|
|
16
|
+
const num = (v) => (v === "" ? null : Number(v));
|
|
17
|
+
|
|
18
|
+
/** SKU + stock management. */
|
|
19
|
+
export default function InventoryTab({ product, up }) {
|
|
20
|
+
return (
|
|
21
|
+
<div className="max-w-md space-y-4">
|
|
22
|
+
<div className="space-y-1.5">
|
|
23
|
+
<Label>SKU</Label>
|
|
24
|
+
<Input
|
|
25
|
+
placeholder="Unique stock keeping unit"
|
|
26
|
+
value={product.sku || ""}
|
|
27
|
+
onChange={(e) => up({ sku: e.target.value })}
|
|
28
|
+
/>
|
|
29
|
+
</div>
|
|
30
|
+
|
|
31
|
+
<div className="flex items-center gap-2">
|
|
32
|
+
<Switch
|
|
33
|
+
checked={product.manage_stock}
|
|
34
|
+
onCheckedChange={(v) => up({ manage_stock: Boolean(v) })}
|
|
35
|
+
/>
|
|
36
|
+
<Label>Track stock quantity for this product</Label>
|
|
37
|
+
</div>
|
|
38
|
+
|
|
39
|
+
{product.manage_stock ? (
|
|
40
|
+
<>
|
|
41
|
+
<div className="space-y-1.5">
|
|
42
|
+
<Label>Stock quantity</Label>
|
|
43
|
+
<Input
|
|
44
|
+
type="number"
|
|
45
|
+
value={product.stock_quantity ?? ""}
|
|
46
|
+
onChange={(e) => up({ stock_quantity: num(e.target.value) })}
|
|
47
|
+
/>
|
|
48
|
+
</div>
|
|
49
|
+
<div className="space-y-1.5">
|
|
50
|
+
<Label>Allow backorders?</Label>
|
|
51
|
+
<Select value={product.backorders || "no"} onValueChange={(v) => up({ backorders: v })}>
|
|
52
|
+
<SelectTrigger><SelectValue /></SelectTrigger>
|
|
53
|
+
<SelectContent>
|
|
54
|
+
{BACKORDER_OPTIONS.map((o) => (
|
|
55
|
+
<SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>
|
|
56
|
+
))}
|
|
57
|
+
</SelectContent>
|
|
58
|
+
</Select>
|
|
59
|
+
</div>
|
|
60
|
+
<div className="space-y-1.5">
|
|
61
|
+
<Label>Low stock threshold</Label>
|
|
62
|
+
<Input
|
|
63
|
+
type="number"
|
|
64
|
+
placeholder="Store default"
|
|
65
|
+
value={product.low_stock_amount ?? ""}
|
|
66
|
+
onChange={(e) => up({ low_stock_amount: num(e.target.value) })}
|
|
67
|
+
/>
|
|
68
|
+
</div>
|
|
69
|
+
</>
|
|
70
|
+
) : (
|
|
71
|
+
<div className="space-y-1.5">
|
|
72
|
+
<Label>Stock status</Label>
|
|
73
|
+
<Select value={product.stock_status || "instock"} onValueChange={(v) => up({ stock_status: v })}>
|
|
74
|
+
<SelectTrigger><SelectValue /></SelectTrigger>
|
|
75
|
+
<SelectContent>
|
|
76
|
+
{STOCK_STATUSES.map((s) => (
|
|
77
|
+
<SelectItem key={s.value} value={s.value}>{s.label}</SelectItem>
|
|
78
|
+
))}
|
|
79
|
+
</SelectContent>
|
|
80
|
+
</Select>
|
|
81
|
+
</div>
|
|
82
|
+
)}
|
|
83
|
+
|
|
84
|
+
<label className="flex items-center gap-2 text-sm">
|
|
85
|
+
<Checkbox
|
|
86
|
+
checked={product.sold_individually}
|
|
87
|
+
onCheckedChange={(v) => up({ sold_individually: Boolean(v) })}
|
|
88
|
+
/>
|
|
89
|
+
Limit purchases to 1 item per order
|
|
90
|
+
</label>
|
|
91
|
+
</div>
|
|
92
|
+
);
|
|
93
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import React, { useEffect, useState } from "react";
|
|
2
|
+
import { Label } from "@/components/ui/label";
|
|
3
|
+
|
|
4
|
+
import { base44, call } from "../../../../lib/api";
|
|
5
|
+
import SearchSelect from "../../../../components/SearchSelect";
|
|
6
|
+
import { isGrouped } from "../../../../lib/product-utils";
|
|
7
|
+
|
|
8
|
+
/** Search products server-side for the pickers. */
|
|
9
|
+
const searchProducts = async (q) => {
|
|
10
|
+
const data = await call("admin-products", "search", { q: q || undefined, limit: 10 }, { silent: true });
|
|
11
|
+
const rows = data?.rows || data || [];
|
|
12
|
+
return rows.map((p) => ({ value: p.id, label: p.name, meta: p.sku || undefined }));
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Multi product picker bound to an array of product ids.
|
|
17
|
+
* Resolves labels for pre-existing ids on mount.
|
|
18
|
+
*/
|
|
19
|
+
function ProductIdsPicker({ ids, onChange, placeholder }) {
|
|
20
|
+
const [options, setOptions] = useState(null); // [{value,label}]
|
|
21
|
+
|
|
22
|
+
useEffect(() => {
|
|
23
|
+
let cancelled = false;
|
|
24
|
+
const known = new Map((options || []).map((o) => [o.value, o]));
|
|
25
|
+
const missing = (ids || []).filter((id) => !known.has(id));
|
|
26
|
+
if (!missing.length) {
|
|
27
|
+
if (options === null) setOptions([]);
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
Promise.all(
|
|
31
|
+
missing.map((id) =>
|
|
32
|
+
base44.entities["commerce.Product"].get(id)
|
|
33
|
+
.then((p) => ({ value: id, label: p?.name || "(deleted product)" }))
|
|
34
|
+
.catch(() => ({ value: id, label: "(deleted product)" }))
|
|
35
|
+
)
|
|
36
|
+
).then((resolved) => {
|
|
37
|
+
if (!cancelled) setOptions([...(options || []), ...resolved]);
|
|
38
|
+
});
|
|
39
|
+
return () => {
|
|
40
|
+
cancelled = true;
|
|
41
|
+
};
|
|
42
|
+
// eslint-disable-next-line -- deps intentionally partial
|
|
43
|
+
}, [JSON.stringify(ids)]);
|
|
44
|
+
|
|
45
|
+
const value = (ids || []).map(
|
|
46
|
+
(id) => (options || []).find((o) => o.value === id) || { value: id, label: "…" }
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
return (
|
|
50
|
+
<SearchSelect
|
|
51
|
+
multiple
|
|
52
|
+
placeholder={placeholder}
|
|
53
|
+
search={searchProducts}
|
|
54
|
+
value={value}
|
|
55
|
+
onChange={(next) => {
|
|
56
|
+
setOptions(next);
|
|
57
|
+
onChange(next.map((o) => o.value));
|
|
58
|
+
}}
|
|
59
|
+
/>
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Upsells, cross-sells and (for grouped products) child products. */
|
|
64
|
+
export default function LinkedTab({ product, up }) {
|
|
65
|
+
return (
|
|
66
|
+
<div className="max-w-md space-y-4">
|
|
67
|
+
{isGrouped(product) && (
|
|
68
|
+
<div className="space-y-1.5">
|
|
69
|
+
<Label>Grouped products</Label>
|
|
70
|
+
<ProductIdsPicker
|
|
71
|
+
ids={product.grouped_products}
|
|
72
|
+
onChange={(grouped_products) => up({ grouped_products })}
|
|
73
|
+
placeholder="Search for products to group…"
|
|
74
|
+
/>
|
|
75
|
+
<p className="text-xs text-muted-foreground">Products shown as part of this grouped product.</p>
|
|
76
|
+
</div>
|
|
77
|
+
)}
|
|
78
|
+
|
|
79
|
+
<div className="space-y-1.5">
|
|
80
|
+
<Label>Upsells</Label>
|
|
81
|
+
<ProductIdsPicker
|
|
82
|
+
ids={product.upsell_ids}
|
|
83
|
+
onChange={(upsell_ids) => up({ upsell_ids })}
|
|
84
|
+
placeholder="Search for products to upsell…"
|
|
85
|
+
/>
|
|
86
|
+
<p className="text-xs text-muted-foreground">
|
|
87
|
+
Recommended instead of this product, e.g. premium alternatives.
|
|
88
|
+
</p>
|
|
89
|
+
</div>
|
|
90
|
+
|
|
91
|
+
<div className="space-y-1.5">
|
|
92
|
+
<Label>Cross-sells</Label>
|
|
93
|
+
<ProductIdsPicker
|
|
94
|
+
ids={product.cross_sell_ids}
|
|
95
|
+
onChange={(cross_sell_ids) => up({ cross_sell_ids })}
|
|
96
|
+
placeholder="Search for products to cross-sell…"
|
|
97
|
+
/>
|
|
98
|
+
<p className="text-xs text-muted-foreground">Promoted in the cart alongside this product.</p>
|
|
99
|
+
</div>
|
|
100
|
+
</div>
|
|
101
|
+
);
|
|
102
|
+
}
|