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