Files
igny8/frontend/src/pages/Planner/Keywords.tsx
IGNY8 VPS (Salman) 7ad06c6227 Refactor keyword handling: Replace 'intent' with 'country' across backend and frontend
- Updated AutomationService to include estimated_word_count.
- Increased stage_1_batch_size from 20 to 50 in AutomationViewSet.
- Changed Keywords model to replace 'intent' property with 'country'.
- Adjusted ClusteringService to allow a maximum of 50 keywords for clustering.
- Modified admin and management commands to remove 'intent' and use 'country' instead.
- Updated serializers to reflect the change from 'intent' to 'country'.
- Adjusted views and filters to use 'country' instead of 'intent'.
- Updated frontend forms, filters, and pages to replace 'intent' with 'country'.
- Added migration to remove 'intent' field and add 'country' field to SeedKeyword model.
2025-12-17 07:37:36 +00:00

1059 lines
38 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,
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 { useResourceDebug } from '../../hooks/useResourceDebug';
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);
// 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);
// Resource Debug toggle - controls AI Function Logs
const resourceDebugEnabled = useResourceDebug();
// AI Function Logs state
const [aiLogs, setAiLogs] = useState<Array<{
timestamp: string;
type: 'request' | 'success' | 'error' | 'step';
action: string;
data: any;
stepName?: string;
percentage?: number;
}>>([]);
// Track last logged step to avoid duplicates
const lastLoggedStepRef = useRef<string | null>(null);
const lastLoggedPercentageRef = useRef<number>(-1);
// Helper function to add log entry (only if Resource Debug is enabled)
const addAiLog = useCallback((log: {
timestamp: string;
type: 'request' | 'success' | 'error' | 'step';
action: string;
data: any;
stepName?: string;
percentage?: number;
}) => {
if (resourceDebugEnabled) {
setAiLogs(prev => [...prev, log]);
}
}, [resourceDebugEnabled]);
// 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));
const requestData = {
ids: numIds,
keyword_count: numIds.length,
keyword_names: selectedKeywords.map(k => k.keyword),
sector_id: sectorId,
};
// Log request (only if Resource Debug is enabled)
addAiLog({
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
addAiLog({
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
addAiLog({
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
addAiLog({
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
addAiLog({
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
addAiLog({
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';
addAiLog({
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';
addAiLog({
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';
addAiLog({
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, addAiLog]);
// 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({
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', path: '/planner/keywords', icon: <ListIcon /> },
{ label: 'Clusters', path: '/planner/clusters', icon: <GroupIcon /> },
{ label: 'Ideas', path: '/planner/ideas', icon: <BoltIcon /> },
];
return (
<>
<PageHeader
title="Keywords"
badge={{ icon: <ListIcon />, color: 'green' }}
navigation={<ModuleNavigationTabs tabs={plannerTabs} />}
workflowInsights={workflowInsights}
/>
<TablePageTemplate
columns={pageConfig.columns}
data={keywords}
loading={loading}
showContent={showContent}
filters={pageConfig.filters}
filterValues={{
search: searchTerm,
status: statusFilter,
country: countryFilter,
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 === '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);
}}
/>
{/* Module Metrics Footer */}
<ModuleMetricsFooter
metrics={[
{
title: 'Keywords',
value: totalCount.toLocaleString(),
subtitle: `in ${clusters.length} clusters`,
icon: <ListIcon className="w-5 h-5" />,
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: <GroupIcon className="w-5 h-5" />,
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: <BoltIcon className="w-5 h-5" />,
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 */}
<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();
}
}}
/>
{/* AI Function Logs - Display below table (only when Resource Debug is enabled) */}
{resourceDebugEnabled && aiLogs.length > 0 && (
<div className="mt-6 bg-gray-50 dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-semibold text-gray-900 dark:text-gray-100">
AI Function Logs
</h3>
<button
onClick={() => setAiLogs([])}
className="text-xs text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
>
Clear Logs
</button>
</div>
<div className="space-y-2 max-h-96 overflow-y-auto">
{aiLogs.slice().reverse().map((log, index) => (
<div
key={index}
className={`p-3 rounded border text-xs font-mono ${
log.type === 'request'
? 'bg-blue-50 dark:bg-blue-900/20 border-blue-200 dark:border-blue-800'
: log.type === 'success'
? 'bg-green-50 dark:bg-green-900/20 border-green-200 dark:border-green-800'
: log.type === 'error'
? 'bg-red-50 dark:bg-red-900/20 border-red-200 dark:border-red-800'
: 'bg-purple-50 dark:bg-purple-900/20 border-purple-200 dark:border-purple-800'
}`}
>
<div className="flex items-center justify-between mb-1">
<div className="flex items-center gap-2 flex-wrap">
<span className={`font-semibold ${
log.type === 'request'
? 'text-blue-700 dark:text-blue-300'
: log.type === 'success'
? 'text-green-700 dark:text-green-300'
: log.type === 'error'
? 'text-red-700 dark:text-red-300'
: 'text-purple-700 dark:text-purple-300'
}`}>
[{log.type.toUpperCase()}]
</span>
<span className="text-gray-700 dark:text-gray-300">
{log.action}
</span>
{log.stepName && (
<span className="text-xs px-2 py-0.5 rounded bg-gray-200 dark:bg-gray-700 text-gray-600 dark:text-gray-400">
{log.stepName}
</span>
)}
{log.percentage !== undefined && (
<span className="text-xs text-gray-500 dark:text-gray-400">
{log.percentage}%
</span>
)}
</div>
<span className="text-gray-500 dark:text-gray-400">
{new Date(log.timestamp).toLocaleTimeString()}
</span>
</div>
<pre className="text-xs text-gray-700 dark:text-gray-300 whitespace-pre-wrap break-words">
{JSON.stringify(log.data, null, 2)}
</pre>
</div>
))}
</div>
</div>
)}
</>
);
}