keywrod slibrary page dsigning
This commit is contained in:
@@ -45,7 +45,7 @@ export default function SeedKeywords() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageMeta title="Seed Keywords" />
|
||||
<PageMeta title="Seed Keywords" description="Global keyword library for reference" />
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Seed Keywords</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400 mt-1">Global keyword library for reference</p>
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
fetchKeywords,
|
||||
fetchSectorStats,
|
||||
SectorStats,
|
||||
SectorStatsItem,
|
||||
} from '../../services/api';
|
||||
import Badge from '../../components/ui/badge/Badge';
|
||||
import { BoltIcon, ShootingStarIcon } from '../../icons';
|
||||
@@ -32,19 +33,23 @@ import { usePageSizeStore } from '../../store/pageSizeStore';
|
||||
import { getDifficultyNumber, getDifficultyRange, getDifficultyLabelFromNumber } from '../../utils/difficulty';
|
||||
import { useSiteStore } from '../../store/siteStore';
|
||||
import { useSectorStore } from '../../store/sectorStore';
|
||||
import type { Sector } from '../../store/sectorStore';
|
||||
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';
|
||||
import Input from '../../components/form/input/InputField';
|
||||
import { Card } from '../../components/ui/card';
|
||||
import { SectorMetricGrid, StatType } from '../../components/keywords-library/SectorMetricCard';
|
||||
import SmartSuggestions, { buildSmartSuggestions } from '../../components/keywords-library/SmartSuggestions';
|
||||
import SectorCardsGrid from '../../components/keywords-library/SectorCardsGrid';
|
||||
import BulkAddConfirmation from '../../components/keywords-library/BulkAddConfirmation';
|
||||
|
||||
export default function IndustriesSectorsKeywords() {
|
||||
const toast = useToast();
|
||||
const { startLoading, stopLoading } = usePageLoading();
|
||||
const { activeSite } = useSiteStore();
|
||||
const { activeSector, loadSectorsForSite } = useSectorStore();
|
||||
const { activeSector, loadSectorsForSite, sectors, setActiveSector } = useSectorStore();
|
||||
const { pageSize } = usePageSizeStore();
|
||||
|
||||
// Data state
|
||||
@@ -54,9 +59,11 @@ export default function IndustriesSectorsKeywords() {
|
||||
const [selectedIds, setSelectedIds] = useState<string[]>([]);
|
||||
// Track recently added keywords to preserve their state during reload
|
||||
const recentlyAddedRef = useRef<Set<number>>(new Set());
|
||||
const attachedSeedKeywordIdsRef = useRef<Set<number>>(new Set());
|
||||
|
||||
// Sector Stats state (for metric cards)
|
||||
const [sectorStats, setSectorStats] = useState<SectorStats | null>(null);
|
||||
const [sectorStatsList, setSectorStatsList] = useState<SectorStatsItem[]>([]);
|
||||
const [loadingSectorStats, setLoadingSectorStats] = useState(false);
|
||||
const [activeStatFilter, setActiveStatFilter] = useState<StatType | null>(null);
|
||||
|
||||
@@ -64,6 +71,10 @@ export default function IndustriesSectorsKeywords() {
|
||||
const [showBulkAddModal, setShowBulkAddModal] = useState(false);
|
||||
const [bulkAddKeywordIds, setBulkAddKeywordIds] = useState<number[]>([]);
|
||||
const [bulkAddStatLabel, setBulkAddStatLabel] = useState<string | undefined>();
|
||||
const [pendingBulkAddKey, setPendingBulkAddKey] = useState<string | null>(null);
|
||||
const [pendingBulkAddGroup, setPendingBulkAddGroup] = useState<'stat' | 'suggestion' | null>(null);
|
||||
const [addedStatActions, setAddedStatActions] = useState<Set<string>>(new Set());
|
||||
const [addedSuggestionActions, setAddedSuggestionActions] = useState<Set<string>>(new Set());
|
||||
|
||||
// Ahrefs banner state
|
||||
const [showAhrefsBanner, setShowAhrefsBanner] = useState(true);
|
||||
@@ -82,6 +93,8 @@ export default function IndustriesSectorsKeywords() {
|
||||
const [countryFilter, setCountryFilter] = useState('');
|
||||
const [difficultyFilter, setDifficultyFilter] = useState('');
|
||||
const [showNotAddedOnly, setShowNotAddedOnly] = useState(false);
|
||||
const [volumeMin, setVolumeMin] = useState('');
|
||||
const [volumeMax, setVolumeMax] = useState('');
|
||||
|
||||
// Keyword count tracking
|
||||
const [addedCount, setAddedCount] = useState(0);
|
||||
@@ -176,6 +189,7 @@ export default function IndustriesSectorsKeywords() {
|
||||
const loadSectorStats = useCallback(async () => {
|
||||
if (!activeSite || !activeSite.industry) {
|
||||
setSectorStats(null);
|
||||
setSectorStatsList([]);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -183,15 +197,21 @@ export default function IndustriesSectorsKeywords() {
|
||||
try {
|
||||
const response = await fetchSectorStats({
|
||||
industry_id: activeSite.industry,
|
||||
sector_id: activeSector?.industry_sector ?? undefined,
|
||||
site_id: activeSite.id,
|
||||
});
|
||||
|
||||
// If sector-specific stats returned
|
||||
if (response.stats) {
|
||||
setSectorStats(response.stats as SectorStats);
|
||||
} else if (response.sectors && response.sectors.length > 0) {
|
||||
// Aggregate stats from all sectors
|
||||
const allSectors = response.sectors || [];
|
||||
setSectorStatsList(allSectors);
|
||||
|
||||
if (activeSector?.industry_sector) {
|
||||
const matching = allSectors.find((sector) => sector.sector_id === activeSector.industry_sector);
|
||||
if (matching) {
|
||||
setSectorStats(matching.stats as SectorStats);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (allSectors.length > 0) {
|
||||
const aggregated: SectorStats = {
|
||||
total: { count: 0 },
|
||||
available: { count: 0 },
|
||||
@@ -200,15 +220,14 @@ export default function IndustriesSectorsKeywords() {
|
||||
long_tail: { count: 0, threshold: 1000 },
|
||||
quick_wins: { count: 0, threshold: 1000 },
|
||||
};
|
||||
|
||||
response.sectors.forEach((sector) => {
|
||||
|
||||
allSectors.forEach((sector) => {
|
||||
aggregated.total.count += sector.stats.total.count;
|
||||
aggregated.available.count += sector.stats.available.count;
|
||||
aggregated.high_volume.count += sector.stats.high_volume.count;
|
||||
aggregated.premium_traffic.count += sector.stats.premium_traffic.count;
|
||||
aggregated.long_tail.count += sector.stats.long_tail.count;
|
||||
aggregated.quick_wins.count += sector.stats.quick_wins.count;
|
||||
// Use first sector's thresholds (they should be consistent)
|
||||
if (!aggregated.premium_traffic.threshold) {
|
||||
aggregated.premium_traffic.threshold = sector.stats.premium_traffic.threshold;
|
||||
}
|
||||
@@ -219,8 +238,10 @@ export default function IndustriesSectorsKeywords() {
|
||||
aggregated.quick_wins.threshold = sector.stats.quick_wins.threshold;
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
setSectorStats(aggregated);
|
||||
} else {
|
||||
setSectorStats(null);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load sector stats:', error);
|
||||
@@ -236,6 +257,26 @@ export default function IndustriesSectorsKeywords() {
|
||||
}
|
||||
}, [activeSite, activeSector, loadSectorStats]);
|
||||
|
||||
// Reset filters and state when site changes
|
||||
useEffect(() => {
|
||||
setActiveStatFilter(null);
|
||||
setSearchTerm('');
|
||||
setCountryFilter('');
|
||||
setDifficultyFilter('');
|
||||
setShowNotAddedOnly(false);
|
||||
setCurrentPage(1);
|
||||
setSelectedIds([]);
|
||||
setAddedStatActions(new Set());
|
||||
setAddedSuggestionActions(new Set());
|
||||
}, [activeSite?.id]);
|
||||
|
||||
// Reset pagination/selection when sector changes
|
||||
useEffect(() => {
|
||||
setActiveStatFilter(null);
|
||||
setCurrentPage(1);
|
||||
setSelectedIds([]);
|
||||
}, [activeSector?.id]);
|
||||
|
||||
// Load counts on mount and when site/sector changes
|
||||
useEffect(() => {
|
||||
if (activeSite) {
|
||||
@@ -283,6 +324,9 @@ export default function IndustriesSectorsKeywords() {
|
||||
console.warn('Could not fetch sectors or attached keywords:', err);
|
||||
}
|
||||
|
||||
// Keep attached IDs available for bulk add actions
|
||||
attachedSeedKeywordIdsRef.current = attachedSeedKeywordIds;
|
||||
|
||||
// Build API filters - use server-side pagination
|
||||
const pageSizeNum = pageSize || 25;
|
||||
const filters: any = {
|
||||
@@ -298,6 +342,8 @@ export default function IndustriesSectorsKeywords() {
|
||||
|
||||
if (searchTerm) filters.search = searchTerm;
|
||||
if (countryFilter) filters.country = countryFilter;
|
||||
if (volumeMin) filters.volume_min = parseInt(volumeMin);
|
||||
if (volumeMax) filters.volume_max = parseInt(volumeMax);
|
||||
|
||||
// Apply difficulty filter (if API supports it, otherwise we'll filter client-side)
|
||||
if (difficultyFilter) {
|
||||
@@ -361,7 +407,7 @@ export default function IndustriesSectorsKeywords() {
|
||||
setAvailableCount(0);
|
||||
setShowContent(true);
|
||||
}
|
||||
}, [activeSite, activeSector, currentPage, pageSize, searchTerm, countryFilter, difficultyFilter, showNotAddedOnly, sortBy, sortDirection, toast]);
|
||||
}, [activeSite, activeSector, currentPage, pageSize, searchTerm, countryFilter, volumeMin, volumeMax, difficultyFilter, showNotAddedOnly, sortBy, sortDirection, toast]);
|
||||
|
||||
// Load data when site/sector/filters change (show table by default per plan)
|
||||
useEffect(() => {
|
||||
@@ -381,6 +427,7 @@ export default function IndustriesSectorsKeywords() {
|
||||
// Reset to page 1 on pageSize change
|
||||
useEffect(() => {
|
||||
setCurrentPage(1);
|
||||
setSelectedIds([]);
|
||||
}, [pageSize]);
|
||||
|
||||
// Handle sorting
|
||||
@@ -388,6 +435,7 @@ export default function IndustriesSectorsKeywords() {
|
||||
setSortBy(field || 'keyword');
|
||||
setSortDirection(direction);
|
||||
setCurrentPage(1);
|
||||
setSelectedIds([]);
|
||||
};
|
||||
|
||||
// Handle adding keywords to workflow
|
||||
@@ -684,6 +732,50 @@ export default function IndustriesSectorsKeywords() {
|
||||
type: 'text' as const,
|
||||
placeholder: 'Search keywords...',
|
||||
},
|
||||
{
|
||||
key: 'sector',
|
||||
label: 'Sector',
|
||||
type: 'select' as const,
|
||||
options: [
|
||||
{ value: '', label: 'All Sectors' },
|
||||
...sectors.map((sector) => ({
|
||||
value: String(sector.id),
|
||||
label: sector.name,
|
||||
})),
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'volume',
|
||||
label: 'Volume',
|
||||
type: 'custom' as const,
|
||||
customRender: () => (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">Volume</span>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="Min"
|
||||
value={volumeMin}
|
||||
onChange={(e) => {
|
||||
setVolumeMin(e.target.value);
|
||||
setCurrentPage(1);
|
||||
setSelectedIds([]);
|
||||
}}
|
||||
className="w-20 h-8"
|
||||
/>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="Max"
|
||||
value={volumeMax}
|
||||
onChange={(e) => {
|
||||
setVolumeMax(e.target.value);
|
||||
setCurrentPage(1);
|
||||
setSelectedIds([]);
|
||||
}}
|
||||
className="w-20 h-8"
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'country',
|
||||
label: 'Country',
|
||||
@@ -730,7 +822,7 @@ export default function IndustriesSectorsKeywords() {
|
||||
},
|
||||
],
|
||||
};
|
||||
}, [activeSector, handleAddToWorkflow]);
|
||||
}, [activeSector, handleAddToWorkflow, sectors]);
|
||||
|
||||
// Build smart suggestions from sector stats
|
||||
const smartSuggestions = useMemo(() => {
|
||||
@@ -738,6 +830,407 @@ export default function IndustriesSectorsKeywords() {
|
||||
return buildSmartSuggestions(sectorStats, { showOnlyWithResults: true });
|
||||
}, [sectorStats]);
|
||||
|
||||
// Helper: word count for keyword string
|
||||
const getWordCount = useCallback((keyword: string) => {
|
||||
return keyword.trim().split(/\s+/).filter(Boolean).length;
|
||||
}, []);
|
||||
|
||||
const buildStatActionKey = useCallback((statType: StatType, count: number) => {
|
||||
return `${statType}:${count}`;
|
||||
}, []);
|
||||
|
||||
const buildSuggestionActionKey = useCallback((suggestionId: string, count: number) => {
|
||||
return `${suggestionId}:${count}`;
|
||||
}, []);
|
||||
|
||||
const isStatActionAdded = useCallback((statType: StatType, count: number) => {
|
||||
return addedStatActions.has(buildStatActionKey(statType, count));
|
||||
}, [addedStatActions, buildStatActionKey]);
|
||||
|
||||
const isSuggestionActionAdded = useCallback((suggestionId: string, count: number) => {
|
||||
return addedSuggestionActions.has(buildSuggestionActionKey(suggestionId, count));
|
||||
}, [addedSuggestionActions, buildSuggestionActionKey]);
|
||||
|
||||
const fetchBulkKeywords = useCallback(async (options: {
|
||||
ordering: string;
|
||||
difficultyMax?: number;
|
||||
minVolume?: number;
|
||||
longTail?: boolean;
|
||||
availableOnly?: boolean;
|
||||
count: number;
|
||||
}) => {
|
||||
if (!activeSite || !activeSector?.industry_sector) {
|
||||
throw new Error('Please select a site and sector first');
|
||||
}
|
||||
|
||||
const { ordering, difficultyMax, minVolume, longTail, availableOnly, count } = options;
|
||||
const pageSize = Math.max(500, count * 2);
|
||||
|
||||
const filters: any = {
|
||||
industry: activeSite.industry,
|
||||
sector: activeSector.industry_sector,
|
||||
page_size: pageSize,
|
||||
ordering,
|
||||
};
|
||||
|
||||
if (difficultyMax !== undefined) {
|
||||
filters.difficulty_max = difficultyMax;
|
||||
}
|
||||
|
||||
const response = await fetchKeywordsLibrary(filters);
|
||||
let results = response.results || [];
|
||||
|
||||
if (minVolume !== undefined) {
|
||||
results = results.filter((kw) => (kw.volume || 0) >= minVolume);
|
||||
}
|
||||
|
||||
if (longTail) {
|
||||
results = results.filter((kw) => getWordCount(kw.keyword) >= 4);
|
||||
}
|
||||
|
||||
if (availableOnly) {
|
||||
results = results.filter((kw) => !attachedSeedKeywordIdsRef.current.has(Number(kw.id)));
|
||||
}
|
||||
|
||||
const topIds = results.slice(0, count).map((kw) => kw.id);
|
||||
return topIds;
|
||||
}, [activeSite, activeSector, getWordCount]);
|
||||
|
||||
const prepareBulkAdd = useCallback(async (payload: {
|
||||
label: string;
|
||||
ids: number[];
|
||||
actionKey: string;
|
||||
group: 'stat' | 'suggestion';
|
||||
}) => {
|
||||
if (payload.ids.length === 0) {
|
||||
toast.error('No matching keywords found for this selection');
|
||||
return;
|
||||
}
|
||||
|
||||
setBulkAddKeywordIds(payload.ids);
|
||||
setBulkAddStatLabel(payload.label);
|
||||
setPendingBulkAddKey(payload.actionKey);
|
||||
setPendingBulkAddGroup(payload.group);
|
||||
setShowBulkAddModal(true);
|
||||
}, [toast]);
|
||||
|
||||
// Handle stat card click - filters table to show matching keywords
|
||||
const handleStatClick = useCallback((statType: StatType) => {
|
||||
// Toggle off if clicking same stat
|
||||
if (activeStatFilter === statType) {
|
||||
setActiveStatFilter(null);
|
||||
setShowNotAddedOnly(false);
|
||||
setDifficultyFilter('');
|
||||
setVolumeMin('');
|
||||
setVolumeMax('');
|
||||
setSelectedIds([]);
|
||||
return;
|
||||
}
|
||||
|
||||
setActiveStatFilter(statType);
|
||||
setCurrentPage(1);
|
||||
setSelectedIds([]);
|
||||
|
||||
const statThresholds = {
|
||||
highVolume: sectorStats?.high_volume.threshold ?? 10000,
|
||||
premium: sectorStats?.premium_traffic.threshold ?? 50000,
|
||||
longTail: sectorStats?.long_tail.threshold ?? 1000,
|
||||
quickWins: sectorStats?.quick_wins.threshold ?? 1000,
|
||||
};
|
||||
|
||||
switch (statType) {
|
||||
case 'available':
|
||||
setShowNotAddedOnly(true);
|
||||
setDifficultyFilter('');
|
||||
setVolumeMin('');
|
||||
setVolumeMax('');
|
||||
break;
|
||||
case 'high_volume':
|
||||
setShowNotAddedOnly(false);
|
||||
setDifficultyFilter('');
|
||||
setVolumeMin(String(statThresholds.highVolume));
|
||||
setVolumeMax('');
|
||||
setSortBy('volume');
|
||||
setSortDirection('desc');
|
||||
break;
|
||||
case 'premium_traffic':
|
||||
setShowNotAddedOnly(false);
|
||||
setDifficultyFilter('');
|
||||
setVolumeMin(String(statThresholds.premium));
|
||||
setVolumeMax('');
|
||||
setSortBy('volume');
|
||||
setSortDirection('desc');
|
||||
break;
|
||||
case 'long_tail':
|
||||
setShowNotAddedOnly(false);
|
||||
setDifficultyFilter('');
|
||||
setVolumeMin(String(statThresholds.longTail));
|
||||
setVolumeMax('');
|
||||
setSortBy('keyword');
|
||||
setSortDirection('asc');
|
||||
break;
|
||||
case 'quick_wins':
|
||||
setShowNotAddedOnly(true);
|
||||
setDifficultyFilter('1');
|
||||
setVolumeMin(String(statThresholds.quickWins));
|
||||
setVolumeMax('');
|
||||
setSortBy('difficulty');
|
||||
setSortDirection('asc');
|
||||
break;
|
||||
default:
|
||||
setShowNotAddedOnly(false);
|
||||
setDifficultyFilter('');
|
||||
setVolumeMin('');
|
||||
setVolumeMax('');
|
||||
}
|
||||
}, [activeStatFilter, sectorStats]);
|
||||
|
||||
const handleSuggestionClick = useCallback((suggestion: any) => {
|
||||
setActiveStatFilter(null);
|
||||
setCurrentPage(1);
|
||||
setSelectedIds([]);
|
||||
|
||||
const difficultyMax = suggestion.filterParams?.difficulty_max;
|
||||
const volumeMinValue = suggestion.filterParams?.volume_min;
|
||||
const availableOnly = Boolean(suggestion.filterParams?.available_only);
|
||||
|
||||
if (availableOnly) {
|
||||
setShowNotAddedOnly(true);
|
||||
} else {
|
||||
setShowNotAddedOnly(false);
|
||||
}
|
||||
|
||||
if (volumeMinValue) {
|
||||
setVolumeMin(String(volumeMinValue));
|
||||
} else {
|
||||
setVolumeMin('');
|
||||
}
|
||||
setVolumeMax('');
|
||||
|
||||
if (difficultyMax !== undefined) {
|
||||
if (difficultyMax <= 20) {
|
||||
setDifficultyFilter('1');
|
||||
} else if (difficultyMax <= 40) {
|
||||
setDifficultyFilter('2');
|
||||
} else {
|
||||
setDifficultyFilter('');
|
||||
}
|
||||
} else {
|
||||
setDifficultyFilter('');
|
||||
}
|
||||
|
||||
if (suggestion.id === 'quick_wins') {
|
||||
setSortBy('difficulty');
|
||||
setSortDirection('asc');
|
||||
} else {
|
||||
setSortBy('volume');
|
||||
setSortDirection('desc');
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Handle sector card click
|
||||
const handleSectorSelect = useCallback((sector: Sector | null) => {
|
||||
setActiveStatFilter(null);
|
||||
setSelectedIds([]);
|
||||
setCurrentPage(1);
|
||||
if (!sector) {
|
||||
setActiveSector(null);
|
||||
return;
|
||||
}
|
||||
setActiveSector(sector);
|
||||
}, [setActiveSector]);
|
||||
|
||||
// Handle bulk add from metric cards
|
||||
const handleMetricBulkAdd = useCallback(async (statType: StatType, count: number) => {
|
||||
if (!activeSite || !activeSector?.industry_sector) {
|
||||
toast.error('Please select a site and sector first');
|
||||
return;
|
||||
}
|
||||
|
||||
const actionKey = buildStatActionKey(statType, count);
|
||||
if (addedStatActions.has(actionKey)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const statLabelMap: Record<StatType, string> = {
|
||||
total: 'Total Keywords',
|
||||
available: 'Available Keywords',
|
||||
high_volume: 'High Volume',
|
||||
premium_traffic: 'Premium Traffic',
|
||||
long_tail: 'Long Tail',
|
||||
quick_wins: 'Quick Wins',
|
||||
};
|
||||
|
||||
const thresholdMap = {
|
||||
high_volume: sectorStats?.high_volume.threshold ?? 10000,
|
||||
premium_traffic: sectorStats?.premium_traffic.threshold ?? 50000,
|
||||
long_tail: sectorStats?.long_tail.threshold ?? 1000,
|
||||
quick_wins: sectorStats?.quick_wins.threshold ?? 1000,
|
||||
};
|
||||
|
||||
let ids: number[] = [];
|
||||
if (statType === 'quick_wins') {
|
||||
ids = await fetchBulkKeywords({
|
||||
ordering: 'difficulty',
|
||||
difficultyMax: 20,
|
||||
minVolume: thresholdMap.quick_wins,
|
||||
availableOnly: true,
|
||||
count,
|
||||
});
|
||||
} else if (statType === 'long_tail') {
|
||||
ids = await fetchBulkKeywords({
|
||||
ordering: '-volume',
|
||||
minVolume: thresholdMap.long_tail,
|
||||
longTail: true,
|
||||
availableOnly: true,
|
||||
count,
|
||||
});
|
||||
} else if (statType === 'premium_traffic') {
|
||||
ids = await fetchBulkKeywords({
|
||||
ordering: '-volume',
|
||||
minVolume: thresholdMap.premium_traffic,
|
||||
availableOnly: true,
|
||||
count,
|
||||
});
|
||||
} else if (statType === 'high_volume') {
|
||||
ids = await fetchBulkKeywords({
|
||||
ordering: '-volume',
|
||||
minVolume: thresholdMap.high_volume,
|
||||
availableOnly: true,
|
||||
count,
|
||||
});
|
||||
} else if (statType === 'available') {
|
||||
ids = await fetchBulkKeywords({
|
||||
ordering: '-volume',
|
||||
availableOnly: true,
|
||||
count,
|
||||
});
|
||||
} else {
|
||||
ids = await fetchBulkKeywords({
|
||||
ordering: '-volume',
|
||||
availableOnly: true,
|
||||
count,
|
||||
});
|
||||
}
|
||||
|
||||
await prepareBulkAdd({
|
||||
label: statLabelMap[statType],
|
||||
ids,
|
||||
actionKey,
|
||||
group: 'stat',
|
||||
});
|
||||
}, [activeSite, activeSector, addedStatActions, buildStatActionKey, fetchBulkKeywords, prepareBulkAdd, sectorStats, toast]);
|
||||
|
||||
// Handle bulk add from suggestions
|
||||
const handleSuggestionBulkAdd = useCallback(async (suggestion: any, count: number) => {
|
||||
if (!activeSite || !activeSector?.industry_sector) {
|
||||
toast.error('Please select a site and sector first');
|
||||
return;
|
||||
}
|
||||
|
||||
const actionKey = buildSuggestionActionKey(suggestion.id, count);
|
||||
if (addedSuggestionActions.has(actionKey)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const suggestionThresholds = {
|
||||
quick_wins: sectorStats?.quick_wins.threshold ?? 1000,
|
||||
long_tail: sectorStats?.long_tail.threshold ?? 1000,
|
||||
premium_traffic: sectorStats?.premium_traffic.threshold ?? 50000,
|
||||
};
|
||||
|
||||
let ids: number[] = [];
|
||||
if (suggestion.id === 'quick_wins') {
|
||||
ids = await fetchBulkKeywords({
|
||||
ordering: 'difficulty',
|
||||
difficultyMax: 20,
|
||||
minVolume: suggestionThresholds.quick_wins,
|
||||
availableOnly: true,
|
||||
count,
|
||||
});
|
||||
} else if (suggestion.id === 'long_tail') {
|
||||
ids = await fetchBulkKeywords({
|
||||
ordering: '-volume',
|
||||
minVolume: suggestionThresholds.long_tail,
|
||||
longTail: true,
|
||||
availableOnly: true,
|
||||
count,
|
||||
});
|
||||
} else if (suggestion.id === 'premium_traffic') {
|
||||
ids = await fetchBulkKeywords({
|
||||
ordering: '-volume',
|
||||
minVolume: suggestionThresholds.premium_traffic,
|
||||
availableOnly: true,
|
||||
count,
|
||||
});
|
||||
} else if (suggestion.id === 'available') {
|
||||
ids = await fetchBulkKeywords({
|
||||
ordering: '-volume',
|
||||
availableOnly: true,
|
||||
count,
|
||||
});
|
||||
} else {
|
||||
ids = await fetchBulkKeywords({
|
||||
ordering: '-volume',
|
||||
availableOnly: true,
|
||||
count,
|
||||
});
|
||||
}
|
||||
|
||||
await prepareBulkAdd({
|
||||
label: suggestion.label,
|
||||
ids,
|
||||
actionKey,
|
||||
group: 'suggestion',
|
||||
});
|
||||
}, [activeSite, activeSector, addedSuggestionActions, buildSuggestionActionKey, fetchBulkKeywords, prepareBulkAdd, sectorStats, toast]);
|
||||
|
||||
const handleConfirmBulkAdd = useCallback(async () => {
|
||||
if (!activeSite || !activeSector) {
|
||||
throw new Error('Please select a site and sector first');
|
||||
}
|
||||
|
||||
const result = await addSeedKeywordsToWorkflow(
|
||||
bulkAddKeywordIds,
|
||||
activeSite.id,
|
||||
activeSector.id
|
||||
);
|
||||
|
||||
if (!result.success) {
|
||||
const errorMsg = result.errors?.[0] || 'Unable to add keywords. Please try again.';
|
||||
throw new Error(errorMsg);
|
||||
}
|
||||
|
||||
if (result.created > 0) {
|
||||
bulkAddKeywordIds.forEach((id) => recentlyAddedRef.current.add(id));
|
||||
}
|
||||
|
||||
if (pendingBulkAddKey) {
|
||||
if (pendingBulkAddGroup === 'stat') {
|
||||
setAddedStatActions((prev) => new Set([...prev, pendingBulkAddKey]));
|
||||
}
|
||||
if (pendingBulkAddGroup === 'suggestion') {
|
||||
setAddedSuggestionActions((prev) => new Set([...prev, pendingBulkAddKey]));
|
||||
}
|
||||
}
|
||||
|
||||
setPendingBulkAddKey(null);
|
||||
setPendingBulkAddGroup(null);
|
||||
setShowBulkAddModal(false);
|
||||
|
||||
if (activeSite) {
|
||||
loadSeedKeywords();
|
||||
loadKeywordCounts();
|
||||
loadSectorStats();
|
||||
}
|
||||
|
||||
return {
|
||||
added: result.created || 0,
|
||||
skipped: result.skipped || 0,
|
||||
total_requested: bulkAddKeywordIds.length,
|
||||
};
|
||||
}, [activeSite, activeSector, bulkAddKeywordIds, loadKeywordCounts, loadSectorStats, loadSeedKeywords, pendingBulkAddGroup, pendingBulkAddKey]);
|
||||
|
||||
// Show WorkflowGuide if no sites
|
||||
if (sites.length === 0) {
|
||||
return (
|
||||
@@ -754,59 +1247,6 @@ export default function IndustriesSectorsKeywords() {
|
||||
);
|
||||
}
|
||||
|
||||
// Handle stat card click - filters table to show matching keywords
|
||||
const handleStatClick = (statType: StatType) => {
|
||||
// Toggle off if clicking same stat
|
||||
if (activeStatFilter === statType) {
|
||||
setActiveStatFilter(null);
|
||||
setShowNotAddedOnly(false);
|
||||
setDifficultyFilter('');
|
||||
return;
|
||||
}
|
||||
|
||||
setActiveStatFilter(statType);
|
||||
setCurrentPage(1);
|
||||
|
||||
// Apply filters based on stat type
|
||||
switch (statType) {
|
||||
case 'available':
|
||||
setShowNotAddedOnly(true);
|
||||
setDifficultyFilter('');
|
||||
break;
|
||||
case 'high_volume':
|
||||
// Volume >= 10K - needs sort by volume desc
|
||||
setShowNotAddedOnly(false);
|
||||
setDifficultyFilter('');
|
||||
setSortBy('volume');
|
||||
setSortDirection('desc');
|
||||
break;
|
||||
case 'premium_traffic':
|
||||
// Premium traffic - sort by volume
|
||||
setShowNotAddedOnly(false);
|
||||
setDifficultyFilter('');
|
||||
setSortBy('volume');
|
||||
setSortDirection('desc');
|
||||
break;
|
||||
case 'long_tail':
|
||||
// Long tail - can't filter by word count server-side, just show all sorted
|
||||
setShowNotAddedOnly(false);
|
||||
setDifficultyFilter('');
|
||||
setSortBy('keyword');
|
||||
setSortDirection('asc');
|
||||
break;
|
||||
case 'quick_wins':
|
||||
// Quick wins - low difficulty + available
|
||||
setShowNotAddedOnly(true);
|
||||
setDifficultyFilter('1'); // Very Easy (level 1 = backend 0-20)
|
||||
setSortBy('difficulty');
|
||||
setSortDirection('asc');
|
||||
break;
|
||||
default:
|
||||
setShowNotAddedOnly(false);
|
||||
setDifficultyFilter('');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageMeta title="Keywords Library" description="Browse curated keywords and add them to your workflow. Ahrefs research coming soon." />
|
||||
@@ -816,66 +1256,49 @@ export default function IndustriesSectorsKeywords() {
|
||||
badge={{ icon: <BoltIcon />, color: 'blue' }}
|
||||
/>
|
||||
|
||||
{/* Sector Cards - Top of page */}
|
||||
{activeSite && sectors.length > 0 && (
|
||||
<div className="mx-6 mt-6">
|
||||
<SectorCardsGrid
|
||||
sectors={sectors}
|
||||
sectorStats={sectorStatsList}
|
||||
activeSectorId={activeSector?.id}
|
||||
onSelectSector={handleSectorSelect}
|
||||
isLoading={loadingSectorStats}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Sector Metric Cards - Show when site is selected */}
|
||||
{activeSite && (
|
||||
<div className="mx-6 mt-6">
|
||||
<div className="mb-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-1">
|
||||
Keyword Stats {activeSector ? `— ${activeSector.name}` : '— All Sectors'}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Click a card to filter the table below
|
||||
</p>
|
||||
</div>
|
||||
<SectorMetricGrid
|
||||
stats={sectorStats}
|
||||
activeStatType={activeStatFilter}
|
||||
onStatClick={handleStatClick}
|
||||
onBulkAdd={activeSector ? handleMetricBulkAdd : undefined}
|
||||
isAddedAction={isStatActionAdded}
|
||||
clickable={true}
|
||||
sectorName={activeSector?.name}
|
||||
isLoading={loadingSectorStats}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Smart Suggestions Panel */}
|
||||
{activeSite && sectorStats && smartSuggestions.length > 0 && (
|
||||
{/* Smart Suggestions Panel - Always show when site is selected */}
|
||||
{activeSite && sectorStats && (
|
||||
<div className="mx-6 mt-6">
|
||||
<SmartSuggestions
|
||||
suggestions={smartSuggestions}
|
||||
onSuggestionClick={(suggestion) => {
|
||||
// Apply the filter from the suggestion
|
||||
if (suggestion.filterParams.statType) {
|
||||
handleStatClick(suggestion.filterParams.statType as StatType);
|
||||
}
|
||||
}}
|
||||
onSuggestionClick={handleSuggestionClick}
|
||||
onBulkAdd={activeSector ? handleSuggestionBulkAdd : undefined}
|
||||
isAddedAction={isSuggestionActionAdded}
|
||||
enableFilterClick={true}
|
||||
isLoading={loadingSectorStats}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Show info banner when no sector is selected */}
|
||||
{!activeSector && activeSite && (
|
||||
<div className="mx-6 mt-6 mb-4">
|
||||
<div className="bg-brand-50 dark:bg-brand-900/20 border border-brand-200 dark:border-brand-800 rounded-lg p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex-shrink-0">
|
||||
<svg className="w-5 h-5 text-brand-600 dark:text-brand-400 mt-0.5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="text-sm font-medium text-brand-900 dark:text-brand-200">
|
||||
Choose a Topic Area First
|
||||
</h3>
|
||||
<p className="mt-1 text-sm text-brand-700 dark:text-brand-300">
|
||||
Pick a topic area first, then add keywords - You need to choose what you're writing about before adding search terms to target
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Keywords Table - Shown by default (per plan) */}
|
||||
{activeSite && (
|
||||
<TablePageTemplate
|
||||
@@ -883,9 +1306,14 @@ export default function IndustriesSectorsKeywords() {
|
||||
data={seedKeywords}
|
||||
loading={!showContent}
|
||||
showContent={showContent}
|
||||
defaultShowFilters={true}
|
||||
centerFilters={true}
|
||||
filters={pageConfig.filters}
|
||||
filterValues={{
|
||||
sector: activeSector?.id ? String(activeSector.id) : '',
|
||||
search: searchTerm,
|
||||
volume_min: volumeMin,
|
||||
volume_max: volumeMax,
|
||||
country: countryFilter,
|
||||
difficulty: difficultyFilter,
|
||||
showNotAddedOnly: showNotAddedOnly ? 'true' : '',
|
||||
@@ -921,19 +1349,49 @@ export default function IndustriesSectorsKeywords() {
|
||||
onFilterChange={(key, value) => {
|
||||
const stringValue = value === null || value === undefined ? '' : String(value);
|
||||
|
||||
if (activeStatFilter) {
|
||||
setActiveStatFilter(null);
|
||||
}
|
||||
|
||||
if (key === 'search') {
|
||||
setSearchTerm(stringValue);
|
||||
} else if (key === 'sector') {
|
||||
if (!stringValue) {
|
||||
handleSectorSelect(null);
|
||||
} else {
|
||||
const selectedSector = sectors.find((sector) => String(sector.id) === stringValue) || null;
|
||||
handleSectorSelect(selectedSector);
|
||||
}
|
||||
} else if (key === 'country') {
|
||||
setCountryFilter(stringValue);
|
||||
setCurrentPage(1);
|
||||
setSelectedIds([]);
|
||||
} else if (key === 'difficulty') {
|
||||
setDifficultyFilter(stringValue);
|
||||
setCurrentPage(1);
|
||||
setSelectedIds([]);
|
||||
} else if (key === 'showNotAddedOnly') {
|
||||
setShowNotAddedOnly(stringValue === 'true');
|
||||
setCurrentPage(1);
|
||||
setSelectedIds([]);
|
||||
}
|
||||
}}
|
||||
onFilterReset={() => {
|
||||
// Clear all filters
|
||||
setSearchTerm('');
|
||||
setCountryFilter('');
|
||||
setVolumeMin('');
|
||||
setVolumeMax('');
|
||||
setDifficultyFilter('');
|
||||
setShowNotAddedOnly(false);
|
||||
setActiveStatFilter(null);
|
||||
setCurrentPage(1);
|
||||
setSelectedIds([]);
|
||||
setActiveSector(null);
|
||||
// Reset sorting to default
|
||||
setSortBy('keyword');
|
||||
setSortDirection('asc');
|
||||
}}
|
||||
onBulkAction={async (actionKey: string, ids: string[]) => {
|
||||
if (actionKey === 'add_selected_to_workflow') {
|
||||
await handleBulkAddSelected(ids);
|
||||
@@ -1045,6 +1503,22 @@ export default function IndustriesSectorsKeywords() {
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* Bulk Add Confirmation */}
|
||||
<BulkAddConfirmation
|
||||
isOpen={showBulkAddModal}
|
||||
onClose={() => {
|
||||
setShowBulkAddModal(false);
|
||||
setBulkAddKeywordIds([]);
|
||||
setBulkAddStatLabel(undefined);
|
||||
setPendingBulkAddKey(null);
|
||||
setPendingBulkAddGroup(null);
|
||||
}}
|
||||
onConfirm={handleConfirmBulkAdd}
|
||||
keywordCount={bulkAddKeywordIds.length}
|
||||
sectorName={activeSector?.name}
|
||||
statTypeLabel={bulkAddStatLabel}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user