From 05bc433c80a1f9c6d1e84298f8eb1871bd3e0da5 Mon Sep 17 00:00:00 2001 From: "IGNY8 VPS (Salman)" Date: Sun, 18 Jan 2026 20:55:02 +0000 Subject: [PATCH] keywrods library fixes --- backend/igny8_core/auth/views.py | 150 ++++++- .../keywords-library/SectorCardsGrid.tsx | 52 ++- .../keywords-library/SectorMetricCard.tsx | 69 ++-- .../keywords-library/SmartSuggestions.tsx | 273 +------------ .../src/components/keywords-library/index.tsx | 2 +- .../pages/Setup/IndustriesSectorsKeywords.tsx | 377 ++++++++---------- frontend/src/services/api.ts | 28 +- frontend/src/styles/design-system.css | 4 +- 8 files changed, 435 insertions(+), 520 deletions(-) diff --git a/backend/igny8_core/auth/views.py b/backend/igny8_core/auth/views.py index 7c393a82..98299dac 100644 --- a/backend/igny8_core/auth/views.py +++ b/backend/igny8_core/auth/views.py @@ -869,6 +869,8 @@ class SeedKeywordViewSet(viewsets.ReadOnlyModelViewSet): difficulty_max = self.request.query_params.get('difficulty_max') volume_min = self.request.query_params.get('volume_min') volume_max = self.request.query_params.get('volume_max') + site_id = self.request.query_params.get('site_id') + available_only = self.request.query_params.get('available_only') if industry_id: queryset = queryset.filter(industry_id=industry_id) @@ -902,6 +904,19 @@ class SeedKeywordViewSet(viewsets.ReadOnlyModelViewSet): queryset = queryset.filter(volume__lte=int(volume_max)) except (ValueError, TypeError): pass + + # Availability filter - exclude keywords already added to the site + if available_only and str(available_only).lower() in ['true', '1', 'yes']: + if site_id: + try: + from igny8_core.business.planning.models import Keywords + attached_ids = Keywords.objects.filter( + site_id=site_id, + seed_keyword__isnull=False + ).values_list('seed_keyword_id', flat=True) + queryset = queryset.exclude(id__in=attached_ids) + except Exception: + pass return queryset @@ -1152,6 +1167,9 @@ class SeedKeywordViewSet(viewsets.ReadOnlyModelViewSet): # 3. High Volume (>= 10K) - simple threshold high_volume_count = base_qs.filter(volume__gte=10000).count() + + # 3b. Mid Volume (5K-10K) + mid_volume_count = base_qs.filter(volume__gte=5000, volume__lt=10000).count() # 4. Premium Traffic with dynamic fallback (50K -> 25K -> 10K) premium_thresholds = [50000, 25000, 10000] @@ -1182,6 +1200,7 @@ class SeedKeywordViewSet(viewsets.ReadOnlyModelViewSet): 'total': {'count': total_count}, 'available': {'count': available_count}, 'high_volume': {'count': high_volume_count, 'threshold': 10000}, + 'mid_volume': {'count': mid_volume_count, 'threshold': 5000}, 'premium_traffic': premium_result, 'long_tail': long_tail_result, 'quick_wins': quick_wins_result, @@ -1201,6 +1220,7 @@ class SeedKeywordViewSet(viewsets.ReadOnlyModelViewSet): sector_available = count_available(sector_qs) sector_high_volume = sector_qs.filter(volume__gte=10000).count() + sector_mid_volume = sector_qs.filter(volume__gte=5000, volume__lt=10000).count() sector_premium = get_count_with_fallback(sector_qs, premium_thresholds) sector_long_tail_base = sector_qs.filter(keyword__regex=r'^(\S+\s+){3,}\S+$') @@ -1218,6 +1238,7 @@ class SeedKeywordViewSet(viewsets.ReadOnlyModelViewSet): 'total': {'count': sector_total}, 'available': {'count': sector_available}, 'high_volume': {'count': sector_high_volume, 'threshold': 10000}, + 'mid_volume': {'count': sector_mid_volume, 'threshold': 5000}, 'premium_traffic': sector_premium, 'long_tail': sector_long_tail, 'quick_wins': sector_quick_wins, @@ -1243,12 +1264,20 @@ class SeedKeywordViewSet(viewsets.ReadOnlyModelViewSet): """ Get cascading filter options for Keywords Library. Returns industries, sectors (filtered by industry), and available filter values. + Supports cascading options based on current filters. """ from django.db.models import Count, Min, Max, Q try: industry_id = request.query_params.get('industry_id') - + sector_id = request.query_params.get('sector_id') + country_filter = request.query_params.get('country') + difficulty_min = request.query_params.get('difficulty_min') + difficulty_max = request.query_params.get('difficulty_max') + volume_min = request.query_params.get('volume_min') + volume_max = request.query_params.get('volume_max') + search_term = request.query_params.get('search') + # Get industries with keyword counts industries = Industry.objects.annotate( keyword_count=Count('seed_keywords', filter=Q(seed_keywords__is_active=True)) @@ -1276,31 +1305,120 @@ class SeedKeywordViewSet(viewsets.ReadOnlyModelViewSet): 'slug': sec.slug, 'keyword_count': sec.keyword_count, } for sec in sectors] - - # Get difficulty range - difficulty_range = SeedKeyword.objects.filter(is_active=True).aggregate( + + # Base queryset for cascading options + base_qs = SeedKeyword.objects.filter(is_active=True) + if industry_id: + base_qs = base_qs.filter(industry_id=industry_id) + if sector_id: + base_qs = base_qs.filter(sector_id=sector_id) + + # Countries options - apply all filters except country itself + countries_qs = base_qs + if difficulty_min is not None: + try: + countries_qs = countries_qs.filter(difficulty__gte=int(difficulty_min)) + except (ValueError, TypeError): + pass + if difficulty_max is not None: + try: + countries_qs = countries_qs.filter(difficulty__lte=int(difficulty_max)) + except (ValueError, TypeError): + pass + if volume_min is not None: + try: + countries_qs = countries_qs.filter(volume__gte=int(volume_min)) + except (ValueError, TypeError): + pass + if volume_max is not None: + try: + countries_qs = countries_qs.filter(volume__lte=int(volume_max)) + except (ValueError, TypeError): + pass + if search_term: + countries_qs = countries_qs.filter(keyword__icontains=search_term) + + countries = countries_qs.values('country').annotate( + keyword_count=Count('id') + ).order_by('country') + country_label_map = dict(SeedKeyword.COUNTRY_CHOICES) + countries_data = [{ + 'value': c['country'], + 'label': country_label_map.get(c['country'], c['country']), + 'keyword_count': c['keyword_count'], + } for c in countries if c['country']] + + # Difficulty options - apply all filters except difficulty itself + difficulty_qs = base_qs + if country_filter: + difficulty_qs = difficulty_qs.filter(country=country_filter) + if volume_min is not None: + try: + difficulty_qs = difficulty_qs.filter(volume__gte=int(volume_min)) + except (ValueError, TypeError): + pass + if volume_max is not None: + try: + difficulty_qs = difficulty_qs.filter(volume__lte=int(volume_max)) + except (ValueError, TypeError): + pass + if search_term: + difficulty_qs = difficulty_qs.filter(keyword__icontains=search_term) + + difficulty_ranges = [ + (1, 'Very Easy', 0, 10), + (2, 'Easy', 11, 30), + (3, 'Medium', 31, 50), + (4, 'Hard', 51, 70), + (5, 'Very Hard', 71, 100), + ] + + difficulty_levels = [] + for level, label, min_val, max_val in difficulty_ranges: + count = difficulty_qs.filter( + difficulty__gte=min_val, + difficulty__lte=max_val + ).count() + if count > 0: + difficulty_levels.append({ + 'level': level, + 'label': label, + 'backend_range': [min_val, max_val], + 'keyword_count': count, + }) + + # Difficulty range (filtered by current non-difficulty filters) + difficulty_range = difficulty_qs.aggregate( min_difficulty=Min('difficulty'), max_difficulty=Max('difficulty') ) - - # Get volume range - volume_range = SeedKeyword.objects.filter(is_active=True).aggregate( + + # Volume range (filtered by current non-volume filters) + volume_qs = base_qs + if country_filter: + volume_qs = volume_qs.filter(country=country_filter) + if difficulty_min is not None: + try: + volume_qs = volume_qs.filter(difficulty__gte=int(difficulty_min)) + except (ValueError, TypeError): + pass + if difficulty_max is not None: + try: + volume_qs = volume_qs.filter(difficulty__lte=int(difficulty_max)) + except (ValueError, TypeError): + pass + if search_term: + volume_qs = volume_qs.filter(keyword__icontains=search_term) + + volume_range = volume_qs.aggregate( min_volume=Min('volume'), max_volume=Max('volume') ) - # Difficulty levels for frontend (maps to backend values) - difficulty_levels = [ - {'level': 1, 'label': 'Very Easy', 'backend_range': [0, 20]}, - {'level': 2, 'label': 'Easy', 'backend_range': [21, 40]}, - {'level': 3, 'label': 'Medium', 'backend_range': [41, 60]}, - {'level': 4, 'label': 'Hard', 'backend_range': [61, 80]}, - {'level': 5, 'label': 'Very Hard', 'backend_range': [81, 100]}, - ] - data = { 'industries': industries_data, 'sectors': sectors_data, + 'countries': countries_data, 'difficulty': { 'range': difficulty_range, 'levels': difficulty_levels, diff --git a/frontend/src/components/keywords-library/SectorCardsGrid.tsx b/frontend/src/components/keywords-library/SectorCardsGrid.tsx index cfdf4874..592f6c57 100644 --- a/frontend/src/components/keywords-library/SectorCardsGrid.tsx +++ b/frontend/src/components/keywords-library/SectorCardsGrid.tsx @@ -62,6 +62,8 @@ export default function SectorCardsGrid({ const total = stats?.total.count ?? 0; const available = stats?.available.count ?? 0; const inWorkflow = Math.max(total - available, 0); + const over10k = stats?.high_volume.count ?? 0; + const midVolume = stats?.mid_volume?.count ?? 0; const isActive = activeSectorId === sector.id; return ( @@ -74,7 +76,7 @@ export default function SectorCardsGrid({ )} >
-
+

@@ -87,33 +89,47 @@ export default function SectorCardsGrid({ ) : ( Not linked to template )} + {isActive && ( +
+ + Active + +
+ )}

- {isActive && ( - - Active - - )} -
- -
-
-
Total
-
+
+
Total
+
{total.toLocaleString()}
-
-
Available
-
+
+ +
+
+
Available
+
{available.toLocaleString()}
-
-
In Workflow
-
+
+
In Workflow
+
{inWorkflow.toLocaleString()}
+
+
> 10K
+
+ {over10k.toLocaleString()} +
+
+
+
5K - 10K
+
+ {midVolume.toLocaleString()} +
+
); diff --git a/frontend/src/components/keywords-library/SectorMetricCard.tsx b/frontend/src/components/keywords-library/SectorMetricCard.tsx index 338e4ba7..73a8f8dd 100644 --- a/frontend/src/components/keywords-library/SectorMetricCard.tsx +++ b/frontend/src/components/keywords-library/SectorMetricCard.tsx @@ -43,6 +43,9 @@ const STAT_CONFIG: Record, accentColor: 'bg-gray-500', + borderColor: 'border-gray-400', + ringColor: 'ring-gray-400/20', + dotColor: 'bg-gray-500', textColor: 'text-gray-600 dark:text-gray-400', badgeColor: 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300', }, @@ -61,6 +67,9 @@ const STAT_CONFIG: Record, accentColor: 'bg-success-500', + borderColor: 'border-success-500', + ringColor: 'ring-success-500/20', + dotColor: 'bg-success-500', textColor: 'text-success-600 dark:text-success-400', badgeColor: 'bg-success-100 text-success-700 dark:bg-success-900/40 dark:text-success-300', }, @@ -69,6 +78,9 @@ const STAT_CONFIG: Record, accentColor: 'bg-brand-500', + borderColor: 'border-brand-500', + ringColor: 'ring-brand-500/20', + dotColor: 'bg-brand-500', textColor: 'text-brand-600 dark:text-brand-400', badgeColor: 'bg-brand-100 text-brand-700 dark:bg-brand-900/40 dark:text-brand-300', }, @@ -77,6 +89,9 @@ const STAT_CONFIG: Record, accentColor: 'bg-warning-500', + borderColor: 'border-warning-500', + ringColor: 'ring-warning-500/20', + dotColor: 'bg-warning-500', textColor: 'text-warning-600 dark:text-warning-400', badgeColor: 'bg-warning-100 text-warning-700 dark:bg-warning-900/40 dark:text-warning-300', showThreshold: true, @@ -87,6 +102,9 @@ const STAT_CONFIG: Record, accentColor: 'bg-purple-500', + borderColor: 'border-purple-500', + ringColor: 'ring-purple-500/20', + dotColor: 'bg-purple-500', textColor: 'text-purple-600 dark:text-purple-400', badgeColor: 'bg-purple-100 text-purple-700 dark:bg-purple-900/40 dark:text-purple-300', showThreshold: true, @@ -97,6 +115,9 @@ const STAT_CONFIG: Record, accentColor: 'bg-emerald-500', + borderColor: 'border-emerald-500', + ringColor: 'ring-emerald-500/20', + dotColor: 'bg-emerald-500', textColor: 'text-emerald-600 dark:text-emerald-400', badgeColor: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300', showThreshold: true, @@ -189,7 +210,7 @@ export default function SectorMetricCard({ : 'cursor-default', compact ? 'p-3' : 'p-4', isActive - ? 'border-brand-500 shadow-sm ring-4 ring-brand-500/15' + ? clsx('border-2 shadow-sm ring-4', config.borderColor, config.ringColor) : 'border-gray-200 dark:border-gray-800', )} > @@ -208,7 +229,7 @@ export default function SectorMetricCard({
{isActive && ( -
+
)}
{formatCount(statData.count)} @@ -227,26 +248,28 @@ export default function SectorMetricCard({
- Add to workflow + ADD - {bulkAddOptions.map((count) => { - const isAdded = isAddedAction ? isAddedAction(statType, count) : false; - return ( - - ); - })} +
+ {bulkAddOptions.map((count) => { + const isAdded = isAddedAction ? isAddedAction(statType, count) : false; + return ( + + ); + })} +
)} @@ -285,7 +308,7 @@ export function SectorMetricGrid({ return (
{statTypes.map((statType) => (
{statTypes.map((statType) => ( void; - onBulkAdd?: (suggestion: SmartSuggestion, count: number) => void; - isAddedAction?: (suggestionId: string, count: number) => boolean; - enableFilterClick?: boolean; + children?: ReactNode; + showEmptyState?: boolean; className?: string; isLoading?: boolean; } -// Icon mapping -const SUGGESTION_ICONS: Record = { - quick_wins: , - long_tail: , - premium_traffic: , - available: , -}; - -// Color mappings with white bg card style -const colorClasses = { - blue: { - accent: 'bg-brand-500', - text: 'text-brand-600 dark:text-brand-400', - iconBg: 'bg-brand-100 dark:bg-brand-900/40', - }, - green: { - accent: 'bg-success-500', - text: 'text-success-600 dark:text-success-400', - iconBg: 'bg-success-100 dark:bg-success-900/40', - }, - amber: { - accent: 'bg-warning-500', - text: 'text-warning-600 dark:text-warning-400', - iconBg: 'bg-warning-100 dark:bg-warning-900/40', - }, - purple: { - accent: 'bg-purple-500', - text: 'text-purple-600 dark:text-purple-400', - iconBg: 'bg-purple-100 dark:bg-purple-900/40', - }, - emerald: { - accent: 'bg-emerald-500', - text: 'text-emerald-600 dark:text-emerald-400', - iconBg: 'bg-emerald-100 dark:bg-emerald-900/40', - }, -}; - export default function SmartSuggestions({ - suggestions, - onSuggestionClick, - onBulkAdd, - isAddedAction, - enableFilterClick = true, + children, + showEmptyState = false, className, isLoading = false, }: SmartSuggestionsProps) { const [isExpanded, setIsExpanded] = useState(true); - const totalAvailable = suggestions.reduce((sum, s) => sum + s.count, 0); - const hasKeywords = totalAvailable > 0; + const hasContent = Boolean(children); if (isLoading) { return ( @@ -98,8 +37,7 @@ export default function SmartSuggestions({ ); } - // Show breathing indicator when no suggestions (waiting for data) - if (suggestions.length === 0) { + if (showEmptyState && !hasContent) { return (
- {/* Breathing circle indicator */}
Ready-to-use keywords waiting for you! Search for a keyword or apply any filter to see smart suggestions... @@ -129,218 +66,40 @@ export default function SmartSuggestions({ 'rounded-xl border border-gray-200 dark:border-gray-800 bg-white dark:bg-white/[0.03] overflow-hidden', className )}> - {/* Header */} - - {/* Expandable content - Grid layout */} + {isExpanded && (
-
- {suggestions.map((suggestion) => { - const colors = colorClasses[suggestion.color]; - const icon = SUGGESTION_ICONS[suggestion.id] || ; - // Bulk add options - const bulkOptions: number[] = []; - if (suggestion.count >= 50) bulkOptions.push(50); - if (suggestion.count >= 100) bulkOptions.push(100); - if (suggestion.count >= 200) bulkOptions.push(200); - if (suggestion.count > 0 && suggestion.count <= 200) bulkOptions.push(suggestion.count); - - return ( -
- {/* Accent border */} -
- - {/* Main content - clickable to filter */} -
{ - if (enableFilterClick) { - onSuggestionClick(suggestion); - } - }} - className="p-3 pl-4" - > -
-
-
- {icon} -
- - {suggestion.label} - -
- - {suggestion.count.toLocaleString()} - -
- -

- {suggestion.description} -

-
- - {/* Bulk add buttons - always visible */} - {onBulkAdd && bulkOptions.length > 0 && ( -
-
- - - Add - -
-
- {bulkOptions.map((count) => { - const isAdded = isAddedAction ? isAddedAction(suggestion.id, count) : false; - return ( - - ); - })} -
-
- )} -
- ); - })} -
+ {children}
)}
); } - -// Helper to build suggestions from sector stats -export function buildSmartSuggestions( - stats: { - available: { count: number }; - quick_wins: { count: number; threshold?: number }; - long_tail: { count: number; threshold?: number }; - premium_traffic: { count: number; threshold?: number }; - }, - options?: { - showOnlyWithResults?: boolean; - } -): SmartSuggestion[] { - const suggestions: SmartSuggestion[] = []; - - // Quick Wins - Low difficulty + good volume + available - if (stats.quick_wins.count > 0 || !options?.showOnlyWithResults) { - suggestions.push({ - id: 'quick_wins', - label: 'Quick Wins', - description: `Easy to rank keywords with vol > ${(stats.quick_wins.threshold || 1000) / 1000}K`, - count: stats.quick_wins.count, - filterParams: { - statType: 'quick_wins', - difficulty_max: 20, - volume_min: stats.quick_wins.threshold || 1000, - available_only: true, - }, - color: 'emerald', - }); - } - - // Long Tail - 4+ words with volume - if (stats.long_tail.count > 0 || !options?.showOnlyWithResults) { - suggestions.push({ - id: 'long_tail', - label: 'Long Tail', - description: `4+ word phrases with vol > ${(stats.long_tail.threshold || 1000) / 1000}K`, - count: stats.long_tail.count, - filterParams: { - statType: 'long_tail', - word_count_min: 4, - volume_min: stats.long_tail.threshold || 1000, - }, - color: 'purple', - }); - } - - // Premium Traffic - High volume - if (stats.premium_traffic.count > 0 || !options?.showOnlyWithResults) { - suggestions.push({ - id: 'premium_traffic', - label: 'Premium Traffic', - description: `High volume keywords (${(stats.premium_traffic.threshold || 50000) / 1000}K+ searches)`, - count: stats.premium_traffic.count, - filterParams: { - statType: 'premium_traffic', - volume_min: stats.premium_traffic.threshold || 50000, - }, - color: 'amber', - }); - } - - // Available Only - if (stats.available.count > 0 || !options?.showOnlyWithResults) { - suggestions.push({ - id: 'available', - label: 'Available Keywords', - description: 'Keywords not yet added to your site', - count: stats.available.count, - filterParams: { - statType: 'available', - available_only: true, - }, - color: 'green', - }); - } - - return suggestions; -} diff --git a/frontend/src/components/keywords-library/index.tsx b/frontend/src/components/keywords-library/index.tsx index 39a442c3..3f13878b 100644 --- a/frontend/src/components/keywords-library/index.tsx +++ b/frontend/src/components/keywords-library/index.tsx @@ -6,6 +6,6 @@ export { default as SectorMetricCard, SectorMetricGrid } from './SectorMetricCard'; export type { StatType, StatResult, SectorStats } from './SectorMetricCard'; -export { default as SmartSuggestions, buildSmartSuggestions } from './SmartSuggestions'; +export { default as SmartSuggestions } from './SmartSuggestions'; export { default as BulkAddConfirmation } from './BulkAddConfirmation'; diff --git a/frontend/src/pages/Setup/IndustriesSectorsKeywords.tsx b/frontend/src/pages/Setup/IndustriesSectorsKeywords.tsx index 0342d2bf..1319c614 100644 --- a/frontend/src/pages/Setup/IndustriesSectorsKeywords.tsx +++ b/frontend/src/pages/Setup/IndustriesSectorsKeywords.tsx @@ -25,6 +25,8 @@ import { fetchSectorStats, SectorStats, SectorStatsItem, + fetchKeywordsLibraryFilterOptions, + FilterOption, } from '../../services/api'; import Badge from '../../components/ui/badge/Badge'; import { BoltIcon, ShootingStarIcon } from '../../icons'; @@ -41,7 +43,7 @@ import Label from '../../components/form/Label'; import Input from '../../components/form/input/InputField'; import { Card } from '../../components/ui/card'; import { SectorMetricGrid, StatType } from '../../components/keywords-library/SectorMetricCard'; -import SmartSuggestions, { buildSmartSuggestions } from '../../components/keywords-library/SmartSuggestions'; +import SmartSuggestions from '../../components/keywords-library/SmartSuggestions'; import SectorCardsGrid from '../../components/keywords-library/SectorCardsGrid'; import BulkAddConfirmation from '../../components/keywords-library/BulkAddConfirmation'; @@ -72,9 +74,7 @@ export default function IndustriesSectorsKeywords() { const [bulkAddKeywordIds, setBulkAddKeywordIds] = useState([]); const [bulkAddStatLabel, setBulkAddStatLabel] = useState(); const [pendingBulkAddKey, setPendingBulkAddKey] = useState(null); - const [pendingBulkAddGroup, setPendingBulkAddGroup] = useState<'stat' | 'suggestion' | null>(null); const [addedStatActions, setAddedStatActions] = useState>(new Set()); - const [addedSuggestionActions, setAddedSuggestionActions] = useState>(new Set()); // Ahrefs banner state const [showAhrefsBanner, setShowAhrefsBanner] = useState(true); @@ -95,6 +95,10 @@ export default function IndustriesSectorsKeywords() { const [showNotAddedOnly, setShowNotAddedOnly] = useState(false); const [volumeMin, setVolumeMin] = useState(''); const [volumeMax, setVolumeMax] = useState(''); + + // Dynamic filter options (cascading) + const [countryOptions, setCountryOptions] = useState(undefined); + const [difficultyOptions, setDifficultyOptions] = useState(undefined); // Keyword count tracking const [addedCount, setAddedCount] = useState(0); @@ -267,7 +271,6 @@ export default function IndustriesSectorsKeywords() { setCurrentPage(1); setSelectedIds([]); setAddedStatActions(new Set()); - setAddedSuggestionActions(new Set()); }, [activeSite?.id]); // Reset pagination/selection when sector changes @@ -275,8 +278,44 @@ export default function IndustriesSectorsKeywords() { setActiveStatFilter(null); setCurrentPage(1); setSelectedIds([]); + setAddedStatActions(new Set()); }, [activeSector?.id]); + // Load cascading filter options based on current filters + const loadFilterOptions = useCallback(async () => { + if (!activeSite?.industry) return; + + const difficultyLabel = difficultyFilter ? getDifficultyLabelFromNumber(parseInt(difficultyFilter, 10)) : null; + const difficultyRange = difficultyLabel ? getDifficultyRange(difficultyLabel) : null; + + try { + const options = await fetchKeywordsLibraryFilterOptions({ + industry_id: activeSite.industry, + sector_id: activeSector?.industry_sector || undefined, + country: countryFilter || undefined, + difficulty_min: difficultyRange?.min, + difficulty_max: difficultyRange?.max, + volume_min: volumeMin ? Number(volumeMin) : undefined, + volume_max: volumeMax ? Number(volumeMax) : undefined, + search: searchTerm || undefined, + }); + + setCountryOptions(options.countries || []); + setDifficultyOptions( + (options.difficulty?.levels || []).map((level) => ({ + value: String(level.level), + label: `${level.level} - ${level.label}`, + })) + ); + } catch (error) { + console.error('Failed to load filter options:', error); + } + }, [activeSite?.industry, activeSector?.industry_sector, countryFilter, difficultyFilter, volumeMin, volumeMax, searchTerm]); + + useEffect(() => { + loadFilterOptions(); + }, [loadFilterOptions]); + // Load counts on mount and when site/sector changes useEffect(() => { if (activeSite) { @@ -297,31 +336,39 @@ export default function IndustriesSectorsKeywords() { setShowContent(false); try { - // Get already-attached keywords for marking (lightweight check) + // Get already-attached keywords for marking (paginate to ensure persistence) const attachedSeedKeywordIds = new Set(); - try { - const sectors = await fetchSiteSectors(activeSite.id); - - // Check keywords in all sectors (needed for isAdded flag) - for (const sector of sectors) { - try { - const keywordsData = await fetchKeywords({ - site_id: activeSite.id, - sector_id: sector.id, - page_size: 1000, - }); - (keywordsData.results || []).forEach((k: any) => { - 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); + const fetchAttachedSeedKeywordIds = async (siteId: number, sectorId?: number) => { + const pageSize = 500; + let page = 1; + while (true) { + const keywordsData = await fetchKeywords({ + site_id: siteId, + sector_id: sectorId, + page, + page_size: pageSize, + }); + (keywordsData.results || []).forEach((k: any) => { + const seedKeywordId = k.seed_keyword_id || (k.seed_keyword && k.seed_keyword.id); + if (seedKeywordId) { + attachedSeedKeywordIds.add(Number(seedKeywordId)); + } + }); + if (!keywordsData.next || (keywordsData.results || []).length < pageSize) { + break; } + page += 1; + } + }; + + try { + if (activeSector?.id) { + await fetchAttachedSeedKeywordIds(activeSite.id, activeSector.id); + } else { + await fetchAttachedSeedKeywordIds(activeSite.id); } } catch (err) { - console.warn('Could not fetch sectors or attached keywords:', err); + console.warn('Could not fetch attached keywords:', err); } // Keep attached IDs available for bulk add actions @@ -342,8 +389,24 @@ export default function IndustriesSectorsKeywords() { if (searchTerm) filters.search = searchTerm; if (countryFilter) filters.country = countryFilter; - if (volumeMin) filters.volume_min = parseInt(volumeMin); - if (volumeMax) filters.volume_max = parseInt(volumeMax); + if (volumeMin !== '') { + const parsed = Number(volumeMin); + if (!Number.isNaN(parsed)) { + filters.volume_min = parsed; + } + } + if (volumeMax !== '') { + const parsed = Number(volumeMax); + if (!Number.isNaN(parsed)) { + filters.volume_max = parsed; + } + } + + const useServerAvailableFilter = Boolean(showNotAddedOnly && activeSite?.id); + if (useServerAvailableFilter) { + filters.site_id = activeSite.id; + filters.available_only = true; + } // Apply difficulty filter (if API supports it, otherwise we'll filter client-side) if (difficultyFilter) { @@ -386,15 +449,19 @@ export default function IndustriesSectorsKeywords() { setAddedCount(totalAdded); setAvailableCount(actualAvailable); - // Apply "not yet added" filter client-side (if API doesn't support it) + // Apply "not yet added" filter client-side only when server filtering isn't used let filteredResults = results; - if (showNotAddedOnly) { + if (showNotAddedOnly && !useServerAvailableFilter) { filteredResults = results.filter(sk => !sk.isAdded); } + const effectiveTotalCount = useServerAvailableFilter + ? apiTotalCount + : (showNotAddedOnly && activeSite ? Math.max(apiTotalCount - attachedSeedKeywordIds.size, 0) : apiTotalCount); + setSeedKeywords(filteredResults); - setTotalCount(apiTotalCount); - setTotalPages(Math.ceil(apiTotalCount / pageSizeNum)); + setTotalCount(effectiveTotalCount); + setTotalPages(Math.ceil(effectiveTotalCount / pageSizeNum)); setShowContent(true); } catch (error: any) { @@ -409,6 +476,42 @@ export default function IndustriesSectorsKeywords() { } }, [activeSite, activeSector, currentPage, pageSize, searchTerm, countryFilter, volumeMin, volumeMax, difficultyFilter, showNotAddedOnly, sortBy, sortDirection, toast]); + const getAddedStatStorageKey = useCallback(() => { + if (!activeSite?.id) return null; + const sectorKey = activeSector?.id ? `sector-${activeSector.id}` : 'sector-all'; + return `keywordsLibrary:addedStatActions:${activeSite.id}:${sectorKey}`; + }, [activeSite?.id, activeSector?.id]); + + useEffect(() => { + const storageKey = getAddedStatStorageKey(); + if (!storageKey) { + setAddedStatActions(new Set()); + return; + } + try { + const raw = localStorage.getItem(storageKey); + if (raw) { + const parsed = JSON.parse(raw) as string[]; + setAddedStatActions(new Set(parsed)); + } else { + setAddedStatActions(new Set()); + } + } catch (error) { + console.warn('Failed to load added stat actions:', error); + setAddedStatActions(new Set()); + } + }, [getAddedStatStorageKey]); + + useEffect(() => { + const storageKey = getAddedStatStorageKey(); + if (!storageKey) return; + try { + localStorage.setItem(storageKey, JSON.stringify(Array.from(addedStatActions))); + } catch (error) { + console.warn('Failed to persist added stat actions:', error); + } + }, [addedStatActions, getAddedStatStorageKey]); + // Load data when site/sector/filters change (show table by default per plan) useEffect(() => { if (activeSite) { @@ -621,6 +724,32 @@ export default function IndustriesSectorsKeywords() { const pageConfig = useMemo(() => { const showSectorColumn = !activeSector; + const defaultCountryOptions: FilterOption[] = [ + { value: 'US', label: 'United States' }, + { value: 'CA', label: 'Canada' }, + { value: 'GB', label: 'United Kingdom' }, + { value: 'AE', label: 'United Arab Emirates' }, + { value: 'AU', label: 'Australia' }, + { value: 'IN', label: 'India' }, + { value: 'PK', label: 'Pakistan' }, + ]; + + const countryFilterOptions = countryOptions && countryOptions.length > 0 + ? countryOptions + : defaultCountryOptions; + + const defaultDifficultyOptions: FilterOption[] = [ + { value: '1', label: '1 - Very Easy' }, + { value: '2', label: '2 - Easy' }, + { value: '3', label: '3 - Medium' }, + { value: '4', label: '4 - Hard' }, + { value: '5', label: '5 - Very Hard' }, + ]; + + const difficultyFilterOptions = difficultyOptions && difficultyOptions.length > 0 + ? difficultyOptions + : defaultDifficultyOptions; + return { columns: [ { @@ -782,13 +911,7 @@ export default function IndustriesSectorsKeywords() { type: 'select' as const, options: [ { value: '', label: 'All Countries' }, - { value: 'US', label: 'United States' }, - { value: 'CA', label: 'Canada' }, - { value: 'GB', label: 'United Kingdom' }, - { value: 'AE', label: 'United Arab Emirates' }, - { value: 'AU', label: 'Australia' }, - { value: 'IN', label: 'India' }, - { value: 'PK', label: 'Pakistan' }, + ...countryFilterOptions, ], }, { @@ -797,11 +920,7 @@ export default function IndustriesSectorsKeywords() { type: 'select' as const, options: [ { value: '', label: 'All Difficulty' }, - { value: '1', label: '1 - Very Easy' }, - { value: '2', label: '2 - Easy' }, - { value: '3', label: '3 - Medium' }, - { value: '4', label: '4 - Hard' }, - { value: '5', label: '5 - Very Hard' }, + ...difficultyFilterOptions, ], }, { @@ -822,13 +941,7 @@ export default function IndustriesSectorsKeywords() { }, ], }; - }, [activeSector, handleAddToWorkflow, sectors]); - - // Build smart suggestions from sector stats - const smartSuggestions = useMemo(() => { - if (!sectorStats) return []; - return buildSmartSuggestions(sectorStats, { showOnlyWithResults: true }); - }, [sectorStats]); + }, [activeSector, handleAddToWorkflow, sectors, countryOptions, difficultyOptions, volumeMin, volumeMax]); // Helper: word count for keyword string const getWordCount = useCallback((keyword: string) => { @@ -839,18 +952,10 @@ export default function IndustriesSectorsKeywords() { return `${statType}:${count}`; }, []); - const buildSuggestionActionKey = useCallback((suggestionId: string, count: number) => { - return `${suggestionId}:${count}`; - }, []); - const isStatActionAdded = useCallback((statType: StatType, count: number) => { return addedStatActions.has(buildStatActionKey(statType, count)); }, [addedStatActions, buildStatActionKey]); - const isSuggestionActionAdded = useCallback((suggestionId: string, count: number) => { - return addedSuggestionActions.has(buildSuggestionActionKey(suggestionId, count)); - }, [addedSuggestionActions, buildSuggestionActionKey]); - const fetchBulkKeywords = useCallback(async (options: { ordering: string; difficultyMax?: number; @@ -900,7 +1005,6 @@ export default function IndustriesSectorsKeywords() { label: string; ids: number[]; actionKey: string; - group: 'stat' | 'suggestion'; }) => { if (payload.ids.length === 0) { toast.error('No matching keywords found for this selection'); @@ -910,7 +1014,6 @@ export default function IndustriesSectorsKeywords() { setBulkAddKeywordIds(payload.ids); setBulkAddStatLabel(payload.label); setPendingBulkAddKey(payload.actionKey); - setPendingBulkAddGroup(payload.group); setShowBulkAddModal(true); }, [toast]); @@ -985,48 +1088,6 @@ export default function IndustriesSectorsKeywords() { } }, [activeStatFilter, sectorStats]); - const handleSuggestionClick = useCallback((suggestion: any) => { - setActiveStatFilter(null); - setCurrentPage(1); - setSelectedIds([]); - - const difficultyMax = suggestion.filterParams?.difficulty_max; - const volumeMinValue = suggestion.filterParams?.volume_min; - const availableOnly = Boolean(suggestion.filterParams?.available_only); - - if (availableOnly) { - setShowNotAddedOnly(true); - } else { - setShowNotAddedOnly(false); - } - - if (volumeMinValue) { - setVolumeMin(String(volumeMinValue)); - } else { - setVolumeMin(''); - } - setVolumeMax(''); - - if (difficultyMax !== undefined) { - if (difficultyMax <= 20) { - setDifficultyFilter('1'); - } else if (difficultyMax <= 40) { - setDifficultyFilter('2'); - } else { - setDifficultyFilter(''); - } - } else { - setDifficultyFilter(''); - } - - if (suggestion.id === 'quick_wins') { - setSortBy('difficulty'); - setSortDirection('asc'); - } else { - setSortBy('volume'); - setSortDirection('desc'); - } - }, []); // Handle sector card click const handleSectorSelect = useCallback((sector: Sector | null) => { @@ -1117,74 +1178,9 @@ export default function IndustriesSectorsKeywords() { label: statLabelMap[statType], ids, actionKey, - group: 'stat', }); }, [activeSite, activeSector, addedStatActions, buildStatActionKey, fetchBulkKeywords, prepareBulkAdd, sectorStats, toast]); - // Handle bulk add from suggestions - const handleSuggestionBulkAdd = useCallback(async (suggestion: any, count: number) => { - if (!activeSite || !activeSector?.industry_sector) { - toast.error('Please select a site and sector first'); - return; - } - - const actionKey = buildSuggestionActionKey(suggestion.id, count); - if (addedSuggestionActions.has(actionKey)) { - return; - } - - const suggestionThresholds = { - quick_wins: sectorStats?.quick_wins.threshold ?? 1000, - long_tail: sectorStats?.long_tail.threshold ?? 1000, - premium_traffic: sectorStats?.premium_traffic.threshold ?? 50000, - }; - - let ids: number[] = []; - if (suggestion.id === 'quick_wins') { - ids = await fetchBulkKeywords({ - ordering: 'difficulty', - difficultyMax: 20, - minVolume: suggestionThresholds.quick_wins, - availableOnly: true, - count, - }); - } else if (suggestion.id === 'long_tail') { - ids = await fetchBulkKeywords({ - ordering: '-volume', - minVolume: suggestionThresholds.long_tail, - longTail: true, - availableOnly: true, - count, - }); - } else if (suggestion.id === 'premium_traffic') { - ids = await fetchBulkKeywords({ - ordering: '-volume', - minVolume: suggestionThresholds.premium_traffic, - availableOnly: true, - count, - }); - } else if (suggestion.id === 'available') { - ids = await fetchBulkKeywords({ - ordering: '-volume', - availableOnly: true, - count, - }); - } else { - ids = await fetchBulkKeywords({ - ordering: '-volume', - availableOnly: true, - count, - }); - } - - await prepareBulkAdd({ - label: suggestion.label, - ids, - actionKey, - group: 'suggestion', - }); - }, [activeSite, activeSector, addedSuggestionActions, buildSuggestionActionKey, fetchBulkKeywords, prepareBulkAdd, sectorStats, toast]); - const handleConfirmBulkAdd = useCallback(async () => { if (!activeSite || !activeSector) { throw new Error('Please select a site and sector first'); @@ -1206,16 +1202,10 @@ export default function IndustriesSectorsKeywords() { } if (pendingBulkAddKey) { - if (pendingBulkAddGroup === 'stat') { - setAddedStatActions((prev) => new Set([...prev, pendingBulkAddKey])); - } - if (pendingBulkAddGroup === 'suggestion') { - setAddedSuggestionActions((prev) => new Set([...prev, pendingBulkAddKey])); - } + setAddedStatActions((prev) => new Set([...prev, pendingBulkAddKey])); } setPendingBulkAddKey(null); - setPendingBulkAddGroup(null); setShowBulkAddModal(false); if (activeSite) { @@ -1229,7 +1219,7 @@ export default function IndustriesSectorsKeywords() { skipped: result.skipped || 0, total_requested: bulkAddKeywordIds.length, }; - }, [activeSite, activeSector, bulkAddKeywordIds, loadKeywordCounts, loadSectorStats, loadSeedKeywords, pendingBulkAddGroup, pendingBulkAddKey]); + }, [activeSite, activeSector, bulkAddKeywordIds, loadKeywordCounts, loadSectorStats, loadSeedKeywords, pendingBulkAddKey]); // Show WorkflowGuide if no sites if (sites.length === 0) { @@ -1269,33 +1259,21 @@ export default function IndustriesSectorsKeywords() {
)} - {/* Sector Metric Cards - Show when site is selected */} - {activeSite && ( -
- -
- )} - {/* Smart Suggestions Panel - Always show when site is selected */} {activeSite && sectorStats && (
- + + +
)} @@ -1512,7 +1490,6 @@ export default function IndustriesSectorsKeywords() { setBulkAddKeywordIds([]); setBulkAddStatLabel(undefined); setPendingBulkAddKey(null); - setPendingBulkAddGroup(null); }} onConfirm={handleConfirmBulkAdd} keywordCount={bulkAddKeywordIds.length} diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index f5a717a2..75bb24b6 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -2354,6 +2354,7 @@ export interface SectorStats { total: SectorStatResult; available: SectorStatResult; high_volume: SectorStatResult; + mid_volume?: SectorStatResult; premium_traffic: SectorStatResult; long_tail: SectorStatResult; quick_wins: SectorStatResult; @@ -2411,16 +2412,37 @@ export interface DifficultyLevel { export interface FilterOptionsResponse { industries: FilterIndustryOption[]; sectors: FilterSectorOption[]; + countries?: FilterOption[]; difficulty: { range: { min_difficulty: number; max_difficulty: number }; - levels: DifficultyLevel[]; + levels: Array; }; volume: { min_volume: number; max_volume: number }; } -export async function fetchKeywordsLibraryFilterOptions(industryId?: number): Promise { +export interface KeywordsLibraryFilterOptionsRequest { + industry_id?: number; + sector_id?: number; + country?: string; + difficulty_min?: number; + difficulty_max?: number; + volume_min?: number; + volume_max?: number; + search?: string; +} + +export async function fetchKeywordsLibraryFilterOptions( + filters?: KeywordsLibraryFilterOptionsRequest +): Promise { const params = new URLSearchParams(); - if (industryId) params.append('industry_id', industryId.toString()); + if (filters?.industry_id) params.append('industry_id', filters.industry_id.toString()); + if (filters?.sector_id) params.append('sector_id', filters.sector_id.toString()); + if (filters?.country) params.append('country', filters.country); + if (filters?.difficulty_min !== undefined) params.append('difficulty_min', filters.difficulty_min.toString()); + if (filters?.difficulty_max !== undefined) params.append('difficulty_max', filters.difficulty_max.toString()); + if (filters?.volume_min !== undefined) params.append('volume_min', filters.volume_min.toString()); + if (filters?.volume_max !== undefined) params.append('volume_max', filters.volume_max.toString()); + if (filters?.search) params.append('search', filters.search); const queryString = params.toString(); return fetchAPI(`/v1/auth/keywords-library/filter_options/${queryString ? `?${queryString}` : ''}`); diff --git a/frontend/src/styles/design-system.css b/frontend/src/styles/design-system.css index 522ddf7b..f62f2fea 100644 --- a/frontend/src/styles/design-system.css +++ b/frontend/src/styles/design-system.css @@ -234,8 +234,8 @@ } .keywords-library-sector-card .sector-card-active-dot { - width: 8px; - height: 8px; + width: 16px; + height: 16px; border-radius: 999px; background: var(--color-primary); box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-primary) 20%, transparent);