keywrod slibrary page dsigning

This commit is contained in:
IGNY8 VPS (Salman)
2026-01-18 19:56:49 +00:00
parent aa03e15eea
commit 4cf27fa875
9 changed files with 1151 additions and 323 deletions

View File

@@ -867,6 +867,8 @@ class SeedKeywordViewSet(viewsets.ReadOnlyModelViewSet):
sector_name = self.request.query_params.get('sector_name') sector_name = self.request.query_params.get('sector_name')
difficulty_min = self.request.query_params.get('difficulty_min') difficulty_min = self.request.query_params.get('difficulty_min')
difficulty_max = self.request.query_params.get('difficulty_max') difficulty_max = self.request.query_params.get('difficulty_max')
volume_min = self.request.query_params.get('volume_min')
volume_max = self.request.query_params.get('volume_max')
if industry_id: if industry_id:
queryset = queryset.filter(industry_id=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)) queryset = queryset.filter(difficulty__lte=int(difficulty_max))
except (ValueError, TypeError): except (ValueError, TypeError):
pass 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 return queryset
@@ -1106,9 +1120,9 @@ class SeedKeywordViewSet(viewsets.ReadOnlyModelViewSet):
# Get already-added keyword IDs if site_id provided # Get already-added keyword IDs if site_id provided
already_added_ids = set() already_added_ids = set()
if site_id: if site_id:
from igny8_core.business.models import SiteKeyword from igny8_core.business.planning.models import Keywords
already_added_ids = set( already_added_ids = set(
SiteKeyword.objects.filter( Keywords.objects.filter(
site_id=site_id, site_id=site_id,
seed_keyword__isnull=False seed_keyword__isnull=False
).values_list('seed_keyword_id', flat=True) ).values_list('seed_keyword_id', flat=True)
@@ -1230,7 +1244,7 @@ class SeedKeywordViewSet(viewsets.ReadOnlyModelViewSet):
Get cascading filter options for Keywords Library. Get cascading filter options for Keywords Library.
Returns industries, sectors (filtered by industry), and available filter values. Returns industries, sectors (filtered by industry), and available filter values.
""" """
from django.db.models import Count, Min, Max from django.db.models import Count, Min, Max, Q
try: try:
industry_id = request.query_params.get('industry_id') industry_id = request.query_params.get('industry_id')
@@ -1310,7 +1324,7 @@ class SeedKeywordViewSet(viewsets.ReadOnlyModelViewSet):
Accepts a list of seed_keyword IDs and adds them to the specified site. Accepts a list of seed_keyword IDs and adds them to the specified site.
""" """
from django.db import transaction from django.db import transaction
from igny8_core.business.models import SiteKeyword from igny8_core.business.planning.models import Keywords
try: try:
site_id = request.data.get('site_id') site_id = request.data.get('site_id')
@@ -1331,7 +1345,7 @@ class SeedKeywordViewSet(viewsets.ReadOnlyModelViewSet):
) )
# Verify site access # 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() site = Site.objects.filter(id=site_id).first()
if not site: if not site:
return error_response( return error_response(
@@ -1365,12 +1379,19 @@ class SeedKeywordViewSet(viewsets.ReadOnlyModelViewSet):
# Get already existing # Get already existing
existing_seed_ids = set( existing_seed_ids = set(
SiteKeyword.objects.filter( Keywords.objects.filter(
site_id=site_id, site_id=site_id,
seed_keyword_id__in=keyword_ids seed_keyword_id__in=keyword_ids
).values_list('seed_keyword_id', flat=True) ).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 added_count = 0
skipped_count = 0 skipped_count = 0
@@ -1380,14 +1401,17 @@ class SeedKeywordViewSet(viewsets.ReadOnlyModelViewSet):
skipped_count += 1 skipped_count += 1
continue 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, site=site,
keyword=seed_kw.keyword, sector=site_sector,
seed_keyword=seed_kw, seed_keyword=seed_kw,
volume=seed_kw.volume,
difficulty=seed_kw.difficulty,
source='library',
is_active=True
) )
added_count += 1 added_count += 1

View 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>
);
}

View File

@@ -1,12 +1,13 @@
/** /**
* SectorMetricCard - Clickable metric cards for Keywords Library * SectorMetricCard - Redesigned metric cards for Keywords Library
* Displays 6 stat types with dynamic fallback thresholds * White background with accent colors like Dashboard cards
* Clicking a card filters the table to show matching keywords * Shows 6 stat types with click-to-filter and bulk add options
*/ */
import { ReactNode, useMemo } from 'react'; import { ReactNode, useMemo } from 'react';
import clsx from 'clsx'; 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'; export type StatType = 'total' | 'available' | 'high_volume' | 'premium_traffic' | 'long_tail' | 'quick_wins';
@@ -29,88 +30,77 @@ interface SectorMetricCardProps {
stats: SectorStats; stats: SectorStats;
isActive: boolean; isActive: boolean;
onClick: () => void; onClick: () => void;
onBulkAdd?: (statType: StatType, count: number) => void;
isAddedAction?: (statType: StatType, count: number) => boolean;
clickable?: boolean;
sectorName?: string; sectorName?: string;
compact?: boolean; compact?: boolean;
} }
// Card configuration for each stat type // Card configuration for each stat type - using dashboard accent colors
const STAT_CONFIG: Record<StatType, { const STAT_CONFIG: Record<StatType, {
label: string; label: string;
description: string; description: string;
icon: ReactNode; icon: ReactNode;
color: string; accentColor: string;
bgColor: string; textColor: string;
borderColor: string; badgeColor: string;
activeColor: string;
activeBorder: string;
showThreshold?: boolean; showThreshold?: boolean;
thresholdLabel?: string; thresholdPrefix?: string;
}> = { }> = {
total: { total: {
label: 'Total', label: 'Total',
description: 'All keywords in sector', description: 'All keywords in sector',
icon: <PieChartIcon className="w-5 h-5" />, icon: <PieChartIcon className="w-5 h-5" />,
color: 'text-gray-600 dark:text-gray-400', accentColor: 'bg-gray-500',
bgColor: 'bg-gray-50 dark:bg-gray-800/50', textColor: 'text-gray-600 dark:text-gray-400',
borderColor: 'border-gray-200 dark:border-gray-700', badgeColor: 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300',
activeColor: 'bg-gray-100 dark:bg-gray-700/50',
activeBorder: 'border-gray-400 dark:border-gray-500',
}, },
available: { available: {
label: 'Available', label: 'Available',
description: 'Not yet added to your site', description: 'Not yet added to your site',
icon: <CheckCircleIcon className="w-5 h-5" />, icon: <CheckCircleIcon className="w-5 h-5" />,
color: 'text-green-600 dark:text-green-400', accentColor: 'bg-success-500',
bgColor: 'bg-green-50 dark:bg-green-900/20', textColor: 'text-success-600 dark:text-success-400',
borderColor: 'border-green-200 dark:border-green-800', badgeColor: 'bg-success-100 text-success-700 dark:bg-success-900/40 dark:text-success-300',
activeColor: 'bg-green-100 dark:bg-green-800/30',
activeBorder: 'border-green-500 dark:border-green-600',
}, },
high_volume: { high_volume: {
label: 'High Volume', label: 'High Volume',
description: 'Volume ≥ 10K searches/mo', description: 'Volume ≥ 10K searches/mo',
icon: <ShootingStarIcon className="w-5 h-5" />, icon: <ShootingStarIcon className="w-5 h-5" />,
color: 'text-blue-600 dark:text-blue-400', accentColor: 'bg-brand-500',
bgColor: 'bg-blue-50 dark:bg-blue-900/20', textColor: 'text-brand-600 dark:text-brand-400',
borderColor: 'border-blue-200 dark:border-blue-800', badgeColor: 'bg-brand-100 text-brand-700 dark:bg-brand-900/40 dark:text-brand-300',
activeColor: 'bg-blue-100 dark:bg-blue-800/30',
activeBorder: 'border-blue-500 dark:border-blue-600',
}, },
premium_traffic: { premium_traffic: {
label: 'Premium Traffic', label: 'Premium Traffic',
description: 'High volume keywords', description: 'High volume keywords',
icon: <BoxIcon className="w-5 h-5" />, icon: <BoxIcon className="w-5 h-5" />,
color: 'text-amber-600 dark:text-amber-400', accentColor: 'bg-warning-500',
bgColor: 'bg-amber-50 dark:bg-amber-900/20', textColor: 'text-warning-600 dark:text-warning-400',
borderColor: 'border-amber-200 dark:border-amber-800', badgeColor: 'bg-warning-100 text-warning-700 dark:bg-warning-900/40 dark:text-warning-300',
activeColor: 'bg-amber-100 dark:bg-amber-800/30',
activeBorder: 'border-amber-500 dark:border-amber-600',
showThreshold: true, showThreshold: true,
thresholdLabel: 'Vol ≥', thresholdPrefix: 'Vol ≥',
}, },
long_tail: { long_tail: {
label: 'Long Tail', label: 'Long Tail',
description: '4+ words with good volume', description: '4+ words with good volume',
icon: <DocsIcon className="w-5 h-5" />, icon: <DocsIcon className="w-5 h-5" />,
color: 'text-purple-600 dark:text-purple-400', accentColor: 'bg-purple-500',
bgColor: 'bg-purple-50 dark:bg-purple-900/20', textColor: 'text-purple-600 dark:text-purple-400',
borderColor: 'border-purple-200 dark:border-purple-800', badgeColor: 'bg-purple-100 text-purple-700 dark:bg-purple-900/40 dark:text-purple-300',
activeColor: 'bg-purple-100 dark:bg-purple-800/30',
activeBorder: 'border-purple-500 dark:border-purple-600',
showThreshold: true, showThreshold: true,
thresholdLabel: 'Vol >', thresholdPrefix: 'Vol >',
}, },
quick_wins: { quick_wins: {
label: 'Quick Wins', label: 'Quick Wins',
description: 'Low difficulty, good volume', description: 'Low difficulty, good volume',
icon: <BoltIcon className="w-5 h-5" />, icon: <BoltIcon className="w-5 h-5" />,
color: 'text-emerald-600 dark:text-emerald-400', accentColor: 'bg-emerald-500',
bgColor: 'bg-emerald-50 dark:bg-emerald-900/20', textColor: 'text-emerald-600 dark:text-emerald-400',
borderColor: 'border-emerald-200 dark:border-emerald-800', badgeColor: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300',
activeColor: 'bg-emerald-100 dark:bg-emerald-800/30',
activeBorder: 'border-emerald-500 dark:border-emerald-600',
showThreshold: true, showThreshold: true,
thresholdLabel: 'Vol >', thresholdPrefix: 'Vol >',
}, },
}; };
@@ -138,79 +128,129 @@ export default function SectorMetricCard({
stats, stats,
isActive, isActive,
onClick, onClick,
onBulkAdd,
isAddedAction,
clickable = true,
sectorName, sectorName,
compact = false, compact = false,
}: SectorMetricCardProps) { }: SectorMetricCardProps) {
const config = STAT_CONFIG[statType]; const config = STAT_CONFIG[statType];
const statData = stats[statType]; const statData = stats[statType];
// Build description with threshold if applicable // Build description with detailed threshold copy (match Smart Suggestions tone)
const description = useMemo(() => { const description = useMemo(() => {
if (config.showThreshold && statData.threshold) { const threshold = statData.threshold;
return `${config.thresholdLabel} ${formatThreshold(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.description, config.showThreshold, config.thresholdPrefix, statData.threshold, statType]);
}, [config, statData.threshold]);
// 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 ( return (
<button <div
onClick={onClick} onClick={clickable ? onClick : undefined}
className={clsx( className={clsx(
'group relative flex flex-col items-start w-full rounded-xl border-2 transition-all duration-200', 'relative rounded-xl border bg-white dark:bg-white/[0.03] overflow-hidden',
'hover:shadow-md hover:scale-[1.02] active:scale-[0.98]', 'transition-all duration-200',
'focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-brand-500', clickable
? 'cursor-pointer hover:shadow-lg hover:border-gray-300 dark:hover:border-gray-600'
: 'cursor-default',
compact ? 'p-3' : 'p-4', 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 */} {/* Accent Border Left */}
<div className="flex items-center gap-2 mb-2"> <div className={clsx('absolute left-0 top-0 bottom-0 w-1', config.accentColor)} />
<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>
{/* Count */} {/* Header: Icon + Label + Count */}
<div className={clsx( <div className="flex items-start justify-between gap-2">
'font-bold tabular-nums', <div className="flex items-center gap-2">
compact ? 'text-2xl' : 'text-3xl', <div className={clsx('p-2 rounded-lg', config.badgeColor, config.textColor)}>
config.color, {config.icon}
)}> </div>
{formatCount(statData.count)} <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> </div>
{/* Description / Threshold */} {/* Description / Threshold */}
{!compact && ( <p className="mt-2 text-xs text-gray-500 dark:text-gray-400">
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400 line-clamp-1"> {description}
{description} </p>
</p>
)}
{/* Active indicator */} {/* Bulk Add Buttons - Always visible */}
{isActive && ( {onBulkAdd && statData.count > 0 && statType !== 'total' && (
<div className={clsx( <div className="mt-3 pt-3 border-t border-gray-100 dark:border-gray-800">
'absolute top-2 right-2 w-2 h-2 rounded-full animate-pulse', <div className="flex flex-wrap items-center gap-2 text-xs">
statType === 'total' ? 'bg-gray-500' : <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">
statType === 'available' ? 'bg-green-500' : <PlusIcon className="w-3 h-3" />
statType === 'high_volume' ? 'bg-blue-500' : Add to workflow
statType === 'premium_traffic' ? 'bg-amber-500' : </span>
statType === 'long_tail' ? 'bg-purple-500' : {bulkAddOptions.map((count) => {
'bg-emerald-500' 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; stats: SectorStats | null;
activeStatType: StatType | null; activeStatType: StatType | null;
onStatClick: (statType: StatType) => void; onStatClick: (statType: StatType) => void;
onBulkAdd?: (statType: StatType, count: number) => void;
isAddedAction?: (statType: StatType, count: number) => boolean;
clickable?: boolean;
sectorName?: string; sectorName?: string;
compact?: boolean; compact?: boolean;
isLoading?: boolean; isLoading?: boolean;
@@ -228,27 +271,34 @@ export function SectorMetricGrid({
stats, stats,
activeStatType, activeStatType,
onStatClick, onStatClick,
onBulkAdd,
isAddedAction,
clickable = true,
sectorName, sectorName,
compact = false, compact = false,
isLoading = false, isLoading = false,
}: SectorMetricGridProps) { }: 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 // Loading skeleton
if (isLoading || !stats) { if (isLoading || !stats) {
return ( return (
<div className={clsx( <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', compact ? 'grid-cols-3 sm:grid-cols-6' : 'grid-cols-2 sm:grid-cols-3 lg:grid-cols-6',
)}> )}>
{statTypes.map((statType) => ( {statTypes.map((statType) => (
<div <div
key={statType} 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="absolute left-0 top-0 bottom-0 w-1 bg-gray-200 dark:bg-gray-700" />
<div className="h-8 w-16 bg-gray-200 dark:bg-gray-700 rounded mb-1" /> <div className="flex items-center gap-2 mb-2">
<div className="h-3 w-24 bg-gray-200 dark:bg-gray-700 rounded" /> <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>
))} ))}
</div> </div>
@@ -257,7 +307,7 @@ export function SectorMetricGrid({
return ( return (
<div className={clsx( <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', compact ? 'grid-cols-3 sm:grid-cols-6' : 'grid-cols-2 sm:grid-cols-3 lg:grid-cols-6',
)}> )}>
{statTypes.map((statType) => ( {statTypes.map((statType) => (
@@ -267,6 +317,9 @@ export function SectorMetricGrid({
stats={stats} stats={stats}
isActive={activeStatType === statType} isActive={activeStatType === statType}
onClick={() => onStatClick(statType)} onClick={() => onStatClick(statType)}
onBulkAdd={onBulkAdd}
isAddedAction={isAddedAction}
clickable={clickable}
sectorName={sectorName} sectorName={sectorName}
compact={compact} compact={compact}
/> />

View File

@@ -1,12 +1,12 @@
/** /**
* SmartSuggestions - Breathing indicator panel for Keywords Library * SmartSuggestions - Compact card-based suggestions for Keywords Library
* Shows "Ready-to-use keywords waiting for you!" with animated indicator * White background with accent colors like Dashboard cards
* Clicking navigates to pre-filtered keywords * Shows keyword categories with counts and bulk add options
*/ */
import { useState } from 'react'; import { useState } from 'react';
import clsx from 'clsx'; 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'; import Button from '../ui/button/Button';
interface SmartSuggestion { interface SmartSuggestion {
@@ -27,64 +27,98 @@ interface SmartSuggestion {
interface SmartSuggestionsProps { interface SmartSuggestionsProps {
suggestions: SmartSuggestion[]; suggestions: SmartSuggestion[];
onSuggestionClick: (suggestion: SmartSuggestion) => void; onSuggestionClick: (suggestion: SmartSuggestion) => void;
onBulkAdd?: (suggestion: SmartSuggestion, count: number) => void;
isAddedAction?: (suggestionId: string, count: number) => boolean;
enableFilterClick?: boolean;
className?: string; className?: string;
isLoading?: boolean; 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 = { const colorClasses = {
blue: { blue: {
bg: 'bg-blue-50 dark:bg-blue-900/20', accent: 'bg-brand-500',
border: 'border-blue-200 dark:border-blue-800', text: 'text-brand-600 dark:text-brand-400',
text: 'text-blue-600 dark:text-blue-400', iconBg: 'bg-brand-100 dark:bg-brand-900/40',
badge: 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300',
}, },
green: { green: {
bg: 'bg-green-50 dark:bg-green-900/20', accent: 'bg-success-500',
border: 'border-green-200 dark:border-green-800', text: 'text-success-600 dark:text-success-400',
text: 'text-green-600 dark:text-green-400', iconBg: 'bg-success-100 dark:bg-success-900/40',
badge: 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300',
}, },
amber: { amber: {
bg: 'bg-amber-50 dark:bg-amber-900/20', accent: 'bg-warning-500',
border: 'border-amber-200 dark:border-amber-800', text: 'text-warning-600 dark:text-warning-400',
text: 'text-amber-600 dark:text-amber-400', iconBg: 'bg-warning-100 dark:bg-warning-900/40',
badge: 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300',
}, },
purple: { purple: {
bg: 'bg-purple-50 dark:bg-purple-900/20', accent: 'bg-purple-500',
border: 'border-purple-200 dark:border-purple-800',
text: 'text-purple-600 dark:text-purple-400', 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: { emerald: {
bg: 'bg-emerald-50 dark:bg-emerald-900/20', accent: 'bg-emerald-500',
border: 'border-emerald-200 dark:border-emerald-800',
text: 'text-emerald-600 dark:text-emerald-400', 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({ export default function SmartSuggestions({
suggestions, suggestions,
onSuggestionClick, onSuggestionClick,
onBulkAdd,
isAddedAction,
enableFilterClick = true,
className, className,
isLoading = false, isLoading = false,
}: SmartSuggestionsProps) { }: SmartSuggestionsProps) {
const [isExpanded, setIsExpanded] = useState(true); const [isExpanded, setIsExpanded] = useState(true);
const totalAvailable = suggestions.reduce((sum, s) => sum + s.count, 0); const totalAvailable = suggestions.reduce((sum, s) => sum + s.count, 0);
const hasKeywords = totalAvailable > 0; const hasKeywords = totalAvailable > 0;
if (isLoading) { if (isLoading) {
return ( return (
<div className={clsx( <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 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" /> <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>
</div> </div>
); );
@@ -92,46 +126,35 @@ export default function SmartSuggestions({
return ( return (
<div className={clsx( <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', 'rounded-xl border border-gray-200 dark:border-gray-800 bg-white dark:bg-white/[0.03] overflow-hidden',
'border-brand-200 dark:border-brand-800',
className className
)}> )}>
{/* Header with breathing indicator */} {/* Header */}
<button <button
onClick={() => setIsExpanded(!isExpanded)} 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"> <div className="flex items-center gap-3">
{/* Breathing indicator */} {/* Icon with breathing animation */}
<div className="relative"> <div className={clsx(
<div className={clsx( 'w-8 h-8 rounded-lg flex items-center justify-center',
'w-10 h-10 rounded-full flex items-center justify-center', 'bg-gradient-to-br from-brand-500 to-purple-500',
'bg-gradient-to-br from-brand-400 to-purple-500', hasKeywords && 'animate-pulse'
hasKeywords && 'animate-pulse' )}>
)}> <ShootingStarIcon className="w-4 h-4 text-white" />
<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>
)}
</div> </div>
<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 Smart Suggestions
{hasKeywords && ( {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"> <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} ready {totalAvailable.toLocaleString()} ready
</span> </span>
)} )}
</h3> </h3>
<p className="text-sm text-gray-600 dark:text-gray-400"> <p className="text-xs text-gray-500 dark:text-gray-400">
{hasKeywords Ready-to-use keywords waiting for you!
? 'Ready-to-use keywords waiting for you!'
: 'No suggestions available for current filters'
}
</p> </p>
</div> </div>
</div> </div>
@@ -144,52 +167,98 @@ export default function SmartSuggestions({
/> />
</button> </button>
{/* Expandable content */} {/* Expandable content - Grid layout */}
{isExpanded && suggestions.length > 0 && ( {isExpanded && (
<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 && (
<div className="px-4 pb-4"> <div className="px-4 pb-4">
<p className="text-sm text-gray-500 dark:text-gray-400 text-center py-4"> <div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
Select an industry and sector to see smart suggestions. {suggestions.map((suggestion) => {
</p> const colors = colorClasses[suggestion.color];
const icon = SUGGESTION_ICONS[suggestion.id] || <ShootingStarIcon className="w-4 h-4" />;
// Bulk add options
const bulkOptions: number[] = [];
if (suggestion.count >= 50) bulkOptions.push(50);
if (suggestion.count >= 100) bulkOptions.push(100);
if (suggestion.count >= 200) bulkOptions.push(200);
if (suggestion.count > 0 && suggestion.count <= 200) bulkOptions.push(suggestion.count);
return (
<div
key={suggestion.id}
className={clsx(
'relative rounded-lg border bg-white dark:bg-white/[0.02] overflow-hidden',
'transition-all duration-200 cursor-pointer',
enableFilterClick
? 'cursor-pointer hover:shadow-md hover:border-gray-300 dark:hover:border-gray-600'
: 'cursor-default',
'border-gray-200 dark:border-gray-800',
)}
>
{/* Accent border */}
<div className={clsx('absolute left-0 top-0 bottom-0 w-1', colors.accent)} />
{/* Main content - clickable to filter */}
<div
onClick={() => {
if (enableFilterClick) {
onSuggestionClick(suggestion);
}
}}
className="p-3 pl-4"
>
<div className="flex items-center justify-between gap-2 mb-1">
<div className="flex items-center gap-2">
<div className={clsx('p-2 rounded', colors.iconBg, colors.text)}>
{icon}
</div>
<span className={clsx('font-semibold text-base', colors.text)}>
{suggestion.label}
</span>
</div>
<span className="text-xl font-bold text-gray-900 dark:text-white tabular-nums">
{suggestion.count.toLocaleString()}
</span>
</div>
<p className="text-xs text-gray-500 dark:text-gray-400 line-clamp-1">
{suggestion.description}
</p>
</div>
{/* Bulk add buttons - always visible */}
{onBulkAdd && bulkOptions.length > 0 && (
<div className="px-3 pb-3 pt-0">
<div className="flex items-center gap-2 pt-2 border-t border-gray-100 dark:border-gray-800">
<span className="text-xs text-gray-500 dark:text-gray-400 flex items-center gap-1">
<PlusIcon className="w-3 h-3" />
Add
</span>
</div>
<div className="flex flex-wrap gap-2 pt-2">
{bulkOptions.map((count) => {
const isAdded = isAddedAction ? isAddedAction(suggestion.id, count) : false;
return (
<Button
key={count}
size="xs"
variant={isAdded ? 'outline' : 'primary'}
tone={isAdded ? 'success' : 'brand'}
startIcon={
isAdded ? <CheckCircleIcon className="w-3 h-3" /> : <PlusIcon className="w-3 h-3" />
}
onClick={() => onBulkAdd(suggestion, count)}
disabled={isAdded}
>
{isAdded ? 'Added' : (count === suggestion.count ? 'All' : count)}
</Button>
);
})}
</div>
</div>
)}
</div>
);
})}
</div>
</div> </div>
)} )}
</div> </div>

View File

@@ -45,7 +45,7 @@ export default function SeedKeywords() {
return ( return (
<> <>
<PageMeta title="Seed Keywords" /> <PageMeta title="Seed Keywords" description="Global keyword library for reference" />
<div className="mb-6"> <div className="mb-6">
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Seed Keywords</h1> <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> <p className="text-gray-600 dark:text-gray-400 mt-1">Global keyword library for reference</p>

View File

@@ -24,6 +24,7 @@ import {
fetchKeywords, fetchKeywords,
fetchSectorStats, fetchSectorStats,
SectorStats, SectorStats,
SectorStatsItem,
} from '../../services/api'; } from '../../services/api';
import Badge from '../../components/ui/badge/Badge'; import Badge from '../../components/ui/badge/Badge';
import { BoltIcon, ShootingStarIcon } from '../../icons'; import { BoltIcon, ShootingStarIcon } from '../../icons';
@@ -32,19 +33,23 @@ import { usePageSizeStore } from '../../store/pageSizeStore';
import { getDifficultyNumber, getDifficultyRange, getDifficultyLabelFromNumber } from '../../utils/difficulty'; import { getDifficultyNumber, getDifficultyRange, getDifficultyLabelFromNumber } from '../../utils/difficulty';
import { useSiteStore } from '../../store/siteStore'; import { useSiteStore } from '../../store/siteStore';
import { useSectorStore } from '../../store/sectorStore'; import { useSectorStore } from '../../store/sectorStore';
import type { Sector } from '../../store/sectorStore';
import Button from '../../components/ui/button/Button'; import Button from '../../components/ui/button/Button';
import { Modal } from '../../components/ui/modal'; import { Modal } from '../../components/ui/modal';
import FileInput from '../../components/form/input/FileInput'; import FileInput from '../../components/form/input/FileInput';
import Label from '../../components/form/Label'; import Label from '../../components/form/Label';
import Input from '../../components/form/input/InputField';
import { Card } from '../../components/ui/card'; import { Card } from '../../components/ui/card';
import { SectorMetricGrid, StatType } from '../../components/keywords-library/SectorMetricCard'; import { SectorMetricGrid, StatType } from '../../components/keywords-library/SectorMetricCard';
import SmartSuggestions, { buildSmartSuggestions } from '../../components/keywords-library/SmartSuggestions'; import SmartSuggestions, { buildSmartSuggestions } from '../../components/keywords-library/SmartSuggestions';
import SectorCardsGrid from '../../components/keywords-library/SectorCardsGrid';
import BulkAddConfirmation from '../../components/keywords-library/BulkAddConfirmation';
export default function IndustriesSectorsKeywords() { export default function IndustriesSectorsKeywords() {
const toast = useToast(); const toast = useToast();
const { startLoading, stopLoading } = usePageLoading(); const { startLoading, stopLoading } = usePageLoading();
const { activeSite } = useSiteStore(); const { activeSite } = useSiteStore();
const { activeSector, loadSectorsForSite } = useSectorStore(); const { activeSector, loadSectorsForSite, sectors, setActiveSector } = useSectorStore();
const { pageSize } = usePageSizeStore(); const { pageSize } = usePageSizeStore();
// Data state // Data state
@@ -54,9 +59,11 @@ export default function IndustriesSectorsKeywords() {
const [selectedIds, setSelectedIds] = useState<string[]>([]); const [selectedIds, setSelectedIds] = useState<string[]>([]);
// Track recently added keywords to preserve their state during reload // Track recently added keywords to preserve their state during reload
const recentlyAddedRef = useRef<Set<number>>(new Set()); const recentlyAddedRef = useRef<Set<number>>(new Set());
const attachedSeedKeywordIdsRef = useRef<Set<number>>(new Set());
// Sector Stats state (for metric cards) // Sector Stats state (for metric cards)
const [sectorStats, setSectorStats] = useState<SectorStats | null>(null); const [sectorStats, setSectorStats] = useState<SectorStats | null>(null);
const [sectorStatsList, setSectorStatsList] = useState<SectorStatsItem[]>([]);
const [loadingSectorStats, setLoadingSectorStats] = useState(false); const [loadingSectorStats, setLoadingSectorStats] = useState(false);
const [activeStatFilter, setActiveStatFilter] = useState<StatType | null>(null); const [activeStatFilter, setActiveStatFilter] = useState<StatType | null>(null);
@@ -64,6 +71,10 @@ export default function IndustriesSectorsKeywords() {
const [showBulkAddModal, setShowBulkAddModal] = useState(false); const [showBulkAddModal, setShowBulkAddModal] = useState(false);
const [bulkAddKeywordIds, setBulkAddKeywordIds] = useState<number[]>([]); const [bulkAddKeywordIds, setBulkAddKeywordIds] = useState<number[]>([]);
const [bulkAddStatLabel, setBulkAddStatLabel] = useState<string | undefined>(); const [bulkAddStatLabel, setBulkAddStatLabel] = useState<string | undefined>();
const [pendingBulkAddKey, setPendingBulkAddKey] = useState<string | null>(null);
const [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 // Ahrefs banner state
const [showAhrefsBanner, setShowAhrefsBanner] = useState(true); const [showAhrefsBanner, setShowAhrefsBanner] = useState(true);
@@ -82,6 +93,8 @@ export default function IndustriesSectorsKeywords() {
const [countryFilter, setCountryFilter] = useState(''); const [countryFilter, setCountryFilter] = useState('');
const [difficultyFilter, setDifficultyFilter] = useState(''); const [difficultyFilter, setDifficultyFilter] = useState('');
const [showNotAddedOnly, setShowNotAddedOnly] = useState(false); const [showNotAddedOnly, setShowNotAddedOnly] = useState(false);
const [volumeMin, setVolumeMin] = useState('');
const [volumeMax, setVolumeMax] = useState('');
// Keyword count tracking // Keyword count tracking
const [addedCount, setAddedCount] = useState(0); const [addedCount, setAddedCount] = useState(0);
@@ -176,6 +189,7 @@ export default function IndustriesSectorsKeywords() {
const loadSectorStats = useCallback(async () => { const loadSectorStats = useCallback(async () => {
if (!activeSite || !activeSite.industry) { if (!activeSite || !activeSite.industry) {
setSectorStats(null); setSectorStats(null);
setSectorStatsList([]);
return; return;
} }
@@ -183,15 +197,21 @@ export default function IndustriesSectorsKeywords() {
try { try {
const response = await fetchSectorStats({ const response = await fetchSectorStats({
industry_id: activeSite.industry, industry_id: activeSite.industry,
sector_id: activeSector?.industry_sector ?? undefined,
site_id: activeSite.id, site_id: activeSite.id,
}); });
// If sector-specific stats returned const allSectors = response.sectors || [];
if (response.stats) { setSectorStatsList(allSectors);
setSectorStats(response.stats as SectorStats);
} else if (response.sectors && response.sectors.length > 0) { if (activeSector?.industry_sector) {
// Aggregate stats from all sectors 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 = { const aggregated: SectorStats = {
total: { count: 0 }, total: { count: 0 },
available: { count: 0 }, available: { count: 0 },
@@ -200,15 +220,14 @@ export default function IndustriesSectorsKeywords() {
long_tail: { count: 0, threshold: 1000 }, long_tail: { count: 0, threshold: 1000 },
quick_wins: { 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.total.count += sector.stats.total.count;
aggregated.available.count += sector.stats.available.count; aggregated.available.count += sector.stats.available.count;
aggregated.high_volume.count += sector.stats.high_volume.count; aggregated.high_volume.count += sector.stats.high_volume.count;
aggregated.premium_traffic.count += sector.stats.premium_traffic.count; aggregated.premium_traffic.count += sector.stats.premium_traffic.count;
aggregated.long_tail.count += sector.stats.long_tail.count; aggregated.long_tail.count += sector.stats.long_tail.count;
aggregated.quick_wins.count += sector.stats.quick_wins.count; aggregated.quick_wins.count += sector.stats.quick_wins.count;
// Use first sector's thresholds (they should be consistent)
if (!aggregated.premium_traffic.threshold) { if (!aggregated.premium_traffic.threshold) {
aggregated.premium_traffic.threshold = sector.stats.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; aggregated.quick_wins.threshold = sector.stats.quick_wins.threshold;
} }
}); });
setSectorStats(aggregated); setSectorStats(aggregated);
} else {
setSectorStats(null);
} }
} catch (error) { } catch (error) {
console.error('Failed to load sector stats:', error); console.error('Failed to load sector stats:', error);
@@ -236,6 +257,26 @@ export default function IndustriesSectorsKeywords() {
} }
}, [activeSite, activeSector, loadSectorStats]); }, [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 // Load counts on mount and when site/sector changes
useEffect(() => { useEffect(() => {
if (activeSite) { if (activeSite) {
@@ -283,6 +324,9 @@ export default function IndustriesSectorsKeywords() {
console.warn('Could not fetch sectors or attached keywords:', err); 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 // Build API filters - use server-side pagination
const pageSizeNum = pageSize || 25; const pageSizeNum = pageSize || 25;
const filters: any = { const filters: any = {
@@ -298,6 +342,8 @@ export default function IndustriesSectorsKeywords() {
if (searchTerm) filters.search = searchTerm; if (searchTerm) filters.search = searchTerm;
if (countryFilter) filters.country = countryFilter; if (countryFilter) filters.country = countryFilter;
if (volumeMin) filters.volume_min = parseInt(volumeMin);
if (volumeMax) filters.volume_max = parseInt(volumeMax);
// Apply difficulty filter (if API supports it, otherwise we'll filter client-side) // Apply difficulty filter (if API supports it, otherwise we'll filter client-side)
if (difficultyFilter) { if (difficultyFilter) {
@@ -361,7 +407,7 @@ export default function IndustriesSectorsKeywords() {
setAvailableCount(0); setAvailableCount(0);
setShowContent(true); 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) // Load data when site/sector/filters change (show table by default per plan)
useEffect(() => { useEffect(() => {
@@ -381,6 +427,7 @@ export default function IndustriesSectorsKeywords() {
// Reset to page 1 on pageSize change // Reset to page 1 on pageSize change
useEffect(() => { useEffect(() => {
setCurrentPage(1); setCurrentPage(1);
setSelectedIds([]);
}, [pageSize]); }, [pageSize]);
// Handle sorting // Handle sorting
@@ -388,6 +435,7 @@ export default function IndustriesSectorsKeywords() {
setSortBy(field || 'keyword'); setSortBy(field || 'keyword');
setSortDirection(direction); setSortDirection(direction);
setCurrentPage(1); setCurrentPage(1);
setSelectedIds([]);
}; };
// Handle adding keywords to workflow // Handle adding keywords to workflow
@@ -684,6 +732,50 @@ export default function IndustriesSectorsKeywords() {
type: 'text' as const, type: 'text' as const,
placeholder: 'Search keywords...', 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', key: 'country',
label: 'Country', label: 'Country',
@@ -730,7 +822,7 @@ export default function IndustriesSectorsKeywords() {
}, },
], ],
}; };
}, [activeSector, handleAddToWorkflow]); }, [activeSector, handleAddToWorkflow, sectors]);
// Build smart suggestions from sector stats // Build smart suggestions from sector stats
const smartSuggestions = useMemo(() => { const smartSuggestions = useMemo(() => {
@@ -738,6 +830,407 @@ export default function IndustriesSectorsKeywords() {
return buildSmartSuggestions(sectorStats, { showOnlyWithResults: true }); return buildSmartSuggestions(sectorStats, { showOnlyWithResults: true });
}, [sectorStats]); }, [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 // Show WorkflowGuide if no sites
if (sites.length === 0) { if (sites.length === 0) {
return ( 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 ( return (
<> <>
<PageMeta title="Keywords Library" description="Browse curated keywords and add them to your workflow. Ahrefs research coming soon." /> <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' }} 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 */} {/* Sector Metric Cards - Show when site is selected */}
{activeSite && ( {activeSite && (
<div className="mx-6 mt-6"> <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 <SectorMetricGrid
stats={sectorStats} stats={sectorStats}
activeStatType={activeStatFilter} activeStatType={activeStatFilter}
onStatClick={handleStatClick} onStatClick={handleStatClick}
onBulkAdd={activeSector ? handleMetricBulkAdd : undefined}
isAddedAction={isStatActionAdded}
clickable={true}
sectorName={activeSector?.name} sectorName={activeSector?.name}
isLoading={loadingSectorStats} isLoading={loadingSectorStats}
/> />
</div> </div>
)} )}
{/* Smart Suggestions Panel */} {/* Smart Suggestions Panel - Always show when site is selected */}
{activeSite && sectorStats && smartSuggestions.length > 0 && ( {activeSite && sectorStats && (
<div className="mx-6 mt-6"> <div className="mx-6 mt-6">
<SmartSuggestions <SmartSuggestions
suggestions={smartSuggestions} suggestions={smartSuggestions}
onSuggestionClick={(suggestion) => { onSuggestionClick={handleSuggestionClick}
// Apply the filter from the suggestion onBulkAdd={activeSector ? handleSuggestionBulkAdd : undefined}
if (suggestion.filterParams.statType) { isAddedAction={isSuggestionActionAdded}
handleStatClick(suggestion.filterParams.statType as StatType); enableFilterClick={true}
}
}}
isLoading={loadingSectorStats} isLoading={loadingSectorStats}
/> />
</div> </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) */} {/* Keywords Table - Shown by default (per plan) */}
{activeSite && ( {activeSite && (
<TablePageTemplate <TablePageTemplate
@@ -883,9 +1306,14 @@ export default function IndustriesSectorsKeywords() {
data={seedKeywords} data={seedKeywords}
loading={!showContent} loading={!showContent}
showContent={showContent} showContent={showContent}
defaultShowFilters={true}
centerFilters={true}
filters={pageConfig.filters} filters={pageConfig.filters}
filterValues={{ filterValues={{
sector: activeSector?.id ? String(activeSector.id) : '',
search: searchTerm, search: searchTerm,
volume_min: volumeMin,
volume_max: volumeMax,
country: countryFilter, country: countryFilter,
difficulty: difficultyFilter, difficulty: difficultyFilter,
showNotAddedOnly: showNotAddedOnly ? 'true' : '', showNotAddedOnly: showNotAddedOnly ? 'true' : '',
@@ -921,19 +1349,49 @@ export default function IndustriesSectorsKeywords() {
onFilterChange={(key, value) => { onFilterChange={(key, value) => {
const stringValue = value === null || value === undefined ? '' : String(value); const stringValue = value === null || value === undefined ? '' : String(value);
if (activeStatFilter) {
setActiveStatFilter(null);
}
if (key === 'search') { if (key === 'search') {
setSearchTerm(stringValue); 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') { } else if (key === 'country') {
setCountryFilter(stringValue); setCountryFilter(stringValue);
setCurrentPage(1); setCurrentPage(1);
setSelectedIds([]);
} else if (key === 'difficulty') { } else if (key === 'difficulty') {
setDifficultyFilter(stringValue); setDifficultyFilter(stringValue);
setCurrentPage(1); setCurrentPage(1);
setSelectedIds([]);
} else if (key === 'showNotAddedOnly') { } else if (key === 'showNotAddedOnly') {
setShowNotAddedOnly(stringValue === 'true'); setShowNotAddedOnly(stringValue === 'true');
setCurrentPage(1); 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[]) => { onBulkAction={async (actionKey: string, ids: string[]) => {
if (actionKey === 'add_selected_to_workflow') { if (actionKey === 'add_selected_to_workflow') {
await handleBulkAddSelected(ids); await handleBulkAddSelected(ids);
@@ -1045,6 +1503,22 @@ export default function IndustriesSectorsKeywords() {
</div> </div>
</div> </div>
</Modal> </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}
/>
</> </>
); );
} }

View File

@@ -2281,6 +2281,8 @@ export async function fetchKeywordsLibrary(filters?: {
ordering?: string; ordering?: string;
difficulty_min?: number; difficulty_min?: number;
difficulty_max?: number; difficulty_max?: number;
volume_min?: number;
volume_max?: number;
}): Promise<SeedKeywordResponse> { }): Promise<SeedKeywordResponse> {
const params = new URLSearchParams(); const params = new URLSearchParams();
// Use industry_id and sector_id as per backend get_queryset, but also try industry/sector for filterset_fields // 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 // Difficulty range filtering
if (filters?.difficulty_min !== undefined) params.append('difficulty_min', filters.difficulty_min.toString()); 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?.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(); const queryString = params.toString();
return fetchAPI(`/v1/auth/keywords-library/${queryString ? `?${queryString}` : ''}`); return fetchAPI(`/v1/auth/keywords-library/${queryString ? `?${queryString}` : ''}`);

View File

@@ -184,6 +184,81 @@
SECTION 3: TAILWIND CONFIGURATION 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 🚫 TAILWIND DEFAULT COLORS ARE DISABLED

View File

@@ -183,6 +183,9 @@ interface TablePageTemplateProps {
getRowClassName?: (row: any) => string; getRowClassName?: (row: any) => string;
// Custom checkbox column width (default: w-12 = 48px) // Custom checkbox column width (default: w-12 = 48px)
checkboxColumnWidth?: string; checkboxColumnWidth?: string;
// Filter bar behavior
defaultShowFilters?: boolean;
centerFilters?: boolean;
} }
export default function TablePageTemplate({ export default function TablePageTemplate({
@@ -222,6 +225,8 @@ export default function TablePageTemplate({
primaryAction, primaryAction,
getRowClassName, getRowClassName,
checkboxColumnWidth = '48px', checkboxColumnWidth = '48px',
defaultShowFilters = false,
centerFilters = false,
}: TablePageTemplateProps) { }: TablePageTemplateProps) {
const location = useLocation(); const location = useLocation();
const [isBulkActionsDropdownOpen, setIsBulkActionsDropdownOpen] = useState(false); const [isBulkActionsDropdownOpen, setIsBulkActionsDropdownOpen] = useState(false);
@@ -230,7 +235,7 @@ export default function TablePageTemplate({
const bulkActionsButtonRef = React.useRef<HTMLButtonElement>(null); const bulkActionsButtonRef = React.useRef<HTMLButtonElement>(null);
// Filter toggle state - hidden by default // Filter toggle state - hidden by default
const [showFilters, setShowFilters] = useState(false); const [showFilters, setShowFilters] = useState(defaultShowFilters);
// Get notification config for current page // Get notification config for current page
const deleteModalConfig = getDeleteModalConfig(location.pathname); const deleteModalConfig = getDeleteModalConfig(location.pathname);
@@ -765,8 +770,8 @@ export default function TablePageTemplate({
{/* Filters Row - Below action buttons, left aligned with shadow */} {/* Filters Row - Below action buttons, left aligned with shadow */}
{showFilters && (renderFilters || filters.length > 0) && ( {showFilters && (renderFilters || filters.length > 0) && (
<div className="flex justify-start py-1.5 mb-2.5"> <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"> <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"> <div className="flex gap-2 items-center flex-wrap">
{renderFilters ? ( {renderFilters ? (
renderFilters renderFilters