From d2fc5b1a6b5cbc3cde24ae260130c82f4f11ca67 Mon Sep 17 00:00:00 2001 From: "IGNY8 VPS (Salman)" Date: Wed, 14 Jan 2026 17:22:22 +0000 Subject: [PATCH] new top kw add options and add keywrod improvements --- .../src/components/common/SearchModal.tsx | 8 +- .../dashboard/QuickActionsWidget.tsx | 2 +- .../onboarding/steps/Step1Welcome.tsx | 2 +- frontend/src/layout/AppSidebar.tsx | 4 +- .../pages/Setup/IndustriesSectorsKeywords.tsx | 767 +++++++++++++++--- 5 files changed, 648 insertions(+), 135 deletions(-) diff --git a/frontend/src/components/common/SearchModal.tsx b/frontend/src/components/common/SearchModal.tsx index 3be92267..46553c2c 100644 --- a/frontend/src/components/common/SearchModal.tsx +++ b/frontend/src/components/common/SearchModal.tsx @@ -72,7 +72,7 @@ const MAX_RECENT_SEARCHES = 5; // Keys include main terms + common aliases for better search matching const HELP_KNOWLEDGE_BASE: Record = { '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', diff --git a/frontend/src/components/dashboard/QuickActionsWidget.tsx b/frontend/src/components/dashboard/QuickActionsWidget.tsx index ecfc0f50..4f03fd3e 100644 --- a/frontend/src/components/dashboard/QuickActionsWidget.tsx +++ b/frontend/src/components/dashboard/QuickActionsWidget.tsx @@ -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', diff --git a/frontend/src/components/onboarding/steps/Step1Welcome.tsx b/frontend/src/components/onboarding/steps/Step1Welcome.tsx index b415bda4..bf561f30 100644 --- a/frontend/src/components/onboarding/steps/Step1Welcome.tsx +++ b/frontend/src/components/onboarding/steps/Step1Welcome.tsx @@ -40,7 +40,7 @@ const WIZARD_STEPS = [ { step: 3, icon: , - title: 'Add Keywords', + title: 'Add Target Keywords', description: 'Define target keywords for AI content', outcome: 'Keywords ready for clustering and ideas', color: 'warning', diff --git a/frontend/src/layout/AppSidebar.tsx b/frontend/src/layout/AppSidebar.tsx index 01d939ac..aebde232 100644 --- a/frontend/src/layout/AppSidebar.tsx +++ b/frontend/src/layout/AppSidebar.tsx @@ -101,10 +101,10 @@ const AppSidebar: React.FC = () => { path: "/sites", }); - // Add Keywords second + // Keyword Library - Browse and add curated keywords setupItems.push({ icon: , - name: "Add Keywords", + name: "Keyword Library", path: "/setup/add-keywords", }); diff --git a/frontend/src/pages/Setup/IndustriesSectorsKeywords.tsx b/frontend/src/pages/Setup/IndustriesSectorsKeywords.tsx index 371cd0d8..c9dbdd8c 100644 --- a/frontend/src/pages/Setup/IndustriesSectorsKeywords.tsx +++ b/frontend/src/pages/Setup/IndustriesSectorsKeywords.tsx @@ -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>(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([]); + const [addingOption, setAddingOption] = useState(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(); + 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(); 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; - - // 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 + if (searchTerm) filters.search = searchTerm; + if (countryFilter) filters.country = countryFilter; - 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); - } - - // 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 - ); - } - } + filteredResults = results.filter(sk => !sk.isAdded); } - // 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(); + 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 ( +
+ +
+
+ +
+

+ High Opportunity Keywords Complete +

+

+ {addedCount} keywords added to your workflow +

+
+
+ +
+
+
+ ); + } + + return ( +
+ {/* Header */} +
+
+
+

+ High Opportunity Keywords +

+ + + Curated + +
+ {!allAdded && ( + + )} +
+

+ Add top keywords for each of your sectors. Keywords will be added to your planner workflow. +

+
+ + {/* Loading State */} + {loadingOpportunityKeywords ? ( +
+ +
+ ) : ( + <> + {/* Sector Grid */} +
+ {sectorKeywordData.map((sector) => ( +
+ {/* Sector Name */} +

+ {sector.sectorName} +

+ + {/* Options Cards */} + {sector.options.map((option) => { + const addingKey = `${sector.sectorSlug}-${option.type}`; + const isAdding = addingOption === addingKey; + + return ( + + {/* Option Header */} +
+
+
+ {option.label} +
+

+ {option.keywordCount} keywords +

+
+ {option.added ? ( + + + Added + + ) : ( + + )} +
+ + {/* Sample Keywords */} +
+ {option.keywords.slice(0, 3).map((kw) => ( + + {kw.keyword} + + ))} + {option.keywordCount > 3 && ( + + +{option.keywordCount - 3} more + + )} +
+
+ ); + })} +
+ ))} +
+ + {/* Success Summary */} + {addedCount > 0 && ( + +
+ + + {addedCount} keywords added to your workflow + +
+
+ )} + + )} +
+ ); + }; + + // Show WorkflowGuide if no sites and High Opportunity has loaded (to avoid flashing) + if (highOpportunityLoaded && sites.length === 0) { return ( <> - + , color: 'blue' }} />
@@ -681,13 +1130,46 @@ export default function IndustriesSectorsKeywords() { return ( <> - + , color: 'blue' }} /> - {/* Show info banner when no sector is selected */} - {!activeSector && activeSite && ( + + {/* High Opportunity Keywords Section - Loads First */} + + + {/* Browse Individual Keywords Section - Shows after High Opportunity is loaded */} + {highOpportunityLoaded && !showBrowseTable && activeSite && ( +
+ +
+
+ +

+ Browse Individual Keywords +

+
+

+ 💡 Recommended: 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. +

+ +
+
+
+ )} + + {/* Show info banner when no sector is selected and table is shown */} + {showBrowseTable && !activeSector && activeSite && (
@@ -709,7 +1191,9 @@ export default function IndustriesSectorsKeywords() {
)} - + )} + + {/* Ahrefs Coming Soon Banner - Shows at bottom after High Opportunity is loaded */} + {highOpportunityLoaded && showAhrefsBanner && ( +
+ +
+
+ +
+
+

+ Ahrefs Keyword Research - Coming March/April 2026 +

+

+ 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. +

+
+ +
+
+
+ )} {/* Import Modal */}