@hectasquare/medusa-plugin-category-tree 1.0.12 → 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-tree-canvas.js +39 -2
- package/.medusa/server/src/admin/components/category-tree/edit-category-drawer.js +25 -9
- 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-tree-canvas.tsx +39 -2
- package/src/admin/components/category-tree/edit-category-drawer.tsx +25 -9
- 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
|
@@ -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) => {
|
|
@@ -51,7 +51,7 @@ 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));
|
|
@@ -362,9 +362,14 @@ const EditCategoryDrawer = ({ open, onOpenChange, category, salesChannels, onSuc
|
|
|
362
362
|
ui_1.toast.info("Video removed (remember to save)");
|
|
363
363
|
}
|
|
364
364
|
};
|
|
365
|
-
const handleSubmit = async (
|
|
366
|
-
|
|
367
|
-
|
|
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
|
+
}
|
|
368
373
|
if (!name.trim()) {
|
|
369
374
|
ui_1.toast.error("Category name is required");
|
|
370
375
|
return;
|
|
@@ -377,8 +382,10 @@ const EditCategoryDrawer = ({ open, onOpenChange, category, salesChannels, onSuc
|
|
|
377
382
|
const bannerImg = categoryImages.find((img) => img.type === "banner") ||
|
|
378
383
|
(categoryImages.length > 1 ? categoryImages.find((img) => img.url !== thumbImg?.url) : null) ||
|
|
379
384
|
null;
|
|
385
|
+
const descValue = description.trim();
|
|
380
386
|
const updatedMetadata = {
|
|
381
387
|
...(currentMetadata || {}),
|
|
388
|
+
description: descValue || null,
|
|
382
389
|
sales_channels: selectedChannels,
|
|
383
390
|
youtube_urls: youtubeUrls,
|
|
384
391
|
youtube_url: youtubeUrls[0] || null,
|
|
@@ -393,7 +400,7 @@ const EditCategoryDrawer = ({ open, onOpenChange, category, salesChannels, onSuc
|
|
|
393
400
|
await sdk_1.sdk.admin.productCategory.update(category.id, {
|
|
394
401
|
name: name.trim(),
|
|
395
402
|
handle: handle.trim() || undefined,
|
|
396
|
-
description:
|
|
403
|
+
description: descValue ? descValue : null,
|
|
397
404
|
rank: Number(rank) || 0,
|
|
398
405
|
is_active: isActive,
|
|
399
406
|
is_internal: isInternal,
|
|
@@ -403,7 +410,7 @@ const EditCategoryDrawer = ({ open, onOpenChange, category, salesChannels, onSuc
|
|
|
403
410
|
...category,
|
|
404
411
|
name: name.trim(),
|
|
405
412
|
handle: handle.trim(),
|
|
406
|
-
description:
|
|
413
|
+
description: descValue ? descValue : null,
|
|
407
414
|
image_url: thumbImg ? thumbImg.url : "",
|
|
408
415
|
rank: Number(rank) || 0,
|
|
409
416
|
sales_channels: selectedChannels,
|
|
@@ -413,7 +420,9 @@ const EditCategoryDrawer = ({ open, onOpenChange, category, salesChannels, onSuc
|
|
|
413
420
|
};
|
|
414
421
|
ui_1.toast.success("Category updated successfully");
|
|
415
422
|
onSuccess(updated);
|
|
416
|
-
|
|
423
|
+
if (shouldClose) {
|
|
424
|
+
onOpenChange(false);
|
|
425
|
+
}
|
|
417
426
|
}
|
|
418
427
|
catch (err) {
|
|
419
428
|
ui_1.toast.error(err?.message || "Failed to update category");
|
|
@@ -473,7 +482,7 @@ const EditCategoryDrawer = ({ open, onOpenChange, category, salesChannels, onSuc
|
|
|
473
482
|
? "ring-2 ring-blue-500 border-transparent"
|
|
474
483
|
: isBanner
|
|
475
484
|
? "ring-2 ring-emerald-500 border-transparent"
|
|
476
|
-
: "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) => {
|
|
477
486
|
e.preventDefault();
|
|
478
487
|
e.stopPropagation();
|
|
479
488
|
handleSetThumbnail(img);
|
|
@@ -500,6 +509,13 @@ const EditCategoryDrawer = ({ open, onOpenChange, category, salesChannels, onSuc
|
|
|
500
509
|
const id = extractYouTubeVideoId(url);
|
|
501
510
|
const thumb = id ? `https://img.youtube.com/vi/${id}/hqdefault.jpg` : null;
|
|
502
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));
|
|
503
|
-
}) }))] }))] }), (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" })] })] }) }) }));
|
|
504
520
|
};
|
|
505
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",
|
|
@@ -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
|
)
|
|
@@ -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))
|
|
@@ -411,8 +411,13 @@ export const EditCategoryDrawer: React.FC<EditCategoryDrawerProps> = ({
|
|
|
411
411
|
}
|
|
412
412
|
}
|
|
413
413
|
|
|
414
|
-
const handleSubmit = async (
|
|
415
|
-
|
|
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
|
+
}
|
|
416
421
|
if (!name.trim()) {
|
|
417
422
|
toast.error("Category name is required")
|
|
418
423
|
return
|
|
@@ -429,8 +434,11 @@ export const EditCategoryDrawer: React.FC<EditCategoryDrawerProps> = ({
|
|
|
429
434
|
(categoryImages.length > 1 ? categoryImages.find((img) => img.url !== thumbImg?.url) : null) ||
|
|
430
435
|
null
|
|
431
436
|
|
|
437
|
+
const descValue = description.trim()
|
|
438
|
+
|
|
432
439
|
const updatedMetadata: Record<string, any> = {
|
|
433
440
|
...(currentMetadata || {}),
|
|
441
|
+
description: descValue || null,
|
|
434
442
|
sales_channels: selectedChannels,
|
|
435
443
|
youtube_urls: youtubeUrls,
|
|
436
444
|
youtube_url: youtubeUrls[0] || null,
|
|
@@ -446,7 +454,7 @@ export const EditCategoryDrawer: React.FC<EditCategoryDrawerProps> = ({
|
|
|
446
454
|
await sdk.admin.productCategory.update(category.id, {
|
|
447
455
|
name: name.trim(),
|
|
448
456
|
handle: handle.trim() || undefined,
|
|
449
|
-
description:
|
|
457
|
+
description: descValue ? descValue : null,
|
|
450
458
|
rank: Number(rank) || 0,
|
|
451
459
|
is_active: isActive,
|
|
452
460
|
is_internal: isInternal,
|
|
@@ -457,7 +465,7 @@ export const EditCategoryDrawer: React.FC<EditCategoryDrawerProps> = ({
|
|
|
457
465
|
...category,
|
|
458
466
|
name: name.trim(),
|
|
459
467
|
handle: handle.trim(),
|
|
460
|
-
description:
|
|
468
|
+
description: descValue ? descValue : null,
|
|
461
469
|
image_url: thumbImg ? thumbImg.url : "",
|
|
462
470
|
rank: Number(rank) || 0,
|
|
463
471
|
sales_channels: selectedChannels,
|
|
@@ -468,7 +476,9 @@ export const EditCategoryDrawer: React.FC<EditCategoryDrawerProps> = ({
|
|
|
468
476
|
|
|
469
477
|
toast.success("Category updated successfully")
|
|
470
478
|
onSuccess(updated)
|
|
471
|
-
|
|
479
|
+
if (shouldClose) {
|
|
480
|
+
onOpenChange(false)
|
|
481
|
+
}
|
|
472
482
|
} catch (err: any) {
|
|
473
483
|
toast.error(err?.message || "Failed to update category")
|
|
474
484
|
} finally {
|
|
@@ -848,7 +858,7 @@ export const EditCategoryDrawer: React.FC<EditCategoryDrawerProps> = ({
|
|
|
848
858
|
<img
|
|
849
859
|
src={img.url}
|
|
850
860
|
alt="Category Media"
|
|
851
|
-
className="w-full h-full object-
|
|
861
|
+
className="w-full h-full object-contain p-1.5"
|
|
852
862
|
/>
|
|
853
863
|
|
|
854
864
|
{isThumbnail && (
|
|
@@ -1036,7 +1046,13 @@ export const EditCategoryDrawer: React.FC<EditCategoryDrawerProps> = ({
|
|
|
1036
1046
|
type="button"
|
|
1037
1047
|
variant="secondary"
|
|
1038
1048
|
size="small"
|
|
1039
|
-
onClick={() =>
|
|
1049
|
+
onClick={() => {
|
|
1050
|
+
if (name.trim()) {
|
|
1051
|
+
handleSubmit(true)
|
|
1052
|
+
} else {
|
|
1053
|
+
onOpenChange(false)
|
|
1054
|
+
}
|
|
1055
|
+
}}
|
|
1040
1056
|
disabled={isSubmitting || isDeleting}
|
|
1041
1057
|
>
|
|
1042
1058
|
Close
|
|
@@ -1044,7 +1060,7 @@ export const EditCategoryDrawer: React.FC<EditCategoryDrawerProps> = ({
|
|
|
1044
1060
|
<Button
|
|
1045
1061
|
type="button"
|
|
1046
1062
|
size="small"
|
|
1047
|
-
onClick={() => handleSubmit()}
|
|
1063
|
+
onClick={() => handleSubmit(false)}
|
|
1048
1064
|
isLoading={isSubmitting}
|
|
1049
1065
|
disabled={isSubmitting || !name.trim() || isDeleting}
|
|
1050
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),
|