/** * Keywords Page - Refactored to use TablePageTemplate * This demonstrates how to use the config-driven template system * while maintaining all existing functionality. */ import { useState, useEffect, useRef, useMemo, useCallback } from 'react'; import TablePageTemplate from '../../templates/TablePageTemplate'; import { fetchKeywords, createKeyword, updateKeyword, deleteKeyword, bulkDeleteKeywords, bulkUpdateKeywordsStatus, Keyword, KeywordFilters, KeywordCreateData, fetchClusters, Cluster, API_BASE_URL, autoClusterKeywords, } from '../../services/api'; import { useSiteStore } from '../../store/siteStore'; import { useSectorStore } from '../../store/sectorStore'; import { usePageSizeStore } from '../../store/pageSizeStore'; import PageHeader from '../../components/common/PageHeader'; import ModuleNavigationTabs from '../../components/navigation/ModuleNavigationTabs'; import ModuleMetricsFooter, { MetricItem, ProgressMetric } from '../../components/dashboard/ModuleMetricsFooter'; import { getDifficultyLabelFromNumber, getDifficultyRange } from '../../utils/difficulty'; import FormModal from '../../components/common/FormModal'; import ProgressModal from '../../components/common/ProgressModal'; import { useProgressModal } from '../../hooks/useProgressModal'; import { useToast } from '../../components/ui/toast/ToastContainer'; import { ArrowUpIcon, PlusIcon, ListIcon, DownloadIcon, GroupIcon, BoltIcon } from '../../icons'; import { useKeywordsImportExport } from '../../config/import-export.config'; import { createKeywordsPageConfig } from '../../config/pages/keywords.config'; export default function Keywords() { const toast = useToast(); const { activeSite } = useSiteStore(); const { activeSector, loadSectorsForSite } = useSectorStore(); const { pageSize } = usePageSizeStore(); // Data state const [keywords, setKeywords] = useState([]); const [clusters, setClusters] = useState([]); const [loading, setLoading] = useState(true); // Filter state - match Keywords.tsx const [searchTerm, setSearchTerm] = useState(''); const [statusFilter, setStatusFilter] = useState(''); const [clusterFilter, setClusterFilter] = useState(''); const [countryFilter, setCountryFilter] = useState(''); const [difficultyFilter, setDifficultyFilter] = useState(''); const [volumeMin, setVolumeMin] = useState(''); const [volumeMax, setVolumeMax] = useState(''); const [isVolumeDropdownOpen, setIsVolumeDropdownOpen] = useState(false); const [tempVolumeMin, setTempVolumeMin] = useState(''); const [tempVolumeMax, setTempVolumeMax] = useState(''); const volumeDropdownRef = useRef(null); const volumeButtonRef = useRef(null); const [selectedIds, setSelectedIds] = useState([]); // Pagination state const [currentPage, setCurrentPage] = useState(1); const [totalPages, setTotalPages] = useState(1); const [totalCount, setTotalCount] = useState(0); // Sorting state (global - not page-specific) const [sortBy, setSortBy] = useState('created_at'); const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc'); const [showContent, setShowContent] = useState(false); // Modal state const [isModalOpen, setIsModalOpen] = useState(false); const [isEditMode, setIsEditMode] = useState(false); const [editingKeyword, setEditingKeyword] = useState(null); const [formData, setFormData] = useState({ keyword: '', volume: null, difficulty: null, country: 'US', cluster_id: null, status: 'new', }); // Progress modal for AI functions const progressModal = useProgressModal(); const hasReloadedRef = useRef(false); // Load sectors for active site using sector store useEffect(() => { if (activeSite) { loadSectorsForSite(activeSite.id); } }, [activeSite, loadSectorsForSite]); // Load clusters for filter dropdown useEffect(() => { const loadClusters = async () => { try { const data = await fetchClusters({ ordering: 'name' }); setClusters(data.results || []); } catch (error) { console.error('Error fetching clusters:', error); } }; loadClusters(); }, []); // Load keywords - wrapped in useCallback to prevent infinite loops const loadKeywords = useCallback(async () => { setLoading(true); setShowContent(false); // Reset showContent to show loading state try { // Build ordering parameter from sort state const ordering = sortBy ? `${sortDirection === 'desc' ? '-' : ''}${sortBy}` : '-created_at'; // Build filters object from individual filter states // Only include filters that have actual values (not empty strings) // Use activeSector from store for sector filtering const filters: KeywordFilters = { ...(searchTerm && { search: searchTerm }), ...(statusFilter && { status: statusFilter }), ...(clusterFilter && { cluster_id: clusterFilter }), ...(countryFilter && { country: countryFilter }), ...(activeSector?.id && { sector_id: activeSector.id }), page: currentPage, page_size: pageSize || 10, // Ensure we always send a page_size ordering, }; // Add difficulty range filter if (difficultyFilter) { const difficultyNum = parseInt(difficultyFilter); const label = getDifficultyLabelFromNumber(difficultyNum); if (label !== null) { const range = getDifficultyRange(label); if (range) { filters.difficulty_min = range.min; filters.difficulty_max = range.max; } } } // Add volume range filters if (volumeMin !== '' && volumeMin !== null && volumeMin !== undefined) { filters.volume_min = Number(volumeMin); } if (volumeMax !== '' && volumeMax !== null && volumeMax !== undefined) { filters.volume_max = Number(volumeMax); } const data = await fetchKeywords(filters); setKeywords(data.results || []); setTotalCount(data.count || 0); setTotalPages(Math.ceil((data.count || 0) / pageSize)); // Show content after data loads (smooth reveal) setTimeout(() => { setShowContent(true); setLoading(false); }, 100); } catch (error: any) { console.error('Error loading keywords:', error); toast.error(`Failed to load keywords: ${error.message}`); setShowContent(true); setLoading(false); } }, [currentPage, statusFilter, clusterFilter, countryFilter, difficultyFilter, volumeMin, volumeMax, sortBy, sortDirection, searchTerm, activeSite, activeSector, pageSize]); // Listen for site and sector changes and refresh data useEffect(() => { const handleSiteChange = () => { // Reload keywords when site changes (sector store will auto-select first sector) loadKeywords(); // Reload clusters for new site const loadClusters = async () => { try { const data = await fetchClusters({ ordering: 'name' }); setClusters(data.results || []); } catch (error) { console.error('Error fetching clusters:', error); } }; loadClusters(); }; const handleSectorChange = () => { // Reload keywords when sector changes loadKeywords(); }; window.addEventListener('siteChanged', handleSiteChange); window.addEventListener('sectorChanged', handleSectorChange); return () => { window.removeEventListener('siteChanged', handleSiteChange); window.removeEventListener('sectorChanged', handleSectorChange); }; }, [loadKeywords]); // Handle click outside volume dropdown useEffect(() => { const handleClickOutside = (event: MouseEvent) => { if ( volumeDropdownRef.current && !volumeDropdownRef.current.contains(event.target as Node) && volumeButtonRef.current && !volumeButtonRef.current.contains(event.target as Node) ) { setIsVolumeDropdownOpen(false); setTempVolumeMin(volumeMin); setTempVolumeMax(volumeMax); } }; if (isVolumeDropdownOpen) { document.addEventListener("mousedown", handleClickOutside); return () => { document.removeEventListener("mousedown", handleClickOutside); }; } }, [isVolumeDropdownOpen, volumeMin, volumeMax]); // Load data on mount and when filters change (excluding search - handled separately) useEffect(() => { loadKeywords(); }, [loadKeywords]); // Debounced search - reset to page 1 when search term changes useEffect(() => { const timer = setTimeout(() => { // Always reset to page 1 when search changes // The main useEffect will handle reloading when currentPage changes setCurrentPage(1); }, 500); return () => clearTimeout(timer); }, [searchTerm]); // Only depend on searchTerm // Handle pageSize changes - reload data when pageSize changes // Note: TablePageTemplate already calls onPageChange(1), but we need to ensure reload happens useEffect(() => { // When pageSize changes: // 1. Reset to page 1 (TablePageTemplate does this, but we ensure it) // 2. loadKeywords will be recreated because pageSize is in its dependency array // 3. The useEffect that depends on loadKeywords will fire and reload data // But we need to ensure reload happens even if currentPage is already 1 const wasOnPage1 = currentPage === 1; setCurrentPage(1); // If we were already on page 1, explicitly trigger reload // Otherwise, the currentPage change will trigger reload via loadKeywords dependency if (wasOnPage1) { // Use a small timeout to ensure state has updated setTimeout(() => { loadKeywords(); }, 0); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [pageSize]); // Only depend on pageSize - loadKeywords will be recreated when pageSize changes // Handle sorting (global) const handleSort = (field: string, direction: 'asc' | 'desc') => { setSortBy(field || 'created_at'); setSortDirection(direction); setCurrentPage(1); // Reset to first page when sorting changes }; // Import/Export handlers const { handleExport, handleImportClick, ImportModal } = useKeywordsImportExport( () => { toast.success('Import successful', 'Keywords imported successfully.'); loadKeywords(); }, (error) => { toast.error('Import failed', error.message); }, // Pass active site_id and active sector_id for import activeSite && activeSector ? { site_id: activeSite.id, sector_id: activeSector.id } : undefined ); // Handle bulk actions (delete, export, update_status are now handled by TablePageTemplate) // This is only for actions that don't have modals (like auto_cluster) const handleBulkAction = useCallback(async (action: string, ids: string[]) => { if (action === 'auto_cluster') { if (ids.length === 0) { toast.error('Please select at least one keyword to cluster'); return; } if (ids.length > 50) { toast.error('Maximum 50 keywords allowed for clustering'); return; } const numIds = ids.map(id => parseInt(id)); const sectorId = activeSector?.id; const selectedKeywords = keywords.filter(k => numIds.includes(k.id)); try { const result = await autoClusterKeywords(numIds, sectorId); // Check if result has success field - if false, it's an error response if (result && result.success === false) { // Error response from API const errorMsg = result.error || 'Failed to cluster keywords'; toast.error(errorMsg); return; } if (result && result.success) { if (result.task_id) { // Async task - open progress modal hasReloadedRef.current = false; progressModal.openModal(result.task_id, 'Auto-Clustering Keywords', 'ai-auto-cluster-01'); // Don't show toast - progress modal will show status } else { // Synchronous completion toast.success(`Clustering complete: ${result.clusters_created || 0} clusters created, ${result.keywords_updated || 0} keywords updated`); if (!hasReloadedRef.current) { hasReloadedRef.current = true; loadKeywords(); } } } else { // Unexpected response format - show error const errorMsg = result?.error || 'Unexpected response format'; toast.error(errorMsg); } } catch (error: any) { // API error (network error, parse error, etc.) let errorMsg = 'Failed to cluster keywords'; if (error.message) { // Extract clean error message from API error format errorMsg = error.message.replace(/^API Error \(\d+\): [^-]+ - /, '').trim(); if (!errorMsg || errorMsg === error.message) { errorMsg = error.message; } } toast.error(errorMsg); } } else { toast.info(`Bulk action "${action}" for ${ids.length} items`); } }, [toast, activeSector, loadKeywords, progressModal, keywords]); // Reset reload flag when modal closes or opens useEffect(() => { if (!progressModal.isOpen) { hasReloadedRef.current = false; // Reset reload flag when modal closes } else { // Reset reload flag when modal opens for a new task hasReloadedRef.current = false; } }, [progressModal.isOpen, progressModal.taskId]); const resetForm = useCallback(() => { setFormData({ keyword: '', volume: null, difficulty: null, country: 'US', cluster_id: null, status: 'new', }); setIsEditMode(false); setEditingKeyword(null); }, []); // Bulk export handler for selected items (used by TablePageTemplate modal) const handleBulkExport = useCallback(async (ids: string[]) => { try { // Ensure we have valid IDs if (!ids || ids.length === 0) { throw new Error('No records selected for export'); } // For bulk export, ONLY export selected IDs - ignore ALL other filters // Build URL directly to ensure only 'ids' parameter is sent const idsParam = ids.join(','); const exportUrl = `${API_BASE_URL}/v1/planner/keywords/export/?ids=${encodeURIComponent(idsParam)}`; const response = await fetch(exportUrl, { method: 'GET', credentials: 'include', }); if (!response.ok) { const errorText = await response.text(); throw new Error(`Export failed: ${response.statusText} - ${errorText}`); } const blob = await response.blob(); const downloadUrl = window.URL.createObjectURL(blob); const link = document.createElement('a'); link.href = downloadUrl; link.download = 'keywords.csv'; document.body.appendChild(link); link.click(); document.body.removeChild(link); window.URL.revokeObjectURL(downloadUrl); } catch (error: any) { throw error; // Let TablePageTemplate handle toast } }, []); // Bulk status update handler (used by TablePageTemplate modal) const handleBulkUpdateStatus = useCallback(async (ids: string[], status: string) => { try { const numIds = ids.map(id => parseInt(id)); await bulkUpdateKeywordsStatus(numIds, status); await loadKeywords(); // Reload data after status update } catch (error: any) { throw error; // Let TablePageTemplate handle toast } }, [loadKeywords]); // Create page config using factory function - all config comes from keywords.config.tsx const pageConfig = useMemo(() => { return createKeywordsPageConfig({ clusters, activeSector, formData, setFormData, // Filter state handlers searchTerm, setSearchTerm, statusFilter, setStatusFilter, countryFilter, setCountryFilter, difficultyFilter, setDifficultyFilter, clusterFilter, setClusterFilter, volumeMin, volumeMax, setVolumeMin, setVolumeMax, isVolumeDropdownOpen, setIsVolumeDropdownOpen, tempVolumeMin, tempVolumeMax, setTempVolumeMin, setTempVolumeMax, volumeButtonRef, volumeDropdownRef, setCurrentPage, loadKeywords, }); }, [ clusters, activeSector, formData, searchTerm, statusFilter, countryFilter, difficultyFilter, clusterFilter, volumeMin, volumeMax, isVolumeDropdownOpen, tempVolumeMin, tempVolumeMax, loadKeywords, activeSite, ]); // Calculate header metrics from config (matching reference plugin KPIs from kpi-config.php) const headerMetrics = useMemo(() => { if (!pageConfig?.headerMetrics) return []; return pageConfig.headerMetrics.map((metric) => ({ label: metric.label, value: metric.calculate({ keywords, totalCount, clusters }), accentColor: metric.accentColor, tooltip: (metric as any).tooltip, // Add tooltip support })); }, [pageConfig?.headerMetrics, keywords, totalCount, clusters]); // Calculate workflow insights based on UX doc principles const workflowInsights = useMemo(() => { const insights = []; const clusteredCount = keywords.filter(k => k.cluster_id).length; const unclusteredCount = totalCount - clusteredCount; const pipelineReadiness = totalCount > 0 ? Math.round((clusteredCount / totalCount) * 100) : 0; if (totalCount === 0) { insights.push({ type: 'info' as const, message: 'Import keywords to begin building your content strategy and unlock SEO opportunities', }); return insights; } // Pipeline Readiness Score insight if (pipelineReadiness < 30) { insights.push({ type: 'warning' as const, message: `Pipeline readiness at ${pipelineReadiness}% - Most keywords need clustering before content ideation can begin`, }); } else if (pipelineReadiness < 60) { insights.push({ type: 'info' as const, message: `Pipeline readiness at ${pipelineReadiness}% - Clustering progress is moderate, continue organizing keywords`, }); } else if (pipelineReadiness >= 85) { insights.push({ type: 'success' as const, message: `Excellent pipeline readiness (${pipelineReadiness}%) - Ready for content ideation phase`, }); } // Clustering Potential (minimum batch size check) if (unclusteredCount >= 5) { insights.push({ type: 'action' as const, message: `${unclusteredCount} keywords available for auto-clustering (minimum batch size met)`, }); } else if (unclusteredCount > 0 && unclusteredCount < 5) { insights.push({ type: 'info' as const, message: `${unclusteredCount} unclustered keywords - Need ${5 - unclusteredCount} more to run auto-cluster`, }); } // Coverage Gaps - thin clusters that need more research const thinClusters = clusters.filter(c => (c.keywords_count || 0) === 1); if (thinClusters.length > 3) { const thinVolume = thinClusters.reduce((sum, c) => sum + (c.volume || 0), 0); insights.push({ type: 'warning' as const, message: `${thinClusters.length} clusters have only 1 keyword each (${thinVolume.toLocaleString()} monthly volume) - Consider expanding research`, }); } return insights; }, [keywords, totalCount, clusters]); // Handle create/edit const handleSave = async () => { try { if (!activeSite) { toast.error('Please select an active site first'); return; } if (isEditMode && editingKeyword) { await updateKeyword(editingKeyword.id, formData); toast.success('Keyword updated successfully'); } else { // For new keywords, add site_id and sector_id // Use active sector from store if (!activeSector) { toast.error('Please select a sector for this site first'); return; } if (!formData.keyword?.trim()) { toast.error('Please enter a keyword'); return; } if (formData.volume === null || formData.volume === undefined) { toast.error('Please enter search volume'); return; } if (formData.difficulty === null || formData.difficulty === undefined) { toast.error('Please enter difficulty score'); return; } const sectorId = activeSector.id; const keywordData: any = { ...formData, site_id: activeSite.id, sector_id: sectorId, }; console.log('Creating keyword with data:', keywordData); await createKeyword(keywordData); toast.success('Keyword created successfully'); } setIsModalOpen(false); resetForm(); loadKeywords(); } catch (error: any) { toast.error(error.message || 'Unable to save keyword. Please try again.'); } }; // Handle edit - populate form with existing keyword data const handleEdit = useCallback((keyword: Keyword) => { setEditingKeyword(keyword); setIsEditMode(true); setFormData({ keyword: keyword.keyword, volume: keyword.volume, difficulty: keyword.difficulty, country: keyword.country, cluster_id: keyword.cluster_id, status: keyword.status, }); setIsModalOpen(true); }, []); // Planner navigation tabs const plannerTabs = [ { label: 'Keywords (individual terms)', path: '/planner/keywords', icon: }, { label: 'Topics (keyword groups)', path: '/planner/clusters', icon: }, { label: 'Ideas', path: '/planner/ideas', icon: }, ]; return ( <> , color: 'green' }} navigation={} workflowInsights={workflowInsights} /> { // Normalize value to string, preserving empty strings const stringValue = value === null || value === undefined ? '' : String(value); // Map filter keys to state setters if (key === 'search') { setSearchTerm(stringValue); } else if (key === 'status') { setStatusFilter(stringValue); setCurrentPage(1); } else if (key === 'country') { setCountryFilter(stringValue); setCurrentPage(1); } else if (key === 'difficulty') { setDifficultyFilter(stringValue); setCurrentPage(1); } else if (key === 'cluster_id') { setClusterFilter(stringValue); setCurrentPage(1); } // Note: volume filter is handled by custom render, cluster options updated dynamically }} onEdit={handleEdit} onCreate={() => { resetForm(); setIsModalOpen(true); }} createLabel="Add Keyword" onCreateIcon={} onDelete={async (id: number) => { await deleteKeyword(id); loadKeywords(); }} onBulkDelete={async (ids: number[]) => { const result = await bulkDeleteKeywords(ids); // Clear selection first setSelectedIds([]); // Reset to page 1 if we deleted all items on current page if (currentPage > 1 && keywords.length <= ids.length) { setCurrentPage(1); } // Always reload data to refresh the table await loadKeywords(); return result; }} onBulkExport={handleBulkExport} onBulkUpdateStatus={handleBulkUpdateStatus} onBulkAction={handleBulkAction} getItemDisplayName={(row: Keyword) => row.keyword} onExport={async () => { try { const filterValues = { search: searchTerm, status: statusFilter, cluster_id: clusterFilter, country: countryFilter, difficulty: difficultyFilter, }; await handleExport('csv', filterValues); toast.success('Export successful', 'Keywords exported successfully.'); } catch (error: any) { toast.error('Export failed', error.message); } }} onExportIcon={} onImport={handleImportClick} onImportIcon={} selectionLabel="keyword" pagination={{ currentPage, totalPages, totalCount, onPageChange: (page: number) => { setCurrentPage(page); }, }} selection={{ selectedIds, onSelectionChange: setSelectedIds, }} sorting={{ sortBy, sortDirection, onSort: handleSort, }} headerMetrics={headerMetrics} onFilterReset={() => { setSearchTerm(''); setStatusFilter(''); setClusterFilter(''); setCountryFilter(''); setDifficultyFilter(''); setVolumeMin(''); setVolumeMax(''); setTempVolumeMin(''); setTempVolumeMax(''); setIsVolumeDropdownOpen(false); setCurrentPage(1); }} /> {/* Module Metrics Footer */} , accentColor: 'blue', href: '/planner/keywords', }, { title: 'Clustered', value: keywords.filter(k => k.cluster_id).length.toLocaleString(), subtitle: `${Math.round((keywords.filter(k => k.cluster_id).length / Math.max(totalCount, 1)) * 100)}% organized`, icon: , accentColor: 'purple', href: '/planner/clusters', }, { title: 'Easy Wins', value: keywords.filter(k => k.difficulty && k.difficulty <= 3 && (k.volume || 0) > 0).length.toLocaleString(), subtitle: `Low difficulty with ${keywords.filter(k => k.difficulty && k.difficulty <= 3).reduce((sum, k) => sum + (k.volume || 0), 0).toLocaleString()} volume`, icon: , accentColor: 'green', }, ]} progress={{ label: 'Keyword Clustering Pipeline: Keywords organized into topical clusters', value: totalCount > 0 ? Math.round((keywords.filter(k => k.cluster_id).length / totalCount) * 100) : 0, color: 'primary', }} /> {/* Create/Edit Modal */} { setIsModalOpen(false); resetForm(); }} onSubmit={handleSave} title={isEditMode ? 'Edit Keyword' : 'Add Keyword'} submitLabel={isEditMode ? 'Update' : 'Create'} fields={pageConfig.formFields(clusters)} /> {/* Import Modal */} {/* Progress Modal for AI Functions */} { progressModal.closeModal(); // Reload once when modal closes if task was completed if (progressModal.progress.status === 'completed' && !hasReloadedRef.current) { hasReloadedRef.current = true; loadKeywords(); } }} /> ); }