@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
|
@@ -1,7 +1,17 @@
|
|
|
1
1
|
import React, { useState, useEffect } from "react"
|
|
2
|
-
import {
|
|
3
|
-
|
|
2
|
+
import {
|
|
3
|
+
Button,
|
|
4
|
+
Drawer,
|
|
5
|
+
Input,
|
|
6
|
+
Label,
|
|
7
|
+
Switch,
|
|
8
|
+
Text,
|
|
9
|
+
Textarea,
|
|
10
|
+
toast,
|
|
11
|
+
} from "@medusajs/ui"
|
|
12
|
+
import { Trash, Plus, ArrowUpRightMicro, ArrowsPointingOut } from "@medusajs/icons"
|
|
4
13
|
import { CategoryTreeNode, SalesChannelInfo } from "./types"
|
|
14
|
+
import { CategoryProductsManager } from "./category-products-manager"
|
|
5
15
|
import { sdk } from "../../lib/sdk"
|
|
6
16
|
|
|
7
17
|
export interface EditCategoryDrawerProps {
|
|
@@ -13,6 +23,23 @@ export interface EditCategoryDrawerProps {
|
|
|
13
23
|
onDeleteSuccess: (deletedId: string) => void
|
|
14
24
|
}
|
|
15
25
|
|
|
26
|
+
function extractYouTubeVideoId(url: string): string | null {
|
|
27
|
+
if (!url || typeof url !== "string") return null
|
|
28
|
+
const trimmed = url.trim()
|
|
29
|
+
const patterns = [
|
|
30
|
+
/(?:youtube\.com\/shorts\/)([^?&\s]+)/i,
|
|
31
|
+
/(?:youtube\.com\/watch\?.*v=)([^?&\s]+)/i,
|
|
32
|
+
/(?:youtu\.be\/)([^?&\s]+)/i,
|
|
33
|
+
/(?:youtube\.com\/embed\/)([^?&\s]+)/i,
|
|
34
|
+
]
|
|
35
|
+
for (const pattern of patterns) {
|
|
36
|
+
const match = trimmed.match(pattern)
|
|
37
|
+
if (match && match[1]) return match[1]
|
|
38
|
+
}
|
|
39
|
+
if (/^[a-zA-Z0-9_-]{11}$/.test(trimmed)) return trimmed
|
|
40
|
+
return null
|
|
41
|
+
}
|
|
42
|
+
|
|
16
43
|
export const EditCategoryDrawer: React.FC<EditCategoryDrawerProps> = ({
|
|
17
44
|
open,
|
|
18
45
|
onOpenChange,
|
|
@@ -21,6 +48,8 @@ export const EditCategoryDrawer: React.FC<EditCategoryDrawerProps> = ({
|
|
|
21
48
|
onSuccess,
|
|
22
49
|
onDeleteSuccess,
|
|
23
50
|
}) => {
|
|
51
|
+
const [activeTab, setActiveTab] = useState<"general" | "products" | "channels" | "youtube">("general")
|
|
52
|
+
|
|
24
53
|
const [name, setName] = useState("")
|
|
25
54
|
const [handle, setHandle] = useState("")
|
|
26
55
|
const [description, setDescription] = useState("")
|
|
@@ -29,6 +58,11 @@ export const EditCategoryDrawer: React.FC<EditCategoryDrawerProps> = ({
|
|
|
29
58
|
const [isActive, setIsActive] = useState(true)
|
|
30
59
|
const [isInternal, setIsInternal] = useState(false)
|
|
31
60
|
const [selectedChannels, setSelectedChannels] = useState<string[]>([])
|
|
61
|
+
const [youtubeUrls, setYoutubeUrls] = useState<string[]>([])
|
|
62
|
+
const [newYoutubeInput, setNewYoutubeInput] = useState("")
|
|
63
|
+
|
|
64
|
+
const [currentMetadata, setCurrentMetadata] = useState<Record<string, any>>({})
|
|
65
|
+
const [isFullScreen, setIsFullScreen] = useState(false)
|
|
32
66
|
const [isSubmitting, setIsSubmitting] = useState(false)
|
|
33
67
|
const [isDeleting, setIsDeleting] = useState(false)
|
|
34
68
|
|
|
@@ -42,6 +76,18 @@ export const EditCategoryDrawer: React.FC<EditCategoryDrawerProps> = ({
|
|
|
42
76
|
setIsActive(Boolean(category.is_active))
|
|
43
77
|
setIsInternal(Boolean(category.is_internal))
|
|
44
78
|
setSelectedChannels(category.sales_channels || salesChannels.map((s) => s.id))
|
|
79
|
+
setCurrentMetadata((category.metadata as Record<string, any>) || {})
|
|
80
|
+
|
|
81
|
+
// Parse YouTube URLs from metadata
|
|
82
|
+
const meta = category.metadata as Record<string, any> | null
|
|
83
|
+
const urls: string[] = []
|
|
84
|
+
if (meta?.youtube_urls && Array.isArray(meta.youtube_urls)) {
|
|
85
|
+
meta.youtube_urls.forEach((u) => typeof u === "string" && u.trim() && urls.push(u.trim()))
|
|
86
|
+
} else if (meta?.youtube_url && typeof meta.youtube_url === "string") {
|
|
87
|
+
urls.push(meta.youtube_url.trim())
|
|
88
|
+
}
|
|
89
|
+
setYoutubeUrls(urls)
|
|
90
|
+
setActiveTab("general")
|
|
45
91
|
}
|
|
46
92
|
}, [category, salesChannels])
|
|
47
93
|
|
|
@@ -55,6 +101,28 @@ export const EditCategoryDrawer: React.FC<EditCategoryDrawerProps> = ({
|
|
|
55
101
|
)
|
|
56
102
|
}
|
|
57
103
|
|
|
104
|
+
const handleAddYoutube = () => {
|
|
105
|
+
const raw = newYoutubeInput.trim()
|
|
106
|
+
if (!raw) return
|
|
107
|
+
const id = extractYouTubeVideoId(raw)
|
|
108
|
+
if (!id) {
|
|
109
|
+
toast.error("Please enter a valid YouTube URL or video ID")
|
|
110
|
+
return
|
|
111
|
+
}
|
|
112
|
+
const cleanUrl = `https://www.youtube.com/watch?v=${id}`
|
|
113
|
+
if (youtubeUrls.includes(cleanUrl)) {
|
|
114
|
+
toast.error("Video already added")
|
|
115
|
+
return
|
|
116
|
+
}
|
|
117
|
+
setYoutubeUrls((prev) => [...prev, cleanUrl])
|
|
118
|
+
setNewYoutubeInput("")
|
|
119
|
+
toast.success("YouTube video added")
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const handleRemoveYoutube = (urlToRemove: string) => {
|
|
123
|
+
setYoutubeUrls((prev) => prev.filter((u) => u !== urlToRemove))
|
|
124
|
+
}
|
|
125
|
+
|
|
58
126
|
const handleSubmit = async (e: React.FormEvent) => {
|
|
59
127
|
e.preventDefault()
|
|
60
128
|
if (!name.trim()) {
|
|
@@ -65,8 +133,9 @@ export const EditCategoryDrawer: React.FC<EditCategoryDrawerProps> = ({
|
|
|
65
133
|
setIsSubmitting(true)
|
|
66
134
|
try {
|
|
67
135
|
const updatedMetadata: Record<string, any> = {
|
|
68
|
-
...(
|
|
136
|
+
...(currentMetadata || {}),
|
|
69
137
|
sales_channels: selectedChannels,
|
|
138
|
+
youtube_urls: youtubeUrls,
|
|
70
139
|
}
|
|
71
140
|
if (imageUrl.trim()) {
|
|
72
141
|
updatedMetadata.thumbnail = imageUrl.trim()
|
|
@@ -126,185 +195,377 @@ export const EditCategoryDrawer: React.FC<EditCategoryDrawerProps> = ({
|
|
|
126
195
|
|
|
127
196
|
return (
|
|
128
197
|
<Drawer open={open} onOpenChange={onOpenChange}>
|
|
129
|
-
<Drawer.Content
|
|
198
|
+
<Drawer.Content
|
|
199
|
+
className={`transition-all duration-300 w-full z-50 ${
|
|
200
|
+
isFullScreen
|
|
201
|
+
? "!max-w-full !w-screen !h-screen !inset-0 !rounded-none"
|
|
202
|
+
: "max-w-5xl xl:max-w-6xl w-[92vw]"
|
|
203
|
+
}`}
|
|
204
|
+
>
|
|
130
205
|
<form onSubmit={handleSubmit} className="flex flex-col h-full">
|
|
131
|
-
<Drawer.Header>
|
|
132
|
-
<
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
206
|
+
<Drawer.Header className="border-b border-ui-border-base pb-3">
|
|
207
|
+
<div className="flex items-center justify-between w-full">
|
|
208
|
+
<div>
|
|
209
|
+
<Drawer.Title className="text-base font-semibold text-ui-fg-base">
|
|
210
|
+
{category.name}
|
|
211
|
+
</Drawer.Title>
|
|
212
|
+
<Drawer.Description className="text-xs text-ui-fg-muted mt-0.5">
|
|
213
|
+
Tier {category.tier} • ID: {category.id}
|
|
214
|
+
</Drawer.Description>
|
|
215
|
+
</div>
|
|
137
216
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
217
|
+
<div className="flex items-center gap-2">
|
|
218
|
+
{/* Full Screen / Wide Mode Toggle */}
|
|
219
|
+
<button
|
|
220
|
+
type="button"
|
|
221
|
+
onClick={() => setIsFullScreen((prev) => !prev)}
|
|
222
|
+
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-ui-fg-subtle hover:text-ui-fg-base rounded-lg border border-ui-border-base bg-ui-bg-subtle hover:bg-ui-bg-base shadow-2xs transition-colors"
|
|
223
|
+
title={isFullScreen ? "Exit Fullscreen" : "Expand to Full Page Modal"}
|
|
224
|
+
>
|
|
225
|
+
<ArrowsPointingOut className="w-3.5 h-3.5" />
|
|
226
|
+
<span>{isFullScreen ? "Exit Fullscreen" : "Full Page"}</span>
|
|
227
|
+
</button>
|
|
228
|
+
|
|
229
|
+
{/* Direct Open Legacy Page Link */}
|
|
230
|
+
<button
|
|
231
|
+
type="button"
|
|
232
|
+
onClick={() => window.open(`/app/categories/${category.id}`, "_blank")}
|
|
233
|
+
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-ui-fg-subtle hover:text-ui-fg-base rounded-lg border border-ui-border-base bg-ui-bg-subtle hover:bg-ui-bg-base shadow-2xs transition-colors"
|
|
234
|
+
title="Open full category details page in a new tab"
|
|
235
|
+
>
|
|
236
|
+
<span>Open Full Page</span>
|
|
237
|
+
<ArrowUpRightMicro className="w-3.5 h-3.5" />
|
|
238
|
+
</button>
|
|
239
|
+
</div>
|
|
149
240
|
</div>
|
|
150
241
|
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
242
|
+
{/* Tab Navigation Bar */}
|
|
243
|
+
<div className="flex items-center gap-1 mt-3 border-b border-ui-border-base/50 -mb-3 pb-1">
|
|
244
|
+
<button
|
|
245
|
+
type="button"
|
|
246
|
+
onClick={() => setActiveTab("general")}
|
|
247
|
+
className={`px-3 py-1.5 text-xs font-medium rounded-t-lg transition-colors border-b-2 -mb-[5px] ${
|
|
248
|
+
activeTab === "general"
|
|
249
|
+
? "border-ui-border-interactive text-ui-fg-base bg-ui-bg-subtle/50"
|
|
250
|
+
: "border-transparent text-ui-fg-muted hover:text-ui-fg-base"
|
|
251
|
+
}`}
|
|
252
|
+
>
|
|
253
|
+
General & Ranking
|
|
254
|
+
</button>
|
|
255
|
+
|
|
256
|
+
<button
|
|
257
|
+
type="button"
|
|
258
|
+
onClick={() => setActiveTab("products")}
|
|
259
|
+
className={`px-3 py-1.5 text-xs font-medium rounded-t-lg transition-colors border-b-2 -mb-[5px] ${
|
|
260
|
+
activeTab === "products"
|
|
261
|
+
? "border-ui-border-interactive text-ui-fg-base bg-ui-bg-subtle/50"
|
|
262
|
+
: "border-transparent text-ui-fg-muted hover:text-ui-fg-base"
|
|
263
|
+
}`}
|
|
264
|
+
>
|
|
265
|
+
Products Management
|
|
266
|
+
</button>
|
|
267
|
+
|
|
268
|
+
<button
|
|
269
|
+
type="button"
|
|
270
|
+
onClick={() => setActiveTab("channels")}
|
|
271
|
+
className={`px-3 py-1.5 text-xs font-medium rounded-t-lg transition-colors border-b-2 -mb-[5px] ${
|
|
272
|
+
activeTab === "channels"
|
|
273
|
+
? "border-ui-border-interactive text-ui-fg-base bg-ui-bg-subtle/50"
|
|
274
|
+
: "border-transparent text-ui-fg-muted hover:text-ui-fg-base"
|
|
275
|
+
}`}
|
|
276
|
+
>
|
|
277
|
+
Storefronts & Media
|
|
278
|
+
</button>
|
|
279
|
+
|
|
280
|
+
<button
|
|
281
|
+
type="button"
|
|
282
|
+
onClick={() => setActiveTab("youtube")}
|
|
283
|
+
className={`px-3 py-1.5 text-xs font-medium rounded-t-lg transition-colors border-b-2 -mb-[5px] ${
|
|
284
|
+
activeTab === "youtube"
|
|
285
|
+
? "border-ui-border-interactive text-ui-fg-base bg-ui-bg-subtle/50"
|
|
286
|
+
: "border-transparent text-ui-fg-muted hover:text-ui-fg-base"
|
|
287
|
+
}`}
|
|
288
|
+
>
|
|
289
|
+
YouTube Videos ({youtubeUrls.length})
|
|
290
|
+
</button>
|
|
161
291
|
</div>
|
|
292
|
+
</Drawer.Header>
|
|
162
293
|
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
value={rank}
|
|
178
|
-
onChange={(e) => setRank(Number(e.target.value))}
|
|
179
|
-
className="w-24"
|
|
180
|
-
/>
|
|
181
|
-
<div className="flex items-center gap-1">
|
|
182
|
-
<button
|
|
183
|
-
type="button"
|
|
184
|
-
onClick={() => setRank((r) => Math.max(0, r - 1))}
|
|
185
|
-
className="px-2 py-1 text-xs font-medium rounded border border-ui-border-base bg-ui-bg-base hover:bg-ui-bg-subtle transition-colors"
|
|
186
|
-
title="Move Earlier in Storefront"
|
|
187
|
-
>
|
|
188
|
-
↑ Earlier
|
|
189
|
-
</button>
|
|
190
|
-
<button
|
|
191
|
-
type="button"
|
|
192
|
-
onClick={() => setRank((r) => r + 1)}
|
|
193
|
-
className="px-2 py-1 text-xs font-medium rounded border border-ui-border-base bg-ui-bg-base hover:bg-ui-bg-subtle transition-colors"
|
|
194
|
-
title="Move Later in Storefront"
|
|
195
|
-
>
|
|
196
|
-
↓ Later
|
|
197
|
-
</button>
|
|
198
|
-
<button
|
|
199
|
-
type="button"
|
|
200
|
-
onClick={() => setRank(0)}
|
|
201
|
-
className="px-2 py-1 text-xs font-medium rounded border border-ui-border-base bg-ui-bg-base hover:bg-ui-bg-subtle transition-colors"
|
|
202
|
-
title="Set as First Category"
|
|
203
|
-
>
|
|
204
|
-
Top (#0)
|
|
205
|
-
</button>
|
|
294
|
+
<Drawer.Body className="flex flex-col gap-4 overflow-y-auto px-6 py-4 flex-1">
|
|
295
|
+
{/* TAB 1: GENERAL & RANKING */}
|
|
296
|
+
{activeTab === "general" && (
|
|
297
|
+
<div className="flex flex-col gap-4">
|
|
298
|
+
<div className="flex flex-col gap-1.5">
|
|
299
|
+
<Label htmlFor="edit-name" className="text-xs font-semibold">
|
|
300
|
+
Category Name *
|
|
301
|
+
</Label>
|
|
302
|
+
<Input
|
|
303
|
+
id="edit-name"
|
|
304
|
+
size="small"
|
|
305
|
+
value={name}
|
|
306
|
+
onChange={(e) => setName(e.target.value)}
|
|
307
|
+
/>
|
|
206
308
|
</div>
|
|
207
|
-
</div>
|
|
208
|
-
<p className="text-[11px] text-ui-fg-muted leading-relaxed">
|
|
209
|
-
Categories are sorted by rank ASC (0, 1, 2...). Lower numbers appear first in your storefront navigation.
|
|
210
|
-
</p>
|
|
211
|
-
</div>
|
|
212
309
|
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
/>
|
|
223
|
-
{imageUrl && (
|
|
224
|
-
<div className="mt-1 w-16 h-16 rounded-lg overflow-hidden border border-ui-border-base bg-ui-bg-subtle">
|
|
225
|
-
<img
|
|
226
|
-
src={imageUrl}
|
|
227
|
-
alt="Category"
|
|
228
|
-
className="w-full h-full object-cover"
|
|
229
|
-
onError={(e) => {
|
|
230
|
-
;(e.target as any).style.display = "none"
|
|
231
|
-
}}
|
|
310
|
+
<div className="flex flex-col gap-1.5">
|
|
311
|
+
<Label htmlFor="edit-handle" className="text-xs font-semibold">
|
|
312
|
+
Handle (URL slug)
|
|
313
|
+
</Label>
|
|
314
|
+
<Input
|
|
315
|
+
id="edit-handle"
|
|
316
|
+
size="small"
|
|
317
|
+
value={handle}
|
|
318
|
+
onChange={(e) => setHandle(e.target.value)}
|
|
232
319
|
/>
|
|
233
320
|
</div>
|
|
234
|
-
)}
|
|
235
|
-
</div>
|
|
236
321
|
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
322
|
+
<div className="flex flex-col gap-1.5 p-3 rounded-lg bg-ui-bg-subtle border border-ui-border-base">
|
|
323
|
+
<div className="flex items-center justify-between">
|
|
324
|
+
<Label htmlFor="edit-rank" className="text-xs font-semibold">
|
|
325
|
+
Storefront Ranking (Sort Order)
|
|
326
|
+
</Label>
|
|
327
|
+
<span className="text-[10px] font-mono text-ui-fg-muted">
|
|
328
|
+
Controls Storefront 2 & Monfil
|
|
329
|
+
</span>
|
|
330
|
+
</div>
|
|
331
|
+
<div className="flex items-center gap-2">
|
|
332
|
+
<Input
|
|
333
|
+
id="edit-rank"
|
|
334
|
+
type="number"
|
|
335
|
+
size="small"
|
|
336
|
+
value={rank}
|
|
337
|
+
onChange={(e) => setRank(Number(e.target.value))}
|
|
338
|
+
className="w-24"
|
|
339
|
+
/>
|
|
340
|
+
<div className="flex items-center gap-1">
|
|
341
|
+
<button
|
|
342
|
+
type="button"
|
|
343
|
+
onClick={() => setRank((r) => Math.max(0, r - 1))}
|
|
344
|
+
className="px-2 py-1 text-xs font-medium rounded border border-ui-border-base bg-ui-bg-base hover:bg-ui-bg-subtle transition-colors"
|
|
345
|
+
title="Move Earlier in Storefront"
|
|
346
|
+
>
|
|
347
|
+
↑ Earlier
|
|
348
|
+
</button>
|
|
349
|
+
<button
|
|
350
|
+
type="button"
|
|
351
|
+
onClick={() => setRank((r) => r + 1)}
|
|
352
|
+
className="px-2 py-1 text-xs font-medium rounded border border-ui-border-base bg-ui-bg-base hover:bg-ui-bg-subtle transition-colors"
|
|
353
|
+
title="Move Later in Storefront"
|
|
354
|
+
>
|
|
355
|
+
↓ Later
|
|
356
|
+
</button>
|
|
357
|
+
<button
|
|
358
|
+
type="button"
|
|
359
|
+
onClick={() => setRank(0)}
|
|
360
|
+
className="px-2 py-1 text-xs font-medium rounded border border-ui-border-base bg-ui-bg-base hover:bg-ui-bg-subtle transition-colors"
|
|
361
|
+
title="Set as First Category"
|
|
362
|
+
>
|
|
363
|
+
Top (#0)
|
|
364
|
+
</button>
|
|
365
|
+
</div>
|
|
366
|
+
</div>
|
|
367
|
+
<p className="text-[11px] text-ui-fg-muted leading-relaxed">
|
|
368
|
+
Categories are sorted by rank ASC (0, 1, 2...). Lower numbers appear first on your storefront navigation menu.
|
|
369
|
+
</p>
|
|
370
|
+
</div>
|
|
248
371
|
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
372
|
+
<div className="flex flex-col gap-1.5">
|
|
373
|
+
<Label htmlFor="edit-desc" className="text-xs font-semibold">
|
|
374
|
+
Description
|
|
375
|
+
</Label>
|
|
376
|
+
<Textarea
|
|
377
|
+
id="edit-desc"
|
|
378
|
+
value={description}
|
|
379
|
+
onChange={(e) => setDescription(e.target.value)}
|
|
380
|
+
rows={3}
|
|
381
|
+
/>
|
|
382
|
+
</div>
|
|
383
|
+
|
|
384
|
+
<div className="flex items-center justify-between pt-2 border-t border-ui-border-base">
|
|
385
|
+
<div>
|
|
386
|
+
<Label className="text-xs font-semibold">Active Status</Label>
|
|
387
|
+
<Text size="xsmall" className="text-ui-fg-muted">
|
|
388
|
+
Visible to shoppers on enabled storefronts
|
|
389
|
+
</Text>
|
|
390
|
+
</div>
|
|
391
|
+
<Switch checked={isActive} onCheckedChange={setIsActive} />
|
|
392
|
+
</div>
|
|
393
|
+
|
|
394
|
+
<div className="flex items-center justify-between">
|
|
395
|
+
<div>
|
|
396
|
+
<Label className="text-xs font-semibold">Internal Only</Label>
|
|
397
|
+
<Text size="xsmall" className="text-ui-fg-muted">
|
|
398
|
+
Hidden from public storefront navigation
|
|
399
|
+
</Text>
|
|
400
|
+
</div>
|
|
401
|
+
<Switch checked={isInternal} onCheckedChange={setIsInternal} />
|
|
402
|
+
</div>
|
|
403
|
+
|
|
404
|
+
<div className="pt-3 border-t border-ui-border-base">
|
|
405
|
+
<Button
|
|
406
|
+
type="button"
|
|
407
|
+
variant="danger"
|
|
408
|
+
size="small"
|
|
409
|
+
onClick={handleDelete}
|
|
410
|
+
isLoading={isDeleting}
|
|
411
|
+
className="w-full flex items-center justify-center gap-1.5"
|
|
412
|
+
>
|
|
413
|
+
<Trash className="w-3.5 h-3.5" />
|
|
414
|
+
<span>Delete Category</span>
|
|
415
|
+
</Button>
|
|
416
|
+
</div>
|
|
272
417
|
</div>
|
|
273
|
-
|
|
418
|
+
)}
|
|
274
419
|
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
<
|
|
279
|
-
|
|
280
|
-
|
|
420
|
+
{/* TAB 2: PRODUCTS MANAGEMENT */}
|
|
421
|
+
{activeTab === "products" && (
|
|
422
|
+
<div className="flex flex-col gap-3">
|
|
423
|
+
<CategoryProductsManager
|
|
424
|
+
categoryId={category.id}
|
|
425
|
+
categoryName={category.name}
|
|
426
|
+
initialMetadata={currentMetadata}
|
|
427
|
+
onMetadataUpdate={(meta) => {
|
|
428
|
+
setCurrentMetadata(meta)
|
|
429
|
+
}}
|
|
430
|
+
/>
|
|
281
431
|
</div>
|
|
282
|
-
|
|
283
|
-
</div>
|
|
432
|
+
)}
|
|
284
433
|
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
<
|
|
289
|
-
|
|
290
|
-
|
|
434
|
+
{/* TAB 3: STOREFRONTS & MEDIA */}
|
|
435
|
+
{activeTab === "channels" && (
|
|
436
|
+
<div className="flex flex-col gap-4">
|
|
437
|
+
<div className="flex flex-col gap-2">
|
|
438
|
+
<Label className="text-xs font-semibold">Storefront Visibility</Label>
|
|
439
|
+
<Text size="xsmall" className="text-ui-fg-muted">
|
|
440
|
+
Designate which storefront sales channels display this category.
|
|
441
|
+
</Text>
|
|
442
|
+
<div className="flex flex-col gap-2 mt-1">
|
|
443
|
+
{salesChannels.map((channel) => {
|
|
444
|
+
const isChecked = selectedChannels.includes(channel.id)
|
|
445
|
+
return (
|
|
446
|
+
<label
|
|
447
|
+
key={channel.id}
|
|
448
|
+
className="flex items-center gap-2.5 text-xs text-ui-fg-base cursor-pointer select-none p-2 rounded-lg border border-ui-border-base bg-ui-bg-subtle/40 hover:bg-ui-bg-subtle"
|
|
449
|
+
>
|
|
450
|
+
<input
|
|
451
|
+
type="checkbox"
|
|
452
|
+
checked={isChecked}
|
|
453
|
+
onChange={() => toggleChannel(channel.id)}
|
|
454
|
+
className="rounded border-ui-border-base text-blue-600 focus:ring-blue-500 cursor-pointer"
|
|
455
|
+
/>
|
|
456
|
+
<span className="font-medium">{channel.name}</span>
|
|
457
|
+
</label>
|
|
458
|
+
)
|
|
459
|
+
})}
|
|
460
|
+
</div>
|
|
461
|
+
</div>
|
|
462
|
+
|
|
463
|
+
<div className="flex flex-col gap-1.5 pt-3 border-t border-ui-border-base">
|
|
464
|
+
<Label htmlFor="edit-image" className="text-xs font-semibold">
|
|
465
|
+
Category Thumbnail / Image URL
|
|
466
|
+
</Label>
|
|
467
|
+
<Input
|
|
468
|
+
id="edit-image"
|
|
469
|
+
size="small"
|
|
470
|
+
value={imageUrl}
|
|
471
|
+
onChange={(e) => setImageUrl(e.target.value)}
|
|
472
|
+
placeholder="https://example.com/category-image.jpg"
|
|
473
|
+
/>
|
|
474
|
+
{imageUrl && (
|
|
475
|
+
<div className="mt-2 w-24 h-24 rounded-xl overflow-hidden border border-ui-border-base bg-ui-bg-subtle shadow-xs">
|
|
476
|
+
<img
|
|
477
|
+
src={imageUrl}
|
|
478
|
+
alt="Category Preview"
|
|
479
|
+
className="w-full h-full object-cover"
|
|
480
|
+
onError={(e) => {
|
|
481
|
+
;(e.target as any).style.display = "none"
|
|
482
|
+
}}
|
|
483
|
+
/>
|
|
484
|
+
</div>
|
|
485
|
+
)}
|
|
486
|
+
</div>
|
|
291
487
|
</div>
|
|
292
|
-
|
|
293
|
-
</div>
|
|
488
|
+
)}
|
|
294
489
|
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
490
|
+
{/* TAB 4: YOUTUBE VIDEOS */}
|
|
491
|
+
{activeTab === "youtube" && (
|
|
492
|
+
<div className="flex flex-col gap-4">
|
|
493
|
+
<div>
|
|
494
|
+
<h4 className="text-xs font-semibold text-ui-fg-base">
|
|
495
|
+
Category Demonstration Videos (YouTube)
|
|
496
|
+
</h4>
|
|
497
|
+
<p className="text-xs text-ui-fg-muted mt-0.5">
|
|
498
|
+
Attach YouTube video demonstration links to show interactive video carousels on the storefront category page.
|
|
499
|
+
</p>
|
|
500
|
+
</div>
|
|
501
|
+
|
|
502
|
+
<div className="flex items-center gap-2">
|
|
503
|
+
<Input
|
|
504
|
+
size="small"
|
|
505
|
+
placeholder="Paste YouTube watch URL, shorts, or video ID…"
|
|
506
|
+
value={newYoutubeInput}
|
|
507
|
+
onChange={(e) => setNewYoutubeInput(e.target.value)}
|
|
508
|
+
className="flex-1 text-xs"
|
|
509
|
+
onKeyDown={(e) => {
|
|
510
|
+
if (e.key === "Enter") {
|
|
511
|
+
e.preventDefault()
|
|
512
|
+
handleAddYoutube()
|
|
513
|
+
}
|
|
514
|
+
}}
|
|
515
|
+
/>
|
|
516
|
+
<Button
|
|
517
|
+
size="small"
|
|
518
|
+
type="button"
|
|
519
|
+
variant="secondary"
|
|
520
|
+
onClick={handleAddYoutube}
|
|
521
|
+
className="flex items-center gap-1 text-xs flex-shrink-0"
|
|
522
|
+
>
|
|
523
|
+
<Plus className="w-3.5 h-3.5" />
|
|
524
|
+
<span>Add Video</span>
|
|
525
|
+
</Button>
|
|
526
|
+
</div>
|
|
527
|
+
|
|
528
|
+
{youtubeUrls.length === 0 ? (
|
|
529
|
+
<div className="p-8 text-center text-xs text-ui-fg-muted border border-dashed border-ui-border-base rounded-xl">
|
|
530
|
+
No demonstration videos attached yet.
|
|
531
|
+
</div>
|
|
532
|
+
) : (
|
|
533
|
+
<div className="grid grid-cols-2 gap-3">
|
|
534
|
+
{youtubeUrls.map((url) => {
|
|
535
|
+
const id = extractYouTubeVideoId(url)
|
|
536
|
+
const thumb = id ? `https://img.youtube.com/vi/${id}/hqdefault.jpg` : null
|
|
537
|
+
return (
|
|
538
|
+
<div
|
|
539
|
+
key={url}
|
|
540
|
+
className="flex flex-col border border-ui-border-base rounded-xl overflow-hidden bg-ui-bg-subtle/40 shadow-xs"
|
|
541
|
+
>
|
|
542
|
+
<div className="relative aspect-video bg-black/80 overflow-hidden flex items-center justify-center">
|
|
543
|
+
{thumb ? (
|
|
544
|
+
<img src={thumb} alt="YouTube Preview" className="w-full h-full object-cover" />
|
|
545
|
+
) : (
|
|
546
|
+
<span className="text-xs text-white">Video</span>
|
|
547
|
+
)}
|
|
548
|
+
<button
|
|
549
|
+
type="button"
|
|
550
|
+
onClick={() => handleRemoveYoutube(url)}
|
|
551
|
+
className="absolute top-1.5 right-1.5 p-1 bg-black/70 hover:bg-rose-600 text-white rounded-full transition-colors"
|
|
552
|
+
title="Remove video"
|
|
553
|
+
>
|
|
554
|
+
<Trash className="w-3 h-3" />
|
|
555
|
+
</button>
|
|
556
|
+
</div>
|
|
557
|
+
<div className="p-2">
|
|
558
|
+
<span className="text-[10px] text-ui-fg-muted truncate block font-mono" title={url}>
|
|
559
|
+
{url}
|
|
560
|
+
</span>
|
|
561
|
+
</div>
|
|
562
|
+
</div>
|
|
563
|
+
)
|
|
564
|
+
})}
|
|
565
|
+
</div>
|
|
566
|
+
)}
|
|
567
|
+
</div>
|
|
568
|
+
)}
|
|
308
569
|
</Drawer.Body>
|
|
309
570
|
|
|
310
571
|
<Drawer.Footer className="flex justify-end gap-2 px-6 py-4 border-t border-ui-border-base">
|
|
@@ -315,7 +576,7 @@ export const EditCategoryDrawer: React.FC<EditCategoryDrawerProps> = ({
|
|
|
315
576
|
onClick={() => onOpenChange(false)}
|
|
316
577
|
disabled={isSubmitting || isDeleting}
|
|
317
578
|
>
|
|
318
|
-
|
|
579
|
+
Close
|
|
319
580
|
</Button>
|
|
320
581
|
<Button
|
|
321
582
|
type="submit"
|
|
@@ -323,7 +584,7 @@ export const EditCategoryDrawer: React.FC<EditCategoryDrawerProps> = ({
|
|
|
323
584
|
isLoading={isSubmitting}
|
|
324
585
|
disabled={isSubmitting || !name.trim() || isDeleting}
|
|
325
586
|
>
|
|
326
|
-
Save
|
|
587
|
+
Save Category
|
|
327
588
|
</Button>
|
|
328
589
|
</Drawer.Footer>
|
|
329
590
|
</form>
|