@base44/app-plugin-commerce 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (173) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +117 -0
  3. package/base44/agents/commerce/StoreAdmin.jsonc +64 -0
  4. package/base44/entities/commerce.Cart.jsonc +73 -0
  5. package/base44/entities/commerce.Coupon.jsonc +113 -0
  6. package/base44/entities/commerce.Customer.jsonc +96 -0
  7. package/base44/entities/commerce.DownloadPermission.jsonc +54 -0
  8. package/base44/entities/commerce.EmailLog.jsonc +43 -0
  9. package/base44/entities/commerce.Order.jsonc +287 -0
  10. package/base44/entities/commerce.OrderNote.jsonc +31 -0
  11. package/base44/entities/commerce.OrderRefund.jsonc +64 -0
  12. package/base44/entities/commerce.PaymentGateway.jsonc +48 -0
  13. package/base44/entities/commerce.Product.jsonc +291 -0
  14. package/base44/entities/commerce.ProductAttribute.jsonc +39 -0
  15. package/base44/entities/commerce.ProductAttributeTerm.jsonc +38 -0
  16. package/base44/entities/commerce.ProductCategory.jsonc +51 -0
  17. package/base44/entities/commerce.ProductReview.jsonc +48 -0
  18. package/base44/entities/commerce.ProductTag.jsonc +30 -0
  19. package/base44/entities/commerce.ProductVariation.jsonc +167 -0
  20. package/base44/entities/commerce.ShippingClass.jsonc +30 -0
  21. package/base44/entities/commerce.ShippingZone.jsonc +41 -0
  22. package/base44/entities/commerce.ShippingZoneMethod.jsonc +84 -0
  23. package/base44/entities/commerce.StoreSettings.jsonc +23 -0
  24. package/base44/entities/commerce.TaxClass.jsonc +23 -0
  25. package/base44/entities/commerce.TaxRate.jsonc +68 -0
  26. package/base44/entities/commerce.Webhook.jsonc +57 -0
  27. package/base44/entities/commerce.WebhookDelivery.jsonc +45 -0
  28. package/base44/functions/commerce/admin-coupons/entry.ts +100 -0
  29. package/base44/functions/commerce/admin-customers/entry.ts +141 -0
  30. package/base44/functions/commerce/admin-orders/entry.ts +396 -0
  31. package/base44/functions/commerce/admin-orders/helpers.ts +246 -0
  32. package/base44/functions/commerce/admin-products/entry.ts +506 -0
  33. package/base44/functions/commerce/admin-refunds/entry.ts +158 -0
  34. package/base44/functions/commerce/admin-reports/entry.ts +283 -0
  35. package/base44/functions/commerce/admin-reviews/entry.ts +66 -0
  36. package/base44/functions/commerce/admin-tools/entry.ts +261 -0
  37. package/base44/functions/commerce/admin-webhooks/entry.ts +52 -0
  38. package/base44/functions/commerce/payment-webhook/entry.ts +135 -0
  39. package/base44/functions/commerce/payments/entry.ts +238 -0
  40. package/base44/functions/commerce/seed-store/defaults.ts +162 -0
  41. package/base44/functions/commerce/seed-store/entry.ts +310 -0
  42. package/base44/functions/commerce/seed-store/sample-data.ts +349 -0
  43. package/base44/functions/commerce/storefront-account/entry.ts +207 -0
  44. package/base44/functions/commerce/storefront-cart/cart-pricing.ts +258 -0
  45. package/base44/functions/commerce/storefront-cart/entry.ts +283 -0
  46. package/base44/functions/commerce/storefront-catalog/entry.ts +459 -0
  47. package/base44/functions/commerce/storefront-checkout/cart-pricing.ts +258 -0
  48. package/base44/functions/commerce/storefront-checkout/entry.ts +485 -0
  49. package/base44/shared/commerce/auth.ts +60 -0
  50. package/base44/shared/commerce/coupons.ts +257 -0
  51. package/base44/shared/commerce/data/continents.ts +75 -0
  52. package/base44/shared/commerce/data/countries.ts +307 -0
  53. package/base44/shared/commerce/data/currencies.ts +46 -0
  54. package/base44/shared/commerce/email-templates.ts +240 -0
  55. package/base44/shared/commerce/emails.ts +225 -0
  56. package/base44/shared/commerce/money.ts +66 -0
  57. package/base44/shared/commerce/orders.ts +251 -0
  58. package/base44/shared/commerce/payments.ts +495 -0
  59. package/base44/shared/commerce/reviews.ts +36 -0
  60. package/base44/shared/commerce/scan.ts +57 -0
  61. package/base44/shared/commerce/sequence.ts +35 -0
  62. package/base44/shared/commerce/settings.ts +57 -0
  63. package/base44/shared/commerce/shipping.ts +215 -0
  64. package/base44/shared/commerce/stock.ts +227 -0
  65. package/base44/shared/commerce/stripe.ts +463 -0
  66. package/base44/shared/commerce/tax.ts +136 -0
  67. package/base44/shared/commerce/totals.ts +314 -0
  68. package/base44/shared/commerce/webhooks.ts +116 -0
  69. package/package.json +37 -0
  70. package/scripts/install.js +156 -0
  71. package/skills/commerce/SKILL.md +62 -0
  72. package/skills/commerce/docs/api-admin.md +186 -0
  73. package/skills/commerce/docs/api-storefront.md +408 -0
  74. package/skills/commerce/installation-guidelines.md +91 -0
  75. package/skills/commerce/post-installation.md +157 -0
  76. package/skills/commerce/references/emails.md +13 -0
  77. package/skills/commerce/references/guest-access-security.md +18 -0
  78. package/skills/commerce/references/limits-and-performance.md +16 -0
  79. package/skills/commerce/references/media-and-downloads.md +4 -0
  80. package/skills/commerce/references/online-payments.md +201 -0
  81. package/skills/commerce/references/product-render.md +87 -0
  82. package/skills/commerce/references/scheduled-work.md +19 -0
  83. package/skills/commerce/references/storefront-product-page.md +83 -0
  84. package/skills/commerce/references/webhooks.md +8 -0
  85. package/src/commerce/admin/README.md +107 -0
  86. package/src/commerce/admin/bot/Markdown.jsx +138 -0
  87. package/src/commerce/admin/bot/StoreAdminBot.jsx +249 -0
  88. package/src/commerce/admin/bot/pipe-tables.js +116 -0
  89. package/src/commerce/admin/components/AddressForm.jsx +78 -0
  90. package/src/commerce/admin/components/ConfirmDialog.jsx +52 -0
  91. package/src/commerce/admin/components/CountrySelect.jsx +81 -0
  92. package/src/commerce/admin/components/DataTable.jsx +192 -0
  93. package/src/commerce/admin/components/DateRangePicker.jsx +91 -0
  94. package/src/commerce/admin/components/EmptyState.jsx +17 -0
  95. package/src/commerce/admin/components/MediaUploader.jsx +116 -0
  96. package/src/commerce/admin/components/MetaDataEditor.jsx +45 -0
  97. package/src/commerce/admin/components/MoneyInput.jsx +50 -0
  98. package/src/commerce/admin/components/PageHeader.jsx +29 -0
  99. package/src/commerce/admin/components/RichTextarea.jsx +21 -0
  100. package/src/commerce/admin/components/SearchSelect.jsx +142 -0
  101. package/src/commerce/admin/components/StatusBadge.jsx +17 -0
  102. package/src/commerce/admin/context/BasePathContext.jsx +26 -0
  103. package/src/commerce/admin/context/SettingsContext.jsx +207 -0
  104. package/src/commerce/admin/hooks/useAsync.js +46 -0
  105. package/src/commerce/admin/hooks/useDebounce.js +11 -0
  106. package/src/commerce/admin/hooks/useMoney.js +52 -0
  107. package/src/commerce/admin/hooks/usePagedList.js +83 -0
  108. package/src/commerce/admin/hooks/usePaymentProvider.js +27 -0
  109. package/src/commerce/admin/hooks/useRealtime.js +129 -0
  110. package/src/commerce/admin/index.jsx +34 -0
  111. package/src/commerce/admin/layout/AccessDenied.jsx +54 -0
  112. package/src/commerce/admin/layout/AdminLayout.jsx +33 -0
  113. package/src/commerce/admin/layout/AuthGuard.jsx +84 -0
  114. package/src/commerce/admin/layout/Sidebar.jsx +130 -0
  115. package/src/commerce/admin/layout/Topbar.jsx +94 -0
  116. package/src/commerce/admin/lib/api.js +55 -0
  117. package/src/commerce/admin/lib/constants.js +157 -0
  118. package/src/commerce/admin/lib/format.js +27 -0
  119. package/src/commerce/admin/lib/geo-data.js +125 -0
  120. package/src/commerce/admin/lib/order-utils.js +147 -0
  121. package/src/commerce/admin/lib/paths.js +35 -0
  122. package/src/commerce/admin/lib/product-utils.js +55 -0
  123. package/src/commerce/admin/pages/Dashboard.jsx +245 -0
  124. package/src/commerce/admin/pages/coupons/CouponEditor.jsx +565 -0
  125. package/src/commerce/admin/pages/coupons/CouponsList.jsx +172 -0
  126. package/src/commerce/admin/pages/customers/CustomerEditor.jsx +318 -0
  127. package/src/commerce/admin/pages/customers/CustomersList.jsx +169 -0
  128. package/src/commerce/admin/pages/orders/OrderEditor.jsx +952 -0
  129. package/src/commerce/admin/pages/orders/OrdersList.jsx +227 -0
  130. package/src/commerce/admin/pages/orders/components/AddProductDialog.jsx +149 -0
  131. package/src/commerce/admin/pages/orders/components/DownloadPermissionsPanel.jsx +119 -0
  132. package/src/commerce/admin/pages/orders/components/LineItemsTable.jsx +208 -0
  133. package/src/commerce/admin/pages/orders/components/OrderNotesPanel.jsx +123 -0
  134. package/src/commerce/admin/pages/orders/components/PaymentPanel.jsx +199 -0
  135. package/src/commerce/admin/pages/orders/components/RefundPanel.jsx +239 -0
  136. package/src/commerce/admin/pages/orders/components/TotalsBox.jsx +52 -0
  137. package/src/commerce/admin/pages/products/AttributeTerms.jsx +180 -0
  138. package/src/commerce/admin/pages/products/Attributes.jsx +183 -0
  139. package/src/commerce/admin/pages/products/Categories.jsx +236 -0
  140. package/src/commerce/admin/pages/products/ProductEditor.jsx +267 -0
  141. package/src/commerce/admin/pages/products/ProductsList.jsx +391 -0
  142. package/src/commerce/admin/pages/products/Reviews.jsx +255 -0
  143. package/src/commerce/admin/pages/products/Tags.jsx +150 -0
  144. package/src/commerce/admin/pages/products/components/ProductDataPanel.jsx +132 -0
  145. package/src/commerce/admin/pages/products/components/PublishBox.jsx +101 -0
  146. package/src/commerce/admin/pages/products/components/TaxonomyPanel.jsx +243 -0
  147. package/src/commerce/admin/pages/products/components/tabs/AdvancedTab.jsx +48 -0
  148. package/src/commerce/admin/pages/products/components/tabs/AttributesTab.jsx +208 -0
  149. package/src/commerce/admin/pages/products/components/tabs/DownloadsTab.jsx +91 -0
  150. package/src/commerce/admin/pages/products/components/tabs/ExternalTab.jsx +41 -0
  151. package/src/commerce/admin/pages/products/components/tabs/GeneralTab.jsx +103 -0
  152. package/src/commerce/admin/pages/products/components/tabs/InventoryTab.jsx +93 -0
  153. package/src/commerce/admin/pages/products/components/tabs/LinkedTab.jsx +102 -0
  154. package/src/commerce/admin/pages/products/components/tabs/ShippingTab.jsx +86 -0
  155. package/src/commerce/admin/pages/products/components/tabs/VariationsTab.jsx +377 -0
  156. package/src/commerce/admin/pages/reports/Reports.jsx +416 -0
  157. package/src/commerce/admin/pages/settings/EmailsSettings.jsx +240 -0
  158. package/src/commerce/admin/pages/settings/GeneralSettings.jsx +232 -0
  159. package/src/commerce/admin/pages/settings/InventorySettings.jsx +146 -0
  160. package/src/commerce/admin/pages/settings/PaymentsSettings.jsx +260 -0
  161. package/src/commerce/admin/pages/settings/ProductsSettings.jsx +118 -0
  162. package/src/commerce/admin/pages/settings/SettingsLayout.jsx +53 -0
  163. package/src/commerce/admin/pages/settings/ShippingSettings.jsx +304 -0
  164. package/src/commerce/admin/pages/settings/ShippingZoneEditor.jsx +514 -0
  165. package/src/commerce/admin/pages/settings/TaxRatesTable.jsx +231 -0
  166. package/src/commerce/admin/pages/settings/TaxSettings.jsx +281 -0
  167. package/src/commerce/admin/pages/settings/useGroupForm.jsx +76 -0
  168. package/src/commerce/admin/pages/status/WebhookEditor.jsx +296 -0
  169. package/src/commerce/admin/pages/status/Webhooks.jsx +53 -0
  170. package/src/commerce/admin/routes.jsx +151 -0
  171. package/src/commerce/utils/index.js +19 -0
  172. package/src/commerce/utils/shipping-promos.js +99 -0
  173. package/src/commerce/utils/variants.js +411 -0
@@ -0,0 +1,236 @@
1
+ import React, { useMemo, 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 {
8
+ Select,
9
+ SelectContent,
10
+ SelectItem,
11
+ SelectTrigger,
12
+ SelectValue,
13
+ } from "@/components/ui/select";
14
+ import { FolderTree, ImageIcon, Loader2, Pencil, Trash2 } from "lucide-react";
15
+ import { toast } from "sonner";
16
+
17
+ import { base44 } from "../../lib/api";
18
+ import useAsync from "../../hooks/useAsync";
19
+ import PageHeader from "../../components/PageHeader";
20
+ import DataTable from "../../components/DataTable";
21
+ import ConfirmDialog from "../../components/ConfirmDialog";
22
+ import MediaUploader from "../../components/MediaUploader";
23
+ import { slugify } from "../../lib/product-utils";
24
+
25
+ const NO_PARENT = "__none__";
26
+ const BLANK = { id: null, name: "", slug: "", parent_id: "", description: "", image: null };
27
+
28
+ export default function Categories() {
29
+ const { data: categories, loading, refetch } = useAsync(
30
+ () => base44.entities["commerce.ProductCategory"].list("menu_order", 1000),
31
+ []
32
+ );
33
+ const [form, setForm] = useState({ ...BLANK });
34
+ const [slugTouched, setSlugTouched] = useState(false);
35
+ const [saving, setSaving] = useState(false);
36
+ const [confirmDelete, setConfirmDelete] = useState(null);
37
+ const [deleting, setDeleting] = useState(false);
38
+
39
+ const list = categories || [];
40
+
41
+ // Depth map for indented display.
42
+ const rows = useMemo(() => {
43
+ const byParent = new Map();
44
+ for (const c of list) {
45
+ const key = c.parent_id || "";
46
+ if (!byParent.has(key)) byParent.set(key, []);
47
+ byParent.get(key).push(c);
48
+ }
49
+ const out = [];
50
+ const walk = (parentId, depth) => {
51
+ for (const c of byParent.get(parentId) || []) {
52
+ out.push({ ...c, depth });
53
+ walk(c.id, depth + 1);
54
+ }
55
+ };
56
+ walk("", 0);
57
+ return out;
58
+ }, [list]);
59
+
60
+ const edit = (c) => {
61
+ setForm({
62
+ id: c.id,
63
+ name: c.name || "",
64
+ slug: c.slug || "",
65
+ parent_id: c.parent_id || "",
66
+ description: c.description || "",
67
+ image: c.image || null,
68
+ });
69
+ setSlugTouched(true);
70
+ };
71
+
72
+ const reset = () => {
73
+ setForm({ ...BLANK });
74
+ setSlugTouched(false);
75
+ };
76
+
77
+ const setName = (name) => {
78
+ setForm((f) => ({ ...f, name, slug: slugTouched ? f.slug : slugify(name) }));
79
+ };
80
+
81
+ const save = async () => {
82
+ if (!form.name.trim()) {
83
+ toast.error("Name is required");
84
+ return;
85
+ }
86
+ setSaving(true);
87
+ try {
88
+ const payload = {
89
+ name: form.name.trim(),
90
+ slug: form.slug || slugify(form.name),
91
+ parent_id: form.parent_id === NO_PARENT ? "" : form.parent_id,
92
+ description: form.description,
93
+ image: form.image,
94
+ };
95
+ if (form.id) await base44.entities["commerce.ProductCategory"].update(form.id, payload);
96
+ else await base44.entities["commerce.ProductCategory"].create(payload);
97
+ toast.success(form.id ? "Category updated" : "Category created");
98
+ reset();
99
+ refetch();
100
+ } catch (e) {
101
+ toast.error(e.message || "Failed to save category");
102
+ } finally {
103
+ setSaving(false);
104
+ }
105
+ };
106
+
107
+ const doDelete = async () => {
108
+ setDeleting(true);
109
+ try {
110
+ await base44.entities["commerce.ProductCategory"].delete(confirmDelete.id);
111
+ toast.success("Category deleted");
112
+ if (form.id === confirmDelete.id) reset();
113
+ setConfirmDelete(null);
114
+ refetch();
115
+ } catch (e) {
116
+ toast.error(e.message || "Failed to delete category");
117
+ } finally {
118
+ setDeleting(false);
119
+ }
120
+ };
121
+
122
+ const columns = [
123
+ {
124
+ key: "image",
125
+ label: "",
126
+ className: "w-12",
127
+ render: (row) =>
128
+ row.image?.src ? (
129
+ <img src={row.image.src} alt="" className="h-9 w-9 rounded object-cover" />
130
+ ) : (
131
+ <div className="flex h-9 w-9 items-center justify-center rounded bg-muted">
132
+ <ImageIcon className="h-4 w-4 text-muted-foreground" />
133
+ </div>
134
+ ),
135
+ },
136
+ {
137
+ key: "name",
138
+ label: "Name",
139
+ render: (row) => (
140
+ <span style={{ paddingLeft: `${row.depth * 16}px` }} className="font-medium">
141
+ {row.depth > 0 && <span className="text-muted-foreground">— </span>}
142
+ {row.name}
143
+ </span>
144
+ ),
145
+ },
146
+ { key: "slug", label: "Slug", className: "hidden md:table-cell text-muted-foreground" },
147
+ ];
148
+
149
+ return (
150
+ <div className="space-y-4">
151
+ <PageHeader title="Categories" description="Organize products into a hierarchy" />
152
+ <div className="grid gap-4 lg:grid-cols-[340px_1fr]">
153
+ <Card className="h-fit">
154
+ <CardHeader className="pb-2">
155
+ <CardTitle className="text-base">{form.id ? "Edit category" : "Add new category"}</CardTitle>
156
+ </CardHeader>
157
+ <CardContent className="space-y-3">
158
+ <div className="space-y-1.5">
159
+ <Label>Name</Label>
160
+ <Input value={form.name} onChange={(e) => setName(e.target.value)} />
161
+ </div>
162
+ <div className="space-y-1.5">
163
+ <Label>Slug</Label>
164
+ <Input
165
+ value={form.slug}
166
+ onChange={(e) => {
167
+ setSlugTouched(true);
168
+ setForm((f) => ({ ...f, slug: e.target.value }));
169
+ }}
170
+ />
171
+ </div>
172
+ <div className="space-y-1.5">
173
+ <Label>Parent</Label>
174
+ <Select
175
+ value={form.parent_id || NO_PARENT}
176
+ onValueChange={(v) => setForm((f) => ({ ...f, parent_id: v === NO_PARENT ? "" : v }))}
177
+ >
178
+ <SelectTrigger><SelectValue /></SelectTrigger>
179
+ <SelectContent>
180
+ <SelectItem value={NO_PARENT}>— None —</SelectItem>
181
+ {list
182
+ .filter((c) => c.id !== form.id)
183
+ .map((c) => (
184
+ <SelectItem key={c.id} value={c.id}>{c.name}</SelectItem>
185
+ ))}
186
+ </SelectContent>
187
+ </Select>
188
+ </div>
189
+ <div className="space-y-1.5">
190
+ <Label>Description</Label>
191
+ <Textarea
192
+ rows={3}
193
+ value={form.description}
194
+ onChange={(e) => setForm((f) => ({ ...f, description: e.target.value }))}
195
+ />
196
+ </div>
197
+ <div className="space-y-1.5">
198
+ <Label>Image</Label>
199
+ <MediaUploader value={form.image} onChange={(img) => setForm((f) => ({ ...f, image: img }))} />
200
+ </div>
201
+ <div className="flex gap-2">
202
+ <Button onClick={save} disabled={saving}>
203
+ {saving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
204
+ {form.id ? "Update" : "Add category"}
205
+ </Button>
206
+ {form.id && (
207
+ <Button variant="ghost" onClick={reset} disabled={saving}>Cancel</Button>
208
+ )}
209
+ </div>
210
+ </CardContent>
211
+ </Card>
212
+
213
+ <DataTable
214
+ columns={columns}
215
+ rows={rows}
216
+ loading={loading}
217
+ rowActions={(row) => [
218
+ { label: "Edit", icon: Pencil, onClick: () => edit(row) },
219
+ { label: "Delete", icon: Trash2, destructive: true, onClick: () => setConfirmDelete(row) },
220
+ ]}
221
+ empty={{ icon: FolderTree, title: "No categories yet", description: "Create your first category." }}
222
+ />
223
+ </div>
224
+
225
+ <ConfirmDialog
226
+ open={Boolean(confirmDelete)}
227
+ onOpenChange={(o) => !o && setConfirmDelete(null)}
228
+ title={`Delete "${confirmDelete?.name}"?`}
229
+ description="Products in this category are not deleted; they simply lose the category."
230
+ confirmLabel="Delete"
231
+ onConfirm={doDelete}
232
+ loading={deleting}
233
+ />
234
+ </div>
235
+ );
236
+ }
@@ -0,0 +1,267 @@
1
+ import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
2
+ import { useNavigate, useParams } from "react-router-dom";
3
+ import { Input } from "@/components/ui/input";
4
+ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
5
+ import { Skeleton } from "@/components/ui/skeleton";
6
+ import { toast } from "sonner";
7
+
8
+ import { base44, call } from "../../lib/api";
9
+ import useAsync from "../../hooks/useAsync";
10
+ import { useAdminHref } from "../../context/BasePathContext";
11
+ import PageHeader from "../../components/PageHeader";
12
+ import MediaUploader from "../../components/MediaUploader";
13
+ import RichTextarea from "../../components/RichTextarea";
14
+ import { slugify, isVariable } from "../../lib/product-utils";
15
+ import ProductDataPanel from "./components/ProductDataPanel";
16
+ import TaxonomyPanel from "./components/TaxonomyPanel";
17
+ import PublishBox from "./components/PublishBox";
18
+
19
+ const NEW_PRODUCT = {
20
+ name: "",
21
+ slug: "",
22
+ type: "simple",
23
+ status: "draft",
24
+ featured: false,
25
+ catalog_visibility: "visible",
26
+ description: "",
27
+ short_description: "",
28
+ sku: "",
29
+ regular_price: null,
30
+ sale_price: null,
31
+ date_on_sale_from: null,
32
+ date_on_sale_to: null,
33
+ virtual: false,
34
+ downloadable: false,
35
+ downloads: [],
36
+ download_limit: -1,
37
+ download_expiry: -1,
38
+ external_url: "",
39
+ button_text: "",
40
+ tax_status: "taxable",
41
+ tax_class: "standard",
42
+ manage_stock: false,
43
+ stock_quantity: null,
44
+ stock_status: "instock",
45
+ backorders: "no",
46
+ low_stock_amount: null,
47
+ sold_individually: false,
48
+ weight: null,
49
+ dimensions: { length: null, width: null, height: null },
50
+ shipping_class_id: "",
51
+ reviews_allowed: true,
52
+ upsell_ids: [],
53
+ cross_sell_ids: [],
54
+ grouped_products: [],
55
+ purchase_note: "",
56
+ category_ids: [],
57
+ tag_ids: [],
58
+ images: [],
59
+ attributes: [],
60
+ default_attributes: [],
61
+ menu_order: 0,
62
+ meta_data: [],
63
+ };
64
+
65
+ export default function ProductEditor() {
66
+ const { id } = useParams();
67
+ const isNew = !id;
68
+ const navigate = useNavigate();
69
+ const href = useAdminHref();
70
+
71
+ const [product, setProduct] = useState(null);
72
+ const [variations, setVariations] = useState([]);
73
+ const [saving, setSaving] = useState(false);
74
+ const initialRef = useRef("");
75
+ const slugTouched = useRef(false);
76
+
77
+ // Reference data, loaded once.
78
+ const { data: categories, refetch: refreshCategories } = useAsync(
79
+ () => base44.entities["commerce.ProductCategory"].list(undefined, 1000),
80
+ []
81
+ );
82
+ const { data: tags, refetch: refreshTags } = useAsync(
83
+ () => base44.entities["commerce.ProductTag"].list(undefined, 1000),
84
+ []
85
+ );
86
+ const { data: attributes } = useAsync(() => base44.entities["commerce.ProductAttribute"].list(undefined, 500), []);
87
+ const { data: shippingClasses } = useAsync(() => base44.entities["commerce.ShippingClass"].list(undefined, 500), []);
88
+ const { data: taxClasses } = useAsync(() => base44.entities["commerce.TaxClass"].list(undefined, 100), []);
89
+
90
+ const load = useCallback(async () => {
91
+ if (isNew) {
92
+ setProduct({ ...NEW_PRODUCT });
93
+ setVariations([]);
94
+ initialRef.current = JSON.stringify({ p: NEW_PRODUCT, v: [] });
95
+ return;
96
+ }
97
+ const [p, vars] = await Promise.all([
98
+ base44.entities["commerce.Product"].get(id),
99
+ base44.entities["commerce.ProductVariation"].filter({ product_id: id }, "menu_order", 1000),
100
+ ]);
101
+ const merged = { ...NEW_PRODUCT, ...p };
102
+ setProduct(merged);
103
+ setVariations(vars || []);
104
+ slugTouched.current = Boolean(p.slug);
105
+ initialRef.current = JSON.stringify({ p: merged, v: vars || [] });
106
+ }, [id, isNew]);
107
+
108
+ useEffect(() => {
109
+ load().catch((e) => toast.error(e.message || "Failed to load product"));
110
+ }, [load]);
111
+
112
+ /** Patch the product object. */
113
+ const up = useCallback((patch) => setProduct((p) => ({ ...p, ...patch })), []);
114
+
115
+ const setName = (name) => {
116
+ const patch = { name };
117
+ if (!slugTouched.current) patch.slug = slugify(name);
118
+ up(patch);
119
+ };
120
+
121
+ const dirty = product && JSON.stringify({ p: product, v: variations }) !== initialRef.current;
122
+
123
+ const save = async () => {
124
+ if (!product.name?.trim()) {
125
+ toast.error("Product name is required");
126
+ return;
127
+ }
128
+ setSaving(true);
129
+ try {
130
+ const payload = { product: { ...product, name: product.name.trim() } };
131
+ if (isVariable(product)) payload.variations = variations;
132
+ const data = await call("admin-products", "save", payload);
133
+ const savedId = data?.product?.id || data?.id || id;
134
+ toast.success(isNew ? "Product created" : "Product saved");
135
+ if (isNew && savedId) navigate(href(`products/${savedId}`), { replace: true });
136
+ else await load();
137
+ } catch {
138
+ /* toast handled by call() */
139
+ } finally {
140
+ setSaving(false);
141
+ }
142
+ };
143
+
144
+ const doDelete = async () => {
145
+ await call("admin-products", "delete", { id });
146
+ toast.success("Product deleted");
147
+ navigate(href("products"));
148
+ };
149
+
150
+ // Primary image / gallery are views over product.images (first = primary).
151
+ const primaryImage = product?.images?.[0] || null;
152
+ const gallery = useMemo(() => product?.images?.slice(1) || [], [product?.images]);
153
+ const setPrimaryImage = (img) => up({ images: img ? [img, ...gallery] : [...gallery] });
154
+ const setGallery = (list) => up({ images: primaryImage ? [primaryImage, ...list] : [...list] });
155
+
156
+ if (!product) {
157
+ return (
158
+ <div className="space-y-4">
159
+ <Skeleton className="h-9 w-64" />
160
+ <Skeleton className="h-64 w-full" />
161
+ </div>
162
+ );
163
+ }
164
+
165
+ const refs = { categories, tags, attributes, shippingClasses, taxClasses, refreshCategories, refreshTags };
166
+
167
+ return (
168
+ <div className="space-y-4">
169
+ <PageHeader
170
+ title={isNew ? "Add new product" : "Edit product"}
171
+ backHref={href("products")}
172
+ />
173
+
174
+ <div className="grid gap-4 lg:grid-cols-[1fr_300px]">
175
+ {/* Main column */}
176
+ <div className="min-w-0 space-y-4">
177
+ <Input
178
+ className="h-11 text-lg font-medium"
179
+ placeholder="Product name"
180
+ value={product.name}
181
+ onChange={(e) => setName(e.target.value)}
182
+ />
183
+
184
+ <div className="flex items-center gap-2 text-sm text-muted-foreground">
185
+ <span>Slug:</span>
186
+ <Input
187
+ className="h-7 w-72 text-xs"
188
+ value={product.slug || ""}
189
+ onChange={(e) => {
190
+ slugTouched.current = true;
191
+ up({ slug: e.target.value });
192
+ }}
193
+ />
194
+ </div>
195
+
196
+ <Card>
197
+ <CardHeader className="pb-2">
198
+ <CardTitle className="text-base">Description</CardTitle>
199
+ </CardHeader>
200
+ <CardContent>
201
+ <RichTextarea
202
+ rows={8}
203
+ placeholder="Full product description (HTML allowed)"
204
+ value={product.description || ""}
205
+ onChange={(v) => up({ description: v })}
206
+ />
207
+ </CardContent>
208
+ </Card>
209
+
210
+ <ProductDataPanel
211
+ product={product}
212
+ up={up}
213
+ refs={refs}
214
+ variations={variations}
215
+ setVariations={setVariations}
216
+ />
217
+
218
+ <Card>
219
+ <CardHeader className="pb-2">
220
+ <CardTitle className="text-base">Short description</CardTitle>
221
+ </CardHeader>
222
+ <CardContent>
223
+ <RichTextarea
224
+ rows={4}
225
+ placeholder="Short summary shown in listings"
226
+ value={product.short_description || ""}
227
+ onChange={(v) => up({ short_description: v })}
228
+ />
229
+ </CardContent>
230
+ </Card>
231
+ </div>
232
+
233
+ {/* Sidebar */}
234
+ <div className="space-y-4">
235
+ <PublishBox
236
+ product={product}
237
+ up={up}
238
+ onSave={save}
239
+ onDelete={isNew ? null : doDelete}
240
+ saving={saving}
241
+ dirty={dirty}
242
+ />
243
+
244
+ <Card>
245
+ <CardHeader className="pb-2">
246
+ <CardTitle className="text-base">Product image</CardTitle>
247
+ </CardHeader>
248
+ <CardContent>
249
+ <MediaUploader value={primaryImage} onChange={setPrimaryImage} label="Set product image" />
250
+ </CardContent>
251
+ </Card>
252
+
253
+ <Card>
254
+ <CardHeader className="pb-2">
255
+ <CardTitle className="text-base">Product gallery</CardTitle>
256
+ </CardHeader>
257
+ <CardContent>
258
+ <MediaUploader multiple value={gallery} onChange={setGallery} label="Add gallery images" />
259
+ </CardContent>
260
+ </Card>
261
+
262
+ <TaxonomyPanel product={product} up={up} refs={refs} />
263
+ </div>
264
+ </div>
265
+ </div>
266
+ );
267
+ }