Initial commit - WhyRating Engine (Google Reviews Scraper)
This commit is contained in:
435
web/app/categories/page.tsx
Normal file
435
web/app/categories/page.tsx
Normal file
@@ -0,0 +1,435 @@
|
||||
'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: () => <div className="flex items-center justify-center h-full"><Loader2 className="animate-spin" /></div>,
|
||||
});
|
||||
|
||||
// API base URL
|
||||
const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8001';
|
||||
|
||||
export default function CategoriesPage() {
|
||||
const [categories, setCategories] = useState<Category[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [viewMode, setViewMode] = useState<'explorer' | 'diagram'>('explorer');
|
||||
const [expandedPaths, setExpandedPaths] = useState<Set<string>>(new Set());
|
||||
const [selectedCategory, setSelectedCategory] = useState<Category | null>(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<string>(
|
||||
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<string>();
|
||||
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 (
|
||||
<div key={node.id} className="select-none">
|
||||
<div
|
||||
className={`flex items-center gap-2 py-1.5 px-2 rounded cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-800 ${
|
||||
isSelected ? 'bg-blue-50 dark:bg-blue-900/30 border-l-2 border-blue-500' : ''
|
||||
}`}
|
||||
style={{ paddingLeft: `${depth * 20 + 8}px` }}
|
||||
onClick={() => {
|
||||
setSelectedCategory(node.data || null);
|
||||
if (hasChildren) {
|
||||
toggleExpand(node.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{/* Expand/Collapse Icon */}
|
||||
<span className="w-4 h-4 flex items-center justify-center">
|
||||
{hasChildren ? (
|
||||
isExpanded ? (
|
||||
<ChevronDown className="w-4 h-4 text-gray-500" />
|
||||
) : (
|
||||
<ChevronRight className="w-4 h-4 text-gray-500" />
|
||||
)
|
||||
) : (
|
||||
<span className="w-4" />
|
||||
)}
|
||||
</span>
|
||||
|
||||
{/* Folder/Tag Icon */}
|
||||
{hasChildren ? (
|
||||
isExpanded ? (
|
||||
<FolderOpen className="w-4 h-4 text-yellow-500" />
|
||||
) : (
|
||||
<Folder className="w-4 h-4 text-yellow-600" />
|
||||
)
|
||||
) : (
|
||||
<Tag className="w-4 h-4 text-purple-500" />
|
||||
)}
|
||||
|
||||
{/* Name */}
|
||||
<span className="flex-1 truncate text-sm">{node.name}</span>
|
||||
|
||||
{/* Level Badge */}
|
||||
<span
|
||||
className={`text-xs px-1.5 py-0.5 rounded ${getLevelColor(level)} text-white`}
|
||||
>
|
||||
L{level}
|
||||
</span>
|
||||
|
||||
{/* Count */}
|
||||
{node.data && node.data.category_count > 0 && (
|
||||
<span className="text-xs text-gray-400">
|
||||
({node.data.category_count})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Children */}
|
||||
{hasChildren && isExpanded && (
|
||||
<div>
|
||||
{node.children!.map((child) => renderTreeNode(child, depth + 1))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-blue-500" />
|
||||
<span className="ml-2">Loading categories...</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-screen">
|
||||
<p className="text-red-500 mb-4">{error}</p>
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
|
||||
{/* Header */}
|
||||
<header className="bg-white dark:bg-gray-800 shadow-sm border-b">
|
||||
<div className="max-w-7xl mx-auto px-4 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
GBP Category Explorer
|
||||
</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
Browse {stats.total.toLocaleString()} Google Business Profile categories
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="flex gap-4 text-sm">
|
||||
<div className="text-center">
|
||||
<div className="font-bold text-blue-600">{stats.sectors}</div>
|
||||
<div className="text-gray-500">Sectors</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="font-bold text-green-600">{stats.types}</div>
|
||||
<div className="text-gray-500">Types</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="font-bold text-yellow-600">{stats.subs}</div>
|
||||
<div className="text-gray-500">Sub-cats</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="font-bold text-purple-600">{stats.leaves}</div>
|
||||
<div className="text-gray-500">Categories</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Toolbar */}
|
||||
<div className="bg-white dark:bg-gray-800 border-b px-4 py-3">
|
||||
<div className="max-w-7xl mx-auto flex items-center gap-4">
|
||||
{/* Search */}
|
||||
<div className="relative flex-1 max-w-md">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search categories..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* View Toggle */}
|
||||
<div className="flex border rounded-lg overflow-hidden">
|
||||
<button
|
||||
onClick={() => setViewMode('explorer')}
|
||||
className={`px-4 py-2 flex items-center gap-2 ${
|
||||
viewMode === 'explorer'
|
||||
? 'bg-blue-500 text-white'
|
||||
: 'bg-white dark:bg-gray-700 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
<TreePine className="w-4 h-4" />
|
||||
Explorer
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setViewMode('diagram')}
|
||||
className={`px-4 py-2 flex items-center gap-2 ${
|
||||
viewMode === 'diagram'
|
||||
? 'bg-blue-500 text-white'
|
||||
: 'bg-white dark:bg-gray-700 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
<Network className="w-4 h-4" />
|
||||
Diagram
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Results count */}
|
||||
{searchQuery && (
|
||||
<span className="text-sm text-gray-500">
|
||||
{filteredCategories.length} results
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="px-4 py-6">
|
||||
{viewMode === 'explorer' ? (
|
||||
/* Explorer View - Full Width */
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-4">
|
||||
{tree.length > 0 ? (
|
||||
tree.map((node) => renderTreeNode(node))
|
||||
) : (
|
||||
<div className="text-center text-gray-500 py-8">
|
||||
No categories found
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
/* Diagram View with Detail Panel */
|
||||
<div className="flex gap-6 h-[calc(100vh-180px)]">
|
||||
<div className="flex-1 bg-white dark:bg-gray-800 rounded-lg shadow overflow-hidden">
|
||||
<div className="h-full w-full">
|
||||
{d3TreeData ? (
|
||||
<Tree
|
||||
data={d3TreeData}
|
||||
orientation="vertical"
|
||||
pathFunc="step"
|
||||
translate={{ x: 400, y: 50 }}
|
||||
separation={{ siblings: 1, nonSiblings: 2 }}
|
||||
nodeSize={{ x: 200, y: 80 }}
|
||||
renderCustomNodeElement={({ nodeDatum, toggleNode }) => (
|
||||
<g onClick={toggleNode}>
|
||||
<circle r={15} fill="#3b82f6" />
|
||||
<text
|
||||
fill="#1f2937"
|
||||
strokeWidth="0"
|
||||
x={20}
|
||||
dy=".35em"
|
||||
fontSize={12}
|
||||
fontFamily="sans-serif"
|
||||
>
|
||||
{nodeDatum.name.length > 25
|
||||
? nodeDatum.name.slice(0, 25) + '...'
|
||||
: nodeDatum.name}
|
||||
</text>
|
||||
</g>
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-full text-gray-500">
|
||||
No data to display
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Detail Panel - Only in Diagram Mode */}
|
||||
{selectedCategory && (
|
||||
<div className="w-80 bg-white dark:bg-gray-800 rounded-lg shadow p-4">
|
||||
<h3 className="font-bold text-lg mb-4">{selectedCategory.name}</h3>
|
||||
|
||||
{/* Breadcrumb */}
|
||||
<div className="mb-4">
|
||||
<span className="text-xs text-gray-500 uppercase">Path</span>
|
||||
<div className="flex flex-wrap gap-1 mt-1">
|
||||
{breadcrumb.map((cat, i) => (
|
||||
<span key={cat.path} className="flex items-center">
|
||||
<span
|
||||
className={`text-xs px-2 py-1 rounded ${getLevelColor(cat.level)} text-white cursor-pointer hover:opacity-80`}
|
||||
onClick={() => setSelectedCategory(cat)}
|
||||
>
|
||||
{cat.name}
|
||||
</span>
|
||||
{i < breadcrumb.length - 1 && (
|
||||
<ChevronRight className="w-3 h-3 text-gray-400 mx-1" />
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Details */}
|
||||
<div className="space-y-3 text-sm">
|
||||
<div>
|
||||
<span className="text-gray-500">Level:</span>
|
||||
<span className="ml-2 font-medium">
|
||||
{getLevelName(selectedCategory.level)} (L{selectedCategory.level})
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500">Path:</span>
|
||||
<code className="ml-2 text-xs bg-gray-100 dark:bg-gray-700 px-2 py-1 rounded">
|
||||
{selectedCategory.path}
|
||||
</code>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500">Children:</span>
|
||||
<span className="ml-2 font-medium">
|
||||
{selectedCategory.category_count}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500">Slug:</span>
|
||||
<code className="ml-2 text-xs bg-gray-100 dark:bg-gray-700 px-2 py-1 rounded">
|
||||
{selectedCategory.slug}
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Use in search */}
|
||||
<div className="mt-6 pt-4 border-t">
|
||||
<button
|
||||
className="w-full py-2 px-4 bg-blue-500 text-white rounded hover:bg-blue-600 text-sm"
|
||||
onClick={() => {
|
||||
// Copy ltree path for use in queries
|
||||
navigator.clipboard.writeText(selectedCategory.path);
|
||||
alert('Path copied to clipboard!');
|
||||
}}
|
||||
>
|
||||
Copy Path for Query
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user