keywrods library fixes
This commit is contained in:
@@ -869,6 +869,8 @@ class SeedKeywordViewSet(viewsets.ReadOnlyModelViewSet):
|
|||||||
difficulty_max = self.request.query_params.get('difficulty_max')
|
difficulty_max = self.request.query_params.get('difficulty_max')
|
||||||
volume_min = self.request.query_params.get('volume_min')
|
volume_min = self.request.query_params.get('volume_min')
|
||||||
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')
|
||||||
|
available_only = self.request.query_params.get('available_only')
|
||||||
|
|
||||||
if industry_id:
|
if industry_id:
|
||||||
queryset = queryset.filter(industry_id=industry_id)
|
queryset = queryset.filter(industry_id=industry_id)
|
||||||
@@ -902,6 +904,19 @@ class SeedKeywordViewSet(viewsets.ReadOnlyModelViewSet):
|
|||||||
queryset = queryset.filter(volume__lte=int(volume_max))
|
queryset = queryset.filter(volume__lte=int(volume_max))
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
# Availability filter - exclude keywords already added to the site
|
||||||
|
if available_only and str(available_only).lower() in ['true', '1', 'yes']:
|
||||||
|
if site_id:
|
||||||
|
try:
|
||||||
|
from igny8_core.business.planning.models import Keywords
|
||||||
|
attached_ids = Keywords.objects.filter(
|
||||||
|
site_id=site_id,
|
||||||
|
seed_keyword__isnull=False
|
||||||
|
).values_list('seed_keyword_id', flat=True)
|
||||||
|
queryset = queryset.exclude(id__in=attached_ids)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
return queryset
|
return queryset
|
||||||
|
|
||||||
@@ -1152,6 +1167,9 @@ class SeedKeywordViewSet(viewsets.ReadOnlyModelViewSet):
|
|||||||
|
|
||||||
# 3. High Volume (>= 10K) - simple threshold
|
# 3. High Volume (>= 10K) - simple threshold
|
||||||
high_volume_count = base_qs.filter(volume__gte=10000).count()
|
high_volume_count = base_qs.filter(volume__gte=10000).count()
|
||||||
|
|
||||||
|
# 3b. Mid Volume (5K-10K)
|
||||||
|
mid_volume_count = base_qs.filter(volume__gte=5000, volume__lt=10000).count()
|
||||||
|
|
||||||
# 4. Premium Traffic with dynamic fallback (50K -> 25K -> 10K)
|
# 4. Premium Traffic with dynamic fallback (50K -> 25K -> 10K)
|
||||||
premium_thresholds = [50000, 25000, 10000]
|
premium_thresholds = [50000, 25000, 10000]
|
||||||
@@ -1182,6 +1200,7 @@ class SeedKeywordViewSet(viewsets.ReadOnlyModelViewSet):
|
|||||||
'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, 'threshold': 10000},
|
||||||
|
'mid_volume': {'count': mid_volume_count, '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,
|
||||||
@@ -1201,6 +1220,7 @@ class SeedKeywordViewSet(viewsets.ReadOnlyModelViewSet):
|
|||||||
|
|
||||||
sector_available = count_available(sector_qs)
|
sector_available = count_available(sector_qs)
|
||||||
sector_high_volume = sector_qs.filter(volume__gte=10000).count()
|
sector_high_volume = sector_qs.filter(volume__gte=10000).count()
|
||||||
|
sector_mid_volume = sector_qs.filter(volume__gte=5000, volume__lt=10000).count()
|
||||||
sector_premium = get_count_with_fallback(sector_qs, premium_thresholds)
|
sector_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+$')
|
||||||
@@ -1218,6 +1238,7 @@ class SeedKeywordViewSet(viewsets.ReadOnlyModelViewSet):
|
|||||||
'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, 'threshold': 10000},
|
||||||
|
'mid_volume': {'count': sector_mid_volume, '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,
|
||||||
@@ -1243,12 +1264,20 @@ class SeedKeywordViewSet(viewsets.ReadOnlyModelViewSet):
|
|||||||
"""
|
"""
|
||||||
Get cascading filter options for Keywords Library.
|
Get cascading filter options for Keywords Library.
|
||||||
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.
|
||||||
"""
|
"""
|
||||||
from django.db.models import Count, Min, Max, Q
|
from django.db.models import Count, Min, Max, Q
|
||||||
|
|
||||||
try:
|
try:
|
||||||
industry_id = request.query_params.get('industry_id')
|
industry_id = request.query_params.get('industry_id')
|
||||||
|
sector_id = request.query_params.get('sector_id')
|
||||||
|
country_filter = request.query_params.get('country')
|
||||||
|
difficulty_min = request.query_params.get('difficulty_min')
|
||||||
|
difficulty_max = request.query_params.get('difficulty_max')
|
||||||
|
volume_min = request.query_params.get('volume_min')
|
||||||
|
volume_max = request.query_params.get('volume_max')
|
||||||
|
search_term = request.query_params.get('search')
|
||||||
|
|
||||||
# Get industries with keyword counts
|
# Get industries with keyword counts
|
||||||
industries = Industry.objects.annotate(
|
industries = Industry.objects.annotate(
|
||||||
keyword_count=Count('seed_keywords', filter=Q(seed_keywords__is_active=True))
|
keyword_count=Count('seed_keywords', filter=Q(seed_keywords__is_active=True))
|
||||||
@@ -1276,31 +1305,120 @@ class SeedKeywordViewSet(viewsets.ReadOnlyModelViewSet):
|
|||||||
'slug': sec.slug,
|
'slug': sec.slug,
|
||||||
'keyword_count': sec.keyword_count,
|
'keyword_count': sec.keyword_count,
|
||||||
} for sec in sectors]
|
} for sec in sectors]
|
||||||
|
|
||||||
# Get difficulty range
|
# Base queryset for cascading options
|
||||||
difficulty_range = SeedKeyword.objects.filter(is_active=True).aggregate(
|
base_qs = SeedKeyword.objects.filter(is_active=True)
|
||||||
|
if industry_id:
|
||||||
|
base_qs = base_qs.filter(industry_id=industry_id)
|
||||||
|
if sector_id:
|
||||||
|
base_qs = base_qs.filter(sector_id=sector_id)
|
||||||
|
|
||||||
|
# Countries options - apply all filters except country itself
|
||||||
|
countries_qs = base_qs
|
||||||
|
if difficulty_min is not None:
|
||||||
|
try:
|
||||||
|
countries_qs = countries_qs.filter(difficulty__gte=int(difficulty_min))
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
if difficulty_max is not None:
|
||||||
|
try:
|
||||||
|
countries_qs = countries_qs.filter(difficulty__lte=int(difficulty_max))
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
if volume_min is not None:
|
||||||
|
try:
|
||||||
|
countries_qs = countries_qs.filter(volume__gte=int(volume_min))
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
if volume_max is not None:
|
||||||
|
try:
|
||||||
|
countries_qs = countries_qs.filter(volume__lte=int(volume_max))
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
if search_term:
|
||||||
|
countries_qs = countries_qs.filter(keyword__icontains=search_term)
|
||||||
|
|
||||||
|
countries = countries_qs.values('country').annotate(
|
||||||
|
keyword_count=Count('id')
|
||||||
|
).order_by('country')
|
||||||
|
country_label_map = dict(SeedKeyword.COUNTRY_CHOICES)
|
||||||
|
countries_data = [{
|
||||||
|
'value': c['country'],
|
||||||
|
'label': country_label_map.get(c['country'], c['country']),
|
||||||
|
'keyword_count': c['keyword_count'],
|
||||||
|
} for c in countries if c['country']]
|
||||||
|
|
||||||
|
# Difficulty options - apply all filters except difficulty itself
|
||||||
|
difficulty_qs = base_qs
|
||||||
|
if country_filter:
|
||||||
|
difficulty_qs = difficulty_qs.filter(country=country_filter)
|
||||||
|
if volume_min is not None:
|
||||||
|
try:
|
||||||
|
difficulty_qs = difficulty_qs.filter(volume__gte=int(volume_min))
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
if volume_max is not None:
|
||||||
|
try:
|
||||||
|
difficulty_qs = difficulty_qs.filter(volume__lte=int(volume_max))
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
if search_term:
|
||||||
|
difficulty_qs = difficulty_qs.filter(keyword__icontains=search_term)
|
||||||
|
|
||||||
|
difficulty_ranges = [
|
||||||
|
(1, 'Very Easy', 0, 10),
|
||||||
|
(2, 'Easy', 11, 30),
|
||||||
|
(3, 'Medium', 31, 50),
|
||||||
|
(4, 'Hard', 51, 70),
|
||||||
|
(5, 'Very Hard', 71, 100),
|
||||||
|
]
|
||||||
|
|
||||||
|
difficulty_levels = []
|
||||||
|
for level, label, min_val, max_val in difficulty_ranges:
|
||||||
|
count = difficulty_qs.filter(
|
||||||
|
difficulty__gte=min_val,
|
||||||
|
difficulty__lte=max_val
|
||||||
|
).count()
|
||||||
|
if count > 0:
|
||||||
|
difficulty_levels.append({
|
||||||
|
'level': level,
|
||||||
|
'label': label,
|
||||||
|
'backend_range': [min_val, max_val],
|
||||||
|
'keyword_count': count,
|
||||||
|
})
|
||||||
|
|
||||||
|
# Difficulty range (filtered by current non-difficulty filters)
|
||||||
|
difficulty_range = difficulty_qs.aggregate(
|
||||||
min_difficulty=Min('difficulty'),
|
min_difficulty=Min('difficulty'),
|
||||||
max_difficulty=Max('difficulty')
|
max_difficulty=Max('difficulty')
|
||||||
)
|
)
|
||||||
|
|
||||||
# Get volume range
|
# Volume range (filtered by current non-volume filters)
|
||||||
volume_range = SeedKeyword.objects.filter(is_active=True).aggregate(
|
volume_qs = base_qs
|
||||||
|
if country_filter:
|
||||||
|
volume_qs = volume_qs.filter(country=country_filter)
|
||||||
|
if difficulty_min is not None:
|
||||||
|
try:
|
||||||
|
volume_qs = volume_qs.filter(difficulty__gte=int(difficulty_min))
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
if difficulty_max is not None:
|
||||||
|
try:
|
||||||
|
volume_qs = volume_qs.filter(difficulty__lte=int(difficulty_max))
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
if search_term:
|
||||||
|
volume_qs = volume_qs.filter(keyword__icontains=search_term)
|
||||||
|
|
||||||
|
volume_range = volume_qs.aggregate(
|
||||||
min_volume=Min('volume'),
|
min_volume=Min('volume'),
|
||||||
max_volume=Max('volume')
|
max_volume=Max('volume')
|
||||||
)
|
)
|
||||||
|
|
||||||
# Difficulty levels for frontend (maps to backend values)
|
|
||||||
difficulty_levels = [
|
|
||||||
{'level': 1, 'label': 'Very Easy', 'backend_range': [0, 20]},
|
|
||||||
{'level': 2, 'label': 'Easy', 'backend_range': [21, 40]},
|
|
||||||
{'level': 3, 'label': 'Medium', 'backend_range': [41, 60]},
|
|
||||||
{'level': 4, 'label': 'Hard', 'backend_range': [61, 80]},
|
|
||||||
{'level': 5, 'label': 'Very Hard', 'backend_range': [81, 100]},
|
|
||||||
]
|
|
||||||
|
|
||||||
data = {
|
data = {
|
||||||
'industries': industries_data,
|
'industries': industries_data,
|
||||||
'sectors': sectors_data,
|
'sectors': sectors_data,
|
||||||
|
'countries': countries_data,
|
||||||
'difficulty': {
|
'difficulty': {
|
||||||
'range': difficulty_range,
|
'range': difficulty_range,
|
||||||
'levels': difficulty_levels,
|
'levels': difficulty_levels,
|
||||||
|
|||||||
@@ -62,6 +62,8 @@ export default function SectorCardsGrid({
|
|||||||
const total = stats?.total.count ?? 0;
|
const total = stats?.total.count ?? 0;
|
||||||
const available = stats?.available.count ?? 0;
|
const available = stats?.available.count ?? 0;
|
||||||
const inWorkflow = Math.max(total - available, 0);
|
const inWorkflow = Math.max(total - available, 0);
|
||||||
|
const over10k = stats?.high_volume.count ?? 0;
|
||||||
|
const midVolume = stats?.mid_volume?.count ?? 0;
|
||||||
const isActive = activeSectorId === sector.id;
|
const isActive = activeSectorId === sector.id;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -74,7 +76,7 @@ export default function SectorCardsGrid({
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="sector-card-accent" />
|
<div className="sector-card-accent" />
|
||||||
<div className="flex items-center justify-between gap-3">
|
<div className="flex items-start justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<h4 className="text-base font-semibold text-gray-900 dark:text-white">
|
<h4 className="text-base font-semibold text-gray-900 dark:text-white">
|
||||||
@@ -87,33 +89,47 @@ export default function SectorCardsGrid({
|
|||||||
) : (
|
) : (
|
||||||
<span className="text-xs text-gray-500 dark:text-gray-400">Not linked to template</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>
|
||||||
{isActive && (
|
<div className="text-right">
|
||||||
<Badge color="info" size="sm" variant="light">
|
<div className="text-xs text-gray-500 dark:text-gray-400">Total</div>
|
||||||
Active
|
<div className="text-2xl font-semibold text-gray-900 dark:text-white">
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-4 grid grid-cols-3 gap-2 text-center">
|
|
||||||
<div className="sector-stats-pill">
|
|
||||||
<div className="label">Total</div>
|
|
||||||
<div className="value text-base">
|
|
||||||
{total.toLocaleString()}
|
{total.toLocaleString()}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="sector-stats-pill">
|
</div>
|
||||||
<div className="label">Available</div>
|
|
||||||
<div className="value text-base">
|
<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">
|
||||||
{available.toLocaleString()}
|
{available.toLocaleString()}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="sector-stats-pill">
|
<div>
|
||||||
<div className="label">In Workflow</div>
|
<div className="text-xs font-semibold text-brand-600 dark:text-brand-400">In Workflow</div>
|
||||||
<div className="value text-base">
|
<div className="text-sm font-medium text-gray-900 dark:text-white">
|
||||||
{inWorkflow.toLocaleString()}
|
{inWorkflow.toLocaleString()}
|
||||||
</div>
|
</div>
|
||||||
</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">
|
||||||
|
{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">
|
||||||
|
{midVolume.toLocaleString()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -43,6 +43,9 @@ const STAT_CONFIG: Record<StatType, {
|
|||||||
description: string;
|
description: string;
|
||||||
icon: ReactNode;
|
icon: ReactNode;
|
||||||
accentColor: string;
|
accentColor: string;
|
||||||
|
borderColor: string;
|
||||||
|
ringColor: string;
|
||||||
|
dotColor: string;
|
||||||
textColor: string;
|
textColor: string;
|
||||||
badgeColor: string;
|
badgeColor: string;
|
||||||
showThreshold?: boolean;
|
showThreshold?: boolean;
|
||||||
@@ -53,6 +56,9 @@ const STAT_CONFIG: Record<StatType, {
|
|||||||
description: 'All keywords in sector',
|
description: 'All keywords in sector',
|
||||||
icon: <PieChartIcon className="w-5 h-5" />,
|
icon: <PieChartIcon className="w-5 h-5" />,
|
||||||
accentColor: 'bg-gray-500',
|
accentColor: 'bg-gray-500',
|
||||||
|
borderColor: 'border-gray-400',
|
||||||
|
ringColor: 'ring-gray-400/20',
|
||||||
|
dotColor: 'bg-gray-500',
|
||||||
textColor: 'text-gray-600 dark:text-gray-400',
|
textColor: 'text-gray-600 dark:text-gray-400',
|
||||||
badgeColor: 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300',
|
badgeColor: 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300',
|
||||||
},
|
},
|
||||||
@@ -61,6 +67,9 @@ const STAT_CONFIG: Record<StatType, {
|
|||||||
description: 'Not yet added to your site',
|
description: 'Not yet added to your site',
|
||||||
icon: <CheckCircleIcon className="w-5 h-5" />,
|
icon: <CheckCircleIcon className="w-5 h-5" />,
|
||||||
accentColor: 'bg-success-500',
|
accentColor: 'bg-success-500',
|
||||||
|
borderColor: 'border-success-500',
|
||||||
|
ringColor: 'ring-success-500/20',
|
||||||
|
dotColor: 'bg-success-500',
|
||||||
textColor: 'text-success-600 dark:text-success-400',
|
textColor: 'text-success-600 dark:text-success-400',
|
||||||
badgeColor: 'bg-success-100 text-success-700 dark:bg-success-900/40 dark:text-success-300',
|
badgeColor: 'bg-success-100 text-success-700 dark:bg-success-900/40 dark:text-success-300',
|
||||||
},
|
},
|
||||||
@@ -69,6 +78,9 @@ const STAT_CONFIG: Record<StatType, {
|
|||||||
description: 'Volume ≥ 10K searches/mo',
|
description: 'Volume ≥ 10K searches/mo',
|
||||||
icon: <ShootingStarIcon className="w-5 h-5" />,
|
icon: <ShootingStarIcon className="w-5 h-5" />,
|
||||||
accentColor: 'bg-brand-500',
|
accentColor: 'bg-brand-500',
|
||||||
|
borderColor: 'border-brand-500',
|
||||||
|
ringColor: 'ring-brand-500/20',
|
||||||
|
dotColor: 'bg-brand-500',
|
||||||
textColor: 'text-brand-600 dark:text-brand-400',
|
textColor: 'text-brand-600 dark:text-brand-400',
|
||||||
badgeColor: 'bg-brand-100 text-brand-700 dark:bg-brand-900/40 dark:text-brand-300',
|
badgeColor: 'bg-brand-100 text-brand-700 dark:bg-brand-900/40 dark:text-brand-300',
|
||||||
},
|
},
|
||||||
@@ -77,6 +89,9 @@ const STAT_CONFIG: Record<StatType, {
|
|||||||
description: 'High volume keywords',
|
description: 'High volume keywords',
|
||||||
icon: <BoxIcon className="w-5 h-5" />,
|
icon: <BoxIcon className="w-5 h-5" />,
|
||||||
accentColor: 'bg-warning-500',
|
accentColor: 'bg-warning-500',
|
||||||
|
borderColor: 'border-warning-500',
|
||||||
|
ringColor: 'ring-warning-500/20',
|
||||||
|
dotColor: 'bg-warning-500',
|
||||||
textColor: 'text-warning-600 dark:text-warning-400',
|
textColor: 'text-warning-600 dark:text-warning-400',
|
||||||
badgeColor: 'bg-warning-100 text-warning-700 dark:bg-warning-900/40 dark:text-warning-300',
|
badgeColor: 'bg-warning-100 text-warning-700 dark:bg-warning-900/40 dark:text-warning-300',
|
||||||
showThreshold: true,
|
showThreshold: true,
|
||||||
@@ -87,6 +102,9 @@ const STAT_CONFIG: Record<StatType, {
|
|||||||
description: '4+ words with good volume',
|
description: '4+ words with good volume',
|
||||||
icon: <DocsIcon className="w-5 h-5" />,
|
icon: <DocsIcon className="w-5 h-5" />,
|
||||||
accentColor: 'bg-purple-500',
|
accentColor: 'bg-purple-500',
|
||||||
|
borderColor: 'border-purple-500',
|
||||||
|
ringColor: 'ring-purple-500/20',
|
||||||
|
dotColor: 'bg-purple-500',
|
||||||
textColor: 'text-purple-600 dark:text-purple-400',
|
textColor: 'text-purple-600 dark:text-purple-400',
|
||||||
badgeColor: 'bg-purple-100 text-purple-700 dark:bg-purple-900/40 dark:text-purple-300',
|
badgeColor: 'bg-purple-100 text-purple-700 dark:bg-purple-900/40 dark:text-purple-300',
|
||||||
showThreshold: true,
|
showThreshold: true,
|
||||||
@@ -97,6 +115,9 @@ const STAT_CONFIG: Record<StatType, {
|
|||||||
description: 'Low difficulty, good volume',
|
description: 'Low difficulty, good volume',
|
||||||
icon: <BoltIcon className="w-5 h-5" />,
|
icon: <BoltIcon className="w-5 h-5" />,
|
||||||
accentColor: 'bg-emerald-500',
|
accentColor: 'bg-emerald-500',
|
||||||
|
borderColor: 'border-emerald-500',
|
||||||
|
ringColor: 'ring-emerald-500/20',
|
||||||
|
dotColor: 'bg-emerald-500',
|
||||||
textColor: 'text-emerald-600 dark:text-emerald-400',
|
textColor: 'text-emerald-600 dark:text-emerald-400',
|
||||||
badgeColor: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300',
|
badgeColor: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300',
|
||||||
showThreshold: true,
|
showThreshold: true,
|
||||||
@@ -189,7 +210,7 @@ export default function SectorMetricCard({
|
|||||||
: 'cursor-default',
|
: 'cursor-default',
|
||||||
compact ? 'p-3' : 'p-4',
|
compact ? 'p-3' : 'p-4',
|
||||||
isActive
|
isActive
|
||||||
? 'border-brand-500 shadow-sm ring-4 ring-brand-500/15'
|
? clsx('border-2 shadow-sm ring-4', config.borderColor, config.ringColor)
|
||||||
: 'border-gray-200 dark:border-gray-800',
|
: 'border-gray-200 dark:border-gray-800',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -208,7 +229,7 @@ export default function SectorMetricCard({
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{isActive && (
|
{isActive && (
|
||||||
<div className="w-2 h-2 rounded-full bg-brand-500 animate-pulse" />
|
<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)}>
|
<div className={clsx('font-bold tabular-nums', compact ? 'text-2xl' : 'text-3xl', config.textColor)}>
|
||||||
{formatCount(statData.count)}
|
{formatCount(statData.count)}
|
||||||
@@ -227,26 +248,28 @@ export default function SectorMetricCard({
|
|||||||
<div className="flex flex-wrap items-center gap-2 text-xs">
|
<div className="flex flex-wrap items-center gap-2 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 to workflow
|
ADD
|
||||||
</span>
|
</span>
|
||||||
{bulkAddOptions.map((count) => {
|
<div className="ml-auto flex flex-wrap items-center gap-2 justify-end">
|
||||||
const isAdded = isAddedAction ? isAddedAction(statType, count) : false;
|
{bulkAddOptions.map((count) => {
|
||||||
return (
|
const isAdded = isAddedAction ? isAddedAction(statType, count) : false;
|
||||||
<Button
|
return (
|
||||||
key={count}
|
<Button
|
||||||
size="xs"
|
key={count}
|
||||||
variant={isAdded ? 'outline' : 'primary'}
|
size="xs"
|
||||||
tone={isAdded ? 'success' : 'brand'}
|
variant={isAdded ? 'outline' : 'primary'}
|
||||||
startIcon={
|
tone={isAdded ? 'success' : 'brand'}
|
||||||
isAdded ? <CheckCircleIcon className="w-3 h-3" /> : <PlusIcon className="w-3 h-3" />
|
startIcon={
|
||||||
}
|
isAdded ? <CheckCircleIcon className="w-3 h-3" /> : <PlusIcon className="w-3 h-3" />
|
||||||
onClick={() => handleAddClick(count)}
|
}
|
||||||
disabled={isAdded}
|
onClick={() => handleAddClick(count)}
|
||||||
>
|
disabled={isAdded}
|
||||||
{isAdded ? 'Added' : (count === statData.count ? 'All' : count)}
|
>
|
||||||
</Button>
|
{isAdded ? 'Added' : (count === statData.count ? 'All' : count)}
|
||||||
);
|
</Button>
|
||||||
})}
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -285,7 +308,7 @@ export function SectorMetricGrid({
|
|||||||
return (
|
return (
|
||||||
<div className={clsx(
|
<div className={clsx(
|
||||||
'grid gap-4',
|
'grid gap-4',
|
||||||
compact ? 'grid-cols-3 sm:grid-cols-6' : 'grid-cols-2 sm:grid-cols-3 lg:grid-cols-6',
|
compact ? 'grid-cols-2 sm:grid-cols-3 md:grid-cols-5' : 'grid-cols-1 sm:grid-cols-2 md:grid-cols-3 xl:grid-cols-5',
|
||||||
)}>
|
)}>
|
||||||
{statTypes.map((statType) => (
|
{statTypes.map((statType) => (
|
||||||
<div
|
<div
|
||||||
@@ -308,7 +331,7 @@ export function SectorMetricGrid({
|
|||||||
return (
|
return (
|
||||||
<div className={clsx(
|
<div className={clsx(
|
||||||
'grid gap-4',
|
'grid gap-4',
|
||||||
compact ? 'grid-cols-3 sm:grid-cols-6' : 'grid-cols-2 sm:grid-cols-3 lg:grid-cols-6',
|
compact ? 'grid-cols-2 sm:grid-cols-3 md:grid-cols-5' : 'grid-cols-1 sm:grid-cols-2 md:grid-cols-3 xl:grid-cols-5',
|
||||||
)}>
|
)}>
|
||||||
{statTypes.map((statType) => (
|
{statTypes.map((statType) => (
|
||||||
<SectorMetricCard
|
<SectorMetricCard
|
||||||
|
|||||||
@@ -1,88 +1,27 @@
|
|||||||
/**
|
/**
|
||||||
* SmartSuggestions - Compact card-based suggestions for Keywords Library
|
* SmartSuggestions - Container for smart suggestions area
|
||||||
* White background with accent colors like Dashboard cards
|
* Shows header and optional content, with breathing indicator when empty
|
||||||
* Shows keyword categories with counts and bulk add options
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { ReactNode, useState } from 'react';
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
import { ShootingStarIcon, ChevronDownIcon, PlusIcon, BoltIcon, DocsIcon, BoxIcon, CheckCircleIcon } from '../../icons';
|
import { ShootingStarIcon, ChevronDownIcon } from '../../icons';
|
||||||
import Button from '../ui/button/Button';
|
|
||||||
|
|
||||||
interface SmartSuggestion {
|
|
||||||
id: string;
|
|
||||||
label: string;
|
|
||||||
description: string;
|
|
||||||
count: number;
|
|
||||||
filterParams: {
|
|
||||||
statType?: string;
|
|
||||||
difficulty_max?: number;
|
|
||||||
volume_min?: number;
|
|
||||||
word_count_min?: number;
|
|
||||||
available_only?: boolean;
|
|
||||||
};
|
|
||||||
color: 'blue' | 'green' | 'amber' | 'purple' | 'emerald';
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SmartSuggestionsProps {
|
interface SmartSuggestionsProps {
|
||||||
suggestions: SmartSuggestion[];
|
children?: ReactNode;
|
||||||
onSuggestionClick: (suggestion: SmartSuggestion) => void;
|
showEmptyState?: boolean;
|
||||||
onBulkAdd?: (suggestion: SmartSuggestion, count: number) => void;
|
|
||||||
isAddedAction?: (suggestionId: string, count: number) => boolean;
|
|
||||||
enableFilterClick?: boolean;
|
|
||||||
className?: string;
|
className?: string;
|
||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Icon mapping
|
|
||||||
const SUGGESTION_ICONS: Record<string, React.ReactNode> = {
|
|
||||||
quick_wins: <BoltIcon className="w-5 h-5" />,
|
|
||||||
long_tail: <DocsIcon className="w-5 h-5" />,
|
|
||||||
premium_traffic: <BoxIcon className="w-5 h-5" />,
|
|
||||||
available: <CheckCircleIcon className="w-5 h-5" />,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Color mappings with white bg card style
|
|
||||||
const colorClasses = {
|
|
||||||
blue: {
|
|
||||||
accent: 'bg-brand-500',
|
|
||||||
text: 'text-brand-600 dark:text-brand-400',
|
|
||||||
iconBg: 'bg-brand-100 dark:bg-brand-900/40',
|
|
||||||
},
|
|
||||||
green: {
|
|
||||||
accent: 'bg-success-500',
|
|
||||||
text: 'text-success-600 dark:text-success-400',
|
|
||||||
iconBg: 'bg-success-100 dark:bg-success-900/40',
|
|
||||||
},
|
|
||||||
amber: {
|
|
||||||
accent: 'bg-warning-500',
|
|
||||||
text: 'text-warning-600 dark:text-warning-400',
|
|
||||||
iconBg: 'bg-warning-100 dark:bg-warning-900/40',
|
|
||||||
},
|
|
||||||
purple: {
|
|
||||||
accent: 'bg-purple-500',
|
|
||||||
text: 'text-purple-600 dark:text-purple-400',
|
|
||||||
iconBg: 'bg-purple-100 dark:bg-purple-900/40',
|
|
||||||
},
|
|
||||||
emerald: {
|
|
||||||
accent: 'bg-emerald-500',
|
|
||||||
text: 'text-emerald-600 dark:text-emerald-400',
|
|
||||||
iconBg: 'bg-emerald-100 dark:bg-emerald-900/40',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function SmartSuggestions({
|
export default function SmartSuggestions({
|
||||||
suggestions,
|
children,
|
||||||
onSuggestionClick,
|
showEmptyState = false,
|
||||||
onBulkAdd,
|
|
||||||
isAddedAction,
|
|
||||||
enableFilterClick = true,
|
|
||||||
className,
|
className,
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
}: SmartSuggestionsProps) {
|
}: SmartSuggestionsProps) {
|
||||||
const [isExpanded, setIsExpanded] = useState(true);
|
const [isExpanded, setIsExpanded] = useState(true);
|
||||||
const totalAvailable = suggestions.reduce((sum, s) => sum + s.count, 0);
|
const hasContent = Boolean(children);
|
||||||
const hasKeywords = totalAvailable > 0;
|
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
@@ -98,8 +37,7 @@ export default function SmartSuggestions({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show breathing indicator when no suggestions (waiting for data)
|
if (showEmptyState && !hasContent) {
|
||||||
if (suggestions.length === 0) {
|
|
||||||
return (
|
return (
|
||||||
<div className={clsx(
|
<div className={clsx(
|
||||||
'rounded-xl border border-gray-200 dark:border-gray-800 bg-white dark:bg-white/[0.03] p-4',
|
'rounded-xl border border-gray-200 dark:border-gray-800 bg-white dark:bg-white/[0.03] p-4',
|
||||||
@@ -113,7 +51,6 @@ export default function SmartSuggestions({
|
|||||||
<ShootingStarIcon className="w-4 h-4 text-white" />
|
<ShootingStarIcon className="w-4 h-4 text-white" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
{/* Breathing circle indicator */}
|
|
||||||
<div className="w-3 h-3 rounded-full bg-brand-500 animate-pulse" />
|
<div className="w-3 h-3 rounded-full bg-brand-500 animate-pulse" />
|
||||||
<span className="text-gray-600 dark:text-gray-400 text-sm">
|
<span className="text-gray-600 dark:text-gray-400 text-sm">
|
||||||
Ready-to-use keywords waiting for you! Search for a keyword or apply any filter to see smart suggestions...
|
Ready-to-use keywords waiting for you! Search for a keyword or apply any filter to see smart suggestions...
|
||||||
@@ -129,218 +66,40 @@ export default function SmartSuggestions({
|
|||||||
'rounded-xl border border-gray-200 dark:border-gray-800 bg-white dark:bg-white/[0.03] overflow-hidden',
|
'rounded-xl border border-gray-200 dark:border-gray-800 bg-white dark:bg-white/[0.03] overflow-hidden',
|
||||||
className
|
className
|
||||||
)}>
|
)}>
|
||||||
{/* Header */}
|
|
||||||
<button
|
<button
|
||||||
onClick={() => setIsExpanded(!isExpanded)}
|
onClick={() => setIsExpanded(!isExpanded)}
|
||||||
className="flex items-center justify-between w-full p-4 text-left hover:bg-gray-50 dark:hover:bg-gray-800/50 transition-colors"
|
className="flex items-center justify-between w-full p-4 text-left hover:bg-gray-50 dark:hover:bg-gray-800/50 transition-colors"
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
{/* Icon with breathing animation */}
|
|
||||||
<div className={clsx(
|
<div className={clsx(
|
||||||
'w-8 h-8 rounded-lg flex items-center justify-center',
|
'w-8 h-8 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',
|
||||||
hasKeywords && 'animate-pulse'
|
hasContent && 'animate-pulse'
|
||||||
)}>
|
)}>
|
||||||
<ShootingStarIcon className="w-4 h-4 text-white" />
|
<ShootingStarIcon className="w-4 h-4 text-white" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<h3 className="font-semibold text-gray-900 dark:text-white text-sm flex items-center gap-2">
|
<h3 className="font-semibold text-gray-900 dark:text-white text-base flex items-center gap-2">
|
||||||
Smart Suggestions
|
Smart Suggestions
|
||||||
{hasKeywords && (
|
|
||||||
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300">
|
|
||||||
{totalAvailable.toLocaleString()} ready
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
<p className="text-xs 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-5 h-5 text-gray-400 transition-transform duration-200',
|
||||||
isExpanded && 'rotate-180'
|
isExpanded && 'rotate-180'
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Expandable content - Grid layout */}
|
|
||||||
{isExpanded && (
|
{isExpanded && (
|
||||||
<div className="px-4 pb-4">
|
<div className="px-4 pb-4">
|
||||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
|
{children}
|
||||||
{suggestions.map((suggestion) => {
|
|
||||||
const colors = colorClasses[suggestion.color];
|
|
||||||
const icon = SUGGESTION_ICONS[suggestion.id] || <ShootingStarIcon className="w-4 h-4" />;
|
|
||||||
// Bulk add options
|
|
||||||
const bulkOptions: number[] = [];
|
|
||||||
if (suggestion.count >= 50) bulkOptions.push(50);
|
|
||||||
if (suggestion.count >= 100) bulkOptions.push(100);
|
|
||||||
if (suggestion.count >= 200) bulkOptions.push(200);
|
|
||||||
if (suggestion.count > 0 && suggestion.count <= 200) bulkOptions.push(suggestion.count);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={suggestion.id}
|
|
||||||
className={clsx(
|
|
||||||
'relative rounded-lg border bg-white dark:bg-white/[0.02] overflow-hidden',
|
|
||||||
'transition-all duration-200 cursor-pointer',
|
|
||||||
enableFilterClick
|
|
||||||
? 'cursor-pointer hover:shadow-md hover:border-gray-300 dark:hover:border-gray-600'
|
|
||||||
: 'cursor-default',
|
|
||||||
'border-gray-200 dark:border-gray-800',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{/* Accent border */}
|
|
||||||
<div className={clsx('absolute left-0 top-0 bottom-0 w-1', colors.accent)} />
|
|
||||||
|
|
||||||
{/* Main content - clickable to filter */}
|
|
||||||
<div
|
|
||||||
onClick={() => {
|
|
||||||
if (enableFilterClick) {
|
|
||||||
onSuggestionClick(suggestion);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className="p-3 pl-4"
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between gap-2 mb-1">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className={clsx('p-2 rounded', colors.iconBg, colors.text)}>
|
|
||||||
{icon}
|
|
||||||
</div>
|
|
||||||
<span className={clsx('font-semibold text-base', colors.text)}>
|
|
||||||
{suggestion.label}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<span className="text-xl font-bold text-gray-900 dark:text-white tabular-nums">
|
|
||||||
{suggestion.count.toLocaleString()}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p className="text-xs text-gray-500 dark:text-gray-400 line-clamp-1">
|
|
||||||
{suggestion.description}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Bulk add buttons - always visible */}
|
|
||||||
{onBulkAdd && bulkOptions.length > 0 && (
|
|
||||||
<div className="px-3 pb-3 pt-0">
|
|
||||||
<div className="flex items-center gap-2 pt-2 border-t border-gray-100 dark:border-gray-800">
|
|
||||||
<span className="text-xs text-gray-500 dark:text-gray-400 flex items-center gap-1">
|
|
||||||
<PlusIcon className="w-3 h-3" />
|
|
||||||
Add
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-wrap gap-2 pt-2">
|
|
||||||
{bulkOptions.map((count) => {
|
|
||||||
const isAdded = isAddedAction ? isAddedAction(suggestion.id, count) : false;
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
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={() => onBulkAdd(suggestion, count)}
|
|
||||||
disabled={isAdded}
|
|
||||||
>
|
|
||||||
{isAdded ? 'Added' : (count === suggestion.count ? 'All' : count)}
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper to build suggestions from sector stats
|
|
||||||
export function buildSmartSuggestions(
|
|
||||||
stats: {
|
|
||||||
available: { count: number };
|
|
||||||
quick_wins: { count: number; threshold?: number };
|
|
||||||
long_tail: { count: number; threshold?: number };
|
|
||||||
premium_traffic: { count: number; threshold?: number };
|
|
||||||
},
|
|
||||||
options?: {
|
|
||||||
showOnlyWithResults?: boolean;
|
|
||||||
}
|
|
||||||
): SmartSuggestion[] {
|
|
||||||
const suggestions: SmartSuggestion[] = [];
|
|
||||||
|
|
||||||
// Quick Wins - Low difficulty + good volume + available
|
|
||||||
if (stats.quick_wins.count > 0 || !options?.showOnlyWithResults) {
|
|
||||||
suggestions.push({
|
|
||||||
id: 'quick_wins',
|
|
||||||
label: 'Quick Wins',
|
|
||||||
description: `Easy to rank keywords with vol > ${(stats.quick_wins.threshold || 1000) / 1000}K`,
|
|
||||||
count: stats.quick_wins.count,
|
|
||||||
filterParams: {
|
|
||||||
statType: 'quick_wins',
|
|
||||||
difficulty_max: 20,
|
|
||||||
volume_min: stats.quick_wins.threshold || 1000,
|
|
||||||
available_only: true,
|
|
||||||
},
|
|
||||||
color: 'emerald',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Long Tail - 4+ words with volume
|
|
||||||
if (stats.long_tail.count > 0 || !options?.showOnlyWithResults) {
|
|
||||||
suggestions.push({
|
|
||||||
id: 'long_tail',
|
|
||||||
label: 'Long Tail',
|
|
||||||
description: `4+ word phrases with vol > ${(stats.long_tail.threshold || 1000) / 1000}K`,
|
|
||||||
count: stats.long_tail.count,
|
|
||||||
filterParams: {
|
|
||||||
statType: 'long_tail',
|
|
||||||
word_count_min: 4,
|
|
||||||
volume_min: stats.long_tail.threshold || 1000,
|
|
||||||
},
|
|
||||||
color: 'purple',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Premium Traffic - High volume
|
|
||||||
if (stats.premium_traffic.count > 0 || !options?.showOnlyWithResults) {
|
|
||||||
suggestions.push({
|
|
||||||
id: 'premium_traffic',
|
|
||||||
label: 'Premium Traffic',
|
|
||||||
description: `High volume keywords (${(stats.premium_traffic.threshold || 50000) / 1000}K+ searches)`,
|
|
||||||
count: stats.premium_traffic.count,
|
|
||||||
filterParams: {
|
|
||||||
statType: 'premium_traffic',
|
|
||||||
volume_min: stats.premium_traffic.threshold || 50000,
|
|
||||||
},
|
|
||||||
color: 'amber',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Available Only
|
|
||||||
if (stats.available.count > 0 || !options?.showOnlyWithResults) {
|
|
||||||
suggestions.push({
|
|
||||||
id: 'available',
|
|
||||||
label: 'Available Keywords',
|
|
||||||
description: 'Keywords not yet added to your site',
|
|
||||||
count: stats.available.count,
|
|
||||||
filterParams: {
|
|
||||||
statType: 'available',
|
|
||||||
available_only: true,
|
|
||||||
},
|
|
||||||
color: 'green',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return suggestions;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -6,6 +6,6 @@
|
|||||||
export { default as SectorMetricCard, SectorMetricGrid } from './SectorMetricCard';
|
export { default as SectorMetricCard, SectorMetricGrid } from './SectorMetricCard';
|
||||||
export type { StatType, StatResult, SectorStats } from './SectorMetricCard';
|
export type { StatType, StatResult, SectorStats } from './SectorMetricCard';
|
||||||
|
|
||||||
export { default as SmartSuggestions, buildSmartSuggestions } from './SmartSuggestions';
|
export { default as SmartSuggestions } from './SmartSuggestions';
|
||||||
|
|
||||||
export { default as BulkAddConfirmation } from './BulkAddConfirmation';
|
export { default as BulkAddConfirmation } from './BulkAddConfirmation';
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ import {
|
|||||||
fetchSectorStats,
|
fetchSectorStats,
|
||||||
SectorStats,
|
SectorStats,
|
||||||
SectorStatsItem,
|
SectorStatsItem,
|
||||||
|
fetchKeywordsLibraryFilterOptions,
|
||||||
|
FilterOption,
|
||||||
} from '../../services/api';
|
} from '../../services/api';
|
||||||
import Badge from '../../components/ui/badge/Badge';
|
import Badge from '../../components/ui/badge/Badge';
|
||||||
import { BoltIcon, ShootingStarIcon } from '../../icons';
|
import { BoltIcon, ShootingStarIcon } from '../../icons';
|
||||||
@@ -41,7 +43,7 @@ import Label from '../../components/form/Label';
|
|||||||
import Input from '../../components/form/input/InputField';
|
import Input from '../../components/form/input/InputField';
|
||||||
import { Card } from '../../components/ui/card';
|
import { Card } from '../../components/ui/card';
|
||||||
import { SectorMetricGrid, StatType } from '../../components/keywords-library/SectorMetricCard';
|
import { SectorMetricGrid, StatType } from '../../components/keywords-library/SectorMetricCard';
|
||||||
import SmartSuggestions, { buildSmartSuggestions } from '../../components/keywords-library/SmartSuggestions';
|
import SmartSuggestions from '../../components/keywords-library/SmartSuggestions';
|
||||||
import SectorCardsGrid from '../../components/keywords-library/SectorCardsGrid';
|
import SectorCardsGrid from '../../components/keywords-library/SectorCardsGrid';
|
||||||
import BulkAddConfirmation from '../../components/keywords-library/BulkAddConfirmation';
|
import BulkAddConfirmation from '../../components/keywords-library/BulkAddConfirmation';
|
||||||
|
|
||||||
@@ -72,9 +74,7 @@ export default function IndustriesSectorsKeywords() {
|
|||||||
const [bulkAddKeywordIds, setBulkAddKeywordIds] = useState<number[]>([]);
|
const [bulkAddKeywordIds, setBulkAddKeywordIds] = useState<number[]>([]);
|
||||||
const [bulkAddStatLabel, setBulkAddStatLabel] = useState<string | undefined>();
|
const [bulkAddStatLabel, setBulkAddStatLabel] = useState<string | undefined>();
|
||||||
const [pendingBulkAddKey, setPendingBulkAddKey] = useState<string | null>(null);
|
const [pendingBulkAddKey, setPendingBulkAddKey] = useState<string | null>(null);
|
||||||
const [pendingBulkAddGroup, setPendingBulkAddGroup] = useState<'stat' | 'suggestion' | null>(null);
|
|
||||||
const [addedStatActions, setAddedStatActions] = useState<Set<string>>(new Set());
|
const [addedStatActions, setAddedStatActions] = useState<Set<string>>(new Set());
|
||||||
const [addedSuggestionActions, setAddedSuggestionActions] = useState<Set<string>>(new Set());
|
|
||||||
|
|
||||||
// Ahrefs banner state
|
// Ahrefs banner state
|
||||||
const [showAhrefsBanner, setShowAhrefsBanner] = useState(true);
|
const [showAhrefsBanner, setShowAhrefsBanner] = useState(true);
|
||||||
@@ -95,6 +95,10 @@ 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('');
|
||||||
|
|
||||||
|
// Dynamic filter options (cascading)
|
||||||
|
const [countryOptions, setCountryOptions] = useState<FilterOption[] | undefined>(undefined);
|
||||||
|
const [difficultyOptions, setDifficultyOptions] = useState<FilterOption[] | undefined>(undefined);
|
||||||
|
|
||||||
// Keyword count tracking
|
// Keyword count tracking
|
||||||
const [addedCount, setAddedCount] = useState(0);
|
const [addedCount, setAddedCount] = useState(0);
|
||||||
@@ -267,7 +271,6 @@ export default function IndustriesSectorsKeywords() {
|
|||||||
setCurrentPage(1);
|
setCurrentPage(1);
|
||||||
setSelectedIds([]);
|
setSelectedIds([]);
|
||||||
setAddedStatActions(new Set());
|
setAddedStatActions(new Set());
|
||||||
setAddedSuggestionActions(new Set());
|
|
||||||
}, [activeSite?.id]);
|
}, [activeSite?.id]);
|
||||||
|
|
||||||
// Reset pagination/selection when sector changes
|
// Reset pagination/selection when sector changes
|
||||||
@@ -275,8 +278,44 @@ export default function IndustriesSectorsKeywords() {
|
|||||||
setActiveStatFilter(null);
|
setActiveStatFilter(null);
|
||||||
setCurrentPage(1);
|
setCurrentPage(1);
|
||||||
setSelectedIds([]);
|
setSelectedIds([]);
|
||||||
|
setAddedStatActions(new Set());
|
||||||
}, [activeSector?.id]);
|
}, [activeSector?.id]);
|
||||||
|
|
||||||
|
// Load cascading filter options based on current filters
|
||||||
|
const loadFilterOptions = useCallback(async () => {
|
||||||
|
if (!activeSite?.industry) return;
|
||||||
|
|
||||||
|
const difficultyLabel = difficultyFilter ? getDifficultyLabelFromNumber(parseInt(difficultyFilter, 10)) : null;
|
||||||
|
const difficultyRange = difficultyLabel ? getDifficultyRange(difficultyLabel) : null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const options = await fetchKeywordsLibraryFilterOptions({
|
||||||
|
industry_id: activeSite.industry,
|
||||||
|
sector_id: activeSector?.industry_sector || undefined,
|
||||||
|
country: countryFilter || undefined,
|
||||||
|
difficulty_min: difficultyRange?.min,
|
||||||
|
difficulty_max: difficultyRange?.max,
|
||||||
|
volume_min: volumeMin ? Number(volumeMin) : undefined,
|
||||||
|
volume_max: volumeMax ? Number(volumeMax) : undefined,
|
||||||
|
search: searchTerm || undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
setCountryOptions(options.countries || []);
|
||||||
|
setDifficultyOptions(
|
||||||
|
(options.difficulty?.levels || []).map((level) => ({
|
||||||
|
value: String(level.level),
|
||||||
|
label: `${level.level} - ${level.label}`,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load filter options:', error);
|
||||||
|
}
|
||||||
|
}, [activeSite?.industry, activeSector?.industry_sector, countryFilter, difficultyFilter, volumeMin, volumeMax, searchTerm]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadFilterOptions();
|
||||||
|
}, [loadFilterOptions]);
|
||||||
|
|
||||||
// Load counts on mount and when site/sector changes
|
// Load counts on mount and when site/sector changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (activeSite) {
|
if (activeSite) {
|
||||||
@@ -297,31 +336,39 @@ export default function IndustriesSectorsKeywords() {
|
|||||||
setShowContent(false);
|
setShowContent(false);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Get already-attached keywords for marking (lightweight check)
|
// Get already-attached keywords for marking (paginate to ensure persistence)
|
||||||
const attachedSeedKeywordIds = new Set<number>();
|
const attachedSeedKeywordIds = new Set<number>();
|
||||||
try {
|
const fetchAttachedSeedKeywordIds = async (siteId: number, sectorId?: number) => {
|
||||||
const sectors = await fetchSiteSectors(activeSite.id);
|
const pageSize = 500;
|
||||||
|
let page = 1;
|
||||||
// Check keywords in all sectors (needed for isAdded flag)
|
while (true) {
|
||||||
for (const sector of sectors) {
|
const keywordsData = await fetchKeywords({
|
||||||
try {
|
site_id: siteId,
|
||||||
const keywordsData = await fetchKeywords({
|
sector_id: sectorId,
|
||||||
site_id: activeSite.id,
|
page,
|
||||||
sector_id: sector.id,
|
page_size: pageSize,
|
||||||
page_size: 1000,
|
});
|
||||||
});
|
(keywordsData.results || []).forEach((k: any) => {
|
||||||
(keywordsData.results || []).forEach((k: any) => {
|
const seedKeywordId = k.seed_keyword_id || (k.seed_keyword && k.seed_keyword.id);
|
||||||
const seedKeywordId = k.seed_keyword_id || (k.seed_keyword && k.seed_keyword.id);
|
if (seedKeywordId) {
|
||||||
if (seedKeywordId) {
|
attachedSeedKeywordIds.add(Number(seedKeywordId));
|
||||||
attachedSeedKeywordIds.add(Number(seedKeywordId));
|
}
|
||||||
}
|
});
|
||||||
});
|
if (!keywordsData.next || (keywordsData.results || []).length < pageSize) {
|
||||||
} catch (err) {
|
break;
|
||||||
console.warn(`Could not fetch attached keywords for sector ${sector.id}:`, err);
|
|
||||||
}
|
}
|
||||||
|
page += 1;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (activeSector?.id) {
|
||||||
|
await fetchAttachedSeedKeywordIds(activeSite.id, activeSector.id);
|
||||||
|
} else {
|
||||||
|
await fetchAttachedSeedKeywordIds(activeSite.id);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn('Could not fetch sectors or attached keywords:', err);
|
console.warn('Could not fetch attached keywords:', err);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Keep attached IDs available for bulk add actions
|
// Keep attached IDs available for bulk add actions
|
||||||
@@ -342,8 +389,24 @@ export default function IndustriesSectorsKeywords() {
|
|||||||
|
|
||||||
if (searchTerm) filters.search = searchTerm;
|
if (searchTerm) filters.search = searchTerm;
|
||||||
if (countryFilter) filters.country = countryFilter;
|
if (countryFilter) filters.country = countryFilter;
|
||||||
if (volumeMin) filters.volume_min = parseInt(volumeMin);
|
if (volumeMin !== '') {
|
||||||
if (volumeMax) filters.volume_max = parseInt(volumeMax);
|
const parsed = Number(volumeMin);
|
||||||
|
if (!Number.isNaN(parsed)) {
|
||||||
|
filters.volume_min = parsed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (volumeMax !== '') {
|
||||||
|
const parsed = Number(volumeMax);
|
||||||
|
if (!Number.isNaN(parsed)) {
|
||||||
|
filters.volume_max = parsed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const useServerAvailableFilter = Boolean(showNotAddedOnly && activeSite?.id);
|
||||||
|
if (useServerAvailableFilter) {
|
||||||
|
filters.site_id = activeSite.id;
|
||||||
|
filters.available_only = true;
|
||||||
|
}
|
||||||
|
|
||||||
// Apply difficulty filter (if API supports it, otherwise we'll filter client-side)
|
// Apply difficulty filter (if API supports it, otherwise we'll filter client-side)
|
||||||
if (difficultyFilter) {
|
if (difficultyFilter) {
|
||||||
@@ -386,15 +449,19 @@ export default function IndustriesSectorsKeywords() {
|
|||||||
setAddedCount(totalAdded);
|
setAddedCount(totalAdded);
|
||||||
setAvailableCount(actualAvailable);
|
setAvailableCount(actualAvailable);
|
||||||
|
|
||||||
// Apply "not yet added" filter client-side (if API doesn't support it)
|
// Apply "not yet added" filter client-side only when server filtering isn't used
|
||||||
let filteredResults = results;
|
let filteredResults = results;
|
||||||
if (showNotAddedOnly) {
|
if (showNotAddedOnly && !useServerAvailableFilter) {
|
||||||
filteredResults = results.filter(sk => !sk.isAdded);
|
filteredResults = results.filter(sk => !sk.isAdded);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const effectiveTotalCount = useServerAvailableFilter
|
||||||
|
? apiTotalCount
|
||||||
|
: (showNotAddedOnly && activeSite ? Math.max(apiTotalCount - attachedSeedKeywordIds.size, 0) : apiTotalCount);
|
||||||
|
|
||||||
setSeedKeywords(filteredResults);
|
setSeedKeywords(filteredResults);
|
||||||
setTotalCount(apiTotalCount);
|
setTotalCount(effectiveTotalCount);
|
||||||
setTotalPages(Math.ceil(apiTotalCount / pageSizeNum));
|
setTotalPages(Math.ceil(effectiveTotalCount / pageSizeNum));
|
||||||
|
|
||||||
setShowContent(true);
|
setShowContent(true);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
@@ -409,6 +476,42 @@ export default function IndustriesSectorsKeywords() {
|
|||||||
}
|
}
|
||||||
}, [activeSite, activeSector, currentPage, pageSize, searchTerm, countryFilter, volumeMin, volumeMax, difficultyFilter, showNotAddedOnly, sortBy, sortDirection, toast]);
|
}, [activeSite, activeSector, currentPage, pageSize, searchTerm, countryFilter, volumeMin, volumeMax, difficultyFilter, showNotAddedOnly, sortBy, sortDirection, toast]);
|
||||||
|
|
||||||
|
const getAddedStatStorageKey = useCallback(() => {
|
||||||
|
if (!activeSite?.id) return null;
|
||||||
|
const sectorKey = activeSector?.id ? `sector-${activeSector.id}` : 'sector-all';
|
||||||
|
return `keywordsLibrary:addedStatActions:${activeSite.id}:${sectorKey}`;
|
||||||
|
}, [activeSite?.id, activeSector?.id]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const storageKey = getAddedStatStorageKey();
|
||||||
|
if (!storageKey) {
|
||||||
|
setAddedStatActions(new Set());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(storageKey);
|
||||||
|
if (raw) {
|
||||||
|
const parsed = JSON.parse(raw) as string[];
|
||||||
|
setAddedStatActions(new Set(parsed));
|
||||||
|
} else {
|
||||||
|
setAddedStatActions(new Set());
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to load added stat actions:', error);
|
||||||
|
setAddedStatActions(new Set());
|
||||||
|
}
|
||||||
|
}, [getAddedStatStorageKey]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const storageKey = getAddedStatStorageKey();
|
||||||
|
if (!storageKey) return;
|
||||||
|
try {
|
||||||
|
localStorage.setItem(storageKey, JSON.stringify(Array.from(addedStatActions)));
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to persist added stat actions:', error);
|
||||||
|
}
|
||||||
|
}, [addedStatActions, getAddedStatStorageKey]);
|
||||||
|
|
||||||
// Load data when site/sector/filters change (show table by default per plan)
|
// Load data when site/sector/filters change (show table by default per plan)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (activeSite) {
|
if (activeSite) {
|
||||||
@@ -621,6 +724,32 @@ export default function IndustriesSectorsKeywords() {
|
|||||||
const pageConfig = useMemo(() => {
|
const pageConfig = useMemo(() => {
|
||||||
const showSectorColumn = !activeSector;
|
const showSectorColumn = !activeSector;
|
||||||
|
|
||||||
|
const defaultCountryOptions: FilterOption[] = [
|
||||||
|
{ value: 'US', label: 'United States' },
|
||||||
|
{ value: 'CA', label: 'Canada' },
|
||||||
|
{ value: 'GB', label: 'United Kingdom' },
|
||||||
|
{ value: 'AE', label: 'United Arab Emirates' },
|
||||||
|
{ value: 'AU', label: 'Australia' },
|
||||||
|
{ value: 'IN', label: 'India' },
|
||||||
|
{ value: 'PK', label: 'Pakistan' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const countryFilterOptions = countryOptions && countryOptions.length > 0
|
||||||
|
? countryOptions
|
||||||
|
: defaultCountryOptions;
|
||||||
|
|
||||||
|
const defaultDifficultyOptions: FilterOption[] = [
|
||||||
|
{ value: '1', label: '1 - Very Easy' },
|
||||||
|
{ value: '2', label: '2 - Easy' },
|
||||||
|
{ value: '3', label: '3 - Medium' },
|
||||||
|
{ value: '4', label: '4 - Hard' },
|
||||||
|
{ value: '5', label: '5 - Very Hard' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const difficultyFilterOptions = difficultyOptions && difficultyOptions.length > 0
|
||||||
|
? difficultyOptions
|
||||||
|
: defaultDifficultyOptions;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
columns: [
|
columns: [
|
||||||
{
|
{
|
||||||
@@ -782,13 +911,7 @@ export default function IndustriesSectorsKeywords() {
|
|||||||
type: 'select' as const,
|
type: 'select' as const,
|
||||||
options: [
|
options: [
|
||||||
{ value: '', label: 'All Countries' },
|
{ value: '', label: 'All Countries' },
|
||||||
{ value: 'US', label: 'United States' },
|
...countryFilterOptions,
|
||||||
{ value: 'CA', label: 'Canada' },
|
|
||||||
{ value: 'GB', label: 'United Kingdom' },
|
|
||||||
{ value: 'AE', label: 'United Arab Emirates' },
|
|
||||||
{ value: 'AU', label: 'Australia' },
|
|
||||||
{ value: 'IN', label: 'India' },
|
|
||||||
{ value: 'PK', label: 'Pakistan' },
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -797,11 +920,7 @@ export default function IndustriesSectorsKeywords() {
|
|||||||
type: 'select' as const,
|
type: 'select' as const,
|
||||||
options: [
|
options: [
|
||||||
{ value: '', label: 'All Difficulty' },
|
{ value: '', label: 'All Difficulty' },
|
||||||
{ value: '1', label: '1 - Very Easy' },
|
...difficultyFilterOptions,
|
||||||
{ value: '2', label: '2 - Easy' },
|
|
||||||
{ value: '3', label: '3 - Medium' },
|
|
||||||
{ value: '4', label: '4 - Hard' },
|
|
||||||
{ value: '5', label: '5 - Very Hard' },
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -822,13 +941,7 @@ export default function IndustriesSectorsKeywords() {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
}, [activeSector, handleAddToWorkflow, sectors]);
|
}, [activeSector, handleAddToWorkflow, sectors, countryOptions, difficultyOptions, volumeMin, volumeMax]);
|
||||||
|
|
||||||
// Build smart suggestions from sector stats
|
|
||||||
const smartSuggestions = useMemo(() => {
|
|
||||||
if (!sectorStats) return [];
|
|
||||||
return buildSmartSuggestions(sectorStats, { showOnlyWithResults: true });
|
|
||||||
}, [sectorStats]);
|
|
||||||
|
|
||||||
// Helper: word count for keyword string
|
// Helper: word count for keyword string
|
||||||
const getWordCount = useCallback((keyword: string) => {
|
const getWordCount = useCallback((keyword: string) => {
|
||||||
@@ -839,18 +952,10 @@ export default function IndustriesSectorsKeywords() {
|
|||||||
return `${statType}:${count}`;
|
return `${statType}:${count}`;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const buildSuggestionActionKey = useCallback((suggestionId: string, count: number) => {
|
|
||||||
return `${suggestionId}:${count}`;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const isStatActionAdded = useCallback((statType: StatType, count: number) => {
|
const isStatActionAdded = useCallback((statType: StatType, count: number) => {
|
||||||
return addedStatActions.has(buildStatActionKey(statType, count));
|
return addedStatActions.has(buildStatActionKey(statType, count));
|
||||||
}, [addedStatActions, buildStatActionKey]);
|
}, [addedStatActions, buildStatActionKey]);
|
||||||
|
|
||||||
const isSuggestionActionAdded = useCallback((suggestionId: string, count: number) => {
|
|
||||||
return addedSuggestionActions.has(buildSuggestionActionKey(suggestionId, count));
|
|
||||||
}, [addedSuggestionActions, buildSuggestionActionKey]);
|
|
||||||
|
|
||||||
const fetchBulkKeywords = useCallback(async (options: {
|
const fetchBulkKeywords = useCallback(async (options: {
|
||||||
ordering: string;
|
ordering: string;
|
||||||
difficultyMax?: number;
|
difficultyMax?: number;
|
||||||
@@ -900,7 +1005,6 @@ export default function IndustriesSectorsKeywords() {
|
|||||||
label: string;
|
label: string;
|
||||||
ids: number[];
|
ids: number[];
|
||||||
actionKey: string;
|
actionKey: string;
|
||||||
group: 'stat' | 'suggestion';
|
|
||||||
}) => {
|
}) => {
|
||||||
if (payload.ids.length === 0) {
|
if (payload.ids.length === 0) {
|
||||||
toast.error('No matching keywords found for this selection');
|
toast.error('No matching keywords found for this selection');
|
||||||
@@ -910,7 +1014,6 @@ export default function IndustriesSectorsKeywords() {
|
|||||||
setBulkAddKeywordIds(payload.ids);
|
setBulkAddKeywordIds(payload.ids);
|
||||||
setBulkAddStatLabel(payload.label);
|
setBulkAddStatLabel(payload.label);
|
||||||
setPendingBulkAddKey(payload.actionKey);
|
setPendingBulkAddKey(payload.actionKey);
|
||||||
setPendingBulkAddGroup(payload.group);
|
|
||||||
setShowBulkAddModal(true);
|
setShowBulkAddModal(true);
|
||||||
}, [toast]);
|
}, [toast]);
|
||||||
|
|
||||||
@@ -985,48 +1088,6 @@ export default function IndustriesSectorsKeywords() {
|
|||||||
}
|
}
|
||||||
}, [activeStatFilter, sectorStats]);
|
}, [activeStatFilter, sectorStats]);
|
||||||
|
|
||||||
const handleSuggestionClick = useCallback((suggestion: any) => {
|
|
||||||
setActiveStatFilter(null);
|
|
||||||
setCurrentPage(1);
|
|
||||||
setSelectedIds([]);
|
|
||||||
|
|
||||||
const difficultyMax = suggestion.filterParams?.difficulty_max;
|
|
||||||
const volumeMinValue = suggestion.filterParams?.volume_min;
|
|
||||||
const availableOnly = Boolean(suggestion.filterParams?.available_only);
|
|
||||||
|
|
||||||
if (availableOnly) {
|
|
||||||
setShowNotAddedOnly(true);
|
|
||||||
} else {
|
|
||||||
setShowNotAddedOnly(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (volumeMinValue) {
|
|
||||||
setVolumeMin(String(volumeMinValue));
|
|
||||||
} else {
|
|
||||||
setVolumeMin('');
|
|
||||||
}
|
|
||||||
setVolumeMax('');
|
|
||||||
|
|
||||||
if (difficultyMax !== undefined) {
|
|
||||||
if (difficultyMax <= 20) {
|
|
||||||
setDifficultyFilter('1');
|
|
||||||
} else if (difficultyMax <= 40) {
|
|
||||||
setDifficultyFilter('2');
|
|
||||||
} else {
|
|
||||||
setDifficultyFilter('');
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
setDifficultyFilter('');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (suggestion.id === 'quick_wins') {
|
|
||||||
setSortBy('difficulty');
|
|
||||||
setSortDirection('asc');
|
|
||||||
} else {
|
|
||||||
setSortBy('volume');
|
|
||||||
setSortDirection('desc');
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Handle sector card click
|
// Handle sector card click
|
||||||
const handleSectorSelect = useCallback((sector: Sector | null) => {
|
const handleSectorSelect = useCallback((sector: Sector | null) => {
|
||||||
@@ -1117,74 +1178,9 @@ export default function IndustriesSectorsKeywords() {
|
|||||||
label: statLabelMap[statType],
|
label: statLabelMap[statType],
|
||||||
ids,
|
ids,
|
||||||
actionKey,
|
actionKey,
|
||||||
group: 'stat',
|
|
||||||
});
|
});
|
||||||
}, [activeSite, activeSector, addedStatActions, buildStatActionKey, fetchBulkKeywords, prepareBulkAdd, sectorStats, toast]);
|
}, [activeSite, activeSector, addedStatActions, buildStatActionKey, fetchBulkKeywords, prepareBulkAdd, sectorStats, toast]);
|
||||||
|
|
||||||
// Handle bulk add from suggestions
|
|
||||||
const handleSuggestionBulkAdd = useCallback(async (suggestion: any, count: number) => {
|
|
||||||
if (!activeSite || !activeSector?.industry_sector) {
|
|
||||||
toast.error('Please select a site and sector first');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const actionKey = buildSuggestionActionKey(suggestion.id, count);
|
|
||||||
if (addedSuggestionActions.has(actionKey)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const suggestionThresholds = {
|
|
||||||
quick_wins: sectorStats?.quick_wins.threshold ?? 1000,
|
|
||||||
long_tail: sectorStats?.long_tail.threshold ?? 1000,
|
|
||||||
premium_traffic: sectorStats?.premium_traffic.threshold ?? 50000,
|
|
||||||
};
|
|
||||||
|
|
||||||
let ids: number[] = [];
|
|
||||||
if (suggestion.id === 'quick_wins') {
|
|
||||||
ids = await fetchBulkKeywords({
|
|
||||||
ordering: 'difficulty',
|
|
||||||
difficultyMax: 20,
|
|
||||||
minVolume: suggestionThresholds.quick_wins,
|
|
||||||
availableOnly: true,
|
|
||||||
count,
|
|
||||||
});
|
|
||||||
} else if (suggestion.id === 'long_tail') {
|
|
||||||
ids = await fetchBulkKeywords({
|
|
||||||
ordering: '-volume',
|
|
||||||
minVolume: suggestionThresholds.long_tail,
|
|
||||||
longTail: true,
|
|
||||||
availableOnly: true,
|
|
||||||
count,
|
|
||||||
});
|
|
||||||
} else if (suggestion.id === 'premium_traffic') {
|
|
||||||
ids = await fetchBulkKeywords({
|
|
||||||
ordering: '-volume',
|
|
||||||
minVolume: suggestionThresholds.premium_traffic,
|
|
||||||
availableOnly: true,
|
|
||||||
count,
|
|
||||||
});
|
|
||||||
} else if (suggestion.id === 'available') {
|
|
||||||
ids = await fetchBulkKeywords({
|
|
||||||
ordering: '-volume',
|
|
||||||
availableOnly: true,
|
|
||||||
count,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
ids = await fetchBulkKeywords({
|
|
||||||
ordering: '-volume',
|
|
||||||
availableOnly: true,
|
|
||||||
count,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
await prepareBulkAdd({
|
|
||||||
label: suggestion.label,
|
|
||||||
ids,
|
|
||||||
actionKey,
|
|
||||||
group: 'suggestion',
|
|
||||||
});
|
|
||||||
}, [activeSite, activeSector, addedSuggestionActions, buildSuggestionActionKey, fetchBulkKeywords, prepareBulkAdd, sectorStats, toast]);
|
|
||||||
|
|
||||||
const handleConfirmBulkAdd = useCallback(async () => {
|
const handleConfirmBulkAdd = useCallback(async () => {
|
||||||
if (!activeSite || !activeSector) {
|
if (!activeSite || !activeSector) {
|
||||||
throw new Error('Please select a site and sector first');
|
throw new Error('Please select a site and sector first');
|
||||||
@@ -1206,16 +1202,10 @@ export default function IndustriesSectorsKeywords() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (pendingBulkAddKey) {
|
if (pendingBulkAddKey) {
|
||||||
if (pendingBulkAddGroup === 'stat') {
|
setAddedStatActions((prev) => new Set([...prev, pendingBulkAddKey]));
|
||||||
setAddedStatActions((prev) => new Set([...prev, pendingBulkAddKey]));
|
|
||||||
}
|
|
||||||
if (pendingBulkAddGroup === 'suggestion') {
|
|
||||||
setAddedSuggestionActions((prev) => new Set([...prev, pendingBulkAddKey]));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setPendingBulkAddKey(null);
|
setPendingBulkAddKey(null);
|
||||||
setPendingBulkAddGroup(null);
|
|
||||||
setShowBulkAddModal(false);
|
setShowBulkAddModal(false);
|
||||||
|
|
||||||
if (activeSite) {
|
if (activeSite) {
|
||||||
@@ -1229,7 +1219,7 @@ export default function IndustriesSectorsKeywords() {
|
|||||||
skipped: result.skipped || 0,
|
skipped: result.skipped || 0,
|
||||||
total_requested: bulkAddKeywordIds.length,
|
total_requested: bulkAddKeywordIds.length,
|
||||||
};
|
};
|
||||||
}, [activeSite, activeSector, bulkAddKeywordIds, loadKeywordCounts, loadSectorStats, loadSeedKeywords, pendingBulkAddGroup, pendingBulkAddKey]);
|
}, [activeSite, activeSector, bulkAddKeywordIds, loadKeywordCounts, loadSectorStats, loadSeedKeywords, pendingBulkAddKey]);
|
||||||
|
|
||||||
// Show WorkflowGuide if no sites
|
// Show WorkflowGuide if no sites
|
||||||
if (sites.length === 0) {
|
if (sites.length === 0) {
|
||||||
@@ -1269,33 +1259,21 @@ export default function IndustriesSectorsKeywords() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Sector Metric Cards - Show when site is selected */}
|
|
||||||
{activeSite && (
|
|
||||||
<div className="mx-6 mt-6">
|
|
||||||
<SectorMetricGrid
|
|
||||||
stats={sectorStats}
|
|
||||||
activeStatType={activeStatFilter}
|
|
||||||
onStatClick={handleStatClick}
|
|
||||||
onBulkAdd={activeSector ? handleMetricBulkAdd : undefined}
|
|
||||||
isAddedAction={isStatActionAdded}
|
|
||||||
clickable={true}
|
|
||||||
sectorName={activeSector?.name}
|
|
||||||
isLoading={loadingSectorStats}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Smart Suggestions Panel - Always show when site is selected */}
|
{/* Smart Suggestions Panel - Always show when site is selected */}
|
||||||
{activeSite && sectorStats && (
|
{activeSite && sectorStats && (
|
||||||
<div className="mx-6 mt-6">
|
<div className="mx-6 mt-6">
|
||||||
<SmartSuggestions
|
<SmartSuggestions isLoading={loadingSectorStats}>
|
||||||
suggestions={smartSuggestions}
|
<SectorMetricGrid
|
||||||
onSuggestionClick={handleSuggestionClick}
|
stats={sectorStats}
|
||||||
onBulkAdd={activeSector ? handleSuggestionBulkAdd : undefined}
|
activeStatType={activeStatFilter}
|
||||||
isAddedAction={isSuggestionActionAdded}
|
onStatClick={handleStatClick}
|
||||||
enableFilterClick={true}
|
onBulkAdd={activeSector ? handleMetricBulkAdd : undefined}
|
||||||
isLoading={loadingSectorStats}
|
isAddedAction={isStatActionAdded}
|
||||||
/>
|
clickable={true}
|
||||||
|
sectorName={activeSector?.name}
|
||||||
|
isLoading={false}
|
||||||
|
/>
|
||||||
|
</SmartSuggestions>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -1512,7 +1490,6 @@ export default function IndustriesSectorsKeywords() {
|
|||||||
setBulkAddKeywordIds([]);
|
setBulkAddKeywordIds([]);
|
||||||
setBulkAddStatLabel(undefined);
|
setBulkAddStatLabel(undefined);
|
||||||
setPendingBulkAddKey(null);
|
setPendingBulkAddKey(null);
|
||||||
setPendingBulkAddGroup(null);
|
|
||||||
}}
|
}}
|
||||||
onConfirm={handleConfirmBulkAdd}
|
onConfirm={handleConfirmBulkAdd}
|
||||||
keywordCount={bulkAddKeywordIds.length}
|
keywordCount={bulkAddKeywordIds.length}
|
||||||
|
|||||||
@@ -2354,6 +2354,7 @@ export interface SectorStats {
|
|||||||
total: SectorStatResult;
|
total: SectorStatResult;
|
||||||
available: SectorStatResult;
|
available: SectorStatResult;
|
||||||
high_volume: SectorStatResult;
|
high_volume: SectorStatResult;
|
||||||
|
mid_volume?: SectorStatResult;
|
||||||
premium_traffic: SectorStatResult;
|
premium_traffic: SectorStatResult;
|
||||||
long_tail: SectorStatResult;
|
long_tail: SectorStatResult;
|
||||||
quick_wins: SectorStatResult;
|
quick_wins: SectorStatResult;
|
||||||
@@ -2411,16 +2412,37 @@ export interface DifficultyLevel {
|
|||||||
export interface FilterOptionsResponse {
|
export interface FilterOptionsResponse {
|
||||||
industries: FilterIndustryOption[];
|
industries: FilterIndustryOption[];
|
||||||
sectors: FilterSectorOption[];
|
sectors: FilterSectorOption[];
|
||||||
|
countries?: FilterOption[];
|
||||||
difficulty: {
|
difficulty: {
|
||||||
range: { min_difficulty: number; max_difficulty: number };
|
range: { min_difficulty: number; max_difficulty: number };
|
||||||
levels: DifficultyLevel[];
|
levels: Array<DifficultyLevel & { keyword_count?: number }>;
|
||||||
};
|
};
|
||||||
volume: { min_volume: number; max_volume: number };
|
volume: { min_volume: number; max_volume: number };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchKeywordsLibraryFilterOptions(industryId?: number): Promise<FilterOptionsResponse> {
|
export interface KeywordsLibraryFilterOptionsRequest {
|
||||||
|
industry_id?: number;
|
||||||
|
sector_id?: number;
|
||||||
|
country?: string;
|
||||||
|
difficulty_min?: number;
|
||||||
|
difficulty_max?: number;
|
||||||
|
volume_min?: number;
|
||||||
|
volume_max?: number;
|
||||||
|
search?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchKeywordsLibraryFilterOptions(
|
||||||
|
filters?: KeywordsLibraryFilterOptionsRequest
|
||||||
|
): Promise<FilterOptionsResponse> {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
if (industryId) params.append('industry_id', industryId.toString());
|
if (filters?.industry_id) params.append('industry_id', filters.industry_id.toString());
|
||||||
|
if (filters?.sector_id) params.append('sector_id', filters.sector_id.toString());
|
||||||
|
if (filters?.country) params.append('country', filters.country);
|
||||||
|
if (filters?.difficulty_min !== undefined) params.append('difficulty_min', filters.difficulty_min.toString());
|
||||||
|
if (filters?.difficulty_max !== undefined) params.append('difficulty_max', filters.difficulty_max.toString());
|
||||||
|
if (filters?.volume_min !== undefined) params.append('volume_min', filters.volume_min.toString());
|
||||||
|
if (filters?.volume_max !== undefined) params.append('volume_max', filters.volume_max.toString());
|
||||||
|
if (filters?.search) params.append('search', filters.search);
|
||||||
const queryString = params.toString();
|
const queryString = params.toString();
|
||||||
|
|
||||||
return fetchAPI(`/v1/auth/keywords-library/filter_options/${queryString ? `?${queryString}` : ''}`);
|
return fetchAPI(`/v1/auth/keywords-library/filter_options/${queryString ? `?${queryString}` : ''}`);
|
||||||
|
|||||||
@@ -234,8 +234,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.keywords-library-sector-card .sector-card-active-dot {
|
.keywords-library-sector-card .sector-card-active-dot {
|
||||||
width: 8px;
|
width: 16px;
|
||||||
height: 8px;
|
height: 16px;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
background: var(--color-primary);
|
background: var(--color-primary);
|
||||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-primary) 20%, transparent);
|
box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-primary) 20%, transparent);
|
||||||
|
|||||||
Reference in New Issue
Block a user