new top kw add options and add keywrod improvements

This commit is contained in:
IGNY8 VPS (Salman)
2026-01-14 17:22:22 +00:00
parent 5c4593359e
commit d2fc5b1a6b
5 changed files with 648 additions and 135 deletions

View File

@@ -72,7 +72,7 @@ const MAX_RECENT_SEARCHES = 5;
// Keys include main terms + common aliases for better search matching
const HELP_KNOWLEDGE_BASE: Record<string, SuggestedQuestion[]> = {
'keyword': [
{ question: 'How do I import keywords?', answer: 'Go to Add Keywords page and either select your industry/sector for seed keywords or upload a CSV file with your own keywords.', helpSection: 'Importing Keywords', path: '/help#importing-keywords' },
{ question: 'How do I import keywords?', answer: 'Go to Keyword Library page and browse curated keywords for your industry, or upload a CSV file with your own keywords.', helpSection: 'Importing Keywords', path: '/help#importing-keywords' },
{ question: 'How do I organize keywords into clusters?', answer: 'Navigate to Clusters page and run the AI clustering algorithm. It will automatically group similar keywords by topic.', helpSection: 'Keyword Clustering', path: '/help#keyword-clustering' },
{ question: 'Can I bulk delete keywords?', answer: 'Yes, on the Keywords page select multiple keywords using checkboxes and click the bulk delete action button.', helpSection: 'Managing Keywords', path: '/help#managing-keywords' },
],
@@ -172,7 +172,7 @@ const SEARCH_ITEMS: SearchResult[] = [
keywords: ['keyword', 'search terms', 'seo', 'target', 'focus', 'research', 'phrases', 'queries'],
content: 'View and manage all your target keywords. Filter by cluster, search volume, or status. Bulk actions: delete, assign to cluster, export to CSV. Table shows keyword text, search volume, cluster assignment, and status.',
quickActions: [
{ label: 'Import Keywords', path: '/setup/add-keywords' },
{ label: 'Keyword Library', path: '/setup/add-keywords' },
{ label: 'View Clusters', path: '/planner/clusters' },
]
},
@@ -306,12 +306,12 @@ const SEARCH_ITEMS: SearchResult[] = [
keywords: ['sites', 'wordpress', 'blog', 'website', 'connection', 'integration', 'wp', 'domain'],
content: 'Manage WordPress site connections. Add new sites, configure API credentials, test connections. View site details, publishing settings, and connection status. Supports multiple WordPress sites.',
quickActions: [
{ label: 'Add Keywords', path: '/setup/add-keywords' },
{ label: 'Browse Keywords', path: '/setup/add-keywords' },
{ label: 'Content Settings', path: '/account/content-settings' },
]
},
{
title: 'Add Keywords',
title: 'Keyword Library',
path: '/setup/add-keywords',
type: 'setup',
category: 'Setup',

View File

@@ -29,7 +29,7 @@ interface QuickActionsWidgetProps {
const workflowSteps = [
{
num: 1,
title: 'Add Keywords',
title: 'Keyword Library',
description: 'Import your target keywords manually or from CSV',
href: '/planner/keyword-opportunities',
actionLabel: 'Add',

View File

@@ -40,7 +40,7 @@ const WIZARD_STEPS = [
{
step: 3,
icon: <BoltIcon className="h-6 w-6" />,
title: 'Add Keywords',
title: 'Add Target Keywords',
description: 'Define target keywords for AI content',
outcome: 'Keywords ready for clustering and ideas',
color: 'warning',

View File

@@ -101,10 +101,10 @@ const AppSidebar: React.FC = () => {
path: "/sites",
});
// Add Keywords second
// Keyword Library - Browse and add curated keywords
setupItems.push({
icon: <DocsIcon />,
name: "Add Keywords",
name: "Keyword Library",
path: "/setup/add-keywords",
});

View File

@@ -1,8 +1,9 @@
/**
* Add Keywords Page
* Keyword Library Page (formerly Add Keywords)
* 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
* Features: High Opportunity Keywords section, Browse all keywords, CSV import
* Coming soon: Ahrefs keyword research integration (March/April 2026)
*/
import { useState, useEffect, useRef, useMemo, useCallback } from 'react';
@@ -19,9 +20,12 @@ import {
fetchSites,
Site,
addSeedKeywordsToWorkflow,
fetchSiteSectors,
fetchIndustries,
fetchKeywords,
} from '../../services/api';
import Badge from '../../components/ui/badge/Badge';
import { BoltIcon, PlusIcon } from '../../icons';
import { BoltIcon, PlusIcon, CheckCircleIcon, ShootingStarIcon, DocsIcon } from '../../icons';
import TablePageTemplate from '../../templates/TablePageTemplate';
import { usePageSizeStore } from '../../store/pageSizeStore';
import { getDifficultyNumber, getDifficultyRange, getDifficultyLabelFromNumber } from '../../utils/difficulty';
@@ -32,6 +36,24 @@ 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 { Card } from '../../components/ui/card';
import { Spinner } from '../../components/ui/spinner/Spinner';
// High Opportunity Keywords types
interface SectorKeywordOption {
type: 'high-volume' | 'low-difficulty';
label: string;
keywords: SeedKeyword[];
added: boolean;
keywordCount: number;
}
interface SectorKeywordData {
sectorSlug: string;
sectorName: string;
sectorId: number;
options: SectorKeywordOption[];
}
export default function IndustriesSectorsKeywords() {
const toast = useToast();
@@ -49,6 +71,19 @@ export default function IndustriesSectorsKeywords() {
// Track recently added keywords to preserve their state during reload
const recentlyAddedRef = useRef<Set<number>>(new Set());
// High Opportunity Keywords state
const [showHighOpportunity, setShowHighOpportunity] = useState(true);
const [loadingOpportunityKeywords, setLoadingOpportunityKeywords] = useState(false);
const [highOpportunityLoaded, setHighOpportunityLoaded] = useState(false);
const [sectorKeywordData, setSectorKeywordData] = useState<SectorKeywordData[]>([]);
const [addingOption, setAddingOption] = useState<string | null>(null);
// Browse table state
const [showBrowseTable, setShowBrowseTable] = useState(false);
// Ahrefs banner state
const [showAhrefsBanner, setShowAhrefsBanner] = useState(true);
// Pagination state
const [currentPage, setCurrentPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
@@ -83,13 +118,13 @@ export default function IndustriesSectorsKeywords() {
const loadInitialData = async () => {
try {
startLoading('Loading sites...');
startLoading('Loading...');
const response = await fetchSites();
const activeSites = (response.results || []).filter(site => site.is_active);
setSites(activeSites);
// Don't stop loading here - let High Opportunity Keywords loading finish
} catch (error: any) {
toast.error(`Failed to load sites: ${error.message}`);
} finally {
stopLoading();
}
};
@@ -99,6 +134,122 @@ export default function IndustriesSectorsKeywords() {
loadInitialData();
};
// Load High Opportunity Keywords for active site
const loadHighOpportunityKeywords = useCallback(async () => {
if (!activeSite || !activeSite.industry) {
setSectorKeywordData([]);
return;
}
setLoadingOpportunityKeywords(true);
try {
// 1. Get site sectors
const siteSectors = await fetchSiteSectors(activeSite.id);
// 2. Get industry data
const industriesResponse = await fetchIndustries();
const industry = industriesResponse.industries?.find(
i => i.id === activeSite.industry || i.slug === activeSite.industry_slug
);
if (!industry?.id) {
console.warn('Could not find industry information');
setSectorKeywordData([]);
return;
}
// 3. Get already-attached keywords to mark as added
const attachedSeedKeywordIds = new Set<number>();
for (const sector of siteSectors) {
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);
}
}
// 4. Build sector keyword data
const sectorData: SectorKeywordData[] = [];
for (const siteSector of siteSectors) {
if (!siteSector.is_active) continue;
// Fetch all keywords for this sector
const response = await fetchSeedKeywords({
industry: industry.id,
sector: siteSector.industry_sector,
page_size: 500,
});
const sectorKeywords = response.results;
// Top 50 by highest volume
const highVolumeKeywords = [...sectorKeywords]
.sort((a, b) => (b.volume || 0) - (a.volume || 0))
.slice(0, 50);
// Top 50 by lowest difficulty - exclude keywords already in high volume to avoid duplicates
const highVolumeIds = new Set(highVolumeKeywords.map(kw => kw.id));
const lowDifficultyKeywords = [...sectorKeywords]
.filter(kw => !highVolumeIds.has(kw.id)) // Exclude duplicates from high volume
.sort((a, b) => (a.difficulty || 100) - (b.difficulty || 100))
.slice(0, 50);
// Check if all keywords in each option are already added
const hvAdded = highVolumeKeywords.length > 0 && highVolumeKeywords.every(kw =>
attachedSeedKeywordIds.has(Number(kw.id))
);
const ldAdded = lowDifficultyKeywords.length > 0 && lowDifficultyKeywords.every(kw =>
attachedSeedKeywordIds.has(Number(kw.id))
);
sectorData.push({
sectorSlug: siteSector.slug,
sectorName: siteSector.name,
sectorId: siteSector.id,
options: [
{
type: 'high-volume',
label: 'Top 50 High Volume',
keywords: highVolumeKeywords,
added: hvAdded,
keywordCount: highVolumeKeywords.length,
},
{
type: 'low-difficulty',
label: 'Top 50 Low Difficulty',
keywords: lowDifficultyKeywords,
added: ldAdded,
keywordCount: lowDifficultyKeywords.length,
},
],
});
}
setSectorKeywordData(sectorData);
setHighOpportunityLoaded(true);
stopLoading(); // Stop global loading spinner after High Opportunity is ready
} catch (error: any) {
console.error('Failed to load high opportunity keywords:', error);
toast.error(`Failed to load high opportunity keywords: ${error.message}`);
setHighOpportunityLoaded(true); // Still mark as loaded even on error
stopLoading(); // Stop spinner even on error
} finally {
setLoadingOpportunityKeywords(false);
}
}, [activeSite, toast, stopLoading]);
// Load sectors for active site
useEffect(() => {
if (activeSite?.id) {
@@ -106,7 +257,68 @@ export default function IndustriesSectorsKeywords() {
}
}, [activeSite?.id, loadSectorsForSite]);
// Load seed keywords
// Load High Opportunity Keywords when site changes
useEffect(() => {
if (activeSite && activeSite.id && showHighOpportunity) {
loadHighOpportunityKeywords();
}
}, [activeSite?.id, showHighOpportunity, loadHighOpportunityKeywords]);
// Load keyword counts for display (lightweight - just get count from API)
const loadKeywordCounts = useCallback(async () => {
if (!activeSite || !activeSite.industry) {
setAddedCount(0);
setAvailableCount(0);
return;
}
try {
// Get attached keywords count
const sectors = await fetchSiteSectors(activeSite.id);
let totalAdded = 0;
for (const sector of sectors) {
try {
const keywordsData = await fetchKeywords({
site_id: activeSite.id,
sector_id: sector.id,
page_size: 1,
});
totalAdded += keywordsData.count || 0;
} catch (err) {
console.warn(`Could not fetch keyword count for sector ${sector.id}:`, err);
}
}
// Get total available keywords from API
const filters: any = {
industry: activeSite.industry,
page_size: 1,
page: 1,
};
if (activeSector && activeSector.industry_sector) {
filters.sector = activeSector.industry_sector;
}
const data = await fetchSeedKeywords(filters);
const totalAvailable = data.count || 0;
setAddedCount(totalAdded);
setAvailableCount(totalAvailable);
} catch (err) {
console.warn('Could not fetch keyword counts:', err);
}
}, [activeSite, activeSector]);
// Load counts on mount and when site/sector changes
useEffect(() => {
if (activeSite) {
loadKeywordCounts();
}
}, [activeSite, activeSector, loadKeywordCounts]);
// Load seed keywords with optimized API pagination - only load current page
const loadSeedKeywords = useCallback(async () => {
if (!activeSite || !activeSite.industry) {
setSeedKeywords([]);
@@ -119,14 +331,12 @@ export default function IndustriesSectorsKeywords() {
setShowContent(false);
try {
// Get already-attached keywords across ALL sectors for this site
// Get already-attached keywords for marking (lightweight check)
const 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
// Check keywords in all sectors (needed for isAdded flag)
for (const sector of sectors) {
try {
const keywordsData = await fetchKeywords({
@@ -148,56 +358,46 @@ export default function IndustriesSectorsKeywords() {
console.warn('Could not fetch sectors or attached keywords:', err);
}
// Build filters - fetch up to 500 records max for performance
const MAX_RECORDS = 500;
const baseFilters: any = {
// Build API filters - use server-side pagination
const pageSizeNum = pageSize || 25;
const filters: any = {
industry: activeSite.industry,
page_size: 100, // Fetch in batches of 100
page_size: pageSizeNum,
page: currentPage,
};
// Add sector filter if active sector is selected
if (activeSector && activeSector.industry_sector) {
baseFilters.sector = activeSector.industry_sector;
filters.sector = activeSector.industry_sector;
}
if (searchTerm) baseFilters.search = searchTerm;
if (countryFilter) baseFilters.country = countryFilter;
if (searchTerm) filters.search = searchTerm;
if (countryFilter) filters.country = countryFilter;
// Fetch up to MAX_RECORDS (500) for initial display
let allResults: SeedKeyword[] = [];
let currentPageNum = 1;
let hasMore = true;
let apiTotalCount = 0; // Store the total count from API
while (hasMore && allResults.length < MAX_RECORDS) {
const filters = { ...baseFilters, page: currentPageNum };
const data: SeedKeywordResponse = await fetchSeedKeywords(filters);
// Store total count from first response
if (currentPageNum === 1 && data.count !== undefined) {
apiTotalCount = data.count;
}
if (data.results && data.results.length > 0) {
// Only add records up to MAX_RECORDS limit
const remainingSpace = MAX_RECORDS - allResults.length;
const recordsToAdd = data.results.slice(0, remainingSpace);
allResults = [...allResults, ...recordsToAdd];
}
// Stop if we've reached the limit or no more pages
hasMore = data.next !== null && data.next !== undefined && allResults.length < MAX_RECORDS;
currentPageNum++;
// Safety check to prevent infinite loops
if (currentPageNum > 10) {
console.warn('Reached safety limit while fetching seed keywords');
break;
// Apply difficulty filter (if API supports it, otherwise we'll filter client-side)
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 sorting to API request
if (sortBy && sortDirection) {
const sortPrefix = sortDirection === 'desc' ? '-' : '';
filters.ordering = `${sortPrefix}${sortBy}`;
}
// Fetch only current page from API
const data: SeedKeywordResponse = await fetchSeedKeywords(filters);
// Mark already-attached keywords
let filteredResults = allResults.map(sk => {
const results = (data.results || []).map(sk => {
const isAdded = attachedSeedKeywordIds.has(Number(sk.id)) || recentlyAddedRef.current.has(Number(sk.id));
return {
...sk,
@@ -205,81 +405,25 @@ export default function IndustriesSectorsKeywords() {
};
});
// Calculate counts before applying filters (for display in header)
const totalAdded = filteredResults.filter(sk => sk.isAdded).length;
const loadedAvailable = filteredResults.filter(sk => !sk.isAdded).length;
// Calculate counts from API response
const apiTotalCount = data.count || 0;
// Use API total count for available if we have it, otherwise use loaded count
const actualAvailable = apiTotalCount > 0 ? apiTotalCount - totalAdded : loadedAvailable;
// For added count, we need to check all attached keywords
const totalAdded = attachedSeedKeywordIds.size;
const actualAvailable = apiTotalCount;
setAddedCount(totalAdded);
setAvailableCount(actualAvailable);
// Apply "not yet added" filter
// Apply "not yet added" filter client-side (if API doesn't support it)
let filteredResults = results;
if (showNotAddedOnly) {
filteredResults = filteredResults.filter(sk => !sk.isAdded);
filteredResults = results.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);
// Use API total count if available and no client-side filters are applied
// Otherwise use filtered count
const shouldUseApiCount = apiTotalCount > 0 && !showNotAddedOnly && !difficultyFilter;
const displayTotalCount = shouldUseApiCount ? apiTotalCount : totalFiltered;
setTotalCount(displayTotalCount);
setTotalPages(Math.ceil(displayTotalCount / pageSizeNum));
setSeedKeywords(filteredResults);
setTotalCount(apiTotalCount);
setTotalPages(Math.ceil(apiTotalCount / pageSizeNum));
setShowContent(true);
} catch (error: any) {
@@ -290,15 +434,16 @@ export default function IndustriesSectorsKeywords() {
setTotalPages(1);
setAddedCount(0);
setAvailableCount(0);
setShowContent(true);
}
}, [activeSite, activeSector, currentPage, pageSize, searchTerm, countryFilter, difficultyFilter, showNotAddedOnly, sortBy, sortDirection, toast]);
// Load data on mount and when filters change
// Load data only when browse table is shown and filters change
useEffect(() => {
if (activeSite) {
if (activeSite && showBrowseTable) {
loadSeedKeywords();
}
}, [loadSeedKeywords, activeSite]);
}, [loadSeedKeywords, activeSite, showBrowseTable]);
// Debounced search
useEffect(() => {
@@ -455,6 +600,111 @@ export default function IndustriesSectorsKeywords() {
setIsImportModalOpen(true);
}, []);
// Handle adding High Opportunity Keywords for a sector
const handleAddSectorKeywords = useCallback(async (
sectorSlug: string,
optionType: 'high-volume' | 'low-difficulty'
) => {
const sector = sectorKeywordData.find(s => s.sectorSlug === sectorSlug);
if (!sector || !activeSite) return;
const option = sector.options.find(o => o.type === optionType);
if (!option || option.added || option.keywords.length === 0) return;
const addingKey = `${sectorSlug}-${optionType}`;
setAddingOption(addingKey);
try {
// Get currently attached keywords to filter out duplicates
const attachedSeedKeywordIds = new Set<number>();
try {
const sectors = await fetchSiteSectors(activeSite.id);
for (const s of sectors) {
try {
const keywordsData = await fetchKeywords({
site_id: activeSite.id,
sector_id: s.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 ${s.id}:`, err);
}
}
} catch (err) {
console.warn('Could not fetch attached keywords:', err);
}
// Filter out already-added keywords
const seedKeywordIds = option.keywords
.filter(kw => !attachedSeedKeywordIds.has(Number(kw.id)))
.map(kw => kw.id);
if (seedKeywordIds.length === 0) {
toast.warning('All keywords from this set are already in your workflow.');
// Mark as added since all are already there
setSectorKeywordData(prev =>
prev.map(s =>
s.sectorSlug === sectorSlug
? {
...s,
options: s.options.map(o =>
o.type === optionType ? { ...o, added: true } : o
),
}
: s
)
);
setAddingOption(null);
return;
}
const result = await addSeedKeywordsToWorkflow(
seedKeywordIds,
activeSite.id,
sector.sectorId
);
if (result.success && result.created > 0) {
// Mark option as added
setSectorKeywordData(prev =>
prev.map(s =>
s.sectorSlug === sectorSlug
? {
...s,
options: s.options.map(o =>
o.type === optionType ? { ...o, added: true } : o
),
}
: s
)
);
let message = `Added ${result.created} keywords to ${sector.sectorName}`;
if (result.skipped && result.skipped > 0) {
message += ` (${result.skipped} already exist)`;
}
toast.success(message);
// Reload the main table to reflect changes
loadSeedKeywords();
} else if (result.errors && result.errors.length > 0) {
toast.error(result.errors[0]);
} else {
toast.warning('No keywords were added. They may already exist in your workflow.');
}
} catch (err: any) {
toast.error(err.message || 'Failed to add keywords to workflow');
} finally {
setAddingOption(null);
}
}, [sectorKeywordData, activeSite, toast, loadSeedKeywords]);
// Handle import file upload
const handleImportSubmit = useCallback(async () => {
if (!importFile) {
@@ -663,13 +913,212 @@ export default function IndustriesSectorsKeywords() {
};
}, [activeSector, handleAddToWorkflow]);
// Show WorkflowGuide if no sites
if (sites.length === 0) {
// High Opportunity Keywords Component
const HighOpportunityKeywordsSection = () => {
if (!activeSite || sectorKeywordData.length === 0) return null;
const addedCount = sectorKeywordData.reduce(
(acc, s) =>
acc +
s.options
.filter(o => o.added)
.reduce((sum, o) => sum + o.keywordCount, 0),
0
);
const totalKeywordCount = sectorKeywordData.reduce(
(acc, s) => acc + s.options.reduce((sum, o) => sum + o.keywordCount, 0),
0
);
const allAdded = totalKeywordCount > 0 && addedCount === totalKeywordCount;
// Auto-collapse when all keywords are added
useEffect(() => {
if (allAdded && showHighOpportunity) {
// Keep it open for a moment to show success, then collapse
const timer = setTimeout(() => {
setShowHighOpportunity(false);
}, 2000);
return () => clearTimeout(timer);
}
}, [allAdded]);
// Show collapsed state with option to expand
if (!showHighOpportunity) {
return (
<div className="mx-6 mt-6 mb-6">
<Card className="p-4 bg-success-50 dark:bg-success-900/20 border-success-200 dark:border-success-800">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<CheckCircleIcon className="w-5 h-5 text-success-600 dark:text-success-400" />
<div>
<h3 className="text-sm font-medium text-success-900 dark:text-success-200">
High Opportunity Keywords Complete
</h3>
<p className="text-xs text-success-700 dark:text-success-300">
{addedCount} keywords added to your workflow
</p>
</div>
</div>
<Button
variant="ghost"
tone="success"
size="sm"
onClick={() => setShowHighOpportunity(true)}
>
Show Section
</Button>
</div>
</Card>
</div>
);
}
return (
<div className="mx-6 mt-6 mb-6 p-6 bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700">
{/* Header */}
<div className="mb-6">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-3">
<h2 className="text-xl font-bold text-gray-900 dark:text-white">
High Opportunity Keywords
</h2>
<Badge tone="brand" variant="soft" size="sm">
<BoltIcon className="w-3 h-3 mr-1" />
Curated
</Badge>
</div>
{!allAdded && (
<Button
variant="ghost"
size="sm"
onClick={() => setShowHighOpportunity(false)}
>
Hide
</Button>
)}
</div>
<p className="text-sm text-gray-600 dark:text-gray-400">
Add top keywords for each of your sectors. Keywords will be added to your planner workflow.
</p>
</div>
{/* Loading State */}
{loadingOpportunityKeywords ? (
<div className="flex items-center justify-center py-12">
<Spinner size="lg" />
</div>
) : (
<>
{/* Sector Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 mb-6 items-start">
{sectorKeywordData.map((sector) => (
<div key={sector.sectorSlug} className="flex flex-col gap-3">
{/* Sector Name */}
<h4 className="text-base font-semibold text-gray-900 dark:text-white border-b border-gray-200 dark:border-gray-700 pb-2">
{sector.sectorName}
</h4>
{/* Options Cards */}
{sector.options.map((option) => {
const addingKey = `${sector.sectorSlug}-${option.type}`;
const isAdding = addingOption === addingKey;
return (
<Card
key={option.type}
className={`p-4 transition-all flex flex-col ${
option.added
? 'border-success-300 dark:border-success-700 bg-success-50 dark:bg-success-900/20'
: 'hover:border-brand-300 dark:hover:border-brand-700'
}`}
>
{/* Option Header */}
<div className="flex items-center justify-between mb-3">
<div>
<h5 className="text-sm font-medium text-gray-900 dark:text-white">
{option.label}
</h5>
<p className="text-xs text-gray-500 dark:text-gray-400">
{option.keywordCount} keywords
</p>
</div>
{option.added ? (
<Badge tone="success" variant="soft" size="sm">
<CheckCircleIcon className="w-3 h-3 mr-1" />
Added
</Badge>
) : (
<Button
variant="primary"
tone="brand"
size="xs"
onClick={() =>
handleAddSectorKeywords(sector.sectorSlug, option.type)
}
disabled={isAdding}
>
{isAdding ? 'Adding...' : 'Add All'}
</Button>
)}
</div>
{/* Sample Keywords */}
<div className="flex flex-wrap gap-1.5 flex-1">
{option.keywords.slice(0, 3).map((kw) => (
<Badge
key={kw.id}
tone={option.added ? 'success' : 'neutral'}
variant="soft"
size="xs"
className="text-xs"
>
{kw.keyword}
</Badge>
))}
{option.keywordCount > 3 && (
<Badge
tone="neutral"
variant="outline"
size="xs"
className="text-xs"
>
+{option.keywordCount - 3} more
</Badge>
)}
</div>
</Card>
);
})}
</div>
))}
</div>
{/* Success Summary */}
{addedCount > 0 && (
<Card className="p-4 bg-success-50 dark:bg-success-900/20 border-success-200 dark:border-success-800">
<div className="flex items-center gap-3">
<CheckCircleIcon className="w-5 h-5 text-success-600 dark:text-success-400" />
<span className="text-sm text-success-700 dark:text-success-300">
{addedCount} keywords added to your workflow
</span>
</div>
</Card>
)}
</>
)}
</div>
);
};
// Show WorkflowGuide if no sites and High Opportunity has loaded (to avoid flashing)
if (highOpportunityLoaded && sites.length === 0) {
return (
<>
<PageMeta title="Add Keywords" description="Browse and add keywords to your workflow" />
<PageMeta title="Keyword Library" description="Browse and add keywords to your workflow" />
<PageHeader
title="Add Keywords"
title="Keyword Library"
badge={{ icon: <BoltIcon />, color: 'blue' }}
/>
<div className="p-6">
@@ -681,13 +1130,46 @@ export default function IndustriesSectorsKeywords() {
return (
<>
<PageMeta title="Find Keywords" description="Search and add keywords to start creating content" />
<PageMeta title="Keyword Library" description="Browse curated keywords and add them to your workflow. Ahrefs research coming soon." />
<PageHeader
title="Find Keywords"
title="Keyword Library"
badge={{ icon: <BoltIcon />, color: 'blue' }}
/>
{/* Show info banner when no sector is selected */}
{!activeSector && activeSite && (
{/* High Opportunity Keywords Section - Loads First */}
<HighOpportunityKeywordsSection />
{/* Browse Individual Keywords Section - Shows after High Opportunity is loaded */}
{highOpportunityLoaded && !showBrowseTable && activeSite && (
<div className="mx-6 mt-6 mb-6">
<Card className="p-6">
<div className="text-center">
<div className="flex items-center justify-center gap-2 mb-3">
<DocsIcon className="w-6 h-6 text-gray-400" />
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
Browse Individual Keywords
</h3>
</div>
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4 max-w-2xl mx-auto">
💡 <strong>Recommended:</strong> Start by adding High Opportunity Keywords from the section above.
They're curated for your sectors and ready to use. Once you've added those, you can browse our full keyword library below for additional targeted keywords.
</p>
<Button
variant="outline"
tone="brand"
size="md"
onClick={() => setShowBrowseTable(true)}
startIcon={<DocsIcon className="w-4 h-4" />}
>
Browse Full Keyword Library ({availableCount.toLocaleString()} available)
</Button>
</div>
</Card>
</div>
)}
{/* Show info banner when no sector is selected and table is shown */}
{showBrowseTable && !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">
@@ -709,7 +1191,9 @@ export default function IndustriesSectorsKeywords() {
</div>
)}
<TablePageTemplate
{/* Keywords Browse Table - Only show when user clicks browse button */}
{showBrowseTable && (
<TablePageTemplate
columns={pageConfig.columns}
data={seedKeywords}
loading={!showContent}
@@ -789,6 +1273,35 @@ export default function IndustriesSectorsKeywords() {
onEdit={undefined}
onDelete={undefined}
/>
)}
{/* Ahrefs Coming Soon Banner - Shows at bottom after High Opportunity is loaded */}
{highOpportunityLoaded && showAhrefsBanner && (
<div className="mx-6 mt-6 mb-6">
<Card className="p-4 bg-purple-50 dark:bg-purple-900/20 border-purple-200 dark:border-purple-800">
<div className="flex items-start gap-3">
<div className="flex-shrink-0">
<ShootingStarIcon className="w-5 h-5 text-purple-600 dark:text-purple-400 mt-0.5" />
</div>
<div className="flex-1">
<h3 className="text-sm font-medium text-purple-900 dark:text-purple-200 mb-1">
Ahrefs Keyword Research - Coming March/April 2026
</h3>
<p className="text-sm text-purple-700 dark:text-purple-300">
Soon you'll be able to research keywords directly with Ahrefs API, pulling fresh data with volume, difficulty, and competition metrics. For now, browse our curated keyword library above.
</p>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => setShowAhrefsBanner(false)}
>
Hide
</Button>
</div>
</Card>
</div>
)}
{/* Import Modal */}
<Modal