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')
volume_min = self.request.query_params.get('volume_min')
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:
queryset = queryset.filter(industry_id=industry_id)
@@ -902,6 +904,19 @@ class SeedKeywordViewSet(viewsets.ReadOnlyModelViewSet):
queryset = queryset.filter(volume__lte=int(volume_max))
except (ValueError, TypeError):
pass
# Availability filter - exclude keywords already added to the site
if available_only and str(available_only).lower() in ['true', '1', 'yes']:
if site_id:
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
@@ -1152,6 +1167,9 @@ class SeedKeywordViewSet(viewsets.ReadOnlyModelViewSet):
# 3. High Volume (>= 10K) - simple threshold
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)
premium_thresholds = [50000, 25000, 10000]
@@ -1182,6 +1200,7 @@ class SeedKeywordViewSet(viewsets.ReadOnlyModelViewSet):
'total': {'count': total_count},
'available': {'count': available_count},
'high_volume': {'count': high_volume_count, 'threshold': 10000},
'mid_volume': {'count': mid_volume_count, 'threshold': 5000},
'premium_traffic': premium_result,
'long_tail': long_tail_result,
'quick_wins': quick_wins_result,
@@ -1201,6 +1220,7 @@ class SeedKeywordViewSet(viewsets.ReadOnlyModelViewSet):
sector_available = count_available(sector_qs)
sector_high_volume = sector_qs.filter(volume__gte=10000).count()
sector_mid_volume = sector_qs.filter(volume__gte=5000, volume__lt=10000).count()
sector_premium = get_count_with_fallback(sector_qs, premium_thresholds)
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},
'available': {'count': sector_available},
'high_volume': {'count': sector_high_volume, 'threshold': 10000},
'mid_volume': {'count': sector_mid_volume, 'threshold': 5000},
'premium_traffic': sector_premium,
'long_tail': sector_long_tail,
'quick_wins': sector_quick_wins,
@@ -1243,12 +1264,20 @@ class SeedKeywordViewSet(viewsets.ReadOnlyModelViewSet):
"""
Get cascading filter options for Keywords Library.
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
try:
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
industries = Industry.objects.annotate(
keyword_count=Count('seed_keywords', filter=Q(seed_keywords__is_active=True))
@@ -1276,31 +1305,120 @@ class SeedKeywordViewSet(viewsets.ReadOnlyModelViewSet):
'slug': sec.slug,
'keyword_count': sec.keyword_count,
} for sec in sectors]
# Get difficulty range
difficulty_range = SeedKeyword.objects.filter(is_active=True).aggregate(
# Base queryset for cascading options
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'),
max_difficulty=Max('difficulty')
)
# Get volume range
volume_range = SeedKeyword.objects.filter(is_active=True).aggregate(
# Volume range (filtered by current non-volume filters)
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'),
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 = {
'industries': industries_data,
'sectors': sectors_data,
'countries': countries_data,
'difficulty': {
'range': difficulty_range,
'levels': difficulty_levels,

View File

@@ -62,6 +62,8 @@ export default function SectorCardsGrid({
const total = stats?.total.count ?? 0;
const available = stats?.available.count ?? 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;
return (
@@ -74,7 +76,7 @@ export default function SectorCardsGrid({
)}
>
<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 className="flex items-center gap-2">
<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>
)}
{isActive && (
<div className="mt-2">
<Badge color="info" size="sm" variant="light">
Active
</Badge>
</div>
)}
</div>
{isActive && (
<Badge color="info" size="sm" variant="light">
Active
</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">
<div className="text-right">
<div className="text-xs text-gray-500 dark:text-gray-400">Total</div>
<div className="text-2xl font-semibold text-gray-900 dark:text-white">
{total.toLocaleString()}
</div>
</div>
<div className="sector-stats-pill">
<div className="label">Available</div>
<div className="value text-base">
</div>
<div className="mt-4 grid grid-cols-2 gap-3">
<div>
<div className="text-xs font-semibold text-success-600 dark:text-success-400">Available</div>
<div className="text-sm font-medium text-gray-900 dark:text-white">
{available.toLocaleString()}
</div>
</div>
<div className="sector-stats-pill">
<div className="label">In Workflow</div>
<div className="value text-base">
<div>
<div className="text-xs font-semibold text-brand-600 dark:text-brand-400">In Workflow</div>
<div className="text-sm font-medium text-gray-900 dark:text-white">
{inWorkflow.toLocaleString()}
</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>
</Card>
);

View File

@@ -43,6 +43,9 @@ const STAT_CONFIG: Record<StatType, {
description: string;
icon: ReactNode;
accentColor: string;
borderColor: string;
ringColor: string;
dotColor: string;
textColor: string;
badgeColor: string;
showThreshold?: boolean;
@@ -53,6 +56,9 @@ const STAT_CONFIG: Record<StatType, {
description: 'All keywords in sector',
icon: <PieChartIcon className="w-5 h-5" />,
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',
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',
icon: <CheckCircleIcon className="w-5 h-5" />,
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',
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',
icon: <ShootingStarIcon className="w-5 h-5" />,
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',
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',
icon: <BoxIcon className="w-5 h-5" />,
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',
badgeColor: 'bg-warning-100 text-warning-700 dark:bg-warning-900/40 dark:text-warning-300',
showThreshold: true,
@@ -87,6 +102,9 @@ const STAT_CONFIG: Record<StatType, {
description: '4+ words with good volume',
icon: <DocsIcon className="w-5 h-5" />,
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',
badgeColor: 'bg-purple-100 text-purple-700 dark:bg-purple-900/40 dark:text-purple-300',
showThreshold: true,
@@ -97,6 +115,9 @@ const STAT_CONFIG: Record<StatType, {
description: 'Low difficulty, good volume',
icon: <BoltIcon className="w-5 h-5" />,
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',
badgeColor: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300',
showThreshold: true,
@@ -189,7 +210,7 @@ export default function SectorMetricCard({
: 'cursor-default',
compact ? 'p-3' : 'p-4',
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',
)}
>
@@ -208,7 +229,7 @@ export default function SectorMetricCard({
</div>
<div className="flex items-center gap-2">
{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)}>
{formatCount(statData.count)}
@@ -227,26 +248,28 @@ export default function SectorMetricCard({
<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">
<PlusIcon className="w-3 h-3" />
Add to workflow
ADD
</span>
{bulkAddOptions.map((count) => {
const isAdded = isAddedAction ? isAddedAction(statType, 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={() => handleAddClick(count)}
disabled={isAdded}
>
{isAdded ? 'Added' : (count === statData.count ? 'All' : count)}
</Button>
);
})}
<div className="ml-auto flex flex-wrap items-center gap-2 justify-end">
{bulkAddOptions.map((count) => {
const isAdded = isAddedAction ? isAddedAction(statType, 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={() => handleAddClick(count)}
disabled={isAdded}
>
{isAdded ? 'Added' : (count === statData.count ? 'All' : count)}
</Button>
);
})}
</div>
</div>
</div>
)}
@@ -285,7 +308,7 @@ export function SectorMetricGrid({
return (
<div className={clsx(
'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) => (
<div
@@ -308,7 +331,7 @@ export function SectorMetricGrid({
return (
<div className={clsx(
'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) => (
<SectorMetricCard

View File

@@ -1,88 +1,27 @@
/**
* SmartSuggestions - Compact card-based suggestions for Keywords Library
* White background with accent colors like Dashboard cards
* Shows keyword categories with counts and bulk add options
* SmartSuggestions - Container for smart suggestions area
* Shows header and optional content, with breathing indicator when empty
*/
import { useState } from 'react';
import { ReactNode, useState } from 'react';
import clsx from 'clsx';
import { ShootingStarIcon, ChevronDownIcon, PlusIcon, BoltIcon, DocsIcon, BoxIcon, CheckCircleIcon } 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';
}
import { ShootingStarIcon, ChevronDownIcon } from '../../icons';
interface SmartSuggestionsProps {
suggestions: SmartSuggestion[];
onSuggestionClick: (suggestion: SmartSuggestion) => void;
onBulkAdd?: (suggestion: SmartSuggestion, count: number) => void;
isAddedAction?: (suggestionId: string, count: number) => boolean;
enableFilterClick?: boolean;
children?: ReactNode;
showEmptyState?: boolean;
className?: string;
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({
suggestions,
onSuggestionClick,
onBulkAdd,
isAddedAction,
enableFilterClick = true,
children,
showEmptyState = false,
className,
isLoading = false,
}: SmartSuggestionsProps) {
const [isExpanded, setIsExpanded] = useState(true);
const totalAvailable = suggestions.reduce((sum, s) => sum + s.count, 0);
const hasKeywords = totalAvailable > 0;
const hasContent = Boolean(children);
if (isLoading) {
return (
@@ -98,8 +37,7 @@ export default function SmartSuggestions({
);
}
// Show breathing indicator when no suggestions (waiting for data)
if (suggestions.length === 0) {
if (showEmptyState && !hasContent) {
return (
<div className={clsx(
'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" />
</div>
<div className="flex items-center gap-3">
{/* Breathing circle indicator */}
<div className="w-3 h-3 rounded-full bg-brand-500 animate-pulse" />
<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...
@@ -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',
className
)}>
{/* Header */}
<button
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"
>
<div className="flex items-center gap-3">
{/* Icon with breathing animation */}
<div className={clsx(
'w-8 h-8 rounded-lg flex items-center justify-center',
'bg-gradient-to-br from-brand-500 to-purple-500',
hasKeywords && 'animate-pulse'
hasContent && 'animate-pulse'
)}>
<ShootingStarIcon className="w-4 h-4 text-white" />
</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
{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>
<p className="text-xs text-gray-500 dark:text-gray-400">
Ready-to-use keywords waiting for you!
</p>
</div>
</div>
<ChevronDownIcon
<ChevronDownIcon
className={clsx(
'w-5 h-5 text-gray-400 transition-transform duration-200',
isExpanded && 'rotate-180'
)}
)}
/>
</button>
{/* Expandable content - Grid layout */}
{isExpanded && (
<div className="px-4 pb-4">
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
{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>
{children}
</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 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';

View File

@@ -25,6 +25,8 @@ import {
fetchSectorStats,
SectorStats,
SectorStatsItem,
fetchKeywordsLibraryFilterOptions,
FilterOption,
} from '../../services/api';
import Badge from '../../components/ui/badge/Badge';
import { BoltIcon, ShootingStarIcon } from '../../icons';
@@ -41,7 +43,7 @@ import Label from '../../components/form/Label';
import Input from '../../components/form/input/InputField';
import { Card } from '../../components/ui/card';
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 BulkAddConfirmation from '../../components/keywords-library/BulkAddConfirmation';
@@ -72,9 +74,7 @@ export default function IndustriesSectorsKeywords() {
const [bulkAddKeywordIds, setBulkAddKeywordIds] = useState<number[]>([]);
const [bulkAddStatLabel, setBulkAddStatLabel] = useState<string | undefined>();
const [pendingBulkAddKey, setPendingBulkAddKey] = useState<string | null>(null);
const [pendingBulkAddGroup, setPendingBulkAddGroup] = useState<'stat' | 'suggestion' | null>(null);
const [addedStatActions, setAddedStatActions] = useState<Set<string>>(new Set());
const [addedSuggestionActions, setAddedSuggestionActions] = useState<Set<string>>(new Set());
// Ahrefs banner state
const [showAhrefsBanner, setShowAhrefsBanner] = useState(true);
@@ -95,6 +95,10 @@ export default function IndustriesSectorsKeywords() {
const [showNotAddedOnly, setShowNotAddedOnly] = useState(false);
const [volumeMin, setVolumeMin] = 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
const [addedCount, setAddedCount] = useState(0);
@@ -267,7 +271,6 @@ export default function IndustriesSectorsKeywords() {
setCurrentPage(1);
setSelectedIds([]);
setAddedStatActions(new Set());
setAddedSuggestionActions(new Set());
}, [activeSite?.id]);
// Reset pagination/selection when sector changes
@@ -275,8 +278,44 @@ export default function IndustriesSectorsKeywords() {
setActiveStatFilter(null);
setCurrentPage(1);
setSelectedIds([]);
setAddedStatActions(new Set());
}, [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
useEffect(() => {
if (activeSite) {
@@ -297,31 +336,39 @@ export default function IndustriesSectorsKeywords() {
setShowContent(false);
try {
// Get already-attached keywords for marking (lightweight check)
// Get already-attached keywords for marking (paginate to ensure persistence)
const attachedSeedKeywordIds = new Set<number>();
try {
const sectors = await fetchSiteSectors(activeSite.id);
// Check keywords in all sectors (needed for isAdded flag)
for (const sector of sectors) {
try {
const keywordsData = await fetchKeywords({
site_id: activeSite.id,
sector_id: sector.id,
page_size: 1000,
});
(keywordsData.results || []).forEach((k: any) => {
const seedKeywordId = k.seed_keyword_id || (k.seed_keyword && k.seed_keyword.id);
if (seedKeywordId) {
attachedSeedKeywordIds.add(Number(seedKeywordId));
}
});
} catch (err) {
console.warn(`Could not fetch attached keywords for sector ${sector.id}:`, err);
const fetchAttachedSeedKeywordIds = async (siteId: number, sectorId?: number) => {
const pageSize = 500;
let page = 1;
while (true) {
const keywordsData = await fetchKeywords({
site_id: siteId,
sector_id: sectorId,
page,
page_size: pageSize,
});
(keywordsData.results || []).forEach((k: any) => {
const seedKeywordId = k.seed_keyword_id || (k.seed_keyword && k.seed_keyword.id);
if (seedKeywordId) {
attachedSeedKeywordIds.add(Number(seedKeywordId));
}
});
if (!keywordsData.next || (keywordsData.results || []).length < pageSize) {
break;
}
page += 1;
}
};
try {
if (activeSector?.id) {
await fetchAttachedSeedKeywordIds(activeSite.id, activeSector.id);
} else {
await fetchAttachedSeedKeywordIds(activeSite.id);
}
} 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
@@ -342,8 +389,24 @@ export default function IndustriesSectorsKeywords() {
if (searchTerm) filters.search = searchTerm;
if (countryFilter) filters.country = countryFilter;
if (volumeMin) filters.volume_min = parseInt(volumeMin);
if (volumeMax) filters.volume_max = parseInt(volumeMax);
if (volumeMin !== '') {
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)
if (difficultyFilter) {
@@ -386,15 +449,19 @@ export default function IndustriesSectorsKeywords() {
setAddedCount(totalAdded);
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;
if (showNotAddedOnly) {
if (showNotAddedOnly && !useServerAvailableFilter) {
filteredResults = results.filter(sk => !sk.isAdded);
}
const effectiveTotalCount = useServerAvailableFilter
? apiTotalCount
: (showNotAddedOnly && activeSite ? Math.max(apiTotalCount - attachedSeedKeywordIds.size, 0) : apiTotalCount);
setSeedKeywords(filteredResults);
setTotalCount(apiTotalCount);
setTotalPages(Math.ceil(apiTotalCount / pageSizeNum));
setTotalCount(effectiveTotalCount);
setTotalPages(Math.ceil(effectiveTotalCount / pageSizeNum));
setShowContent(true);
} catch (error: any) {
@@ -409,6 +476,42 @@ export default function IndustriesSectorsKeywords() {
}
}, [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)
useEffect(() => {
if (activeSite) {
@@ -621,6 +724,32 @@ export default function IndustriesSectorsKeywords() {
const pageConfig = useMemo(() => {
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 {
columns: [
{
@@ -782,13 +911,7 @@ export default function IndustriesSectorsKeywords() {
type: 'select' as const,
options: [
{ value: '', label: 'All Countries' },
{ 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' },
...countryFilterOptions,
],
},
{
@@ -797,11 +920,7 @@ export default function IndustriesSectorsKeywords() {
type: 'select' as const,
options: [
{ value: '', label: 'All Difficulty' },
{ 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' },
...difficultyFilterOptions,
],
},
{
@@ -822,13 +941,7 @@ export default function IndustriesSectorsKeywords() {
},
],
};
}, [activeSector, handleAddToWorkflow, sectors]);
// Build smart suggestions from sector stats
const smartSuggestions = useMemo(() => {
if (!sectorStats) return [];
return buildSmartSuggestions(sectorStats, { showOnlyWithResults: true });
}, [sectorStats]);
}, [activeSector, handleAddToWorkflow, sectors, countryOptions, difficultyOptions, volumeMin, volumeMax]);
// Helper: word count for keyword string
const getWordCount = useCallback((keyword: string) => {
@@ -839,18 +952,10 @@ export default function IndustriesSectorsKeywords() {
return `${statType}:${count}`;
}, []);
const buildSuggestionActionKey = useCallback((suggestionId: string, count: number) => {
return `${suggestionId}:${count}`;
}, []);
const isStatActionAdded = useCallback((statType: StatType, count: number) => {
return addedStatActions.has(buildStatActionKey(statType, count));
}, [addedStatActions, buildStatActionKey]);
const isSuggestionActionAdded = useCallback((suggestionId: string, count: number) => {
return addedSuggestionActions.has(buildSuggestionActionKey(suggestionId, count));
}, [addedSuggestionActions, buildSuggestionActionKey]);
const fetchBulkKeywords = useCallback(async (options: {
ordering: string;
difficultyMax?: number;
@@ -900,7 +1005,6 @@ export default function IndustriesSectorsKeywords() {
label: string;
ids: number[];
actionKey: string;
group: 'stat' | 'suggestion';
}) => {
if (payload.ids.length === 0) {
toast.error('No matching keywords found for this selection');
@@ -910,7 +1014,6 @@ export default function IndustriesSectorsKeywords() {
setBulkAddKeywordIds(payload.ids);
setBulkAddStatLabel(payload.label);
setPendingBulkAddKey(payload.actionKey);
setPendingBulkAddGroup(payload.group);
setShowBulkAddModal(true);
}, [toast]);
@@ -985,48 +1088,6 @@ export default function IndustriesSectorsKeywords() {
}
}, [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
const handleSectorSelect = useCallback((sector: Sector | null) => {
@@ -1117,74 +1178,9 @@ export default function IndustriesSectorsKeywords() {
label: statLabelMap[statType],
ids,
actionKey,
group: 'stat',
});
}, [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 () => {
if (!activeSite || !activeSector) {
throw new Error('Please select a site and sector first');
@@ -1206,16 +1202,10 @@ export default function IndustriesSectorsKeywords() {
}
if (pendingBulkAddKey) {
if (pendingBulkAddGroup === 'stat') {
setAddedStatActions((prev) => new Set([...prev, pendingBulkAddKey]));
}
if (pendingBulkAddGroup === 'suggestion') {
setAddedSuggestionActions((prev) => new Set([...prev, pendingBulkAddKey]));
}
setAddedStatActions((prev) => new Set([...prev, pendingBulkAddKey]));
}
setPendingBulkAddKey(null);
setPendingBulkAddGroup(null);
setShowBulkAddModal(false);
if (activeSite) {
@@ -1229,7 +1219,7 @@ export default function IndustriesSectorsKeywords() {
skipped: result.skipped || 0,
total_requested: bulkAddKeywordIds.length,
};
}, [activeSite, activeSector, bulkAddKeywordIds, loadKeywordCounts, loadSectorStats, loadSeedKeywords, pendingBulkAddGroup, pendingBulkAddKey]);
}, [activeSite, activeSector, bulkAddKeywordIds, loadKeywordCounts, loadSectorStats, loadSeedKeywords, pendingBulkAddKey]);
// Show WorkflowGuide if no sites
if (sites.length === 0) {
@@ -1269,33 +1259,21 @@ export default function IndustriesSectorsKeywords() {
</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 */}
{activeSite && sectorStats && (
<div className="mx-6 mt-6">
<SmartSuggestions
suggestions={smartSuggestions}
onSuggestionClick={handleSuggestionClick}
onBulkAdd={activeSector ? handleSuggestionBulkAdd : undefined}
isAddedAction={isSuggestionActionAdded}
enableFilterClick={true}
isLoading={loadingSectorStats}
/>
<SmartSuggestions isLoading={loadingSectorStats}>
<SectorMetricGrid
stats={sectorStats}
activeStatType={activeStatFilter}
onStatClick={handleStatClick}
onBulkAdd={activeSector ? handleMetricBulkAdd : undefined}
isAddedAction={isStatActionAdded}
clickable={true}
sectorName={activeSector?.name}
isLoading={false}
/>
</SmartSuggestions>
</div>
)}
@@ -1512,7 +1490,6 @@ export default function IndustriesSectorsKeywords() {
setBulkAddKeywordIds([]);
setBulkAddStatLabel(undefined);
setPendingBulkAddKey(null);
setPendingBulkAddGroup(null);
}}
onConfirm={handleConfirmBulkAdd}
keywordCount={bulkAddKeywordIds.length}

View File

@@ -2354,6 +2354,7 @@ export interface SectorStats {
total: SectorStatResult;
available: SectorStatResult;
high_volume: SectorStatResult;
mid_volume?: SectorStatResult;
premium_traffic: SectorStatResult;
long_tail: SectorStatResult;
quick_wins: SectorStatResult;
@@ -2411,16 +2412,37 @@ export interface DifficultyLevel {
export interface FilterOptionsResponse {
industries: FilterIndustryOption[];
sectors: FilterSectorOption[];
countries?: FilterOption[];
difficulty: {
range: { min_difficulty: number; max_difficulty: number };
levels: DifficultyLevel[];
levels: Array<DifficultyLevel & { keyword_count?: 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();
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();
return fetchAPI(`/v1/auth/keywords-library/filter_options/${queryString ? `?${queryString}` : ''}`);

View File

@@ -234,8 +234,8 @@
}
.keywords-library-sector-card .sector-card-active-dot {
width: 8px;
height: 8px;
width: 16px;
height: 16px;
border-radius: 999px;
background: var(--color-primary);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-primary) 20%, transparent);