/** * Keyword Opportunities Page * Shows available SeedKeywords for the active site/sectors * Allows users to add keywords to their workflow */ import { useState, useEffect, useRef, useMemo, useCallback } from 'react'; import TablePageTemplate from '../../templates/TablePageTemplate'; import { fetchSeedKeywords, SeedKeyword, SeedKeywordResponse, addSeedKeywordsToWorkflow, } from '../../services/api'; import { useSiteStore } from '../../store/siteStore'; import { useSectorStore } from '../../store/sectorStore'; import { usePageSizeStore } from '../../store/pageSizeStore'; import { useToast } from '../../components/ui/toast/ToastContainer'; import { getDifficultyLabelFromNumber, getDifficultyRange, getDifficultyNumber } from '../../utils/difficulty'; import Badge from '../../components/ui/badge/Badge'; import { formatRelativeDate } from '../../utils/date'; import { BoltIcon, PlusIcon } from '../../icons'; export default function KeywordOpportunities() { const toast = useToast(); const { activeSite } = useSiteStore(); const { activeSector, loadSectorsForSite } = useSectorStore(); const { pageSize } = usePageSizeStore(); // Data state 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 [intentFilter, setIntentFilter] = useState(''); const [difficultyFilter, setDifficultyFilter] = useState(''); const [volumeMin, setVolumeMin] = useState(''); const [volumeMax, setVolumeMax] = useState(''); // Load sectors for active site useEffect(() => { if (activeSite?.id) { loadSectorsForSite(activeSite.id); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [activeSite?.id]); // loadSectorsForSite is stable from Zustand store, no need to include it // Load seed keywords const loadSeedKeywords = useCallback(async () => { if (!activeSite || !activeSite.industry) { setSeedKeywords([]); setTotalCount(0); setTotalPages(1); setLoading(false); return; } setLoading(true); 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, // Get all to check which are attached }); (keywordsData.results || []).forEach((k: any) => { // seed_keyword_id is write_only in serializer, so use seed_keyword.id instead const seedKeywordId = k.seed_keyword_id || (k.seed_keyword && k.seed_keyword.id); if (seedKeywordId) { attachedSeedKeywordIds.add(Number(seedKeywordId)); } }); } catch (err) { // If keywords fetch fails for a sector, continue with others console.warn(`Could not fetch attached keywords for sector ${sector.id}:`, err); } } } catch (err) { // If sectors fetch fails, continue without filtering 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, // Use reasonable page size (API might have max limit) }; // Add sector filter if active sector is selected // IMPORTANT: Filter by industry_sector (IndustrySector ID) which is what SeedKeyword.sector references if (activeSector && activeSector.industry_sector) { baseFilters.sector = activeSector.industry_sector; } if (searchTerm) baseFilters.search = searchTerm; if (intentFilter) baseFilters.intent = intentFilter; // 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]; } // Check if there are more pages hasMore = data.next !== null && data.next !== undefined; currentPageNum++; // Safety limit to prevent infinite loops if (currentPageNum > 100) { console.warn('Reached maximum page limit (100) while fetching seed keywords'); break; } } // Mark already-attached keywords instead of filtering them out // Also check recentlyAddedRef to preserve state for keywords just added let filteredResults = allResults.map(sk => { const isAdded = attachedSeedKeywordIds.has(Number(sk.id)) || recentlyAddedRef.current.has(Number(sk.id)); return { ...sk, isAdded: Boolean(isAdded) // Explicitly convert to boolean true/false }; }); 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 ); } } } if (volumeMin !== '' && volumeMin !== null && volumeMin !== undefined) { filteredResults = filteredResults.filter(sk => sk.volume >= Number(volumeMin)); } if (volumeMax !== '' && volumeMax !== null && volumeMax !== undefined) { filteredResults = filteredResults.filter(sk => sk.volume <= Number(volumeMax)); } // 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 === 'intent') { aVal = a.intent.toLowerCase(); bVal = b.intent.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 keyword opportunities: ${error.message}`); setSeedKeywords([]); setTotalCount(0); setTotalPages(1); } finally { setLoading(false); } }, [activeSite, activeSector, currentPage, pageSize, searchTerm, intentFilter, difficultyFilter, volumeMin, volumeMax, sortBy, sortDirection]); // Load data on mount and when filters change (excluding search - handled separately) useEffect(() => { loadSeedKeywords(); }, [loadSeedKeywords]); // Debounced search - reset to page 1 when search term changes useEffect(() => { const timer = setTimeout(() => { setCurrentPage(1); }, 500); return () => clearTimeout(timer); }, [searchTerm]); // Only depend on searchTerm // Handle pageSize changes - reload data when pageSize changes // Note: loadSeedKeywords will be recreated when pageSize changes (it's in its dependencies) // The effect that depends on loadSeedKeywords will handle the reload // We just need to reset to page 1 useEffect(() => { setCurrentPage(1); }, [pageSize]); // Only depend on 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 - use activeSector if available, otherwise get first available sector 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) { toast.success(`Successfully added ${result.created} keyword(s) to workflow`); // Track these as recently added to preserve state during reload seedKeywordIds.forEach(id => { recentlyAddedRef.current.add(id); }); // Clear selection setSelectedIds([]); // Immediately update state to mark keywords as added - this gives instant feedback setSeedKeywords(prevKeywords => prevKeywords.map(kw => seedKeywordIds.includes(kw.id) ? { ...kw, isAdded: true } : kw ) ); // Don't reload immediately - the state is already updated // The recentlyAddedRef will ensure they stay marked as added // Only reload if user changes filters/pagination } else { toast.error(`Failed to add keywords: ${result.errors?.join(', ') || 'Unknown error'}`); } } catch (error: any) { toast.error(`Failed to add keywords: ${error.message}`); } }, [activeSite, activeSector, toast]); // Handle bulk add selected - filter out already added keywords 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 - fetch all keywords for site/sectors, not just current page const handleAddAll = useCallback(async () => { if (!activeSite || !activeSite.industry) { toast.error('Please select an active site first'); return; } try { // Fetch ALL seed keywords for the site/sectors (no pagination) const filters: any = { industry: activeSite.industry, page_size: 1000, // Large page size to get all }; if (activeSector?.industry_sector) { filters.sector = activeSector.industry_sector; } const data: SeedKeywordResponse = await fetchSeedKeywords(filters); const allSeedKeywords = data.results || []; if (allSeedKeywords.length === 0) { toast.error('No keywords available to add'); return; } // Get already-added keywords to filter them out const { fetchKeywords, fetchSiteSectors } = await import('../../services/api'); const sectors = await fetchSiteSectors(activeSite.id); let attachedSeedKeywordIds = new Set(); 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) => { // seed_keyword_id is write_only in serializer, so use seed_keyword.id instead 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); } } // Filter out already added keywords const availableKeywords = allSeedKeywords.filter(sk => !attachedSeedKeywordIds.has(sk.id)); if (availableKeywords.length === 0) { toast.error('All keywords are already added to workflow'); return; } if (availableKeywords.length < allSeedKeywords.length) { toast.info(`${allSeedKeywords.length - availableKeywords.length} keyword(s) were already added and were skipped`); } const seedKeywordIds = availableKeywords.map(sk => sk.id); await handleAddToWorkflow(seedKeywordIds); } catch (error: any) { toast.error(`Failed to load all keywords: ${error.message}`); } }, [activeSite, activeSector, handleAddToWorkflow, toast]); // Page config const pageConfig = useMemo(() => { const showSectorColumn = !activeSector; // Show when viewing all sectors 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 = typeof difficultyNum === 'number' && difficultyNum === 5 ? 'solid' : typeof difficultyNum === 'number' && (difficultyNum === 2 || difficultyNum === 3 || difficultyNum === 4) ? 'light' : typeof difficultyNum === 'number' && difficultyNum === 1 ? 'solid' : '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: 'intent', label: 'Intent', sortable: true, sortField: 'intent', render: (value: string) => { const getIntentColor = (intent: string) => { const lowerIntent = intent?.toLowerCase() || ''; if (lowerIntent === 'transactional' || lowerIntent === 'commercial') { return 'success'; } else if (lowerIntent === 'navigational') { return 'warning'; } return 'info'; }; return ( {value} ); }, }, ], filters: [ { key: 'search', label: 'Search', type: 'text', placeholder: 'Search keywords...', }, { key: 'intent', label: 'Intent', type: 'select', options: [ { value: '', label: 'All Intent' }, { value: 'informational', label: 'Informational' }, { value: 'navigational', label: 'Navigational' }, { value: 'transactional', label: 'Transactional' }, { value: 'commercial', label: 'Commercial' }, ], }, { key: 'difficulty', label: 'Difficulty', type: 'select', 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' }, ], }, ], }; }, [activeSector]); return ( <> } subtitle="Discover and add keywords to your workflow" columns={pageConfig.columns} data={seedKeywords} loading={loading} showContent={showContent} filters={pageConfig.filters} filterValues={{ search: searchTerm, intent: intentFilter, difficulty: difficultyFilter, }} onFilterChange={(key, value) => { const stringValue = value === null || value === undefined ? '' : String(value); if (key === 'search') { setSearchTerm(stringValue); } else if (key === 'intent') { setIntentFilter(stringValue); setCurrentPage(1); } else if (key === 'difficulty') { setDifficultyFilter(stringValue); setCurrentPage(1); } }} onRowAction={async (actionKey: string, row: SeedKeyword & { isAdded?: boolean }) => { if (actionKey === 'add_to_workflow') { // Don't allow adding already-added keywords if (row.isAdded) { toast.info('This keyword is already added to workflow'); return; } await handleAddToWorkflow([row.id]); } }} onBulkAction={async (actionKey: string, ids: string[]) => { if (actionKey === 'add_selected_to_workflow') { await handleBulkAddSelected(ids); } }} onCreate={handleAddAll} createLabel="Add All to Workflow" onCreateIcon={} pagination={{ currentPage, totalPages, totalCount, onPageChange: setCurrentPage, }} sorting={{ sortBy, sortDirection, onSort: handleSort, }} selection={{ selectedIds, onSelectionChange: setSelectedIds, }} /> ); }