626 lines
22 KiB
TypeScript
626 lines
22 KiB
TypeScript
/**
|
|
* 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<string[]>([]);
|
|
// Track recently added keywords to preserve their state during reload
|
|
const recentlyAddedRef = useRef<Set<number>>(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<string>('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<number | ''>('');
|
|
const [volumeMax, setVolumeMax] = useState<number | ''>('');
|
|
|
|
// 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<number>();
|
|
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<number>();
|
|
|
|
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) => (
|
|
<Badge color="info" size="sm" variant="light">
|
|
{row.sector_name || '-'}
|
|
</Badge>
|
|
),
|
|
}] : []),
|
|
{
|
|
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' ? (
|
|
<Badge
|
|
color={difficultyBadgeColor}
|
|
variant={difficultyBadgeVariant}
|
|
size="sm"
|
|
>
|
|
{difficultyNum}
|
|
</Badge>
|
|
) : (
|
|
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 (
|
|
<Badge
|
|
color={getIntentColor(value)}
|
|
size="sm"
|
|
variant={value?.toLowerCase() === 'informational' ? 'light' : undefined}
|
|
>
|
|
{value}
|
|
</Badge>
|
|
);
|
|
},
|
|
},
|
|
],
|
|
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 (
|
|
<>
|
|
<TablePageTemplate
|
|
title="Keyword Opportunities"
|
|
titleIcon={<BoltIcon className="text-brand-500 size-5" />}
|
|
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={<PlusIcon />}
|
|
pagination={{
|
|
currentPage,
|
|
totalPages,
|
|
totalCount,
|
|
onPageChange: setCurrentPage,
|
|
}}
|
|
sorting={{
|
|
sortBy,
|
|
sortDirection,
|
|
onSort: handleSort,
|
|
}}
|
|
selection={{
|
|
selectedIds,
|
|
onSelectionChange: setSelectedIds,
|
|
}}
|
|
/>
|
|
</>
|
|
);
|
|
}
|
|
|