@hectasquare/medusa-plugin-category-tree 1.0.2 → 1.0.6
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/.medusa/server/src/admin/components/category-tree/category-products-manager.d.ts +8 -0
- package/.medusa/server/src/admin/components/category-tree/category-products-manager.js +290 -0
- package/.medusa/server/src/admin/components/category-tree/category-tree-canvas.js +6 -1
- package/.medusa/server/src/admin/components/category-tree/custom-edge.js +5 -3
- package/.medusa/server/src/admin/components/category-tree/edit-category-drawer.js +88 -8
- package/.medusa/server/src/admin/components/category-tree/lib/layout-engine.js +12 -4
- package/LICENSE +190 -0
- package/README.md +102 -120
- package/package.json +12 -3
- package/src/admin/components/category-tree/category-products-manager.tsx +749 -0
- package/src/admin/components/category-tree/category-tree-canvas.tsx +11 -2
- package/src/admin/components/category-tree/custom-edge.tsx +6 -2
- package/src/admin/components/category-tree/edit-category-drawer.tsx +427 -166
- package/src/admin/components/category-tree/lib/layout-engine.ts +13 -4
|
@@ -0,0 +1,749 @@
|
|
|
1
|
+
import React, { useState, useEffect, useCallback, useMemo } from "react"
|
|
2
|
+
import {
|
|
3
|
+
Button,
|
|
4
|
+
Badge,
|
|
5
|
+
toast,
|
|
6
|
+
} from "@medusajs/ui"
|
|
7
|
+
import {
|
|
8
|
+
Plus,
|
|
9
|
+
Trash,
|
|
10
|
+
MagnifyingGlass,
|
|
11
|
+
CheckMini,
|
|
12
|
+
Sparkles,
|
|
13
|
+
} from "@medusajs/icons"
|
|
14
|
+
import { sdk } from "../../lib/sdk"
|
|
15
|
+
|
|
16
|
+
export interface CategoryProductsManagerProps {
|
|
17
|
+
categoryId: string
|
|
18
|
+
categoryName: string
|
|
19
|
+
initialMetadata?: Record<string, any> | null
|
|
20
|
+
onMetadataUpdate?: (updatedMetadata: Record<string, any>) => void
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface ProductItem {
|
|
24
|
+
id: string
|
|
25
|
+
title: string
|
|
26
|
+
thumbnail?: string | null
|
|
27
|
+
status: string
|
|
28
|
+
variants?: Array<{ id: string; title: string }>
|
|
29
|
+
collection?: { id: string; title: string } | null
|
|
30
|
+
sales_channels?: Array<{ id: string; name: string }>
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export const CategoryProductsManager: React.FC<CategoryProductsManagerProps> = ({
|
|
34
|
+
categoryId,
|
|
35
|
+
categoryName,
|
|
36
|
+
initialMetadata,
|
|
37
|
+
onMetadataUpdate,
|
|
38
|
+
}) => {
|
|
39
|
+
const [products, setProducts] = useState<ProductItem[]>([])
|
|
40
|
+
const [totalCount, setTotalCount] = useState(0)
|
|
41
|
+
const [isLoading, setIsLoading] = useState(true)
|
|
42
|
+
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
|
|
43
|
+
const [isRemoving, setIsRemoving] = useState(false)
|
|
44
|
+
const [activeView, setActiveView] = useState<"all" | "top_sellers">("all")
|
|
45
|
+
|
|
46
|
+
// Product ranking and top sellers state
|
|
47
|
+
const [orderedProductIds, setOrderedProductIds] = useState<string[]>([])
|
|
48
|
+
const [topSellerIds, setTopSellerIds] = useState<Set<string>>(new Set())
|
|
49
|
+
|
|
50
|
+
// Full-Screen Add Products FocusModal state (matching Medusa legacy modal)
|
|
51
|
+
const [focusModalOpen, setFocusModalOpen] = useState(false)
|
|
52
|
+
const [searchQuery, setSearchQuery] = useState("")
|
|
53
|
+
const [catalogProducts, setCatalogProducts] = useState<ProductItem[]>([])
|
|
54
|
+
const [catalogTotalCount, setCatalogTotalCount] = useState(0)
|
|
55
|
+
const [catalogPage, setCatalogPage] = useState(0)
|
|
56
|
+
const [isSearchingCatalog, setIsSearchingCatalog] = useState(false)
|
|
57
|
+
const [catalogSelectedIds, setCatalogSelectedIds] = useState<Set<string>>(new Set())
|
|
58
|
+
const [isAdding, setIsAdding] = useState(false)
|
|
59
|
+
|
|
60
|
+
const PAGE_SIZE = 50
|
|
61
|
+
|
|
62
|
+
// Initialize ranking and top sellers from category metadata
|
|
63
|
+
useEffect(() => {
|
|
64
|
+
if (initialMetadata) {
|
|
65
|
+
if (Array.isArray(initialMetadata.ordered_product_ids)) {
|
|
66
|
+
setOrderedProductIds(initialMetadata.ordered_product_ids)
|
|
67
|
+
}
|
|
68
|
+
if (Array.isArray(initialMetadata.top_sellers)) {
|
|
69
|
+
setTopSellerIds(new Set(initialMetadata.top_sellers))
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}, [initialMetadata])
|
|
73
|
+
|
|
74
|
+
// Load products assigned to this category
|
|
75
|
+
const loadAssignedProducts = useCallback(async () => {
|
|
76
|
+
setIsLoading(true)
|
|
77
|
+
try {
|
|
78
|
+
const res = await sdk.client.fetch<{ products: ProductItem[]; count: number }>(
|
|
79
|
+
`/admin/products?category_id[]=${categoryId}&limit=100&fields=id,title,thumbnail,status,variants.id,collection.title,sales_channels.name`
|
|
80
|
+
)
|
|
81
|
+
const fetched = res.products || []
|
|
82
|
+
setTotalCount(res.count ?? fetched.length)
|
|
83
|
+
|
|
84
|
+
// Sort products according to orderedProductIds if available
|
|
85
|
+
setProducts((prev) => {
|
|
86
|
+
const orderMap = new Map(orderedProductIds.map((id, index) => [id, index]))
|
|
87
|
+
return [...fetched].sort((a, b) => {
|
|
88
|
+
const indexA = orderMap.has(a.id) ? orderMap.get(a.id)! : 9999
|
|
89
|
+
const indexB = orderMap.has(b.id) ? orderMap.get(b.id)! : 9999
|
|
90
|
+
return indexA - indexB
|
|
91
|
+
})
|
|
92
|
+
})
|
|
93
|
+
} catch (err: any) {
|
|
94
|
+
toast.error(err?.message || "Failed to load assigned products")
|
|
95
|
+
} finally {
|
|
96
|
+
setIsLoading(false)
|
|
97
|
+
}
|
|
98
|
+
}, [categoryId, orderedProductIds])
|
|
99
|
+
|
|
100
|
+
useEffect(() => {
|
|
101
|
+
loadAssignedProducts()
|
|
102
|
+
}, [loadAssignedProducts])
|
|
103
|
+
|
|
104
|
+
// Save updated product rankings and top sellers to category metadata
|
|
105
|
+
const persistProductConfig = async (newOrder: string[], newTopSellers: Set<string>) => {
|
|
106
|
+
const ranksMap: Record<string, number> = {}
|
|
107
|
+
newOrder.forEach((id, idx) => {
|
|
108
|
+
ranksMap[id] = idx + 1
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
const updatedMetadata = {
|
|
112
|
+
...(initialMetadata || {}),
|
|
113
|
+
ordered_product_ids: newOrder,
|
|
114
|
+
product_ranks: ranksMap,
|
|
115
|
+
top_sellers: Array.from(newTopSellers),
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
try {
|
|
119
|
+
await sdk.admin.productCategory.update(categoryId, {
|
|
120
|
+
metadata: updatedMetadata,
|
|
121
|
+
})
|
|
122
|
+
if (onMetadataUpdate) {
|
|
123
|
+
onMetadataUpdate(updatedMetadata)
|
|
124
|
+
}
|
|
125
|
+
} catch (err: any) {
|
|
126
|
+
toast.error(err?.message || "Failed to save product ordering")
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Sibling product ranking handlers (Move Up / Move Down)
|
|
131
|
+
const handleMoveProduct = async (index: number, direction: "up" | "down") => {
|
|
132
|
+
const targetIndex = direction === "up" ? index - 1 : index + 1
|
|
133
|
+
if (targetIndex < 0 || targetIndex >= products.length) return
|
|
134
|
+
|
|
135
|
+
const reordered = [...products]
|
|
136
|
+
const [moved] = reordered.splice(index, 1)
|
|
137
|
+
reordered.splice(targetIndex, 0, moved)
|
|
138
|
+
|
|
139
|
+
setProducts(reordered)
|
|
140
|
+
const newOrder = reordered.map((p) => p.id)
|
|
141
|
+
setOrderedProductIds(newOrder)
|
|
142
|
+
|
|
143
|
+
toast.info(`Moved "${moved.title}" to #${targetIndex + 1}`)
|
|
144
|
+
await persistProductConfig(newOrder, topSellerIds)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Toggle Top Commercial Seller status
|
|
148
|
+
const handleToggleTopSeller = async (productId: string, productTitle: string) => {
|
|
149
|
+
const next = new Set(topSellerIds)
|
|
150
|
+
const isNowTop = !next.has(productId)
|
|
151
|
+
if (isNowTop) {
|
|
152
|
+
next.add(productId)
|
|
153
|
+
toast.success(`Marked "${productTitle}" as Top Commercial Seller ⭐`)
|
|
154
|
+
} else {
|
|
155
|
+
next.delete(productId)
|
|
156
|
+
toast.info(`Removed "${productTitle}" from Top Commercial Sellers`)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
setTopSellerIds(next)
|
|
160
|
+
const currentOrder = products.map((p) => p.id)
|
|
161
|
+
await persistProductConfig(currentOrder, next)
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Remove products from category
|
|
165
|
+
const handleRemoveProducts = async (idsToRemove: string[]) => {
|
|
166
|
+
if (idsToRemove.length === 0) return
|
|
167
|
+
const msg =
|
|
168
|
+
idsToRemove.length === 1
|
|
169
|
+
? "Are you sure you want to remove this product from the category?"
|
|
170
|
+
: `Are you sure you want to remove ${idsToRemove.length} products from this category?`
|
|
171
|
+
if (!confirm(msg)) return
|
|
172
|
+
|
|
173
|
+
setIsRemoving(true)
|
|
174
|
+
try {
|
|
175
|
+
await sdk.client.fetch(`/admin/product-categories/${categoryId}/products`, {
|
|
176
|
+
method: "POST",
|
|
177
|
+
body: { remove: idsToRemove },
|
|
178
|
+
})
|
|
179
|
+
toast.success(`Removed ${idsToRemove.length} product(s) from category`)
|
|
180
|
+
setSelectedIds(new Set())
|
|
181
|
+
|
|
182
|
+
// Also clean up ranking and top sellers
|
|
183
|
+
const removeSet = new Set(idsToRemove)
|
|
184
|
+
const newOrder = orderedProductIds.filter((id) => !removeSet.has(id))
|
|
185
|
+
const newTopSellers = new Set(Array.from(topSellerIds).filter((id) => !removeSet.has(id)))
|
|
186
|
+
setOrderedProductIds(newOrder)
|
|
187
|
+
setTopSellerIds(newTopSellers)
|
|
188
|
+
await persistProductConfig(newOrder, newTopSellers)
|
|
189
|
+
|
|
190
|
+
await loadAssignedProducts()
|
|
191
|
+
} catch (err: any) {
|
|
192
|
+
toast.error(err?.message || "Failed to remove products")
|
|
193
|
+
} finally {
|
|
194
|
+
setIsRemoving(false)
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Search catalog for the Add Products FocusModal (matching Medusa's legacy product table)
|
|
199
|
+
const searchCatalog = useCallback(async (query: string, page: number) => {
|
|
200
|
+
setIsSearchingCatalog(true)
|
|
201
|
+
try {
|
|
202
|
+
const qParam = query.trim() ? `&q=${encodeURIComponent(query.trim())}` : ""
|
|
203
|
+
const offset = page * PAGE_SIZE
|
|
204
|
+
const res = await sdk.client.fetch<{ products: ProductItem[]; count: number }>(
|
|
205
|
+
`/admin/products?limit=${PAGE_SIZE}&offset=${offset}${qParam}&fields=id,title,thumbnail,status,variants.id,collection.title,sales_channels.name`
|
|
206
|
+
)
|
|
207
|
+
setCatalogProducts(res.products || [])
|
|
208
|
+
setCatalogTotalCount(res.count ?? (res.products?.length || 0))
|
|
209
|
+
} catch (err: any) {
|
|
210
|
+
toast.error(err?.message || "Failed to search catalog")
|
|
211
|
+
} finally {
|
|
212
|
+
setIsSearchingCatalog(false)
|
|
213
|
+
}
|
|
214
|
+
}, [])
|
|
215
|
+
|
|
216
|
+
useEffect(() => {
|
|
217
|
+
if (focusModalOpen) {
|
|
218
|
+
searchCatalog(searchQuery, catalogPage)
|
|
219
|
+
}
|
|
220
|
+
}, [focusModalOpen, searchQuery, catalogPage, searchCatalog])
|
|
221
|
+
|
|
222
|
+
const handleSaveAddedProducts = async () => {
|
|
223
|
+
const idsToAdd = Array.from(catalogSelectedIds)
|
|
224
|
+
if (idsToAdd.length === 0) return
|
|
225
|
+
|
|
226
|
+
setIsAdding(true)
|
|
227
|
+
try {
|
|
228
|
+
await sdk.client.fetch(`/admin/product-categories/${categoryId}/products`, {
|
|
229
|
+
method: "POST",
|
|
230
|
+
body: { add: idsToAdd },
|
|
231
|
+
})
|
|
232
|
+
toast.success(`Added ${idsToAdd.length} product(s) to ${categoryName}`)
|
|
233
|
+
setFocusModalOpen(false)
|
|
234
|
+
setCatalogSelectedIds(new Set())
|
|
235
|
+
|
|
236
|
+
// Append new products to orderedProductIds
|
|
237
|
+
const newOrder = [...orderedProductIds, ...idsToAdd]
|
|
238
|
+
setOrderedProductIds(newOrder)
|
|
239
|
+
await persistProductConfig(newOrder, topSellerIds)
|
|
240
|
+
|
|
241
|
+
await loadAssignedProducts()
|
|
242
|
+
} catch (err: any) {
|
|
243
|
+
toast.error(err?.message || "Failed to add products")
|
|
244
|
+
} finally {
|
|
245
|
+
setIsAdding(false)
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const assignedProductIdsSet = useMemo(() => new Set(products.map((p) => p.id)), [products])
|
|
250
|
+
|
|
251
|
+
const displayedProducts = useMemo(() => {
|
|
252
|
+
if (activeView === "top_sellers") {
|
|
253
|
+
return products.filter((p) => topSellerIds.has(p.id))
|
|
254
|
+
}
|
|
255
|
+
return products
|
|
256
|
+
}, [products, activeView, topSellerIds])
|
|
257
|
+
|
|
258
|
+
return (
|
|
259
|
+
<div className="flex flex-col gap-3">
|
|
260
|
+
{/* View Switcher and Action Bar */}
|
|
261
|
+
<div className="flex items-center justify-between flex-wrap gap-2">
|
|
262
|
+
<div className="flex items-center gap-1.5 p-1 rounded-lg bg-ui-bg-subtle border border-ui-border-base">
|
|
263
|
+
<button
|
|
264
|
+
type="button"
|
|
265
|
+
onClick={() => setActiveView("all")}
|
|
266
|
+
className={`px-2.5 py-1 text-xs font-medium rounded-md transition-colors ${
|
|
267
|
+
activeView === "all"
|
|
268
|
+
? "bg-ui-bg-base text-ui-fg-base shadow-2xs font-semibold"
|
|
269
|
+
: "text-ui-fg-muted hover:text-ui-fg-base"
|
|
270
|
+
}`}
|
|
271
|
+
>
|
|
272
|
+
All Products ({products.length})
|
|
273
|
+
</button>
|
|
274
|
+
<button
|
|
275
|
+
type="button"
|
|
276
|
+
onClick={() => setActiveView("top_sellers")}
|
|
277
|
+
className={`flex items-center gap-1 px-2.5 py-1 text-xs font-medium rounded-md transition-colors ${
|
|
278
|
+
activeView === "top_sellers"
|
|
279
|
+
? "bg-amber-500/10 text-amber-700 dark:text-amber-400 font-semibold"
|
|
280
|
+
: "text-ui-fg-muted hover:text-amber-600"
|
|
281
|
+
}`}
|
|
282
|
+
>
|
|
283
|
+
<Sparkles className="w-3.5 h-3.5 text-amber-500" />
|
|
284
|
+
<span>Top Commercial Sellers ({topSellerIds.size})</span>
|
|
285
|
+
</button>
|
|
286
|
+
</div>
|
|
287
|
+
|
|
288
|
+
<div className="flex items-center gap-2">
|
|
289
|
+
{selectedIds.size > 0 && (
|
|
290
|
+
<Button
|
|
291
|
+
size="small"
|
|
292
|
+
variant="danger"
|
|
293
|
+
disabled={isRemoving}
|
|
294
|
+
onClick={() => handleRemoveProducts(Array.from(selectedIds))}
|
|
295
|
+
className="flex items-center gap-1 text-xs"
|
|
296
|
+
>
|
|
297
|
+
<Trash className="w-3.5 h-3.5" />
|
|
298
|
+
<span>Remove Selected ({selectedIds.size})</span>
|
|
299
|
+
</Button>
|
|
300
|
+
)}
|
|
301
|
+
|
|
302
|
+
<Button
|
|
303
|
+
size="small"
|
|
304
|
+
variant="secondary"
|
|
305
|
+
onClick={() => {
|
|
306
|
+
setCatalogSelectedIds(new Set())
|
|
307
|
+
setSearchQuery("")
|
|
308
|
+
setCatalogPage(0)
|
|
309
|
+
setFocusModalOpen(true)
|
|
310
|
+
}}
|
|
311
|
+
className="flex items-center gap-1 text-xs"
|
|
312
|
+
>
|
|
313
|
+
<Plus className="w-3.5 h-3.5" />
|
|
314
|
+
<span>Add Products</span>
|
|
315
|
+
</Button>
|
|
316
|
+
</div>
|
|
317
|
+
</div>
|
|
318
|
+
|
|
319
|
+
{/* Products Table with Ranking and Top Sellers */}
|
|
320
|
+
<div className="border border-ui-border-base rounded-xl overflow-hidden bg-ui-bg-base shadow-2xs">
|
|
321
|
+
{isLoading ? (
|
|
322
|
+
<div className="p-8 text-center text-xs text-ui-fg-muted">
|
|
323
|
+
Loading products…
|
|
324
|
+
</div>
|
|
325
|
+
) : displayedProducts.length === 0 ? (
|
|
326
|
+
<div className="p-8 text-center flex flex-col items-center justify-center gap-2">
|
|
327
|
+
<p className="text-xs text-ui-fg-muted">
|
|
328
|
+
{activeView === "top_sellers"
|
|
329
|
+
? "No products marked as Top Commercial Sellers yet. Star a product below to highlight it."
|
|
330
|
+
: "No products assigned to this category."}
|
|
331
|
+
</p>
|
|
332
|
+
{activeView === "all" && (
|
|
333
|
+
<Button
|
|
334
|
+
size="small"
|
|
335
|
+
variant="secondary"
|
|
336
|
+
onClick={() => setFocusModalOpen(true)}
|
|
337
|
+
className="flex items-center gap-1 text-xs"
|
|
338
|
+
>
|
|
339
|
+
<Plus className="w-3.5 h-3.5" />
|
|
340
|
+
<span>Add Products</span>
|
|
341
|
+
</Button>
|
|
342
|
+
)}
|
|
343
|
+
</div>
|
|
344
|
+
) : (
|
|
345
|
+
<table className="w-full text-left text-xs border-collapse">
|
|
346
|
+
<thead>
|
|
347
|
+
<tr className="border-b border-ui-border-base bg-ui-bg-subtle/60 text-ui-fg-muted font-medium">
|
|
348
|
+
<th className="py-2.5 px-3 w-8">
|
|
349
|
+
<input
|
|
350
|
+
type="checkbox"
|
|
351
|
+
checked={displayedProducts.length > 0 && selectedIds.size === displayedProducts.length}
|
|
352
|
+
onChange={() => {
|
|
353
|
+
if (selectedIds.size === displayedProducts.length) setSelectedIds(new Set())
|
|
354
|
+
else setSelectedIds(new Set(displayedProducts.map((p) => p.id)))
|
|
355
|
+
}}
|
|
356
|
+
className="rounded border-ui-border-base cursor-pointer"
|
|
357
|
+
/>
|
|
358
|
+
</th>
|
|
359
|
+
<th className="py-2.5 px-3 w-20">Rank</th>
|
|
360
|
+
<th className="py-2.5 px-3">Product</th>
|
|
361
|
+
<th className="py-2.5 px-3">Top Seller</th>
|
|
362
|
+
<th className="py-2.5 px-3">Collection</th>
|
|
363
|
+
<th className="py-2.5 px-3">Variants</th>
|
|
364
|
+
<th className="py-2.5 px-3">Status</th>
|
|
365
|
+
<th className="py-2.5 px-3 text-right">Action</th>
|
|
366
|
+
</tr>
|
|
367
|
+
</thead>
|
|
368
|
+
<tbody className="divide-y divide-ui-border-base">
|
|
369
|
+
{displayedProducts.map((p, index) => {
|
|
370
|
+
const isChecked = selectedIds.has(p.id)
|
|
371
|
+
const isTopSeller = topSellerIds.has(p.id)
|
|
372
|
+
|
|
373
|
+
return (
|
|
374
|
+
<tr
|
|
375
|
+
key={p.id}
|
|
376
|
+
className={`hover:bg-ui-bg-subtle/40 transition-colors ${
|
|
377
|
+
isChecked ? "bg-blue-50/40 dark:bg-blue-950/20" : ""
|
|
378
|
+
}`}
|
|
379
|
+
>
|
|
380
|
+
<td className="py-2.5 px-3">
|
|
381
|
+
<input
|
|
382
|
+
type="checkbox"
|
|
383
|
+
checked={isChecked}
|
|
384
|
+
onChange={() => {
|
|
385
|
+
setSelectedIds((prev) => {
|
|
386
|
+
const next = new Set(prev)
|
|
387
|
+
if (next.has(p.id)) next.delete(p.id)
|
|
388
|
+
else next.add(p.id)
|
|
389
|
+
return next
|
|
390
|
+
})
|
|
391
|
+
}}
|
|
392
|
+
className="rounded border-ui-border-base cursor-pointer"
|
|
393
|
+
/>
|
|
394
|
+
</td>
|
|
395
|
+
|
|
396
|
+
{/* Sibling Product Ranking with Move Up / Down */}
|
|
397
|
+
<td className="py-2.5 px-3">
|
|
398
|
+
<div className="flex items-center gap-1 font-mono">
|
|
399
|
+
<span className="text-xs font-bold text-ui-fg-subtle px-1.5 py-0.5 rounded bg-ui-bg-subtle border border-ui-border-base">
|
|
400
|
+
#{index + 1}
|
|
401
|
+
</span>
|
|
402
|
+
<div className="flex flex-col">
|
|
403
|
+
<button
|
|
404
|
+
type="button"
|
|
405
|
+
title="Move Up"
|
|
406
|
+
disabled={index === 0}
|
|
407
|
+
onClick={() => handleMoveProduct(index, "up")}
|
|
408
|
+
className="text-[9px] text-ui-fg-muted hover:text-ui-fg-base disabled:opacity-20 px-0.5 leading-none"
|
|
409
|
+
>
|
|
410
|
+
▲
|
|
411
|
+
</button>
|
|
412
|
+
<button
|
|
413
|
+
type="button"
|
|
414
|
+
title="Move Down"
|
|
415
|
+
disabled={index === displayedProducts.length - 1}
|
|
416
|
+
onClick={() => handleMoveProduct(index, "down")}
|
|
417
|
+
className="text-[9px] text-ui-fg-muted hover:text-ui-fg-base disabled:opacity-20 px-0.5 leading-none"
|
|
418
|
+
>
|
|
419
|
+
▼
|
|
420
|
+
</button>
|
|
421
|
+
</div>
|
|
422
|
+
</div>
|
|
423
|
+
</td>
|
|
424
|
+
|
|
425
|
+
{/* Product Thumbnail & Title */}
|
|
426
|
+
<td className="py-2.5 px-3">
|
|
427
|
+
<div className="flex items-center gap-2.5 min-w-0">
|
|
428
|
+
<div className="w-9 h-9 rounded-md border border-ui-border-base overflow-hidden bg-ui-bg-subtle flex-shrink-0 flex items-center justify-center">
|
|
429
|
+
{p.thumbnail ? (
|
|
430
|
+
<img src={p.thumbnail} alt={p.title} className="w-full h-full object-cover" />
|
|
431
|
+
) : (
|
|
432
|
+
<span className="text-[9px] text-ui-fg-muted">IMG</span>
|
|
433
|
+
)}
|
|
434
|
+
</div>
|
|
435
|
+
<div className="flex flex-col min-w-0">
|
|
436
|
+
<span className="font-semibold text-ui-fg-base truncate leading-snug" title={p.title}>
|
|
437
|
+
{p.title}
|
|
438
|
+
</span>
|
|
439
|
+
</div>
|
|
440
|
+
</div>
|
|
441
|
+
</td>
|
|
442
|
+
|
|
443
|
+
{/* Top Commercial Seller Toggle */}
|
|
444
|
+
<td className="py-2.5 px-3">
|
|
445
|
+
<button
|
|
446
|
+
type="button"
|
|
447
|
+
onClick={() => handleToggleTopSeller(p.id, p.title)}
|
|
448
|
+
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[11px] font-semibold border transition-all ${
|
|
449
|
+
isTopSeller
|
|
450
|
+
? "bg-amber-500/15 text-amber-700 dark:text-amber-400 border-amber-300 dark:border-amber-700 shadow-2xs"
|
|
451
|
+
: "bg-ui-bg-subtle text-ui-fg-muted border-ui-border-base hover:text-amber-600 hover:border-amber-300"
|
|
452
|
+
}`}
|
|
453
|
+
title={
|
|
454
|
+
isTopSeller
|
|
455
|
+
? "Click to remove from Top Commercial Sellers"
|
|
456
|
+
: "Click to mark as Top Commercial Seller (Reflected in Storefront 2)"
|
|
457
|
+
}
|
|
458
|
+
>
|
|
459
|
+
<span>{isTopSeller ? "★" : "☆"}</span>
|
|
460
|
+
<span>{isTopSeller ? "Top Seller" : "Normal"}</span>
|
|
461
|
+
</button>
|
|
462
|
+
</td>
|
|
463
|
+
|
|
464
|
+
<td className="py-2.5 px-3 text-ui-fg-muted">
|
|
465
|
+
{p.collection?.title || "-"}
|
|
466
|
+
</td>
|
|
467
|
+
|
|
468
|
+
<td className="py-2.5 px-3 text-ui-fg-subtle">
|
|
469
|
+
{p.variants?.length || 0} variant(s)
|
|
470
|
+
</td>
|
|
471
|
+
|
|
472
|
+
<td className="py-2.5 px-3">
|
|
473
|
+
<span
|
|
474
|
+
className={`inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium ${
|
|
475
|
+
p.status === "published"
|
|
476
|
+
? "bg-emerald-50 text-emerald-700 border border-emerald-200 dark:bg-emerald-950/50 dark:text-emerald-300"
|
|
477
|
+
: "bg-neutral-100 text-neutral-600 dark:bg-neutral-800 dark:text-neutral-400"
|
|
478
|
+
}`}
|
|
479
|
+
>
|
|
480
|
+
{p.status}
|
|
481
|
+
</span>
|
|
482
|
+
</td>
|
|
483
|
+
|
|
484
|
+
<td className="py-2.5 px-3 text-right">
|
|
485
|
+
<button
|
|
486
|
+
type="button"
|
|
487
|
+
title="Remove from category"
|
|
488
|
+
onClick={() => handleRemoveProducts([p.id])}
|
|
489
|
+
className="p-1 text-ui-fg-muted hover:text-rose-600 hover:bg-rose-50 dark:hover:bg-rose-950/50 rounded transition-colors"
|
|
490
|
+
>
|
|
491
|
+
<Trash className="w-3.5 h-3.5" />
|
|
492
|
+
</button>
|
|
493
|
+
</td>
|
|
494
|
+
</tr>
|
|
495
|
+
)
|
|
496
|
+
})}
|
|
497
|
+
</tbody>
|
|
498
|
+
</table>
|
|
499
|
+
)}
|
|
500
|
+
</div>
|
|
501
|
+
|
|
502
|
+
{/* ─────────────────────────────────────────────────────────────────── */}
|
|
503
|
+
{/* FULL-SCREEN ADD PRODUCTS FOCUS MODAL (Medusa Legacy Parity) */}
|
|
504
|
+
{/* ─────────────────────────────────────────────────────────────────── */}
|
|
505
|
+
{focusModalOpen && (
|
|
506
|
+
<div className="fixed inset-0 z-50 flex flex-col bg-white dark:bg-neutral-950 animate-in fade-in-50">
|
|
507
|
+
{/* Header */}
|
|
508
|
+
<div className="flex items-center justify-between px-6 py-3 border-b border-ui-border-base bg-ui-bg-base flex-shrink-0">
|
|
509
|
+
<div className="flex items-center gap-3">
|
|
510
|
+
<button
|
|
511
|
+
type="button"
|
|
512
|
+
onClick={() => setFocusModalOpen(false)}
|
|
513
|
+
className="p-1.5 rounded-lg text-ui-fg-muted hover:text-ui-fg-base hover:bg-ui-bg-subtle transition-colors"
|
|
514
|
+
title="Close (ESC)"
|
|
515
|
+
>
|
|
516
|
+
<span className="text-sm font-bold">✕</span>
|
|
517
|
+
</button>
|
|
518
|
+
<span className="text-[11px] font-mono font-medium text-ui-fg-muted px-1.5 py-0.5 rounded bg-ui-bg-subtle border border-ui-border-base">
|
|
519
|
+
ESC
|
|
520
|
+
</span>
|
|
521
|
+
<h2 className="text-sm font-semibold text-ui-fg-base">
|
|
522
|
+
Add Products to Category: {categoryName}
|
|
523
|
+
</h2>
|
|
524
|
+
</div>
|
|
525
|
+
|
|
526
|
+
<div className="flex items-center gap-3">
|
|
527
|
+
<div className="relative w-64">
|
|
528
|
+
<input
|
|
529
|
+
type="text"
|
|
530
|
+
placeholder="Search products…"
|
|
531
|
+
value={searchQuery}
|
|
532
|
+
onChange={(e) => {
|
|
533
|
+
setSearchQuery(e.target.value)
|
|
534
|
+
setCatalogPage(0)
|
|
535
|
+
}}
|
|
536
|
+
className="w-full text-xs pl-8 pr-3 py-1.5 rounded-lg bg-ui-bg-subtle border border-ui-border-base text-ui-fg-base focus:bg-ui-bg-base focus:border-ui-border-interactive focus:outline-none placeholder:text-ui-fg-muted"
|
|
537
|
+
autoFocus
|
|
538
|
+
/>
|
|
539
|
+
<MagnifyingGlass className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-ui-fg-muted pointer-events-none" />
|
|
540
|
+
</div>
|
|
541
|
+
</div>
|
|
542
|
+
</div>
|
|
543
|
+
|
|
544
|
+
{/* Full-width Products Table */}
|
|
545
|
+
<div className="flex-1 overflow-y-auto px-6 py-4">
|
|
546
|
+
<div className="border border-ui-border-base rounded-xl overflow-hidden bg-ui-bg-base shadow-sm">
|
|
547
|
+
<table className="w-full text-left text-xs border-collapse">
|
|
548
|
+
<thead>
|
|
549
|
+
<tr className="border-b border-ui-border-base bg-ui-bg-subtle/80 text-ui-fg-muted font-medium">
|
|
550
|
+
<th className="py-2.5 px-3 w-8">
|
|
551
|
+
<input
|
|
552
|
+
type="checkbox"
|
|
553
|
+
checked={
|
|
554
|
+
catalogProducts.length > 0 &&
|
|
555
|
+
catalogProducts.every(
|
|
556
|
+
(p) => assignedProductIdsSet.has(p.id) || catalogSelectedIds.has(p.id)
|
|
557
|
+
)
|
|
558
|
+
}
|
|
559
|
+
onChange={() => {
|
|
560
|
+
const available = catalogProducts.filter((p) => !assignedProductIdsSet.has(p.id))
|
|
561
|
+
if (available.every((p) => catalogSelectedIds.has(p.id))) {
|
|
562
|
+
setCatalogSelectedIds(new Set())
|
|
563
|
+
} else {
|
|
564
|
+
setCatalogSelectedIds(new Set(available.map((p) => p.id)))
|
|
565
|
+
}
|
|
566
|
+
}}
|
|
567
|
+
className="rounded border-ui-border-base cursor-pointer"
|
|
568
|
+
/>
|
|
569
|
+
</th>
|
|
570
|
+
<th className="py-2.5 px-3">Product</th>
|
|
571
|
+
<th className="py-2.5 px-3">Collection</th>
|
|
572
|
+
<th className="py-2.5 px-3">Sales Channels</th>
|
|
573
|
+
<th className="py-2.5 px-3">Variants</th>
|
|
574
|
+
<th className="py-2.5 px-3">Status</th>
|
|
575
|
+
</tr>
|
|
576
|
+
</thead>
|
|
577
|
+
<tbody className="divide-y divide-ui-border-base">
|
|
578
|
+
{isSearchingCatalog ? (
|
|
579
|
+
<tr>
|
|
580
|
+
<td colSpan={6} className="py-12 text-center text-ui-fg-muted">
|
|
581
|
+
Searching catalog products…
|
|
582
|
+
</td>
|
|
583
|
+
</tr>
|
|
584
|
+
) : catalogProducts.length === 0 ? (
|
|
585
|
+
<tr>
|
|
586
|
+
<td colSpan={6} className="py-12 text-center text-ui-fg-muted">
|
|
587
|
+
No products found.
|
|
588
|
+
</td>
|
|
589
|
+
</tr>
|
|
590
|
+
) : (
|
|
591
|
+
catalogProducts.map((p) => {
|
|
592
|
+
const isAlreadyAssigned = assignedProductIdsSet.has(p.id)
|
|
593
|
+
const isSelected = catalogSelectedIds.has(p.id) || isAlreadyAssigned
|
|
594
|
+
|
|
595
|
+
return (
|
|
596
|
+
<tr
|
|
597
|
+
key={p.id}
|
|
598
|
+
onClick={() => {
|
|
599
|
+
if (isAlreadyAssigned) return
|
|
600
|
+
setCatalogSelectedIds((prev) => {
|
|
601
|
+
const next = new Set(prev)
|
|
602
|
+
if (next.has(p.id)) next.delete(p.id)
|
|
603
|
+
else next.add(p.id)
|
|
604
|
+
return next
|
|
605
|
+
})
|
|
606
|
+
}}
|
|
607
|
+
className={`cursor-pointer transition-colors ${
|
|
608
|
+
isAlreadyAssigned
|
|
609
|
+
? "opacity-50 bg-ui-bg-subtle/30 cursor-not-allowed"
|
|
610
|
+
: isSelected
|
|
611
|
+
? "bg-blue-50/50 dark:bg-blue-950/30"
|
|
612
|
+
: "hover:bg-ui-bg-subtle/50"
|
|
613
|
+
}`}
|
|
614
|
+
>
|
|
615
|
+
<td className="py-2.5 px-3" onClick={(e) => e.stopPropagation()}>
|
|
616
|
+
<input
|
|
617
|
+
type="checkbox"
|
|
618
|
+
checked={isSelected}
|
|
619
|
+
disabled={isAlreadyAssigned}
|
|
620
|
+
onChange={() => {
|
|
621
|
+
if (isAlreadyAssigned) return
|
|
622
|
+
setCatalogSelectedIds((prev) => {
|
|
623
|
+
const next = new Set(prev)
|
|
624
|
+
if (next.has(p.id)) next.delete(p.id)
|
|
625
|
+
else next.add(p.id)
|
|
626
|
+
return next
|
|
627
|
+
})
|
|
628
|
+
}}
|
|
629
|
+
className="rounded border-ui-border-base cursor-pointer"
|
|
630
|
+
/>
|
|
631
|
+
</td>
|
|
632
|
+
|
|
633
|
+
<td className="py-2.5 px-3">
|
|
634
|
+
<div className="flex items-center gap-2.5 min-w-0">
|
|
635
|
+
<div className="w-8 h-8 rounded border border-ui-border-base overflow-hidden bg-ui-bg-subtle flex-shrink-0 flex items-center justify-center">
|
|
636
|
+
{p.thumbnail ? (
|
|
637
|
+
<img src={p.thumbnail} alt={p.title} className="w-full h-full object-cover" />
|
|
638
|
+
) : (
|
|
639
|
+
<span className="text-[8px] text-ui-fg-muted">IMG</span>
|
|
640
|
+
)}
|
|
641
|
+
</div>
|
|
642
|
+
<span className="font-semibold text-ui-fg-base truncate leading-snug">
|
|
643
|
+
{p.title}
|
|
644
|
+
</span>
|
|
645
|
+
{isAlreadyAssigned && (
|
|
646
|
+
<Badge size="small" color="grey" className="ml-1 text-[9px]">
|
|
647
|
+
Already in Category
|
|
648
|
+
</Badge>
|
|
649
|
+
)}
|
|
650
|
+
</div>
|
|
651
|
+
</td>
|
|
652
|
+
|
|
653
|
+
<td className="py-2.5 px-3 text-ui-fg-muted">
|
|
654
|
+
{p.collection?.title || "-"}
|
|
655
|
+
</td>
|
|
656
|
+
|
|
657
|
+
<td className="py-2.5 px-3">
|
|
658
|
+
<div className="flex items-center gap-1 flex-wrap">
|
|
659
|
+
{(p.sales_channels || []).map((sc) => (
|
|
660
|
+
<span
|
|
661
|
+
key={sc.id}
|
|
662
|
+
className="px-1.5 py-0.5 rounded text-[10px] bg-ui-bg-subtle border border-ui-border-base text-ui-fg-subtle"
|
|
663
|
+
>
|
|
664
|
+
{sc.name}
|
|
665
|
+
</span>
|
|
666
|
+
))}
|
|
667
|
+
</div>
|
|
668
|
+
</td>
|
|
669
|
+
|
|
670
|
+
<td className="py-2.5 px-3 text-ui-fg-subtle">
|
|
671
|
+
{p.variants?.length || 0} variant(s)
|
|
672
|
+
</td>
|
|
673
|
+
|
|
674
|
+
<td className="py-2.5 px-3">
|
|
675
|
+
<span
|
|
676
|
+
className={`inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium ${
|
|
677
|
+
p.status === "published"
|
|
678
|
+
? "bg-emerald-50 text-emerald-700 border border-emerald-200 dark:bg-emerald-950/50 dark:text-emerald-300"
|
|
679
|
+
: "bg-neutral-100 text-neutral-600 dark:bg-neutral-800 dark:text-neutral-400"
|
|
680
|
+
}`}
|
|
681
|
+
>
|
|
682
|
+
{p.status}
|
|
683
|
+
</span>
|
|
684
|
+
</td>
|
|
685
|
+
</tr>
|
|
686
|
+
)
|
|
687
|
+
})
|
|
688
|
+
)}
|
|
689
|
+
</tbody>
|
|
690
|
+
</table>
|
|
691
|
+
</div>
|
|
692
|
+
</div>
|
|
693
|
+
|
|
694
|
+
{/* Footer (Matching Medusa Legacy modal footer) */}
|
|
695
|
+
<div className="flex items-center justify-between px-6 py-3 border-t border-ui-border-base bg-ui-bg-base flex-shrink-0">
|
|
696
|
+
<div className="flex items-center gap-3">
|
|
697
|
+
<span className="text-xs text-ui-fg-muted">
|
|
698
|
+
Showing {Math.min(catalogProducts.length, PAGE_SIZE)} of {catalogTotalCount} results
|
|
699
|
+
</span>
|
|
700
|
+
<span className="text-xs text-ui-fg-muted">•</span>
|
|
701
|
+
<span className="text-xs font-semibold text-ui-fg-base">
|
|
702
|
+
{catalogSelectedIds.size} product(s) selected
|
|
703
|
+
</span>
|
|
704
|
+
</div>
|
|
705
|
+
|
|
706
|
+
<div className="flex items-center gap-2">
|
|
707
|
+
<button
|
|
708
|
+
type="button"
|
|
709
|
+
disabled={catalogPage === 0}
|
|
710
|
+
onClick={() => setCatalogPage((p) => Math.max(0, p - 1))}
|
|
711
|
+
className="px-2.5 py-1 text-xs rounded border border-ui-border-base bg-ui-bg-subtle hover:bg-ui-bg-base disabled:opacity-40"
|
|
712
|
+
>
|
|
713
|
+
Prev
|
|
714
|
+
</button>
|
|
715
|
+
<button
|
|
716
|
+
type="button"
|
|
717
|
+
disabled={(catalogPage + 1) * PAGE_SIZE >= catalogTotalCount}
|
|
718
|
+
onClick={() => setCatalogPage((p) => p + 1)}
|
|
719
|
+
className="px-2.5 py-1 text-xs rounded border border-ui-border-base bg-ui-bg-subtle hover:bg-ui-bg-base disabled:opacity-40"
|
|
720
|
+
>
|
|
721
|
+
Next
|
|
722
|
+
</button>
|
|
723
|
+
|
|
724
|
+
<div className="h-4 w-[1px] bg-ui-border-base mx-1" />
|
|
725
|
+
|
|
726
|
+
<Button
|
|
727
|
+
size="small"
|
|
728
|
+
variant="secondary"
|
|
729
|
+
onClick={() => setFocusModalOpen(false)}
|
|
730
|
+
>
|
|
731
|
+
Cancel
|
|
732
|
+
</Button>
|
|
733
|
+
<Button
|
|
734
|
+
size="small"
|
|
735
|
+
variant="primary"
|
|
736
|
+
disabled={catalogSelectedIds.size === 0 || isAdding}
|
|
737
|
+
onClick={handleSaveAddedProducts}
|
|
738
|
+
className="flex items-center gap-1 text-xs min-w-[70px] justify-center"
|
|
739
|
+
>
|
|
740
|
+
<CheckMini className="w-3.5 h-3.5" />
|
|
741
|
+
<span>{isAdding ? "Saving…" : "Save"}</span>
|
|
742
|
+
</Button>
|
|
743
|
+
</div>
|
|
744
|
+
</div>
|
|
745
|
+
</div>
|
|
746
|
+
)}
|
|
747
|
+
</div>
|
|
748
|
+
)
|
|
749
|
+
}
|