COMPLETED KEYWORDS-LIBRARY-REDESIGN-PLAN.md

This commit is contained in:
IGNY8 VPS (Salman)
2026-01-18 22:05:38 +00:00
parent 05bc433c80
commit 328098a48c
6 changed files with 264 additions and 93 deletions

View File

@@ -864,6 +864,7 @@ class SeedKeywordViewSet(viewsets.ReadOnlyModelViewSet):
industry_id = self.request.query_params.get('industry_id') industry_id = self.request.query_params.get('industry_id')
industry_name = self.request.query_params.get('industry_name') industry_name = self.request.query_params.get('industry_name')
sector_id = self.request.query_params.get('sector_id') 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') sector_name = self.request.query_params.get('sector_name')
difficulty_min = self.request.query_params.get('difficulty_min') difficulty_min = self.request.query_params.get('difficulty_min')
difficulty_max = self.request.query_params.get('difficulty_max') 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') volume_max = self.request.query_params.get('volume_max')
site_id = self.request.query_params.get('site_id') site_id = self.request.query_params.get('site_id')
available_only = self.request.query_params.get('available_only') available_only = self.request.query_params.get('available_only')
min_words = self.request.query_params.get('min_words')
if industry_id: if industry_id:
queryset = queryset.filter(industry_id=industry_id) queryset = queryset.filter(industry_id=industry_id)
if industry_name: if industry_name:
queryset = queryset.filter(industry__name__icontains=industry_name) queryset = queryset.filter(industry__name__icontains=industry_name)
# Support single sector_id OR multiple sector_ids (comma-separated)
if sector_id: if sector_id:
queryset = queryset.filter(sector_id=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: if sector_name:
queryset = queryset.filter(sector__name__icontains=sector_name) queryset = queryset.filter(sector__name__icontains=sector_name)
@@ -905,6 +917,20 @@ class SeedKeywordViewSet(viewsets.ReadOnlyModelViewSet):
except (ValueError, TypeError): except (ValueError, TypeError):
pass 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 # Availability filter - exclude keywords already added to the site
if available_only and str(available_only).lower() in ['true', '1', 'yes']: if available_only and str(available_only).lower() in ['true', '1', 'yes']:
if site_id: if site_id:
@@ -1106,6 +1132,8 @@ class SeedKeywordViewSet(viewsets.ReadOnlyModelViewSet):
- premium_traffic: Volume >= 50K with fallbacks (50K -> 25K -> 10K) - premium_traffic: Volume >= 50K with fallbacks (50K -> 25K -> 10K)
- long_tail: 4+ words with Volume > threshold (1K -> 500 -> 200) - long_tail: 4+ words with Volume > threshold (1K -> 500 -> 200)
- quick_wins: Difficulty <= 20, Volume > threshold, AND available - 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 import Count, Sum, Q, F
from django.db.models.functions import Length from django.db.models.functions import Length
@@ -1114,6 +1142,7 @@ class SeedKeywordViewSet(viewsets.ReadOnlyModelViewSet):
# Get filters # Get filters
industry_id = request.query_params.get('industry_id') industry_id = request.query_params.get('industry_id')
sector_id = request.query_params.get('sector_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') site_id = request.query_params.get('site_id')
if not industry_id: if not industry_id:
@@ -1149,15 +1178,16 @@ class SeedKeywordViewSet(viewsets.ReadOnlyModelViewSet):
return qs.count() return qs.count()
return qs.exclude(id__in=already_added_ids).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'): def get_count_with_fallback(qs, thresholds, volume_field='volume'):
"""Try thresholds in order, return first with results.""" """Try thresholds in order, return first with results."""
for threshold in thresholds: for threshold in thresholds:
filtered = qs.filter(**{f'{volume_field}__gte': threshold}) filtered = qs.filter(**{f'{volume_field}__gte': threshold})
count = filtered.count() total_count = filtered.count()
if count > 0: if total_count > 0:
return {'count': count, 'threshold': threshold} available = count_available(filtered)
return {'count': 0, 'threshold': thresholds[-1]} return {'count': total_count, 'available': available, 'threshold': threshold}
return {'count': 0, 'available': 0, 'threshold': thresholds[-1]}
# 1. Total keywords # 1. Total keywords
total_count = base_qs.count() total_count = base_qs.count()
@@ -1166,10 +1196,14 @@ class SeedKeywordViewSet(viewsets.ReadOnlyModelViewSet):
available_count = count_available(base_qs) available_count = count_available(base_qs)
# 3. High Volume (>= 10K) - simple threshold # 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) # 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) # 4. Premium Traffic with dynamic fallback (50K -> 25K -> 10K)
premium_thresholds = [50000, 25000, 10000] premium_thresholds = [50000, 25000, 10000]
@@ -1199,8 +1233,8 @@ class SeedKeywordViewSet(viewsets.ReadOnlyModelViewSet):
'stats': { 'stats': {
'total': {'count': total_count}, 'total': {'count': total_count},
'available': {'count': available_count}, 'available': {'count': available_count},
'high_volume': {'count': high_volume_count, 'threshold': 10000}, 'high_volume': {'count': high_volume_count, 'available': high_volume_available, 'threshold': 10000},
'mid_volume': {'count': mid_volume_count, 'threshold': 5000}, 'mid_volume': {'count': mid_volume_count, 'available': mid_volume_available, 'threshold': 5000},
'premium_traffic': premium_result, 'premium_traffic': premium_result,
'long_tail': long_tail_result, 'long_tail': long_tail_result,
'quick_wins': quick_wins_result, 'quick_wins': quick_wins_result,
@@ -1208,7 +1242,16 @@ class SeedKeywordViewSet(viewsets.ReadOnlyModelViewSet):
} }
else: else:
# Get stats per sector in the industry # 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) 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 = [] sectors_data = []
for sector in sectors: for sector in sectors:
@@ -1219,8 +1262,17 @@ class SeedKeywordViewSet(viewsets.ReadOnlyModelViewSet):
continue continue
sector_available = count_available(sector_qs) 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_premium = get_count_with_fallback(sector_qs, premium_thresholds)
sector_long_tail_base = sector_qs.filter(keyword__regex=r'^(\S+\s+){3,}\S+$') sector_long_tail_base = sector_qs.filter(keyword__regex=r'^(\S+\s+){3,}\S+$')
@@ -1237,8 +1289,8 @@ class SeedKeywordViewSet(viewsets.ReadOnlyModelViewSet):
'stats': { 'stats': {
'total': {'count': sector_total}, 'total': {'count': sector_total},
'available': {'count': sector_available}, 'available': {'count': sector_available},
'high_volume': {'count': sector_high_volume, 'threshold': 10000}, 'high_volume': {'count': sector_high_volume, 'available': sector_high_volume_available, 'threshold': 10000},
'mid_volume': {'count': sector_mid_volume, 'threshold': 5000}, 'mid_volume': {'count': sector_mid_volume, 'available': sector_mid_volume_available, 'threshold': 5000},
'premium_traffic': sector_premium, 'premium_traffic': sector_premium,
'long_tail': sector_long_tail, 'long_tail': sector_long_tail,
'quick_wins': sector_quick_wins, 'quick_wins': sector_quick_wins,
@@ -1266,7 +1318,8 @@ class SeedKeywordViewSet(viewsets.ReadOnlyModelViewSet):
Returns industries, sectors (filtered by industry), and available filter values. Returns industries, sectors (filtered by industry), and available filter values.
Supports cascading options based on current filters. 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: try:
industry_id = request.query_params.get('industry_id') 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_min = request.query_params.get('volume_min')
volume_max = request.query_params.get('volume_max') volume_max = request.query_params.get('volume_max')
search_term = request.query_params.get('search') 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 # Get industries with keyword counts
industries = Industry.objects.annotate( industries = Industry.objects.annotate(
@@ -1312,6 +1368,32 @@ class SeedKeywordViewSet(viewsets.ReadOnlyModelViewSet):
base_qs = base_qs.filter(industry_id=industry_id) base_qs = base_qs.filter(industry_id=industry_id)
if sector_id: if sector_id:
base_qs = base_qs.filter(sector_id=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 options - apply all filters except country itself
countries_qs = base_qs countries_qs = base_qs

View File

@@ -71,62 +71,53 @@ export default function SectorCardsGrid({
key={sector.id} key={sector.id}
onClick={() => onSelectSector(sector)} onClick={() => onSelectSector(sector)}
className={clsx( className={clsx(
'keywords-library-sector-card', 'keywords-library-sector-card relative',
isActive ? 'is-active' : '' isActive ? 'is-active' : ''
)} )}
> >
<div className="sector-card-accent" /> <div className="sector-card-accent" />
{/* Active indicator - top right corner */}
{isActive && (
<div className="absolute top-2 right-2 flex items-center gap-2">
<Badge color="success" size="md" variant="light" className="flex items-center gap-1.5">
<span className="w-2.5 h-2.5 rounded-full bg-success-500 animate-pulse" />
Active
</Badge>
</div>
)}
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-3">
<div> <h4 className="text-lg font-bold text-gray-900 dark:text-white">
<div className="flex items-center gap-2"> {sector.name}
<h4 className="text-base font-semibold text-gray-900 dark:text-white"> </h4>
{sector.name} {!sector.industry_sector && (
</h4> <span className="text-xs text-gray-500 dark:text-gray-400">Not linked</span>
{isActive && <span className="sector-card-active-dot" />} )}
</div>
{sector.industry_sector ? (
<span className="text-xs text-gray-500 dark:text-gray-400">Sector keywords</span>
) : (
<span className="text-xs text-gray-500 dark:text-gray-400">Not linked to template</span>
)}
{isActive && (
<div className="mt-2">
<Badge color="info" size="sm" variant="light">
Active
</Badge>
</div>
)}
</div>
<div className="text-right">
<div className="text-xs text-gray-500 dark:text-gray-400">Total</div>
<div className="text-2xl font-semibold text-gray-900 dark:text-white">
{total.toLocaleString()}
</div>
</div>
</div> </div>
<div className="mt-4 grid grid-cols-2 gap-3"> <div className="mt-4 grid grid-cols-4 gap-2">
<div> <div className="flex flex-col items-center justify-center p-2 rounded-lg border border-success-200 dark:border-success-800 bg-success-50/50 dark:bg-success-900/10">
<div className="text-xs font-semibold text-success-600 dark:text-success-400">Available</div> <div className="text-sm font-semibold text-success-600 dark:text-success-400">Available</div>
<div className="text-sm font-medium text-gray-900 dark:text-white"> <div className="text-base font-bold text-gray-900 dark:text-white">
{available.toLocaleString()} {available.toLocaleString()}
</div> </div>
</div> </div>
<div> <div className="flex flex-col items-center justify-center p-2 rounded-lg border border-brand-200 dark:border-brand-800 bg-brand-50/50 dark:bg-brand-900/10">
<div className="text-xs font-semibold text-brand-600 dark:text-brand-400">In Workflow</div> <div className="text-sm font-semibold text-brand-600 dark:text-brand-400">Added</div>
<div className="text-sm font-medium text-gray-900 dark:text-white"> <div className="text-base font-bold text-gray-900 dark:text-white">
{inWorkflow.toLocaleString()} {inWorkflow.toLocaleString()}
</div> </div>
</div> </div>
<div> <div className="flex flex-col items-center justify-center p-2 rounded-lg border border-warning-200 dark:border-warning-800 bg-warning-50/50 dark:bg-warning-900/10">
<div className="text-xs font-semibold text-warning-600 dark:text-warning-400">&gt; 10K</div> <div className="text-sm font-semibold text-warning-600 dark:text-warning-400">&gt; 10K</div>
<div className="text-sm font-medium text-gray-900 dark:text-white"> <div className="text-base font-bold text-gray-900 dark:text-white">
{over10k.toLocaleString()} {over10k.toLocaleString()}
</div> </div>
</div> </div>
<div> <div className="flex flex-col items-center justify-center p-2 rounded-lg border border-purple-200 dark:border-purple-800 bg-purple-50/50 dark:bg-purple-900/10">
<div className="text-xs font-semibold text-purple-600 dark:text-purple-400">5K - 10K</div> <div className="text-sm font-semibold text-purple-600 dark:text-purple-400">5K - 10K</div>
<div className="text-sm font-medium text-gray-900 dark:text-white"> <div className="text-base font-bold text-gray-900 dark:text-white">
{midVolume.toLocaleString()} {midVolume.toLocaleString()}
</div> </div>
</div> </div>

View File

@@ -223,18 +223,13 @@ export default function SectorMetricCard({
<div className={clsx('p-2 rounded-lg', config.badgeColor, config.textColor)}> <div className={clsx('p-2 rounded-lg', config.badgeColor, config.textColor)}>
{config.icon} {config.icon}
</div> </div>
<span className={clsx('text-base font-semibold', config.textColor)}> <h4 className={clsx('text-base font-semibold m-0', config.textColor)}>
{config.label} {config.label}
</span> </h4>
</div>
<div className="flex items-center gap-2">
{isActive && (
<div className={clsx('w-2 h-2 rounded-full animate-pulse', config.dotColor)} />
)}
<div className={clsx('font-bold tabular-nums', compact ? 'text-2xl' : 'text-3xl', config.textColor)}>
{formatCount(statData.count)}
</div>
</div> </div>
<h4 className={clsx('font-bold tabular-nums m-0', compact ? 'text-2xl' : 'text-3xl', config.textColor)}>
{formatCount(statData.count)}
</h4>
</div> </div>
{/* Description / Threshold */} {/* Description / Threshold */}
@@ -245,28 +240,32 @@ export default function SectorMetricCard({
{/* Bulk Add Buttons - Always visible */} {/* Bulk Add Buttons - Always visible */}
{onBulkAdd && statData.count > 0 && statType !== 'total' && ( {onBulkAdd && statData.count > 0 && statType !== 'total' && (
<div className="mt-3 pt-3 border-t border-gray-100 dark:border-gray-800"> <div className="mt-3 pt-3 border-t border-gray-100 dark:border-gray-800">
<div className="flex flex-wrap items-center gap-2 text-xs"> <div className="flex flex-wrap items-center gap-1 text-xs">
<span className="inline-flex items-center gap-1 rounded-full bg-success-100 text-success-700 dark:bg-success-900/40 dark:text-success-300 px-2 py-0.5"> <span className="inline-flex items-center gap-1 rounded-full bg-success-100 text-success-700 dark:bg-success-900/40 dark:text-success-300 px-2 py-0.5">
<PlusIcon className="w-3 h-3" /> <PlusIcon className="w-3 h-3" />
ADD ADD
</span> </span>
<div className="ml-auto flex flex-wrap items-center gap-2 justify-end"> <div className="ml-auto flex flex-wrap items-center gap-1 justify-end">
{bulkAddOptions.map((count) => { {bulkAddOptions.map((count) => {
const isAdded = isAddedAction ? isAddedAction(statType, count) : false; const isAdded = isAddedAction ? isAddedAction(statType, count) : false;
return ( return (
<Button <span
key={count} key={count}
size="xs" onClick={(e) => e.stopPropagation()}
variant={isAdded ? 'outline' : 'primary'}
tone={isAdded ? 'success' : 'brand'}
startIcon={
isAdded ? <CheckCircleIcon className="w-3 h-3" /> : <PlusIcon className="w-3 h-3" />
}
onClick={() => handleAddClick(count)}
disabled={isAdded}
> >
{isAdded ? 'Added' : (count === statData.count ? 'All' : count)} <Button
</Button> size="xs"
variant={isAdded ? 'outline' : 'primary'}
tone={isAdded ? 'success' : 'brand'}
startIcon={
isAdded ? <CheckCircleIcon className="w-3 h-3" /> : <PlusIcon className="w-3 h-3" />
}
onClick={() => handleAddClick(count)}
disabled={isAdded}
>
{isAdded ? 'Added' : (count === statData.count ? 'All' : count)}
</Button>
</span>
); );
})} })}
</div> </div>

View File

@@ -72,24 +72,24 @@ export default function SmartSuggestions({
> >
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className={clsx( <div className={clsx(
'w-8 h-8 rounded-lg flex items-center justify-center', 'w-10 h-10 rounded-lg flex items-center justify-center',
'bg-gradient-to-br from-brand-500 to-purple-500', 'bg-gradient-to-br from-brand-500 to-purple-500',
hasContent && 'animate-pulse' hasContent && 'animate-pulse'
)}> )}>
<ShootingStarIcon className="w-4 h-4 text-white" /> <ShootingStarIcon className="w-5 h-5 text-white" />
</div> </div>
<div> <div>
<h3 className="font-semibold text-gray-900 dark:text-white text-base flex items-center gap-2"> <h3 className="font-bold text-gray-900 dark:text-white text-lg flex items-center gap-2">
Smart Suggestions Smart Suggestions
</h3> </h3>
<p className="text-xs text-gray-500 dark:text-gray-400"> <p className="text-sm text-gray-500 dark:text-gray-400">
Ready-to-use keywords waiting for you! Ready-to-use keywords waiting for you!
</p> </p>
</div> </div>
</div> </div>
<ChevronDownIcon <ChevronDownIcon
className={clsx( className={clsx(
'w-5 h-5 text-gray-400 transition-transform duration-200', 'w-6 h-6 text-gray-400 transition-transform duration-200',
isExpanded && 'rotate-180' isExpanded && 'rotate-180'
)} )}
/> />

View File

@@ -95,7 +95,7 @@ export default function IndustriesSectorsKeywords() {
const [showNotAddedOnly, setShowNotAddedOnly] = useState(false); const [showNotAddedOnly, setShowNotAddedOnly] = useState(false);
const [volumeMin, setVolumeMin] = useState(''); const [volumeMin, setVolumeMin] = useState('');
const [volumeMax, setVolumeMax] = useState(''); const [volumeMax, setVolumeMax] = useState('');
const [minWords, setMinWords] = useState<number | undefined>(undefined);
// Dynamic filter options (cascading) // Dynamic filter options (cascading)
const [countryOptions, setCountryOptions] = useState<FilterOption[] | undefined>(undefined); const [countryOptions, setCountryOptions] = useState<FilterOption[] | undefined>(undefined);
const [difficultyOptions, setDifficultyOptions] = useState<FilterOption[] | undefined>(undefined); const [difficultyOptions, setDifficultyOptions] = useState<FilterOption[] | undefined>(undefined);
@@ -199,9 +199,15 @@ export default function IndustriesSectorsKeywords() {
setLoadingSectorStats(true); setLoadingSectorStats(true);
try { 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({ const response = await fetchSectorStats({
industry_id: activeSite.industry, industry_id: activeSite.industry,
site_id: activeSite.id, site_id: activeSite.id,
sector_ids: siteSectorIds.length > 0 ? siteSectorIds : undefined,
}); });
const allSectors = response.sectors || []; const allSectors = response.sectors || [];
@@ -219,19 +225,36 @@ export default function IndustriesSectorsKeywords() {
const aggregated: SectorStats = { const aggregated: SectorStats = {
total: { count: 0 }, total: { count: 0 },
available: { count: 0 }, available: { count: 0 },
high_volume: { count: 0, threshold: 10000 }, high_volume: { count: 0, available: 0, threshold: 10000 },
premium_traffic: { count: 0, threshold: 50000 }, mid_volume: { count: 0, available: 0, threshold: 5000 },
long_tail: { count: 0, threshold: 1000 }, premium_traffic: { count: 0, available: 0, threshold: 50000 },
quick_wins: { count: 0, threshold: 1000 }, long_tail: { count: 0, available: 0, threshold: 1000 },
quick_wins: { count: 0, available: 0, threshold: 1000 },
}; };
allSectors.forEach((sector) => { allSectors.forEach((sector) => {
aggregated.total.count += sector.stats.total.count; aggregated.total.count += sector.stats.total.count;
aggregated.available.count += sector.stats.available.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.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.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.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.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) { if (!aggregated.premium_traffic.threshold) {
aggregated.premium_traffic.threshold = sector.stats.premium_traffic.threshold; aggregated.premium_traffic.threshold = sector.stats.premium_traffic.threshold;
} }
@@ -252,7 +275,7 @@ export default function IndustriesSectorsKeywords() {
} finally { } finally {
setLoadingSectorStats(false); setLoadingSectorStats(false);
} }
}, [activeSite, activeSector]); }, [activeSite, activeSector, sectors]);
// Load sector stats when site/sector changes // Load sector stats when site/sector changes
useEffect(() => { useEffect(() => {
@@ -268,6 +291,7 @@ export default function IndustriesSectorsKeywords() {
setCountryFilter(''); setCountryFilter('');
setDifficultyFilter(''); setDifficultyFilter('');
setShowNotAddedOnly(false); setShowNotAddedOnly(false);
setMinWords(undefined);
setCurrentPage(1); setCurrentPage(1);
setSelectedIds([]); setSelectedIds([]);
setAddedStatActions(new Set()); setAddedStatActions(new Set());
@@ -276,6 +300,7 @@ export default function IndustriesSectorsKeywords() {
// Reset pagination/selection when sector changes // Reset pagination/selection when sector changes
useEffect(() => { useEffect(() => {
setActiveStatFilter(null); setActiveStatFilter(null);
setMinWords(undefined);
setCurrentPage(1); setCurrentPage(1);
setSelectedIds([]); setSelectedIds([]);
setAddedStatActions(new Set()); setAddedStatActions(new Set());
@@ -298,6 +323,9 @@ export default function IndustriesSectorsKeywords() {
volume_min: volumeMin ? Number(volumeMin) : undefined, volume_min: volumeMin ? Number(volumeMin) : undefined,
volume_max: volumeMax ? Number(volumeMax) : undefined, volume_max: volumeMax ? Number(volumeMax) : undefined,
search: searchTerm || undefined, search: searchTerm || undefined,
min_words: minWords,
site_id: showNotAddedOnly ? activeSite.id : undefined,
available_only: showNotAddedOnly,
}); });
setCountryOptions(options.countries || []); setCountryOptions(options.countries || []);
@@ -310,7 +338,7 @@ export default function IndustriesSectorsKeywords() {
} catch (error) { } catch (error) {
console.error('Failed to load filter options:', 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(() => { useEffect(() => {
loadFilterOptions(); loadFilterOptions();
@@ -382,9 +410,17 @@ export default function IndustriesSectorsKeywords() {
page: currentPage, 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) { if (activeSector && activeSector.industry_sector) {
filters.sector = 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; 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 // Add sorting to API request
if (sortBy && sortDirection) { if (sortBy && sortDirection) {
const sortPrefix = sortDirection === 'desc' ? '-' : ''; const sortPrefix = sortDirection === 'desc' ? '-' : '';
@@ -474,7 +515,7 @@ export default function IndustriesSectorsKeywords() {
setAvailableCount(0); setAvailableCount(0);
setShowContent(true); 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(() => { const getAddedStatStorageKey = useCallback(() => {
if (!activeSite?.id) return null; if (!activeSite?.id) return null;
@@ -952,9 +993,37 @@ export default function IndustriesSectorsKeywords() {
return `${statType}:${count}`; 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) => { const isStatActionAdded = useCallback((statType: StatType, count: number) => {
return addedStatActions.has(buildStatActionKey(statType, count)); // First check if it was added in this session (localStorage) - for immediate feedback
}, [addedStatActions, buildStatActionKey]); 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: { const fetchBulkKeywords = useCallback(async (options: {
ordering: string; ordering: string;
@@ -1026,6 +1095,7 @@ export default function IndustriesSectorsKeywords() {
setDifficultyFilter(''); setDifficultyFilter('');
setVolumeMin(''); setVolumeMin('');
setVolumeMax(''); setVolumeMax('');
setMinWords(undefined);
setSelectedIds([]); setSelectedIds([]);
return; return;
} }
@@ -1047,12 +1117,14 @@ export default function IndustriesSectorsKeywords() {
setDifficultyFilter(''); setDifficultyFilter('');
setVolumeMin(''); setVolumeMin('');
setVolumeMax(''); setVolumeMax('');
setMinWords(undefined);
break; break;
case 'high_volume': case 'high_volume':
setShowNotAddedOnly(false); setShowNotAddedOnly(false);
setDifficultyFilter(''); setDifficultyFilter('');
setVolumeMin(String(statThresholds.highVolume)); setVolumeMin(String(statThresholds.highVolume));
setVolumeMax(''); setVolumeMax('');
setMinWords(undefined);
setSortBy('volume'); setSortBy('volume');
setSortDirection('desc'); setSortDirection('desc');
break; break;
@@ -1061,6 +1133,7 @@ export default function IndustriesSectorsKeywords() {
setDifficultyFilter(''); setDifficultyFilter('');
setVolumeMin(String(statThresholds.premium)); setVolumeMin(String(statThresholds.premium));
setVolumeMax(''); setVolumeMax('');
setMinWords(undefined);
setSortBy('volume'); setSortBy('volume');
setSortDirection('desc'); setSortDirection('desc');
break; break;
@@ -1069,6 +1142,7 @@ export default function IndustriesSectorsKeywords() {
setDifficultyFilter(''); setDifficultyFilter('');
setVolumeMin(String(statThresholds.longTail)); setVolumeMin(String(statThresholds.longTail));
setVolumeMax(''); setVolumeMax('');
setMinWords(4); // Long-tail = 4+ words
setSortBy('keyword'); setSortBy('keyword');
setSortDirection('asc'); setSortDirection('asc');
break; break;
@@ -1077,6 +1151,7 @@ export default function IndustriesSectorsKeywords() {
setDifficultyFilter('1'); setDifficultyFilter('1');
setVolumeMin(String(statThresholds.quickWins)); setVolumeMin(String(statThresholds.quickWins));
setVolumeMax(''); setVolumeMax('');
setMinWords(undefined);
setSortBy('difficulty'); setSortBy('difficulty');
setSortDirection('asc'); setSortDirection('asc');
break; break;
@@ -1085,6 +1160,7 @@ export default function IndustriesSectorsKeywords() {
setDifficultyFilter(''); setDifficultyFilter('');
setVolumeMin(''); setVolumeMin('');
setVolumeMax(''); setVolumeMax('');
setMinWords(undefined);
} }
}, [activeStatFilter, sectorStats]); }, [activeStatFilter, sectorStats]);
@@ -1362,6 +1438,7 @@ export default function IndustriesSectorsKeywords() {
setVolumeMax(''); setVolumeMax('');
setDifficultyFilter(''); setDifficultyFilter('');
setShowNotAddedOnly(false); setShowNotAddedOnly(false);
setMinWords(undefined);
setActiveStatFilter(null); setActiveStatFilter(null);
setCurrentPage(1); setCurrentPage(1);
setSelectedIds([]); setSelectedIds([]);

View File

@@ -2274,6 +2274,7 @@ export interface SeedKeywordResponse {
export async function fetchKeywordsLibrary(filters?: { export async function fetchKeywordsLibrary(filters?: {
industry?: number; industry?: number;
sector?: number; sector?: number;
sector_ids?: string; // Comma-separated list of sector IDs
country?: string; country?: string;
search?: string; search?: string;
page?: number; page?: number;
@@ -2283,6 +2284,9 @@ export async function fetchKeywordsLibrary(filters?: {
difficulty_max?: number; difficulty_max?: number;
volume_min?: number; volume_min?: number;
volume_max?: number; volume_max?: number;
min_words?: number;
site_id?: number;
available_only?: boolean;
}): Promise<SeedKeywordResponse> { }): Promise<SeedKeywordResponse> {
const params = new URLSearchParams(); const params = new URLSearchParams();
// Use industry_id and sector_id as per backend get_queryset, but also try industry/sector for filterset_fields // 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', filters.sector.toString());
params.append('sector_id', filters.sector.toString()); // Also send sector_id for get_queryset 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?.country) params.append('country', filters.country);
if (filters?.search) params.append('search', filters.search); if (filters?.search) params.append('search', filters.search);
if (filters?.page) params.append('page', filters.page.toString()); if (filters?.page) params.append('page', filters.page.toString());
@@ -2306,6 +2312,11 @@ export async function fetchKeywordsLibrary(filters?: {
// Volume range filtering // Volume range filtering
if (filters?.volume_min !== undefined) params.append('volume_min', filters.volume_min.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?.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(); const queryString = params.toString();
return fetchAPI(`/v1/auth/keywords-library/${queryString ? `?${queryString}` : ''}`); return fetchAPI(`/v1/auth/keywords-library/${queryString ? `?${queryString}` : ''}`);
@@ -2347,6 +2358,7 @@ export async function fetchKeywordStats(): Promise<KeywordStats> {
*/ */
export interface SectorStatResult { export interface SectorStatResult {
count: number; count: number;
available?: number; // Number of keywords not yet added (for checking "Added" state)
threshold?: number; threshold?: number;
} }
@@ -2376,11 +2388,15 @@ export interface SectorStatsResponse {
export async function fetchSectorStats(filters: { export async function fetchSectorStats(filters: {
industry_id: number; industry_id: number;
sector_id?: number; sector_id?: number;
sector_ids?: number[]; // For filtering by site's sectors
site_id?: number; site_id?: number;
}): Promise<SectorStatsResponse> { }): Promise<SectorStatsResponse> {
const params = new URLSearchParams(); const params = new URLSearchParams();
params.append('industry_id', filters.industry_id.toString()); params.append('industry_id', filters.industry_id.toString());
if (filters.sector_id) params.append('sector_id', filters.sector_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()); if (filters.site_id) params.append('site_id', filters.site_id.toString());
return fetchAPI(`/v1/auth/keywords-library/sector_stats/?${params.toString()}`); return fetchAPI(`/v1/auth/keywords-library/sector_stats/?${params.toString()}`);
@@ -2429,6 +2445,9 @@ export interface KeywordsLibraryFilterOptionsRequest {
volume_min?: number; volume_min?: number;
volume_max?: number; volume_max?: number;
search?: string; search?: string;
min_words?: number;
site_id?: number;
available_only?: boolean;
} }
export async function fetchKeywordsLibraryFilterOptions( 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_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?.volume_max !== undefined) params.append('volume_max', filters.volume_max.toString());
if (filters?.search) params.append('search', filters.search); 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(); const queryString = params.toString();
return fetchAPI(`/v1/auth/keywords-library/filter_options/${queryString ? `?${queryString}` : ''}`); return fetchAPI(`/v1/auth/keywords-library/filter_options/${queryString ? `?${queryString}` : ''}`);