@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,227 @@
1
+ import React, { useMemo, useState } from "react";
2
+ import { useNavigate, useSearchParams } from "react-router-dom";
3
+ import { Button } from "@/components/ui/button";
4
+ import { Input } from "@/components/ui/input";
5
+ import {
6
+ Select,
7
+ SelectContent,
8
+ SelectItem,
9
+ SelectTrigger,
10
+ SelectValue,
11
+ } from "@/components/ui/select";
12
+ import { Loader2, Plus, Search, ShoppingCart } from "lucide-react";
13
+ import { toast } from "sonner";
14
+
15
+ import { call, base44 } from "../../lib/api";
16
+ import useAsync from "../../hooks/useAsync";
17
+ import usePagedList from "../../hooks/usePagedList";
18
+ import useRealtime from "../../hooks/useRealtime";
19
+ import useDebounce from "../../hooks/useDebounce";
20
+ import useMoney from "../../hooks/useMoney";
21
+ import { useAdminHref } from "../../context/BasePathContext";
22
+ import { ORDER_STATUSES } from "../../lib/constants";
23
+ import { formatDate } from "../../lib/format";
24
+ import { customerName } from "../../lib/order-utils";
25
+ import PageHeader from "../../components/PageHeader";
26
+ import DataTable from "../../components/DataTable";
27
+ import DateRangePicker from "../../components/DateRangePicker";
28
+ import StatusBadge from "../../components/StatusBadge";
29
+
30
+ const TABS = [{ value: "", label: "All" }, ...ORDER_STATUSES];
31
+
32
+ export default function OrdersList() {
33
+ const href = useAdminHref();
34
+ const navigate = useNavigate();
35
+ const { format } = useMoney();
36
+ const [searchParams, setSearchParams] = useSearchParams();
37
+
38
+ const status = searchParams.get("status") || "";
39
+ const [query, setQuery] = useState("");
40
+ const [dateRange, setDateRange] = useState(null);
41
+ const [selected, setSelected] = useState([]);
42
+ const [bulkStatus, setBulkStatus] = useState("");
43
+ const [bulkBusy, setBulkBusy] = useState(false);
44
+ const [creating, setCreating] = useState(false);
45
+
46
+ const debouncedQuery = useDebounce(query, 300);
47
+ const searchMode = Boolean(debouncedQuery.trim() || dateRange?.from);
48
+
49
+ const counts = useAsync(async () => {
50
+ const data = await call("admin-orders", "status-counts", {}, { silent: true });
51
+ return data?.counts || data || {};
52
+ }, []);
53
+
54
+ const list = usePagedList(
55
+ async (limit, skip, sort) => {
56
+ if (searchMode) {
57
+ const data = await call("admin-orders", "search", {
58
+ q: debouncedQuery.trim(),
59
+ status: status || undefined,
60
+ date_min: dateRange?.from || undefined,
61
+ date_max: dateRange?.to || undefined,
62
+ limit,
63
+ skip,
64
+ sort,
65
+ });
66
+ return data?.rows || data || [];
67
+ }
68
+ return base44.entities["commerce.Order"].filter(status ? { status } : {}, sort, limit, skip);
69
+ },
70
+ { deps: [debouncedQuery, status, dateRange?.from, dateRange?.to] }
71
+ );
72
+
73
+ // Orders arrive without the admin doing anything, so keep the list and the
74
+ // status tab counts live. Quiet refetches leave the table on screen.
75
+ useRealtime(["commerce.Order", "commerce.OrderRefund"], () => {
76
+ list.refetchQuiet();
77
+ counts.refetchQuiet();
78
+ });
79
+
80
+ const setStatusTab = (value) => {
81
+ setSelected([]);
82
+ const next = new URLSearchParams(searchParams);
83
+ if (value) next.set("status", value);
84
+ else next.delete("status");
85
+ setSearchParams(next, { replace: true });
86
+ };
87
+
88
+ const applyBulk = async () => {
89
+ if (!bulkStatus || selected.length === 0) return;
90
+ setBulkBusy(true);
91
+ try {
92
+ await call("admin-orders", "bulk-status", { ids: selected, status: bulkStatus });
93
+ toast.success(`${selected.length} order(s) updated`);
94
+ setSelected([]);
95
+ setBulkStatus("");
96
+ list.refetch();
97
+ counts.refetch();
98
+ } catch {
99
+ /* toast handled by call() */
100
+ } finally {
101
+ setBulkBusy(false);
102
+ }
103
+ };
104
+
105
+ const addOrder = async () => {
106
+ setCreating(true);
107
+ try {
108
+ const data = await call("admin-orders", "create-draft");
109
+ const id = data?.id || data?.order_id || data?.order?.id;
110
+ if (id) navigate(href(`orders/${id}`));
111
+ } catch {
112
+ /* toast handled by call() */
113
+ } finally {
114
+ setCreating(false);
115
+ }
116
+ };
117
+
118
+ const columns = useMemo(
119
+ () => [
120
+ {
121
+ key: "order_number",
122
+ label: "Order",
123
+ render: (o) => (
124
+ <span className="font-medium">
125
+ #{o.order_number || o.id} <span className="font-normal text-muted-foreground">{customerName(o)}</span>
126
+ </span>
127
+ ),
128
+ },
129
+ { key: "created_date", label: "Date", sortable: true, render: (o) => formatDate(o.created_date) },
130
+ { key: "status", label: "Status", render: (o) => <StatusBadge status={o.status} /> },
131
+ { key: "total", label: "Total", sortable: true, className: "text-right", render: (o) => format(o.total || 0) },
132
+ ],
133
+ [format]
134
+ );
135
+
136
+ const totalCount = Object.values(counts.data || {}).reduce((a, b) => a + (b || 0), 0);
137
+
138
+ return (
139
+ <div>
140
+ <PageHeader
141
+ title="Orders"
142
+ actions={
143
+ <Button onClick={addOrder} disabled={creating}>
144
+ {creating ? <Loader2 className="mr-1.5 h-4 w-4 animate-spin" /> : <Plus className="mr-1.5 h-4 w-4" />}
145
+ Add order
146
+ </Button>
147
+ }
148
+ />
149
+
150
+ {/* Status tabs */}
151
+ <div className="mb-4 flex flex-wrap gap-1 border-b pb-px">
152
+ {TABS.map((tab) => {
153
+ const count = tab.value === "" ? totalCount : (counts.data || {})[tab.value] || 0;
154
+ const active = status === tab.value;
155
+ return (
156
+ <button
157
+ key={tab.value || "all"}
158
+ onClick={() => setStatusTab(tab.value)}
159
+ className={`-mb-px border-b-2 px-3 py-2 text-sm transition-colors ${
160
+ active
161
+ ? "border-primary font-medium text-foreground"
162
+ : "border-transparent text-muted-foreground hover:text-foreground"
163
+ }`}
164
+ >
165
+ {tab.label}
166
+ <span className="ml-1 text-xs text-muted-foreground">({count})</span>
167
+ </button>
168
+ );
169
+ })}
170
+ </div>
171
+
172
+ {/* Filters + bulk bar */}
173
+ <div className="mb-4 flex flex-wrap items-center gap-2">
174
+ <div className="relative">
175
+ <Search className="pointer-events-none absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
176
+ <Input
177
+ value={query}
178
+ onChange={(e) => setQuery(e.target.value)}
179
+ placeholder="Search order #, name, email…"
180
+ className="w-64 pl-8"
181
+ />
182
+ </div>
183
+ <DateRangePicker value={dateRange} onChange={setDateRange} />
184
+ {selected.length > 0 && (
185
+ <div className="ml-auto flex items-center gap-2 rounded-md border bg-muted/50 px-2 py-1">
186
+ <span className="text-sm text-muted-foreground">{selected.length} selected</span>
187
+ <Select value={bulkStatus} onValueChange={setBulkStatus}>
188
+ <SelectTrigger className="h-8 w-44">
189
+ <SelectValue placeholder="Change status to…" />
190
+ </SelectTrigger>
191
+ <SelectContent>
192
+ {ORDER_STATUSES.map((s) => (
193
+ <SelectItem key={s.value} value={s.value}>
194
+ {s.label}
195
+ </SelectItem>
196
+ ))}
197
+ </SelectContent>
198
+ </Select>
199
+ <Button size="sm" onClick={applyBulk} disabled={!bulkStatus || bulkBusy}>
200
+ {bulkBusy && <Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />}
201
+ Apply
202
+ </Button>
203
+ </div>
204
+ )}
205
+ </div>
206
+
207
+ <DataTable
208
+ columns={columns}
209
+ rows={list.rows}
210
+ loading={list.loading}
211
+ sort={list.sort}
212
+ onSort={list.setSort}
213
+ selectable
214
+ selected={selected}
215
+ onSelectChange={setSelected}
216
+ onRowClick={(o) => navigate(href(`orders/${o.id}`))}
217
+ rowActions={(o) => [{ label: "View / edit", onClick: () => navigate(href(`orders/${o.id}`)) }]}
218
+ pagination={{ page: list.page, hasNext: list.hasNext, onNext: list.next, onPrev: list.prev }}
219
+ empty={{
220
+ icon: ShoppingCart,
221
+ title: "No orders found",
222
+ description: searchMode ? "Try adjusting your search or date range." : "Orders will appear here as they come in.",
223
+ }}
224
+ />
225
+ </div>
226
+ );
227
+ }
@@ -0,0 +1,149 @@
1
+ import React, { useEffect, useState } from "react";
2
+ import {
3
+ Dialog,
4
+ DialogContent,
5
+ DialogFooter,
6
+ DialogHeader,
7
+ DialogTitle,
8
+ } from "@/components/ui/dialog";
9
+ import { Button } from "@/components/ui/button";
10
+ import { Input } from "@/components/ui/input";
11
+ import { Label } from "@/components/ui/label";
12
+ import {
13
+ Select,
14
+ SelectContent,
15
+ SelectItem,
16
+ SelectTrigger,
17
+ SelectValue,
18
+ } from "@/components/ui/select";
19
+ import { Loader2 } from "lucide-react";
20
+
21
+ import { call, base44 } from "../../../lib/api";
22
+ import SearchSelect from "../../../components/SearchSelect";
23
+ import { variationLabel } from "../../../lib/product-utils";
24
+
25
+ /**
26
+ * Two-step product picker for the order editor:
27
+ * product search → (variable only) variation select → quantity.
28
+ *
29
+ * Props: { open, onOpenChange, onAdd({product, variation|null, quantity}) }
30
+ */
31
+ export default function AddProductDialog({ open, onOpenChange, onAdd }) {
32
+ const [picked, setPicked] = useState(null); // {value,label,meta,product}
33
+ const [variations, setVariations] = useState(null); // null = not loaded
34
+ const [variationId, setVariationId] = useState("");
35
+ const [quantity, setQuantity] = useState(1);
36
+ const [loadingVariations, setLoadingVariations] = useState(false);
37
+
38
+ useEffect(() => {
39
+ if (!open) {
40
+ setPicked(null);
41
+ setVariations(null);
42
+ setVariationId("");
43
+ setQuantity(1);
44
+ }
45
+ }, [open]);
46
+
47
+ const searchProducts = async (q) => {
48
+ const data = await call("admin-products", "search", { q, limit: 20 }, { silent: true });
49
+ const rows = data?.rows || data || [];
50
+ return rows.map((p) => ({
51
+ value: p.id,
52
+ label: p.name,
53
+ meta: [p.sku && `SKU: ${p.sku}`, p.type].filter(Boolean).join(" · "),
54
+ product: p,
55
+ }));
56
+ };
57
+
58
+ const handlePick = async (opt) => {
59
+ setPicked(opt);
60
+ setVariations(null);
61
+ setVariationId("");
62
+ if (opt?.product?.type === "variable") {
63
+ setLoadingVariations(true);
64
+ try {
65
+ const rows = await base44.entities["commerce.ProductVariation"].filter({ product_id: opt.value }, "menu_order", 200);
66
+ setVariations(rows || []);
67
+ } finally {
68
+ setLoadingVariations(false);
69
+ }
70
+ }
71
+ };
72
+
73
+ const isVariable = picked?.product?.type === "variable";
74
+ const canAdd = picked && quantity > 0 && (!isVariable || variationId);
75
+
76
+ const submit = () => {
77
+ const variation = isVariable ? (variations || []).find((v) => v.id === variationId) : null;
78
+ onAdd({ product: picked.product, variation, quantity });
79
+ onOpenChange(false);
80
+ };
81
+
82
+ return (
83
+ <Dialog open={open} onOpenChange={onOpenChange}>
84
+ <DialogContent className="sm:max-w-md">
85
+ <DialogHeader>
86
+ <DialogTitle>Add product</DialogTitle>
87
+ </DialogHeader>
88
+
89
+ <div className="space-y-4">
90
+ <div className="grid gap-1.5">
91
+ <Label>Product</Label>
92
+ <SearchSelect
93
+ search={searchProducts}
94
+ value={picked}
95
+ onChange={handlePick}
96
+ placeholder="Search products…"
97
+ />
98
+ </div>
99
+
100
+ {isVariable && (
101
+ <div className="grid gap-1.5">
102
+ <Label>Variation</Label>
103
+ {loadingVariations ? (
104
+ <div className="flex items-center gap-2 text-sm text-muted-foreground">
105
+ <Loader2 className="h-4 w-4 animate-spin" /> Loading variations…
106
+ </div>
107
+ ) : (variations || []).length === 0 ? (
108
+ <p className="text-sm text-muted-foreground">This product has no variations yet.</p>
109
+ ) : (
110
+ <Select value={variationId} onValueChange={setVariationId}>
111
+ <SelectTrigger>
112
+ <SelectValue placeholder="Choose a variation…" />
113
+ </SelectTrigger>
114
+ <SelectContent>
115
+ {(variations || []).map((v) => (
116
+ <SelectItem key={v.id} value={v.id}>
117
+ {variationLabel(v) || v.sku || v.id}
118
+ </SelectItem>
119
+ ))}
120
+ </SelectContent>
121
+ </Select>
122
+ )}
123
+ </div>
124
+ )}
125
+
126
+ <div className="grid gap-1.5">
127
+ <Label>Quantity</Label>
128
+ <Input
129
+ type="number"
130
+ min="1"
131
+ className="w-24"
132
+ value={quantity}
133
+ onChange={(e) => setQuantity(Math.max(1, parseInt(e.target.value, 10) || 1))}
134
+ />
135
+ </div>
136
+ </div>
137
+
138
+ <DialogFooter>
139
+ <Button variant="outline" onClick={() => onOpenChange(false)}>
140
+ Cancel
141
+ </Button>
142
+ <Button onClick={submit} disabled={!canAdd}>
143
+ Add to order
144
+ </Button>
145
+ </DialogFooter>
146
+ </DialogContent>
147
+ </Dialog>
148
+ );
149
+ }
@@ -0,0 +1,119 @@
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 {
5
+ Table,
6
+ TableBody,
7
+ TableCell,
8
+ TableHead,
9
+ TableHeader,
10
+ TableRow,
11
+ } from "@/components/ui/table";
12
+ import { Loader2, X } from "lucide-react";
13
+ import { toast } from "sonner";
14
+
15
+ import { call } from "../../../lib/api";
16
+ import SearchSelect from "../../../components/SearchSelect";
17
+ import { formatDate } from "../../../lib/format";
18
+
19
+ /**
20
+ * Downloadable product access for an order: grant / revoke rows.
21
+ * Props: { orderId, permissions, loading, onChanged() }
22
+ */
23
+ export default function DownloadPermissionsPanel({ orderId, permissions, loading, onChanged }) {
24
+ const [picked, setPicked] = useState(null);
25
+ const [busy, setBusy] = useState(false);
26
+
27
+ const searchDownloadable = async (q) => {
28
+ const data = await call("admin-products", "search", { q, limit: 20 }, { silent: true });
29
+ const rows = data?.rows || data || [];
30
+ return rows
31
+ .filter((p) => p.downloadable)
32
+ .map((p) => ({ value: p.id, label: p.name, meta: p.sku ? `SKU: ${p.sku}` : undefined }));
33
+ };
34
+
35
+ const grant = async () => {
36
+ if (!picked) return;
37
+ setBusy(true);
38
+ try {
39
+ await call("admin-orders", "grant-download", { order_id: orderId, product_id: picked.value });
40
+ toast.success("Download access granted");
41
+ setPicked(null);
42
+ onChanged();
43
+ } catch {
44
+ /* toast handled by call() */
45
+ } finally {
46
+ setBusy(false);
47
+ }
48
+ };
49
+
50
+ const revoke = async (permissionId) => {
51
+ try {
52
+ await call("admin-orders", "revoke-download", { order_id: orderId, permission_id: permissionId });
53
+ toast.success("Download access revoked");
54
+ onChanged();
55
+ } catch {
56
+ /* toast handled by call() */
57
+ }
58
+ };
59
+
60
+ return (
61
+ <Card>
62
+ <CardHeader className="pb-3">
63
+ <CardTitle className="text-base">Downloadable product permissions</CardTitle>
64
+ </CardHeader>
65
+ <CardContent className="space-y-3">
66
+ {loading ? (
67
+ <div className="flex justify-center py-4">
68
+ <Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
69
+ </div>
70
+ ) : (permissions || []).length === 0 ? (
71
+ <p className="text-sm text-muted-foreground">No download permissions on this order.</p>
72
+ ) : (
73
+ <Table>
74
+ <TableHeader>
75
+ <TableRow>
76
+ <TableHead>File</TableHead>
77
+ <TableHead className="w-32 text-right">Remaining</TableHead>
78
+ <TableHead className="w-32">Expires</TableHead>
79
+ <TableHead className="w-10" />
80
+ </TableRow>
81
+ </TableHeader>
82
+ <TableBody>
83
+ {permissions.map((p) => (
84
+ <TableRow key={p.id}>
85
+ <TableCell className="text-sm">{p.download_name || p.file_url || p.product_id}</TableCell>
86
+ <TableCell className="text-right text-sm">
87
+ {p.downloads_remaining === -1 ? "∞" : p.downloads_remaining}
88
+ <span className="text-xs text-muted-foreground"> (used {p.download_count || 0})</span>
89
+ </TableCell>
90
+ <TableCell className="text-sm">{p.access_expires ? formatDate(p.access_expires) : "Never"}</TableCell>
91
+ <TableCell>
92
+ <Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => revoke(p.id)} title="Revoke">
93
+ <X className="h-4 w-4" />
94
+ </Button>
95
+ </TableCell>
96
+ </TableRow>
97
+ ))}
98
+ </TableBody>
99
+ </Table>
100
+ )}
101
+
102
+ <div className="flex items-end gap-2 border-t pt-3">
103
+ <div className="flex-1">
104
+ <SearchSelect
105
+ search={searchDownloadable}
106
+ value={picked}
107
+ onChange={setPicked}
108
+ placeholder="Search downloadable products…"
109
+ />
110
+ </div>
111
+ <Button size="sm" onClick={grant} disabled={!picked || busy}>
112
+ {busy && <Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />}
113
+ Grant access
114
+ </Button>
115
+ </div>
116
+ </CardContent>
117
+ </Card>
118
+ );
119
+ }