'use client'; import { useState, useEffect, useMemo, useCallback } from 'react'; import { Search, TreePine, Network, ChevronRight, ChevronDown, Folder, FolderOpen, Tag, Loader2 } from 'lucide-react'; import dynamic from 'next/dynamic'; import { Category, CategoryTreeNode, buildCategoryTree, toD3Tree, getLevelName, getLevelColor, searchCategories, getCategoryBreadcrumb, } from '@/lib/categories'; // Dynamic import for react-d3-tree (SSR issues) const Tree = dynamic(() => import('react-d3-tree').then((mod) => mod.default), { ssr: false, loading: () =>
, }); // API base URL const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8001'; export default function CategoriesPage() { const [categories, setCategories] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [searchQuery, setSearchQuery] = useState(''); const [viewMode, setViewMode] = useState<'explorer' | 'diagram'>('explorer'); const [expandedPaths, setExpandedPaths] = useState>(new Set()); const [selectedCategory, setSelectedCategory] = useState(null); const [stats, setStats] = useState({ total: 0, sectors: 0, types: 0, subs: 0, leaves: 0 }); // Fetch categories from API useEffect(() => { async function fetchCategories() { try { setLoading(true); const response = await fetch(`${API_BASE}/categories`); if (!response.ok) { throw new Error('Failed to fetch categories'); } const data = await response.json(); setCategories(data.categories || []); setStats({ total: data.total || 0, sectors: data.categories?.filter((c: Category) => c.level === 1).length || 0, types: data.categories?.filter((c: Category) => c.level === 2).length || 0, subs: data.categories?.filter((c: Category) => c.level === 3).length || 0, leaves: data.categories?.filter((c: Category) => c.level === 4).length || 0, }); // Expand level 1 by default const level1Paths = new Set( data.categories ?.filter((c: Category) => c.level === 1) .map((c: Category) => c.path) || [] ); setExpandedPaths(level1Paths); } catch (err) { console.error('Error fetching categories:', err); setError(err instanceof Error ? err.message : 'Failed to load categories'); } finally { setLoading(false); } } fetchCategories(); }, []); // Filter categories based on search const filteredCategories = useMemo(() => { if (!searchQuery.trim()) return categories; return searchCategories(categories, searchQuery); }, [categories, searchQuery]); // Build tree structure const tree = useMemo(() => buildCategoryTree(filteredCategories), [filteredCategories]); // D3 tree data const d3TreeData = useMemo(() => { if (tree.length === 0) return null; return { name: 'GBP Categories', children: toD3Tree(tree), }; }, [tree]); // Toggle expand/collapse const toggleExpand = useCallback((path: string) => { setExpandedPaths((prev) => { const next = new Set(prev); if (next.has(path)) { next.delete(path); } else { next.add(path); } return next; }); }, []); // Expand all ancestors when searching useEffect(() => { if (searchQuery.trim()) { const pathsToExpand = new Set(); for (const cat of filteredCategories) { const parts = cat.path.split('.'); for (let i = 1; i < parts.length; i++) { pathsToExpand.add(parts.slice(0, i).join('.')); } } setExpandedPaths(pathsToExpand); } }, [searchQuery, filteredCategories]); // Get breadcrumb for selected category const breadcrumb = useMemo(() => { if (!selectedCategory) return []; return getCategoryBreadcrumb(selectedCategory.path, categories); }, [selectedCategory, categories]); // Render tree node (recursive) const renderTreeNode = (node: CategoryTreeNode, depth: number = 0) => { const isExpanded = expandedPaths.has(node.id); const hasChildren = node.children && node.children.length > 0; const isSelected = selectedCategory?.path === node.id; const level = node.data?.level || 1; return (
{ setSelectedCategory(node.data || null); if (hasChildren) { toggleExpand(node.id); } }} > {/* Expand/Collapse Icon */} {hasChildren ? ( isExpanded ? ( ) : ( ) ) : ( )} {/* Folder/Tag Icon */} {hasChildren ? ( isExpanded ? ( ) : ( ) ) : ( )} {/* Name */} {node.name} {/* Level Badge */} L{level} {/* Count */} {node.data && node.data.category_count > 0 && ( ({node.data.category_count}) )}
{/* Children */} {hasChildren && isExpanded && (
{node.children!.map((child) => renderTreeNode(child, depth + 1))}
)}
); }; if (loading) { return (
Loading categories...
); } if (error) { return (

{error}

); } return (
{/* Header */}

GBP Category Explorer

Browse {stats.total.toLocaleString()} Google Business Profile categories

{/* Stats */}
{stats.sectors}
Sectors
{stats.types}
Types
{stats.subs}
Sub-cats
{stats.leaves}
Categories
{/* Toolbar */}
{/* Search */}
setSearchQuery(e.target.value)} className="w-full pl-10 pr-4 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600" />
{/* View Toggle */}
{/* Results count */} {searchQuery && ( {filteredCategories.length} results )}
{/* Main Content */}
{viewMode === 'explorer' ? ( /* Explorer View - Full Width */
{tree.length > 0 ? ( tree.map((node) => renderTreeNode(node)) ) : (
No categories found
)}
) : ( /* Diagram View with Detail Panel */
{d3TreeData ? ( ( {nodeDatum.name.length > 25 ? nodeDatum.name.slice(0, 25) + '...' : nodeDatum.name} )} /> ) : (
No data to display
)}
{/* Detail Panel - Only in Diagram Mode */} {selectedCategory && (

{selectedCategory.name}

{/* Breadcrumb */}
Path
{breadcrumb.map((cat, i) => ( setSelectedCategory(cat)} > {cat.name} {i < breadcrumb.length - 1 && ( )} ))}
{/* Details */}
Level: {getLevelName(selectedCategory.level)} (L{selectedCategory.level})
Path: {selectedCategory.path}
Children: {selectedCategory.category_count}
Slug: {selectedCategory.slug}
{/* Use in search */}
)}
)}
); }