keywrods library fixes
This commit is contained in:
@@ -869,6 +869,8 @@ class SeedKeywordViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
difficulty_max = self.request.query_params.get('difficulty_max')
|
||||
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,
|
||||
|
||||
@@ -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">> 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>
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}` : ''}`);
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user