@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,150 @@
|
|
|
1
|
+
import React, { 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 { Textarea } from "@/components/ui/textarea";
|
|
6
|
+
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
7
|
+
import { Loader2, Pencil, Tag as TagIcon, Trash2 } from "lucide-react";
|
|
8
|
+
import { toast } from "sonner";
|
|
9
|
+
|
|
10
|
+
import { base44 } from "../../lib/api";
|
|
11
|
+
import useAsync from "../../hooks/useAsync";
|
|
12
|
+
import PageHeader from "../../components/PageHeader";
|
|
13
|
+
import DataTable from "../../components/DataTable";
|
|
14
|
+
import ConfirmDialog from "../../components/ConfirmDialog";
|
|
15
|
+
import { slugify } from "../../lib/product-utils";
|
|
16
|
+
|
|
17
|
+
const BLANK = { id: null, name: "", slug: "", description: "" };
|
|
18
|
+
|
|
19
|
+
export default function Tags() {
|
|
20
|
+
const { data: tags, loading, refetch } = useAsync(
|
|
21
|
+
() => base44.entities["commerce.ProductTag"].list("name", 1000),
|
|
22
|
+
[]
|
|
23
|
+
);
|
|
24
|
+
const [form, setForm] = useState({ ...BLANK });
|
|
25
|
+
const [slugTouched, setSlugTouched] = useState(false);
|
|
26
|
+
const [saving, setSaving] = useState(false);
|
|
27
|
+
const [confirmDelete, setConfirmDelete] = useState(null);
|
|
28
|
+
const [deleting, setDeleting] = useState(false);
|
|
29
|
+
|
|
30
|
+
const setName = (name) => setForm((f) => ({ ...f, name, slug: slugTouched ? f.slug : slugify(name) }));
|
|
31
|
+
const reset = () => {
|
|
32
|
+
setForm({ ...BLANK });
|
|
33
|
+
setSlugTouched(false);
|
|
34
|
+
};
|
|
35
|
+
const edit = (t) => {
|
|
36
|
+
setForm({ id: t.id, name: t.name || "", slug: t.slug || "", description: t.description || "" });
|
|
37
|
+
setSlugTouched(true);
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const save = async () => {
|
|
41
|
+
if (!form.name.trim()) {
|
|
42
|
+
toast.error("Name is required");
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
setSaving(true);
|
|
46
|
+
try {
|
|
47
|
+
const payload = { name: form.name.trim(), slug: form.slug || slugify(form.name), description: form.description };
|
|
48
|
+
if (form.id) await base44.entities["commerce.ProductTag"].update(form.id, payload);
|
|
49
|
+
else await base44.entities["commerce.ProductTag"].create(payload);
|
|
50
|
+
toast.success(form.id ? "Tag updated" : "Tag created");
|
|
51
|
+
reset();
|
|
52
|
+
refetch();
|
|
53
|
+
} catch (e) {
|
|
54
|
+
toast.error(e.message || "Failed to save tag");
|
|
55
|
+
} finally {
|
|
56
|
+
setSaving(false);
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const doDelete = async () => {
|
|
61
|
+
setDeleting(true);
|
|
62
|
+
try {
|
|
63
|
+
await base44.entities["commerce.ProductTag"].delete(confirmDelete.id);
|
|
64
|
+
toast.success("Tag deleted");
|
|
65
|
+
if (form.id === confirmDelete.id) reset();
|
|
66
|
+
setConfirmDelete(null);
|
|
67
|
+
refetch();
|
|
68
|
+
} catch (e) {
|
|
69
|
+
toast.error(e.message || "Failed to delete tag");
|
|
70
|
+
} finally {
|
|
71
|
+
setDeleting(false);
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
const columns = [
|
|
76
|
+
{ key: "name", label: "Name", render: (row) => <span className="font-medium">{row.name}</span> },
|
|
77
|
+
{ key: "slug", label: "Slug", className: "hidden md:table-cell text-muted-foreground" },
|
|
78
|
+
{
|
|
79
|
+
key: "description",
|
|
80
|
+
label: "Description",
|
|
81
|
+
className: "hidden lg:table-cell text-muted-foreground",
|
|
82
|
+
render: (row) => row.description || "—",
|
|
83
|
+
},
|
|
84
|
+
];
|
|
85
|
+
|
|
86
|
+
return (
|
|
87
|
+
<div className="space-y-4">
|
|
88
|
+
<PageHeader title="Tags" description="Freeform labels for products" />
|
|
89
|
+
<div className="grid gap-4 lg:grid-cols-[340px_1fr]">
|
|
90
|
+
<Card className="h-fit">
|
|
91
|
+
<CardHeader className="pb-2">
|
|
92
|
+
<CardTitle className="text-base">{form.id ? "Edit tag" : "Add new tag"}</CardTitle>
|
|
93
|
+
</CardHeader>
|
|
94
|
+
<CardContent className="space-y-3">
|
|
95
|
+
<div className="space-y-1.5">
|
|
96
|
+
<Label>Name</Label>
|
|
97
|
+
<Input value={form.name} onChange={(e) => setName(e.target.value)} />
|
|
98
|
+
</div>
|
|
99
|
+
<div className="space-y-1.5">
|
|
100
|
+
<Label>Slug</Label>
|
|
101
|
+
<Input
|
|
102
|
+
value={form.slug}
|
|
103
|
+
onChange={(e) => {
|
|
104
|
+
setSlugTouched(true);
|
|
105
|
+
setForm((f) => ({ ...f, slug: e.target.value }));
|
|
106
|
+
}}
|
|
107
|
+
/>
|
|
108
|
+
</div>
|
|
109
|
+
<div className="space-y-1.5">
|
|
110
|
+
<Label>Description</Label>
|
|
111
|
+
<Textarea
|
|
112
|
+
rows={3}
|
|
113
|
+
value={form.description}
|
|
114
|
+
onChange={(e) => setForm((f) => ({ ...f, description: e.target.value }))}
|
|
115
|
+
/>
|
|
116
|
+
</div>
|
|
117
|
+
<div className="flex gap-2">
|
|
118
|
+
<Button onClick={save} disabled={saving}>
|
|
119
|
+
{saving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
|
120
|
+
{form.id ? "Update" : "Add tag"}
|
|
121
|
+
</Button>
|
|
122
|
+
{form.id && <Button variant="ghost" onClick={reset} disabled={saving}>Cancel</Button>}
|
|
123
|
+
</div>
|
|
124
|
+
</CardContent>
|
|
125
|
+
</Card>
|
|
126
|
+
|
|
127
|
+
<DataTable
|
|
128
|
+
columns={columns}
|
|
129
|
+
rows={tags || []}
|
|
130
|
+
loading={loading}
|
|
131
|
+
rowActions={(row) => [
|
|
132
|
+
{ label: "Edit", icon: Pencil, onClick: () => edit(row) },
|
|
133
|
+
{ label: "Delete", icon: Trash2, destructive: true, onClick: () => setConfirmDelete(row) },
|
|
134
|
+
]}
|
|
135
|
+
empty={{ icon: TagIcon, title: "No tags yet", description: "Create your first tag." }}
|
|
136
|
+
/>
|
|
137
|
+
</div>
|
|
138
|
+
|
|
139
|
+
<ConfirmDialog
|
|
140
|
+
open={Boolean(confirmDelete)}
|
|
141
|
+
onOpenChange={(o) => !o && setConfirmDelete(null)}
|
|
142
|
+
title={`Delete "${confirmDelete?.name}"?`}
|
|
143
|
+
description="Products with this tag are not deleted; they simply lose the tag."
|
|
144
|
+
confirmLabel="Delete"
|
|
145
|
+
onConfirm={doDelete}
|
|
146
|
+
loading={deleting}
|
|
147
|
+
/>
|
|
148
|
+
</div>
|
|
149
|
+
);
|
|
150
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import React, { useMemo, useState } from "react";
|
|
2
|
+
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
|
3
|
+
import { Checkbox } from "@/components/ui/checkbox";
|
|
4
|
+
import { Label } from "@/components/ui/label";
|
|
5
|
+
import {
|
|
6
|
+
Select,
|
|
7
|
+
SelectContent,
|
|
8
|
+
SelectItem,
|
|
9
|
+
SelectTrigger,
|
|
10
|
+
SelectValue,
|
|
11
|
+
} from "@/components/ui/select";
|
|
12
|
+
import { Separator } from "@/components/ui/separator";
|
|
13
|
+
|
|
14
|
+
import { PRODUCT_TYPES } from "../../../lib/constants";
|
|
15
|
+
import { isSimple, isGrouped, isExternal, isVariable } from "../../../lib/product-utils";
|
|
16
|
+
import GeneralTab from "./tabs/GeneralTab";
|
|
17
|
+
import ExternalTab from "./tabs/ExternalTab";
|
|
18
|
+
import InventoryTab from "./tabs/InventoryTab";
|
|
19
|
+
import ShippingTab from "./tabs/ShippingTab";
|
|
20
|
+
import LinkedTab from "./tabs/LinkedTab";
|
|
21
|
+
import AttributesTab from "./tabs/AttributesTab";
|
|
22
|
+
import VariationsTab from "./tabs/VariationsTab";
|
|
23
|
+
import DownloadsTab from "./tabs/DownloadsTab";
|
|
24
|
+
import AdvancedTab from "./tabs/AdvancedTab";
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The "Product data" panel: type selector + vertical tabs.
|
|
28
|
+
* Tab visibility follows the product type / virtual / downloadable flags.
|
|
29
|
+
*/
|
|
30
|
+
export default function ProductDataPanel({ product, up, refs, variations, setVariations }) {
|
|
31
|
+
const tabs = useMemo(() => {
|
|
32
|
+
const list = [];
|
|
33
|
+
if (!isGrouped(product)) list.push({ id: "general", label: "General" });
|
|
34
|
+
list.push({ id: "inventory", label: "Inventory" });
|
|
35
|
+
if (!product.virtual && !isExternal(product)) list.push({ id: "shipping", label: "Shipping" });
|
|
36
|
+
list.push({ id: "linked", label: "Linked products" });
|
|
37
|
+
list.push({ id: "attributes", label: "Attributes" });
|
|
38
|
+
if (isVariable(product)) list.push({ id: "variations", label: "Variations" });
|
|
39
|
+
if (product.downloadable && !isGrouped(product) && !isExternal(product))
|
|
40
|
+
list.push({ id: "downloads", label: "Downloads" });
|
|
41
|
+
list.push({ id: "advanced", label: "Advanced" });
|
|
42
|
+
return list;
|
|
43
|
+
}, [product]);
|
|
44
|
+
|
|
45
|
+
const [active, setActive] = useState("general");
|
|
46
|
+
const activeTab = tabs.some((t) => t.id === active) ? active : tabs[0].id;
|
|
47
|
+
|
|
48
|
+
return (
|
|
49
|
+
<Card>
|
|
50
|
+
<CardHeader className="flex-row items-center gap-4 space-y-0 border-b py-3">
|
|
51
|
+
<span className="text-base font-semibold">Product data</span>
|
|
52
|
+
<Select value={product.type} onValueChange={(type) => up({ type })}>
|
|
53
|
+
<SelectTrigger className="h-8 w-52"><SelectValue /></SelectTrigger>
|
|
54
|
+
<SelectContent>
|
|
55
|
+
{PRODUCT_TYPES.map((t) => (
|
|
56
|
+
<SelectItem key={t.value} value={t.value}>{t.label}</SelectItem>
|
|
57
|
+
))}
|
|
58
|
+
</SelectContent>
|
|
59
|
+
</Select>
|
|
60
|
+
{isSimple(product) && (
|
|
61
|
+
<div className="flex items-center gap-4 text-sm">
|
|
62
|
+
<label className="flex items-center gap-1.5">
|
|
63
|
+
<Checkbox
|
|
64
|
+
checked={product.virtual}
|
|
65
|
+
onCheckedChange={(v) => up({ virtual: Boolean(v) })}
|
|
66
|
+
/>
|
|
67
|
+
Virtual
|
|
68
|
+
</label>
|
|
69
|
+
<label className="flex items-center gap-1.5">
|
|
70
|
+
<Checkbox
|
|
71
|
+
checked={product.downloadable}
|
|
72
|
+
onCheckedChange={(v) => up({ downloadable: Boolean(v) })}
|
|
73
|
+
/>
|
|
74
|
+
Downloadable
|
|
75
|
+
</label>
|
|
76
|
+
</div>
|
|
77
|
+
)}
|
|
78
|
+
</CardHeader>
|
|
79
|
+
<CardContent className="p-0">
|
|
80
|
+
<div className="flex min-h-72 flex-col sm:flex-row">
|
|
81
|
+
{/* Vertical tab nav */}
|
|
82
|
+
<nav className="flex shrink-0 flex-row gap-1 overflow-x-auto border-b bg-muted/40 p-2 sm:w-44 sm:flex-col sm:border-b-0 sm:border-r">
|
|
83
|
+
{tabs.map((t) => (
|
|
84
|
+
<button
|
|
85
|
+
key={t.id}
|
|
86
|
+
type="button"
|
|
87
|
+
onClick={() => setActive(t.id)}
|
|
88
|
+
className={`rounded-md px-3 py-2 text-left text-sm transition-colors ${
|
|
89
|
+
activeTab === t.id
|
|
90
|
+
? "bg-background font-medium shadow-sm"
|
|
91
|
+
: "text-muted-foreground hover:bg-background/60"
|
|
92
|
+
}`}
|
|
93
|
+
>
|
|
94
|
+
{t.label}
|
|
95
|
+
</button>
|
|
96
|
+
))}
|
|
97
|
+
</nav>
|
|
98
|
+
|
|
99
|
+
<div className="min-w-0 flex-1 p-4">
|
|
100
|
+
{activeTab === "general" &&
|
|
101
|
+
(isExternal(product) ? (
|
|
102
|
+
<ExternalTab product={product} up={up} refs={refs} />
|
|
103
|
+
) : (
|
|
104
|
+
<GeneralTab product={product} up={up} refs={refs} />
|
|
105
|
+
))}
|
|
106
|
+
{activeTab === "inventory" && <InventoryTab product={product} up={up} />}
|
|
107
|
+
{activeTab === "shipping" && <ShippingTab product={product} up={up} refs={refs} />}
|
|
108
|
+
{activeTab === "linked" && <LinkedTab product={product} up={up} />}
|
|
109
|
+
{activeTab === "attributes" && <AttributesTab product={product} up={up} refs={refs} />}
|
|
110
|
+
{activeTab === "variations" && (
|
|
111
|
+
<VariationsTab
|
|
112
|
+
product={product}
|
|
113
|
+
up={up}
|
|
114
|
+
refs={refs}
|
|
115
|
+
variations={variations}
|
|
116
|
+
setVariations={setVariations}
|
|
117
|
+
/>
|
|
118
|
+
)}
|
|
119
|
+
{activeTab === "downloads" && <DownloadsTab product={product} up={up} />}
|
|
120
|
+
{activeTab === "advanced" && <AdvancedTab product={product} up={up} />}
|
|
121
|
+
</div>
|
|
122
|
+
</div>
|
|
123
|
+
<Separator />
|
|
124
|
+
<div className="px-4 py-2 text-xs text-muted-foreground">
|
|
125
|
+
Type: {PRODUCT_TYPES.find((t) => t.value === product.type)?.label}
|
|
126
|
+
{product.virtual ? " · Virtual" : ""}
|
|
127
|
+
{product.downloadable ? " · Downloadable" : ""}
|
|
128
|
+
</div>
|
|
129
|
+
</CardContent>
|
|
130
|
+
</Card>
|
|
131
|
+
);
|
|
132
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
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 { Label } from "@/components/ui/label";
|
|
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
|
+
import { Loader2, Save, Trash2 } from "lucide-react";
|
|
14
|
+
|
|
15
|
+
import ConfirmDialog from "../../../components/ConfirmDialog";
|
|
16
|
+
import { PRODUCT_STATUSES, CATALOG_VISIBILITIES } from "../../../lib/constants";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Product editor "Publish" sidebar box.
|
|
20
|
+
* Props: { product, up, onSave, onDelete (null hides delete), saving, dirty }
|
|
21
|
+
*/
|
|
22
|
+
export default function PublishBox({ product, up, onSave, onDelete, saving, dirty }) {
|
|
23
|
+
const [confirm, setConfirm] = useState(false);
|
|
24
|
+
const [deleting, setDeleting] = useState(false);
|
|
25
|
+
|
|
26
|
+
const doDelete = async () => {
|
|
27
|
+
setDeleting(true);
|
|
28
|
+
try {
|
|
29
|
+
await onDelete();
|
|
30
|
+
setConfirm(false);
|
|
31
|
+
} finally {
|
|
32
|
+
setDeleting(false);
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
return (
|
|
37
|
+
<Card>
|
|
38
|
+
<CardHeader className="pb-2">
|
|
39
|
+
<CardTitle className="text-base">Publish</CardTitle>
|
|
40
|
+
</CardHeader>
|
|
41
|
+
<CardContent className="space-y-3">
|
|
42
|
+
<div className="space-y-1.5">
|
|
43
|
+
<Label>Status</Label>
|
|
44
|
+
<Select value={product.status || "draft"} onValueChange={(v) => up({ status: v })}>
|
|
45
|
+
<SelectTrigger><SelectValue /></SelectTrigger>
|
|
46
|
+
<SelectContent>
|
|
47
|
+
{PRODUCT_STATUSES.map((s) => (
|
|
48
|
+
<SelectItem key={s.value} value={s.value}>{s.label}</SelectItem>
|
|
49
|
+
))}
|
|
50
|
+
</SelectContent>
|
|
51
|
+
</Select>
|
|
52
|
+
</div>
|
|
53
|
+
|
|
54
|
+
<div className="space-y-1.5">
|
|
55
|
+
<Label>Catalog visibility</Label>
|
|
56
|
+
<Select
|
|
57
|
+
value={product.catalog_visibility || "visible"}
|
|
58
|
+
onValueChange={(v) => up({ catalog_visibility: v })}
|
|
59
|
+
>
|
|
60
|
+
<SelectTrigger><SelectValue /></SelectTrigger>
|
|
61
|
+
<SelectContent>
|
|
62
|
+
{CATALOG_VISIBILITIES.map((v) => (
|
|
63
|
+
<SelectItem key={v.value} value={v.value}>{v.label}</SelectItem>
|
|
64
|
+
))}
|
|
65
|
+
</SelectContent>
|
|
66
|
+
</Select>
|
|
67
|
+
</div>
|
|
68
|
+
|
|
69
|
+
<label className="flex cursor-pointer items-center gap-2 text-sm">
|
|
70
|
+
<Checkbox
|
|
71
|
+
checked={Boolean(product.featured)}
|
|
72
|
+
onCheckedChange={(c) => up({ featured: Boolean(c) })}
|
|
73
|
+
/>
|
|
74
|
+
Featured product
|
|
75
|
+
</label>
|
|
76
|
+
|
|
77
|
+
<div className="flex items-center gap-2 pt-1">
|
|
78
|
+
<Button onClick={onSave} disabled={saving || !dirty} className="flex-1">
|
|
79
|
+
{saving ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Save className="mr-2 h-4 w-4" />}
|
|
80
|
+
Save
|
|
81
|
+
</Button>
|
|
82
|
+
{onDelete && (
|
|
83
|
+
<Button variant="outline" size="icon" onClick={() => setConfirm(true)} title="Delete product">
|
|
84
|
+
<Trash2 className="h-4 w-4 text-destructive" />
|
|
85
|
+
</Button>
|
|
86
|
+
)}
|
|
87
|
+
</div>
|
|
88
|
+
</CardContent>
|
|
89
|
+
|
|
90
|
+
<ConfirmDialog
|
|
91
|
+
open={confirm}
|
|
92
|
+
onOpenChange={(o) => !o && setConfirm(false)}
|
|
93
|
+
title="Delete this product?"
|
|
94
|
+
description="This permanently deletes the product and its variations."
|
|
95
|
+
confirmLabel="Delete"
|
|
96
|
+
onConfirm={doDelete}
|
|
97
|
+
loading={deleting}
|
|
98
|
+
/>
|
|
99
|
+
</Card>
|
|
100
|
+
);
|
|
101
|
+
}
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
import React, { useMemo, useState } from "react";
|
|
2
|
+
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
3
|
+
import { Checkbox } from "@/components/ui/checkbox";
|
|
4
|
+
import { Input } from "@/components/ui/input";
|
|
5
|
+
import { Button } from "@/components/ui/button";
|
|
6
|
+
import { Label } from "@/components/ui/label";
|
|
7
|
+
import { ScrollArea } from "@/components/ui/scroll-area";
|
|
8
|
+
import {
|
|
9
|
+
Select,
|
|
10
|
+
SelectContent,
|
|
11
|
+
SelectItem,
|
|
12
|
+
SelectTrigger,
|
|
13
|
+
SelectValue,
|
|
14
|
+
} from "@/components/ui/select";
|
|
15
|
+
import { Loader2, Plus } from "lucide-react";
|
|
16
|
+
import { toast } from "sonner";
|
|
17
|
+
|
|
18
|
+
import { base44 } from "../../../lib/api";
|
|
19
|
+
import SearchSelect from "../../../components/SearchSelect";
|
|
20
|
+
import { slugify } from "../../../lib/product-utils";
|
|
21
|
+
|
|
22
|
+
const NO_PARENT = "__none__";
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Product editor sidebar panel: categories (hierarchical checkboxes + quick-add)
|
|
26
|
+
* and tags (multi-select + create-on-enter).
|
|
27
|
+
*
|
|
28
|
+
* Props: { product, up, refs } where refs = { categories, tags, refreshCategories, refreshTags }
|
|
29
|
+
*/
|
|
30
|
+
export default function TaxonomyPanel({ product, up, refs }) {
|
|
31
|
+
const categories = refs?.categories || [];
|
|
32
|
+
const tags = refs?.tags || [];
|
|
33
|
+
|
|
34
|
+
return (
|
|
35
|
+
<>
|
|
36
|
+
<CategoriesCard
|
|
37
|
+
categories={categories}
|
|
38
|
+
selected={product.category_ids || []}
|
|
39
|
+
onChange={(ids) => up({ category_ids: ids })}
|
|
40
|
+
refresh={refs?.refreshCategories}
|
|
41
|
+
/>
|
|
42
|
+
<TagsCard
|
|
43
|
+
tags={tags}
|
|
44
|
+
selected={product.tag_ids || []}
|
|
45
|
+
onChange={(ids) => up({ tag_ids: ids })}
|
|
46
|
+
refresh={refs?.refreshTags}
|
|
47
|
+
/>
|
|
48
|
+
</>
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function CategoriesCard({ categories, selected, onChange, refresh }) {
|
|
53
|
+
const [adding, setAdding] = useState(false);
|
|
54
|
+
const [name, setName] = useState("");
|
|
55
|
+
const [parent, setParent] = useState(NO_PARENT);
|
|
56
|
+
const [saving, setSaving] = useState(false);
|
|
57
|
+
|
|
58
|
+
// Build a parent → children tree for indented rendering.
|
|
59
|
+
const tree = useMemo(() => {
|
|
60
|
+
const byParent = new Map();
|
|
61
|
+
for (const c of categories) {
|
|
62
|
+
const key = c.parent_id || "";
|
|
63
|
+
if (!byParent.has(key)) byParent.set(key, []);
|
|
64
|
+
byParent.get(key).push(c);
|
|
65
|
+
}
|
|
66
|
+
for (const list of byParent.values()) list.sort((a, b) => (a.name || "").localeCompare(b.name || ""));
|
|
67
|
+
const rows = [];
|
|
68
|
+
const walk = (parentId, depth) => {
|
|
69
|
+
for (const c of byParent.get(parentId) || []) {
|
|
70
|
+
rows.push({ ...c, depth });
|
|
71
|
+
walk(c.id, depth + 1);
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
walk("", 0);
|
|
75
|
+
return rows;
|
|
76
|
+
}, [categories]);
|
|
77
|
+
|
|
78
|
+
const toggle = (id) =>
|
|
79
|
+
onChange(selected.includes(id) ? selected.filter((x) => x !== id) : [...selected, id]);
|
|
80
|
+
|
|
81
|
+
const create = async () => {
|
|
82
|
+
if (!name.trim()) return;
|
|
83
|
+
setSaving(true);
|
|
84
|
+
try {
|
|
85
|
+
const created = await base44.entities["commerce.ProductCategory"].create({
|
|
86
|
+
name: name.trim(),
|
|
87
|
+
slug: slugify(name),
|
|
88
|
+
parent_id: parent === NO_PARENT ? "" : parent,
|
|
89
|
+
});
|
|
90
|
+
toast.success("Category created");
|
|
91
|
+
setName("");
|
|
92
|
+
setParent(NO_PARENT);
|
|
93
|
+
setAdding(false);
|
|
94
|
+
await refresh?.();
|
|
95
|
+
if (created?.id) onChange([...selected, created.id]);
|
|
96
|
+
} catch (e) {
|
|
97
|
+
toast.error(e.message || "Failed to create category");
|
|
98
|
+
} finally {
|
|
99
|
+
setSaving(false);
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
return (
|
|
104
|
+
<Card>
|
|
105
|
+
<CardHeader className="pb-2">
|
|
106
|
+
<CardTitle className="text-base">Categories</CardTitle>
|
|
107
|
+
</CardHeader>
|
|
108
|
+
<CardContent className="space-y-2">
|
|
109
|
+
{tree.length > 0 ? (
|
|
110
|
+
<ScrollArea className="h-48 rounded-md border p-2">
|
|
111
|
+
<div className="space-y-1">
|
|
112
|
+
{tree.map((c) => (
|
|
113
|
+
<label
|
|
114
|
+
key={c.id}
|
|
115
|
+
className="flex cursor-pointer items-center gap-2 text-sm"
|
|
116
|
+
style={{ paddingLeft: `${c.depth * 16}px` }}
|
|
117
|
+
>
|
|
118
|
+
<Checkbox checked={selected.includes(c.id)} onCheckedChange={() => toggle(c.id)} />
|
|
119
|
+
<span className="truncate">{c.name}</span>
|
|
120
|
+
</label>
|
|
121
|
+
))}
|
|
122
|
+
</div>
|
|
123
|
+
</ScrollArea>
|
|
124
|
+
) : (
|
|
125
|
+
<p className="text-sm text-muted-foreground">No categories yet.</p>
|
|
126
|
+
)}
|
|
127
|
+
|
|
128
|
+
{adding ? (
|
|
129
|
+
<div className="space-y-2 rounded-md border p-2">
|
|
130
|
+
<div className="space-y-1">
|
|
131
|
+
<Label className="text-xs">Name</Label>
|
|
132
|
+
<Input value={name} onChange={(e) => setName(e.target.value)} placeholder="Category name" />
|
|
133
|
+
</div>
|
|
134
|
+
<div className="space-y-1">
|
|
135
|
+
<Label className="text-xs">Parent</Label>
|
|
136
|
+
<Select value={parent} onValueChange={setParent}>
|
|
137
|
+
<SelectTrigger><SelectValue /></SelectTrigger>
|
|
138
|
+
<SelectContent>
|
|
139
|
+
<SelectItem value={NO_PARENT}>— None —</SelectItem>
|
|
140
|
+
{categories.map((c) => (
|
|
141
|
+
<SelectItem key={c.id} value={c.id}>{c.name}</SelectItem>
|
|
142
|
+
))}
|
|
143
|
+
</SelectContent>
|
|
144
|
+
</Select>
|
|
145
|
+
</div>
|
|
146
|
+
<div className="flex gap-2">
|
|
147
|
+
<Button size="sm" onClick={create} disabled={saving || !name.trim()}>
|
|
148
|
+
{saving && <Loader2 className="mr-2 h-3 w-3 animate-spin" />}Add
|
|
149
|
+
</Button>
|
|
150
|
+
<Button size="sm" variant="ghost" onClick={() => setAdding(false)} disabled={saving}>
|
|
151
|
+
Cancel
|
|
152
|
+
</Button>
|
|
153
|
+
</div>
|
|
154
|
+
</div>
|
|
155
|
+
) : (
|
|
156
|
+
<Button variant="link" size="sm" className="h-auto p-0 text-xs" onClick={() => setAdding(true)}>
|
|
157
|
+
<Plus className="mr-1 h-3 w-3" /> Add new category
|
|
158
|
+
</Button>
|
|
159
|
+
)}
|
|
160
|
+
</CardContent>
|
|
161
|
+
</Card>
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function TagsCard({ tags, selected, onChange, refresh }) {
|
|
166
|
+
const [newTag, setNewTag] = useState("");
|
|
167
|
+
const [saving, setSaving] = useState(false);
|
|
168
|
+
|
|
169
|
+
const value = useMemo(
|
|
170
|
+
() =>
|
|
171
|
+
selected
|
|
172
|
+
.map((id) => {
|
|
173
|
+
const t = tags.find((x) => x.id === id);
|
|
174
|
+
return t ? { value: t.id, label: t.name } : null;
|
|
175
|
+
})
|
|
176
|
+
.filter(Boolean),
|
|
177
|
+
[selected, tags]
|
|
178
|
+
);
|
|
179
|
+
|
|
180
|
+
const search = async (q) =>
|
|
181
|
+
tags
|
|
182
|
+
.filter((t) => !q || (t.name || "").toLowerCase().includes(q.toLowerCase()))
|
|
183
|
+
.slice(0, 30)
|
|
184
|
+
.map((t) => ({ value: t.id, label: t.name }));
|
|
185
|
+
|
|
186
|
+
const createTag = async () => {
|
|
187
|
+
const label = newTag.trim();
|
|
188
|
+
if (!label) return;
|
|
189
|
+
// Reuse an existing tag with the same name if present.
|
|
190
|
+
const existing = tags.find((t) => (t.name || "").toLowerCase() === label.toLowerCase());
|
|
191
|
+
if (existing) {
|
|
192
|
+
if (!selected.includes(existing.id)) onChange([...selected, existing.id]);
|
|
193
|
+
setNewTag("");
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
setSaving(true);
|
|
197
|
+
try {
|
|
198
|
+
const created = await base44.entities["commerce.ProductTag"].create({ name: label, slug: slugify(label) });
|
|
199
|
+
toast.success("Tag created");
|
|
200
|
+
setNewTag("");
|
|
201
|
+
await refresh?.();
|
|
202
|
+
if (created?.id) onChange([...selected, created.id]);
|
|
203
|
+
} catch (e) {
|
|
204
|
+
toast.error(e.message || "Failed to create tag");
|
|
205
|
+
} finally {
|
|
206
|
+
setSaving(false);
|
|
207
|
+
}
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
return (
|
|
211
|
+
<Card>
|
|
212
|
+
<CardHeader className="pb-2">
|
|
213
|
+
<CardTitle className="text-base">Tags</CardTitle>
|
|
214
|
+
</CardHeader>
|
|
215
|
+
<CardContent className="space-y-2">
|
|
216
|
+
<SearchSelect
|
|
217
|
+
multiple
|
|
218
|
+
placeholder="Select tags"
|
|
219
|
+
value={value}
|
|
220
|
+
onChange={(next) => onChange(next.map((o) => o.value))}
|
|
221
|
+
search={search}
|
|
222
|
+
/>
|
|
223
|
+
<div className="flex gap-2">
|
|
224
|
+
<Input
|
|
225
|
+
value={newTag}
|
|
226
|
+
onChange={(e) => setNewTag(e.target.value)}
|
|
227
|
+
onKeyDown={(e) => {
|
|
228
|
+
if (e.key === "Enter") {
|
|
229
|
+
e.preventDefault();
|
|
230
|
+
createTag();
|
|
231
|
+
}
|
|
232
|
+
}}
|
|
233
|
+
placeholder="Add new tag"
|
|
234
|
+
className="h-8 text-sm"
|
|
235
|
+
/>
|
|
236
|
+
<Button size="sm" variant="outline" onClick={createTag} disabled={saving || !newTag.trim()}>
|
|
237
|
+
{saving ? <Loader2 className="h-3 w-3 animate-spin" /> : "Add"}
|
|
238
|
+
</Button>
|
|
239
|
+
</div>
|
|
240
|
+
</CardContent>
|
|
241
|
+
</Card>
|
|
242
|
+
);
|
|
243
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
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 { Textarea } from "@/components/ui/textarea";
|
|
6
|
+
|
|
7
|
+
import MetaDataEditor from "../../../../components/MetaDataEditor";
|
|
8
|
+
|
|
9
|
+
/** Purchase note, menu order, reviews toggle and custom meta data. */
|
|
10
|
+
export default function AdvancedTab({ product, up }) {
|
|
11
|
+
return (
|
|
12
|
+
<div className="max-w-xl space-y-4">
|
|
13
|
+
<div className="space-y-1.5">
|
|
14
|
+
<Label>Purchase note</Label>
|
|
15
|
+
<Textarea
|
|
16
|
+
rows={3}
|
|
17
|
+
placeholder="Optional note sent to the customer after purchase"
|
|
18
|
+
value={product.purchase_note || ""}
|
|
19
|
+
onChange={(e) => up({ purchase_note: e.target.value })}
|
|
20
|
+
/>
|
|
21
|
+
</div>
|
|
22
|
+
|
|
23
|
+
<div className="space-y-1.5">
|
|
24
|
+
<Label>Menu order</Label>
|
|
25
|
+
<Input
|
|
26
|
+
type="number"
|
|
27
|
+
className="w-32"
|
|
28
|
+
value={product.menu_order ?? 0}
|
|
29
|
+
onChange={(e) => up({ menu_order: e.target.value === "" ? 0 : Number(e.target.value) })}
|
|
30
|
+
/>
|
|
31
|
+
<p className="text-xs text-muted-foreground">Custom ordering position (lower shows first).</p>
|
|
32
|
+
</div>
|
|
33
|
+
|
|
34
|
+
<div className="flex items-center gap-2">
|
|
35
|
+
<Switch
|
|
36
|
+
checked={product.reviews_allowed !== false}
|
|
37
|
+
onCheckedChange={(v) => up({ reviews_allowed: Boolean(v) })}
|
|
38
|
+
/>
|
|
39
|
+
<Label>Enable reviews</Label>
|
|
40
|
+
</div>
|
|
41
|
+
|
|
42
|
+
<div className="space-y-1.5">
|
|
43
|
+
<Label>Custom fields</Label>
|
|
44
|
+
<MetaDataEditor value={product.meta_data || []} onChange={(meta_data) => up({ meta_data })} />
|
|
45
|
+
</div>
|
|
46
|
+
</div>
|
|
47
|
+
);
|
|
48
|
+
}
|