From 328098a48c21cc1b7ac8bbec5d96ed83d43fb49e Mon Sep 17 00:00:00 2001 From: "IGNY8 VPS (Salman)" Date: Sun, 18 Jan 2026 22:05:38 +0000 Subject: [PATCH] COMPLETED KEYWORDS-LIBRARY-REDESIGN-PLAN.md --- backend/igny8_core/auth/views.py | 110 +++++++++++++++--- .../keywords-library/SectorCardsGrid.tsx | 71 +++++------ .../keywords-library/SectorMetricCard.tsx | 45 ++++--- .../keywords-library/SmartSuggestions.tsx | 10 +- .../pages/Setup/IndustriesSectorsKeywords.tsx | 99 ++++++++++++++-- frontend/src/services/api.ts | 22 ++++ 6 files changed, 264 insertions(+), 93 deletions(-) diff --git a/backend/igny8_core/auth/views.py b/backend/igny8_core/auth/views.py index 98299dac..dfe1e158 100644 --- a/backend/igny8_core/auth/views.py +++ b/backend/igny8_core/auth/views.py @@ -864,6 +864,7 @@ class SeedKeywordViewSet(viewsets.ReadOnlyModelViewSet): industry_id = self.request.query_params.get('industry_id') industry_name = self.request.query_params.get('industry_name') sector_id = self.request.query_params.get('sector_id') + sector_ids = self.request.query_params.get('sector_ids') # Comma-separated list sector_name = self.request.query_params.get('sector_name') difficulty_min = self.request.query_params.get('difficulty_min') difficulty_max = self.request.query_params.get('difficulty_max') @@ -871,13 +872,24 @@ class SeedKeywordViewSet(viewsets.ReadOnlyModelViewSet): 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') + min_words = self.request.query_params.get('min_words') if industry_id: queryset = queryset.filter(industry_id=industry_id) if industry_name: queryset = queryset.filter(industry__name__icontains=industry_name) + + # Support single sector_id OR multiple sector_ids (comma-separated) if sector_id: queryset = queryset.filter(sector_id=sector_id) + elif sector_ids: + try: + ids_list = [int(s.strip()) for s in sector_ids.split(',') if s.strip()] + if ids_list: + queryset = queryset.filter(sector_id__in=ids_list) + except (ValueError, TypeError): + pass + if sector_name: queryset = queryset.filter(sector__name__icontains=sector_name) @@ -905,6 +917,20 @@ class SeedKeywordViewSet(viewsets.ReadOnlyModelViewSet): except (ValueError, TypeError): pass + # Word count filtering (for long-tail keywords - 4+ words) + if min_words is not None: + try: + min_word_count = int(min_words) + if min_word_count == 4: + # Long-tail: 4+ words (keywords with at least 3 spaces) + queryset = queryset.filter(keyword__regex=r'^(\S+\s+){3,}\S+$') + elif min_word_count > 1: + # Generic word count filter using regex + pattern = r'^(\S+\s+){' + str(min_word_count - 1) + r',}\S+$' + queryset = queryset.filter(keyword__regex=pattern) + 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: @@ -1106,6 +1132,8 @@ class SeedKeywordViewSet(viewsets.ReadOnlyModelViewSet): - premium_traffic: Volume >= 50K with fallbacks (50K -> 25K -> 10K) - long_tail: 4+ words with Volume > threshold (1K -> 500 -> 200) - quick_wins: Difficulty <= 20, Volume > threshold, AND available + + sector_ids: Comma-separated list of IndustrySector IDs to filter by (for site-specific filtering) """ from django.db.models import Count, Sum, Q, F from django.db.models.functions import Length @@ -1114,6 +1142,7 @@ class SeedKeywordViewSet(viewsets.ReadOnlyModelViewSet): # Get filters industry_id = request.query_params.get('industry_id') sector_id = request.query_params.get('sector_id') + sector_ids = request.query_params.get('sector_ids') # Comma-separated list site_id = request.query_params.get('site_id') if not industry_id: @@ -1149,15 +1178,16 @@ class SeedKeywordViewSet(viewsets.ReadOnlyModelViewSet): return qs.count() return qs.exclude(id__in=already_added_ids).count() - # Helper for dynamic threshold fallback + # Helper for dynamic threshold fallback - returns both total and available def get_count_with_fallback(qs, thresholds, volume_field='volume'): """Try thresholds in order, return first with results.""" for threshold in thresholds: filtered = qs.filter(**{f'{volume_field}__gte': threshold}) - count = filtered.count() - if count > 0: - return {'count': count, 'threshold': threshold} - return {'count': 0, 'threshold': thresholds[-1]} + total_count = filtered.count() + if total_count > 0: + available = count_available(filtered) + return {'count': total_count, 'available': available, 'threshold': threshold} + return {'count': 0, 'available': 0, 'threshold': thresholds[-1]} # 1. Total keywords total_count = base_qs.count() @@ -1166,10 +1196,14 @@ class SeedKeywordViewSet(viewsets.ReadOnlyModelViewSet): available_count = count_available(base_qs) # 3. High Volume (>= 10K) - simple threshold - high_volume_count = base_qs.filter(volume__gte=10000).count() + high_volume_qs = base_qs.filter(volume__gte=10000) + high_volume_count = high_volume_qs.count() + high_volume_available = count_available(high_volume_qs) # 3b. Mid Volume (5K-10K) - mid_volume_count = base_qs.filter(volume__gte=5000, volume__lt=10000).count() + mid_volume_qs = base_qs.filter(volume__gte=5000, volume__lt=10000) + mid_volume_count = mid_volume_qs.count() + mid_volume_available = count_available(mid_volume_qs) # 4. Premium Traffic with dynamic fallback (50K -> 25K -> 10K) premium_thresholds = [50000, 25000, 10000] @@ -1199,8 +1233,8 @@ class SeedKeywordViewSet(viewsets.ReadOnlyModelViewSet): 'stats': { 'total': {'count': total_count}, 'available': {'count': available_count}, - 'high_volume': {'count': high_volume_count, 'threshold': 10000}, - 'mid_volume': {'count': mid_volume_count, 'threshold': 5000}, + 'high_volume': {'count': high_volume_count, 'available': high_volume_available, 'threshold': 10000}, + 'mid_volume': {'count': mid_volume_count, 'available': mid_volume_available, 'threshold': 5000}, 'premium_traffic': premium_result, 'long_tail': long_tail_result, 'quick_wins': quick_wins_result, @@ -1208,7 +1242,16 @@ class SeedKeywordViewSet(viewsets.ReadOnlyModelViewSet): } else: # Get stats per sector in the industry + # Filter by specific sector_ids if provided (for site-specific sectors) sectors = IndustrySector.objects.filter(industry_id=industry_id) + if sector_ids: + try: + ids_list = [int(s.strip()) for s in sector_ids.split(',') if s.strip()] + if ids_list: + sectors = sectors.filter(id__in=ids_list) + except (ValueError, TypeError): + pass + sectors_data = [] for sector in sectors: @@ -1219,8 +1262,17 @@ class SeedKeywordViewSet(viewsets.ReadOnlyModelViewSet): continue 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() + + # High volume with available count + sector_high_volume_qs = sector_qs.filter(volume__gte=10000) + sector_high_volume = sector_high_volume_qs.count() + sector_high_volume_available = count_available(sector_high_volume_qs) + + # Mid volume with available count + sector_mid_volume_qs = sector_qs.filter(volume__gte=5000, volume__lt=10000) + sector_mid_volume = sector_mid_volume_qs.count() + sector_mid_volume_available = count_available(sector_mid_volume_qs) + sector_premium = get_count_with_fallback(sector_qs, premium_thresholds) sector_long_tail_base = sector_qs.filter(keyword__regex=r'^(\S+\s+){3,}\S+$') @@ -1237,8 +1289,8 @@ class SeedKeywordViewSet(viewsets.ReadOnlyModelViewSet): 'stats': { 'total': {'count': sector_total}, 'available': {'count': sector_available}, - 'high_volume': {'count': sector_high_volume, 'threshold': 10000}, - 'mid_volume': {'count': sector_mid_volume, 'threshold': 5000}, + 'high_volume': {'count': sector_high_volume, 'available': sector_high_volume_available, 'threshold': 10000}, + 'mid_volume': {'count': sector_mid_volume, 'available': sector_mid_volume_available, 'threshold': 5000}, 'premium_traffic': sector_premium, 'long_tail': sector_long_tail, 'quick_wins': sector_quick_wins, @@ -1266,7 +1318,8 @@ class SeedKeywordViewSet(viewsets.ReadOnlyModelViewSet): 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 + from django.db.models import Count, Min, Max, Q, Value + from django.db.models.functions import Length, Replace try: industry_id = request.query_params.get('industry_id') @@ -1277,6 +1330,9 @@ class SeedKeywordViewSet(viewsets.ReadOnlyModelViewSet): volume_min = request.query_params.get('volume_min') volume_max = request.query_params.get('volume_max') search_term = request.query_params.get('search') + min_words = request.query_params.get('min_words') + site_id = request.query_params.get('site_id') + available_only = request.query_params.get('available_only') == 'true' # Get industries with keyword counts industries = Industry.objects.annotate( @@ -1312,6 +1368,32 @@ class SeedKeywordViewSet(viewsets.ReadOnlyModelViewSet): base_qs = base_qs.filter(industry_id=industry_id) if sector_id: base_qs = base_qs.filter(sector_id=sector_id) + + # Apply min_words filter (for long-tail keywords) + if min_words is not None: + try: + min_words_int = int(min_words) + from django.db.models.functions import Length + # Count words by counting spaces + 1 + base_qs = base_qs.annotate( + word_count=Length('keyword') - Length(Replace('keyword', Value(' '), Value(''))) + 1 + ).filter(word_count__gte=min_words_int) + except (ValueError, TypeError): + pass + + # Apply available_only filter (exclude keywords already added to site) + if available_only and site_id: + try: + from igny8_core.business.planning.models import Keywords + site_id_int = int(site_id) + # Get seed keyword IDs already added to this site + existing_seed_ids = Keywords.objects.filter( + site_id=site_id_int, + seed_keyword__isnull=False + ).values_list('seed_keyword_id', flat=True) + base_qs = base_qs.exclude(id__in=existing_seed_ids) + except (ValueError, TypeError): + pass # Countries options - apply all filters except country itself countries_qs = base_qs diff --git a/frontend/src/components/keywords-library/SectorCardsGrid.tsx b/frontend/src/components/keywords-library/SectorCardsGrid.tsx index 592f6c57..03fef9ea 100644 --- a/frontend/src/components/keywords-library/SectorCardsGrid.tsx +++ b/frontend/src/components/keywords-library/SectorCardsGrid.tsx @@ -71,62 +71,53 @@ export default function SectorCardsGrid({ key={sector.id} onClick={() => onSelectSector(sector)} className={clsx( - 'keywords-library-sector-card', + 'keywords-library-sector-card relative', isActive ? 'is-active' : '' )} >
+ + {/* Active indicator - top right corner */} + {isActive && ( +
+ + + Active + +
+ )} +
-
-
-

- {sector.name} -

- {isActive && } -
- {sector.industry_sector ? ( - Sector keywords - ) : ( - Not linked to template - )} - {isActive && ( -
- - Active - -
- )} -
-
-
Total
-
- {total.toLocaleString()} -
-
+

+ {sector.name} +

+ {!sector.industry_sector && ( + Not linked + )}
-
-
-
Available
-
+
+
+
Available
+
{available.toLocaleString()}
-
-
In Workflow
-
+
+
Added
+
{inWorkflow.toLocaleString()}
-
-
> 10K
-
+
+
> 10K
+
{over10k.toLocaleString()}
-
-
5K - 10K
-
+
+
5K - 10K
+
{midVolume.toLocaleString()}
diff --git a/frontend/src/components/keywords-library/SectorMetricCard.tsx b/frontend/src/components/keywords-library/SectorMetricCard.tsx index 73a8f8dd..a87e3656 100644 --- a/frontend/src/components/keywords-library/SectorMetricCard.tsx +++ b/frontend/src/components/keywords-library/SectorMetricCard.tsx @@ -223,18 +223,13 @@ export default function SectorMetricCard({
{config.icon}
- +

{config.label} - -

-
- {isActive && ( -
- )} -
- {formatCount(statData.count)} -
+
+

+ {formatCount(statData.count)} +

{/* Description / Threshold */} @@ -245,28 +240,32 @@ export default function SectorMetricCard({ {/* Bulk Add Buttons - Always visible */} {onBulkAdd && statData.count > 0 && statType !== 'total' && (
-
+
ADD -
+
{bulkAddOptions.map((count) => { const isAdded = isAddedAction ? isAddedAction(statType, count) : false; return ( - + + ); })}
diff --git a/frontend/src/components/keywords-library/SmartSuggestions.tsx b/frontend/src/components/keywords-library/SmartSuggestions.tsx index 5ef08b64..1fcbecb3 100644 --- a/frontend/src/components/keywords-library/SmartSuggestions.tsx +++ b/frontend/src/components/keywords-library/SmartSuggestions.tsx @@ -72,24 +72,24 @@ export default function SmartSuggestions({ >
- +
-

+

Smart Suggestions

-

+

Ready-to-use keywords waiting for you!

diff --git a/frontend/src/pages/Setup/IndustriesSectorsKeywords.tsx b/frontend/src/pages/Setup/IndustriesSectorsKeywords.tsx index 1319c614..bf9fcec3 100644 --- a/frontend/src/pages/Setup/IndustriesSectorsKeywords.tsx +++ b/frontend/src/pages/Setup/IndustriesSectorsKeywords.tsx @@ -95,7 +95,7 @@ export default function IndustriesSectorsKeywords() { const [showNotAddedOnly, setShowNotAddedOnly] = useState(false); const [volumeMin, setVolumeMin] = useState(''); const [volumeMax, setVolumeMax] = useState(''); - + const [minWords, setMinWords] = useState(undefined); // Dynamic filter options (cascading) const [countryOptions, setCountryOptions] = useState(undefined); const [difficultyOptions, setDifficultyOptions] = useState(undefined); @@ -199,9 +199,15 @@ export default function IndustriesSectorsKeywords() { setLoadingSectorStats(true); try { + // Get the site's sector industry_sector IDs to filter stats + const siteSectorIds = sectors + .filter(s => s.industry_sector) + .map(s => s.industry_sector as number); + const response = await fetchSectorStats({ industry_id: activeSite.industry, site_id: activeSite.id, + sector_ids: siteSectorIds.length > 0 ? siteSectorIds : undefined, }); const allSectors = response.sectors || []; @@ -219,19 +225,36 @@ export default function IndustriesSectorsKeywords() { const aggregated: SectorStats = { total: { count: 0 }, available: { count: 0 }, - high_volume: { count: 0, threshold: 10000 }, - premium_traffic: { count: 0, threshold: 50000 }, - long_tail: { count: 0, threshold: 1000 }, - quick_wins: { count: 0, threshold: 1000 }, + high_volume: { count: 0, available: 0, threshold: 10000 }, + mid_volume: { count: 0, available: 0, threshold: 5000 }, + premium_traffic: { count: 0, available: 0, threshold: 50000 }, + long_tail: { count: 0, available: 0, threshold: 1000 }, + quick_wins: { count: 0, available: 0, threshold: 1000 }, }; allSectors.forEach((sector) => { aggregated.total.count += sector.stats.total.count; aggregated.available.count += sector.stats.available.count; + + // Aggregate counts AND available counts for each stat type aggregated.high_volume.count += sector.stats.high_volume.count; + aggregated.high_volume.available = (aggregated.high_volume.available || 0) + (sector.stats.high_volume.available || 0); + + if (sector.stats.mid_volume) { + aggregated.mid_volume!.count += sector.stats.mid_volume.count; + aggregated.mid_volume!.available = (aggregated.mid_volume!.available || 0) + (sector.stats.mid_volume.available || 0); + } + aggregated.premium_traffic.count += sector.stats.premium_traffic.count; + aggregated.premium_traffic.available = (aggregated.premium_traffic.available || 0) + (sector.stats.premium_traffic.available || 0); + aggregated.long_tail.count += sector.stats.long_tail.count; + aggregated.long_tail.available = (aggregated.long_tail.available || 0) + (sector.stats.long_tail.available || 0); + aggregated.quick_wins.count += sector.stats.quick_wins.count; + aggregated.quick_wins.available = (aggregated.quick_wins.available || 0) + (sector.stats.quick_wins.available || 0); + + // Use first non-zero threshold if (!aggregated.premium_traffic.threshold) { aggregated.premium_traffic.threshold = sector.stats.premium_traffic.threshold; } @@ -252,7 +275,7 @@ export default function IndustriesSectorsKeywords() { } finally { setLoadingSectorStats(false); } - }, [activeSite, activeSector]); + }, [activeSite, activeSector, sectors]); // Load sector stats when site/sector changes useEffect(() => { @@ -268,6 +291,7 @@ export default function IndustriesSectorsKeywords() { setCountryFilter(''); setDifficultyFilter(''); setShowNotAddedOnly(false); + setMinWords(undefined); setCurrentPage(1); setSelectedIds([]); setAddedStatActions(new Set()); @@ -276,6 +300,7 @@ export default function IndustriesSectorsKeywords() { // Reset pagination/selection when sector changes useEffect(() => { setActiveStatFilter(null); + setMinWords(undefined); setCurrentPage(1); setSelectedIds([]); setAddedStatActions(new Set()); @@ -298,6 +323,9 @@ export default function IndustriesSectorsKeywords() { volume_min: volumeMin ? Number(volumeMin) : undefined, volume_max: volumeMax ? Number(volumeMax) : undefined, search: searchTerm || undefined, + min_words: minWords, + site_id: showNotAddedOnly ? activeSite.id : undefined, + available_only: showNotAddedOnly, }); setCountryOptions(options.countries || []); @@ -310,7 +338,7 @@ export default function IndustriesSectorsKeywords() { } catch (error) { console.error('Failed to load filter options:', error); } - }, [activeSite?.industry, activeSector?.industry_sector, countryFilter, difficultyFilter, volumeMin, volumeMax, searchTerm]); + }, [activeSite?.industry, activeSite?.id, activeSector?.industry_sector, countryFilter, difficultyFilter, volumeMin, volumeMax, searchTerm, minWords, showNotAddedOnly]); useEffect(() => { loadFilterOptions(); @@ -382,9 +410,17 @@ export default function IndustriesSectorsKeywords() { page: currentPage, }; - // Add sector filter if active sector is selected + // Add sector filter - if active sector selected, use its ID; otherwise use all site's sector IDs if (activeSector && activeSector.industry_sector) { filters.sector = activeSector.industry_sector; + } else { + // Filter by all of the site's sector IDs + const siteSectorIds = sectors + .filter(s => s.industry_sector) + .map(s => s.industry_sector); + if (siteSectorIds.length > 0) { + filters.sector_ids = siteSectorIds.join(','); + } } if (searchTerm) filters.search = searchTerm; @@ -421,6 +457,11 @@ export default function IndustriesSectorsKeywords() { } } + // Apply word count filter (for long-tail keywords) + if (minWords !== undefined) { + filters.min_words = minWords; + } + // Add sorting to API request if (sortBy && sortDirection) { const sortPrefix = sortDirection === 'desc' ? '-' : ''; @@ -474,7 +515,7 @@ export default function IndustriesSectorsKeywords() { setAvailableCount(0); setShowContent(true); } - }, [activeSite, activeSector, currentPage, pageSize, searchTerm, countryFilter, volumeMin, volumeMax, difficultyFilter, showNotAddedOnly, sortBy, sortDirection, toast]); + }, [activeSite, activeSector, sectors, currentPage, pageSize, searchTerm, countryFilter, volumeMin, volumeMax, difficultyFilter, showNotAddedOnly, minWords, sortBy, sortDirection, toast]); const getAddedStatStorageKey = useCallback(() => { if (!activeSite?.id) return null; @@ -952,9 +993,37 @@ export default function IndustriesSectorsKeywords() { return `${statType}:${count}`; }, []); + // Check if a bulk add action should show as "Added" + // This checks actual available counts from backend stats const isStatActionAdded = useCallback((statType: StatType, count: number) => { - return addedStatActions.has(buildStatActionKey(statType, count)); - }, [addedStatActions, buildStatActionKey]); + // First check if it was added in this session (localStorage) - for immediate feedback + if (addedStatActions.has(buildStatActionKey(statType, count))) { + return true; + } + + // Check actual available counts from backend stats + // This ensures buttons reflect true state after page reload + if (sectorStats) { + const statData = sectorStats[statType]; + if (!statData) return false; + + // Get available count - use 'available' field if present, otherwise fallback to count + const availableCount = statData.available !== undefined ? statData.available : statData.count; + + // If no keywords available for this stat type, show as Added + if (availableCount === 0) { + return true; + } + + // If requesting more than available, mark as added + // (e.g., clicking "+50" when only 30 are available) + if (count > availableCount) { + return true; + } + } + + return false; + }, [addedStatActions, buildStatActionKey, sectorStats]); const fetchBulkKeywords = useCallback(async (options: { ordering: string; @@ -1026,6 +1095,7 @@ export default function IndustriesSectorsKeywords() { setDifficultyFilter(''); setVolumeMin(''); setVolumeMax(''); + setMinWords(undefined); setSelectedIds([]); return; } @@ -1047,12 +1117,14 @@ export default function IndustriesSectorsKeywords() { setDifficultyFilter(''); setVolumeMin(''); setVolumeMax(''); + setMinWords(undefined); break; case 'high_volume': setShowNotAddedOnly(false); setDifficultyFilter(''); setVolumeMin(String(statThresholds.highVolume)); setVolumeMax(''); + setMinWords(undefined); setSortBy('volume'); setSortDirection('desc'); break; @@ -1061,6 +1133,7 @@ export default function IndustriesSectorsKeywords() { setDifficultyFilter(''); setVolumeMin(String(statThresholds.premium)); setVolumeMax(''); + setMinWords(undefined); setSortBy('volume'); setSortDirection('desc'); break; @@ -1069,6 +1142,7 @@ export default function IndustriesSectorsKeywords() { setDifficultyFilter(''); setVolumeMin(String(statThresholds.longTail)); setVolumeMax(''); + setMinWords(4); // Long-tail = 4+ words setSortBy('keyword'); setSortDirection('asc'); break; @@ -1077,6 +1151,7 @@ export default function IndustriesSectorsKeywords() { setDifficultyFilter('1'); setVolumeMin(String(statThresholds.quickWins)); setVolumeMax(''); + setMinWords(undefined); setSortBy('difficulty'); setSortDirection('asc'); break; @@ -1085,6 +1160,7 @@ export default function IndustriesSectorsKeywords() { setDifficultyFilter(''); setVolumeMin(''); setVolumeMax(''); + setMinWords(undefined); } }, [activeStatFilter, sectorStats]); @@ -1362,6 +1438,7 @@ export default function IndustriesSectorsKeywords() { setVolumeMax(''); setDifficultyFilter(''); setShowNotAddedOnly(false); + setMinWords(undefined); setActiveStatFilter(null); setCurrentPage(1); setSelectedIds([]); diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 75bb24b6..5fdaf187 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -2274,6 +2274,7 @@ export interface SeedKeywordResponse { export async function fetchKeywordsLibrary(filters?: { industry?: number; sector?: number; + sector_ids?: string; // Comma-separated list of sector IDs country?: string; search?: string; page?: number; @@ -2283,6 +2284,9 @@ export async function fetchKeywordsLibrary(filters?: { difficulty_max?: number; volume_min?: number; volume_max?: number; + min_words?: number; + site_id?: number; + available_only?: boolean; }): Promise { const params = new URLSearchParams(); // Use industry_id and sector_id as per backend get_queryset, but also try industry/sector for filterset_fields @@ -2294,6 +2298,8 @@ export async function fetchKeywordsLibrary(filters?: { params.append('sector', filters.sector.toString()); params.append('sector_id', filters.sector.toString()); // Also send sector_id for get_queryset } + // Multiple sector IDs (for site-specific filtering) + if (filters?.sector_ids) params.append('sector_ids', filters.sector_ids); if (filters?.country) params.append('country', filters.country); if (filters?.search) params.append('search', filters.search); if (filters?.page) params.append('page', filters.page.toString()); @@ -2306,6 +2312,11 @@ export async function fetchKeywordsLibrary(filters?: { // Volume range filtering 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()); + // Word count filtering (for long-tail keywords) + if (filters?.min_words !== undefined) params.append('min_words', filters.min_words.toString()); + // Availability filtering + if (filters?.site_id !== undefined) params.append('site_id', filters.site_id.toString()); + if (filters?.available_only) params.append('available_only', 'true'); const queryString = params.toString(); return fetchAPI(`/v1/auth/keywords-library/${queryString ? `?${queryString}` : ''}`); @@ -2347,6 +2358,7 @@ export async function fetchKeywordStats(): Promise { */ export interface SectorStatResult { count: number; + available?: number; // Number of keywords not yet added (for checking "Added" state) threshold?: number; } @@ -2376,11 +2388,15 @@ export interface SectorStatsResponse { export async function fetchSectorStats(filters: { industry_id: number; sector_id?: number; + sector_ids?: number[]; // For filtering by site's sectors site_id?: number; }): Promise { const params = new URLSearchParams(); params.append('industry_id', filters.industry_id.toString()); if (filters.sector_id) params.append('sector_id', filters.sector_id.toString()); + if (filters.sector_ids && filters.sector_ids.length > 0) { + params.append('sector_ids', filters.sector_ids.join(',')); + } if (filters.site_id) params.append('site_id', filters.site_id.toString()); return fetchAPI(`/v1/auth/keywords-library/sector_stats/?${params.toString()}`); @@ -2429,6 +2445,9 @@ export interface KeywordsLibraryFilterOptionsRequest { volume_min?: number; volume_max?: number; search?: string; + min_words?: number; + site_id?: number; + available_only?: boolean; } export async function fetchKeywordsLibraryFilterOptions( @@ -2443,6 +2462,9 @@ export async function fetchKeywordsLibraryFilterOptions( 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); + if (filters?.min_words !== undefined) params.append('min_words', filters.min_words.toString()); + if (filters?.site_id) params.append('site_id', filters.site_id.toString()); + if (filters?.available_only) params.append('available_only', 'true'); const queryString = params.toString(); return fetchAPI(`/v1/auth/keywords-library/filter_options/${queryString ? `?${queryString}` : ''}`);