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 (
-
:
- }
- onClick={() => handleAddClick(count)}
- disabled={isAdded}
- >
- {isAdded ? 'Added' : (count === statData.count ? 'All' : count)}
-
- );
- })}
+
+ {bulkAddOptions.map((count) => {
+ const isAdded = isAddedAction ? isAddedAction(statType, count) : false;
+ return (
+
:
+ }
+ onClick={() => handleAddClick(count)}
+ disabled={isAdded}
+ >
+ {isAdded ? 'Added' : (count === statData.count ? 'All' : count)}
+
+ );
+ })}
+
)}
@@ -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 && (
-
-
-
- {bulkOptions.map((count) => {
- const isAdded = isAddedAction ? isAddedAction(suggestion.id, count) : false;
- return (
-
:
- }
- onClick={() => onBulkAdd(suggestion, count)}
- disabled={isAdded}
- >
- {isAdded ? 'Added' : (count === suggestion.count ? 'All' : count)}
-
- );
- })}
-
-
- )}
-
- );
- })}
-
+ {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);