/** * Add Keywords Page * Browse global seed keywords filtered by site's industry and sectors * Add keywords to workflow (creates Keywords records in planner) * Admin users can import seed keywords via CSV */ import { useState, useEffect, useRef, useMemo, useCallback } from 'react'; import { useNavigate } from 'react-router-dom'; import PageMeta from '../../components/common/PageMeta'; import PageHeader from '../../components/common/PageHeader'; import { useToast } from '../../components/ui/toast/ToastContainer'; import WorkflowGuide from '../../components/onboarding/WorkflowGuide'; import { fetchSeedKeywords, SeedKeyword, SeedKeywordResponse, fetchSites, Site, addSeedKeywordsToWorkflow, } from '../../services/api'; import Badge from '../../components/ui/badge/Badge'; import { BoltIcon, PlusIcon } from '../../icons'; import TablePageTemplate from '../../templates/TablePageTemplate'; import { usePageSizeStore } from '../../store/pageSizeStore'; import { getDifficultyNumber, getDifficultyRange, getDifficultyLabelFromNumber } from '../../utils/difficulty'; import { useSiteStore } from '../../store/siteStore'; import { useSectorStore } from '../../store/sectorStore'; import { useAuthStore } from '../../store/authStore'; import Button from '../../components/ui/button/Button'; import { Modal } from '../../components/ui/modal'; import FileInput from '../../components/form/input/FileInput'; import Label from '../../components/form/Label'; export default function IndustriesSectorsKeywords() { const toast = useToast(); const { activeSite } = useSiteStore(); const { activeSector, loadSectorsForSite } = useSectorStore(); const { pageSize } = usePageSizeStore(); const { user } = useAuthStore(); // Data state const [sites, setSites] = useState([]); const [seedKeywords, setSeedKeywords] = useState<(SeedKeyword & { isAdded?: boolean })[]>([]); const [loading, setLoading] = useState(true); const [showContent, setShowContent] = useState(false); const [selectedIds, setSelectedIds] = useState([]); // Track recently added keywords to preserve their state during reload const recentlyAddedRef = useRef>(new Set()); // Pagination state const [currentPage, setCurrentPage] = useState(1); const [totalPages, setTotalPages] = useState(1); const [totalCount, setTotalCount] = useState(0); // Sorting state const [sortBy, setSortBy] = useState('keyword'); const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc'); // Filter state const [searchTerm, setSearchTerm] = useState(''); const [countryFilter, setCountryFilter] = useState(''); const [difficultyFilter, setDifficultyFilter] = useState(''); const [showNotAddedOnly, setShowNotAddedOnly] = useState(false); // Keyword count tracking const [addedCount, setAddedCount] = useState(0); const [availableCount, setAvailableCount] = useState(0); // Navigation const navigate = useNavigate(); // Import modal state const [isImportModalOpen, setIsImportModalOpen] = useState(false); const [isImporting, setIsImporting] = useState(false); const [importFile, setImportFile] = useState(null); // Load sites on mount useEffect(() => { loadInitialData(); }, []); const loadInitialData = async () => { try { setLoading(true); const response = await fetchSites(); const activeSites = (response.results || []).filter(site => site.is_active); setSites(activeSites); } catch (error: any) { toast.error(`Failed to load sites: ${error.message}`); } finally { setLoading(false); } }; // Handle site added from WorkflowGuide const handleSiteAdded = () => { loadInitialData(); }; // Load sectors for active site useEffect(() => { if (activeSite?.id) { loadSectorsForSite(activeSite.id); } }, [activeSite?.id, loadSectorsForSite]); // Load seed keywords const loadSeedKeywords = useCallback(async () => { if (!activeSite || !activeSite.industry) { setSeedKeywords([]); setTotalCount(0); setTotalPages(1); setShowContent(true); return; } setShowContent(false); try { // Get already-attached keywords across ALL sectors for this site let attachedSeedKeywordIds = new Set(); try { const { fetchKeywords, fetchSiteSectors } = await import('../../services/api'); // Get all sectors for the site const sectors = await fetchSiteSectors(activeSite.id); // Check keywords in all sectors for (const sector of sectors) { try { const keywordsData = await fetchKeywords({ site_id: activeSite.id, sector_id: sector.id, page_size: 1000, }); (keywordsData.results || []).forEach((k: any) => { const seedKeywordId = k.seed_keyword_id || (k.seed_keyword && k.seed_keyword.id); if (seedKeywordId) { attachedSeedKeywordIds.add(Number(seedKeywordId)); } }); } catch (err) { console.warn(`Could not fetch attached keywords for sector ${sector.id}:`, err); } } } catch (err) { console.warn('Could not fetch sectors or attached keywords:', err); } // Build filters - fetch ALL results by paginating through all pages const baseFilters: any = { industry: activeSite.industry, page_size: 1000, }; // Add sector filter if active sector is selected if (activeSector && activeSector.industry_sector) { baseFilters.sector = activeSector.industry_sector; } if (searchTerm) baseFilters.search = searchTerm; if (countryFilter) baseFilters.country = countryFilter; // Fetch ALL pages to get complete dataset let allResults: SeedKeyword[] = []; let currentPageNum = 1; let hasMore = true; while (hasMore) { const filters = { ...baseFilters, page: currentPageNum }; const data: SeedKeywordResponse = await fetchSeedKeywords(filters); if (data.results && data.results.length > 0) { allResults = [...allResults, ...data.results]; } hasMore = data.next !== null && data.next !== undefined; currentPageNum++; if (currentPageNum > 100) { console.warn('Reached maximum page limit (100) while fetching seed keywords'); break; } } // Mark already-attached keywords let filteredResults = allResults.map(sk => { const isAdded = attachedSeedKeywordIds.has(Number(sk.id)) || recentlyAddedRef.current.has(Number(sk.id)); return { ...sk, isAdded: Boolean(isAdded) }; }); // Calculate counts before applying filters const totalAdded = filteredResults.filter(sk => sk.isAdded).length; const totalAvailable = filteredResults.filter(sk => !sk.isAdded).length; setAddedCount(totalAdded); setAvailableCount(totalAvailable); // Apply "not yet added" filter if (showNotAddedOnly) { filteredResults = filteredResults.filter(sk => !sk.isAdded); } // Apply difficulty filter if (difficultyFilter) { const difficultyNum = parseInt(difficultyFilter); const label = getDifficultyLabelFromNumber(difficultyNum); if (label !== null) { const range = getDifficultyRange(label); if (range) { filteredResults = filteredResults.filter( sk => sk.difficulty >= range.min && sk.difficulty <= range.max ); } } } // Apply client-side sorting if (sortBy) { filteredResults.sort((a, b) => { let aVal: any; let bVal: any; if (sortBy === 'keyword') { aVal = a.keyword.toLowerCase(); bVal = b.keyword.toLowerCase(); } else if (sortBy === 'volume') { aVal = a.volume; bVal = b.volume; } else if (sortBy === 'difficulty') { aVal = a.difficulty; bVal = b.difficulty; } else if (sortBy === 'country') { aVal = a.country.toLowerCase(); bVal = b.country.toLowerCase(); } else { return 0; } if (aVal < bVal) return sortDirection === 'asc' ? -1 : 1; if (aVal > bVal) return sortDirection === 'asc' ? 1 : -1; return 0; }); } // Calculate total count and pages from filtered results const totalFiltered = filteredResults.length; const pageSizeNum = pageSize || 10; // Apply client-side pagination const startIndex = (currentPage - 1) * pageSizeNum; const endIndex = startIndex + pageSizeNum; const paginatedResults = filteredResults.slice(startIndex, endIndex); setSeedKeywords(paginatedResults); setTotalCount(totalFiltered); setTotalPages(Math.ceil(totalFiltered / pageSizeNum)); setShowContent(true); } catch (error: any) { console.error('Error loading seed keywords:', error); toast.error(`Failed to load keywords: ${error.message}`); setSeedKeywords([]); setTotalCount(0); setTotalPages(1); setAddedCount(0); setAvailableCount(0); } }, [activeSite, activeSector, currentPage, pageSize, searchTerm, countryFilter, difficultyFilter, showNotAddedOnly, sortBy, sortDirection, toast]); // Load data on mount and when filters change useEffect(() => { if (activeSite) { loadSeedKeywords(); } }, [loadSeedKeywords, activeSite]); // Debounced search useEffect(() => { const timer = setTimeout(() => { setCurrentPage(1); }, 500); return () => clearTimeout(timer); }, [searchTerm]); // Reset to page 1 on pageSize change useEffect(() => { setCurrentPage(1); }, [pageSize]); // Handle sorting const handleSort = (field: string, direction: 'asc' | 'desc') => { setSortBy(field || 'keyword'); setSortDirection(direction); setCurrentPage(1); }; // Handle adding keywords to workflow const handleAddToWorkflow = useCallback(async (seedKeywordIds: number[]) => { if (!activeSite) { toast.error('Please select an active site first'); return; } // Get sector to use let sectorToUse = activeSector; if (!sectorToUse) { try { const { fetchSiteSectors } = await import('../../services/api'); const sectors = await fetchSiteSectors(activeSite.id); if (sectors.length === 0) { toast.error('No sectors available for this site. Please create a sector first.'); return; } sectorToUse = { id: sectors[0].id, name: sectors[0].name, slug: sectors[0].slug, site_id: activeSite.id, is_active: sectors[0].is_active !== false, industry_sector: sectors[0].industry_sector || null, }; } catch (error: any) { toast.error(`Failed to get sectors: ${error.message}`); return; } } try { const result = await addSeedKeywordsToWorkflow( seedKeywordIds, activeSite.id, sectorToUse.id ); if (result.success) { // Show success message with created count if (result.created > 0) { toast.success(`Successfully added ${result.created} keyword(s) to workflow`); } // Show skipped count if any if (result.skipped > 0) { toast.warning(`${result.skipped} keyword(s) were skipped (already exist or validation failed)`); } // Show detailed errors if any if (result.errors && result.errors.length > 0) { result.errors.forEach((error: string) => { toast.error(error); }); } // Only track and mark as added if actually created if (result.created > 0) { // Track as recently added seedKeywordIds.forEach(id => { recentlyAddedRef.current.add(id); }); // Update state - mark as added setSeedKeywords(prevKeywords => prevKeywords.map(kw => seedKeywordIds.includes(kw.id) ? { ...kw, isAdded: true } : kw ) ); } // Clear selection setSelectedIds([]); } else { // Show user-friendly error message (errors array already contains clean messages) const errorMsg = result.errors?.[0] || 'Unable to add keywords. Please try again.'; toast.error(errorMsg); } } catch (error: any) { // Show user-friendly error message (error.message is already cleaned) toast.error(error.message || 'Unable to add keywords. Please try again.'); } }, [activeSite, activeSector, toast]); // Handle bulk add selected const handleBulkAddSelected = useCallback(async (ids: string[]) => { if (ids.length === 0) { toast.error('Please select at least one keyword'); return; } // Filter out already added keywords const availableIds = ids.filter(id => { const keyword = seedKeywords.find(sk => String(sk.id) === id); return keyword && !keyword.isAdded; }); if (availableIds.length === 0) { toast.error('All selected keywords are already added to workflow'); return; } if (availableIds.length < ids.length) { toast.info(`${ids.length - availableIds.length} keyword(s) were already added and were skipped`); } const seedKeywordIds = availableIds.map(id => parseInt(id)); await handleAddToWorkflow(seedKeywordIds); }, [handleAddToWorkflow, toast, seedKeywords]); // Handle add all const handleAddAll = useCallback(async () => { if (!activeSite || !activeSite.industry) { toast.error('Please select an active site first'); return; } const availableKeywords = seedKeywords.filter(sk => !sk.isAdded); if (availableKeywords.length === 0) { toast.error('All keywords are already added to workflow'); return; } const seedKeywordIds = availableKeywords.map(sk => sk.id); await handleAddToWorkflow(seedKeywordIds); }, [activeSite, seedKeywords, handleAddToWorkflow, toast]); // Handle import click const handleImportClick = useCallback(() => { setIsImportModalOpen(true); }, []); // Handle import file upload const handleImportSubmit = useCallback(async () => { if (!importFile) { toast.error('Please select a file to import'); return; } setIsImporting(true); try { const formData = new FormData(); formData.append('file', importFile); const response = await fetch('/api/v1/auth/seed-keywords/import_seed_keywords/', { method: 'POST', headers: { 'Authorization': `Token ${localStorage.getItem('authToken')}`, }, body: formData, }); if (!response.ok) { const errorData = await response.json(); throw new Error(errorData.error || 'Import failed'); } const result = await response.json(); toast.success(`Successfully imported ${result.created || 0} keywords`); // Reset and close modal setImportFile(null); setIsImportModalOpen(false); // Reload keywords if (activeSite) { loadSeedKeywords(); } } catch (error: any) { console.error('Import error:', error); toast.error(`Import failed: ${error.message}`); } finally { setIsImporting(false); } }, [importFile, toast, activeSite, loadSeedKeywords]); // Page config const pageConfig = useMemo(() => { const showSectorColumn = !activeSector; return { columns: [ { key: 'keyword', label: 'Keyword', sortable: true, sortField: 'keyword', }, ...(showSectorColumn ? [{ key: 'sector_name', label: 'Sector', sortable: false, render: (_value: string, row: SeedKeyword) => ( {row.sector_name || '-'} ), }] : []), { key: 'volume', label: 'Volume', sortable: true, sortField: 'volume', render: (value: number) => value.toLocaleString(), }, { key: 'difficulty', label: 'Difficulty', sortable: true, sortField: 'difficulty', align: 'center' as const, render: (value: number) => { const difficultyNum = getDifficultyNumber(value); const difficultyBadgeVariant = 'light'; const difficultyBadgeColor = typeof difficultyNum === 'number' && difficultyNum === 1 ? 'success' : typeof difficultyNum === 'number' && difficultyNum === 2 ? 'success' : typeof difficultyNum === 'number' && difficultyNum === 3 ? 'warning' : typeof difficultyNum === 'number' && difficultyNum === 4 ? 'error' : typeof difficultyNum === 'number' && difficultyNum === 5 ? 'error' : 'light'; return typeof difficultyNum === 'number' ? ( {difficultyNum} ) : ( difficultyNum ); }, }, { key: 'country', label: 'Country', sortable: true, sortField: 'country', render: (value: string) => { return ( {value || '-'} ); }, }, { key: 'actions', label: '', sortable: false, align: 'right' as const, render: (_value: any, row: SeedKeyword & { isAdded?: boolean }) => { const isDisabled = !activeSector || row.isAdded; const buttonText = row.isAdded ? 'Added' : 'Add to Workflow'; return (
); }, }, ], filters: [ { key: 'search', label: 'Search', type: 'text' as const, placeholder: 'Search keywords...', }, { key: 'country', label: 'Country', type: 'select' as const, options: [ { value: '', label: 'All Countries' }, { value: 'US', label: 'United States' }, { value: 'CA', label: 'Canada' }, { value: 'GB', label: 'United Kingdom' }, { value: 'AE', label: 'United Arab Emirates' }, { value: 'AU', label: 'Australia' }, { value: 'IN', label: 'India' }, { value: 'PK', label: 'Pakistan' }, ], }, { key: 'difficulty', label: 'Difficulty', type: 'select' as const, options: [ { value: '', label: 'All Difficulty' }, { value: '1', label: '1 - Very Easy' }, { value: '2', label: '2 - Easy' }, { value: '3', label: '3 - Medium' }, { value: '4', label: '4 - Hard' }, { value: '5', label: '5 - Very Hard' }, ], }, { key: 'showNotAddedOnly', label: 'Status', type: 'select' as const, options: [ { value: '', label: 'All Keywords' }, { value: 'true', label: 'Not Yet Added Only' }, ], }, ], bulkActions: !activeSector ? [] : [ { key: 'add_selected_to_workflow', label: 'Add Selected to Workflow', variant: 'primary' as const, }, ], }; }, [activeSector, handleAddToWorkflow]); // Show loading state if (loading) { return ( <> , color: 'blue' }} />
Loading...
); } // Show WorkflowGuide if no sites if (sites.length === 0) { return ( <> , color: 'blue' }} />
); } return ( <> , color: 'blue' }} /> {/* Show info banner when no sector is selected */} {!activeSector && activeSite && (

Choose a Topic Area First

Pick a topic area first, then add keywords - You need to choose what you're writing about before adding search terms to target

)} {addedCount} in workflow
{availableCount} available {addedCount > 0 && ( <>
)}
) : undefined} onFilterChange={(key, value) => { const stringValue = value === null || value === undefined ? '' : String(value); if (key === 'search') { setSearchTerm(stringValue); } else if (key === 'country') { setCountryFilter(stringValue); setCurrentPage(1); } else if (key === 'difficulty') { setDifficultyFilter(stringValue); setCurrentPage(1); } else if (key === 'showNotAddedOnly') { setShowNotAddedOnly(stringValue === 'true'); setCurrentPage(1); } }} onBulkAction={async (actionKey: string, ids: string[]) => { if (actionKey === 'add_selected_to_workflow') { await handleBulkAddSelected(ids); } }} bulkActions={pageConfig.bulkActions} pagination={{ currentPage, totalPages, totalCount, onPageChange: setCurrentPage, }} sorting={{ sortBy, sortDirection, onSort: handleSort, }} selection={{ selectedIds, onSelectionChange: setSelectedIds, }} onEdit={undefined} onDelete={undefined} /> {/* Import Modal */} { if (!isImporting) { setIsImportModalOpen(false); setImportFile(null); } }} >

Import Seed Keywords

Expected columns: keyword, volume, difficulty, country, industry_name, sector_name

{ const file = e.target.files?.[0]; if (file) { setImportFile(file); } }} disabled={isImporting} /> {importFile && (

Selected: {importFile.name}

)}
); }