@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.
@@ -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 (index, direction) => {
130
- const targetIndex = direction === "up" ? index - 1 : index + 1;
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(index, 1);
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(index, "up");
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(index, "down");
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) => (0, tree_state_1.moveSiblingRank)(prev, id, "up"));
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) => (0, tree_state_1.moveSiblingRank)(prev, id, "down"));
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\/)([^?&\s]+)/i,
16
- /(?:youtube\.com\/watch\?.*v=)([^?&\s]+)/i,
17
- /(?:youtu\.be\/)([^?&\s]+)/i,
18
- /(?:youtube\.com\/embed\/)([^?&\s]+)/i,
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 (e) => {
356
- if (e && e.preventDefault)
357
- e.preventDefault();
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: description.trim() || undefined,
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: description.trim() || null,
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
- onOpenChange(false);
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-cover" }), 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) => {
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: () => onOpenChange(false), disabled: isSubmitting || isDeleting, children: "Close" }), (0, jsx_runtime_1.jsx)(ui_1.Button, { type: "button", size: "small", onClick: () => handleSubmit(), isLoading: isSubmitting, disabled: isSubmitting || !name.trim() || isDeleting, children: "Save Category" })] })] }) }) }));
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
- // Sort children by their category rank
37
- const getRank = (id) => {
38
- const raw = nodeMap.get(id)?.data?.category?.rank;
39
- return typeof raw === "number" ? raw : 0;
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((a, b) => getRank(a) - getRank(b) || a.localeCompare(b));
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"): CategoryTreeNode[];
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((a, b) => a.rank - b.rank);
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((a, b) => a.rank - b.rank);
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
- const adjacent = siblings[targetIndex];
208
- // Normalize ranks if they are identical or uninitialized
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
- siblings.forEach((s, idx) => {
211
- updatedRanks.set(s.id, idx * 10);
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
- // Swap target and adjacent
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.11",
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 (index: number, direction: "up" | "down") => {
138
- const targetIndex = direction === "up" ? index - 1 : index + 1
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(index, 1)
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(index, "up")
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(index, "down")
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) => moveSiblingRank(prev, id, "up"))
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) => moveSiblingRank(prev, id, "down"))
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\/)([^?&\s]+)/i,
31
- /(?:youtube\.com\/watch\?.*v=)([^?&\s]+)/i,
32
- /(?:youtu\.be\/)([^?&\s]+)/i,
33
- /(?:youtube\.com\/embed\/)([^?&\s]+)/i,
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 (e?: React.FormEvent) => {
407
- if (e && e.preventDefault) e.preventDefault()
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: description.trim() || undefined,
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: description.trim() || null,
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
- onOpenChange(false)
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-cover"
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={() => onOpenChange(false)}
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
- // Sort children by their category rank
60
- const getRank = (id: string): number => {
61
- const raw = (nodeMap.get(id)?.data as any)?.category?.rank
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
- (a, b) => getRank(a) - getRank(b) || a.localeCompare(b)
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((a, b) => a.rank - b.rank)
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
- ): CategoryTreeNode[] {
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((a, b) => a.rank - b.rank)
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
- const adjacent = siblings[targetIndex]
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
- siblings.forEach((s, idx) => {
264
- updatedRanks.set(s.id, idx * 10)
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
- // Swap target and adjacent
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),