@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.
@@ -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) => {
@@ -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 (e) => {
366
- if (e && e.preventDefault)
367
- 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
+ }
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: description.trim() || undefined,
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: description.trim() || null,
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
- onOpenChange(false);
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-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) => {
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: () => 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" })] })] }) }) }));
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
- // 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.12",
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) => 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
  )
@@ -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 (e?: React.FormEvent) => {
415
- 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
+ }
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: description.trim() || undefined,
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: description.trim() || null,
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
- onOpenChange(false)
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-cover"
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={() => onOpenChange(false)}
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
- // 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),