Files
igny8/frontend/src/pages/Planner/Keywords.tsx
2026-01-14 23:08:48 +00:00

900 lines
31 KiB
TypeScript

/**
* 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,
fetchPlannerKeywordStats,
fetchPlannerKeywordFilterOptions,
FilterOption,
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 StandardThreeWidgetFooter from '../../components/dashboard/StandardThreeWidgetFooter';
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<Keyword[]>([]);
const [clusters, setClusters] = useState<Cluster[]>([]);
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);
// Dynamic filter options (loaded from backend based on current data)
const [countryOptions, setCountryOptions] = useState<FilterOption[]>([]);
const [statusOptions, setStatusOptions] = useState<FilterOption[]>([]);
const [clusterOptions, setClusterOptions] = useState<FilterOption[]>([]);
// 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<number | ''>('');
const [volumeMax, setVolumeMax] = useState<number | ''>('');
const [isVolumeDropdownOpen, setIsVolumeDropdownOpen] = useState(false);
const [tempVolumeMin, setTempVolumeMin] = useState<number | ''>('');
const [tempVolumeMax, setTempVolumeMax] = useState<number | ''>('');
const volumeDropdownRef = useRef<HTMLDivElement>(null);
const volumeButtonRef = useRef<HTMLButtonElement>(null);
const [selectedIds, setSelectedIds] = useState<string[]>([]);
// 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<string>('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<Keyword | null>(null);
const [formData, setFormData] = useState<KeywordCreateData>({
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 dynamic filter options based on current site's data
const loadFilterOptions = useCallback(async () => {
if (!activeSite) return;
try {
const options = await fetchPlannerKeywordFilterOptions(activeSite.id);
setCountryOptions(options.countries || []);
setStatusOptions(options.statuses || []);
setClusterOptions(options.clusters || []);
} catch (error) {
console.error('Error loading filter options:', error);
}
}, [activeSite]);
// Load filter options when site changes
useEffect(() => {
loadFilterOptions();
}, [loadFilterOptions]);
// Load total metrics for footer widget (site-wide totals, no sector filter)
const loadTotalMetrics = useCallback(async () => {
if (!activeSite) return;
try {
// Batch all API calls in parallel for better performance
const [allRes, mappedRes, newRes, imagesRes, statsRes] = await Promise.all([
// Get total keywords count (site-wide)
fetchKeywords({
page_size: 1,
site_id: activeSite.id,
}),
// Get keywords with status='mapped' (site-wide)
fetchKeywords({
page_size: 1,
site_id: activeSite.id,
status: 'mapped',
}),
// Get keywords with status='new' (site-wide)
fetchKeywords({
page_size: 1,
site_id: activeSite.id,
status: 'new',
}),
// Get actual total images count
fetchImages({ page_size: 1 }),
// Get total volume from stats endpoint
fetchPlannerKeywordStats(activeSite.id),
]);
setTotalCount(allRes.count || 0);
setTotalClustered(mappedRes.count || 0);
setTotalUnmapped(newRes.count || 0);
setTotalImagesCount(imagesRes.count || 0);
setTotalVolume(statsRes.total_volume || 0);
} catch (error) {
console.error('Error loading total metrics:', error);
}
}, [activeSite]);
// 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));
// Validate single sector - keywords must all be from the same sector
const uniqueSectors = new Set(selectedKeywords.map(k => k.sector_id).filter(Boolean));
if (uniqueSectors.size > 1) {
toast.error(`Selected keywords span ${uniqueSectors.size} different sectors. Please select keywords from a single sector only.`);
return;
}
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,
// Dynamic filter options
countryOptions,
statusOptions,
clusterOptions,
});
}, [
clusters,
activeSector,
formData,
searchTerm,
statusFilter,
countryFilter,
difficultyFilter,
clusterFilter,
volumeMin,
volumeMax,
isVolumeDropdownOpen,
tempVolumeMin,
tempVolumeMax,
loadKeywords,
activeSite,
countryOptions,
statusOptions,
clusterOptions,
]);
// 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 (
<>
<PageHeader
title="Keywords"
badge={{ icon: <ListIcon />, color: 'green' }}
parent="Planner"
/>
<TablePageTemplate
columns={pageConfig.columns}
data={keywords}
loading={loading}
showContent={showContent}
checkboxColumnWidth="40px"
filters={pageConfig.filters}
filterValues={{
search: searchTerm,
status: statusFilter,
country: countryFilter,
difficulty: difficultyFilter,
cluster_id: clusterFilter,
volumeMin: volumeMin,
volumeMax: volumeMax,
}}
primaryAction={{
label: 'Auto-Cluster',
icon: <BoltIcon className="w-4 h-4" />,
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={<PlusIcon />}
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={<DownloadIcon />}
onImport={handleImportClick}
onImportIcon={<ArrowUpIcon />}
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 with Standardized Workflow Widget */}
<StandardThreeWidgetFooter
submoduleColor="blue"
pageProgress={{
title: 'Page Progress',
submoduleColor: 'blue',
metrics: [
{ label: 'Keywords', value: totalCount },
{ label: 'Clustered', value: totalClustered, percentage: `${totalCount > 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!',
statusInsight: totalUnmapped > 0
? `Select unmapped keywords and run clustering to group them into topics.`
: totalClustered > 0
? `Keywords are clustered. Go to Clusters to generate content ideas.`
: `Add keywords to begin. Import from CSV or add manually.`,
}}
module="planner"
showCredits={true}
analyticsHref="/account/usage"
/>
{/* Create/Edit Modal */}
<FormModal
key={`keyword-form-${isEditMode ? editingKeyword?.id : 'new'}`}
isOpen={isModalOpen}
onClose={() => {
setIsModalOpen(false);
resetForm();
}}
onSubmit={handleSave}
title={isEditMode ? 'Edit Keyword' : 'Add Keyword'}
submitLabel={isEditMode ? 'Update' : 'Create'}
fields={pageConfig.formFields(clusters)}
/>
{/* Import Modal */}
<ImportModal />
{/* Progress Modal for AI Functions */}
<ProgressModal
isOpen={progressModal.isOpen}
title={progressModal.title}
percentage={progressModal.progress.percentage}
status={progressModal.progress.status}
message={progressModal.progress.message}
details={progressModal.progress.details}
taskId={progressModal.taskId || undefined}
functionId={progressModal.functionId}
onClose={() => {
progressModal.closeModal();
// Reload once when modal closes if task was completed
if (progressModal.progress.status === 'completed' && !hasReloadedRef.current) {
hasReloadedRef.current = true;
loadKeywords();
}
}}
/>
</>
);
}