/** * 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, fetchImages, 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 ThreeWidgetFooter from '../../components/dashboard/ThreeWidgetFooter'; 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); // Total counts for footer widget (not page-filtered) const [totalClustered, setTotalClustered] = useState(0); const [totalUnmapped, setTotalUnmapped] = useState(0); const [totalVolume, setTotalVolume] = useState(0); const [totalImagesCount, setTotalImagesCount] = useState(0); // 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 total metrics for footer widget (not affected by pagination) const loadTotalMetrics = useCallback(async () => { if (!activeSite) return; try { // Get all keywords (total count) - this is already in totalCount from main load // Get keywords with status='mapped' (those that have been mapped to a cluster) const mappedRes = await fetchKeywords({ page_size: 1, site_id: activeSite.id, ...(activeSector?.id && { sector_id: activeSector.id }), status: 'mapped', }); setTotalClustered(mappedRes.count || 0); // Get keywords with status='new' (those that are ready to cluster but haven't been yet) const newRes = await fetchKeywords({ page_size: 1, site_id: activeSite.id, ...(activeSector?.id && { sector_id: activeSector.id }), status: 'new', }); setTotalUnmapped(newRes.count || 0); // Get total volume across all keywords (we need to fetch all or rely on backend aggregation) // For now, we'll just calculate from current data or set to 0 // TODO: Backend should provide total volume as an aggregated metric setTotalVolume(0); // Get actual total images count const imagesRes = await fetchImages({ page_size: 1 }); setTotalImagesCount(imagesRes.count || 0); } catch (error) { console.error('Error loading total metrics:', error); } }, [activeSite, activeSector]); // Load total metrics when site/sector changes useEffect(() => { loadTotalMetrics(); }, [loadTotalMetrics]); // 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]); // Quick auto-cluster unclustered keywords (for Next Step button) const handleAutoCluster = useCallback(async () => { const unclusteredIds = keywords.filter(k => !k.cluster_id).map(k => k.id); if (unclusteredIds.length === 0) { toast.info('All keywords are already clustered'); return; } // Limit to 50 keywords const idsToCluster = unclusteredIds.slice(0, 50); await handleBulkAction('auto_cluster', idsToCluster.map(String)); }, [keywords, handleBulkAction, toast]); // 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 - use totalClustered/totalUnmapped from API calls (not page data) // This ensures metrics show correct totals across all pages, not just current page const headerMetrics = useMemo(() => { if (!pageConfig?.headerMetrics) return []; // Override the calculate function to use pre-loaded totals instead of filtering page data return pageConfig.headerMetrics.map((metric) => { let value: number; switch (metric.label) { case 'Keywords': value = totalCount || 0; break; case 'Clustered': // Use totalClustered from loadTotalMetrics() instead of filtering page data value = totalClustered; break; case 'Unmapped': // Use totalUnmapped from loadTotalMetrics() instead of filtering page data value = totalUnmapped; break; case 'Volume': // Use totalVolume from loadTotalMetrics() (if implemented) or keep original value = totalVolume || keywords.reduce((sum: number, k) => sum + (k.volume || 0), 0); break; default: value = metric.calculate({ keywords, totalCount, clusters }); } return { label: metric.label, value, accentColor: metric.accentColor, tooltip: (metric as any).tooltip, }; }); }, [pageConfig?.headerMetrics, keywords, totalCount, clusters, totalClustered, totalUnmapped, totalVolume]); // Calculate workflow insights based on UX doc principles const workflowStats = useMemo(() => { const clusteredCount = keywords.filter(k => k.cluster_id).length; const unclusteredCount = totalCount - clusteredCount; const pipelineReadiness = totalCount > 0 ? Math.round((clusteredCount / totalCount) * 100) : 0; return { total: totalCount, clustered: clusteredCount, unclustered: unclusteredCount, readiness: pipelineReadiness, }; }, [keywords, totalCount]); // 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); }, []); return ( <> , color: 'green' }} parent="Planner" /> , onClick: () => handleBulkAction('auto_cluster', selectedIds), variant: 'success', }} 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 === '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); }} /> {/* Three Widget Footer - Section 3 Layout */} 0 ? Math.round((totalClustered / totalCount) * 100) : 0}%` }, { label: 'Unmapped', value: totalUnmapped }, { label: 'Volume', value: totalVolume > 0 ? `${(totalVolume / 1000).toFixed(1)}K` : '-' }, ], progress: { value: totalCount > 0 ? Math.round((totalClustered / totalCount) * 100) : 0, label: 'Clustered', color: 'blue', }, hint: totalUnmapped > 0 ? `${totalUnmapped} keywords ready to cluster` : 'All keywords clustered!', }} moduleStats={{ title: 'Planner Module', pipeline: [ { fromLabel: 'Keywords', fromValue: totalCount, actionLabel: 'Auto Cluster', toLabel: 'Clusters', toValue: clusters.length, toHref: '/planner/clusters', progress: totalCount > 0 ? Math.round((keywords.filter(k => k.cluster_id).length / totalCount) * 100) : 0, color: 'blue', }, { fromLabel: 'Clusters', fromValue: clusters.length, fromHref: '/planner/clusters', actionLabel: 'Generate Ideas', toLabel: 'Ideas', toValue: clusters.reduce((sum, c) => sum + (c.ideas_count || 0), 0), toHref: '/planner/ideas', progress: clusters.length > 0 ? Math.round((clusters.filter(c => (c.ideas_count || 0) > 0).length / clusters.length) * 100) : 0, color: 'green', }, { fromLabel: 'Ideas', fromValue: clusters.reduce((sum, c) => sum + (c.ideas_count || 0), 0), fromHref: '/planner/ideas', actionLabel: 'Create Tasks', toLabel: 'Tasks', toValue: 0, toHref: '/writer/tasks', progress: 0, color: 'amber', }, ], links: [ { label: 'Keywords', href: '/planner/keywords' }, { label: 'Clusters', href: '/planner/clusters' }, { label: 'Ideas', href: '/planner/ideas' }, ], }} completion={{ title: 'Workflow Completion', plannerItems: [ { label: 'Keywords Clustered', value: keywords.filter(k => k.cluster_id).length, color: 'blue' }, { label: 'Clusters Created', value: clusters.length, color: 'green' }, { label: 'Ideas Generated', value: clusters.reduce((sum, c) => sum + (c.ideas_count || 0), 0), color: 'amber' }, ], writerItems: [ { label: 'Content Generated', value: 0, color: 'blue' }, { label: 'Images Created', value: totalImagesCount, color: 'purple' }, { label: 'Published', value: 0, color: 'green' }, ], creditsUsed: 0, operationsCount: 0, analyticsHref: '/account/usage', }} /> {/* 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(); } }} /> ); }