@hectasquare/medusa-plugin-category-tree 1.0.11 → 1.0.13
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.js +8 -5
- package/.medusa/server/src/admin/components/category-tree/category-tree-canvas.js +39 -2
- package/.medusa/server/src/admin/components/category-tree/edit-category-drawer.js +44 -16
- package/.medusa/server/src/admin/components/category-tree/lib/layout-engine.js +11 -9
- package/.medusa/server/src/admin/components/category-tree/lib/tree-state.d.ts +19 -2
- package/.medusa/server/src/admin/components/category-tree/lib/tree-state.js +36 -16
- package/.medusa/server/src/api/admin/category-tree/reorder/route.js +8 -2
- package/.medusa/server/src/api/admin/category-tree/route.js +14 -1
- package/package.json +1 -1
- package/src/admin/components/category-tree/category-products-manager.tsx +8 -5
- package/src/admin/components/category-tree/category-tree-canvas.tsx +39 -2
- package/src/admin/components/category-tree/edit-category-drawer.tsx +42 -16
- package/src/admin/components/category-tree/lib/layout-engine.ts +11 -12
- package/src/admin/components/category-tree/lib/tree-state.ts +44 -18
- package/src/api/admin/category-tree/reorder/route.ts +9 -3
- package/src/api/admin/category-tree/route.ts +13 -1
|
@@ -126,12 +126,15 @@ const CategoryProductsManager = ({ categoryId, categoryName, initialMetadata, on
|
|
|
126
126
|
}
|
|
127
127
|
};
|
|
128
128
|
// Sibling product ranking handlers (Move Up / Move Down)
|
|
129
|
-
const handleMoveProduct = async (
|
|
130
|
-
const
|
|
129
|
+
const handleMoveProduct = async (productId, direction) => {
|
|
130
|
+
const currentIndex = products.findIndex((p) => p.id === productId);
|
|
131
|
+
if (currentIndex === -1)
|
|
132
|
+
return;
|
|
133
|
+
const targetIndex = direction === "up" ? currentIndex - 1 : currentIndex + 1;
|
|
131
134
|
if (targetIndex < 0 || targetIndex >= products.length)
|
|
132
135
|
return;
|
|
133
136
|
const reordered = [...products];
|
|
134
|
-
const [moved] = reordered.splice(
|
|
137
|
+
const [moved] = reordered.splice(currentIndex, 1);
|
|
135
138
|
reordered.splice(targetIndex, 0, moved);
|
|
136
139
|
setProducts(reordered);
|
|
137
140
|
const newOrder = reordered.map((p) => p.id);
|
|
@@ -294,11 +297,11 @@ const CategoryProductsManager = ({ categoryId, categoryName, initialMetadata, on
|
|
|
294
297
|
}, className: "rounded border-ui-border-base cursor-pointer" }) }), (0, jsx_runtime_1.jsx)("td", { className: "py-2.5 px-3", children: (0, jsx_runtime_1.jsxs)("div", { className: "flex items-center gap-1 font-mono", children: [(0, jsx_runtime_1.jsxs)("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", children: ["#", index + 1] }), (0, jsx_runtime_1.jsxs)("div", { className: "flex flex-col", children: [(0, jsx_runtime_1.jsx)("button", { type: "button", title: "Move Up", disabled: index === 0, onClick: (e) => {
|
|
295
298
|
e.preventDefault();
|
|
296
299
|
e.stopPropagation();
|
|
297
|
-
handleMoveProduct(
|
|
300
|
+
handleMoveProduct(p.id, "up");
|
|
298
301
|
}, className: "text-[9px] text-ui-fg-muted hover:text-ui-fg-base disabled:opacity-20 px-0.5 leading-none cursor-pointer", children: "\u25B2" }), (0, jsx_runtime_1.jsx)("button", { type: "button", title: "Move Down", disabled: index === displayedProducts.length - 1, onClick: (e) => {
|
|
299
302
|
e.preventDefault();
|
|
300
303
|
e.stopPropagation();
|
|
301
|
-
handleMoveProduct(
|
|
304
|
+
handleMoveProduct(p.id, "down");
|
|
302
305
|
}, className: "text-[9px] text-ui-fg-muted hover:text-ui-fg-base disabled:opacity-20 px-0.5 leading-none cursor-pointer", children: "\u25BC" })] })] }) }), (0, jsx_runtime_1.jsx)("td", { className: "py-2.5 px-3", children: (0, jsx_runtime_1.jsxs)("div", { className: "flex items-center gap-2.5 min-w-0", children: [(0, jsx_runtime_1.jsx)("a", { href: `/app/products/${p.id}`, target: "_blank", rel: "noopener noreferrer", 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 hover:opacity-80 transition-opacity", title: `Open ${p.title} in new tab`, children: p.thumbnail ? ((0, jsx_runtime_1.jsx)("img", { src: p.thumbnail, alt: p.title, className: "w-full h-full object-cover" })) : ((0, jsx_runtime_1.jsx)("span", { className: "text-[9px] text-ui-fg-muted", children: "IMG" })) }), (0, jsx_runtime_1.jsx)("div", { className: "flex flex-col min-w-0", children: (0, jsx_runtime_1.jsxs)("a", { href: `/app/products/${p.id}`, target: "_blank", rel: "noopener noreferrer", className: "font-semibold text-ui-fg-base hover:text-ui-fg-interactive truncate leading-snug hover:underline flex items-center gap-1 group/pname", title: `Open ${p.title} in new tab`, children: [(0, jsx_runtime_1.jsx)("span", { className: "truncate", children: p.title }), (0, jsx_runtime_1.jsx)(icons_1.ArrowUpRightMicro, { className: "w-3.5 h-3.5 text-ui-fg-muted group-hover/pname:text-ui-fg-interactive transition-colors shrink-0" })] }) })] }) }), (0, jsx_runtime_1.jsx)("td", { className: "py-2.5 px-3", children: (0, jsx_runtime_1.jsxs)("button", { type: "button", onClick: (e) => {
|
|
303
306
|
e.preventDefault();
|
|
304
307
|
e.stopPropagation();
|
|
@@ -114,10 +114,46 @@ const CanvasInner = () => {
|
|
|
114
114
|
});
|
|
115
115
|
}, [selectedNodeIds]);
|
|
116
116
|
const handleMoveSiblingUp = (0, react_1.useCallback)((id) => {
|
|
117
|
-
setCategories((prev) =>
|
|
117
|
+
setCategories((prev) => {
|
|
118
|
+
const { nextCategories, siblingMoves } = (0, tree_state_1.moveSiblingRank)(prev, id, "up");
|
|
119
|
+
if (siblingMoves.length > 0) {
|
|
120
|
+
// AAA Realtime: Immediately auto-persist contiguous ranks to backend in background
|
|
121
|
+
sdk_1.sdk.client
|
|
122
|
+
.fetch("/admin/category-tree/reorder", {
|
|
123
|
+
method: "POST",
|
|
124
|
+
body: { moves: siblingMoves },
|
|
125
|
+
})
|
|
126
|
+
.then(() => {
|
|
127
|
+
setOriginalCategories([...nextCategories]);
|
|
128
|
+
})
|
|
129
|
+
.catch((err) => {
|
|
130
|
+
console.error("Failed to auto-save sibling rank:", err);
|
|
131
|
+
ui_1.toast.error("Failed to save ranking: " + (err.message || "Unknown error"));
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
return nextCategories;
|
|
135
|
+
});
|
|
118
136
|
}, []);
|
|
119
137
|
const handleMoveSiblingDown = (0, react_1.useCallback)((id) => {
|
|
120
|
-
setCategories((prev) =>
|
|
138
|
+
setCategories((prev) => {
|
|
139
|
+
const { nextCategories, siblingMoves } = (0, tree_state_1.moveSiblingRank)(prev, id, "down");
|
|
140
|
+
if (siblingMoves.length > 0) {
|
|
141
|
+
// AAA Realtime: Immediately auto-persist contiguous ranks to backend in background
|
|
142
|
+
sdk_1.sdk.client
|
|
143
|
+
.fetch("/admin/category-tree/reorder", {
|
|
144
|
+
method: "POST",
|
|
145
|
+
body: { moves: siblingMoves },
|
|
146
|
+
})
|
|
147
|
+
.then(() => {
|
|
148
|
+
setOriginalCategories([...nextCategories]);
|
|
149
|
+
})
|
|
150
|
+
.catch((err) => {
|
|
151
|
+
console.error("Failed to auto-save sibling rank:", err);
|
|
152
|
+
ui_1.toast.error("Failed to save ranking: " + (err.message || "Unknown error"));
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
return nextCategories;
|
|
156
|
+
});
|
|
121
157
|
}, []);
|
|
122
158
|
const handleAddChild = (0, react_1.useCallback)((parent) => {
|
|
123
159
|
setCreateParentCategory(parent);
|
|
@@ -315,6 +351,7 @@ const CanvasInner = () => {
|
|
|
315
351
|
setCategories((prev) => [...prev, newCat]);
|
|
316
352
|
setOriginalCategories((prev) => [...prev, newCat]);
|
|
317
353
|
} }), (0, jsx_runtime_1.jsx)(edit_category_drawer_1.EditCategoryDrawer, { open: editDrawerOpen, onOpenChange: setEditDrawerOpen, category: editingCategory, salesChannels: salesChannels, onSuccess: (updatedCat) => {
|
|
354
|
+
setEditingCategory(updatedCat);
|
|
318
355
|
setCategories((prev) => prev.map((c) => (c.id === updatedCat.id ? updatedCat : c)));
|
|
319
356
|
setOriginalCategories((prev) => prev.map((c) => (c.id === updatedCat.id ? updatedCat : c)));
|
|
320
357
|
}, onDeleteSuccess: (deletedId) => {
|
|
@@ -12,17 +12,17 @@ function extractYouTubeVideoId(url) {
|
|
|
12
12
|
return null;
|
|
13
13
|
const trimmed = url.trim();
|
|
14
14
|
const patterns = [
|
|
15
|
-
/(?:youtube\.com\/shorts\/)([
|
|
16
|
-
/(?:youtube\.com\/watch\?.*v=)([
|
|
17
|
-
/(?:youtu\.be\/)([
|
|
18
|
-
/(?:youtube\.com\/embed\/)([
|
|
15
|
+
/(?:youtube\.com\/shorts\/)([a-zA-Z0-9_-]{11})/i,
|
|
16
|
+
/(?:youtube\.com\/watch\?.*v=)([a-zA-Z0-9_-]{11})/i,
|
|
17
|
+
/(?:youtu\.be\/)([a-zA-Z0-9_-]{11})/i,
|
|
18
|
+
/(?:youtube\.com\/embed\/)([a-zA-Z0-9_-]{11})/i,
|
|
19
19
|
];
|
|
20
20
|
for (const pattern of patterns) {
|
|
21
21
|
const match = trimmed.match(pattern);
|
|
22
22
|
if (match && match[1])
|
|
23
23
|
return match[1];
|
|
24
24
|
}
|
|
25
|
-
if (/^[a-zA-Z0-9_-]{11}$/.test(trimmed))
|
|
25
|
+
if (/^[a-zA-Z0-9_-]{11}$/.test(trimmed) && !/^[A-Za-z]{11}$/.test(trimmed))
|
|
26
26
|
return trimmed;
|
|
27
27
|
return null;
|
|
28
28
|
}
|
|
@@ -51,22 +51,28 @@ const EditCategoryDrawer = ({ open, onOpenChange, category, salesChannels, onSuc
|
|
|
51
51
|
if (category) {
|
|
52
52
|
setName(category.name || "");
|
|
53
53
|
setHandle(category.handle || "");
|
|
54
|
-
setDescription(category.description || "");
|
|
54
|
+
setDescription(category.description || category.metadata?.description || "");
|
|
55
55
|
setImageUrl(category.image_url || "");
|
|
56
56
|
setRank(category.rank ?? 0);
|
|
57
57
|
setIsActive(Boolean(category.is_active));
|
|
58
58
|
setIsInternal(Boolean(category.is_internal));
|
|
59
59
|
setSelectedChannels(category.sales_channels || salesChannels.map((s) => s.id));
|
|
60
60
|
setCurrentMetadata(category.metadata || {});
|
|
61
|
-
// Parse YouTube URLs from metadata
|
|
61
|
+
// Parse YouTube URLs from metadata (including legacy fields)
|
|
62
62
|
const meta = category.metadata;
|
|
63
63
|
const urls = [];
|
|
64
64
|
if (meta?.youtube_urls && Array.isArray(meta.youtube_urls)) {
|
|
65
65
|
meta.youtube_urls.forEach((u) => typeof u === "string" && u.trim() && urls.push(u.trim()));
|
|
66
66
|
}
|
|
67
|
-
else if (meta?.youtube_url && typeof meta.youtube_url === "string") {
|
|
67
|
+
else if (meta?.youtube_url && typeof meta.youtube_url === "string" && meta.youtube_url.trim()) {
|
|
68
68
|
urls.push(meta.youtube_url.trim());
|
|
69
69
|
}
|
|
70
|
+
else if (meta?.youtube_link && typeof meta.youtube_link === "string" && meta.youtube_link.trim()) {
|
|
71
|
+
urls.push(meta.youtube_link.trim());
|
|
72
|
+
}
|
|
73
|
+
else if (meta?.video_url && typeof meta.video_url === "string" && meta.video_url.trim()) {
|
|
74
|
+
urls.push(meta.video_url.trim());
|
|
75
|
+
}
|
|
70
76
|
setYoutubeUrls(urls);
|
|
71
77
|
setActiveTab("general");
|
|
72
78
|
// Load category media from backend
|
|
@@ -313,6 +319,8 @@ const EditCategoryDrawer = ({ open, onOpenChange, category, salesChannels, onSuc
|
|
|
313
319
|
...(currentMetadata || {}),
|
|
314
320
|
youtube_urls: nextUrls,
|
|
315
321
|
youtube_url: nextUrls[0] || null,
|
|
322
|
+
youtube_link: nextUrls[0] || null,
|
|
323
|
+
video_url: nextUrls[0] || null,
|
|
316
324
|
};
|
|
317
325
|
setCurrentMetadata(updatedMeta);
|
|
318
326
|
try {
|
|
@@ -336,6 +344,8 @@ const EditCategoryDrawer = ({ open, onOpenChange, category, salesChannels, onSuc
|
|
|
336
344
|
...(currentMetadata || {}),
|
|
337
345
|
youtube_urls: nextUrls,
|
|
338
346
|
youtube_url: nextUrls[0] || null,
|
|
347
|
+
youtube_link: nextUrls[0] || null,
|
|
348
|
+
video_url: nextUrls[0] || null,
|
|
339
349
|
};
|
|
340
350
|
setCurrentMetadata(updatedMeta);
|
|
341
351
|
try {
|
|
@@ -352,9 +362,14 @@ const EditCategoryDrawer = ({ open, onOpenChange, category, salesChannels, onSuc
|
|
|
352
362
|
ui_1.toast.info("Video removed (remember to save)");
|
|
353
363
|
}
|
|
354
364
|
};
|
|
355
|
-
const handleSubmit = async (
|
|
356
|
-
|
|
357
|
-
|
|
365
|
+
const handleSubmit = async (eOrClose, closeAfter = false) => {
|
|
366
|
+
let shouldClose = closeAfter;
|
|
367
|
+
if (typeof eOrClose === "boolean") {
|
|
368
|
+
shouldClose = eOrClose;
|
|
369
|
+
}
|
|
370
|
+
else if (eOrClose && eOrClose.preventDefault) {
|
|
371
|
+
eOrClose.preventDefault();
|
|
372
|
+
}
|
|
358
373
|
if (!name.trim()) {
|
|
359
374
|
ui_1.toast.error("Category name is required");
|
|
360
375
|
return;
|
|
@@ -367,11 +382,15 @@ const EditCategoryDrawer = ({ open, onOpenChange, category, salesChannels, onSuc
|
|
|
367
382
|
const bannerImg = categoryImages.find((img) => img.type === "banner") ||
|
|
368
383
|
(categoryImages.length > 1 ? categoryImages.find((img) => img.url !== thumbImg?.url) : null) ||
|
|
369
384
|
null;
|
|
385
|
+
const descValue = description.trim();
|
|
370
386
|
const updatedMetadata = {
|
|
371
387
|
...(currentMetadata || {}),
|
|
388
|
+
description: descValue || null,
|
|
372
389
|
sales_channels: selectedChannels,
|
|
373
390
|
youtube_urls: youtubeUrls,
|
|
374
391
|
youtube_url: youtubeUrls[0] || null,
|
|
392
|
+
youtube_link: youtubeUrls[0] || null,
|
|
393
|
+
video_url: youtubeUrls[0] || null,
|
|
375
394
|
images: categoryImages,
|
|
376
395
|
thumbnail: thumbImg ? thumbImg.url : null,
|
|
377
396
|
image_url: thumbImg ? thumbImg.url : null,
|
|
@@ -381,7 +400,7 @@ const EditCategoryDrawer = ({ open, onOpenChange, category, salesChannels, onSuc
|
|
|
381
400
|
await sdk_1.sdk.admin.productCategory.update(category.id, {
|
|
382
401
|
name: name.trim(),
|
|
383
402
|
handle: handle.trim() || undefined,
|
|
384
|
-
description:
|
|
403
|
+
description: descValue ? descValue : null,
|
|
385
404
|
rank: Number(rank) || 0,
|
|
386
405
|
is_active: isActive,
|
|
387
406
|
is_internal: isInternal,
|
|
@@ -391,7 +410,7 @@ const EditCategoryDrawer = ({ open, onOpenChange, category, salesChannels, onSuc
|
|
|
391
410
|
...category,
|
|
392
411
|
name: name.trim(),
|
|
393
412
|
handle: handle.trim(),
|
|
394
|
-
description:
|
|
413
|
+
description: descValue ? descValue : null,
|
|
395
414
|
image_url: thumbImg ? thumbImg.url : "",
|
|
396
415
|
rank: Number(rank) || 0,
|
|
397
416
|
sales_channels: selectedChannels,
|
|
@@ -401,7 +420,9 @@ const EditCategoryDrawer = ({ open, onOpenChange, category, salesChannels, onSuc
|
|
|
401
420
|
};
|
|
402
421
|
ui_1.toast.success("Category updated successfully");
|
|
403
422
|
onSuccess(updated);
|
|
404
|
-
|
|
423
|
+
if (shouldClose) {
|
|
424
|
+
onOpenChange(false);
|
|
425
|
+
}
|
|
405
426
|
}
|
|
406
427
|
catch (err) {
|
|
407
428
|
ui_1.toast.error(err?.message || "Failed to update category");
|
|
@@ -461,7 +482,7 @@ const EditCategoryDrawer = ({ open, onOpenChange, category, salesChannels, onSuc
|
|
|
461
482
|
? "ring-2 ring-blue-500 border-transparent"
|
|
462
483
|
: isBanner
|
|
463
484
|
? "ring-2 ring-emerald-500 border-transparent"
|
|
464
|
-
: "border-ui-border-base hover:border-ui-border-strong"}`, children: [(0, jsx_runtime_1.jsx)("img", { src: img.url, alt: "Category Media", className: "w-full h-full object-
|
|
485
|
+
: "border-ui-border-base hover:border-ui-border-strong"}`, children: [(0, jsx_runtime_1.jsx)("img", { src: img.url, alt: "Category Media", className: "w-full h-full object-contain p-1.5" }), isThumbnail && ((0, jsx_runtime_1.jsx)("div", { className: "absolute top-1.5 left-1.5 bg-blue-600 text-white text-[9px] font-bold px-1.5 py-0.5 rounded shadow-xs", children: "\u2605 Thumbnail" })), !isThumbnail && isBanner && ((0, jsx_runtime_1.jsx)("div", { className: "absolute top-1.5 left-1.5 bg-emerald-600 text-white text-[9px] font-bold px-1.5 py-0.5 rounded shadow-xs", children: "\uD83D\uDDBC\uFE0F Hero Banner" })), (0, jsx_runtime_1.jsxs)("div", { className: "absolute inset-0 bg-black/60 opacity-0 group-hover:opacity-100 transition-opacity flex flex-col items-center justify-center gap-1.5 p-1.5", children: [!isThumbnail && ((0, jsx_runtime_1.jsx)("button", { type: "button", onClick: (e) => {
|
|
465
486
|
e.preventDefault();
|
|
466
487
|
e.stopPropagation();
|
|
467
488
|
handleSetThumbnail(img);
|
|
@@ -488,6 +509,13 @@ const EditCategoryDrawer = ({ open, onOpenChange, category, salesChannels, onSuc
|
|
|
488
509
|
const id = extractYouTubeVideoId(url);
|
|
489
510
|
const thumb = id ? `https://img.youtube.com/vi/${id}/hqdefault.jpg` : null;
|
|
490
511
|
return ((0, jsx_runtime_1.jsxs)("div", { className: "flex flex-col border border-ui-border-base rounded-xl overflow-hidden bg-ui-bg-subtle/40 shadow-xs", children: [(0, jsx_runtime_1.jsxs)("div", { className: "relative aspect-video bg-black/80 overflow-hidden flex items-center justify-center", children: [thumb ? ((0, jsx_runtime_1.jsx)("img", { src: thumb, alt: "YouTube Preview", className: "w-full h-full object-cover" })) : ((0, jsx_runtime_1.jsx)("span", { className: "text-xs text-white", children: "Video" })), (0, jsx_runtime_1.jsx)("button", { type: "button", onClick: () => handleRemoveYoutube(url), className: "absolute top-1.5 right-1.5 p-1 bg-black/70 hover:bg-rose-600 text-white rounded-full transition-colors cursor-pointer", title: "Remove video", children: (0, jsx_runtime_1.jsx)(icons_1.Trash, { className: "w-3 h-3" }) })] }), (0, jsx_runtime_1.jsx)("div", { className: "p-2", children: (0, jsx_runtime_1.jsx)("span", { className: "text-[10px] text-ui-fg-muted truncate block font-mono", title: url, children: url }) })] }, url));
|
|
491
|
-
}) }))] }))] }), (0, jsx_runtime_1.jsxs)(ui_1.Drawer.Footer, { className: "flex justify-end gap-2 px-6 py-4 border-t border-ui-border-base", children: [(0, jsx_runtime_1.jsx)(ui_1.Button, { type: "button", variant: "secondary", size: "small", onClick: () =>
|
|
512
|
+
}) }))] }))] }), (0, jsx_runtime_1.jsxs)(ui_1.Drawer.Footer, { className: "flex justify-end gap-2 px-6 py-4 border-t border-ui-border-base", children: [(0, jsx_runtime_1.jsx)(ui_1.Button, { type: "button", variant: "secondary", size: "small", onClick: () => {
|
|
513
|
+
if (name.trim()) {
|
|
514
|
+
handleSubmit(true);
|
|
515
|
+
}
|
|
516
|
+
else {
|
|
517
|
+
onOpenChange(false);
|
|
518
|
+
}
|
|
519
|
+
}, disabled: isSubmitting || isDeleting, children: "Close" }), (0, jsx_runtime_1.jsx)(ui_1.Button, { type: "button", size: "small", onClick: () => handleSubmit(false), isLoading: isSubmitting, disabled: isSubmitting || !name.trim() || isDeleting, children: "Save Category" })] })] }) }) }));
|
|
492
520
|
};
|
|
493
521
|
exports.EditCategoryDrawer = EditCategoryDrawer;
|
|
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.computeDagreLayout = exports.CARD_HEIGHT = exports.CARD_WIDTH = void 0;
|
|
4
4
|
exports.computeTreeLayout = computeTreeLayout;
|
|
5
5
|
const react_1 = require("@xyflow/react");
|
|
6
|
+
const tree_state_1 = require("./tree-state");
|
|
6
7
|
exports.CARD_WIDTH = 270;
|
|
7
8
|
exports.CARD_HEIGHT = 86;
|
|
8
9
|
/**
|
|
@@ -32,15 +33,18 @@ function computeTreeLayout(nodes, edges, options = {}) {
|
|
|
32
33
|
}
|
|
33
34
|
});
|
|
34
35
|
// Identify roots (nodes with no incoming edge in the visible graph)
|
|
35
|
-
const rootNodes = nodes.filter((n) => !parentMap.has(n.id))
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
};
|
|
36
|
+
const rootNodes = nodes.filter((n) => !parentMap.has(n.id)).sort((a, b) => {
|
|
37
|
+
const catA = a.data?.category || { id: a.id };
|
|
38
|
+
const catB = b.data?.category || { id: b.id };
|
|
39
|
+
return (0, tree_state_1.compareCategories)(catA, catB);
|
|
40
|
+
});
|
|
41
41
|
function buildTree(id, depth) {
|
|
42
42
|
const rawNode = nodeMap.get(id);
|
|
43
|
-
const childIds = (childrenMap.get(id) || []).sort((
|
|
43
|
+
const childIds = (childrenMap.get(id) || []).sort((aId, bId) => {
|
|
44
|
+
const catA = nodeMap.get(aId)?.data?.category || { id: aId };
|
|
45
|
+
const catB = nodeMap.get(bId)?.data?.category || { id: bId };
|
|
46
|
+
return (0, tree_state_1.compareCategories)(catA, catB);
|
|
47
|
+
});
|
|
44
48
|
const children = childIds.map((childId) => buildTree(childId, depth + 1));
|
|
45
49
|
return {
|
|
46
50
|
id,
|
|
@@ -87,8 +91,6 @@ function computeTreeLayout(nodes, edges, options = {}) {
|
|
|
87
91
|
const centerOfChildren = (firstChild.primaryCoord + lastChild.primaryCoord) / 2;
|
|
88
92
|
treeNode.primaryCoord = centerOfChildren;
|
|
89
93
|
}
|
|
90
|
-
// Sort roots by their rank
|
|
91
|
-
rootNodes.sort((a, b) => getRank(a.id) - getRank(b.id) || a.id.localeCompare(b.id));
|
|
92
94
|
const positionedNodes = [];
|
|
93
95
|
let globalOffset = 0;
|
|
94
96
|
rootNodes.forEach((root) => {
|
|
@@ -2,6 +2,15 @@ import { Node, Edge } from "@xyflow/react";
|
|
|
2
2
|
import { CategoryTreeNode, CategoryMoveItem } from "../types";
|
|
3
3
|
export declare function toSuperscript(num: number): string;
|
|
4
4
|
export declare function computeTiers(categories: CategoryTreeNode[]): Map<string, number>;
|
|
5
|
+
export declare function compareCategories(a: {
|
|
6
|
+
rank?: number;
|
|
7
|
+
name?: string;
|
|
8
|
+
id?: string;
|
|
9
|
+
}, b: {
|
|
10
|
+
rank?: number;
|
|
11
|
+
name?: string;
|
|
12
|
+
id?: string;
|
|
13
|
+
}): number;
|
|
5
14
|
export declare function computeSiblingRanks(categories: CategoryTreeNode[]): Map<string, {
|
|
6
15
|
rankNum: number;
|
|
7
16
|
superscript: string;
|
|
@@ -24,6 +33,14 @@ export declare const BRANCH_PALETTE: {
|
|
|
24
33
|
export declare function computeBranchColors(categories: CategoryTreeNode[]): Map<string, string>;
|
|
25
34
|
/**
|
|
26
35
|
* Swaps sibling ranking (up or down). Modifies rank property among siblings
|
|
27
|
-
* and returns a new categories array ready for preview and commit
|
|
36
|
+
* and returns a new categories array ready for preview and commit, along with
|
|
37
|
+
* explicit contiguous moves for all siblings in the group.
|
|
28
38
|
*/
|
|
29
|
-
export declare function moveSiblingRank(categories: CategoryTreeNode[], targetId: string, direction: "up" | "down"):
|
|
39
|
+
export declare function moveSiblingRank(categories: CategoryTreeNode[], targetId: string, direction: "up" | "down"): {
|
|
40
|
+
nextCategories: CategoryTreeNode[];
|
|
41
|
+
siblingMoves: Array<{
|
|
42
|
+
id: string;
|
|
43
|
+
parent_category_id: string | null;
|
|
44
|
+
rank: number;
|
|
45
|
+
}>;
|
|
46
|
+
};
|
|
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.BRANCH_PALETTE = void 0;
|
|
4
4
|
exports.toSuperscript = toSuperscript;
|
|
5
5
|
exports.computeTiers = computeTiers;
|
|
6
|
+
exports.compareCategories = compareCategories;
|
|
6
7
|
exports.computeSiblingRanks = computeSiblingRanks;
|
|
7
8
|
exports.wouldCreateCycle = wouldCreateCycle;
|
|
8
9
|
exports.computeSubtreeIds = computeSubtreeIds;
|
|
@@ -53,6 +54,18 @@ function computeTiers(categories) {
|
|
|
53
54
|
}
|
|
54
55
|
return tierMap;
|
|
55
56
|
}
|
|
57
|
+
function compareCategories(a, b) {
|
|
58
|
+
const rankA = typeof a.rank === "number" ? a.rank : 9999;
|
|
59
|
+
const rankB = typeof b.rank === "number" ? b.rank : 9999;
|
|
60
|
+
if (rankA !== rankB)
|
|
61
|
+
return rankA - rankB;
|
|
62
|
+
const nameA = (a.name || "").trim();
|
|
63
|
+
const nameB = (b.name || "").trim();
|
|
64
|
+
const nameComp = nameA.localeCompare(nameB);
|
|
65
|
+
if (nameComp !== 0)
|
|
66
|
+
return nameComp;
|
|
67
|
+
return (a.id || "").localeCompare(b.id || "");
|
|
68
|
+
}
|
|
56
69
|
function computeSiblingRanks(categories) {
|
|
57
70
|
const grouped = new Map();
|
|
58
71
|
for (const c of categories) {
|
|
@@ -63,7 +76,7 @@ function computeSiblingRanks(categories) {
|
|
|
63
76
|
}
|
|
64
77
|
const result = new Map();
|
|
65
78
|
for (const [, list] of grouped.entries()) {
|
|
66
|
-
list.sort(
|
|
79
|
+
list.sort(compareCategories);
|
|
67
80
|
list.forEach((item, index) => {
|
|
68
81
|
const rankNum = index + 1;
|
|
69
82
|
result.set(item.id, {
|
|
@@ -188,37 +201,44 @@ function computeBranchColors(categories) {
|
|
|
188
201
|
}
|
|
189
202
|
/**
|
|
190
203
|
* Swaps sibling ranking (up or down). Modifies rank property among siblings
|
|
191
|
-
* and returns a new categories array ready for preview and commit
|
|
204
|
+
* and returns a new categories array ready for preview and commit, along with
|
|
205
|
+
* explicit contiguous moves for all siblings in the group.
|
|
192
206
|
*/
|
|
193
207
|
function moveSiblingRank(categories, targetId, direction) {
|
|
194
208
|
const target = categories.find((c) => c.id === targetId);
|
|
195
209
|
if (!target)
|
|
196
|
-
return categories;
|
|
210
|
+
return { nextCategories: categories, siblingMoves: [] };
|
|
197
211
|
const parentId = target.parent_category_id || null;
|
|
198
212
|
const siblings = categories
|
|
199
213
|
.filter((c) => (c.parent_category_id || null) === parentId)
|
|
200
|
-
.sort(
|
|
214
|
+
.sort(compareCategories);
|
|
201
215
|
const currentIndex = siblings.findIndex((s) => s.id === targetId);
|
|
202
216
|
if (currentIndex === -1)
|
|
203
|
-
return categories;
|
|
217
|
+
return { nextCategories: categories, siblingMoves: [] };
|
|
204
218
|
const targetIndex = direction === "up" ? currentIndex - 1 : currentIndex + 1;
|
|
205
219
|
if (targetIndex < 0 || targetIndex >= siblings.length)
|
|
206
|
-
return categories;
|
|
207
|
-
|
|
208
|
-
|
|
220
|
+
return { nextCategories: categories, siblingMoves: [] };
|
|
221
|
+
// Swap target and adjacent within ordered sibling list
|
|
222
|
+
const reordered = [...siblings];
|
|
223
|
+
const temp = reordered[currentIndex];
|
|
224
|
+
reordered[currentIndex] = reordered[targetIndex];
|
|
225
|
+
reordered[targetIndex] = temp;
|
|
226
|
+
// Normalize contiguous ranks: 0, 1, 2, 3...
|
|
209
227
|
const updatedRanks = new Map();
|
|
210
|
-
|
|
211
|
-
|
|
228
|
+
const siblingMoves = [];
|
|
229
|
+
reordered.forEach((s, idx) => {
|
|
230
|
+
updatedRanks.set(s.id, idx);
|
|
231
|
+
siblingMoves.push({
|
|
232
|
+
id: s.id,
|
|
233
|
+
parent_category_id: parentId,
|
|
234
|
+
rank: idx,
|
|
235
|
+
});
|
|
212
236
|
});
|
|
213
|
-
|
|
214
|
-
const targetNewRank = targetIndex * 10;
|
|
215
|
-
const adjacentNewRank = currentIndex * 10;
|
|
216
|
-
updatedRanks.set(target.id, targetNewRank);
|
|
217
|
-
updatedRanks.set(adjacent.id, adjacentNewRank);
|
|
218
|
-
return categories.map((c) => {
|
|
237
|
+
const nextCategories = categories.map((c) => {
|
|
219
238
|
if (updatedRanks.has(c.id)) {
|
|
220
239
|
return { ...c, rank: updatedRanks.get(c.id) };
|
|
221
240
|
}
|
|
222
241
|
return c;
|
|
223
242
|
});
|
|
243
|
+
return { nextCategories, siblingMoves };
|
|
224
244
|
}
|
|
@@ -10,10 +10,10 @@ const POST = async (req, res) => {
|
|
|
10
10
|
}
|
|
11
11
|
const { moves } = parsed.data;
|
|
12
12
|
const productModule = req.scope.resolve(utils_1.Modules.PRODUCT);
|
|
13
|
-
const existing = await productModule.listProductCategories({}, { select: ["id", "parent_category_id", "rank"], take: null });
|
|
13
|
+
const existing = await productModule.listProductCategories({}, { select: ["id", "parent_category_id", "rank", "metadata"], take: null });
|
|
14
14
|
const existingMap = new Map(existing.map((c) => [
|
|
15
15
|
c.id,
|
|
16
|
-
{ parent_category_id: c.parent_category_id, rank: c.rank },
|
|
16
|
+
{ parent_category_id: c.parent_category_id, rank: c.rank, metadata: c.metadata },
|
|
17
17
|
]));
|
|
18
18
|
const simulatedParents = new Map();
|
|
19
19
|
for (const [id, data] of existingMap.entries()) {
|
|
@@ -44,9 +44,15 @@ const POST = async (req, res) => {
|
|
|
44
44
|
const applied = [];
|
|
45
45
|
try {
|
|
46
46
|
for (const m of moves) {
|
|
47
|
+
const prevMeta = existingMap.get(m.id)?.metadata || {};
|
|
47
48
|
await productModule.updateProductCategories(m.id, {
|
|
48
49
|
parent_category_id: m.parent_category_id,
|
|
49
50
|
rank: m.rank,
|
|
51
|
+
metadata: {
|
|
52
|
+
...prevMeta,
|
|
53
|
+
rank: m.rank,
|
|
54
|
+
sibling_rank: m.rank + 1,
|
|
55
|
+
},
|
|
50
56
|
});
|
|
51
57
|
applied.push(m.id);
|
|
52
58
|
}
|
|
@@ -59,6 +59,19 @@ const GET = async (req, res) => {
|
|
|
59
59
|
});
|
|
60
60
|
categories = catDataFallback || [];
|
|
61
61
|
}
|
|
62
|
+
// Enforce universal deterministic sorting (rank ASC, name ASC, id ASC)
|
|
63
|
+
categories.sort((a, b) => {
|
|
64
|
+
const rankA = typeof a.rank === "number" ? a.rank : 9999;
|
|
65
|
+
const rankB = typeof b.rank === "number" ? b.rank : 9999;
|
|
66
|
+
if (rankA !== rankB)
|
|
67
|
+
return rankA - rankB;
|
|
68
|
+
const nameA = (a.name || "").trim();
|
|
69
|
+
const nameB = (b.name || "").trim();
|
|
70
|
+
const nameComp = nameA.localeCompare(nameB);
|
|
71
|
+
if (nameComp !== 0)
|
|
72
|
+
return nameComp;
|
|
73
|
+
return (a.id || "").localeCompare(b.id || "");
|
|
74
|
+
});
|
|
62
75
|
const allChannelIds = salesChannels.map((sc) => sc.id);
|
|
63
76
|
const enriched = categories.map((cat) => {
|
|
64
77
|
const images = Array.isArray(cat.product_category_image)
|
|
@@ -79,7 +92,7 @@ const GET = async (req, res) => {
|
|
|
79
92
|
id: cat.id,
|
|
80
93
|
name: cat.name,
|
|
81
94
|
handle: cat.handle,
|
|
82
|
-
description: cat.description || null,
|
|
95
|
+
description: cat.description || cat.metadata?.description || null,
|
|
83
96
|
parent_category_id: cat.parent_category_id || null,
|
|
84
97
|
rank: typeof cat.rank === "number" ? cat.rank : 0,
|
|
85
98
|
is_active: Boolean(cat.is_active),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hectasquare/medusa-plugin-category-tree",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.13",
|
|
4
4
|
"description": "AAA Interactive Category Tree & Mind Map for Medusa v2 Admin with drag-and-drop reparenting, tier indicators, superscript ranks, multi-storefront filtering, and NotebookLM-style subtree collapse.",
|
|
5
5
|
"author": "HectaSquare Team",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -134,12 +134,15 @@ export const CategoryProductsManager: React.FC<CategoryProductsManagerProps> = (
|
|
|
134
134
|
}
|
|
135
135
|
|
|
136
136
|
// Sibling product ranking handlers (Move Up / Move Down)
|
|
137
|
-
const handleMoveProduct = async (
|
|
138
|
-
const
|
|
137
|
+
const handleMoveProduct = async (productId: string, direction: "up" | "down") => {
|
|
138
|
+
const currentIndex = products.findIndex((p) => p.id === productId)
|
|
139
|
+
if (currentIndex === -1) return
|
|
140
|
+
|
|
141
|
+
const targetIndex = direction === "up" ? currentIndex - 1 : currentIndex + 1
|
|
139
142
|
if (targetIndex < 0 || targetIndex >= products.length) return
|
|
140
143
|
|
|
141
144
|
const reordered = [...products]
|
|
142
|
-
const [moved] = reordered.splice(
|
|
145
|
+
const [moved] = reordered.splice(currentIndex, 1)
|
|
143
146
|
reordered.splice(targetIndex, 0, moved)
|
|
144
147
|
|
|
145
148
|
setProducts(reordered)
|
|
@@ -436,7 +439,7 @@ export const CategoryProductsManager: React.FC<CategoryProductsManagerProps> = (
|
|
|
436
439
|
onClick={(e) => {
|
|
437
440
|
e.preventDefault()
|
|
438
441
|
e.stopPropagation()
|
|
439
|
-
handleMoveProduct(
|
|
442
|
+
handleMoveProduct(p.id, "up")
|
|
440
443
|
}}
|
|
441
444
|
className="text-[9px] text-ui-fg-muted hover:text-ui-fg-base disabled:opacity-20 px-0.5 leading-none cursor-pointer"
|
|
442
445
|
>
|
|
@@ -449,7 +452,7 @@ export const CategoryProductsManager: React.FC<CategoryProductsManagerProps> = (
|
|
|
449
452
|
onClick={(e) => {
|
|
450
453
|
e.preventDefault()
|
|
451
454
|
e.stopPropagation()
|
|
452
|
-
handleMoveProduct(
|
|
455
|
+
handleMoveProduct(p.id, "down")
|
|
453
456
|
}}
|
|
454
457
|
className="text-[9px] text-ui-fg-muted hover:text-ui-fg-base disabled:opacity-20 px-0.5 leading-none cursor-pointer"
|
|
455
458
|
>
|
|
@@ -149,11 +149,47 @@ const CanvasInner: React.FC = () => {
|
|
|
149
149
|
}, [selectedNodeIds])
|
|
150
150
|
|
|
151
151
|
const handleMoveSiblingUp = useCallback((id: string) => {
|
|
152
|
-
setCategories((prev) =>
|
|
152
|
+
setCategories((prev) => {
|
|
153
|
+
const { nextCategories, siblingMoves } = moveSiblingRank(prev, id, "up")
|
|
154
|
+
if (siblingMoves.length > 0) {
|
|
155
|
+
// AAA Realtime: Immediately auto-persist contiguous ranks to backend in background
|
|
156
|
+
sdk.client
|
|
157
|
+
.fetch("/admin/category-tree/reorder", {
|
|
158
|
+
method: "POST",
|
|
159
|
+
body: { moves: siblingMoves },
|
|
160
|
+
})
|
|
161
|
+
.then(() => {
|
|
162
|
+
setOriginalCategories([...nextCategories])
|
|
163
|
+
})
|
|
164
|
+
.catch((err: any) => {
|
|
165
|
+
console.error("Failed to auto-save sibling rank:", err)
|
|
166
|
+
toast.error("Failed to save ranking: " + (err.message || "Unknown error"))
|
|
167
|
+
})
|
|
168
|
+
}
|
|
169
|
+
return nextCategories
|
|
170
|
+
})
|
|
153
171
|
}, [])
|
|
154
172
|
|
|
155
173
|
const handleMoveSiblingDown = useCallback((id: string) => {
|
|
156
|
-
setCategories((prev) =>
|
|
174
|
+
setCategories((prev) => {
|
|
175
|
+
const { nextCategories, siblingMoves } = moveSiblingRank(prev, id, "down")
|
|
176
|
+
if (siblingMoves.length > 0) {
|
|
177
|
+
// AAA Realtime: Immediately auto-persist contiguous ranks to backend in background
|
|
178
|
+
sdk.client
|
|
179
|
+
.fetch("/admin/category-tree/reorder", {
|
|
180
|
+
method: "POST",
|
|
181
|
+
body: { moves: siblingMoves },
|
|
182
|
+
})
|
|
183
|
+
.then(() => {
|
|
184
|
+
setOriginalCategories([...nextCategories])
|
|
185
|
+
})
|
|
186
|
+
.catch((err: any) => {
|
|
187
|
+
console.error("Failed to auto-save sibling rank:", err)
|
|
188
|
+
toast.error("Failed to save ranking: " + (err.message || "Unknown error"))
|
|
189
|
+
})
|
|
190
|
+
}
|
|
191
|
+
return nextCategories
|
|
192
|
+
})
|
|
157
193
|
}, [])
|
|
158
194
|
|
|
159
195
|
const handleAddChild = useCallback((parent: CategoryTreeNode) => {
|
|
@@ -478,6 +514,7 @@ const CanvasInner: React.FC = () => {
|
|
|
478
514
|
category={editingCategory}
|
|
479
515
|
salesChannels={salesChannels}
|
|
480
516
|
onSuccess={(updatedCat) => {
|
|
517
|
+
setEditingCategory(updatedCat)
|
|
481
518
|
setCategories((prev) =>
|
|
482
519
|
prev.map((c) => (c.id === updatedCat.id ? updatedCat : c))
|
|
483
520
|
)
|
|
@@ -27,16 +27,16 @@ function extractYouTubeVideoId(url: string): string | null {
|
|
|
27
27
|
if (!url || typeof url !== "string") return null
|
|
28
28
|
const trimmed = url.trim()
|
|
29
29
|
const patterns = [
|
|
30
|
-
/(?:youtube\.com\/shorts\/)([
|
|
31
|
-
/(?:youtube\.com\/watch\?.*v=)([
|
|
32
|
-
/(?:youtu\.be\/)([
|
|
33
|
-
/(?:youtube\.com\/embed\/)([
|
|
30
|
+
/(?:youtube\.com\/shorts\/)([a-zA-Z0-9_-]{11})/i,
|
|
31
|
+
/(?:youtube\.com\/watch\?.*v=)([a-zA-Z0-9_-]{11})/i,
|
|
32
|
+
/(?:youtu\.be\/)([a-zA-Z0-9_-]{11})/i,
|
|
33
|
+
/(?:youtube\.com\/embed\/)([a-zA-Z0-9_-]{11})/i,
|
|
34
34
|
]
|
|
35
35
|
for (const pattern of patterns) {
|
|
36
36
|
const match = trimmed.match(pattern)
|
|
37
37
|
if (match && match[1]) return match[1]
|
|
38
38
|
}
|
|
39
|
-
if (/^[a-zA-Z0-9_-]{11}$/.test(trimmed)) return trimmed
|
|
39
|
+
if (/^[a-zA-Z0-9_-]{11}$/.test(trimmed) && !/^[A-Za-z]{11}$/.test(trimmed)) return trimmed
|
|
40
40
|
return null
|
|
41
41
|
}
|
|
42
42
|
|
|
@@ -76,7 +76,7 @@ export const EditCategoryDrawer: React.FC<EditCategoryDrawerProps> = ({
|
|
|
76
76
|
if (category) {
|
|
77
77
|
setName(category.name || "")
|
|
78
78
|
setHandle(category.handle || "")
|
|
79
|
-
setDescription(category.description || "")
|
|
79
|
+
setDescription(category.description || (category.metadata as any)?.description || "")
|
|
80
80
|
setImageUrl(category.image_url || "")
|
|
81
81
|
setRank(category.rank ?? 0)
|
|
82
82
|
setIsActive(Boolean(category.is_active))
|
|
@@ -84,13 +84,17 @@ export const EditCategoryDrawer: React.FC<EditCategoryDrawerProps> = ({
|
|
|
84
84
|
setSelectedChannels(category.sales_channels || salesChannels.map((s) => s.id))
|
|
85
85
|
setCurrentMetadata((category.metadata as Record<string, any>) || {})
|
|
86
86
|
|
|
87
|
-
// Parse YouTube URLs from metadata
|
|
87
|
+
// Parse YouTube URLs from metadata (including legacy fields)
|
|
88
88
|
const meta = category.metadata as Record<string, any> | null
|
|
89
89
|
const urls: string[] = []
|
|
90
90
|
if (meta?.youtube_urls && Array.isArray(meta.youtube_urls)) {
|
|
91
91
|
meta.youtube_urls.forEach((u) => typeof u === "string" && u.trim() && urls.push(u.trim()))
|
|
92
|
-
} else if (meta?.youtube_url && typeof meta.youtube_url === "string") {
|
|
92
|
+
} else if (meta?.youtube_url && typeof meta.youtube_url === "string" && meta.youtube_url.trim()) {
|
|
93
93
|
urls.push(meta.youtube_url.trim())
|
|
94
|
+
} else if (meta?.youtube_link && typeof meta.youtube_link === "string" && meta.youtube_link.trim()) {
|
|
95
|
+
urls.push(meta.youtube_link.trim())
|
|
96
|
+
} else if (meta?.video_url && typeof meta.video_url === "string" && meta.video_url.trim()) {
|
|
97
|
+
urls.push(meta.video_url.trim())
|
|
94
98
|
}
|
|
95
99
|
setYoutubeUrls(urls)
|
|
96
100
|
setActiveTab("general")
|
|
@@ -362,6 +366,8 @@ export const EditCategoryDrawer: React.FC<EditCategoryDrawerProps> = ({
|
|
|
362
366
|
...(currentMetadata || {}),
|
|
363
367
|
youtube_urls: nextUrls,
|
|
364
368
|
youtube_url: nextUrls[0] || null,
|
|
369
|
+
youtube_link: nextUrls[0] || null,
|
|
370
|
+
video_url: nextUrls[0] || null,
|
|
365
371
|
}
|
|
366
372
|
setCurrentMetadata(updatedMeta)
|
|
367
373
|
|
|
@@ -386,6 +392,8 @@ export const EditCategoryDrawer: React.FC<EditCategoryDrawerProps> = ({
|
|
|
386
392
|
...(currentMetadata || {}),
|
|
387
393
|
youtube_urls: nextUrls,
|
|
388
394
|
youtube_url: nextUrls[0] || null,
|
|
395
|
+
youtube_link: nextUrls[0] || null,
|
|
396
|
+
video_url: nextUrls[0] || null,
|
|
389
397
|
}
|
|
390
398
|
setCurrentMetadata(updatedMeta)
|
|
391
399
|
|
|
@@ -403,8 +411,13 @@ export const EditCategoryDrawer: React.FC<EditCategoryDrawerProps> = ({
|
|
|
403
411
|
}
|
|
404
412
|
}
|
|
405
413
|
|
|
406
|
-
const handleSubmit = async (
|
|
407
|
-
|
|
414
|
+
const handleSubmit = async (eOrClose?: React.FormEvent | boolean, closeAfter: boolean = false) => {
|
|
415
|
+
let shouldClose = closeAfter
|
|
416
|
+
if (typeof eOrClose === "boolean") {
|
|
417
|
+
shouldClose = eOrClose
|
|
418
|
+
} else if (eOrClose && (eOrClose as any).preventDefault) {
|
|
419
|
+
(eOrClose as any).preventDefault()
|
|
420
|
+
}
|
|
408
421
|
if (!name.trim()) {
|
|
409
422
|
toast.error("Category name is required")
|
|
410
423
|
return
|
|
@@ -421,11 +434,16 @@ export const EditCategoryDrawer: React.FC<EditCategoryDrawerProps> = ({
|
|
|
421
434
|
(categoryImages.length > 1 ? categoryImages.find((img) => img.url !== thumbImg?.url) : null) ||
|
|
422
435
|
null
|
|
423
436
|
|
|
437
|
+
const descValue = description.trim()
|
|
438
|
+
|
|
424
439
|
const updatedMetadata: Record<string, any> = {
|
|
425
440
|
...(currentMetadata || {}),
|
|
441
|
+
description: descValue || null,
|
|
426
442
|
sales_channels: selectedChannels,
|
|
427
443
|
youtube_urls: youtubeUrls,
|
|
428
444
|
youtube_url: youtubeUrls[0] || null,
|
|
445
|
+
youtube_link: youtubeUrls[0] || null,
|
|
446
|
+
video_url: youtubeUrls[0] || null,
|
|
429
447
|
images: categoryImages,
|
|
430
448
|
thumbnail: thumbImg ? thumbImg.url : null,
|
|
431
449
|
image_url: thumbImg ? thumbImg.url : null,
|
|
@@ -436,7 +454,7 @@ export const EditCategoryDrawer: React.FC<EditCategoryDrawerProps> = ({
|
|
|
436
454
|
await sdk.admin.productCategory.update(category.id, {
|
|
437
455
|
name: name.trim(),
|
|
438
456
|
handle: handle.trim() || undefined,
|
|
439
|
-
description:
|
|
457
|
+
description: descValue ? descValue : null,
|
|
440
458
|
rank: Number(rank) || 0,
|
|
441
459
|
is_active: isActive,
|
|
442
460
|
is_internal: isInternal,
|
|
@@ -447,7 +465,7 @@ export const EditCategoryDrawer: React.FC<EditCategoryDrawerProps> = ({
|
|
|
447
465
|
...category,
|
|
448
466
|
name: name.trim(),
|
|
449
467
|
handle: handle.trim(),
|
|
450
|
-
description:
|
|
468
|
+
description: descValue ? descValue : null,
|
|
451
469
|
image_url: thumbImg ? thumbImg.url : "",
|
|
452
470
|
rank: Number(rank) || 0,
|
|
453
471
|
sales_channels: selectedChannels,
|
|
@@ -458,7 +476,9 @@ export const EditCategoryDrawer: React.FC<EditCategoryDrawerProps> = ({
|
|
|
458
476
|
|
|
459
477
|
toast.success("Category updated successfully")
|
|
460
478
|
onSuccess(updated)
|
|
461
|
-
|
|
479
|
+
if (shouldClose) {
|
|
480
|
+
onOpenChange(false)
|
|
481
|
+
}
|
|
462
482
|
} catch (err: any) {
|
|
463
483
|
toast.error(err?.message || "Failed to update category")
|
|
464
484
|
} finally {
|
|
@@ -838,7 +858,7 @@ export const EditCategoryDrawer: React.FC<EditCategoryDrawerProps> = ({
|
|
|
838
858
|
<img
|
|
839
859
|
src={img.url}
|
|
840
860
|
alt="Category Media"
|
|
841
|
-
className="w-full h-full object-
|
|
861
|
+
className="w-full h-full object-contain p-1.5"
|
|
842
862
|
/>
|
|
843
863
|
|
|
844
864
|
{isThumbnail && (
|
|
@@ -1026,7 +1046,13 @@ export const EditCategoryDrawer: React.FC<EditCategoryDrawerProps> = ({
|
|
|
1026
1046
|
type="button"
|
|
1027
1047
|
variant="secondary"
|
|
1028
1048
|
size="small"
|
|
1029
|
-
onClick={() =>
|
|
1049
|
+
onClick={() => {
|
|
1050
|
+
if (name.trim()) {
|
|
1051
|
+
handleSubmit(true)
|
|
1052
|
+
} else {
|
|
1053
|
+
onOpenChange(false)
|
|
1054
|
+
}
|
|
1055
|
+
}}
|
|
1030
1056
|
disabled={isSubmitting || isDeleting}
|
|
1031
1057
|
>
|
|
1032
1058
|
Close
|
|
@@ -1034,7 +1060,7 @@ export const EditCategoryDrawer: React.FC<EditCategoryDrawerProps> = ({
|
|
|
1034
1060
|
<Button
|
|
1035
1061
|
type="button"
|
|
1036
1062
|
size="small"
|
|
1037
|
-
onClick={() => handleSubmit()}
|
|
1063
|
+
onClick={() => handleSubmit(false)}
|
|
1038
1064
|
isLoading={isSubmitting}
|
|
1039
1065
|
disabled={isSubmitting || !name.trim() || isDeleting}
|
|
1040
1066
|
>
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { Node, Edge, Position } from "@xyflow/react"
|
|
2
|
+
import { compareCategories } from "./tree-state"
|
|
2
3
|
|
|
3
4
|
export const CARD_WIDTH = 270
|
|
4
5
|
export const CARD_HEIGHT = 86
|
|
@@ -54,19 +55,19 @@ export function computeTreeLayout(
|
|
|
54
55
|
})
|
|
55
56
|
|
|
56
57
|
// Identify roots (nodes with no incoming edge in the visible graph)
|
|
57
|
-
const rootNodes = nodes.filter((n) => !parentMap.has(n.id))
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
return typeof raw === "number" ? raw : 0
|
|
63
|
-
}
|
|
58
|
+
const rootNodes = nodes.filter((n) => !parentMap.has(n.id)).sort((a, b) => {
|
|
59
|
+
const catA = (a.data as any)?.category || { id: a.id }
|
|
60
|
+
const catB = (b.data as any)?.category || { id: b.id }
|
|
61
|
+
return compareCategories(catA, catB)
|
|
62
|
+
})
|
|
64
63
|
|
|
65
64
|
function buildTree(id: string, depth: number): TreeLayoutNode {
|
|
66
65
|
const rawNode = nodeMap.get(id)!
|
|
67
|
-
const childIds = (childrenMap.get(id) || []).sort(
|
|
68
|
-
|
|
69
|
-
|
|
66
|
+
const childIds = (childrenMap.get(id) || []).sort((aId, bId) => {
|
|
67
|
+
const catA = (nodeMap.get(aId)?.data as any)?.category || { id: aId }
|
|
68
|
+
const catB = (nodeMap.get(bId)?.data as any)?.category || { id: bId }
|
|
69
|
+
return compareCategories(catA, catB)
|
|
70
|
+
})
|
|
70
71
|
|
|
71
72
|
const children = childIds.map((childId) => buildTree(childId, depth + 1))
|
|
72
73
|
|
|
@@ -129,8 +130,6 @@ export function computeTreeLayout(
|
|
|
129
130
|
treeNode.primaryCoord = centerOfChildren
|
|
130
131
|
}
|
|
131
132
|
|
|
132
|
-
// Sort roots by their rank
|
|
133
|
-
rootNodes.sort((a, b) => getRank(a.id) - getRank(b.id) || a.id.localeCompare(b.id))
|
|
134
133
|
|
|
135
134
|
const positionedNodes: Node[] = []
|
|
136
135
|
let globalOffset = 0
|
|
@@ -52,6 +52,20 @@ export function computeTiers(
|
|
|
52
52
|
return tierMap
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
+
export function compareCategories(
|
|
56
|
+
a: { rank?: number; name?: string; id?: string },
|
|
57
|
+
b: { rank?: number; name?: string; id?: string }
|
|
58
|
+
): number {
|
|
59
|
+
const rankA = typeof a.rank === "number" ? a.rank : 9999
|
|
60
|
+
const rankB = typeof b.rank === "number" ? b.rank : 9999
|
|
61
|
+
if (rankA !== rankB) return rankA - rankB
|
|
62
|
+
const nameA = (a.name || "").trim()
|
|
63
|
+
const nameB = (b.name || "").trim()
|
|
64
|
+
const nameComp = nameA.localeCompare(nameB)
|
|
65
|
+
if (nameComp !== 0) return nameComp
|
|
66
|
+
return (a.id || "").localeCompare(b.id || "")
|
|
67
|
+
}
|
|
68
|
+
|
|
55
69
|
export function computeSiblingRanks(
|
|
56
70
|
categories: CategoryTreeNode[]
|
|
57
71
|
): Map<string, { rankNum: number; superscript: string }> {
|
|
@@ -67,7 +81,7 @@ export function computeSiblingRanks(
|
|
|
67
81
|
const result = new Map<string, { rankNum: number; superscript: string }>()
|
|
68
82
|
|
|
69
83
|
for (const [, list] of grouped.entries()) {
|
|
70
|
-
list.sort(
|
|
84
|
+
list.sort(compareCategories)
|
|
71
85
|
list.forEach((item, index) => {
|
|
72
86
|
const rankNum = index + 1
|
|
73
87
|
result.set(item.id, {
|
|
@@ -236,44 +250,56 @@ export function computeBranchColors(
|
|
|
236
250
|
|
|
237
251
|
/**
|
|
238
252
|
* Swaps sibling ranking (up or down). Modifies rank property among siblings
|
|
239
|
-
* and returns a new categories array ready for preview and commit
|
|
253
|
+
* and returns a new categories array ready for preview and commit, along with
|
|
254
|
+
* explicit contiguous moves for all siblings in the group.
|
|
240
255
|
*/
|
|
241
256
|
export function moveSiblingRank(
|
|
242
257
|
categories: CategoryTreeNode[],
|
|
243
258
|
targetId: string,
|
|
244
259
|
direction: "up" | "down"
|
|
245
|
-
):
|
|
260
|
+
): {
|
|
261
|
+
nextCategories: CategoryTreeNode[]
|
|
262
|
+
siblingMoves: Array<{ id: string; parent_category_id: string | null; rank: number }>
|
|
263
|
+
} {
|
|
246
264
|
const target = categories.find((c) => c.id === targetId)
|
|
247
|
-
if (!target) return categories
|
|
265
|
+
if (!target) return { nextCategories: categories, siblingMoves: [] }
|
|
248
266
|
|
|
249
267
|
const parentId = target.parent_category_id || null
|
|
250
268
|
const siblings = categories
|
|
251
269
|
.filter((c) => (c.parent_category_id || null) === parentId)
|
|
252
|
-
.sort(
|
|
270
|
+
.sort(compareCategories)
|
|
253
271
|
|
|
254
272
|
const currentIndex = siblings.findIndex((s) => s.id === targetId)
|
|
255
|
-
if (currentIndex === -1) return categories
|
|
273
|
+
if (currentIndex === -1) return { nextCategories: categories, siblingMoves: [] }
|
|
256
274
|
|
|
257
275
|
const targetIndex = direction === "up" ? currentIndex - 1 : currentIndex + 1
|
|
258
|
-
if (targetIndex < 0 || targetIndex >= siblings.length) return categories
|
|
276
|
+
if (targetIndex < 0 || targetIndex >= siblings.length) return { nextCategories: categories, siblingMoves: [] }
|
|
277
|
+
|
|
278
|
+
// Swap target and adjacent within ordered sibling list
|
|
279
|
+
const reordered = [...siblings]
|
|
280
|
+
const temp = reordered[currentIndex]
|
|
281
|
+
reordered[currentIndex] = reordered[targetIndex]
|
|
282
|
+
reordered[targetIndex] = temp
|
|
259
283
|
|
|
260
|
-
|
|
261
|
-
// Normalize ranks if they are identical or uninitialized
|
|
284
|
+
// Normalize contiguous ranks: 0, 1, 2, 3...
|
|
262
285
|
const updatedRanks = new Map<string, number>()
|
|
263
|
-
|
|
264
|
-
|
|
286
|
+
const siblingMoves: Array<{ id: string; parent_category_id: string | null; rank: number }> = []
|
|
287
|
+
|
|
288
|
+
reordered.forEach((s, idx) => {
|
|
289
|
+
updatedRanks.set(s.id, idx)
|
|
290
|
+
siblingMoves.push({
|
|
291
|
+
id: s.id,
|
|
292
|
+
parent_category_id: parentId,
|
|
293
|
+
rank: idx,
|
|
294
|
+
})
|
|
265
295
|
})
|
|
266
296
|
|
|
267
|
-
|
|
268
|
-
const targetNewRank = targetIndex * 10
|
|
269
|
-
const adjacentNewRank = currentIndex * 10
|
|
270
|
-
updatedRanks.set(target.id, targetNewRank)
|
|
271
|
-
updatedRanks.set(adjacent.id, adjacentNewRank)
|
|
272
|
-
|
|
273
|
-
return categories.map((c) => {
|
|
297
|
+
const nextCategories = categories.map((c) => {
|
|
274
298
|
if (updatedRanks.has(c.id)) {
|
|
275
299
|
return { ...c, rank: updatedRanks.get(c.id)! }
|
|
276
300
|
}
|
|
277
301
|
return c
|
|
278
302
|
})
|
|
303
|
+
|
|
304
|
+
return { nextCategories, siblingMoves }
|
|
279
305
|
}
|
|
@@ -22,15 +22,15 @@ export const POST = async (
|
|
|
22
22
|
|
|
23
23
|
const existing = await productModule.listProductCategories(
|
|
24
24
|
{},
|
|
25
|
-
{ select: ["id", "parent_category_id", "rank"], take: null }
|
|
25
|
+
{ select: ["id", "parent_category_id", "rank", "metadata"], take: null }
|
|
26
26
|
)
|
|
27
27
|
const existingMap = new Map<
|
|
28
28
|
string,
|
|
29
|
-
{ parent_category_id: string | null; rank: number }
|
|
29
|
+
{ parent_category_id: string | null; rank: number; metadata?: any }
|
|
30
30
|
>(
|
|
31
31
|
existing.map((c: any) => [
|
|
32
32
|
c.id,
|
|
33
|
-
{ parent_category_id: c.parent_category_id, rank: c.rank },
|
|
33
|
+
{ parent_category_id: c.parent_category_id, rank: c.rank, metadata: c.metadata },
|
|
34
34
|
])
|
|
35
35
|
)
|
|
36
36
|
|
|
@@ -78,9 +78,15 @@ export const POST = async (
|
|
|
78
78
|
const applied: string[] = []
|
|
79
79
|
try {
|
|
80
80
|
for (const m of moves) {
|
|
81
|
+
const prevMeta = existingMap.get(m.id)?.metadata || {}
|
|
81
82
|
await productModule.updateProductCategories(m.id, {
|
|
82
83
|
parent_category_id: m.parent_category_id,
|
|
83
84
|
rank: m.rank,
|
|
85
|
+
metadata: {
|
|
86
|
+
...prevMeta,
|
|
87
|
+
rank: m.rank,
|
|
88
|
+
sibling_rank: m.rank + 1,
|
|
89
|
+
},
|
|
84
90
|
})
|
|
85
91
|
applied.push(m.id)
|
|
86
92
|
}
|
|
@@ -65,6 +65,18 @@ export const GET = async (
|
|
|
65
65
|
categories = (catDataFallback as any[]) || []
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
+
// Enforce universal deterministic sorting (rank ASC, name ASC, id ASC)
|
|
69
|
+
categories.sort((a, b) => {
|
|
70
|
+
const rankA = typeof a.rank === "number" ? a.rank : 9999
|
|
71
|
+
const rankB = typeof b.rank === "number" ? b.rank : 9999
|
|
72
|
+
if (rankA !== rankB) return rankA - rankB
|
|
73
|
+
const nameA = (a.name || "").trim()
|
|
74
|
+
const nameB = (b.name || "").trim()
|
|
75
|
+
const nameComp = nameA.localeCompare(nameB)
|
|
76
|
+
if (nameComp !== 0) return nameComp
|
|
77
|
+
return (a.id || "").localeCompare(b.id || "")
|
|
78
|
+
})
|
|
79
|
+
|
|
68
80
|
const allChannelIds = salesChannels.map((sc) => sc.id)
|
|
69
81
|
|
|
70
82
|
const enriched = categories.map((cat) => {
|
|
@@ -96,7 +108,7 @@ export const GET = async (
|
|
|
96
108
|
id: cat.id,
|
|
97
109
|
name: cat.name,
|
|
98
110
|
handle: cat.handle,
|
|
99
|
-
description: cat.description || null,
|
|
111
|
+
description: cat.description || cat.metadata?.description || null,
|
|
100
112
|
parent_category_id: cat.parent_category_id || null,
|
|
101
113
|
rank: typeof cat.rank === "number" ? cat.rank : 0,
|
|
102
114
|
is_active: Boolean(cat.is_active),
|