"use client"; import { useEffect, useMemo, useRef, useState } from "react"; import { categoryLabel, type MeshkeeCategory, } from "@/services/meshkee-categories"; import styles from "./CategoryTreeSelect.module.css"; type TreeNode = MeshkeeCategory & { children: TreeNode[] }; function buildTree(categories: MeshkeeCategory[]): TreeNode[] { const byId = new Map(); for (const category of categories) { byId.set(category.id, { ...category, children: [] }); } const roots: TreeNode[] = []; for (const node of byId.values()) { if (node.parentId && byId.has(node.parentId)) { byId.get(node.parentId)!.children.push(node); } else { roots.push(node); } } const sortNodes = (nodes: TreeNode[]) => { nodes.sort( (a, b) => a.sortOrder - b.sortOrder || a.name.localeCompare(b.name, undefined, { sensitivity: "base" }), ); for (const node of nodes) sortNodes(node.children); }; sortNodes(roots); return roots; } function matchesQuery(category: MeshkeeCategory, q: string) { if (!q) return true; const hay = [category.name, category.nameFa ?? "", category.slug, ...category.tags] .join(" ") .toLowerCase(); return hay.includes(q); } /** Keep ancestors of matches so the tree path stays visible while searching. */ function filterTree(nodes: TreeNode[], q: string): TreeNode[] { if (!q) return nodes; const result: TreeNode[] = []; for (const node of nodes) { const children = filterTree(node.children, q); if (matchesQuery(node, q) || children.length) { result.push({ ...node, children }); } } return result; } function collectExpandIds(nodes: TreeNode[], into: Set) { for (const node of nodes) { if (node.children.length) { into.add(node.id); collectExpandIds(node.children, into); } } } type Props = { categories: MeshkeeCategory[]; value: string[]; onChange: (ids: string[]) => void; disabled?: boolean; label?: string; /** multi (default) or single select */ selection?: "single" | "multiple"; /** overlay floats; inFlow expands inside the parent (better in modals) */ dropdownVariant?: "overlay" | "inFlow"; placeholder?: string; }; export default function CategoryTreeSelect({ categories, value, onChange, disabled = false, label = "Category", selection = "multiple", dropdownVariant = "overlay", placeholder = "Search categories…", }: Props) { const rootRef = useRef(null); const [open, setOpen] = useState(false); const [query, setQuery] = useState(""); const [expanded, setExpanded] = useState>(() => new Set()); const single = selection === "single"; const tree = useMemo(() => buildTree(categories), [categories]); const q = query.trim().toLowerCase(); const visibleTree = useMemo(() => filterTree(tree, q), [tree, q]); const byId = useMemo(() => { const map = new Map(); for (const category of categories) map.set(category.id, category); return map; }, [categories]); const selected = value .map((id) => byId.get(id)) .filter((item): item is MeshkeeCategory => Boolean(item)); const selectedLabel = selected[0] ? categoryLabel(selected[0]) : ""; useEffect(() => { if (!q) return; const next = new Set(); collectExpandIds(visibleTree, next); setExpanded(next); }, [q, visibleTree]); useEffect(() => { if (!open) return; function onPointerDown(event: MouseEvent) { if (!rootRef.current?.contains(event.target as Node)) { setOpen(false); if (single) setQuery(""); } } function onKey(event: KeyboardEvent) { if (event.key === "Escape") { setOpen(false); if (single) setQuery(""); } } document.addEventListener("mousedown", onPointerDown); document.addEventListener("keydown", onKey); return () => { document.removeEventListener("mousedown", onPointerDown); document.removeEventListener("keydown", onKey); }; }, [open, single]); function pick(id: string) { if (single) { onChange([id]); setOpen(false); setQuery(""); return; } const next = value.includes(id) ? value.filter((item) => item !== id) : [...value, id]; onChange(next); } function toggleExpand(id: string) { setExpanded((prev) => { const next = new Set(prev); if (next.has(id)) next.delete(id); else next.add(id); return next; }); } function renderNodes(nodes: TreeNode[], depth: number) { return nodes.map((node) => { const hasChildren = node.children.length > 0; const isExpanded = q ? true : expanded.has(node.id); const active = value.includes(node.id); return (
  • {hasChildren ? ( ) : ( )}
    {hasChildren && isExpanded ? (
      {renderNodes(node.children, depth + 1)}
    ) : null}
  • ); }); } const inputValue = open || !single ? query : selectedLabel; return (
    {open ? (
    {visibleTree.length ? (
      {renderNodes(visibleTree, 0)}
    ) : (

    No categories found

    )}
    ) : null}
    {!single && selected.length ? (
    {selected.map((category) => ( ))}
    ) : null}
    ); }