'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.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 */}
)}
)}
);
}