@hectasquare/medusa-plugin-category-tree 1.0.0 → 1.0.2

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.
Files changed (23) hide show
  1. package/.medusa/server/src/admin/components/category-tree/canvas-dock.d.ts +5 -0
  2. package/.medusa/server/src/admin/components/category-tree/canvas-dock.js +4 -2
  3. package/.medusa/server/src/admin/components/category-tree/category-node.js +22 -13
  4. package/.medusa/server/src/admin/components/category-tree/category-tree-canvas.js +43 -4
  5. package/.medusa/server/src/admin/components/category-tree/edit-category-drawer.js +5 -1
  6. package/.medusa/server/src/admin/components/category-tree/lib/layout-engine.d.ts +9 -1
  7. package/.medusa/server/src/admin/components/category-tree/lib/layout-engine.js +116 -30
  8. package/.medusa/server/src/admin/components/category-tree/lib/tree-state.d.ts +14 -0
  9. package/.medusa/server/src/admin/components/category-tree/lib/tree-state.js +95 -0
  10. package/.medusa/server/src/admin/components/category-tree/tier-bars.d.ts +4 -2
  11. package/.medusa/server/src/admin/components/category-tree/tier-bars.js +9 -4
  12. package/.medusa/server/src/admin/components/category-tree/types.d.ts +4 -0
  13. package/.medusa/server/src/admin/routes/category-tree/page.js +2 -1
  14. package/package.json +1 -1
  15. package/src/admin/components/category-tree/canvas-dock.tsx +93 -45
  16. package/src/admin/components/category-tree/category-node.tsx +77 -41
  17. package/src/admin/components/category-tree/category-tree-canvas.tsx +62 -6
  18. package/src/admin/components/category-tree/edit-category-drawer.tsx +54 -0
  19. package/src/admin/components/category-tree/lib/layout-engine.ts +152 -25
  20. package/src/admin/components/category-tree/lib/tree-state.ts +112 -0
  21. package/src/admin/components/category-tree/tier-bars.tsx +12 -5
  22. package/src/admin/components/category-tree/types.ts +4 -0
  23. package/src/admin/routes/category-tree/page.tsx +6 -1
@@ -11,6 +11,11 @@ export interface CanvasDockProps {
11
11
  onExpandAll: () => void;
12
12
  onCollapseAll: () => void;
13
13
  onFocusRoot: () => void;
14
+ selectedCount?: number;
15
+ onExpandSelected?: () => void;
16
+ onCollapseSelected?: () => void;
17
+ isBoxSelect?: boolean;
18
+ onToggleBoxSelect?: () => void;
14
19
  onZoomIn: () => void;
15
20
  onZoomOut: () => void;
16
21
  onFitView: () => void;
@@ -4,7 +4,9 @@ exports.CanvasDock = void 0;
4
4
  const jsx_runtime_1 = require("react/jsx-runtime");
5
5
  const ui_1 = require("@medusajs/ui");
6
6
  const icons_1 = require("@medusajs/icons");
7
- const CanvasDock = ({ salesChannels, selectedStorefront, onSelectStorefront, direction, onToggleDirection, searchQuery, onSearchChange, onExpandAll, onCollapseAll, onFocusRoot, onFitView, onOpenCreateRoot, dirtyCount, isSaving, onSave, onReset, }) => {
8
- return ((0, jsx_runtime_1.jsxs)("div", { className: "flex flex-col gap-2 items-center pointer-events-auto", children: [(0, jsx_runtime_1.jsxs)("div", { className: "flex items-center gap-2.5 px-3.5 py-2 bg-white/90 dark:bg-neutral-900/90 backdrop-blur-md border border-ui-border-base rounded-2xl shadow-xl transition-all", children: [(0, jsx_runtime_1.jsxs)("div", { className: "flex items-center gap-1.5 border-r border-ui-border-base pr-2.5", children: [(0, jsx_runtime_1.jsx)("span", { className: "text-[11px] font-semibold text-ui-fg-muted uppercase tracking-wider", children: "Store:" }), (0, jsx_runtime_1.jsxs)("select", { value: selectedStorefront, onChange: (e) => onSelectStorefront(e.target.value), className: "text-xs bg-ui-bg-subtle text-ui-fg-base border border-ui-border-base rounded-lg px-2 py-1 font-medium focus:outline-none cursor-pointer", children: [(0, jsx_runtime_1.jsx)("option", { value: "all", children: "All Storefronts" }), salesChannels.map((sc) => ((0, jsx_runtime_1.jsx)("option", { value: sc.id, children: sc.name }, sc.id)))] })] }), (0, jsx_runtime_1.jsx)("div", { className: "flex items-center border-r border-ui-border-base pr-2.5", children: (0, jsx_runtime_1.jsx)(ui_1.Tooltip, { content: direction === "TB" ? "Switch to Mind Map (Left-to-Right)" : "Switch to Vertical Tree (Top-to-Bottom)", children: (0, jsx_runtime_1.jsx)("button", { type: "button", onClick: onToggleDirection, className: "flex items-center gap-1.5 px-2.5 py-1 text-xs font-medium rounded-lg bg-ui-bg-subtle text-ui-fg-base hover:bg-ui-bg-base border border-ui-border-base transition-colors", children: (0, jsx_runtime_1.jsx)("span", { children: direction === "TB" ? "⬇ Vertical" : "➡ Mind Map" }) }) }) }), (0, jsx_runtime_1.jsxs)("div", { className: "relative w-40 sm:w-48", children: [(0, jsx_runtime_1.jsx)("input", { type: "text", placeholder: "Search categories\u2026", value: searchQuery, onChange: (e) => onSearchChange(e.target.value), className: "w-full text-xs pl-7 pr-2.5 py-1 rounded-lg bg-ui-bg-subtle border border-ui-border-base text-ui-fg-base focus:bg-ui-bg-base focus:border-ui-border-interactive focus:outline-none transition-all placeholder:text-ui-fg-muted" }), (0, jsx_runtime_1.jsx)(icons_1.MagnifyingGlass, { className: "absolute left-2 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-ui-fg-muted pointer-events-none" })] }), (0, jsx_runtime_1.jsxs)("div", { className: "flex items-center gap-1 border-l border-ui-border-base pl-2.5", children: [(0, jsx_runtime_1.jsx)(ui_1.Tooltip, { content: "Expand All Categories", children: (0, jsx_runtime_1.jsx)("button", { type: "button", onClick: onExpandAll, className: "px-2 py-1 text-xs font-medium text-ui-fg-subtle hover:text-ui-fg-base hover:bg-ui-bg-subtle rounded-md transition-colors", children: "Expand" }) }), (0, jsx_runtime_1.jsx)(ui_1.Tooltip, { content: "Collapse All Subcategories", children: (0, jsx_runtime_1.jsx)("button", { type: "button", onClick: onCollapseAll, className: "px-2 py-1 text-xs font-medium text-ui-fg-subtle hover:text-ui-fg-base hover:bg-ui-bg-subtle rounded-md transition-colors", children: "Collapse" }) }), (0, jsx_runtime_1.jsx)(ui_1.Tooltip, { content: "Focus on Root Categories", children: (0, jsx_runtime_1.jsx)("button", { type: "button", onClick: onFocusRoot, className: "px-2 py-1 text-xs font-medium text-ui-fg-subtle hover:text-ui-fg-base hover:bg-ui-bg-subtle rounded-md transition-colors", children: "Root" }) })] }), (0, jsx_runtime_1.jsx)("div", { className: "flex items-center gap-1 border-l border-ui-border-base pl-2.5", children: (0, jsx_runtime_1.jsx)(ui_1.Tooltip, { content: "Fit View (Center)", children: (0, jsx_runtime_1.jsx)("button", { type: "button", onClick: onFitView, className: "p-1 text-ui-fg-subtle hover:text-ui-fg-base hover:bg-ui-bg-subtle rounded-md transition-colors", children: (0, jsx_runtime_1.jsx)(icons_1.ArrowsPointingOut, { className: "w-3.5 h-3.5" }) }) }) }), (0, jsx_runtime_1.jsx)("div", { className: "border-l border-ui-border-base pl-2.5", children: (0, jsx_runtime_1.jsxs)(ui_1.Button, { size: "small", variant: "secondary", onClick: onOpenCreateRoot, className: "flex items-center gap-1 text-xs h-7", children: [(0, jsx_runtime_1.jsx)(icons_1.Plus, { className: "w-3.5 h-3.5" }), (0, jsx_runtime_1.jsx)("span", { children: "New Root" })] }) })] }), dirtyCount > 0 && ((0, jsx_runtime_1.jsxs)("div", { className: "flex items-center gap-3 px-4 py-1.5 bg-blue-600 text-white rounded-full shadow-lg text-xs font-medium animate-in fade-in slide-in-from-top-2 duration-200", children: [(0, jsx_runtime_1.jsxs)("span", { className: "flex items-center gap-1.5", children: [(0, jsx_runtime_1.jsx)("span", { className: "w-2 h-2 rounded-full bg-white animate-pulse" }), (0, jsx_runtime_1.jsxs)("span", { children: [dirtyCount, " categor", dirtyCount === 1 ? "y" : "ies", " repositioned"] })] }), (0, jsx_runtime_1.jsxs)("div", { className: "flex items-center gap-1 border-l border-blue-400 pl-2", children: [(0, jsx_runtime_1.jsx)("button", { type: "button", onClick: onReset, disabled: isSaving, className: "px-2 py-0.5 rounded hover:bg-blue-700 transition-colors text-blue-100 hover:text-white", children: "Reset" }), (0, jsx_runtime_1.jsx)("button", { type: "button", onClick: onSave, disabled: isSaving, className: "px-3 py-0.5 rounded-full bg-white text-blue-700 font-semibold hover:bg-blue-50 transition-colors shadow-xs", children: isSaving ? "Saving…" : "Save Hierarchy" })] })] }))] }));
7
+ const CanvasDock = ({ salesChannels, selectedStorefront, onSelectStorefront, direction, onToggleDirection, searchQuery, onSearchChange, onExpandAll, onCollapseAll, onFocusRoot, selectedCount = 0, onExpandSelected, onCollapseSelected, isBoxSelect = false, onToggleBoxSelect, onFitView, onOpenCreateRoot, dirtyCount, isSaving, onSave, onReset, }) => {
8
+ return ((0, jsx_runtime_1.jsxs)("div", { className: "flex flex-col gap-2 items-center pointer-events-auto", children: [(0, jsx_runtime_1.jsxs)("div", { className: "flex items-center gap-2.5 px-3.5 py-2 bg-white/90 dark:bg-neutral-900/90 backdrop-blur-md border border-ui-border-base rounded-2xl shadow-xl transition-all", children: [(0, jsx_runtime_1.jsxs)("div", { className: "flex items-center gap-1.5 border-r border-ui-border-base pr-2.5", children: [(0, jsx_runtime_1.jsx)("span", { className: "text-[11px] font-semibold text-ui-fg-muted uppercase tracking-wider", children: "Store:" }), (0, jsx_runtime_1.jsxs)("select", { value: selectedStorefront, onChange: (e) => onSelectStorefront(e.target.value), className: "text-xs bg-ui-bg-subtle text-ui-fg-base border border-ui-border-base rounded-lg px-2 py-1 font-medium focus:outline-none cursor-pointer", children: [(0, jsx_runtime_1.jsx)("option", { value: "all", children: "All Storefronts" }), salesChannels.map((sc) => ((0, jsx_runtime_1.jsx)("option", { value: sc.id, children: sc.name }, sc.id)))] })] }), (0, jsx_runtime_1.jsx)("div", { className: "flex items-center border-r border-ui-border-base pr-2.5", children: (0, jsx_runtime_1.jsx)("button", { type: "button", title: direction === "TB" ? "Switch to Mind Map (Left-to-Right)" : "Switch to Vertical Tree (Top-to-Bottom)", onClick: onToggleDirection, className: "flex items-center gap-1.5 px-2.5 py-1 text-xs font-medium rounded-lg bg-ui-bg-subtle text-ui-fg-base hover:bg-ui-bg-base border border-ui-border-base transition-colors", children: (0, jsx_runtime_1.jsx)("span", { children: direction === "TB" ? "⬇ Vertical" : "➡ Mind Map" }) }) }), (0, jsx_runtime_1.jsxs)("div", { className: "relative w-36 sm:w-44", children: [(0, jsx_runtime_1.jsx)("input", { type: "text", placeholder: "Search categories\u2026", value: searchQuery, onChange: (e) => onSearchChange(e.target.value), className: "w-full text-xs pl-7 pr-2.5 py-1 rounded-lg bg-ui-bg-subtle border border-ui-border-base text-ui-fg-base focus:bg-ui-bg-base focus:border-ui-border-interactive focus:outline-none transition-all placeholder:text-ui-fg-muted" }), (0, jsx_runtime_1.jsx)(icons_1.MagnifyingGlass, { className: "absolute left-2 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-ui-fg-muted pointer-events-none" })] }), (0, jsx_runtime_1.jsxs)("div", { className: "flex items-center gap-1 border-l border-ui-border-base pl-2.5", children: [selectedCount > 0 ? ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsxs)("button", { type: "button", title: `Expand selected category (${selectedCount} selected)`, onClick: onExpandSelected || onExpandAll, className: "px-2 py-1 text-xs font-medium bg-ui-bg-interactive text-ui-fg-on-color rounded-md transition-colors", children: ["Expand (", selectedCount, ")"] }), (0, jsx_runtime_1.jsxs)("button", { type: "button", title: `Collapse selected category (${selectedCount} selected)`, onClick: onCollapseSelected || onCollapseAll, className: "px-2 py-1 text-xs font-medium bg-ui-bg-subtle text-ui-fg-base hover:bg-ui-bg-base border border-ui-border-base rounded-md transition-colors", children: ["Collapse (", selectedCount, ")"] })] })) : ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsx)("button", { type: "button", title: "Expand All Categories", onClick: onExpandAll, className: "px-2 py-1 text-xs font-medium text-ui-fg-subtle hover:text-ui-fg-base hover:bg-ui-bg-subtle rounded-md transition-colors", children: "Expand All" }), (0, jsx_runtime_1.jsx)("button", { type: "button", title: "Collapse All Subcategories", onClick: onCollapseAll, className: "px-2 py-1 text-xs font-medium text-ui-fg-subtle hover:text-ui-fg-base hover:bg-ui-bg-subtle rounded-md transition-colors", children: "Collapse All" })] })), (0, jsx_runtime_1.jsx)("button", { type: "button", title: "Focus on Root Categories", onClick: onFocusRoot, className: "px-2 py-1 text-xs font-medium text-ui-fg-subtle hover:text-ui-fg-base hover:bg-ui-bg-subtle rounded-md transition-colors", children: "Root" })] }), onToggleBoxSelect && ((0, jsx_runtime_1.jsx)("div", { className: "flex items-center border-l border-ui-border-base pl-2.5", children: (0, jsx_runtime_1.jsx)("button", { type: "button", title: isBoxSelect ? "Box Selection Active (Drag to outline cards). Click to switch to Pan" : "Click to enable Box Select marquee (Outline cards to move together)", onClick: onToggleBoxSelect, className: `px-2 py-1 text-xs font-medium rounded-md transition-colors ${isBoxSelect
9
+ ? "bg-blue-600 text-white shadow-xs"
10
+ : "text-ui-fg-subtle hover:text-ui-fg-base hover:bg-ui-bg-subtle"}`, children: "\u26F6 Box Select" }) })), (0, jsx_runtime_1.jsx)("div", { className: "flex items-center gap-1 border-l border-ui-border-base pl-2.5", children: (0, jsx_runtime_1.jsx)("button", { type: "button", title: "Fit View (Center)", onClick: onFitView, className: "p-1 text-ui-fg-subtle hover:text-ui-fg-base hover:bg-ui-bg-subtle rounded-md transition-colors", children: (0, jsx_runtime_1.jsx)(icons_1.ArrowsPointingOut, { className: "w-3.5 h-3.5" }) }) }), (0, jsx_runtime_1.jsx)("div", { className: "border-l border-ui-border-base pl-2.5", children: (0, jsx_runtime_1.jsxs)(ui_1.Button, { size: "small", variant: "secondary", onClick: onOpenCreateRoot, className: "flex items-center gap-1 text-xs h-7", children: [(0, jsx_runtime_1.jsx)(icons_1.Plus, { className: "w-3.5 h-3.5" }), (0, jsx_runtime_1.jsx)("span", { children: "New Root" })] }) })] }), dirtyCount > 0 && ((0, jsx_runtime_1.jsxs)("div", { className: "flex items-center gap-3 px-4 py-1.5 bg-blue-600 text-white rounded-full shadow-lg text-xs font-medium animate-in fade-in slide-in-from-top-2 duration-200", children: [(0, jsx_runtime_1.jsxs)("span", { className: "flex items-center gap-1.5", children: [(0, jsx_runtime_1.jsx)("span", { className: "w-2 h-2 rounded-full bg-white animate-pulse" }), (0, jsx_runtime_1.jsxs)("span", { children: [dirtyCount, " categor", dirtyCount === 1 ? "y" : "ies", " repositioned"] })] }), (0, jsx_runtime_1.jsxs)("div", { className: "flex items-center gap-1 border-l border-blue-400 pl-2", children: [(0, jsx_runtime_1.jsx)("button", { type: "button", onClick: onReset, disabled: isSaving, className: "px-2 py-0.5 rounded hover:bg-blue-700 transition-colors text-blue-100 hover:text-white", children: "Reset" }), (0, jsx_runtime_1.jsx)("button", { type: "button", onClick: onSave, disabled: isSaving, className: "px-3 py-0.5 rounded-full bg-white text-blue-700 font-semibold hover:bg-blue-50 transition-colors shadow-xs", children: isSaving ? "Saving…" : "Save Hierarchy" })] })] }))] }));
9
11
  };
10
12
  exports.CanvasDock = CanvasDock;
@@ -4,11 +4,10 @@ exports.CategoryNode = void 0;
4
4
  const jsx_runtime_1 = require("react/jsx-runtime");
5
5
  const react_1 = require("react");
6
6
  const react_2 = require("@xyflow/react");
7
- const ui_1 = require("@medusajs/ui");
8
7
  const icons_1 = require("@medusajs/icons");
9
8
  const tier_bars_1 = require("./tier-bars");
10
9
  exports.CategoryNode = (0, react_1.memo)(({ id, data, selected }) => {
11
- const { category, tier, superscriptRank, salesChannels, isCollapsed, hiddenDescendantCount, direction, isDimmed, onToggleCollapse, onAddChild, onEdit, onDetach, } = data;
10
+ const { category, tier, siblingRank, superscriptRank, branchColor, salesChannels, isCollapsed, hiddenDescendantCount, direction, isDimmed, onToggleCollapse, onAddChild, onEdit, onDetach, onMoveSiblingUp, onMoveSiblingDown, } = data;
12
11
  const isTB = direction === "TB";
13
12
  const hasChildren = category.has_children || category.children_count > 0;
14
13
  const assignedStorefrontNames = (category.sales_channels || []).map((scId) => {
@@ -17,7 +16,17 @@ exports.CategoryNode = (0, react_1.memo)(({ id, data, selected }) => {
17
16
  });
18
17
  return ((0, jsx_runtime_1.jsxs)("div", { className: `group relative flex items-stretch w-[270px] h-[86px] rounded-xl border bg-ui-bg-base transition-all duration-150 select-none ${selected
19
18
  ? "ring-2 ring-ui-border-interactive border-transparent shadow-lg"
20
- : "border-ui-border-base shadow-sm hover:shadow-md hover:border-ui-border-strong"} ${isDimmed ? "opacity-35 grayscale" : "opacity-100"}`, children: [(0, jsx_runtime_1.jsx)(react_2.Handle, { type: "target", position: isTB ? react_2.Position.Top : react_2.Position.Left, className: "!w-2.5 !h-2.5 !bg-ui-border-interactive !border-2 !border-white dark:!border-neutral-900 transition-transform group-hover:scale-125" }), (0, jsx_runtime_1.jsx)(tier_bars_1.TierBars, { tier: tier }), (0, jsx_runtime_1.jsxs)("div", { className: "flex flex-1 items-center gap-2.5 px-2.5 py-2 min-w-0 overflow-hidden", children: [(0, jsx_runtime_1.jsxs)("div", { className: "relative w-11 h-11 rounded-lg overflow-hidden flex-shrink-0 border border-ui-border-base bg-ui-bg-subtle flex items-center justify-center shadow-2xs", children: [category.image_url ? ((0, jsx_runtime_1.jsx)("img", { src: category.image_url, alt: category.name, className: "w-full h-full object-cover", loading: "lazy" })) : ((0, jsx_runtime_1.jsx)("div", { className: "w-full h-full flex items-center justify-center bg-gradient-to-br from-neutral-100 to-neutral-200 dark:from-neutral-800 dark:to-neutral-900 text-ui-fg-muted", children: (0, jsx_runtime_1.jsx)(icons_1.Folder, { className: "w-5 h-5 opacity-70" }) })), (0, jsx_runtime_1.jsx)("span", { className: `absolute bottom-0.5 right-0.5 w-2 h-2 rounded-full border border-white dark:border-neutral-900 ${category.is_active ? "bg-emerald-500" : "bg-neutral-400"}`, title: category.is_active ? "Active" : "Inactive" })] }), (0, jsx_runtime_1.jsxs)("div", { className: "flex flex-col min-w-0 flex-1 justify-center", children: [(0, jsx_runtime_1.jsxs)("div", { className: "flex items-center gap-1.5 min-w-0", children: [(0, jsx_runtime_1.jsx)("span", { className: "text-xs font-semibold text-ui-fg-base truncate leading-snug", title: category.name, children: category.name }), superscriptRank && ((0, jsx_runtime_1.jsx)("span", { className: "text-[11px] font-bold font-mono text-ui-fg-subtle px-1 py-0.5 rounded bg-ui-bg-subtle/80 flex-shrink-0", title: `Rank #${data.siblingRank} among siblings`, children: superscriptRank }))] }), (0, jsx_runtime_1.jsxs)("span", { className: "text-[11px] text-ui-fg-muted truncate leading-tight mt-0.5", children: ["/", category.handle] }), (0, jsx_runtime_1.jsxs)("div", { className: "flex items-center gap-1 mt-1 flex-wrap", children: [assignedStorefrontNames.slice(0, 2).map((name) => {
19
+ : "border-ui-border-base shadow-sm hover:shadow-md hover:border-ui-border-strong"} ${isDimmed ? "opacity-35 grayscale" : "opacity-100"}`, style: selected && branchColor
20
+ ? {
21
+ boxShadow: `0 0 0 2px ${branchColor}, 0 10px 15px -3px rgba(0, 0, 0, 0.1)`,
22
+ }
23
+ : undefined, children: [(0, jsx_runtime_1.jsx)(react_2.Handle, { type: "target", position: isTB ? react_2.Position.Top : react_2.Position.Left, className: "!w-2.5 !h-2.5 !bg-ui-border-interactive !border-2 !border-white dark:!border-neutral-900 transition-transform group-hover:scale-125" }), (0, jsx_runtime_1.jsx)(tier_bars_1.TierBars, { tier: tier, color: branchColor }), (0, jsx_runtime_1.jsxs)("div", { className: "flex flex-1 items-center gap-2.5 px-2.5 py-2 min-w-0 overflow-hidden", children: [(0, jsx_runtime_1.jsxs)("div", { className: "relative w-11 h-11 rounded-lg overflow-hidden flex-shrink-0 border border-ui-border-base bg-ui-bg-subtle flex items-center justify-center shadow-2xs", children: [category.image_url ? ((0, jsx_runtime_1.jsx)("img", { src: category.image_url, alt: category.name, className: "w-full h-full object-cover", loading: "lazy" })) : ((0, jsx_runtime_1.jsx)("div", { className: "w-full h-full flex items-center justify-center bg-gradient-to-br from-neutral-100 to-neutral-200 dark:from-neutral-800 dark:to-neutral-900 text-ui-fg-muted", children: (0, jsx_runtime_1.jsx)(icons_1.Folder, { className: "w-5 h-5 opacity-70" }) })), (0, jsx_runtime_1.jsx)("span", { className: `absolute bottom-0.5 right-0.5 w-2 h-2 rounded-full border border-white dark:border-neutral-900 ${category.is_active ? "bg-emerald-500" : "bg-neutral-400"}`, title: category.is_active ? "Active" : "Inactive" })] }), (0, jsx_runtime_1.jsxs)("div", { className: "flex flex-col min-w-0 flex-1 justify-center", children: [(0, jsx_runtime_1.jsxs)("div", { className: "flex items-center gap-1 min-w-0", children: [(0, jsx_runtime_1.jsx)("span", { className: "text-xs font-semibold text-ui-fg-base truncate leading-snug flex-1 min-w-0", title: category.name, children: category.name }), superscriptRank && ((0, jsx_runtime_1.jsxs)("div", { className: "flex items-center gap-0.5 flex-shrink-0", children: [(0, jsx_runtime_1.jsx)("span", { className: "text-[11px] font-bold font-mono text-ui-fg-subtle px-1 py-0.5 rounded bg-ui-bg-subtle/80", title: `Rank #${siblingRank} among siblings (Storefront sort order)`, children: superscriptRank }), onMoveSiblingUp && ((0, jsx_runtime_1.jsx)("button", { type: "button", title: "Move Up in Storefront Order (\u2191)", onClick: (e) => {
24
+ e.stopPropagation();
25
+ onMoveSiblingUp(id);
26
+ }, className: "w-3.5 h-3.5 flex items-center justify-center text-[10px] font-bold text-ui-fg-muted hover:text-ui-fg-base hover:bg-ui-bg-subtle rounded transition-colors", children: "\u2191" })), onMoveSiblingDown && ((0, jsx_runtime_1.jsx)("button", { type: "button", title: "Move Down in Storefront Order (\u2193)", onClick: (e) => {
27
+ e.stopPropagation();
28
+ onMoveSiblingDown(id);
29
+ }, className: "w-3.5 h-3.5 flex items-center justify-center text-[10px] font-bold text-ui-fg-muted hover:text-ui-fg-base hover:bg-ui-bg-subtle rounded transition-colors", children: "\u2193" }))] }))] }), (0, jsx_runtime_1.jsxs)("span", { className: "text-[11px] text-ui-fg-muted truncate leading-tight mt-0.5", children: ["/", category.handle] }), (0, jsx_runtime_1.jsxs)("div", { className: "flex items-center gap-1 mt-1 flex-wrap", children: [assignedStorefrontNames.slice(0, 2).map((name) => {
21
30
  const isSuperpunch = name.toLowerCase().includes("superpunch");
22
31
  const isMonfil = name.toLowerCase().includes("monfil");
23
32
  const badgeText = isSuperpunch ? "SP" : isMonfil ? "M" : name.slice(0, 2).toUpperCase();
@@ -27,15 +36,15 @@ exports.CategoryNode = (0, react_1.memo)(({ id, data, selected }) => {
27
36
  onToggleCollapse(id);
28
37
  }, className: `ml-auto px-1.5 py-0.5 rounded text-[10px] font-medium flex items-center gap-0.5 transition-colors ${isCollapsed
29
38
  ? "bg-blue-50 text-blue-700 border border-blue-200 dark:bg-blue-950/60 dark:text-blue-300 dark:border-blue-800"
30
- : "bg-neutral-100 text-neutral-600 hover:bg-neutral-200 dark:bg-neutral-800 dark:text-neutral-300"}`, title: isCollapsed ? "Click to expand subcategories" : "Click to collapse subcategories", children: [(0, jsx_runtime_1.jsx)("span", { children: isCollapsed ? "▸" : "▾" }), (0, jsx_runtime_1.jsx)("span", { children: isCollapsed ? `+${hiddenDescendantCount || category.children_count}` : category.children_count || "sub" })] }))] })] })] }), (0, jsx_runtime_1.jsxs)("div", { className: "absolute -top-3 right-2 hidden group-hover:flex items-center gap-1 px-1.5 py-0.5 bg-ui-bg-base/95 backdrop-blur-sm border border-ui-border-base rounded-full shadow-md z-20", children: [(0, jsx_runtime_1.jsx)(ui_1.Tooltip, { content: "Add Subcategory", children: (0, jsx_runtime_1.jsx)("button", { type: "button", onClick: (e) => {
31
- e.stopPropagation();
32
- onAddChild(category);
33
- }, className: "p-1 text-ui-fg-subtle hover:text-ui-fg-base hover:bg-ui-bg-subtle rounded-full transition-colors", children: (0, jsx_runtime_1.jsx)(icons_1.Plus, { className: "w-3.5 h-3.5" }) }) }), (0, jsx_runtime_1.jsx)(ui_1.Tooltip, { content: "Edit Category", children: (0, jsx_runtime_1.jsx)("button", { type: "button", onClick: (e) => {
34
- e.stopPropagation();
35
- onEdit(category);
36
- }, className: "p-1 text-ui-fg-subtle hover:text-ui-fg-base hover:bg-ui-bg-subtle rounded-full transition-colors", children: (0, jsx_runtime_1.jsx)(icons_1.PencilSquare, { className: "w-3.5 h-3.5" }) }) }), category.parent_category_id && ((0, jsx_runtime_1.jsx)(ui_1.Tooltip, { content: "Promote to Root (Detach)", children: (0, jsx_runtime_1.jsx)("button", { type: "button", onClick: (e) => {
37
- e.stopPropagation();
38
- onDetach(id);
39
- }, className: "p-1 text-ui-fg-subtle hover:text-amber-600 hover:bg-amber-50 dark:hover:bg-amber-950/50 rounded-full transition-colors", children: (0, jsx_runtime_1.jsx)(icons_1.ArrowUturnLeft, { className: "w-3.5 h-3.5" }) }) }))] }), (0, jsx_runtime_1.jsx)(react_2.Handle, { type: "source", position: isTB ? react_2.Position.Bottom : react_2.Position.Right, className: "!w-2.5 !h-2.5 !bg-ui-border-interactive !border-2 !border-white dark:!border-neutral-900 transition-transform group-hover:scale-125" })] }));
39
+ : "bg-neutral-100 text-neutral-600 hover:bg-neutral-200 dark:bg-neutral-800 dark:text-neutral-300"}`, title: isCollapsed ? "Click to expand subcategories" : "Click to collapse subcategories", children: [(0, jsx_runtime_1.jsx)("span", { children: isCollapsed ? "▸" : "▾" }), (0, jsx_runtime_1.jsx)("span", { children: isCollapsed ? `+${hiddenDescendantCount || category.children_count}` : category.children_count || "sub" })] }))] })] })] }), (0, jsx_runtime_1.jsxs)("div", { className: "absolute -top-3 right-2 hidden group-hover:flex items-center gap-1 px-1.5 py-0.5 bg-ui-bg-base/95 backdrop-blur-sm border border-ui-border-base rounded-full shadow-md z-20", children: [(0, jsx_runtime_1.jsx)("button", { type: "button", title: "Add Subcategory", onClick: (e) => {
40
+ e.stopPropagation();
41
+ onAddChild(category);
42
+ }, className: "p-1 text-ui-fg-subtle hover:text-ui-fg-base hover:bg-ui-bg-subtle rounded-full transition-colors", children: (0, jsx_runtime_1.jsx)(icons_1.Plus, { className: "w-3.5 h-3.5" }) }), (0, jsx_runtime_1.jsx)("button", { type: "button", title: "Edit Category", onClick: (e) => {
43
+ e.stopPropagation();
44
+ onEdit(category);
45
+ }, className: "p-1 text-ui-fg-subtle hover:text-ui-fg-base hover:bg-ui-bg-subtle rounded-full transition-colors", children: (0, jsx_runtime_1.jsx)(icons_1.PencilSquare, { className: "w-3.5 h-3.5" }) }), category.parent_category_id && ((0, jsx_runtime_1.jsx)("button", { type: "button", title: "Promote to Root (Detach)", onClick: (e) => {
46
+ e.stopPropagation();
47
+ onDetach(id);
48
+ }, className: "p-1 text-ui-fg-subtle hover:text-amber-600 hover:bg-amber-50 dark:hover:bg-amber-950/50 rounded-full transition-colors", children: (0, jsx_runtime_1.jsx)(icons_1.ArrowUturnLeft, { className: "w-3.5 h-3.5" }) }))] }), (0, jsx_runtime_1.jsx)(react_2.Handle, { type: "source", position: isTB ? react_2.Position.Bottom : react_2.Position.Right, className: "!w-2.5 !h-2.5 !bg-ui-border-interactive !border-2 !border-white dark:!border-neutral-900 transition-transform group-hover:scale-125" })] }));
40
49
  });
41
50
  exports.CategoryNode.displayName = "CategoryNode";
@@ -31,6 +31,8 @@ const CanvasInner = () => {
31
31
  const [selectedStorefront, setSelectedStorefront] = (0, react_1.useState)("all");
32
32
  const [searchQuery, setSearchQuery] = (0, react_1.useState)("");
33
33
  const [collapsedIds, setCollapsedIds] = (0, react_1.useState)(new Set());
34
+ const [isBoxSelect, setIsBoxSelect] = (0, react_1.useState)(false);
35
+ const [selectedNodeIds, setSelectedNodeIds] = (0, react_1.useState)([]);
34
36
  const [createDrawerOpen, setCreateDrawerOpen] = (0, react_1.useState)(false);
35
37
  const [createParentCategory, setCreateParentCategory] = (0, react_1.useState)(null);
36
38
  const [editDrawerOpen, setEditDrawerOpen] = (0, react_1.useState)(false);
@@ -66,6 +68,7 @@ const CanvasInner = () => {
66
68
  }
67
69
  const tiers = (0, tree_state_1.computeTiers)(categories);
68
70
  const siblingRanks = (0, tree_state_1.computeSiblingRanks)(categories);
71
+ const branchColors = (0, tree_state_1.computeBranchColors)(categories);
69
72
  const enriched = categories.map((cat) => {
70
73
  const kids = map.get(cat.id) || [];
71
74
  const rankInfo = siblingRanks.get(cat.id);
@@ -75,6 +78,7 @@ const CanvasInner = () => {
75
78
  sibling_rank: rankInfo?.rankNum || 1,
76
79
  children_count: kids.length,
77
80
  has_children: kids.length > 0,
81
+ branch_color: branchColors.get(cat.id),
78
82
  };
79
83
  });
80
84
  return { childrenMap: map, enrichedCategories: enriched };
@@ -91,6 +95,30 @@ const CanvasInner = () => {
91
95
  return next;
92
96
  });
93
97
  }, []);
98
+ const handleExpandSelected = (0, react_1.useCallback)(() => {
99
+ if (selectedNodeIds.length === 0)
100
+ return;
101
+ setCollapsedIds((prev) => {
102
+ const next = new Set(prev);
103
+ selectedNodeIds.forEach((id) => next.delete(id));
104
+ return next;
105
+ });
106
+ }, [selectedNodeIds]);
107
+ const handleCollapseSelected = (0, react_1.useCallback)(() => {
108
+ if (selectedNodeIds.length === 0)
109
+ return;
110
+ setCollapsedIds((prev) => {
111
+ const next = new Set(prev);
112
+ selectedNodeIds.forEach((id) => next.add(id));
113
+ return next;
114
+ });
115
+ }, [selectedNodeIds]);
116
+ const handleMoveSiblingUp = (0, react_1.useCallback)((id) => {
117
+ setCategories((prev) => (0, tree_state_1.moveSiblingRank)(prev, id, "up"));
118
+ }, []);
119
+ const handleMoveSiblingDown = (0, react_1.useCallback)((id) => {
120
+ setCategories((prev) => (0, tree_state_1.moveSiblingRank)(prev, id, "down"));
121
+ }, []);
94
122
  const handleAddChild = (0, react_1.useCallback)((parent) => {
95
123
  setCreateParentCategory(parent);
96
124
  setCreateDrawerOpen(true);
@@ -127,6 +155,7 @@ const CanvasInner = () => {
127
155
  tier: cat.tier,
128
156
  siblingRank: rankInfo?.rankNum || 1,
129
157
  superscriptRank: rankInfo?.superscript || "¹",
158
+ branchColor: cat.branch_color,
130
159
  salesChannels,
131
160
  isCollapsed: collapsedIds.has(cat.id),
132
161
  hiddenDescendantCount: cat.children_count,
@@ -136,6 +165,8 @@ const CanvasInner = () => {
136
165
  onAddChild: handleAddChild,
137
166
  onEdit: handleEdit,
138
167
  onDetach: handleDetach,
168
+ onMoveSiblingUp: handleMoveSiblingUp,
169
+ onMoveSiblingDown: handleMoveSiblingDown,
139
170
  },
140
171
  };
141
172
  });
@@ -147,11 +178,15 @@ const CanvasInner = () => {
147
178
  source: cat.parent_category_id,
148
179
  target: cat.id,
149
180
  type: "smoothstep",
181
+ style: {
182
+ stroke: cat.branch_color || "#64748b",
183
+ strokeWidth: 2,
184
+ },
150
185
  });
151
186
  }
152
187
  }
153
188
  const { visibleNodes, visibleEdges } = (0, tree_state_1.getVisibleTree)(rawNodes, rawEdges, collapsedIds, childrenMap);
154
- const { nodes: layoutedNodes, edges: layoutedEdges } = (0, layout_engine_1.computeDagreLayout)(visibleNodes, visibleEdges, { direction });
189
+ const { nodes: layoutedNodes, edges: layoutedEdges } = (0, layout_engine_1.computeTreeLayout)(visibleNodes, visibleEdges, { direction });
155
190
  setNodes(layoutedNodes);
156
191
  setEdges(layoutedEdges);
157
192
  }, [
@@ -166,6 +201,8 @@ const CanvasInner = () => {
166
201
  handleAddChild,
167
202
  handleEdit,
168
203
  handleDetach,
204
+ handleMoveSiblingUp,
205
+ handleMoveSiblingDown,
169
206
  setNodes,
170
207
  setEdges,
171
208
  ]);
@@ -223,10 +260,12 @@ const CanvasInner = () => {
223
260
  setCategories([...originalCategories]);
224
261
  ui_1.toast.info("Changes reset to previous saved state");
225
262
  };
226
- return ((0, jsx_runtime_1.jsxs)("div", { className: "relative w-full h-[calc(100vh-56px)] overflow-hidden bg-[#fafafa] dark:bg-[#09090b]", children: [(0, jsx_runtime_1.jsx)("div", { className: "absolute top-4 left-1/2 -translate-x-1/2 z-30 pointer-events-none", children: (0, jsx_runtime_1.jsx)(canvas_dock_1.CanvasDock, { salesChannels: salesChannels, selectedStorefront: selectedStorefront, onSelectStorefront: setSelectedStorefront, direction: direction, onToggleDirection: () => setDirection((d) => (d === "TB" ? "LR" : "TB")), searchQuery: searchQuery, onSearchChange: setSearchQuery, onExpandAll: handleExpandAll, onCollapseAll: handleCollapseAll, onFocusRoot: handleFocusRoot, onZoomIn: () => zoomIn({ duration: 300 }), onZoomOut: () => zoomOut({ duration: 300 }), onFitView: () => fitView({ padding: 0.25, duration: 400 }), onOpenCreateRoot: () => {
263
+ return ((0, jsx_runtime_1.jsxs)("div", { className: "relative w-full h-[calc(100vh-56px)] overflow-hidden bg-[#fafafa] dark:bg-[#09090b]", children: [(0, jsx_runtime_1.jsx)("div", { className: "absolute top-4 left-1/2 -translate-x-1/2 z-30 pointer-events-none", children: (0, jsx_runtime_1.jsx)(canvas_dock_1.CanvasDock, { salesChannels: salesChannels, selectedStorefront: selectedStorefront, onSelectStorefront: setSelectedStorefront, direction: direction, onToggleDirection: () => setDirection((d) => (d === "TB" ? "LR" : "TB")), searchQuery: searchQuery, onSearchChange: setSearchQuery, onExpandAll: handleExpandAll, onCollapseAll: handleCollapseAll, onFocusRoot: handleFocusRoot, selectedCount: selectedNodeIds.length, onExpandSelected: handleExpandSelected, onCollapseSelected: handleCollapseSelected, isBoxSelect: isBoxSelect, onToggleBoxSelect: () => setIsBoxSelect((prev) => !prev), onZoomIn: () => zoomIn({ duration: 300 }), onZoomOut: () => zoomOut({ duration: 300 }), onFitView: () => fitView({ padding: 0.25, duration: 400 }), onOpenCreateRoot: () => {
227
264
  setCreateParentCategory(null);
228
265
  setCreateDrawerOpen(true);
229
- }, dirtyCount: dirtyMoves.length, isSaving: isSaving, onSave: handleSave, onReset: handleReset }) }), (0, jsx_runtime_1.jsxs)(react_2.ReactFlow, { nodes: nodes, edges: edges, onNodesChange: onNodesChange, onEdgesChange: onEdgesChange, onConnect: onConnect, onEdgesDelete: onEdgesDelete, nodeTypes: nodeTypes, edgeTypes: edgeTypes, minZoom: 0.2, maxZoom: 2.0, defaultEdgeOptions: { type: "smoothstep" }, fitView: true, proOptions: { hideAttribution: true }, className: "touch-none", children: [(0, jsx_runtime_1.jsx)(react_2.Background, { color: "#cbd5e1", gap: 20, size: 1.25 }), (0, jsx_runtime_1.jsx)(react_2.Controls, { showInteractive: false, className: "!bottom-4 !left-4 !bg-white/90 dark:!bg-neutral-900/90 !backdrop-blur-md !border !border-ui-border-base !rounded-xl !shadow-md !overflow-hidden" }), (0, jsx_runtime_1.jsx)(react_2.MiniMap, { nodeColor: () => "var(--bg-interactive, #3b82f6)", maskColor: "rgba(0, 0, 0, 0.08)", className: "!bottom-4 !right-4 !bg-white/90 dark:!bg-neutral-900/90 !backdrop-blur-md !border !border-ui-border-base !rounded-xl !shadow-md !overflow-hidden", zoomable: true, pannable: true })] }), (0, jsx_runtime_1.jsx)(create_category_drawer_1.CreateCategoryDrawer, { open: createDrawerOpen, onOpenChange: setCreateDrawerOpen, parentCategory: createParentCategory, salesChannels: salesChannels, onSuccess: (newCat) => {
266
+ }, dirtyCount: dirtyMoves.length, isSaving: isSaving, onSave: handleSave, onReset: handleReset }) }), (0, jsx_runtime_1.jsxs)(react_2.ReactFlow, { nodes: nodes, edges: edges, onNodesChange: onNodesChange, onEdgesChange: onEdgesChange, onConnect: onConnect, onEdgesDelete: onEdgesDelete, onSelectionChange: (0, react_1.useCallback)(({ nodes }) => {
267
+ setSelectedNodeIds(nodes.map((n) => n.id));
268
+ }, []), selectionMode: react_2.SelectionMode.Partial, selectionOnDrag: isBoxSelect, panOnDrag: isBoxSelect ? [1, 2] : true, elementsSelectable: true, nodeTypes: nodeTypes, edgeTypes: edgeTypes, minZoom: 0.2, maxZoom: 2.0, defaultEdgeOptions: { type: "smoothstep" }, fitView: true, proOptions: { hideAttribution: true }, className: "touch-none", children: [(0, jsx_runtime_1.jsx)(react_2.Background, { color: "#cbd5e1", gap: 20, size: 1.25 }), (0, jsx_runtime_1.jsx)(react_2.Controls, { showInteractive: false, className: "!bottom-4 !left-4 !bg-white/90 dark:!bg-neutral-900/90 !backdrop-blur-md !border !border-ui-border-base !rounded-xl !shadow-md !overflow-hidden" }), (0, jsx_runtime_1.jsx)(react_2.MiniMap, { nodeColor: () => "var(--bg-interactive, #3b82f6)", maskColor: "rgba(0, 0, 0, 0.08)", className: "!bottom-4 !right-4 !bg-white/90 dark:!bg-neutral-900/90 !backdrop-blur-md !border !border-ui-border-base !rounded-xl !shadow-md !overflow-hidden", zoomable: true, pannable: true })] }), (0, jsx_runtime_1.jsx)(create_category_drawer_1.CreateCategoryDrawer, { open: createDrawerOpen, onOpenChange: setCreateDrawerOpen, parentCategory: createParentCategory, salesChannels: salesChannels, onSuccess: (newCat) => {
230
269
  setCategories((prev) => [...prev, newCat]);
231
270
  setOriginalCategories((prev) => [...prev, newCat]);
232
271
  } }), (0, jsx_runtime_1.jsx)(edit_category_drawer_1.EditCategoryDrawer, { open: editDrawerOpen, onOpenChange: setEditDrawerOpen, category: editingCategory, salesChannels: salesChannels, onSuccess: (updatedCat) => {
@@ -238,6 +277,6 @@ const CanvasInner = () => {
238
277
  } })] }));
239
278
  };
240
279
  const CategoryTreeCanvas = () => {
241
- return ((0, jsx_runtime_1.jsx)(react_2.ReactFlowProvider, { children: (0, jsx_runtime_1.jsx)(CanvasInner, {}) }));
280
+ return ((0, jsx_runtime_1.jsx)(ui_1.TooltipProvider, { children: (0, jsx_runtime_1.jsx)(react_2.ReactFlowProvider, { children: (0, jsx_runtime_1.jsx)(CanvasInner, {}) }) }));
242
281
  };
243
282
  exports.CategoryTreeCanvas = CategoryTreeCanvas;
@@ -11,6 +11,7 @@ const EditCategoryDrawer = ({ open, onOpenChange, category, salesChannels, onSuc
11
11
  const [handle, setHandle] = (0, react_1.useState)("");
12
12
  const [description, setDescription] = (0, react_1.useState)("");
13
13
  const [imageUrl, setImageUrl] = (0, react_1.useState)("");
14
+ const [rank, setRank] = (0, react_1.useState)(0);
14
15
  const [isActive, setIsActive] = (0, react_1.useState)(true);
15
16
  const [isInternal, setIsInternal] = (0, react_1.useState)(false);
16
17
  const [selectedChannels, setSelectedChannels] = (0, react_1.useState)([]);
@@ -22,6 +23,7 @@ const EditCategoryDrawer = ({ open, onOpenChange, category, salesChannels, onSuc
22
23
  setHandle(category.handle || "");
23
24
  setDescription(category.description || "");
24
25
  setImageUrl(category.image_url || "");
26
+ setRank(category.rank ?? 0);
25
27
  setIsActive(Boolean(category.is_active));
26
28
  setIsInternal(Boolean(category.is_internal));
27
29
  setSelectedChannels(category.sales_channels || salesChannels.map((s) => s.id));
@@ -54,6 +56,7 @@ const EditCategoryDrawer = ({ open, onOpenChange, category, salesChannels, onSuc
54
56
  name: name.trim(),
55
57
  handle: handle.trim() || undefined,
56
58
  description: description.trim() || undefined,
59
+ rank: Number(rank) || 0,
57
60
  is_active: isActive,
58
61
  is_internal: isInternal,
59
62
  metadata: updatedMetadata,
@@ -64,6 +67,7 @@ const EditCategoryDrawer = ({ open, onOpenChange, category, salesChannels, onSuc
64
67
  handle: handle.trim(),
65
68
  description: description.trim() || null,
66
69
  image_url: imageUrl.trim() || category.image_url,
70
+ rank: Number(rank) || 0,
67
71
  sales_channels: selectedChannels,
68
72
  is_active: isActive,
69
73
  is_internal: isInternal,
@@ -98,7 +102,7 @@ const EditCategoryDrawer = ({ open, onOpenChange, category, salesChannels, onSuc
98
102
  setIsDeleting(false);
99
103
  }
100
104
  };
101
- return ((0, jsx_runtime_1.jsx)(ui_1.Drawer, { open: open, onOpenChange: onOpenChange, children: (0, jsx_runtime_1.jsx)(ui_1.Drawer.Content, { className: "max-w-md", children: (0, jsx_runtime_1.jsxs)("form", { onSubmit: handleSubmit, className: "flex flex-col h-full", children: [(0, jsx_runtime_1.jsxs)(ui_1.Drawer.Header, { children: [(0, jsx_runtime_1.jsxs)(ui_1.Drawer.Title, { children: ["Edit Category: ", category.name] }), (0, jsx_runtime_1.jsxs)(ui_1.Drawer.Description, { children: ["Tier ", category.tier, " \u2022 ID: ", category.id] })] }), (0, jsx_runtime_1.jsxs)(ui_1.Drawer.Body, { className: "flex flex-col gap-4 overflow-y-auto px-6 py-4 flex-1", children: [(0, jsx_runtime_1.jsxs)("div", { className: "flex flex-col gap-1.5", children: [(0, jsx_runtime_1.jsx)(ui_1.Label, { htmlFor: "edit-name", className: "text-xs font-semibold", children: "Category Name *" }), (0, jsx_runtime_1.jsx)(ui_1.Input, { id: "edit-name", size: "small", value: name, onChange: (e) => setName(e.target.value) })] }), (0, jsx_runtime_1.jsxs)("div", { className: "flex flex-col gap-1.5", children: [(0, jsx_runtime_1.jsx)(ui_1.Label, { htmlFor: "edit-handle", className: "text-xs font-semibold", children: "Handle (URL slug)" }), (0, jsx_runtime_1.jsx)(ui_1.Input, { id: "edit-handle", size: "small", value: handle, onChange: (e) => setHandle(e.target.value) })] }), (0, jsx_runtime_1.jsxs)("div", { className: "flex flex-col gap-1.5", children: [(0, jsx_runtime_1.jsx)(ui_1.Label, { htmlFor: "edit-image", className: "text-xs font-semibold", children: "Category Image URL" }), (0, jsx_runtime_1.jsx)(ui_1.Input, { id: "edit-image", size: "small", value: imageUrl, onChange: (e) => setImageUrl(e.target.value) }), imageUrl && ((0, jsx_runtime_1.jsx)("div", { className: "mt-1 w-16 h-16 rounded-lg overflow-hidden border border-ui-border-base bg-ui-bg-subtle", children: (0, jsx_runtime_1.jsx)("img", { src: imageUrl, alt: "Category", className: "w-full h-full object-cover", onError: (e) => {
105
+ return ((0, jsx_runtime_1.jsx)(ui_1.Drawer, { open: open, onOpenChange: onOpenChange, children: (0, jsx_runtime_1.jsx)(ui_1.Drawer.Content, { className: "max-w-md", children: (0, jsx_runtime_1.jsxs)("form", { onSubmit: handleSubmit, className: "flex flex-col h-full", children: [(0, jsx_runtime_1.jsxs)(ui_1.Drawer.Header, { children: [(0, jsx_runtime_1.jsxs)(ui_1.Drawer.Title, { children: ["Edit Category: ", category.name] }), (0, jsx_runtime_1.jsxs)(ui_1.Drawer.Description, { children: ["Tier ", category.tier, " \u2022 ID: ", category.id] })] }), (0, jsx_runtime_1.jsxs)(ui_1.Drawer.Body, { className: "flex flex-col gap-4 overflow-y-auto px-6 py-4 flex-1", children: [(0, jsx_runtime_1.jsxs)("div", { className: "flex flex-col gap-1.5", children: [(0, jsx_runtime_1.jsx)(ui_1.Label, { htmlFor: "edit-name", className: "text-xs font-semibold", children: "Category Name *" }), (0, jsx_runtime_1.jsx)(ui_1.Input, { id: "edit-name", size: "small", value: name, onChange: (e) => setName(e.target.value) })] }), (0, jsx_runtime_1.jsxs)("div", { className: "flex flex-col gap-1.5", children: [(0, jsx_runtime_1.jsx)(ui_1.Label, { htmlFor: "edit-handle", className: "text-xs font-semibold", children: "Handle (URL slug)" }), (0, jsx_runtime_1.jsx)(ui_1.Input, { id: "edit-handle", size: "small", value: handle, onChange: (e) => setHandle(e.target.value) })] }), (0, jsx_runtime_1.jsxs)("div", { className: "flex flex-col gap-1.5 p-3 rounded-lg bg-ui-bg-subtle border border-ui-border-base", children: [(0, jsx_runtime_1.jsxs)("div", { className: "flex items-center justify-between", children: [(0, jsx_runtime_1.jsx)(ui_1.Label, { htmlFor: "edit-rank", className: "text-xs font-semibold", children: "Storefront Ranking (Sort Order)" }), (0, jsx_runtime_1.jsx)("span", { className: "text-[10px] font-mono text-ui-fg-muted", children: "Controls Storefront 2 & Monfil" })] }), (0, jsx_runtime_1.jsxs)("div", { className: "flex items-center gap-2", children: [(0, jsx_runtime_1.jsx)(ui_1.Input, { id: "edit-rank", type: "number", size: "small", value: rank, onChange: (e) => setRank(Number(e.target.value)), className: "w-24" }), (0, jsx_runtime_1.jsxs)("div", { className: "flex items-center gap-1", children: [(0, jsx_runtime_1.jsx)("button", { type: "button", onClick: () => setRank((r) => Math.max(0, r - 1)), className: "px-2 py-1 text-xs font-medium rounded border border-ui-border-base bg-ui-bg-base hover:bg-ui-bg-subtle transition-colors", title: "Move Earlier in Storefront", children: "\u2191 Earlier" }), (0, jsx_runtime_1.jsx)("button", { type: "button", onClick: () => setRank((r) => r + 1), className: "px-2 py-1 text-xs font-medium rounded border border-ui-border-base bg-ui-bg-base hover:bg-ui-bg-subtle transition-colors", title: "Move Later in Storefront", children: "\u2193 Later" }), (0, jsx_runtime_1.jsx)("button", { type: "button", onClick: () => setRank(0), className: "px-2 py-1 text-xs font-medium rounded border border-ui-border-base bg-ui-bg-base hover:bg-ui-bg-subtle transition-colors", title: "Set as First Category", children: "Top (#0)" })] })] }), (0, jsx_runtime_1.jsx)("p", { className: "text-[11px] text-ui-fg-muted leading-relaxed", children: "Categories are sorted by rank ASC (0, 1, 2...). Lower numbers appear first in your storefront navigation." })] }), (0, jsx_runtime_1.jsxs)("div", { className: "flex flex-col gap-1.5", children: [(0, jsx_runtime_1.jsx)(ui_1.Label, { htmlFor: "edit-image", className: "text-xs font-semibold", children: "Category Image URL" }), (0, jsx_runtime_1.jsx)(ui_1.Input, { id: "edit-image", size: "small", value: imageUrl, onChange: (e) => setImageUrl(e.target.value) }), imageUrl && ((0, jsx_runtime_1.jsx)("div", { className: "mt-1 w-16 h-16 rounded-lg overflow-hidden border border-ui-border-base bg-ui-bg-subtle", children: (0, jsx_runtime_1.jsx)("img", { src: imageUrl, alt: "Category", className: "w-full h-full object-cover", onError: (e) => {
102
106
  ;
103
107
  e.target.style.display = "none";
104
108
  } }) }))] }), (0, jsx_runtime_1.jsxs)("div", { className: "flex flex-col gap-1.5", children: [(0, jsx_runtime_1.jsx)(ui_1.Label, { htmlFor: "edit-desc", className: "text-xs font-semibold", children: "Description" }), (0, jsx_runtime_1.jsx)(ui_1.Textarea, { id: "edit-desc", value: description, onChange: (e) => setDescription(e.target.value), rows: 2 })] }), (0, jsx_runtime_1.jsxs)("div", { className: "flex flex-col gap-2 pt-2 border-t border-ui-border-base", children: [(0, jsx_runtime_1.jsx)(ui_1.Label, { className: "text-xs font-semibold", children: "Storefront Visibility" }), (0, jsx_runtime_1.jsx)(ui_1.Text, { size: "xsmall", className: "text-ui-fg-muted", children: "Designate which storefronts can display this category." }), (0, jsx_runtime_1.jsx)("div", { className: "flex flex-col gap-2 mt-1", children: salesChannels.map((channel) => {
@@ -6,7 +6,15 @@ export interface LayoutOptions {
6
6
  nodeSep?: number;
7
7
  rankSep?: number;
8
8
  }
9
- export declare function computeDagreLayout(nodes: Node[], edges: Edge[], options?: LayoutOptions): {
9
+ /**
10
+ * AAA Clustered Hierarchical Tree Layout (Buchheim/Walker style)
11
+ * - Guarantees each parent is positioned at the exact midpoint/center of its children
12
+ * - Guarantees subtrees never overlap and stay tightly clustered
13
+ * - Completely eliminates long spanning cross-canvas threads
14
+ * - Sibling order is preserved strictly according to category rank
15
+ */
16
+ export declare function computeTreeLayout(nodes: Node[], edges: Edge[], options?: LayoutOptions): {
10
17
  nodes: Node[];
11
18
  edges: Edge[];
12
19
  };
20
+ export declare const computeDagreLayout: typeof computeTreeLayout;
@@ -1,44 +1,130 @@
1
1
  "use strict";
2
- var __importDefault = (this && this.__importDefault) || function (mod) {
3
- return (mod && mod.__esModule) ? mod : { "default": mod };
4
- };
5
2
  Object.defineProperty(exports, "__esModule", { value: true });
6
- exports.CARD_HEIGHT = exports.CARD_WIDTH = void 0;
7
- exports.computeDagreLayout = computeDagreLayout;
8
- const dagre_1 = __importDefault(require("@dagrejs/dagre"));
3
+ exports.computeDagreLayout = exports.CARD_HEIGHT = exports.CARD_WIDTH = void 0;
4
+ exports.computeTreeLayout = computeTreeLayout;
9
5
  const react_1 = require("@xyflow/react");
10
6
  exports.CARD_WIDTH = 270;
11
7
  exports.CARD_HEIGHT = 86;
12
- function computeDagreLayout(nodes, edges, options = {}) {
8
+ /**
9
+ * AAA Clustered Hierarchical Tree Layout (Buchheim/Walker style)
10
+ * - Guarantees each parent is positioned at the exact midpoint/center of its children
11
+ * - Guarantees subtrees never overlap and stay tightly clustered
12
+ * - Completely eliminates long spanning cross-canvas threads
13
+ * - Sibling order is preserved strictly according to category rank
14
+ */
15
+ function computeTreeLayout(nodes, edges, options = {}) {
16
+ if (nodes.length === 0)
17
+ return { nodes: [], edges: [] };
13
18
  const direction = options.direction || "TB";
14
19
  const isTB = direction === "TB";
15
- const g = new dagre_1.default.graphlib.Graph();
16
- g.setDefaultEdgeLabel(() => ({}));
17
- g.setGraph({
18
- rankdir: direction,
19
- nodesep: options.nodeSep ?? (isTB ? 45 : 32),
20
- ranksep: options.rankSep ?? (isTB ? 65 : 75),
21
- align: "UL",
22
- ranker: "network-simplex",
23
- });
24
- nodes.forEach((node) => {
25
- g.setNode(node.id, { width: exports.CARD_WIDTH, height: exports.CARD_HEIGHT });
26
- });
20
+ const nodeSep = options.nodeSep ?? (isTB ? 40 : 28);
21
+ const rankSep = options.rankSep ?? (isTB ? 70 : 80);
22
+ const treeGap = isTB ? 80 : 64;
23
+ const nodeMap = new Map(nodes.map((n) => [n.id, n]));
24
+ const childrenMap = new Map();
25
+ const parentMap = new Map();
27
26
  edges.forEach((edge) => {
28
- g.setEdge(edge.source, edge.target);
27
+ if (nodeMap.has(edge.source) && nodeMap.has(edge.target)) {
28
+ const list = childrenMap.get(edge.source) || [];
29
+ list.push(edge.target);
30
+ childrenMap.set(edge.source, list);
31
+ parentMap.set(edge.target, edge.source);
32
+ }
33
+ });
34
+ // 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
+ };
41
+ function buildTree(id, depth) {
42
+ const rawNode = nodeMap.get(id);
43
+ const childIds = (childrenMap.get(id) || []).sort((a, b) => getRank(a) - getRank(b) || a.localeCompare(b));
44
+ const children = childIds.map((childId) => buildTree(childId, depth + 1));
45
+ return {
46
+ id,
47
+ node: rawNode,
48
+ children,
49
+ depth,
50
+ span: 0,
51
+ primaryCoord: 0,
52
+ secondaryCoord: 0,
53
+ };
54
+ }
55
+ // 1. Post-order pass: Compute subtree span (width in TB, height in LR)
56
+ function computeSpan(treeNode) {
57
+ const baseSpan = isTB ? exports.CARD_WIDTH : exports.CARD_HEIGHT;
58
+ if (treeNode.children.length === 0) {
59
+ treeNode.span = baseSpan;
60
+ return baseSpan;
61
+ }
62
+ let childrenSpanTotal = 0;
63
+ treeNode.children.forEach((child, index) => {
64
+ const childSpan = computeSpan(child);
65
+ childrenSpanTotal += childSpan + (index > 0 ? nodeSep : 0);
66
+ });
67
+ treeNode.span = Math.max(baseSpan, childrenSpanTotal);
68
+ return treeNode.span;
69
+ }
70
+ // 2. Pre-order pass: Position nodes
71
+ function assignPositions(treeNode, startOffset, depth) {
72
+ const baseSpan = isTB ? exports.CARD_WIDTH : exports.CARD_HEIGHT;
73
+ const baseSecondary = isTB ? exports.CARD_HEIGHT : exports.CARD_WIDTH;
74
+ treeNode.secondaryCoord = depth * (baseSecondary + rankSep);
75
+ if (treeNode.children.length === 0) {
76
+ treeNode.primaryCoord = startOffset + (treeNode.span - baseSpan) / 2;
77
+ return;
78
+ }
79
+ let currentOffset = startOffset;
80
+ treeNode.children.forEach((child) => {
81
+ assignPositions(child, currentOffset, depth + 1);
82
+ currentOffset += child.span + nodeSep;
83
+ });
84
+ // Center parent directly between first and last child
85
+ const firstChild = treeNode.children[0];
86
+ const lastChild = treeNode.children[treeNode.children.length - 1];
87
+ const centerOfChildren = (firstChild.primaryCoord + lastChild.primaryCoord) / 2;
88
+ treeNode.primaryCoord = centerOfChildren;
89
+ }
90
+ // Sort roots by their rank
91
+ rootNodes.sort((a, b) => getRank(a.id) - getRank(b.id) || a.id.localeCompare(b.id));
92
+ const positionedNodes = [];
93
+ let globalOffset = 0;
94
+ rootNodes.forEach((root) => {
95
+ const tree = buildTree(root.id, 0);
96
+ computeSpan(tree);
97
+ assignPositions(tree, globalOffset, 0);
98
+ globalOffset += tree.span + treeGap;
99
+ // Flatten tree
100
+ function collect(node) {
101
+ const x = isTB ? node.primaryCoord : node.secondaryCoord;
102
+ const y = isTB ? node.secondaryCoord : node.primaryCoord;
103
+ positionedNodes.push({
104
+ ...node.node,
105
+ targetPosition: isTB ? react_1.Position.Top : react_1.Position.Left,
106
+ sourcePosition: isTB ? react_1.Position.Bottom : react_1.Position.Right,
107
+ position: { x, y },
108
+ });
109
+ node.children.forEach(collect);
110
+ }
111
+ collect(tree);
29
112
  });
30
- dagre_1.default.layout(g);
31
- const layoutedNodes = nodes.map((node) => {
32
- const pos = g.node(node.id);
113
+ // Style edges to match source branch color if available
114
+ const styledEdges = edges.map((e) => {
115
+ const sourceNode = nodeMap.get(e.source);
116
+ const branchColor = sourceNode?.data?.branchColor || "#64748b";
33
117
  return {
34
- ...node,
35
- targetPosition: isTB ? react_1.Position.Top : react_1.Position.Left,
36
- sourcePosition: isTB ? react_1.Position.Bottom : react_1.Position.Right,
37
- position: {
38
- x: pos.x - exports.CARD_WIDTH / 2,
39
- y: pos.y - exports.CARD_HEIGHT / 2,
118
+ ...e,
119
+ style: {
120
+ stroke: branchColor,
121
+ strokeWidth: 2,
122
+ opacity: 0.85,
123
+ ...e.style,
40
124
  },
41
125
  };
42
126
  });
43
- return { nodes: layoutedNodes, edges };
127
+ return { nodes: positionedNodes, edges: styledEdges };
44
128
  }
129
+ // Keep computeDagreLayout as alias to guarantee backwards compatibility
130
+ exports.computeDagreLayout = computeTreeLayout;
@@ -13,3 +13,17 @@ export declare function getVisibleTree(allNodes: Node[], allEdges: Edge[], colla
13
13
  visibleEdges: Edge[];
14
14
  };
15
15
  export declare function computeMoves(original: CategoryTreeNode[], current: CategoryTreeNode[]): CategoryMoveItem[];
16
+ export declare const BRANCH_PALETTE: {
17
+ name: string;
18
+ hex: string;
19
+ }[];
20
+ /**
21
+ * Assigns a deterministic branch color to each category based on its top-level root ancestor.
22
+ * All descendants in a root branch share the exact same branch color.
23
+ */
24
+ export declare function computeBranchColors(categories: CategoryTreeNode[]): Map<string, string>;
25
+ /**
26
+ * Swaps sibling ranking (up or down). Modifies rank property among siblings
27
+ * and returns a new categories array ready for preview and commit.
28
+ */
29
+ export declare function moveSiblingRank(categories: CategoryTreeNode[], targetId: string, direction: "up" | "down"): CategoryTreeNode[];
@@ -1,5 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.BRANCH_PALETTE = void 0;
3
4
  exports.toSuperscript = toSuperscript;
4
5
  exports.computeTiers = computeTiers;
5
6
  exports.computeSiblingRanks = computeSiblingRanks;
@@ -7,6 +8,8 @@ exports.wouldCreateCycle = wouldCreateCycle;
7
8
  exports.computeSubtreeIds = computeSubtreeIds;
8
9
  exports.getVisibleTree = getVisibleTree;
9
10
  exports.computeMoves = computeMoves;
11
+ exports.computeBranchColors = computeBranchColors;
12
+ exports.moveSiblingRank = moveSiblingRank;
10
13
  const SUPERSCRIPT_MAP = {
11
14
  "0": "⁰",
12
15
  "1": "¹",
@@ -127,3 +130,95 @@ function computeMoves(original, current) {
127
130
  }
128
131
  return moves;
129
132
  }
133
+ exports.BRANCH_PALETTE = [
134
+ { name: "Emerald", hex: "#10b981" },
135
+ { name: "Indigo", hex: "#6366f1" },
136
+ { name: "Amber", hex: "#f59e0b" },
137
+ { name: "Rose", hex: "#f43f5e" },
138
+ { name: "Cyan", hex: "#06b6d4" },
139
+ { name: "Violet", hex: "#8b5cf6" },
140
+ { name: "Pink", hex: "#ec4899" },
141
+ { name: "Lime", hex: "#84cc16" },
142
+ { name: "Sky", hex: "#0ea5e9" },
143
+ { name: "Orange", hex: "#f97316" },
144
+ { name: "Teal", hex: "#14b8a6" },
145
+ { name: "Purple", hex: "#a855f7" },
146
+ ];
147
+ /**
148
+ * Assigns a deterministic branch color to each category based on its top-level root ancestor.
149
+ * All descendants in a root branch share the exact same branch color.
150
+ */
151
+ function computeBranchColors(categories) {
152
+ const catMap = new Map(categories.map((c) => [c.id, c]));
153
+ const rootMap = new Map();
154
+ function findRootId(id, visited) {
155
+ if (rootMap.has(id))
156
+ return rootMap.get(id);
157
+ if (visited.has(id))
158
+ return id;
159
+ visited.add(id);
160
+ const cat = catMap.get(id);
161
+ if (!cat || !cat.parent_category_id || !catMap.has(cat.parent_category_id)) {
162
+ rootMap.set(id, id);
163
+ return id;
164
+ }
165
+ const root = findRootId(cat.parent_category_id, visited);
166
+ rootMap.set(id, root);
167
+ return root;
168
+ }
169
+ for (const c of categories) {
170
+ findRootId(c.id, new Set());
171
+ }
172
+ const rootIds = Array.from(new Set(categories.filter((c) => !c.parent_category_id).map((c) => c.id))).sort((a, b) => {
173
+ const catA = catMap.get(a);
174
+ const catB = catMap.get(b);
175
+ return (catA?.rank ?? 0) - (catB?.rank ?? 0) || a.localeCompare(b);
176
+ });
177
+ const rootColorMap = new Map();
178
+ rootIds.forEach((id, index) => {
179
+ rootColorMap.set(id, exports.BRANCH_PALETTE[index % exports.BRANCH_PALETTE.length].hex);
180
+ });
181
+ const result = new Map();
182
+ for (const c of categories) {
183
+ const rootId = rootMap.get(c.id) || c.id;
184
+ const color = rootColorMap.get(rootId) || exports.BRANCH_PALETTE[0].hex;
185
+ result.set(c.id, color);
186
+ }
187
+ return result;
188
+ }
189
+ /**
190
+ * Swaps sibling ranking (up or down). Modifies rank property among siblings
191
+ * and returns a new categories array ready for preview and commit.
192
+ */
193
+ function moveSiblingRank(categories, targetId, direction) {
194
+ const target = categories.find((c) => c.id === targetId);
195
+ if (!target)
196
+ return categories;
197
+ const parentId = target.parent_category_id || null;
198
+ const siblings = categories
199
+ .filter((c) => (c.parent_category_id || null) === parentId)
200
+ .sort((a, b) => a.rank - b.rank);
201
+ const currentIndex = siblings.findIndex((s) => s.id === targetId);
202
+ if (currentIndex === -1)
203
+ return categories;
204
+ const targetIndex = direction === "up" ? currentIndex - 1 : currentIndex + 1;
205
+ if (targetIndex < 0 || targetIndex >= siblings.length)
206
+ return categories;
207
+ const adjacent = siblings[targetIndex];
208
+ // Normalize ranks if they are identical or uninitialized
209
+ const updatedRanks = new Map();
210
+ siblings.forEach((s, idx) => {
211
+ updatedRanks.set(s.id, idx * 10);
212
+ });
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) => {
219
+ if (updatedRanks.has(c.id)) {
220
+ return { ...c, rank: updatedRanks.get(c.id) };
221
+ }
222
+ return c;
223
+ });
224
+ }