keywrod slibrary page dsigning
This commit is contained in:
@@ -867,6 +867,8 @@ class SeedKeywordViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
sector_name = self.request.query_params.get('sector_name')
|
||||
difficulty_min = self.request.query_params.get('difficulty_min')
|
||||
difficulty_max = self.request.query_params.get('difficulty_max')
|
||||
volume_min = self.request.query_params.get('volume_min')
|
||||
volume_max = self.request.query_params.get('volume_max')
|
||||
|
||||
if industry_id:
|
||||
queryset = queryset.filter(industry_id=industry_id)
|
||||
@@ -888,6 +890,18 @@ class SeedKeywordViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
queryset = queryset.filter(difficulty__lte=int(difficulty_max))
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
# Volume range filtering
|
||||
if volume_min is not None:
|
||||
try:
|
||||
queryset = queryset.filter(volume__gte=int(volume_min))
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
if volume_max is not None:
|
||||
try:
|
||||
queryset = queryset.filter(volume__lte=int(volume_max))
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
return queryset
|
||||
|
||||
@@ -1106,9 +1120,9 @@ class SeedKeywordViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
# Get already-added keyword IDs if site_id provided
|
||||
already_added_ids = set()
|
||||
if site_id:
|
||||
from igny8_core.business.models import SiteKeyword
|
||||
from igny8_core.business.planning.models import Keywords
|
||||
already_added_ids = set(
|
||||
SiteKeyword.objects.filter(
|
||||
Keywords.objects.filter(
|
||||
site_id=site_id,
|
||||
seed_keyword__isnull=False
|
||||
).values_list('seed_keyword_id', flat=True)
|
||||
@@ -1230,7 +1244,7 @@ class SeedKeywordViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
Get cascading filter options for Keywords Library.
|
||||
Returns industries, sectors (filtered by industry), and available filter values.
|
||||
"""
|
||||
from django.db.models import Count, Min, Max
|
||||
from django.db.models import Count, Min, Max, Q
|
||||
|
||||
try:
|
||||
industry_id = request.query_params.get('industry_id')
|
||||
@@ -1310,7 +1324,7 @@ class SeedKeywordViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
Accepts a list of seed_keyword IDs and adds them to the specified site.
|
||||
"""
|
||||
from django.db import transaction
|
||||
from igny8_core.business.models import SiteKeyword
|
||||
from igny8_core.business.planning.models import Keywords
|
||||
|
||||
try:
|
||||
site_id = request.data.get('site_id')
|
||||
@@ -1331,7 +1345,7 @@ class SeedKeywordViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
)
|
||||
|
||||
# Verify site access
|
||||
from igny8_core.business.models import Site
|
||||
from igny8_core.auth.models import Site
|
||||
site = Site.objects.filter(id=site_id).first()
|
||||
if not site:
|
||||
return error_response(
|
||||
@@ -1365,12 +1379,19 @@ class SeedKeywordViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
|
||||
# Get already existing
|
||||
existing_seed_ids = set(
|
||||
SiteKeyword.objects.filter(
|
||||
Keywords.objects.filter(
|
||||
site_id=site_id,
|
||||
seed_keyword_id__in=keyword_ids
|
||||
).values_list('seed_keyword_id', flat=True)
|
||||
)
|
||||
|
||||
# Get site sectors mapped by industry_sector_id for fast lookup
|
||||
from igny8_core.auth.models import Sector
|
||||
site_sectors = {
|
||||
s.industry_sector_id: s
|
||||
for s in Sector.objects.filter(site=site, is_deleted=False, is_active=True)
|
||||
}
|
||||
|
||||
added_count = 0
|
||||
skipped_count = 0
|
||||
|
||||
@@ -1380,14 +1401,17 @@ class SeedKeywordViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
skipped_count += 1
|
||||
continue
|
||||
|
||||
SiteKeyword.objects.create(
|
||||
# Find the site's sector that matches this keyword's industry_sector
|
||||
site_sector = site_sectors.get(seed_kw.sector_id)
|
||||
if not site_sector:
|
||||
# Skip if site doesn't have this sector
|
||||
skipped_count += 1
|
||||
continue
|
||||
|
||||
Keywords.objects.create(
|
||||
site=site,
|
||||
keyword=seed_kw.keyword,
|
||||
sector=site_sector,
|
||||
seed_keyword=seed_kw,
|
||||
volume=seed_kw.volume,
|
||||
difficulty=seed_kw.difficulty,
|
||||
source='library',
|
||||
is_active=True
|
||||
)
|
||||
added_count += 1
|
||||
|
||||
|
||||
123
frontend/src/components/keywords-library/SectorCardsGrid.tsx
Normal file
123
frontend/src/components/keywords-library/SectorCardsGrid.tsx
Normal file
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* SectorCardsGrid - Shows site sectors with keyword stats
|
||||
* Click a card to filter the table by that sector
|
||||
*/
|
||||
|
||||
import clsx from 'clsx';
|
||||
import { Card } from '../ui/card';
|
||||
import Badge from '../ui/badge/Badge';
|
||||
import type { Sector } from '../../store/sectorStore';
|
||||
import type { SectorStats, SectorStatsItem } from '../../services/api';
|
||||
|
||||
interface SectorCardsGridProps {
|
||||
sectors: Sector[];
|
||||
sectorStats: SectorStatsItem[];
|
||||
activeSectorId?: number | null;
|
||||
onSelectSector: (sector: Sector | null) => void;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
function buildStatsMap(sectorStats: SectorStatsItem[]) {
|
||||
const map = new Map<number, SectorStats>();
|
||||
sectorStats.forEach((item) => {
|
||||
map.set(item.sector_id, item.stats);
|
||||
});
|
||||
return map;
|
||||
}
|
||||
|
||||
export default function SectorCardsGrid({
|
||||
sectors,
|
||||
sectorStats,
|
||||
activeSectorId,
|
||||
onSelectSector,
|
||||
isLoading = false,
|
||||
}: SectorCardsGridProps) {
|
||||
const statsMap = buildStatsMap(sectorStats);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="keywords-library-sector-grid">
|
||||
{Array.from({ length: Math.max(1, sectors.length || 4) }).map((_, idx) => (
|
||||
<Card key={idx} className="keywords-library-sector-card animate-pulse">
|
||||
<div className="h-4 w-32 bg-gray-200 dark:bg-gray-700 rounded" />
|
||||
<div className="mt-4 grid grid-cols-3 gap-2">
|
||||
<div className="h-6 bg-gray-200 dark:bg-gray-700 rounded" />
|
||||
<div className="h-6 bg-gray-200 dark:bg-gray-700 rounded" />
|
||||
<div className="h-6 bg-gray-200 dark:bg-gray-700 rounded" />
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (sectors.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="keywords-library-sector-grid">
|
||||
{sectors.map((sector) => {
|
||||
const stats = sector.industry_sector ? statsMap.get(sector.industry_sector) : undefined;
|
||||
const total = stats?.total.count ?? 0;
|
||||
const available = stats?.available.count ?? 0;
|
||||
const inWorkflow = Math.max(total - available, 0);
|
||||
const isActive = activeSectorId === sector.id;
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={sector.id}
|
||||
onClick={() => onSelectSector(sector)}
|
||||
className={clsx(
|
||||
'keywords-library-sector-card',
|
||||
isActive ? 'is-active' : ''
|
||||
)}
|
||||
>
|
||||
<div className="sector-card-accent" />
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h4 className="text-base font-semibold text-gray-900 dark:text-white">
|
||||
{sector.name}
|
||||
</h4>
|
||||
{isActive && <span className="sector-card-active-dot" />}
|
||||
</div>
|
||||
{sector.industry_sector ? (
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">Sector keywords</span>
|
||||
) : (
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">Not linked to template</span>
|
||||
)}
|
||||
</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">
|
||||
{total.toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
<div className="sector-stats-pill">
|
||||
<div className="label">Available</div>
|
||||
<div className="value text-base">
|
||||
{available.toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
<div className="sector-stats-pill">
|
||||
<div className="label">In Workflow</div>
|
||||
<div className="value text-base">
|
||||
{inWorkflow.toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +1,13 @@
|
||||
/**
|
||||
* SectorMetricCard - Clickable metric cards for Keywords Library
|
||||
* Displays 6 stat types with dynamic fallback thresholds
|
||||
* Clicking a card filters the table to show matching keywords
|
||||
* SectorMetricCard - Redesigned metric cards for Keywords Library
|
||||
* White background with accent colors like Dashboard cards
|
||||
* Shows 6 stat types with click-to-filter and bulk add options
|
||||
*/
|
||||
|
||||
import { ReactNode, useMemo } from 'react';
|
||||
import clsx from 'clsx';
|
||||
import { PieChartIcon, CheckCircleIcon, ShootingStarIcon, BoltIcon, DocsIcon, BoxIcon } from '../../icons';
|
||||
import { PieChartIcon, CheckCircleIcon, ShootingStarIcon, BoltIcon, DocsIcon, BoxIcon, PlusIcon } from '../../icons';
|
||||
import Button from '../ui/button/Button';
|
||||
|
||||
export type StatType = 'total' | 'available' | 'high_volume' | 'premium_traffic' | 'long_tail' | 'quick_wins';
|
||||
|
||||
@@ -29,88 +30,77 @@ interface SectorMetricCardProps {
|
||||
stats: SectorStats;
|
||||
isActive: boolean;
|
||||
onClick: () => void;
|
||||
onBulkAdd?: (statType: StatType, count: number) => void;
|
||||
isAddedAction?: (statType: StatType, count: number) => boolean;
|
||||
clickable?: boolean;
|
||||
sectorName?: string;
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
// Card configuration for each stat type
|
||||
// Card configuration for each stat type - using dashboard accent colors
|
||||
const STAT_CONFIG: Record<StatType, {
|
||||
label: string;
|
||||
description: string;
|
||||
icon: ReactNode;
|
||||
color: string;
|
||||
bgColor: string;
|
||||
borderColor: string;
|
||||
activeColor: string;
|
||||
activeBorder: string;
|
||||
accentColor: string;
|
||||
textColor: string;
|
||||
badgeColor: string;
|
||||
showThreshold?: boolean;
|
||||
thresholdLabel?: string;
|
||||
thresholdPrefix?: string;
|
||||
}> = {
|
||||
total: {
|
||||
label: 'Total',
|
||||
description: 'All keywords in sector',
|
||||
icon: <PieChartIcon className="w-5 h-5" />,
|
||||
color: 'text-gray-600 dark:text-gray-400',
|
||||
bgColor: 'bg-gray-50 dark:bg-gray-800/50',
|
||||
borderColor: 'border-gray-200 dark:border-gray-700',
|
||||
activeColor: 'bg-gray-100 dark:bg-gray-700/50',
|
||||
activeBorder: 'border-gray-400 dark:border-gray-500',
|
||||
accentColor: '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',
|
||||
},
|
||||
available: {
|
||||
label: 'Available',
|
||||
description: 'Not yet added to your site',
|
||||
icon: <CheckCircleIcon className="w-5 h-5" />,
|
||||
color: 'text-green-600 dark:text-green-400',
|
||||
bgColor: 'bg-green-50 dark:bg-green-900/20',
|
||||
borderColor: 'border-green-200 dark:border-green-800',
|
||||
activeColor: 'bg-green-100 dark:bg-green-800/30',
|
||||
activeBorder: 'border-green-500 dark:border-green-600',
|
||||
accentColor: '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',
|
||||
},
|
||||
high_volume: {
|
||||
label: 'High Volume',
|
||||
description: 'Volume ≥ 10K searches/mo',
|
||||
icon: <ShootingStarIcon className="w-5 h-5" />,
|
||||
color: 'text-blue-600 dark:text-blue-400',
|
||||
bgColor: 'bg-blue-50 dark:bg-blue-900/20',
|
||||
borderColor: 'border-blue-200 dark:border-blue-800',
|
||||
activeColor: 'bg-blue-100 dark:bg-blue-800/30',
|
||||
activeBorder: 'border-blue-500 dark:border-blue-600',
|
||||
accentColor: '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',
|
||||
},
|
||||
premium_traffic: {
|
||||
label: 'Premium Traffic',
|
||||
description: 'High volume keywords',
|
||||
icon: <BoxIcon className="w-5 h-5" />,
|
||||
color: 'text-amber-600 dark:text-amber-400',
|
||||
bgColor: 'bg-amber-50 dark:bg-amber-900/20',
|
||||
borderColor: 'border-amber-200 dark:border-amber-800',
|
||||
activeColor: 'bg-amber-100 dark:bg-amber-800/30',
|
||||
activeBorder: 'border-amber-500 dark:border-amber-600',
|
||||
accentColor: '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,
|
||||
thresholdLabel: 'Vol ≥',
|
||||
thresholdPrefix: 'Vol ≥',
|
||||
},
|
||||
long_tail: {
|
||||
label: 'Long Tail',
|
||||
description: '4+ words with good volume',
|
||||
icon: <DocsIcon className="w-5 h-5" />,
|
||||
color: 'text-purple-600 dark:text-purple-400',
|
||||
bgColor: 'bg-purple-50 dark:bg-purple-900/20',
|
||||
borderColor: 'border-purple-200 dark:border-purple-800',
|
||||
activeColor: 'bg-purple-100 dark:bg-purple-800/30',
|
||||
activeBorder: 'border-purple-500 dark:border-purple-600',
|
||||
accentColor: '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,
|
||||
thresholdLabel: 'Vol >',
|
||||
thresholdPrefix: 'Vol >',
|
||||
},
|
||||
quick_wins: {
|
||||
label: 'Quick Wins',
|
||||
description: 'Low difficulty, good volume',
|
||||
icon: <BoltIcon className="w-5 h-5" />,
|
||||
color: 'text-emerald-600 dark:text-emerald-400',
|
||||
bgColor: 'bg-emerald-50 dark:bg-emerald-900/20',
|
||||
borderColor: 'border-emerald-200 dark:border-emerald-800',
|
||||
activeColor: 'bg-emerald-100 dark:bg-emerald-800/30',
|
||||
activeBorder: 'border-emerald-500 dark:border-emerald-600',
|
||||
accentColor: '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,
|
||||
thresholdLabel: 'Vol >',
|
||||
thresholdPrefix: 'Vol >',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -138,79 +128,129 @@ export default function SectorMetricCard({
|
||||
stats,
|
||||
isActive,
|
||||
onClick,
|
||||
onBulkAdd,
|
||||
isAddedAction,
|
||||
clickable = true,
|
||||
sectorName,
|
||||
compact = false,
|
||||
}: SectorMetricCardProps) {
|
||||
const config = STAT_CONFIG[statType];
|
||||
const statData = stats[statType];
|
||||
|
||||
// Build description with threshold if applicable
|
||||
// Build description with detailed threshold copy (match Smart Suggestions tone)
|
||||
const description = useMemo(() => {
|
||||
if (config.showThreshold && statData.threshold) {
|
||||
return `${config.thresholdLabel} ${formatThreshold(statData.threshold)}`;
|
||||
const threshold = statData.threshold;
|
||||
switch (statType) {
|
||||
case 'long_tail':
|
||||
return `4+ word phrases with vol > ${formatThreshold(threshold || 1000)}`;
|
||||
case 'quick_wins':
|
||||
return `Easy to rank keywords with vol > ${formatThreshold(threshold || 1000)}`;
|
||||
case 'premium_traffic':
|
||||
return `High volume keywords (vol > ${formatThreshold(threshold || 50000)})`;
|
||||
case 'high_volume':
|
||||
return `Volume ≥ ${formatThreshold(threshold || 10000)} searches/mo`;
|
||||
case 'available':
|
||||
return 'Not yet added to your site';
|
||||
default:
|
||||
if (config.showThreshold && threshold) {
|
||||
return `${config.thresholdPrefix} ${formatThreshold(threshold)}`;
|
||||
}
|
||||
return config.description;
|
||||
}
|
||||
return config.description;
|
||||
}, [config, statData.threshold]);
|
||||
}, [config.description, config.showThreshold, config.thresholdPrefix, statData.threshold, statType]);
|
||||
|
||||
// Determine bulk add options based on count
|
||||
const bulkAddOptions = useMemo(() => {
|
||||
const count = statData.count;
|
||||
const options: number[] = [];
|
||||
if (count >= 50) options.push(50);
|
||||
if (count >= 100) options.push(100);
|
||||
if (count >= 200) options.push(200);
|
||||
if (count > 0 && count <= 200 && !options.includes(count)) {
|
||||
options.push(count);
|
||||
}
|
||||
return options.sort((a, b) => a - b);
|
||||
}, [statData.count]);
|
||||
|
||||
const handleAddClick = (count: number) => {
|
||||
if (onBulkAdd) {
|
||||
onBulkAdd(statType, count);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
<div
|
||||
onClick={clickable ? onClick : undefined}
|
||||
className={clsx(
|
||||
'group relative flex flex-col items-start w-full rounded-xl border-2 transition-all duration-200',
|
||||
'hover:shadow-md hover:scale-[1.02] active:scale-[0.98]',
|
||||
'focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-brand-500',
|
||||
'relative rounded-xl border bg-white dark:bg-white/[0.03] overflow-hidden',
|
||||
'transition-all duration-200',
|
||||
clickable
|
||||
? 'cursor-pointer hover:shadow-lg hover:border-gray-300 dark:hover:border-gray-600'
|
||||
: 'cursor-default',
|
||||
compact ? 'p-3' : 'p-4',
|
||||
isActive ? [config.activeColor, config.activeBorder] : [config.bgColor, config.borderColor],
|
||||
isActive
|
||||
? 'border-brand-500 shadow-sm ring-4 ring-brand-500/15'
|
||||
: 'border-gray-200 dark:border-gray-800',
|
||||
)}
|
||||
>
|
||||
{/* Icon and Label Row */}
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<div className={clsx(
|
||||
'flex items-center justify-center rounded-lg',
|
||||
compact ? 'w-8 h-8' : 'w-9 h-9',
|
||||
config.color,
|
||||
isActive ? 'bg-white/50 dark:bg-black/20' : 'bg-white/60 dark:bg-black/10',
|
||||
)}>
|
||||
{config.icon}
|
||||
</div>
|
||||
<span className={clsx(
|
||||
'font-semibold',
|
||||
compact ? 'text-sm' : 'text-base',
|
||||
'text-gray-900 dark:text-white',
|
||||
)}>
|
||||
{config.label}
|
||||
</span>
|
||||
</div>
|
||||
{/* Accent Border Left */}
|
||||
<div className={clsx('absolute left-0 top-0 bottom-0 w-1', config.accentColor)} />
|
||||
|
||||
{/* Count */}
|
||||
<div className={clsx(
|
||||
'font-bold tabular-nums',
|
||||
compact ? 'text-2xl' : 'text-3xl',
|
||||
config.color,
|
||||
)}>
|
||||
{formatCount(statData.count)}
|
||||
{/* Header: Icon + Label + Count */}
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={clsx('p-2 rounded-lg', config.badgeColor, config.textColor)}>
|
||||
{config.icon}
|
||||
</div>
|
||||
<span className={clsx('text-base font-semibold', config.textColor)}>
|
||||
{config.label}
|
||||
</span>
|
||||
</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('font-bold tabular-nums', compact ? 'text-2xl' : 'text-3xl', config.textColor)}>
|
||||
{formatCount(statData.count)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description / Threshold */}
|
||||
{!compact && (
|
||||
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400 line-clamp-1">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
<p className="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
{description}
|
||||
</p>
|
||||
|
||||
{/* Active indicator */}
|
||||
{isActive && (
|
||||
<div className={clsx(
|
||||
'absolute top-2 right-2 w-2 h-2 rounded-full animate-pulse',
|
||||
statType === 'total' ? 'bg-gray-500' :
|
||||
statType === 'available' ? 'bg-green-500' :
|
||||
statType === 'high_volume' ? 'bg-blue-500' :
|
||||
statType === 'premium_traffic' ? 'bg-amber-500' :
|
||||
statType === 'long_tail' ? 'bg-purple-500' :
|
||||
'bg-emerald-500'
|
||||
)} />
|
||||
{/* Bulk Add Buttons - Always visible */}
|
||||
{onBulkAdd && statData.count > 0 && statType !== 'total' && (
|
||||
<div className="mt-3 pt-3 border-t border-gray-100 dark:border-gray-800">
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs">
|
||||
<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
|
||||
</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>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -219,6 +259,9 @@ interface SectorMetricGridProps {
|
||||
stats: SectorStats | null;
|
||||
activeStatType: StatType | null;
|
||||
onStatClick: (statType: StatType) => void;
|
||||
onBulkAdd?: (statType: StatType, count: number) => void;
|
||||
isAddedAction?: (statType: StatType, count: number) => boolean;
|
||||
clickable?: boolean;
|
||||
sectorName?: string;
|
||||
compact?: boolean;
|
||||
isLoading?: boolean;
|
||||
@@ -228,27 +271,34 @@ export function SectorMetricGrid({
|
||||
stats,
|
||||
activeStatType,
|
||||
onStatClick,
|
||||
onBulkAdd,
|
||||
isAddedAction,
|
||||
clickable = true,
|
||||
sectorName,
|
||||
compact = false,
|
||||
isLoading = false,
|
||||
}: SectorMetricGridProps) {
|
||||
const statTypes: StatType[] = ['total', 'available', 'high_volume', 'premium_traffic', 'long_tail', 'quick_wins'];
|
||||
const statTypes: StatType[] = ['available', 'high_volume', 'premium_traffic', 'long_tail', 'quick_wins'];
|
||||
|
||||
// Loading skeleton
|
||||
if (isLoading || !stats) {
|
||||
return (
|
||||
<div className={clsx(
|
||||
'grid gap-3',
|
||||
'grid gap-4',
|
||||
compact ? 'grid-cols-3 sm:grid-cols-6' : 'grid-cols-2 sm:grid-cols-3 lg:grid-cols-6',
|
||||
)}>
|
||||
{statTypes.map((statType) => (
|
||||
<div
|
||||
key={statType}
|
||||
className="relative flex flex-col items-center justify-center rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-4 animate-pulse"
|
||||
className="relative rounded-xl border border-gray-200 dark:border-gray-800 bg-white dark:bg-white/[0.03] p-4 animate-pulse overflow-hidden"
|
||||
>
|
||||
<div className="h-5 w-5 bg-gray-200 dark:bg-gray-700 rounded mb-2" />
|
||||
<div className="h-8 w-16 bg-gray-200 dark:bg-gray-700 rounded mb-1" />
|
||||
<div className="h-3 w-24 bg-gray-200 dark:bg-gray-700 rounded" />
|
||||
<div className="absolute left-0 top-0 bottom-0 w-1 bg-gray-200 dark:bg-gray-700" />
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<div className="h-6 w-6 bg-gray-200 dark:bg-gray-700 rounded" />
|
||||
<div className="h-4 w-16 bg-gray-200 dark:bg-gray-700 rounded" />
|
||||
</div>
|
||||
<div className="h-7 w-12 bg-gray-200 dark:bg-gray-700 rounded mb-1" />
|
||||
<div className="h-3 w-20 bg-gray-200 dark:bg-gray-700 rounded" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -257,7 +307,7 @@ export function SectorMetricGrid({
|
||||
|
||||
return (
|
||||
<div className={clsx(
|
||||
'grid gap-3',
|
||||
'grid gap-4',
|
||||
compact ? 'grid-cols-3 sm:grid-cols-6' : 'grid-cols-2 sm:grid-cols-3 lg:grid-cols-6',
|
||||
)}>
|
||||
{statTypes.map((statType) => (
|
||||
@@ -267,6 +317,9 @@ export function SectorMetricGrid({
|
||||
stats={stats}
|
||||
isActive={activeStatType === statType}
|
||||
onClick={() => onStatClick(statType)}
|
||||
onBulkAdd={onBulkAdd}
|
||||
isAddedAction={isAddedAction}
|
||||
clickable={clickable}
|
||||
sectorName={sectorName}
|
||||
compact={compact}
|
||||
/>
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
/**
|
||||
* SmartSuggestions - Breathing indicator panel for Keywords Library
|
||||
* Shows "Ready-to-use keywords waiting for you!" with animated indicator
|
||||
* Clicking navigates to pre-filtered keywords
|
||||
* 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
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import clsx from 'clsx';
|
||||
import { ShootingStarIcon, ChevronDownIcon, ArrowRightIcon } from '../../icons';
|
||||
import { ShootingStarIcon, ChevronDownIcon, PlusIcon, BoltIcon, DocsIcon, BoxIcon, CheckCircleIcon } from '../../icons';
|
||||
import Button from '../ui/button/Button';
|
||||
|
||||
interface SmartSuggestion {
|
||||
@@ -27,64 +27,98 @@ interface SmartSuggestion {
|
||||
interface SmartSuggestionsProps {
|
||||
suggestions: SmartSuggestion[];
|
||||
onSuggestionClick: (suggestion: SmartSuggestion) => void;
|
||||
onBulkAdd?: (suggestion: SmartSuggestion, count: number) => void;
|
||||
isAddedAction?: (suggestionId: string, count: number) => boolean;
|
||||
enableFilterClick?: boolean;
|
||||
className?: string;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
// Color mappings
|
||||
// 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: {
|
||||
bg: 'bg-blue-50 dark:bg-blue-900/20',
|
||||
border: 'border-blue-200 dark:border-blue-800',
|
||||
text: 'text-blue-600 dark:text-blue-400',
|
||||
badge: 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300',
|
||||
accent: 'bg-brand-500',
|
||||
text: 'text-brand-600 dark:text-brand-400',
|
||||
iconBg: 'bg-brand-100 dark:bg-brand-900/40',
|
||||
},
|
||||
green: {
|
||||
bg: 'bg-green-50 dark:bg-green-900/20',
|
||||
border: 'border-green-200 dark:border-green-800',
|
||||
text: 'text-green-600 dark:text-green-400',
|
||||
badge: 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300',
|
||||
accent: 'bg-success-500',
|
||||
text: 'text-success-600 dark:text-success-400',
|
||||
iconBg: 'bg-success-100 dark:bg-success-900/40',
|
||||
},
|
||||
amber: {
|
||||
bg: 'bg-amber-50 dark:bg-amber-900/20',
|
||||
border: 'border-amber-200 dark:border-amber-800',
|
||||
text: 'text-amber-600 dark:text-amber-400',
|
||||
badge: 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300',
|
||||
accent: 'bg-warning-500',
|
||||
text: 'text-warning-600 dark:text-warning-400',
|
||||
iconBg: 'bg-warning-100 dark:bg-warning-900/40',
|
||||
},
|
||||
purple: {
|
||||
bg: 'bg-purple-50 dark:bg-purple-900/20',
|
||||
border: 'border-purple-200 dark:border-purple-800',
|
||||
accent: 'bg-purple-500',
|
||||
text: 'text-purple-600 dark:text-purple-400',
|
||||
badge: 'bg-purple-100 text-purple-700 dark:bg-purple-900/40 dark:text-purple-300',
|
||||
iconBg: 'bg-purple-100 dark:bg-purple-900/40',
|
||||
},
|
||||
emerald: {
|
||||
bg: 'bg-emerald-50 dark:bg-emerald-900/20',
|
||||
border: 'border-emerald-200 dark:border-emerald-800',
|
||||
accent: 'bg-emerald-500',
|
||||
text: 'text-emerald-600 dark:text-emerald-400',
|
||||
badge: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300',
|
||||
iconBg: 'bg-emerald-100 dark:bg-emerald-900/40',
|
||||
},
|
||||
};
|
||||
|
||||
export default function SmartSuggestions({
|
||||
suggestions,
|
||||
onSuggestionClick,
|
||||
onBulkAdd,
|
||||
isAddedAction,
|
||||
enableFilterClick = true,
|
||||
className,
|
||||
isLoading = false,
|
||||
}: SmartSuggestionsProps) {
|
||||
const [isExpanded, setIsExpanded] = useState(true);
|
||||
|
||||
const totalAvailable = suggestions.reduce((sum, s) => sum + s.count, 0);
|
||||
const hasKeywords = totalAvailable > 0;
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className={clsx(
|
||||
'rounded-xl border-2 border-dashed border-gray-200 dark:border-gray-700 p-6',
|
||||
'rounded-xl border border-gray-200 dark:border-gray-800 bg-white dark:bg-white/[0.03] p-4',
|
||||
className
|
||||
)}>
|
||||
<div className="flex items-center justify-center gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-5 h-5 rounded-full border-2 border-gray-300 border-t-transparent animate-spin" />
|
||||
<span className="text-gray-500 dark:text-gray-400">Loading suggestions...</span>
|
||||
<span className="text-gray-500 dark:text-gray-400 text-sm">Loading suggestions...</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Show breathing indicator when no suggestions (waiting for data)
|
||||
if (suggestions.length === 0) {
|
||||
return (
|
||||
<div className={clsx(
|
||||
'rounded-xl border border-gray-200 dark:border-gray-800 bg-white dark:bg-white/[0.03] p-4',
|
||||
className
|
||||
)}>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={clsx(
|
||||
'w-8 h-8 rounded-lg flex items-center justify-center',
|
||||
'bg-gradient-to-br from-brand-500 to-purple-500'
|
||||
)}>
|
||||
<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...
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -92,46 +126,35 @@ export default function SmartSuggestions({
|
||||
|
||||
return (
|
||||
<div className={clsx(
|
||||
'rounded-xl border bg-gradient-to-br from-brand-50/50 to-purple-50/50 dark:from-brand-900/10 dark:to-purple-900/10',
|
||||
'border-brand-200 dark:border-brand-800',
|
||||
'rounded-xl border border-gray-200 dark:border-gray-800 bg-white dark:bg-white/[0.03] overflow-hidden',
|
||||
className
|
||||
)}>
|
||||
{/* Header with breathing indicator */}
|
||||
{/* Header */}
|
||||
<button
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
className="flex items-center justify-between w-full p-4 text-left hover:bg-brand-50/50 dark:hover:bg-brand-900/10 transition-colors rounded-t-xl"
|
||||
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">
|
||||
{/* Breathing indicator */}
|
||||
<div className="relative">
|
||||
<div className={clsx(
|
||||
'w-10 h-10 rounded-full flex items-center justify-center',
|
||||
'bg-gradient-to-br from-brand-400 to-purple-500',
|
||||
hasKeywords && 'animate-pulse'
|
||||
)}>
|
||||
<ShootingStarIcon className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
{hasKeywords && (
|
||||
<div className="absolute -top-1 -right-1 w-4 h-4 bg-emerald-500 rounded-full flex items-center justify-center animate-bounce">
|
||||
<span className="text-[10px] font-bold text-white">!</span>
|
||||
</div>
|
||||
)}
|
||||
{/* 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'
|
||||
)}>
|
||||
<ShootingStarIcon className="w-4 h-4 text-white" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-900 dark:text-white flex items-center gap-2">
|
||||
<h3 className="font-semibold text-gray-900 dark:text-white text-sm flex items-center gap-2">
|
||||
Smart Suggestions
|
||||
{hasKeywords && (
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300">
|
||||
{totalAvailable} ready
|
||||
<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-sm text-gray-600 dark:text-gray-400">
|
||||
{hasKeywords
|
||||
? 'Ready-to-use keywords waiting for you!'
|
||||
: 'No suggestions available for current filters'
|
||||
}
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
Ready-to-use keywords waiting for you!
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -144,52 +167,98 @@ export default function SmartSuggestions({
|
||||
/>
|
||||
</button>
|
||||
|
||||
{/* Expandable content */}
|
||||
{isExpanded && suggestions.length > 0 && (
|
||||
<div className="px-4 pb-4 space-y-2">
|
||||
{suggestions.map((suggestion) => {
|
||||
const colors = colorClasses[suggestion.color];
|
||||
|
||||
return (
|
||||
<button
|
||||
key={suggestion.id}
|
||||
onClick={() => onSuggestionClick(suggestion)}
|
||||
className={clsx(
|
||||
'flex items-center justify-between w-full p-3 rounded-lg border transition-all',
|
||||
'hover:shadow-md hover:scale-[1.01] active:scale-[0.99]',
|
||||
colors.bg,
|
||||
colors.border,
|
||||
)}
|
||||
>
|
||||
<div className="flex-1 text-left">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={clsx('font-medium text-sm', colors.text)}>
|
||||
{suggestion.label}
|
||||
</span>
|
||||
<span className={clsx(
|
||||
'px-2 py-0.5 rounded-full text-xs font-semibold',
|
||||
colors.badge
|
||||
)}>
|
||||
{suggestion.count}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
|
||||
{suggestion.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ArrowRightIcon className={clsx('w-4 h-4', colors.text)} />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isExpanded && suggestions.length === 0 && (
|
||||
{/* Expandable content - Grid layout */}
|
||||
{isExpanded && (
|
||||
<div className="px-4 pb-4">
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 text-center py-4">
|
||||
Select an industry and sector to see smart suggestions.
|
||||
</p>
|
||||
<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>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -45,7 +45,7 @@ export default function SeedKeywords() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageMeta title="Seed Keywords" />
|
||||
<PageMeta title="Seed Keywords" description="Global keyword library for reference" />
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Seed Keywords</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400 mt-1">Global keyword library for reference</p>
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
fetchKeywords,
|
||||
fetchSectorStats,
|
||||
SectorStats,
|
||||
SectorStatsItem,
|
||||
} from '../../services/api';
|
||||
import Badge from '../../components/ui/badge/Badge';
|
||||
import { BoltIcon, ShootingStarIcon } from '../../icons';
|
||||
@@ -32,19 +33,23 @@ import { usePageSizeStore } from '../../store/pageSizeStore';
|
||||
import { getDifficultyNumber, getDifficultyRange, getDifficultyLabelFromNumber } from '../../utils/difficulty';
|
||||
import { useSiteStore } from '../../store/siteStore';
|
||||
import { useSectorStore } from '../../store/sectorStore';
|
||||
import type { Sector } from '../../store/sectorStore';
|
||||
import Button from '../../components/ui/button/Button';
|
||||
import { Modal } from '../../components/ui/modal';
|
||||
import FileInput from '../../components/form/input/FileInput';
|
||||
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 SectorCardsGrid from '../../components/keywords-library/SectorCardsGrid';
|
||||
import BulkAddConfirmation from '../../components/keywords-library/BulkAddConfirmation';
|
||||
|
||||
export default function IndustriesSectorsKeywords() {
|
||||
const toast = useToast();
|
||||
const { startLoading, stopLoading } = usePageLoading();
|
||||
const { activeSite } = useSiteStore();
|
||||
const { activeSector, loadSectorsForSite } = useSectorStore();
|
||||
const { activeSector, loadSectorsForSite, sectors, setActiveSector } = useSectorStore();
|
||||
const { pageSize } = usePageSizeStore();
|
||||
|
||||
// Data state
|
||||
@@ -54,9 +59,11 @@ export default function IndustriesSectorsKeywords() {
|
||||
const [selectedIds, setSelectedIds] = useState<string[]>([]);
|
||||
// Track recently added keywords to preserve their state during reload
|
||||
const recentlyAddedRef = useRef<Set<number>>(new Set());
|
||||
const attachedSeedKeywordIdsRef = useRef<Set<number>>(new Set());
|
||||
|
||||
// Sector Stats state (for metric cards)
|
||||
const [sectorStats, setSectorStats] = useState<SectorStats | null>(null);
|
||||
const [sectorStatsList, setSectorStatsList] = useState<SectorStatsItem[]>([]);
|
||||
const [loadingSectorStats, setLoadingSectorStats] = useState(false);
|
||||
const [activeStatFilter, setActiveStatFilter] = useState<StatType | null>(null);
|
||||
|
||||
@@ -64,6 +71,10 @@ export default function IndustriesSectorsKeywords() {
|
||||
const [showBulkAddModal, setShowBulkAddModal] = useState(false);
|
||||
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);
|
||||
@@ -82,6 +93,8 @@ export default function IndustriesSectorsKeywords() {
|
||||
const [countryFilter, setCountryFilter] = useState('');
|
||||
const [difficultyFilter, setDifficultyFilter] = useState('');
|
||||
const [showNotAddedOnly, setShowNotAddedOnly] = useState(false);
|
||||
const [volumeMin, setVolumeMin] = useState('');
|
||||
const [volumeMax, setVolumeMax] = useState('');
|
||||
|
||||
// Keyword count tracking
|
||||
const [addedCount, setAddedCount] = useState(0);
|
||||
@@ -176,6 +189,7 @@ export default function IndustriesSectorsKeywords() {
|
||||
const loadSectorStats = useCallback(async () => {
|
||||
if (!activeSite || !activeSite.industry) {
|
||||
setSectorStats(null);
|
||||
setSectorStatsList([]);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -183,15 +197,21 @@ export default function IndustriesSectorsKeywords() {
|
||||
try {
|
||||
const response = await fetchSectorStats({
|
||||
industry_id: activeSite.industry,
|
||||
sector_id: activeSector?.industry_sector ?? undefined,
|
||||
site_id: activeSite.id,
|
||||
});
|
||||
|
||||
// If sector-specific stats returned
|
||||
if (response.stats) {
|
||||
setSectorStats(response.stats as SectorStats);
|
||||
} else if (response.sectors && response.sectors.length > 0) {
|
||||
// Aggregate stats from all sectors
|
||||
const allSectors = response.sectors || [];
|
||||
setSectorStatsList(allSectors);
|
||||
|
||||
if (activeSector?.industry_sector) {
|
||||
const matching = allSectors.find((sector) => sector.sector_id === activeSector.industry_sector);
|
||||
if (matching) {
|
||||
setSectorStats(matching.stats as SectorStats);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (allSectors.length > 0) {
|
||||
const aggregated: SectorStats = {
|
||||
total: { count: 0 },
|
||||
available: { count: 0 },
|
||||
@@ -200,15 +220,14 @@ export default function IndustriesSectorsKeywords() {
|
||||
long_tail: { count: 0, threshold: 1000 },
|
||||
quick_wins: { count: 0, threshold: 1000 },
|
||||
};
|
||||
|
||||
response.sectors.forEach((sector) => {
|
||||
|
||||
allSectors.forEach((sector) => {
|
||||
aggregated.total.count += sector.stats.total.count;
|
||||
aggregated.available.count += sector.stats.available.count;
|
||||
aggregated.high_volume.count += sector.stats.high_volume.count;
|
||||
aggregated.premium_traffic.count += sector.stats.premium_traffic.count;
|
||||
aggregated.long_tail.count += sector.stats.long_tail.count;
|
||||
aggregated.quick_wins.count += sector.stats.quick_wins.count;
|
||||
// Use first sector's thresholds (they should be consistent)
|
||||
if (!aggregated.premium_traffic.threshold) {
|
||||
aggregated.premium_traffic.threshold = sector.stats.premium_traffic.threshold;
|
||||
}
|
||||
@@ -219,8 +238,10 @@ export default function IndustriesSectorsKeywords() {
|
||||
aggregated.quick_wins.threshold = sector.stats.quick_wins.threshold;
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
setSectorStats(aggregated);
|
||||
} else {
|
||||
setSectorStats(null);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load sector stats:', error);
|
||||
@@ -236,6 +257,26 @@ export default function IndustriesSectorsKeywords() {
|
||||
}
|
||||
}, [activeSite, activeSector, loadSectorStats]);
|
||||
|
||||
// Reset filters and state when site changes
|
||||
useEffect(() => {
|
||||
setActiveStatFilter(null);
|
||||
setSearchTerm('');
|
||||
setCountryFilter('');
|
||||
setDifficultyFilter('');
|
||||
setShowNotAddedOnly(false);
|
||||
setCurrentPage(1);
|
||||
setSelectedIds([]);
|
||||
setAddedStatActions(new Set());
|
||||
setAddedSuggestionActions(new Set());
|
||||
}, [activeSite?.id]);
|
||||
|
||||
// Reset pagination/selection when sector changes
|
||||
useEffect(() => {
|
||||
setActiveStatFilter(null);
|
||||
setCurrentPage(1);
|
||||
setSelectedIds([]);
|
||||
}, [activeSector?.id]);
|
||||
|
||||
// Load counts on mount and when site/sector changes
|
||||
useEffect(() => {
|
||||
if (activeSite) {
|
||||
@@ -283,6 +324,9 @@ export default function IndustriesSectorsKeywords() {
|
||||
console.warn('Could not fetch sectors or attached keywords:', err);
|
||||
}
|
||||
|
||||
// Keep attached IDs available for bulk add actions
|
||||
attachedSeedKeywordIdsRef.current = attachedSeedKeywordIds;
|
||||
|
||||
// Build API filters - use server-side pagination
|
||||
const pageSizeNum = pageSize || 25;
|
||||
const filters: any = {
|
||||
@@ -298,6 +342,8 @@ 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);
|
||||
|
||||
// Apply difficulty filter (if API supports it, otherwise we'll filter client-side)
|
||||
if (difficultyFilter) {
|
||||
@@ -361,7 +407,7 @@ export default function IndustriesSectorsKeywords() {
|
||||
setAvailableCount(0);
|
||||
setShowContent(true);
|
||||
}
|
||||
}, [activeSite, activeSector, currentPage, pageSize, searchTerm, countryFilter, difficultyFilter, showNotAddedOnly, sortBy, sortDirection, toast]);
|
||||
}, [activeSite, activeSector, currentPage, pageSize, searchTerm, countryFilter, volumeMin, volumeMax, difficultyFilter, showNotAddedOnly, sortBy, sortDirection, toast]);
|
||||
|
||||
// Load data when site/sector/filters change (show table by default per plan)
|
||||
useEffect(() => {
|
||||
@@ -381,6 +427,7 @@ export default function IndustriesSectorsKeywords() {
|
||||
// Reset to page 1 on pageSize change
|
||||
useEffect(() => {
|
||||
setCurrentPage(1);
|
||||
setSelectedIds([]);
|
||||
}, [pageSize]);
|
||||
|
||||
// Handle sorting
|
||||
@@ -388,6 +435,7 @@ export default function IndustriesSectorsKeywords() {
|
||||
setSortBy(field || 'keyword');
|
||||
setSortDirection(direction);
|
||||
setCurrentPage(1);
|
||||
setSelectedIds([]);
|
||||
};
|
||||
|
||||
// Handle adding keywords to workflow
|
||||
@@ -684,6 +732,50 @@ export default function IndustriesSectorsKeywords() {
|
||||
type: 'text' as const,
|
||||
placeholder: 'Search keywords...',
|
||||
},
|
||||
{
|
||||
key: 'sector',
|
||||
label: 'Sector',
|
||||
type: 'select' as const,
|
||||
options: [
|
||||
{ value: '', label: 'All Sectors' },
|
||||
...sectors.map((sector) => ({
|
||||
value: String(sector.id),
|
||||
label: sector.name,
|
||||
})),
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'volume',
|
||||
label: 'Volume',
|
||||
type: 'custom' as const,
|
||||
customRender: () => (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">Volume</span>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="Min"
|
||||
value={volumeMin}
|
||||
onChange={(e) => {
|
||||
setVolumeMin(e.target.value);
|
||||
setCurrentPage(1);
|
||||
setSelectedIds([]);
|
||||
}}
|
||||
className="w-20 h-8"
|
||||
/>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="Max"
|
||||
value={volumeMax}
|
||||
onChange={(e) => {
|
||||
setVolumeMax(e.target.value);
|
||||
setCurrentPage(1);
|
||||
setSelectedIds([]);
|
||||
}}
|
||||
className="w-20 h-8"
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'country',
|
||||
label: 'Country',
|
||||
@@ -730,7 +822,7 @@ export default function IndustriesSectorsKeywords() {
|
||||
},
|
||||
],
|
||||
};
|
||||
}, [activeSector, handleAddToWorkflow]);
|
||||
}, [activeSector, handleAddToWorkflow, sectors]);
|
||||
|
||||
// Build smart suggestions from sector stats
|
||||
const smartSuggestions = useMemo(() => {
|
||||
@@ -738,6 +830,407 @@ export default function IndustriesSectorsKeywords() {
|
||||
return buildSmartSuggestions(sectorStats, { showOnlyWithResults: true });
|
||||
}, [sectorStats]);
|
||||
|
||||
// Helper: word count for keyword string
|
||||
const getWordCount = useCallback((keyword: string) => {
|
||||
return keyword.trim().split(/\s+/).filter(Boolean).length;
|
||||
}, []);
|
||||
|
||||
const buildStatActionKey = useCallback((statType: StatType, count: number) => {
|
||||
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;
|
||||
minVolume?: number;
|
||||
longTail?: boolean;
|
||||
availableOnly?: boolean;
|
||||
count: number;
|
||||
}) => {
|
||||
if (!activeSite || !activeSector?.industry_sector) {
|
||||
throw new Error('Please select a site and sector first');
|
||||
}
|
||||
|
||||
const { ordering, difficultyMax, minVolume, longTail, availableOnly, count } = options;
|
||||
const pageSize = Math.max(500, count * 2);
|
||||
|
||||
const filters: any = {
|
||||
industry: activeSite.industry,
|
||||
sector: activeSector.industry_sector,
|
||||
page_size: pageSize,
|
||||
ordering,
|
||||
};
|
||||
|
||||
if (difficultyMax !== undefined) {
|
||||
filters.difficulty_max = difficultyMax;
|
||||
}
|
||||
|
||||
const response = await fetchKeywordsLibrary(filters);
|
||||
let results = response.results || [];
|
||||
|
||||
if (minVolume !== undefined) {
|
||||
results = results.filter((kw) => (kw.volume || 0) >= minVolume);
|
||||
}
|
||||
|
||||
if (longTail) {
|
||||
results = results.filter((kw) => getWordCount(kw.keyword) >= 4);
|
||||
}
|
||||
|
||||
if (availableOnly) {
|
||||
results = results.filter((kw) => !attachedSeedKeywordIdsRef.current.has(Number(kw.id)));
|
||||
}
|
||||
|
||||
const topIds = results.slice(0, count).map((kw) => kw.id);
|
||||
return topIds;
|
||||
}, [activeSite, activeSector, getWordCount]);
|
||||
|
||||
const prepareBulkAdd = useCallback(async (payload: {
|
||||
label: string;
|
||||
ids: number[];
|
||||
actionKey: string;
|
||||
group: 'stat' | 'suggestion';
|
||||
}) => {
|
||||
if (payload.ids.length === 0) {
|
||||
toast.error('No matching keywords found for this selection');
|
||||
return;
|
||||
}
|
||||
|
||||
setBulkAddKeywordIds(payload.ids);
|
||||
setBulkAddStatLabel(payload.label);
|
||||
setPendingBulkAddKey(payload.actionKey);
|
||||
setPendingBulkAddGroup(payload.group);
|
||||
setShowBulkAddModal(true);
|
||||
}, [toast]);
|
||||
|
||||
// Handle stat card click - filters table to show matching keywords
|
||||
const handleStatClick = useCallback((statType: StatType) => {
|
||||
// Toggle off if clicking same stat
|
||||
if (activeStatFilter === statType) {
|
||||
setActiveStatFilter(null);
|
||||
setShowNotAddedOnly(false);
|
||||
setDifficultyFilter('');
|
||||
setVolumeMin('');
|
||||
setVolumeMax('');
|
||||
setSelectedIds([]);
|
||||
return;
|
||||
}
|
||||
|
||||
setActiveStatFilter(statType);
|
||||
setCurrentPage(1);
|
||||
setSelectedIds([]);
|
||||
|
||||
const statThresholds = {
|
||||
highVolume: sectorStats?.high_volume.threshold ?? 10000,
|
||||
premium: sectorStats?.premium_traffic.threshold ?? 50000,
|
||||
longTail: sectorStats?.long_tail.threshold ?? 1000,
|
||||
quickWins: sectorStats?.quick_wins.threshold ?? 1000,
|
||||
};
|
||||
|
||||
switch (statType) {
|
||||
case 'available':
|
||||
setShowNotAddedOnly(true);
|
||||
setDifficultyFilter('');
|
||||
setVolumeMin('');
|
||||
setVolumeMax('');
|
||||
break;
|
||||
case 'high_volume':
|
||||
setShowNotAddedOnly(false);
|
||||
setDifficultyFilter('');
|
||||
setVolumeMin(String(statThresholds.highVolume));
|
||||
setVolumeMax('');
|
||||
setSortBy('volume');
|
||||
setSortDirection('desc');
|
||||
break;
|
||||
case 'premium_traffic':
|
||||
setShowNotAddedOnly(false);
|
||||
setDifficultyFilter('');
|
||||
setVolumeMin(String(statThresholds.premium));
|
||||
setVolumeMax('');
|
||||
setSortBy('volume');
|
||||
setSortDirection('desc');
|
||||
break;
|
||||
case 'long_tail':
|
||||
setShowNotAddedOnly(false);
|
||||
setDifficultyFilter('');
|
||||
setVolumeMin(String(statThresholds.longTail));
|
||||
setVolumeMax('');
|
||||
setSortBy('keyword');
|
||||
setSortDirection('asc');
|
||||
break;
|
||||
case 'quick_wins':
|
||||
setShowNotAddedOnly(true);
|
||||
setDifficultyFilter('1');
|
||||
setVolumeMin(String(statThresholds.quickWins));
|
||||
setVolumeMax('');
|
||||
setSortBy('difficulty');
|
||||
setSortDirection('asc');
|
||||
break;
|
||||
default:
|
||||
setShowNotAddedOnly(false);
|
||||
setDifficultyFilter('');
|
||||
setVolumeMin('');
|
||||
setVolumeMax('');
|
||||
}
|
||||
}, [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) => {
|
||||
setActiveStatFilter(null);
|
||||
setSelectedIds([]);
|
||||
setCurrentPage(1);
|
||||
if (!sector) {
|
||||
setActiveSector(null);
|
||||
return;
|
||||
}
|
||||
setActiveSector(sector);
|
||||
}, [setActiveSector]);
|
||||
|
||||
// Handle bulk add from metric cards
|
||||
const handleMetricBulkAdd = useCallback(async (statType: StatType, count: number) => {
|
||||
if (!activeSite || !activeSector?.industry_sector) {
|
||||
toast.error('Please select a site and sector first');
|
||||
return;
|
||||
}
|
||||
|
||||
const actionKey = buildStatActionKey(statType, count);
|
||||
if (addedStatActions.has(actionKey)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const statLabelMap: Record<StatType, string> = {
|
||||
total: 'Total Keywords',
|
||||
available: 'Available Keywords',
|
||||
high_volume: 'High Volume',
|
||||
premium_traffic: 'Premium Traffic',
|
||||
long_tail: 'Long Tail',
|
||||
quick_wins: 'Quick Wins',
|
||||
};
|
||||
|
||||
const thresholdMap = {
|
||||
high_volume: sectorStats?.high_volume.threshold ?? 10000,
|
||||
premium_traffic: sectorStats?.premium_traffic.threshold ?? 50000,
|
||||
long_tail: sectorStats?.long_tail.threshold ?? 1000,
|
||||
quick_wins: sectorStats?.quick_wins.threshold ?? 1000,
|
||||
};
|
||||
|
||||
let ids: number[] = [];
|
||||
if (statType === 'quick_wins') {
|
||||
ids = await fetchBulkKeywords({
|
||||
ordering: 'difficulty',
|
||||
difficultyMax: 20,
|
||||
minVolume: thresholdMap.quick_wins,
|
||||
availableOnly: true,
|
||||
count,
|
||||
});
|
||||
} else if (statType === 'long_tail') {
|
||||
ids = await fetchBulkKeywords({
|
||||
ordering: '-volume',
|
||||
minVolume: thresholdMap.long_tail,
|
||||
longTail: true,
|
||||
availableOnly: true,
|
||||
count,
|
||||
});
|
||||
} else if (statType === 'premium_traffic') {
|
||||
ids = await fetchBulkKeywords({
|
||||
ordering: '-volume',
|
||||
minVolume: thresholdMap.premium_traffic,
|
||||
availableOnly: true,
|
||||
count,
|
||||
});
|
||||
} else if (statType === 'high_volume') {
|
||||
ids = await fetchBulkKeywords({
|
||||
ordering: '-volume',
|
||||
minVolume: thresholdMap.high_volume,
|
||||
availableOnly: true,
|
||||
count,
|
||||
});
|
||||
} else if (statType === 'available') {
|
||||
ids = await fetchBulkKeywords({
|
||||
ordering: '-volume',
|
||||
availableOnly: true,
|
||||
count,
|
||||
});
|
||||
} else {
|
||||
ids = await fetchBulkKeywords({
|
||||
ordering: '-volume',
|
||||
availableOnly: true,
|
||||
count,
|
||||
});
|
||||
}
|
||||
|
||||
await prepareBulkAdd({
|
||||
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');
|
||||
}
|
||||
|
||||
const result = await addSeedKeywordsToWorkflow(
|
||||
bulkAddKeywordIds,
|
||||
activeSite.id,
|
||||
activeSector.id
|
||||
);
|
||||
|
||||
if (!result.success) {
|
||||
const errorMsg = result.errors?.[0] || 'Unable to add keywords. Please try again.';
|
||||
throw new Error(errorMsg);
|
||||
}
|
||||
|
||||
if (result.created > 0) {
|
||||
bulkAddKeywordIds.forEach((id) => recentlyAddedRef.current.add(id));
|
||||
}
|
||||
|
||||
if (pendingBulkAddKey) {
|
||||
if (pendingBulkAddGroup === 'stat') {
|
||||
setAddedStatActions((prev) => new Set([...prev, pendingBulkAddKey]));
|
||||
}
|
||||
if (pendingBulkAddGroup === 'suggestion') {
|
||||
setAddedSuggestionActions((prev) => new Set([...prev, pendingBulkAddKey]));
|
||||
}
|
||||
}
|
||||
|
||||
setPendingBulkAddKey(null);
|
||||
setPendingBulkAddGroup(null);
|
||||
setShowBulkAddModal(false);
|
||||
|
||||
if (activeSite) {
|
||||
loadSeedKeywords();
|
||||
loadKeywordCounts();
|
||||
loadSectorStats();
|
||||
}
|
||||
|
||||
return {
|
||||
added: result.created || 0,
|
||||
skipped: result.skipped || 0,
|
||||
total_requested: bulkAddKeywordIds.length,
|
||||
};
|
||||
}, [activeSite, activeSector, bulkAddKeywordIds, loadKeywordCounts, loadSectorStats, loadSeedKeywords, pendingBulkAddGroup, pendingBulkAddKey]);
|
||||
|
||||
// Show WorkflowGuide if no sites
|
||||
if (sites.length === 0) {
|
||||
return (
|
||||
@@ -754,59 +1247,6 @@ export default function IndustriesSectorsKeywords() {
|
||||
);
|
||||
}
|
||||
|
||||
// Handle stat card click - filters table to show matching keywords
|
||||
const handleStatClick = (statType: StatType) => {
|
||||
// Toggle off if clicking same stat
|
||||
if (activeStatFilter === statType) {
|
||||
setActiveStatFilter(null);
|
||||
setShowNotAddedOnly(false);
|
||||
setDifficultyFilter('');
|
||||
return;
|
||||
}
|
||||
|
||||
setActiveStatFilter(statType);
|
||||
setCurrentPage(1);
|
||||
|
||||
// Apply filters based on stat type
|
||||
switch (statType) {
|
||||
case 'available':
|
||||
setShowNotAddedOnly(true);
|
||||
setDifficultyFilter('');
|
||||
break;
|
||||
case 'high_volume':
|
||||
// Volume >= 10K - needs sort by volume desc
|
||||
setShowNotAddedOnly(false);
|
||||
setDifficultyFilter('');
|
||||
setSortBy('volume');
|
||||
setSortDirection('desc');
|
||||
break;
|
||||
case 'premium_traffic':
|
||||
// Premium traffic - sort by volume
|
||||
setShowNotAddedOnly(false);
|
||||
setDifficultyFilter('');
|
||||
setSortBy('volume');
|
||||
setSortDirection('desc');
|
||||
break;
|
||||
case 'long_tail':
|
||||
// Long tail - can't filter by word count server-side, just show all sorted
|
||||
setShowNotAddedOnly(false);
|
||||
setDifficultyFilter('');
|
||||
setSortBy('keyword');
|
||||
setSortDirection('asc');
|
||||
break;
|
||||
case 'quick_wins':
|
||||
// Quick wins - low difficulty + available
|
||||
setShowNotAddedOnly(true);
|
||||
setDifficultyFilter('1'); // Very Easy (level 1 = backend 0-20)
|
||||
setSortBy('difficulty');
|
||||
setSortDirection('asc');
|
||||
break;
|
||||
default:
|
||||
setShowNotAddedOnly(false);
|
||||
setDifficultyFilter('');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageMeta title="Keywords Library" description="Browse curated keywords and add them to your workflow. Ahrefs research coming soon." />
|
||||
@@ -816,66 +1256,49 @@ export default function IndustriesSectorsKeywords() {
|
||||
badge={{ icon: <BoltIcon />, color: 'blue' }}
|
||||
/>
|
||||
|
||||
{/* Sector Cards - Top of page */}
|
||||
{activeSite && sectors.length > 0 && (
|
||||
<div className="mx-6 mt-6">
|
||||
<SectorCardsGrid
|
||||
sectors={sectors}
|
||||
sectorStats={sectorStatsList}
|
||||
activeSectorId={activeSector?.id}
|
||||
onSelectSector={handleSectorSelect}
|
||||
isLoading={loadingSectorStats}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Sector Metric Cards - Show when site is selected */}
|
||||
{activeSite && (
|
||||
<div className="mx-6 mt-6">
|
||||
<div className="mb-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-1">
|
||||
Keyword Stats {activeSector ? `— ${activeSector.name}` : '— All Sectors'}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Click a card to filter the table below
|
||||
</p>
|
||||
</div>
|
||||
<SectorMetricGrid
|
||||
stats={sectorStats}
|
||||
activeStatType={activeStatFilter}
|
||||
onStatClick={handleStatClick}
|
||||
onBulkAdd={activeSector ? handleMetricBulkAdd : undefined}
|
||||
isAddedAction={isStatActionAdded}
|
||||
clickable={true}
|
||||
sectorName={activeSector?.name}
|
||||
isLoading={loadingSectorStats}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Smart Suggestions Panel */}
|
||||
{activeSite && sectorStats && smartSuggestions.length > 0 && (
|
||||
{/* Smart Suggestions Panel - Always show when site is selected */}
|
||||
{activeSite && sectorStats && (
|
||||
<div className="mx-6 mt-6">
|
||||
<SmartSuggestions
|
||||
suggestions={smartSuggestions}
|
||||
onSuggestionClick={(suggestion) => {
|
||||
// Apply the filter from the suggestion
|
||||
if (suggestion.filterParams.statType) {
|
||||
handleStatClick(suggestion.filterParams.statType as StatType);
|
||||
}
|
||||
}}
|
||||
onSuggestionClick={handleSuggestionClick}
|
||||
onBulkAdd={activeSector ? handleSuggestionBulkAdd : undefined}
|
||||
isAddedAction={isSuggestionActionAdded}
|
||||
enableFilterClick={true}
|
||||
isLoading={loadingSectorStats}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Show info banner when no sector is selected */}
|
||||
{!activeSector && activeSite && (
|
||||
<div className="mx-6 mt-6 mb-4">
|
||||
<div className="bg-brand-50 dark:bg-brand-900/20 border border-brand-200 dark:border-brand-800 rounded-lg p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex-shrink-0">
|
||||
<svg className="w-5 h-5 text-brand-600 dark:text-brand-400 mt-0.5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="text-sm font-medium text-brand-900 dark:text-brand-200">
|
||||
Choose a Topic Area First
|
||||
</h3>
|
||||
<p className="mt-1 text-sm text-brand-700 dark:text-brand-300">
|
||||
Pick a topic area first, then add keywords - You need to choose what you're writing about before adding search terms to target
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Keywords Table - Shown by default (per plan) */}
|
||||
{activeSite && (
|
||||
<TablePageTemplate
|
||||
@@ -883,9 +1306,14 @@ export default function IndustriesSectorsKeywords() {
|
||||
data={seedKeywords}
|
||||
loading={!showContent}
|
||||
showContent={showContent}
|
||||
defaultShowFilters={true}
|
||||
centerFilters={true}
|
||||
filters={pageConfig.filters}
|
||||
filterValues={{
|
||||
sector: activeSector?.id ? String(activeSector.id) : '',
|
||||
search: searchTerm,
|
||||
volume_min: volumeMin,
|
||||
volume_max: volumeMax,
|
||||
country: countryFilter,
|
||||
difficulty: difficultyFilter,
|
||||
showNotAddedOnly: showNotAddedOnly ? 'true' : '',
|
||||
@@ -921,19 +1349,49 @@ export default function IndustriesSectorsKeywords() {
|
||||
onFilterChange={(key, value) => {
|
||||
const stringValue = value === null || value === undefined ? '' : String(value);
|
||||
|
||||
if (activeStatFilter) {
|
||||
setActiveStatFilter(null);
|
||||
}
|
||||
|
||||
if (key === 'search') {
|
||||
setSearchTerm(stringValue);
|
||||
} else if (key === 'sector') {
|
||||
if (!stringValue) {
|
||||
handleSectorSelect(null);
|
||||
} else {
|
||||
const selectedSector = sectors.find((sector) => String(sector.id) === stringValue) || null;
|
||||
handleSectorSelect(selectedSector);
|
||||
}
|
||||
} else if (key === 'country') {
|
||||
setCountryFilter(stringValue);
|
||||
setCurrentPage(1);
|
||||
setSelectedIds([]);
|
||||
} else if (key === 'difficulty') {
|
||||
setDifficultyFilter(stringValue);
|
||||
setCurrentPage(1);
|
||||
setSelectedIds([]);
|
||||
} else if (key === 'showNotAddedOnly') {
|
||||
setShowNotAddedOnly(stringValue === 'true');
|
||||
setCurrentPage(1);
|
||||
setSelectedIds([]);
|
||||
}
|
||||
}}
|
||||
onFilterReset={() => {
|
||||
// Clear all filters
|
||||
setSearchTerm('');
|
||||
setCountryFilter('');
|
||||
setVolumeMin('');
|
||||
setVolumeMax('');
|
||||
setDifficultyFilter('');
|
||||
setShowNotAddedOnly(false);
|
||||
setActiveStatFilter(null);
|
||||
setCurrentPage(1);
|
||||
setSelectedIds([]);
|
||||
setActiveSector(null);
|
||||
// Reset sorting to default
|
||||
setSortBy('keyword');
|
||||
setSortDirection('asc');
|
||||
}}
|
||||
onBulkAction={async (actionKey: string, ids: string[]) => {
|
||||
if (actionKey === 'add_selected_to_workflow') {
|
||||
await handleBulkAddSelected(ids);
|
||||
@@ -1045,6 +1503,22 @@ export default function IndustriesSectorsKeywords() {
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* Bulk Add Confirmation */}
|
||||
<BulkAddConfirmation
|
||||
isOpen={showBulkAddModal}
|
||||
onClose={() => {
|
||||
setShowBulkAddModal(false);
|
||||
setBulkAddKeywordIds([]);
|
||||
setBulkAddStatLabel(undefined);
|
||||
setPendingBulkAddKey(null);
|
||||
setPendingBulkAddGroup(null);
|
||||
}}
|
||||
onConfirm={handleConfirmBulkAdd}
|
||||
keywordCount={bulkAddKeywordIds.length}
|
||||
sectorName={activeSector?.name}
|
||||
statTypeLabel={bulkAddStatLabel}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2281,6 +2281,8 @@ export async function fetchKeywordsLibrary(filters?: {
|
||||
ordering?: string;
|
||||
difficulty_min?: number;
|
||||
difficulty_max?: number;
|
||||
volume_min?: number;
|
||||
volume_max?: number;
|
||||
}): Promise<SeedKeywordResponse> {
|
||||
const params = new URLSearchParams();
|
||||
// Use industry_id and sector_id as per backend get_queryset, but also try industry/sector for filterset_fields
|
||||
@@ -2301,6 +2303,9 @@ export async function fetchKeywordsLibrary(filters?: {
|
||||
// Difficulty range filtering
|
||||
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());
|
||||
// Volume range filtering
|
||||
if (filters?.volume_min !== undefined) params.append('volume_min', filters.volume_min.toString());
|
||||
if (filters?.volume_max !== undefined) params.append('volume_max', filters.volume_max.toString());
|
||||
|
||||
const queryString = params.toString();
|
||||
return fetchAPI(`/v1/auth/keywords-library/${queryString ? `?${queryString}` : ''}`);
|
||||
|
||||
@@ -184,6 +184,81 @@
|
||||
SECTION 3: TAILWIND CONFIGURATION
|
||||
=================================================================== */
|
||||
|
||||
/* ===================================================================
|
||||
KEYWORDS LIBRARY - SECTOR CARDS
|
||||
=================================================================== */
|
||||
|
||||
.keywords-library-sector-grid {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.keywords-library-sector-grid {
|
||||
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
.keywords-library-sector-card {
|
||||
padding: 16px;
|
||||
border-radius: var(--radius-xl);
|
||||
border: 1px solid var(--color-stroke);
|
||||
background: var(--color-panel);
|
||||
box-shadow: var(--shadow-sm);
|
||||
transition: box-shadow 0.2s ease, border-color 0.2s ease, transform 0.2s ease;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.keywords-library-sector-card:hover {
|
||||
box-shadow: var(--shadow-md);
|
||||
border-color: color-mix(in srgb, var(--color-primary) 35%, var(--color-stroke));
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.keywords-library-sector-card.is-active {
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: 0 0 0 4px color-mix(in srgb, var(--color-primary) 18%, transparent), var(--shadow-md);
|
||||
}
|
||||
|
||||
.keywords-library-sector-card .sector-card-accent {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 4px;
|
||||
background: var(--gradient-primary);
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.keywords-library-sector-card .sector-card-active-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 999px;
|
||||
background: var(--color-primary);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-primary) 20%, transparent);
|
||||
}
|
||||
|
||||
.keywords-library-sector-card .sector-stats-pill {
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--color-panel-alt);
|
||||
border: 1px solid var(--color-stroke);
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.keywords-library-sector-card .sector-stats-pill .label {
|
||||
color: var(--color-text-dim);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.keywords-library-sector-card .sector-stats-pill .value {
|
||||
color: var(--color-text);
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/*
|
||||
🚫 TAILWIND DEFAULT COLORS ARE DISABLED
|
||||
|
||||
|
||||
@@ -183,6 +183,9 @@ interface TablePageTemplateProps {
|
||||
getRowClassName?: (row: any) => string;
|
||||
// Custom checkbox column width (default: w-12 = 48px)
|
||||
checkboxColumnWidth?: string;
|
||||
// Filter bar behavior
|
||||
defaultShowFilters?: boolean;
|
||||
centerFilters?: boolean;
|
||||
}
|
||||
|
||||
export default function TablePageTemplate({
|
||||
@@ -222,6 +225,8 @@ export default function TablePageTemplate({
|
||||
primaryAction,
|
||||
getRowClassName,
|
||||
checkboxColumnWidth = '48px',
|
||||
defaultShowFilters = false,
|
||||
centerFilters = false,
|
||||
}: TablePageTemplateProps) {
|
||||
const location = useLocation();
|
||||
const [isBulkActionsDropdownOpen, setIsBulkActionsDropdownOpen] = useState(false);
|
||||
@@ -230,7 +235,7 @@ export default function TablePageTemplate({
|
||||
const bulkActionsButtonRef = React.useRef<HTMLButtonElement>(null);
|
||||
|
||||
// Filter toggle state - hidden by default
|
||||
const [showFilters, setShowFilters] = useState(false);
|
||||
const [showFilters, setShowFilters] = useState(defaultShowFilters);
|
||||
|
||||
// Get notification config for current page
|
||||
const deleteModalConfig = getDeleteModalConfig(location.pathname);
|
||||
@@ -765,8 +770,8 @@ export default function TablePageTemplate({
|
||||
|
||||
{/* Filters Row - Below action buttons, left aligned with shadow */}
|
||||
{showFilters && (renderFilters || filters.length > 0) && (
|
||||
<div className="flex justify-start py-1.5 mb-2.5">
|
||||
<div className="inline-flex bg-gray-50 dark:bg-gray-800/30 rounded-lg px-4 py-2 border border-gray-200 dark:border-gray-700 shadow-md">
|
||||
<div className={`flex py-1.5 mb-2.5 ${centerFilters ? 'justify-center' : 'justify-start'}`}>
|
||||
<div className={`inline-flex bg-gray-50 dark:bg-gray-800/30 rounded-lg px-4 py-2 border border-gray-200 dark:border-gray-700 shadow-md ${centerFilters ? 'mx-auto' : ''}`}>
|
||||
<div className="flex gap-2 items-center flex-wrap">
|
||||
{renderFilters ? (
|
||||
renderFilters
|
||||
|
||||
Reference in New Issue
Block a user