new top kw add options and add keywrod improvements
This commit is contained in:
@@ -72,7 +72,7 @@ const MAX_RECENT_SEARCHES = 5;
|
|||||||
// Keys include main terms + common aliases for better search matching
|
// Keys include main terms + common aliases for better search matching
|
||||||
const HELP_KNOWLEDGE_BASE: Record<string, SuggestedQuestion[]> = {
|
const HELP_KNOWLEDGE_BASE: Record<string, SuggestedQuestion[]> = {
|
||||||
'keyword': [
|
'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: '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' },
|
{ 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'],
|
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.',
|
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: [
|
quickActions: [
|
||||||
{ label: 'Import Keywords', path: '/setup/add-keywords' },
|
{ label: 'Keyword Library', path: '/setup/add-keywords' },
|
||||||
{ label: 'View Clusters', path: '/planner/clusters' },
|
{ label: 'View Clusters', path: '/planner/clusters' },
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -306,12 +306,12 @@ const SEARCH_ITEMS: SearchResult[] = [
|
|||||||
keywords: ['sites', 'wordpress', 'blog', 'website', 'connection', 'integration', 'wp', 'domain'],
|
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.',
|
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: [
|
quickActions: [
|
||||||
{ label: 'Add Keywords', path: '/setup/add-keywords' },
|
{ label: 'Browse Keywords', path: '/setup/add-keywords' },
|
||||||
{ label: 'Content Settings', path: '/account/content-settings' },
|
{ label: 'Content Settings', path: '/account/content-settings' },
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Add Keywords',
|
title: 'Keyword Library',
|
||||||
path: '/setup/add-keywords',
|
path: '/setup/add-keywords',
|
||||||
type: 'setup',
|
type: 'setup',
|
||||||
category: 'Setup',
|
category: 'Setup',
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ interface QuickActionsWidgetProps {
|
|||||||
const workflowSteps = [
|
const workflowSteps = [
|
||||||
{
|
{
|
||||||
num: 1,
|
num: 1,
|
||||||
title: 'Add Keywords',
|
title: 'Keyword Library',
|
||||||
description: 'Import your target keywords manually or from CSV',
|
description: 'Import your target keywords manually or from CSV',
|
||||||
href: '/planner/keyword-opportunities',
|
href: '/planner/keyword-opportunities',
|
||||||
actionLabel: 'Add',
|
actionLabel: 'Add',
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ const WIZARD_STEPS = [
|
|||||||
{
|
{
|
||||||
step: 3,
|
step: 3,
|
||||||
icon: <BoltIcon className="h-6 w-6" />,
|
icon: <BoltIcon className="h-6 w-6" />,
|
||||||
title: 'Add Keywords',
|
title: 'Add Target Keywords',
|
||||||
description: 'Define target keywords for AI content',
|
description: 'Define target keywords for AI content',
|
||||||
outcome: 'Keywords ready for clustering and ideas',
|
outcome: 'Keywords ready for clustering and ideas',
|
||||||
color: 'warning',
|
color: 'warning',
|
||||||
|
|||||||
@@ -101,10 +101,10 @@ const AppSidebar: React.FC = () => {
|
|||||||
path: "/sites",
|
path: "/sites",
|
||||||
});
|
});
|
||||||
|
|
||||||
// Add Keywords second
|
// Keyword Library - Browse and add curated keywords
|
||||||
setupItems.push({
|
setupItems.push({
|
||||||
icon: <DocsIcon />,
|
icon: <DocsIcon />,
|
||||||
name: "Add Keywords",
|
name: "Keyword Library",
|
||||||
path: "/setup/add-keywords",
|
path: "/setup/add-keywords",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
/**
|
/**
|
||||||
* Add Keywords Page
|
* Keyword Library Page (formerly Add Keywords)
|
||||||
* Browse global seed keywords filtered by site's industry and sectors
|
* Browse global seed keywords filtered by site's industry and sectors
|
||||||
* Add keywords to workflow (creates Keywords records in planner)
|
* 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';
|
import { useState, useEffect, useRef, useMemo, useCallback } from 'react';
|
||||||
@@ -19,9 +20,12 @@ import {
|
|||||||
fetchSites,
|
fetchSites,
|
||||||
Site,
|
Site,
|
||||||
addSeedKeywordsToWorkflow,
|
addSeedKeywordsToWorkflow,
|
||||||
|
fetchSiteSectors,
|
||||||
|
fetchIndustries,
|
||||||
|
fetchKeywords,
|
||||||
} from '../../services/api';
|
} from '../../services/api';
|
||||||
import Badge from '../../components/ui/badge/Badge';
|
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 TablePageTemplate from '../../templates/TablePageTemplate';
|
||||||
import { usePageSizeStore } from '../../store/pageSizeStore';
|
import { usePageSizeStore } from '../../store/pageSizeStore';
|
||||||
import { getDifficultyNumber, getDifficultyRange, getDifficultyLabelFromNumber } from '../../utils/difficulty';
|
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 { Modal } from '../../components/ui/modal';
|
||||||
import FileInput from '../../components/form/input/FileInput';
|
import FileInput from '../../components/form/input/FileInput';
|
||||||
import Label from '../../components/form/Label';
|
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() {
|
export default function IndustriesSectorsKeywords() {
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
@@ -49,6 +71,19 @@ export default function IndustriesSectorsKeywords() {
|
|||||||
// Track recently added keywords to preserve their state during reload
|
// Track recently added keywords to preserve their state during reload
|
||||||
const recentlyAddedRef = useRef<Set<number>>(new Set());
|
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
|
// Pagination state
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
const [totalPages, setTotalPages] = useState(1);
|
const [totalPages, setTotalPages] = useState(1);
|
||||||
@@ -83,13 +118,13 @@ export default function IndustriesSectorsKeywords() {
|
|||||||
|
|
||||||
const loadInitialData = async () => {
|
const loadInitialData = async () => {
|
||||||
try {
|
try {
|
||||||
startLoading('Loading sites...');
|
startLoading('Loading...');
|
||||||
const response = await fetchSites();
|
const response = await fetchSites();
|
||||||
const activeSites = (response.results || []).filter(site => site.is_active);
|
const activeSites = (response.results || []).filter(site => site.is_active);
|
||||||
setSites(activeSites);
|
setSites(activeSites);
|
||||||
|
// Don't stop loading here - let High Opportunity Keywords loading finish
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
toast.error(`Failed to load sites: ${error.message}`);
|
toast.error(`Failed to load sites: ${error.message}`);
|
||||||
} finally {
|
|
||||||
stopLoading();
|
stopLoading();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -99,6 +134,122 @@ export default function IndustriesSectorsKeywords() {
|
|||||||
loadInitialData();
|
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
|
// Load sectors for active site
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (activeSite?.id) {
|
if (activeSite?.id) {
|
||||||
@@ -106,7 +257,68 @@ export default function IndustriesSectorsKeywords() {
|
|||||||
}
|
}
|
||||||
}, [activeSite?.id, loadSectorsForSite]);
|
}, [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 () => {
|
const loadSeedKeywords = useCallback(async () => {
|
||||||
if (!activeSite || !activeSite.industry) {
|
if (!activeSite || !activeSite.industry) {
|
||||||
setSeedKeywords([]);
|
setSeedKeywords([]);
|
||||||
@@ -119,14 +331,12 @@ export default function IndustriesSectorsKeywords() {
|
|||||||
setShowContent(false);
|
setShowContent(false);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Get already-attached keywords across ALL sectors for this site
|
// Get already-attached keywords for marking (lightweight check)
|
||||||
const attachedSeedKeywordIds = new Set<number>();
|
const attachedSeedKeywordIds = new Set<number>();
|
||||||
try {
|
try {
|
||||||
const { fetchKeywords, fetchSiteSectors } = await import('../../services/api');
|
|
||||||
// Get all sectors for the site
|
|
||||||
const sectors = await fetchSiteSectors(activeSite.id);
|
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) {
|
for (const sector of sectors) {
|
||||||
try {
|
try {
|
||||||
const keywordsData = await fetchKeywords({
|
const keywordsData = await fetchKeywords({
|
||||||
@@ -148,56 +358,46 @@ export default function IndustriesSectorsKeywords() {
|
|||||||
console.warn('Could not fetch sectors or attached keywords:', err);
|
console.warn('Could not fetch sectors or attached keywords:', err);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build filters - fetch up to 500 records max for performance
|
// Build API filters - use server-side pagination
|
||||||
const MAX_RECORDS = 500;
|
const pageSizeNum = pageSize || 25;
|
||||||
const baseFilters: any = {
|
const filters: any = {
|
||||||
industry: activeSite.industry,
|
industry: activeSite.industry,
|
||||||
page_size: 100, // Fetch in batches of 100
|
page_size: pageSizeNum,
|
||||||
|
page: currentPage,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Add sector filter if active sector is selected
|
// Add sector filter if active sector is selected
|
||||||
if (activeSector && activeSector.industry_sector) {
|
if (activeSector && activeSector.industry_sector) {
|
||||||
baseFilters.sector = activeSector.industry_sector;
|
filters.sector = activeSector.industry_sector;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (searchTerm) baseFilters.search = searchTerm;
|
if (searchTerm) filters.search = searchTerm;
|
||||||
if (countryFilter) baseFilters.country = countryFilter;
|
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) {
|
// Apply difficulty filter (if API supports it, otherwise we'll filter client-side)
|
||||||
const filters = { ...baseFilters, page: currentPageNum };
|
if (difficultyFilter) {
|
||||||
const data: SeedKeywordResponse = await fetchSeedKeywords(filters);
|
const difficultyNum = parseInt(difficultyFilter);
|
||||||
|
const label = getDifficultyLabelFromNumber(difficultyNum);
|
||||||
// Store total count from first response
|
if (label !== null) {
|
||||||
if (currentPageNum === 1 && data.count !== undefined) {
|
const range = getDifficultyRange(label);
|
||||||
apiTotalCount = data.count;
|
if (range) {
|
||||||
}
|
filters.difficulty_min = range.min;
|
||||||
|
filters.difficulty_max = range.max;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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
|
// 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));
|
const isAdded = attachedSeedKeywordIds.has(Number(sk.id)) || recentlyAddedRef.current.has(Number(sk.id));
|
||||||
return {
|
return {
|
||||||
...sk,
|
...sk,
|
||||||
@@ -205,81 +405,25 @@ export default function IndustriesSectorsKeywords() {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
// Calculate counts before applying filters (for display in header)
|
// Calculate counts from API response
|
||||||
const totalAdded = filteredResults.filter(sk => sk.isAdded).length;
|
const apiTotalCount = data.count || 0;
|
||||||
const loadedAvailable = filteredResults.filter(sk => !sk.isAdded).length;
|
|
||||||
|
|
||||||
// Use API total count for available if we have it, otherwise use loaded count
|
// For added count, we need to check all attached keywords
|
||||||
const actualAvailable = apiTotalCount > 0 ? apiTotalCount - totalAdded : loadedAvailable;
|
const totalAdded = attachedSeedKeywordIds.size;
|
||||||
|
const actualAvailable = apiTotalCount;
|
||||||
|
|
||||||
setAddedCount(totalAdded);
|
setAddedCount(totalAdded);
|
||||||
setAvailableCount(actualAvailable);
|
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) {
|
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
|
setSeedKeywords(filteredResults);
|
||||||
if (sortBy) {
|
setTotalCount(apiTotalCount);
|
||||||
filteredResults.sort((a, b) => {
|
setTotalPages(Math.ceil(apiTotalCount / pageSizeNum));
|
||||||
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));
|
|
||||||
|
|
||||||
setShowContent(true);
|
setShowContent(true);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
@@ -290,15 +434,16 @@ export default function IndustriesSectorsKeywords() {
|
|||||||
setTotalPages(1);
|
setTotalPages(1);
|
||||||
setAddedCount(0);
|
setAddedCount(0);
|
||||||
setAvailableCount(0);
|
setAvailableCount(0);
|
||||||
|
setShowContent(true);
|
||||||
}
|
}
|
||||||
}, [activeSite, activeSector, currentPage, pageSize, searchTerm, countryFilter, difficultyFilter, showNotAddedOnly, sortBy, sortDirection, toast]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
if (activeSite) {
|
if (activeSite && showBrowseTable) {
|
||||||
loadSeedKeywords();
|
loadSeedKeywords();
|
||||||
}
|
}
|
||||||
}, [loadSeedKeywords, activeSite]);
|
}, [loadSeedKeywords, activeSite, showBrowseTable]);
|
||||||
|
|
||||||
// Debounced search
|
// Debounced search
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -455,6 +600,111 @@ export default function IndustriesSectorsKeywords() {
|
|||||||
setIsImportModalOpen(true);
|
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
|
// Handle import file upload
|
||||||
const handleImportSubmit = useCallback(async () => {
|
const handleImportSubmit = useCallback(async () => {
|
||||||
if (!importFile) {
|
if (!importFile) {
|
||||||
@@ -663,13 +913,212 @@ export default function IndustriesSectorsKeywords() {
|
|||||||
};
|
};
|
||||||
}, [activeSector, handleAddToWorkflow]);
|
}, [activeSector, handleAddToWorkflow]);
|
||||||
|
|
||||||
// Show WorkflowGuide if no sites
|
// High Opportunity Keywords Component
|
||||||
if (sites.length === 0) {
|
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 (
|
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
|
<PageHeader
|
||||||
title="Add Keywords"
|
title="Keyword Library"
|
||||||
badge={{ icon: <BoltIcon />, color: 'blue' }}
|
badge={{ icon: <BoltIcon />, color: 'blue' }}
|
||||||
/>
|
/>
|
||||||
<div className="p-6">
|
<div className="p-6">
|
||||||
@@ -681,13 +1130,46 @@ export default function IndustriesSectorsKeywords() {
|
|||||||
|
|
||||||
return (
|
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
|
<PageHeader
|
||||||
title="Find Keywords"
|
title="Keyword Library"
|
||||||
badge={{ icon: <BoltIcon />, color: 'blue' }}
|
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="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="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 items-start gap-3">
|
||||||
@@ -709,7 +1191,9 @@ export default function IndustriesSectorsKeywords() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<TablePageTemplate
|
{/* Keywords Browse Table - Only show when user clicks browse button */}
|
||||||
|
{showBrowseTable && (
|
||||||
|
<TablePageTemplate
|
||||||
columns={pageConfig.columns}
|
columns={pageConfig.columns}
|
||||||
data={seedKeywords}
|
data={seedKeywords}
|
||||||
loading={!showContent}
|
loading={!showContent}
|
||||||
@@ -789,6 +1273,35 @@ export default function IndustriesSectorsKeywords() {
|
|||||||
onEdit={undefined}
|
onEdit={undefined}
|
||||||
onDelete={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 */}
|
{/* Import Modal */}
|
||||||
<Modal
|
<Modal
|
||||||
|
|||||||
Reference in New Issue
Block a user