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

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.
@@ -0,0 +1,8 @@
1
+ import React from "react";
2
+ export interface CategoryProductsManagerProps {
3
+ categoryId: string;
4
+ categoryName: string;
5
+ initialMetadata?: Record<string, any> | null;
6
+ onMetadataUpdate?: (updatedMetadata: Record<string, any>) => void;
7
+ }
8
+ export declare const CategoryProductsManager: React.FC<CategoryProductsManagerProps>;
@@ -0,0 +1,290 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.CategoryProductsManager = void 0;
4
+ const jsx_runtime_1 = require("react/jsx-runtime");
5
+ const react_1 = require("react");
6
+ const ui_1 = require("@medusajs/ui");
7
+ const icons_1 = require("@medusajs/icons");
8
+ const sdk_1 = require("../../lib/sdk");
9
+ const CategoryProductsManager = ({ categoryId, categoryName, initialMetadata, onMetadataUpdate, }) => {
10
+ const [products, setProducts] = (0, react_1.useState)([]);
11
+ const [totalCount, setTotalCount] = (0, react_1.useState)(0);
12
+ const [isLoading, setIsLoading] = (0, react_1.useState)(true);
13
+ const [selectedIds, setSelectedIds] = (0, react_1.useState)(new Set());
14
+ const [isRemoving, setIsRemoving] = (0, react_1.useState)(false);
15
+ const [activeView, setActiveView] = (0, react_1.useState)("all");
16
+ // Product ranking and top sellers state
17
+ const [orderedProductIds, setOrderedProductIds] = (0, react_1.useState)([]);
18
+ const [topSellerIds, setTopSellerIds] = (0, react_1.useState)(new Set());
19
+ // Full-Screen Add Products FocusModal state (matching Medusa legacy modal)
20
+ const [focusModalOpen, setFocusModalOpen] = (0, react_1.useState)(false);
21
+ const [searchQuery, setSearchQuery] = (0, react_1.useState)("");
22
+ const [catalogProducts, setCatalogProducts] = (0, react_1.useState)([]);
23
+ const [catalogTotalCount, setCatalogTotalCount] = (0, react_1.useState)(0);
24
+ const [catalogPage, setCatalogPage] = (0, react_1.useState)(0);
25
+ const [isSearchingCatalog, setIsSearchingCatalog] = (0, react_1.useState)(false);
26
+ const [catalogSelectedIds, setCatalogSelectedIds] = (0, react_1.useState)(new Set());
27
+ const [isAdding, setIsAdding] = (0, react_1.useState)(false);
28
+ const PAGE_SIZE = 50;
29
+ // Initialize ranking and top sellers from category metadata
30
+ (0, react_1.useEffect)(() => {
31
+ if (initialMetadata) {
32
+ if (Array.isArray(initialMetadata.ordered_product_ids)) {
33
+ setOrderedProductIds(initialMetadata.ordered_product_ids);
34
+ }
35
+ if (Array.isArray(initialMetadata.top_sellers)) {
36
+ setTopSellerIds(new Set(initialMetadata.top_sellers));
37
+ }
38
+ }
39
+ }, [initialMetadata]);
40
+ // Load products assigned to this category
41
+ const loadAssignedProducts = (0, react_1.useCallback)(async () => {
42
+ setIsLoading(true);
43
+ try {
44
+ const res = await sdk_1.sdk.client.fetch(`/admin/products?category_id[]=${categoryId}&limit=100&fields=id,title,thumbnail,status,variants.id,collection.title,sales_channels.name`);
45
+ const fetched = res.products || [];
46
+ setTotalCount(res.count ?? fetched.length);
47
+ // Sort products according to orderedProductIds if available
48
+ setProducts((prev) => {
49
+ const orderMap = new Map(orderedProductIds.map((id, index) => [id, index]));
50
+ return [...fetched].sort((a, b) => {
51
+ const indexA = orderMap.has(a.id) ? orderMap.get(a.id) : 9999;
52
+ const indexB = orderMap.has(b.id) ? orderMap.get(b.id) : 9999;
53
+ return indexA - indexB;
54
+ });
55
+ });
56
+ }
57
+ catch (err) {
58
+ ui_1.toast.error(err?.message || "Failed to load assigned products");
59
+ }
60
+ finally {
61
+ setIsLoading(false);
62
+ }
63
+ }, [categoryId, orderedProductIds]);
64
+ (0, react_1.useEffect)(() => {
65
+ loadAssignedProducts();
66
+ }, [loadAssignedProducts]);
67
+ // Save updated product rankings and top sellers to category metadata
68
+ const persistProductConfig = async (newOrder, newTopSellers) => {
69
+ const ranksMap = {};
70
+ newOrder.forEach((id, idx) => {
71
+ ranksMap[id] = idx + 1;
72
+ });
73
+ const updatedMetadata = {
74
+ ...(initialMetadata || {}),
75
+ ordered_product_ids: newOrder,
76
+ product_ranks: ranksMap,
77
+ top_sellers: Array.from(newTopSellers),
78
+ };
79
+ try {
80
+ await sdk_1.sdk.admin.productCategory.update(categoryId, {
81
+ metadata: updatedMetadata,
82
+ });
83
+ if (onMetadataUpdate) {
84
+ onMetadataUpdate(updatedMetadata);
85
+ }
86
+ }
87
+ catch (err) {
88
+ ui_1.toast.error(err?.message || "Failed to save product ordering");
89
+ }
90
+ };
91
+ // Sibling product ranking handlers (Move Up / Move Down)
92
+ const handleMoveProduct = async (index, direction) => {
93
+ const targetIndex = direction === "up" ? index - 1 : index + 1;
94
+ if (targetIndex < 0 || targetIndex >= products.length)
95
+ return;
96
+ const reordered = [...products];
97
+ const [moved] = reordered.splice(index, 1);
98
+ reordered.splice(targetIndex, 0, moved);
99
+ setProducts(reordered);
100
+ const newOrder = reordered.map((p) => p.id);
101
+ setOrderedProductIds(newOrder);
102
+ ui_1.toast.info(`Moved "${moved.title}" to #${targetIndex + 1}`);
103
+ await persistProductConfig(newOrder, topSellerIds);
104
+ };
105
+ // Toggle Top Commercial Seller status
106
+ const handleToggleTopSeller = async (productId, productTitle) => {
107
+ const next = new Set(topSellerIds);
108
+ const isNowTop = !next.has(productId);
109
+ if (isNowTop) {
110
+ next.add(productId);
111
+ ui_1.toast.success(`Marked "${productTitle}" as Top Commercial Seller ⭐`);
112
+ }
113
+ else {
114
+ next.delete(productId);
115
+ ui_1.toast.info(`Removed "${productTitle}" from Top Commercial Sellers`);
116
+ }
117
+ setTopSellerIds(next);
118
+ const currentOrder = products.map((p) => p.id);
119
+ await persistProductConfig(currentOrder, next);
120
+ };
121
+ // Remove products from category
122
+ const handleRemoveProducts = async (idsToRemove) => {
123
+ if (idsToRemove.length === 0)
124
+ return;
125
+ const msg = idsToRemove.length === 1
126
+ ? "Are you sure you want to remove this product from the category?"
127
+ : `Are you sure you want to remove ${idsToRemove.length} products from this category?`;
128
+ if (!confirm(msg))
129
+ return;
130
+ setIsRemoving(true);
131
+ try {
132
+ await sdk_1.sdk.client.fetch(`/admin/product-categories/${categoryId}/products`, {
133
+ method: "POST",
134
+ body: { remove: idsToRemove },
135
+ });
136
+ ui_1.toast.success(`Removed ${idsToRemove.length} product(s) from category`);
137
+ setSelectedIds(new Set());
138
+ // Also clean up ranking and top sellers
139
+ const removeSet = new Set(idsToRemove);
140
+ const newOrder = orderedProductIds.filter((id) => !removeSet.has(id));
141
+ const newTopSellers = new Set(Array.from(topSellerIds).filter((id) => !removeSet.has(id)));
142
+ setOrderedProductIds(newOrder);
143
+ setTopSellerIds(newTopSellers);
144
+ await persistProductConfig(newOrder, newTopSellers);
145
+ await loadAssignedProducts();
146
+ }
147
+ catch (err) {
148
+ ui_1.toast.error(err?.message || "Failed to remove products");
149
+ }
150
+ finally {
151
+ setIsRemoving(false);
152
+ }
153
+ };
154
+ // Search catalog for the Add Products FocusModal (matching Medusa's legacy product table)
155
+ const searchCatalog = (0, react_1.useCallback)(async (query, page) => {
156
+ setIsSearchingCatalog(true);
157
+ try {
158
+ const qParam = query.trim() ? `&q=${encodeURIComponent(query.trim())}` : "";
159
+ const offset = page * PAGE_SIZE;
160
+ const res = await sdk_1.sdk.client.fetch(`/admin/products?limit=${PAGE_SIZE}&offset=${offset}${qParam}&fields=id,title,thumbnail,status,variants.id,collection.title,sales_channels.name`);
161
+ setCatalogProducts(res.products || []);
162
+ setCatalogTotalCount(res.count ?? (res.products?.length || 0));
163
+ }
164
+ catch (err) {
165
+ ui_1.toast.error(err?.message || "Failed to search catalog");
166
+ }
167
+ finally {
168
+ setIsSearchingCatalog(false);
169
+ }
170
+ }, []);
171
+ (0, react_1.useEffect)(() => {
172
+ if (focusModalOpen) {
173
+ searchCatalog(searchQuery, catalogPage);
174
+ }
175
+ }, [focusModalOpen, searchQuery, catalogPage, searchCatalog]);
176
+ const handleSaveAddedProducts = async () => {
177
+ const idsToAdd = Array.from(catalogSelectedIds);
178
+ if (idsToAdd.length === 0)
179
+ return;
180
+ setIsAdding(true);
181
+ try {
182
+ await sdk_1.sdk.client.fetch(`/admin/product-categories/${categoryId}/products`, {
183
+ method: "POST",
184
+ body: { add: idsToAdd },
185
+ });
186
+ ui_1.toast.success(`Added ${idsToAdd.length} product(s) to ${categoryName}`);
187
+ setFocusModalOpen(false);
188
+ setCatalogSelectedIds(new Set());
189
+ // Append new products to orderedProductIds
190
+ const newOrder = [...orderedProductIds, ...idsToAdd];
191
+ setOrderedProductIds(newOrder);
192
+ await persistProductConfig(newOrder, topSellerIds);
193
+ await loadAssignedProducts();
194
+ }
195
+ catch (err) {
196
+ ui_1.toast.error(err?.message || "Failed to add products");
197
+ }
198
+ finally {
199
+ setIsAdding(false);
200
+ }
201
+ };
202
+ const assignedProductIdsSet = (0, react_1.useMemo)(() => new Set(products.map((p) => p.id)), [products]);
203
+ const displayedProducts = (0, react_1.useMemo)(() => {
204
+ if (activeView === "top_sellers") {
205
+ return products.filter((p) => topSellerIds.has(p.id));
206
+ }
207
+ return products;
208
+ }, [products, activeView, topSellerIds]);
209
+ return ((0, jsx_runtime_1.jsxs)("div", { className: "flex flex-col gap-3", children: [(0, jsx_runtime_1.jsxs)("div", { className: "flex items-center justify-between flex-wrap gap-2", children: [(0, jsx_runtime_1.jsxs)("div", { className: "flex items-center gap-1.5 p-1 rounded-lg bg-ui-bg-subtle border border-ui-border-base", children: [(0, jsx_runtime_1.jsxs)("button", { type: "button", onClick: () => setActiveView("all"), className: `px-2.5 py-1 text-xs font-medium rounded-md transition-colors ${activeView === "all"
210
+ ? "bg-ui-bg-base text-ui-fg-base shadow-2xs font-semibold"
211
+ : "text-ui-fg-muted hover:text-ui-fg-base"}`, children: ["All Products (", products.length, ")"] }), (0, jsx_runtime_1.jsxs)("button", { type: "button", onClick: () => setActiveView("top_sellers"), className: `flex items-center gap-1 px-2.5 py-1 text-xs font-medium rounded-md transition-colors ${activeView === "top_sellers"
212
+ ? "bg-amber-500/10 text-amber-700 dark:text-amber-400 font-semibold"
213
+ : "text-ui-fg-muted hover:text-amber-600"}`, children: [(0, jsx_runtime_1.jsx)(icons_1.Sparkles, { className: "w-3.5 h-3.5 text-amber-500" }), (0, jsx_runtime_1.jsxs)("span", { children: ["Top Commercial Sellers (", topSellerIds.size, ")"] })] })] }), (0, jsx_runtime_1.jsxs)("div", { className: "flex items-center gap-2", children: [selectedIds.size > 0 && ((0, jsx_runtime_1.jsxs)(ui_1.Button, { size: "small", variant: "danger", disabled: isRemoving, onClick: () => handleRemoveProducts(Array.from(selectedIds)), className: "flex items-center gap-1 text-xs", children: [(0, jsx_runtime_1.jsx)(icons_1.Trash, { className: "w-3.5 h-3.5" }), (0, jsx_runtime_1.jsxs)("span", { children: ["Remove Selected (", selectedIds.size, ")"] })] })), (0, jsx_runtime_1.jsxs)(ui_1.Button, { size: "small", variant: "secondary", onClick: () => {
214
+ setCatalogSelectedIds(new Set());
215
+ setSearchQuery("");
216
+ setCatalogPage(0);
217
+ setFocusModalOpen(true);
218
+ }, className: "flex items-center gap-1 text-xs", children: [(0, jsx_runtime_1.jsx)(icons_1.Plus, { className: "w-3.5 h-3.5" }), (0, jsx_runtime_1.jsx)("span", { children: "Add Products" })] })] })] }), (0, jsx_runtime_1.jsx)("div", { className: "border border-ui-border-base rounded-xl overflow-hidden bg-ui-bg-base shadow-2xs", children: isLoading ? ((0, jsx_runtime_1.jsx)("div", { className: "p-8 text-center text-xs text-ui-fg-muted", children: "Loading products\u2026" })) : displayedProducts.length === 0 ? ((0, jsx_runtime_1.jsxs)("div", { className: "p-8 text-center flex flex-col items-center justify-center gap-2", children: [(0, jsx_runtime_1.jsx)("p", { className: "text-xs text-ui-fg-muted", children: activeView === "top_sellers"
219
+ ? "No products marked as Top Commercial Sellers yet. Star a product below to highlight it."
220
+ : "No products assigned to this category." }), activeView === "all" && ((0, jsx_runtime_1.jsxs)(ui_1.Button, { size: "small", variant: "secondary", onClick: () => setFocusModalOpen(true), className: "flex items-center gap-1 text-xs", children: [(0, jsx_runtime_1.jsx)(icons_1.Plus, { className: "w-3.5 h-3.5" }), (0, jsx_runtime_1.jsx)("span", { children: "Add Products" })] }))] })) : ((0, jsx_runtime_1.jsxs)("table", { className: "w-full text-left text-xs border-collapse", children: [(0, jsx_runtime_1.jsx)("thead", { children: (0, jsx_runtime_1.jsxs)("tr", { className: "border-b border-ui-border-base bg-ui-bg-subtle/60 text-ui-fg-muted font-medium", children: [(0, jsx_runtime_1.jsx)("th", { className: "py-2.5 px-3 w-8", children: (0, jsx_runtime_1.jsx)("input", { type: "checkbox", checked: displayedProducts.length > 0 && selectedIds.size === displayedProducts.length, onChange: () => {
221
+ if (selectedIds.size === displayedProducts.length)
222
+ setSelectedIds(new Set());
223
+ else
224
+ setSelectedIds(new Set(displayedProducts.map((p) => p.id)));
225
+ }, className: "rounded border-ui-border-base cursor-pointer" }) }), (0, jsx_runtime_1.jsx)("th", { className: "py-2.5 px-3 w-20", children: "Rank" }), (0, jsx_runtime_1.jsx)("th", { className: "py-2.5 px-3", children: "Product" }), (0, jsx_runtime_1.jsx)("th", { className: "py-2.5 px-3", children: "Top Seller" }), (0, jsx_runtime_1.jsx)("th", { className: "py-2.5 px-3", children: "Collection" }), (0, jsx_runtime_1.jsx)("th", { className: "py-2.5 px-3", children: "Variants" }), (0, jsx_runtime_1.jsx)("th", { className: "py-2.5 px-3", children: "Status" }), (0, jsx_runtime_1.jsx)("th", { className: "py-2.5 px-3 text-right", children: "Action" })] }) }), (0, jsx_runtime_1.jsx)("tbody", { className: "divide-y divide-ui-border-base", children: displayedProducts.map((p, index) => {
226
+ const isChecked = selectedIds.has(p.id);
227
+ const isTopSeller = topSellerIds.has(p.id);
228
+ return ((0, jsx_runtime_1.jsxs)("tr", { className: `hover:bg-ui-bg-subtle/40 transition-colors ${isChecked ? "bg-blue-50/40 dark:bg-blue-950/20" : ""}`, children: [(0, jsx_runtime_1.jsx)("td", { className: "py-2.5 px-3", children: (0, jsx_runtime_1.jsx)("input", { type: "checkbox", checked: isChecked, onChange: () => {
229
+ setSelectedIds((prev) => {
230
+ const next = new Set(prev);
231
+ if (next.has(p.id))
232
+ next.delete(p.id);
233
+ else
234
+ next.add(p.id);
235
+ return next;
236
+ });
237
+ }, className: "rounded border-ui-border-base cursor-pointer" }) }), (0, jsx_runtime_1.jsx)("td", { className: "py-2.5 px-3", children: (0, jsx_runtime_1.jsxs)("div", { className: "flex items-center gap-1 font-mono", children: [(0, jsx_runtime_1.jsxs)("span", { className: "text-xs font-bold text-ui-fg-subtle px-1.5 py-0.5 rounded bg-ui-bg-subtle border border-ui-border-base", children: ["#", index + 1] }), (0, jsx_runtime_1.jsxs)("div", { className: "flex flex-col", children: [(0, jsx_runtime_1.jsx)("button", { type: "button", title: "Move Up", disabled: index === 0, onClick: () => handleMoveProduct(index, "up"), className: "text-[9px] text-ui-fg-muted hover:text-ui-fg-base disabled:opacity-20 px-0.5 leading-none", children: "\u25B2" }), (0, jsx_runtime_1.jsx)("button", { type: "button", title: "Move Down", disabled: index === displayedProducts.length - 1, onClick: () => handleMoveProduct(index, "down"), className: "text-[9px] text-ui-fg-muted hover:text-ui-fg-base disabled:opacity-20 px-0.5 leading-none", children: "\u25BC" })] })] }) }), (0, jsx_runtime_1.jsx)("td", { className: "py-2.5 px-3", children: (0, jsx_runtime_1.jsxs)("div", { className: "flex items-center gap-2.5 min-w-0", children: [(0, jsx_runtime_1.jsx)("div", { className: "w-9 h-9 rounded-md border border-ui-border-base overflow-hidden bg-ui-bg-subtle flex-shrink-0 flex items-center justify-center", children: p.thumbnail ? ((0, jsx_runtime_1.jsx)("img", { src: p.thumbnail, alt: p.title, className: "w-full h-full object-cover" })) : ((0, jsx_runtime_1.jsx)("span", { className: "text-[9px] text-ui-fg-muted", children: "IMG" })) }), (0, jsx_runtime_1.jsx)("div", { className: "flex flex-col min-w-0", children: (0, jsx_runtime_1.jsx)("span", { className: "font-semibold text-ui-fg-base truncate leading-snug", title: p.title, children: p.title }) })] }) }), (0, jsx_runtime_1.jsx)("td", { className: "py-2.5 px-3", children: (0, jsx_runtime_1.jsxs)("button", { type: "button", onClick: () => handleToggleTopSeller(p.id, p.title), className: `inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[11px] font-semibold border transition-all ${isTopSeller
238
+ ? "bg-amber-500/15 text-amber-700 dark:text-amber-400 border-amber-300 dark:border-amber-700 shadow-2xs"
239
+ : "bg-ui-bg-subtle text-ui-fg-muted border-ui-border-base hover:text-amber-600 hover:border-amber-300"}`, title: isTopSeller
240
+ ? "Click to remove from Top Commercial Sellers"
241
+ : "Click to mark as Top Commercial Seller (Reflected in Storefront 2)", children: [(0, jsx_runtime_1.jsx)("span", { children: isTopSeller ? "★" : "☆" }), (0, jsx_runtime_1.jsx)("span", { children: isTopSeller ? "Top Seller" : "Normal" })] }) }), (0, jsx_runtime_1.jsx)("td", { className: "py-2.5 px-3 text-ui-fg-muted", children: p.collection?.title || "-" }), (0, jsx_runtime_1.jsxs)("td", { className: "py-2.5 px-3 text-ui-fg-subtle", children: [p.variants?.length || 0, " variant(s)"] }), (0, jsx_runtime_1.jsx)("td", { className: "py-2.5 px-3", children: (0, jsx_runtime_1.jsx)("span", { className: `inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium ${p.status === "published"
242
+ ? "bg-emerald-50 text-emerald-700 border border-emerald-200 dark:bg-emerald-950/50 dark:text-emerald-300"
243
+ : "bg-neutral-100 text-neutral-600 dark:bg-neutral-800 dark:text-neutral-400"}`, children: p.status }) }), (0, jsx_runtime_1.jsx)("td", { className: "py-2.5 px-3 text-right", children: (0, jsx_runtime_1.jsx)("button", { type: "button", title: "Remove from category", onClick: () => handleRemoveProducts([p.id]), className: "p-1 text-ui-fg-muted hover:text-rose-600 hover:bg-rose-50 dark:hover:bg-rose-950/50 rounded transition-colors", children: (0, jsx_runtime_1.jsx)(icons_1.Trash, { className: "w-3.5 h-3.5" }) }) })] }, p.id));
244
+ }) })] })) }), focusModalOpen && ((0, jsx_runtime_1.jsxs)("div", { className: "fixed inset-0 z-50 flex flex-col bg-white dark:bg-neutral-950 animate-in fade-in-50", children: [(0, jsx_runtime_1.jsxs)("div", { className: "flex items-center justify-between px-6 py-3 border-b border-ui-border-base bg-ui-bg-base flex-shrink-0", children: [(0, jsx_runtime_1.jsxs)("div", { className: "flex items-center gap-3", children: [(0, jsx_runtime_1.jsx)("button", { type: "button", onClick: () => setFocusModalOpen(false), className: "p-1.5 rounded-lg text-ui-fg-muted hover:text-ui-fg-base hover:bg-ui-bg-subtle transition-colors", title: "Close (ESC)", children: (0, jsx_runtime_1.jsx)("span", { className: "text-sm font-bold", children: "\u2715" }) }), (0, jsx_runtime_1.jsx)("span", { className: "text-[11px] font-mono font-medium text-ui-fg-muted px-1.5 py-0.5 rounded bg-ui-bg-subtle border border-ui-border-base", children: "ESC" }), (0, jsx_runtime_1.jsxs)("h2", { className: "text-sm font-semibold text-ui-fg-base", children: ["Add Products to Category: ", categoryName] })] }), (0, jsx_runtime_1.jsx)("div", { className: "flex items-center gap-3", children: (0, jsx_runtime_1.jsxs)("div", { className: "relative w-64", children: [(0, jsx_runtime_1.jsx)("input", { type: "text", placeholder: "Search products\u2026", value: searchQuery, onChange: (e) => {
245
+ setSearchQuery(e.target.value);
246
+ setCatalogPage(0);
247
+ }, className: "w-full text-xs pl-8 pr-3 py-1.5 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 placeholder:text-ui-fg-muted", autoFocus: true }), (0, jsx_runtime_1.jsx)(icons_1.MagnifyingGlass, { className: "absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-ui-fg-muted pointer-events-none" })] }) })] }), (0, jsx_runtime_1.jsx)("div", { className: "flex-1 overflow-y-auto px-6 py-4", children: (0, jsx_runtime_1.jsx)("div", { className: "border border-ui-border-base rounded-xl overflow-hidden bg-ui-bg-base shadow-sm", children: (0, jsx_runtime_1.jsxs)("table", { className: "w-full text-left text-xs border-collapse", children: [(0, jsx_runtime_1.jsx)("thead", { children: (0, jsx_runtime_1.jsxs)("tr", { className: "border-b border-ui-border-base bg-ui-bg-subtle/80 text-ui-fg-muted font-medium", children: [(0, jsx_runtime_1.jsx)("th", { className: "py-2.5 px-3 w-8", children: (0, jsx_runtime_1.jsx)("input", { type: "checkbox", checked: catalogProducts.length > 0 &&
248
+ catalogProducts.every((p) => assignedProductIdsSet.has(p.id) || catalogSelectedIds.has(p.id)), onChange: () => {
249
+ const available = catalogProducts.filter((p) => !assignedProductIdsSet.has(p.id));
250
+ if (available.every((p) => catalogSelectedIds.has(p.id))) {
251
+ setCatalogSelectedIds(new Set());
252
+ }
253
+ else {
254
+ setCatalogSelectedIds(new Set(available.map((p) => p.id)));
255
+ }
256
+ }, className: "rounded border-ui-border-base cursor-pointer" }) }), (0, jsx_runtime_1.jsx)("th", { className: "py-2.5 px-3", children: "Product" }), (0, jsx_runtime_1.jsx)("th", { className: "py-2.5 px-3", children: "Collection" }), (0, jsx_runtime_1.jsx)("th", { className: "py-2.5 px-3", children: "Sales Channels" }), (0, jsx_runtime_1.jsx)("th", { className: "py-2.5 px-3", children: "Variants" }), (0, jsx_runtime_1.jsx)("th", { className: "py-2.5 px-3", children: "Status" })] }) }), (0, jsx_runtime_1.jsx)("tbody", { className: "divide-y divide-ui-border-base", children: isSearchingCatalog ? ((0, jsx_runtime_1.jsx)("tr", { children: (0, jsx_runtime_1.jsx)("td", { colSpan: 6, className: "py-12 text-center text-ui-fg-muted", children: "Searching catalog products\u2026" }) })) : catalogProducts.length === 0 ? ((0, jsx_runtime_1.jsx)("tr", { children: (0, jsx_runtime_1.jsx)("td", { colSpan: 6, className: "py-12 text-center text-ui-fg-muted", children: "No products found." }) })) : (catalogProducts.map((p) => {
257
+ const isAlreadyAssigned = assignedProductIdsSet.has(p.id);
258
+ const isSelected = catalogSelectedIds.has(p.id) || isAlreadyAssigned;
259
+ return ((0, jsx_runtime_1.jsxs)("tr", { onClick: () => {
260
+ if (isAlreadyAssigned)
261
+ return;
262
+ setCatalogSelectedIds((prev) => {
263
+ const next = new Set(prev);
264
+ if (next.has(p.id))
265
+ next.delete(p.id);
266
+ else
267
+ next.add(p.id);
268
+ return next;
269
+ });
270
+ }, className: `cursor-pointer transition-colors ${isAlreadyAssigned
271
+ ? "opacity-50 bg-ui-bg-subtle/30 cursor-not-allowed"
272
+ : isSelected
273
+ ? "bg-blue-50/50 dark:bg-blue-950/30"
274
+ : "hover:bg-ui-bg-subtle/50"}`, children: [(0, jsx_runtime_1.jsx)("td", { className: "py-2.5 px-3", onClick: (e) => e.stopPropagation(), children: (0, jsx_runtime_1.jsx)("input", { type: "checkbox", checked: isSelected, disabled: isAlreadyAssigned, onChange: () => {
275
+ if (isAlreadyAssigned)
276
+ return;
277
+ setCatalogSelectedIds((prev) => {
278
+ const next = new Set(prev);
279
+ if (next.has(p.id))
280
+ next.delete(p.id);
281
+ else
282
+ next.add(p.id);
283
+ return next;
284
+ });
285
+ }, className: "rounded border-ui-border-base cursor-pointer" }) }), (0, jsx_runtime_1.jsx)("td", { className: "py-2.5 px-3", children: (0, jsx_runtime_1.jsxs)("div", { className: "flex items-center gap-2.5 min-w-0", children: [(0, jsx_runtime_1.jsx)("div", { className: "w-8 h-8 rounded border border-ui-border-base overflow-hidden bg-ui-bg-subtle flex-shrink-0 flex items-center justify-center", children: p.thumbnail ? ((0, jsx_runtime_1.jsx)("img", { src: p.thumbnail, alt: p.title, className: "w-full h-full object-cover" })) : ((0, jsx_runtime_1.jsx)("span", { className: "text-[8px] text-ui-fg-muted", children: "IMG" })) }), (0, jsx_runtime_1.jsx)("span", { className: "font-semibold text-ui-fg-base truncate leading-snug", children: p.title }), isAlreadyAssigned && ((0, jsx_runtime_1.jsx)(ui_1.Badge, { size: "small", color: "grey", className: "ml-1 text-[9px]", children: "Already in Category" }))] }) }), (0, jsx_runtime_1.jsx)("td", { className: "py-2.5 px-3 text-ui-fg-muted", children: p.collection?.title || "-" }), (0, jsx_runtime_1.jsx)("td", { className: "py-2.5 px-3", children: (0, jsx_runtime_1.jsx)("div", { className: "flex items-center gap-1 flex-wrap", children: (p.sales_channels || []).map((sc) => ((0, jsx_runtime_1.jsx)("span", { className: "px-1.5 py-0.5 rounded text-[10px] bg-ui-bg-subtle border border-ui-border-base text-ui-fg-subtle", children: sc.name }, sc.id))) }) }), (0, jsx_runtime_1.jsxs)("td", { className: "py-2.5 px-3 text-ui-fg-subtle", children: [p.variants?.length || 0, " variant(s)"] }), (0, jsx_runtime_1.jsx)("td", { className: "py-2.5 px-3", children: (0, jsx_runtime_1.jsx)("span", { className: `inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium ${p.status === "published"
286
+ ? "bg-emerald-50 text-emerald-700 border border-emerald-200 dark:bg-emerald-950/50 dark:text-emerald-300"
287
+ : "bg-neutral-100 text-neutral-600 dark:bg-neutral-800 dark:text-neutral-400"}`, children: p.status }) })] }, p.id));
288
+ })) })] }) }) }), (0, jsx_runtime_1.jsxs)("div", { className: "flex items-center justify-between px-6 py-3 border-t border-ui-border-base bg-ui-bg-base flex-shrink-0", children: [(0, jsx_runtime_1.jsxs)("div", { className: "flex items-center gap-3", children: [(0, jsx_runtime_1.jsxs)("span", { className: "text-xs text-ui-fg-muted", children: ["Showing ", Math.min(catalogProducts.length, PAGE_SIZE), " of ", catalogTotalCount, " results"] }), (0, jsx_runtime_1.jsx)("span", { className: "text-xs text-ui-fg-muted", children: "\u2022" }), (0, jsx_runtime_1.jsxs)("span", { className: "text-xs font-semibold text-ui-fg-base", children: [catalogSelectedIds.size, " product(s) selected"] })] }), (0, jsx_runtime_1.jsxs)("div", { className: "flex items-center gap-2", children: [(0, jsx_runtime_1.jsx)("button", { type: "button", disabled: catalogPage === 0, onClick: () => setCatalogPage((p) => Math.max(0, p - 1)), className: "px-2.5 py-1 text-xs rounded border border-ui-border-base bg-ui-bg-subtle hover:bg-ui-bg-base disabled:opacity-40", children: "Prev" }), (0, jsx_runtime_1.jsx)("button", { type: "button", disabled: (catalogPage + 1) * PAGE_SIZE >= catalogTotalCount, onClick: () => setCatalogPage((p) => p + 1), className: "px-2.5 py-1 text-xs rounded border border-ui-border-base bg-ui-bg-subtle hover:bg-ui-bg-base disabled:opacity-40", children: "Next" }), (0, jsx_runtime_1.jsx)("div", { className: "h-4 w-[1px] bg-ui-border-base mx-1" }), (0, jsx_runtime_1.jsx)(ui_1.Button, { size: "small", variant: "secondary", onClick: () => setFocusModalOpen(false), children: "Cancel" }), (0, jsx_runtime_1.jsxs)(ui_1.Button, { size: "small", variant: "primary", disabled: catalogSelectedIds.size === 0 || isAdding, onClick: handleSaveAddedProducts, className: "flex items-center gap-1 text-xs min-w-[70px] justify-center", children: [(0, jsx_runtime_1.jsx)(icons_1.CheckMini, { className: "w-3.5 h-3.5" }), (0, jsx_runtime_1.jsx)("span", { children: isAdding ? "Saving…" : "Save" })] })] })] })] }))] }));
289
+ };
290
+ exports.CategoryProductsManager = CategoryProductsManager;
@@ -178,6 +178,9 @@ const CanvasInner = () => {
178
178
  source: cat.parent_category_id,
179
179
  target: cat.id,
180
180
  type: "smoothstep",
181
+ data: {
182
+ branchColor: cat.branch_color,
183
+ },
181
184
  style: {
182
185
  stroke: cat.branch_color || "#64748b",
183
186
  strokeWidth: 2,
@@ -5,7 +5,8 @@ const jsx_runtime_1 = require("react/jsx-runtime");
5
5
  const react_1 = require("react");
6
6
  const react_2 = require("@xyflow/react");
7
7
  exports.CustomSmoothStepEdge = (0, react_1.memo)((props) => {
8
- const { id, sourceX, sourceY, targetX, targetY, sourcePosition, targetPosition, style = {}, markerEnd, selected, } = props;
8
+ const { id, sourceX, sourceY, targetX, targetY, sourcePosition, targetPosition, style = {}, data, markerEnd, selected, } = props;
9
+ const branchColor = data?.branchColor || style?.stroke || "#64748b";
9
10
  const [edgePath] = (0, react_2.getSmoothStepPath)({
10
11
  sourceX,
11
12
  sourceY,
@@ -17,8 +18,9 @@ exports.CustomSmoothStepEdge = (0, react_1.memo)((props) => {
17
18
  });
18
19
  return ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsx)("path", { d: edgePath, fill: "none", strokeOpacity: 0, strokeWidth: 20, className: "react-flow__edge-interaction cursor-pointer" }), (0, jsx_runtime_1.jsx)(react_2.BaseEdge, { id: id, path: edgePath, markerEnd: markerEnd, style: {
19
20
  ...style,
20
- stroke: selected ? "var(--bg-interactive, #3b82f6)" : "var(--border-strong, #cbd5e1)",
21
- strokeWidth: selected ? 2.5 : 1.75,
21
+ stroke: selected ? "#2563eb" : branchColor,
22
+ strokeWidth: selected ? 2.75 : 2,
23
+ strokeOpacity: 0.95,
22
24
  transition: "stroke 0.2s, stroke-width 0.2s",
23
25
  } })] }));
24
26
  });
@@ -5,8 +5,29 @@ const jsx_runtime_1 = require("react/jsx-runtime");
5
5
  const react_1 = require("react");
6
6
  const ui_1 = require("@medusajs/ui");
7
7
  const icons_1 = require("@medusajs/icons");
8
+ const category_products_manager_1 = require("./category-products-manager");
8
9
  const sdk_1 = require("../../lib/sdk");
10
+ function extractYouTubeVideoId(url) {
11
+ if (!url || typeof url !== "string")
12
+ return null;
13
+ const trimmed = url.trim();
14
+ const patterns = [
15
+ /(?:youtube\.com\/shorts\/)([^?&\s]+)/i,
16
+ /(?:youtube\.com\/watch\?.*v=)([^?&\s]+)/i,
17
+ /(?:youtu\.be\/)([^?&\s]+)/i,
18
+ /(?:youtube\.com\/embed\/)([^?&\s]+)/i,
19
+ ];
20
+ for (const pattern of patterns) {
21
+ const match = trimmed.match(pattern);
22
+ if (match && match[1])
23
+ return match[1];
24
+ }
25
+ if (/^[a-zA-Z0-9_-]{11}$/.test(trimmed))
26
+ return trimmed;
27
+ return null;
28
+ }
9
29
  const EditCategoryDrawer = ({ open, onOpenChange, category, salesChannels, onSuccess, onDeleteSuccess, }) => {
30
+ const [activeTab, setActiveTab] = (0, react_1.useState)("general");
10
31
  const [name, setName] = (0, react_1.useState)("");
11
32
  const [handle, setHandle] = (0, react_1.useState)("");
12
33
  const [description, setDescription] = (0, react_1.useState)("");
@@ -15,6 +36,9 @@ const EditCategoryDrawer = ({ open, onOpenChange, category, salesChannels, onSuc
15
36
  const [isActive, setIsActive] = (0, react_1.useState)(true);
16
37
  const [isInternal, setIsInternal] = (0, react_1.useState)(false);
17
38
  const [selectedChannels, setSelectedChannels] = (0, react_1.useState)([]);
39
+ const [youtubeUrls, setYoutubeUrls] = (0, react_1.useState)([]);
40
+ const [newYoutubeInput, setNewYoutubeInput] = (0, react_1.useState)("");
41
+ const [currentMetadata, setCurrentMetadata] = (0, react_1.useState)({});
18
42
  const [isSubmitting, setIsSubmitting] = (0, react_1.useState)(false);
19
43
  const [isDeleting, setIsDeleting] = (0, react_1.useState)(false);
20
44
  (0, react_1.useEffect)(() => {
@@ -27,6 +51,18 @@ const EditCategoryDrawer = ({ open, onOpenChange, category, salesChannels, onSuc
27
51
  setIsActive(Boolean(category.is_active));
28
52
  setIsInternal(Boolean(category.is_internal));
29
53
  setSelectedChannels(category.sales_channels || salesChannels.map((s) => s.id));
54
+ setCurrentMetadata(category.metadata || {});
55
+ // Parse YouTube URLs from metadata
56
+ const meta = category.metadata;
57
+ const urls = [];
58
+ if (meta?.youtube_urls && Array.isArray(meta.youtube_urls)) {
59
+ meta.youtube_urls.forEach((u) => typeof u === "string" && u.trim() && urls.push(u.trim()));
60
+ }
61
+ else if (meta?.youtube_url && typeof meta.youtube_url === "string") {
62
+ urls.push(meta.youtube_url.trim());
63
+ }
64
+ setYoutubeUrls(urls);
65
+ setActiveTab("general");
30
66
  }
31
67
  }, [category, salesChannels]);
32
68
  if (!category)
@@ -36,6 +72,27 @@ const EditCategoryDrawer = ({ open, onOpenChange, category, salesChannels, onSuc
36
72
  ? prev.filter((id) => id !== channelId)
37
73
  : [...prev, channelId]);
38
74
  };
75
+ const handleAddYoutube = () => {
76
+ const raw = newYoutubeInput.trim();
77
+ if (!raw)
78
+ return;
79
+ const id = extractYouTubeVideoId(raw);
80
+ if (!id) {
81
+ ui_1.toast.error("Please enter a valid YouTube URL or video ID");
82
+ return;
83
+ }
84
+ const cleanUrl = `https://www.youtube.com/watch?v=${id}`;
85
+ if (youtubeUrls.includes(cleanUrl)) {
86
+ ui_1.toast.error("Video already added");
87
+ return;
88
+ }
89
+ setYoutubeUrls((prev) => [...prev, cleanUrl]);
90
+ setNewYoutubeInput("");
91
+ ui_1.toast.success("YouTube video added");
92
+ };
93
+ const handleRemoveYoutube = (urlToRemove) => {
94
+ setYoutubeUrls((prev) => prev.filter((u) => u !== urlToRemove));
95
+ };
39
96
  const handleSubmit = async (e) => {
40
97
  e.preventDefault();
41
98
  if (!name.trim()) {
@@ -45,8 +102,9 @@ const EditCategoryDrawer = ({ open, onOpenChange, category, salesChannels, onSuc
45
102
  setIsSubmitting(true);
46
103
  try {
47
104
  const updatedMetadata = {
48
- ...(category.metadata || {}),
105
+ ...(currentMetadata || {}),
49
106
  sales_channels: selectedChannels,
107
+ youtube_urls: youtubeUrls,
50
108
  };
51
109
  if (imageUrl.trim()) {
52
110
  updatedMetadata.thumbnail = imageUrl.trim();
@@ -102,12 +160,31 @@ const EditCategoryDrawer = ({ open, onOpenChange, category, salesChannels, onSuc
102
160
  setIsDeleting(false);
103
161
  }
104
162
  };
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) => {
106
- ;
107
- e.target.style.display = "none";
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) => {
109
- const isChecked = selectedChannels.includes(channel.id);
110
- return ((0, jsx_runtime_1.jsxs)("label", { className: "flex items-center gap-2.5 text-xs text-ui-fg-base cursor-pointer select-none", children: [(0, jsx_runtime_1.jsx)("input", { type: "checkbox", checked: isChecked, onChange: () => toggleChannel(channel.id), className: "rounded border-ui-border-base text-blue-600 focus:ring-blue-500 cursor-pointer" }), (0, jsx_runtime_1.jsx)("span", { className: "font-medium", children: channel.name })] }, channel.id));
111
- }) })] }), (0, jsx_runtime_1.jsxs)("div", { className: "flex items-center justify-between pt-2 border-t border-ui-border-base", children: [(0, jsx_runtime_1.jsxs)("div", { children: [(0, jsx_runtime_1.jsx)(ui_1.Label, { className: "text-xs font-semibold", children: "Active Status" }), (0, jsx_runtime_1.jsx)(ui_1.Text, { size: "xsmall", className: "text-ui-fg-muted", children: "Visible to shoppers on enabled storefronts" })] }), (0, jsx_runtime_1.jsx)(ui_1.Switch, { checked: isActive, onCheckedChange: setIsActive })] }), (0, jsx_runtime_1.jsxs)("div", { className: "flex items-center justify-between", children: [(0, jsx_runtime_1.jsxs)("div", { children: [(0, jsx_runtime_1.jsx)(ui_1.Label, { className: "text-xs font-semibold", children: "Internal Only" }), (0, jsx_runtime_1.jsx)(ui_1.Text, { size: "xsmall", className: "text-ui-fg-muted", children: "Hidden from public storefront navigation" })] }), (0, jsx_runtime_1.jsx)(ui_1.Switch, { checked: isInternal, onCheckedChange: setIsInternal })] }), (0, jsx_runtime_1.jsx)("div", { className: "pt-3 border-t border-ui-border-base", children: (0, jsx_runtime_1.jsxs)(ui_1.Button, { type: "button", variant: "danger", size: "small", onClick: handleDelete, isLoading: isDeleting, className: "w-full flex items-center justify-center gap-1.5", children: [(0, jsx_runtime_1.jsx)(icons_1.Trash, { className: "w-3.5 h-3.5" }), (0, jsx_runtime_1.jsx)("span", { children: "Delete Category" })] }) })] }), (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: "Cancel" }), (0, jsx_runtime_1.jsx)(ui_1.Button, { type: "submit", size: "small", isLoading: isSubmitting, disabled: isSubmitting || !name.trim() || isDeleting, children: "Save Changes" })] })] }) }) }));
163
+ 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-3xl w-full", 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, { className: "border-b border-ui-border-base pb-3", children: [(0, jsx_runtime_1.jsxs)("div", { className: "flex items-center justify-between w-full", children: [(0, jsx_runtime_1.jsxs)("div", { children: [(0, jsx_runtime_1.jsx)(ui_1.Drawer.Title, { className: "text-base font-semibold text-ui-fg-base", children: category.name }), (0, jsx_runtime_1.jsxs)(ui_1.Drawer.Description, { className: "text-xs text-ui-fg-muted mt-0.5", children: ["Tier ", category.tier, " \u2022 ID: ", category.id] })] }), (0, jsx_runtime_1.jsxs)("button", { type: "button", onClick: () => window.open(`/app/categories/${category.id}`, "_blank"), className: "flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-ui-fg-subtle hover:text-ui-fg-base rounded-lg border border-ui-border-base bg-ui-bg-subtle hover:bg-ui-bg-base shadow-2xs transition-colors", title: "Open full category details page in a new tab", children: [(0, jsx_runtime_1.jsx)("span", { children: "Open Full Page" }), (0, jsx_runtime_1.jsx)(icons_1.ArrowUpRightMicro, { className: "w-3.5 h-3.5" })] })] }), (0, jsx_runtime_1.jsxs)("div", { className: "flex items-center gap-1 mt-3 border-b border-ui-border-base/50 -mb-3 pb-1", children: [(0, jsx_runtime_1.jsx)("button", { type: "button", onClick: () => setActiveTab("general"), className: `px-3 py-1.5 text-xs font-medium rounded-t-lg transition-colors border-b-2 -mb-[5px] ${activeTab === "general"
164
+ ? "border-ui-border-interactive text-ui-fg-base bg-ui-bg-subtle/50"
165
+ : "border-transparent text-ui-fg-muted hover:text-ui-fg-base"}`, children: "General & Ranking" }), (0, jsx_runtime_1.jsx)("button", { type: "button", onClick: () => setActiveTab("products"), className: `px-3 py-1.5 text-xs font-medium rounded-t-lg transition-colors border-b-2 -mb-[5px] ${activeTab === "products"
166
+ ? "border-ui-border-interactive text-ui-fg-base bg-ui-bg-subtle/50"
167
+ : "border-transparent text-ui-fg-muted hover:text-ui-fg-base"}`, children: "Products Management" }), (0, jsx_runtime_1.jsx)("button", { type: "button", onClick: () => setActiveTab("channels"), className: `px-3 py-1.5 text-xs font-medium rounded-t-lg transition-colors border-b-2 -mb-[5px] ${activeTab === "channels"
168
+ ? "border-ui-border-interactive text-ui-fg-base bg-ui-bg-subtle/50"
169
+ : "border-transparent text-ui-fg-muted hover:text-ui-fg-base"}`, children: "Storefronts & Media" }), (0, jsx_runtime_1.jsxs)("button", { type: "button", onClick: () => setActiveTab("youtube"), className: `px-3 py-1.5 text-xs font-medium rounded-t-lg transition-colors border-b-2 -mb-[5px] ${activeTab === "youtube"
170
+ ? "border-ui-border-interactive text-ui-fg-base bg-ui-bg-subtle/50"
171
+ : "border-transparent text-ui-fg-muted hover:text-ui-fg-base"}`, children: ["YouTube Videos (", youtubeUrls.length, ")"] })] })] }), (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: [activeTab === "general" && ((0, jsx_runtime_1.jsxs)("div", { className: "flex flex-col gap-4", 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 on your storefront navigation menu." })] }), (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: 3 })] }), (0, jsx_runtime_1.jsxs)("div", { className: "flex items-center justify-between pt-2 border-t border-ui-border-base", children: [(0, jsx_runtime_1.jsxs)("div", { children: [(0, jsx_runtime_1.jsx)(ui_1.Label, { className: "text-xs font-semibold", children: "Active Status" }), (0, jsx_runtime_1.jsx)(ui_1.Text, { size: "xsmall", className: "text-ui-fg-muted", children: "Visible to shoppers on enabled storefronts" })] }), (0, jsx_runtime_1.jsx)(ui_1.Switch, { checked: isActive, onCheckedChange: setIsActive })] }), (0, jsx_runtime_1.jsxs)("div", { className: "flex items-center justify-between", children: [(0, jsx_runtime_1.jsxs)("div", { children: [(0, jsx_runtime_1.jsx)(ui_1.Label, { className: "text-xs font-semibold", children: "Internal Only" }), (0, jsx_runtime_1.jsx)(ui_1.Text, { size: "xsmall", className: "text-ui-fg-muted", children: "Hidden from public storefront navigation" })] }), (0, jsx_runtime_1.jsx)(ui_1.Switch, { checked: isInternal, onCheckedChange: setIsInternal })] }), (0, jsx_runtime_1.jsx)("div", { className: "pt-3 border-t border-ui-border-base", children: (0, jsx_runtime_1.jsxs)(ui_1.Button, { type: "button", variant: "danger", size: "small", onClick: handleDelete, isLoading: isDeleting, className: "w-full flex items-center justify-center gap-1.5", children: [(0, jsx_runtime_1.jsx)(icons_1.Trash, { className: "w-3.5 h-3.5" }), (0, jsx_runtime_1.jsx)("span", { children: "Delete Category" })] }) })] })), activeTab === "products" && ((0, jsx_runtime_1.jsx)("div", { className: "flex flex-col gap-3", children: (0, jsx_runtime_1.jsx)(category_products_manager_1.CategoryProductsManager, { categoryId: category.id, categoryName: category.name, initialMetadata: currentMetadata, onMetadataUpdate: (meta) => {
172
+ setCurrentMetadata(meta);
173
+ } }) })), activeTab === "channels" && ((0, jsx_runtime_1.jsxs)("div", { className: "flex flex-col gap-4", children: [(0, jsx_runtime_1.jsxs)("div", { className: "flex flex-col gap-2", 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 storefront sales channels display this category." }), (0, jsx_runtime_1.jsx)("div", { className: "flex flex-col gap-2 mt-1", children: salesChannels.map((channel) => {
174
+ const isChecked = selectedChannels.includes(channel.id);
175
+ return ((0, jsx_runtime_1.jsxs)("label", { className: "flex items-center gap-2.5 text-xs text-ui-fg-base cursor-pointer select-none p-2 rounded-lg border border-ui-border-base bg-ui-bg-subtle/40 hover:bg-ui-bg-subtle", children: [(0, jsx_runtime_1.jsx)("input", { type: "checkbox", checked: isChecked, onChange: () => toggleChannel(channel.id), className: "rounded border-ui-border-base text-blue-600 focus:ring-blue-500 cursor-pointer" }), (0, jsx_runtime_1.jsx)("span", { className: "font-medium", children: channel.name })] }, channel.id));
176
+ }) })] }), (0, jsx_runtime_1.jsxs)("div", { className: "flex flex-col gap-1.5 pt-3 border-t border-ui-border-base", children: [(0, jsx_runtime_1.jsx)(ui_1.Label, { htmlFor: "edit-image", className: "text-xs font-semibold", children: "Category Thumbnail / Image URL" }), (0, jsx_runtime_1.jsx)(ui_1.Input, { id: "edit-image", size: "small", value: imageUrl, onChange: (e) => setImageUrl(e.target.value), placeholder: "https://example.com/category-image.jpg" }), imageUrl && ((0, jsx_runtime_1.jsx)("div", { className: "mt-2 w-24 h-24 rounded-xl overflow-hidden border border-ui-border-base bg-ui-bg-subtle shadow-xs", children: (0, jsx_runtime_1.jsx)("img", { src: imageUrl, alt: "Category Preview", className: "w-full h-full object-cover", onError: (e) => {
177
+ ;
178
+ e.target.style.display = "none";
179
+ } }) }))] })] })), activeTab === "youtube" && ((0, jsx_runtime_1.jsxs)("div", { className: "flex flex-col gap-4", children: [(0, jsx_runtime_1.jsxs)("div", { children: [(0, jsx_runtime_1.jsx)("h4", { className: "text-xs font-semibold text-ui-fg-base", children: "Category Demonstration Videos (YouTube)" }), (0, jsx_runtime_1.jsx)("p", { className: "text-xs text-ui-fg-muted mt-0.5", children: "Attach YouTube video demonstration links to show interactive video carousels on the storefront category page." })] }), (0, jsx_runtime_1.jsxs)("div", { className: "flex items-center gap-2", children: [(0, jsx_runtime_1.jsx)(ui_1.Input, { size: "small", placeholder: "Paste YouTube watch URL, shorts, or video ID\u2026", value: newYoutubeInput, onChange: (e) => setNewYoutubeInput(e.target.value), className: "flex-1 text-xs", onKeyDown: (e) => {
180
+ if (e.key === "Enter") {
181
+ e.preventDefault();
182
+ handleAddYoutube();
183
+ }
184
+ } }), (0, jsx_runtime_1.jsxs)(ui_1.Button, { size: "small", type: "button", variant: "secondary", onClick: handleAddYoutube, className: "flex items-center gap-1 text-xs flex-shrink-0", children: [(0, jsx_runtime_1.jsx)(icons_1.Plus, { className: "w-3.5 h-3.5" }), (0, jsx_runtime_1.jsx)("span", { children: "Add Video" })] })] }), youtubeUrls.length === 0 ? ((0, jsx_runtime_1.jsx)("div", { className: "p-8 text-center text-xs text-ui-fg-muted border border-dashed border-ui-border-base rounded-xl", children: "No demonstration videos attached yet." })) : ((0, jsx_runtime_1.jsx)("div", { className: "grid grid-cols-2 gap-3", children: youtubeUrls.map((url) => {
185
+ const id = extractYouTubeVideoId(url);
186
+ const thumb = id ? `https://img.youtube.com/vi/${id}/hqdefault.jpg` : null;
187
+ 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", 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));
188
+ }) }))] }))] }), (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: "submit", size: "small", isLoading: isSubmitting, disabled: isSubmitting || !name.trim() || isDeleting, children: "Save Category" })] })] }) }) }));
112
189
  };
113
190
  exports.EditCategoryDrawer = EditCategoryDrawer;
@@ -110,17 +110,25 @@ function computeTreeLayout(nodes, edges, options = {}) {
110
110
  }
111
111
  collect(tree);
112
112
  });
113
- // Style edges to match source branch color if available
113
+ // Style edges to match branch color
114
114
  const styledEdges = edges.map((e) => {
115
115
  const sourceNode = nodeMap.get(e.source);
116
- const branchColor = sourceNode?.data?.branchColor || "#64748b";
116
+ const targetNode = nodeMap.get(e.target);
117
+ const branchColor = e.data?.branchColor ||
118
+ targetNode?.data?.branchColor ||
119
+ sourceNode?.data?.branchColor ||
120
+ "#64748b";
117
121
  return {
118
122
  ...e,
123
+ data: {
124
+ ...e.data,
125
+ branchColor,
126
+ },
119
127
  style: {
128
+ ...e.style,
120
129
  stroke: branchColor,
121
130
  strokeWidth: 2,
122
- opacity: 0.85,
123
- ...e.style,
131
+ opacity: 0.95,
124
132
  },
125
133
  };
126
134
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hectasquare/medusa-plugin-category-tree",
3
- "version": "1.0.2",
3
+ "version": "1.0.5",
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": "MIT",