keywrods library fixes

This commit is contained in:
IGNY8 VPS (Salman)
2026-01-18 20:55:02 +00:00
parent 4cf27fa875
commit 05bc433c80
8 changed files with 435 additions and 520 deletions

View File

@@ -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,

View File

@@ -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">&gt; 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>
); );

View File

@@ -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

View File

@@ -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;
}

View File

@@ -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';

View File

@@ -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}

View File

@@ -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}` : ''}`);

View File

@@ -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);