COMPLETED KEYWORDS-LIBRARY-REDESIGN-PLAN.md
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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' : ''
|
||||
)}
|
||||
>
|
||||
<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>
|
||||
<div className="flex items-center gap-2">
|
||||
<h4 className="text-base font-semibold text-gray-900 dark:text-white">
|
||||
{sector.name}
|
||||
</h4>
|
||||
{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>
|
||||
<h4 className="text-lg font-bold text-gray-900 dark:text-white">
|
||||
{sector.name}
|
||||
</h4>
|
||||
{!sector.industry_sector && (
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">Not linked</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<div className="text-xs 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="mt-4 grid grid-cols-4 gap-2">
|
||||
<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-sm font-semibold text-success-600 dark:text-success-400">Available</div>
|
||||
<div className="text-base font-bold text-gray-900 dark:text-white">
|
||||
{available.toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs font-semibold text-brand-600 dark:text-brand-400">In Workflow</div>
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
<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-sm font-semibold text-brand-600 dark:text-brand-400">Added</div>
|
||||
<div className="text-base font-bold text-gray-900 dark:text-white">
|
||||
{inWorkflow.toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs font-semibold text-warning-600 dark:text-warning-400">> 10K</div>
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
<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-sm font-semibold text-warning-600 dark:text-warning-400">> 10K</div>
|
||||
<div className="text-base font-bold text-gray-900 dark:text-white">
|
||||
{over10k.toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs 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="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-sm font-semibold text-purple-600 dark:text-purple-400">5K - 10K</div>
|
||||
<div className="text-base font-bold text-gray-900 dark:text-white">
|
||||
{midVolume.toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -223,18 +223,13 @@ export default function SectorMetricCard({
|
||||
<div className={clsx('p-2 rounded-lg', config.badgeColor, config.textColor)}>
|
||||
{config.icon}
|
||||
</div>
|
||||
<span className={clsx('text-base font-semibold', config.textColor)}>
|
||||
<h4 className={clsx('text-base font-semibold m-0', config.textColor)}>
|
||||
{config.label}
|
||||
</span>
|
||||
</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>
|
||||
</h4>
|
||||
</div>
|
||||
<h4 className={clsx('font-bold tabular-nums m-0', compact ? 'text-2xl' : 'text-3xl', config.textColor)}>
|
||||
{formatCount(statData.count)}
|
||||
</h4>
|
||||
</div>
|
||||
|
||||
{/* Description / Threshold */}
|
||||
@@ -245,28 +240,32 @@ export default function SectorMetricCard({
|
||||
{/* Bulk Add Buttons - Always visible */}
|
||||
{onBulkAdd && statData.count > 0 && statType !== 'total' && (
|
||||
<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">
|
||||
<PlusIcon className="w-3 h-3" />
|
||||
ADD
|
||||
</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) => {
|
||||
const isAdded = isAddedAction ? isAddedAction(statType, count) : false;
|
||||
return (
|
||||
<Button
|
||||
<span
|
||||
key={count}
|
||||
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}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{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>
|
||||
|
||||
@@ -72,24 +72,24 @@ export default function SmartSuggestions({
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<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',
|
||||
hasContent && 'animate-pulse'
|
||||
)}>
|
||||
<ShootingStarIcon className="w-4 h-4 text-white" />
|
||||
<ShootingStarIcon className="w-5 h-5 text-white" />
|
||||
</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
|
||||
</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!
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<ChevronDownIcon
|
||||
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'
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -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<number | undefined>(undefined);
|
||||
// Dynamic filter options (cascading)
|
||||
const [countryOptions, setCountryOptions] = useState<FilterOption[] | undefined>(undefined);
|
||||
const [difficultyOptions, setDifficultyOptions] = useState<FilterOption[] | undefined>(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([]);
|
||||
|
||||
@@ -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<SeedKeywordResponse> {
|
||||
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<KeywordStats> {
|
||||
*/
|
||||
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<SectorStatsResponse> {
|
||||
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}` : ''}`);
|
||||
|
||||
Reference in New Issue
Block a user