/** * 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, fetchSeedKeywords, SeedKeyword, } from '../../services/api'; import { useSiteStore } from '../../store/siteStore'; import { useSectorStore } from '../../store/sectorStore'; import { usePageSizeStore } from '../../store/pageSizeStore'; 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 } 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 [availableSeedKeywords, setAvailableSeedKeywords] = useState([]); const [loading, setLoading] = useState(true); const [loadingSeedKeywords, setLoadingSeedKeywords] = useState(false); // Filter state - match Keywords.tsx const [searchTerm, setSearchTerm] = useState(''); const [statusFilter, setStatusFilter] = useState(''); const [clusterFilter, setClusterFilter] = useState(''); const [intentFilter, setIntentFilter] = 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({ seed_keyword_id: 0, volume_override: null, difficulty_override: null, cluster_id: null, status: 'pending', }); // Progress modal for AI functions const progressModal = useProgressModal(); const hasReloadedRef = useRef(false); // AI Function Logs state const [aiLogs, setAiLogs] = useState>([]); // Track last logged step to avoid duplicates const lastLoggedStepRef = useRef(null); const lastLoggedPercentageRef = useRef(-1); // Load sectors for active site using sector store useEffect(() => { if (activeSite) { loadSectorsForSite(activeSite.id); } }, [activeSite, loadSectorsForSite]); // Load available SeedKeywords when site and sector are selected useEffect(() => { const loadAvailableSeedKeywords = async () => { if (!activeSite || !activeSector || !activeSite.industry) { setAvailableSeedKeywords([]); return; } try { setLoadingSeedKeywords(true); // Fetch SeedKeywords for the site's industry and sector's industry_sector const response = await fetchSeedKeywords({ industry: activeSite.industry, sector: activeSector.industry_sector || undefined, }); // Filter out SeedKeywords that are already attached to this site/sector const attachedSeedKeywordIds = new Set( keywords.map(k => k.seed_keyword_id) ); const available = (response.results || []).filter( sk => !attachedSeedKeywordIds.has(sk.id) ); setAvailableSeedKeywords(available); } catch (error: any) { console.error('Failed to load available seed keywords:', error); setAvailableSeedKeywords([]); } finally { setLoadingSeedKeywords(false); } }; loadAvailableSeedKeywords(); }, [activeSite, activeSector, keywords]); // 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 }), ...(intentFilter && { intent: intentFilter }), ...(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, intentFilter, 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 > 20) { toast.error('Maximum 20 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)); const requestData = { ids: numIds, keyword_count: numIds.length, keyword_names: selectedKeywords.map(k => k.keyword), sector_id: sectorId, }; // Log request setAiLogs(prev => [...prev, { timestamp: new Date().toISOString(), type: 'request', action: 'auto_cluster (Bulk Action)', data: requestData, }]); 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'; // Log error setAiLogs(prev => [...prev, { timestamp: new Date().toISOString(), type: 'error', action: 'auto_cluster (Bulk Action)', data: { error: errorMsg, keyword_count: numIds.length }, }]); toast.error(errorMsg); return; } if (result && result.success) { if (result.task_id) { // Log success with task_id setAiLogs(prev => [...prev, { timestamp: new Date().toISOString(), type: 'success', action: 'auto_cluster (Bulk Action)', data: { task_id: result.task_id, message: result.message, keyword_count: numIds.length }, }]); // 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 { // Log success with results setAiLogs(prev => [...prev, { timestamp: new Date().toISOString(), type: 'success', action: 'auto_cluster (Bulk Action)', data: { clusters_created: result.clusters_created || 0, keywords_updated: result.keywords_updated || 0, keyword_count: numIds.length, message: result.message, }, }]); // 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'; // Log error setAiLogs(prev => [...prev, { timestamp: new Date().toISOString(), type: 'error', action: 'auto_cluster (Bulk Action)', data: { error: errorMsg, keyword_count: numIds.length }, }]); 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; } } // Log error setAiLogs(prev => [...prev, { timestamp: new Date().toISOString(), type: 'error', action: 'auto_cluster (Bulk Action)', data: { error: errorMsg, keyword_count: numIds.length }, }]); toast.error(errorMsg); } } else { toast.info(`Bulk action "${action}" for ${ids.length} items`); } }, [toast, activeSector, loadKeywords, progressModal, keywords]); // Log AI function progress steps useEffect(() => { if (!progressModal.taskId || !progressModal.isOpen) { return; } const progress = progressModal.progress; const currentStep = progress.details?.phase || ''; const currentPercentage = progress.percentage; const currentMessage = progress.message; const currentStatus = progress.status; // Log step changes if (currentStep && currentStep !== lastLoggedStepRef.current) { const stepType = currentStatus === 'error' ? 'error' : currentStatus === 'completed' ? 'success' : 'step'; setAiLogs(prev => [...prev, { timestamp: new Date().toISOString(), type: stepType, action: progressModal.title || 'AI Function', stepName: currentStep, percentage: currentPercentage, data: { step: currentStep, message: currentMessage, percentage: currentPercentage, status: currentStatus, details: progress.details, }, }]); lastLoggedStepRef.current = currentStep; lastLoggedPercentageRef.current = currentPercentage; } // Log percentage changes for same step (if significant change) else if (currentStep && Math.abs(currentPercentage - lastLoggedPercentageRef.current) >= 10) { const stepType = currentStatus === 'error' ? 'error' : currentStatus === 'completed' ? 'success' : 'step'; setAiLogs(prev => [...prev, { timestamp: new Date().toISOString(), type: stepType, action: progressModal.title || 'AI Function', stepName: currentStep, percentage: currentPercentage, data: { step: currentStep, message: currentMessage, percentage: currentPercentage, status: currentStatus, details: progress.details, }, }]); lastLoggedPercentageRef.current = currentPercentage; } // Log status changes (error, completed) else if (currentStatus === 'error' || currentStatus === 'completed') { // Only log if we haven't already logged this status for this step if (currentStep !== lastLoggedStepRef.current || (currentStatus === 'error' && lastLoggedStepRef.current !== 'error') || (currentStatus === 'completed' && lastLoggedStepRef.current !== 'completed')) { const stepType = currentStatus === 'error' ? 'error' : 'success'; setAiLogs(prev => [...prev, { timestamp: new Date().toISOString(), type: stepType, action: progressModal.title || 'AI Function', stepName: currentStep || 'Final', percentage: currentPercentage, data: { step: currentStep || 'Final', message: currentMessage, percentage: currentPercentage, status: currentStatus, details: progress.details, }, }]); lastLoggedStepRef.current = currentStep || currentStatus; } } }, [progressModal.progress, progressModal.taskId, progressModal.isOpen, progressModal.title]); // Reset step tracking when modal closes or opens useEffect(() => { if (!progressModal.isOpen) { lastLoggedStepRef.current = null; lastLoggedPercentageRef.current = -1; 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({ seed_keyword_id: 0, volume_override: null, difficulty_override: null, cluster_id: null, status: 'pending', }); 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, availableSeedKeywords, formData, setFormData, // Filter state handlers searchTerm, setSearchTerm, statusFilter, setStatusFilter, intentFilter, setIntentFilter, difficultyFilter, setDifficultyFilter, clusterFilter, setClusterFilter, volumeMin, volumeMax, setVolumeMin, setVolumeMax, isVolumeDropdownOpen, setIsVolumeDropdownOpen, tempVolumeMin, tempVolumeMax, setTempVolumeMin, setTempVolumeMax, volumeButtonRef, volumeDropdownRef, setCurrentPage, loadKeywords, }); }, [ clusters, activeSector, availableSeedKeywords, formData, searchTerm, statusFilter, intentFilter, 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, })); }, [pageConfig?.headerMetrics, 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.seed_keyword_id) { toast.error('Please select a seed keyword'); return; } const sectorId = activeSector.id; const keywordData: any = { ...formData, site_id: activeSite.id, sector_id: sectorId, }; await createKeyword(keywordData); toast.success('Keyword attached successfully'); } setIsModalOpen(false); resetForm(); loadKeywords(); } catch (error: any) { toast.error(`Failed to save: ${error.message}`); } }; // Handle edit - populate form with existing keyword data const handleEdit = useCallback((keyword: Keyword) => { setEditingKeyword(keyword); setIsEditMode(true); setFormData({ seed_keyword_id: keyword.seed_keyword_id, volume_override: keyword.volume_override || null, difficulty_override: keyword.difficulty_override || null, cluster_id: keyword.cluster_id, status: keyword.status, }); setIsModalOpen(true); }, []); return ( <> } subtitle="Manage and organize SEO keywords for content planning" columns={pageConfig.columns} data={keywords} loading={loading} showContent={showContent} filters={pageConfig.filters} filterValues={{ search: searchTerm, status: statusFilter, intent: intentFilter, difficulty: difficultyFilter, cluster_id: clusterFilter, volumeMin: volumeMin, volumeMax: volumeMax, }} onFilterChange={(key, value) => { // 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 === 'intent') { setIntentFilter(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); 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, intent: intentFilter, 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(''); setIntentFilter(''); setDifficultyFilter(''); setVolumeMin(''); setVolumeMax(''); setTempVolumeMin(''); setTempVolumeMax(''); setIsVolumeDropdownOpen(false); setCurrentPage(1); }} /> {/* 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(); } }} /> {/* AI Function Logs - Display below table */} {aiLogs.length > 0 && (

AI Function Logs

{aiLogs.slice().reverse().map((log, index) => (
[{log.type.toUpperCase()}] {log.action} {log.stepName && ( {log.stepName} )} {log.percentage !== undefined && ( {log.percentage}% )}
{new Date(log.timestamp).toLocaleTimeString()}
                  {JSON.stringify(log.data, null, 2)}
                
))}
)} ); }