keywrods library fixes

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

View File

@@ -62,6 +62,8 @@ export default function SectorCardsGrid({
const total = stats?.total.count ?? 0;
const available = stats?.available.count ?? 0;
const inWorkflow = Math.max(total - available, 0);
const over10k = stats?.high_volume.count ?? 0;
const midVolume = stats?.mid_volume?.count ?? 0;
const isActive = activeSectorId === sector.id;
return (
@@ -74,7 +76,7 @@ export default function SectorCardsGrid({
)}
>
<div className="sector-card-accent" />
<div className="flex items-center justify-between gap-3">
<div className="flex items-start justify-between gap-3">
<div>
<div className="flex items-center gap-2">
<h4 className="text-base font-semibold text-gray-900 dark:text-white">
@@ -87,33 +89,47 @@ export default function SectorCardsGrid({
) : (
<span className="text-xs text-gray-500 dark:text-gray-400">Not linked to template</span>
)}
{isActive && (
<div className="mt-2">
<Badge color="info" size="sm" variant="light">
Active
</Badge>
</div>
)}
</div>
{isActive && (
<Badge color="info" size="sm" variant="light">
Active
</Badge>
)}
</div>
<div className="mt-4 grid grid-cols-3 gap-2 text-center">
<div className="sector-stats-pill">
<div className="label">Total</div>
<div className="value text-base">
<div className="text-right">
<div className="text-xs text-gray-500 dark:text-gray-400">Total</div>
<div className="text-2xl font-semibold text-gray-900 dark:text-white">
{total.toLocaleString()}
</div>
</div>
<div className="sector-stats-pill">
<div className="label">Available</div>
<div className="value text-base">
</div>
<div className="mt-4 grid grid-cols-2 gap-3">
<div>
<div className="text-xs font-semibold text-success-600 dark:text-success-400">Available</div>
<div className="text-sm font-medium text-gray-900 dark:text-white">
{available.toLocaleString()}
</div>
</div>
<div className="sector-stats-pill">
<div className="label">In Workflow</div>
<div className="value text-base">
<div>
<div className="text-xs font-semibold text-brand-600 dark:text-brand-400">In Workflow</div>
<div className="text-sm font-medium text-gray-900 dark:text-white">
{inWorkflow.toLocaleString()}
</div>
</div>
<div>
<div className="text-xs font-semibold text-warning-600 dark:text-warning-400">&gt; 10K</div>
<div className="text-sm font-medium text-gray-900 dark:text-white">
{over10k.toLocaleString()}
</div>
</div>
<div>
<div className="text-xs font-semibold text-purple-600 dark:text-purple-400">5K - 10K</div>
<div className="text-sm font-medium text-gray-900 dark:text-white">
{midVolume.toLocaleString()}
</div>
</div>
</div>
</Card>
);

View File

@@ -43,6 +43,9 @@ const STAT_CONFIG: Record<StatType, {
description: string;
icon: ReactNode;
accentColor: string;
borderColor: string;
ringColor: string;
dotColor: string;
textColor: string;
badgeColor: string;
showThreshold?: boolean;
@@ -53,6 +56,9 @@ const STAT_CONFIG: Record<StatType, {
description: 'All keywords in sector',
icon: <PieChartIcon className="w-5 h-5" />,
accentColor: 'bg-gray-500',
borderColor: 'border-gray-400',
ringColor: 'ring-gray-400/20',
dotColor: 'bg-gray-500',
textColor: 'text-gray-600 dark:text-gray-400',
badgeColor: 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300',
},
@@ -61,6 +67,9 @@ const STAT_CONFIG: Record<StatType, {
description: 'Not yet added to your site',
icon: <CheckCircleIcon className="w-5 h-5" />,
accentColor: 'bg-success-500',
borderColor: 'border-success-500',
ringColor: 'ring-success-500/20',
dotColor: 'bg-success-500',
textColor: 'text-success-600 dark:text-success-400',
badgeColor: 'bg-success-100 text-success-700 dark:bg-success-900/40 dark:text-success-300',
},
@@ -69,6 +78,9 @@ const STAT_CONFIG: Record<StatType, {
description: 'Volume ≥ 10K searches/mo',
icon: <ShootingStarIcon className="w-5 h-5" />,
accentColor: 'bg-brand-500',
borderColor: 'border-brand-500',
ringColor: 'ring-brand-500/20',
dotColor: 'bg-brand-500',
textColor: 'text-brand-600 dark:text-brand-400',
badgeColor: 'bg-brand-100 text-brand-700 dark:bg-brand-900/40 dark:text-brand-300',
},
@@ -77,6 +89,9 @@ const STAT_CONFIG: Record<StatType, {
description: 'High volume keywords',
icon: <BoxIcon className="w-5 h-5" />,
accentColor: 'bg-warning-500',
borderColor: 'border-warning-500',
ringColor: 'ring-warning-500/20',
dotColor: 'bg-warning-500',
textColor: 'text-warning-600 dark:text-warning-400',
badgeColor: 'bg-warning-100 text-warning-700 dark:bg-warning-900/40 dark:text-warning-300',
showThreshold: true,
@@ -87,6 +102,9 @@ const STAT_CONFIG: Record<StatType, {
description: '4+ words with good volume',
icon: <DocsIcon className="w-5 h-5" />,
accentColor: 'bg-purple-500',
borderColor: 'border-purple-500',
ringColor: 'ring-purple-500/20',
dotColor: 'bg-purple-500',
textColor: 'text-purple-600 dark:text-purple-400',
badgeColor: 'bg-purple-100 text-purple-700 dark:bg-purple-900/40 dark:text-purple-300',
showThreshold: true,
@@ -97,6 +115,9 @@ const STAT_CONFIG: Record<StatType, {
description: 'Low difficulty, good volume',
icon: <BoltIcon className="w-5 h-5" />,
accentColor: 'bg-emerald-500',
borderColor: 'border-emerald-500',
ringColor: 'ring-emerald-500/20',
dotColor: 'bg-emerald-500',
textColor: 'text-emerald-600 dark:text-emerald-400',
badgeColor: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300',
showThreshold: true,
@@ -189,7 +210,7 @@ export default function SectorMetricCard({
: 'cursor-default',
compact ? 'p-3' : 'p-4',
isActive
? 'border-brand-500 shadow-sm ring-4 ring-brand-500/15'
? clsx('border-2 shadow-sm ring-4', config.borderColor, config.ringColor)
: 'border-gray-200 dark:border-gray-800',
)}
>
@@ -208,7 +229,7 @@ export default function SectorMetricCard({
</div>
<div className="flex items-center gap-2">
{isActive && (
<div className="w-2 h-2 rounded-full bg-brand-500 animate-pulse" />
<div className={clsx('w-2 h-2 rounded-full animate-pulse', config.dotColor)} />
)}
<div className={clsx('font-bold tabular-nums', compact ? 'text-2xl' : 'text-3xl', config.textColor)}>
{formatCount(statData.count)}
@@ -227,26 +248,28 @@ export default function SectorMetricCard({
<div className="flex flex-wrap items-center gap-2 text-xs">
<span className="inline-flex items-center gap-1 rounded-full bg-success-100 text-success-700 dark:bg-success-900/40 dark:text-success-300 px-2 py-0.5">
<PlusIcon className="w-3 h-3" />
Add to workflow
ADD
</span>
{bulkAddOptions.map((count) => {
const isAdded = isAddedAction ? isAddedAction(statType, count) : false;
return (
<Button
key={count}
size="xs"
variant={isAdded ? 'outline' : 'primary'}
tone={isAdded ? 'success' : 'brand'}
startIcon={
isAdded ? <CheckCircleIcon className="w-3 h-3" /> : <PlusIcon className="w-3 h-3" />
}
onClick={() => handleAddClick(count)}
disabled={isAdded}
>
{isAdded ? 'Added' : (count === statData.count ? 'All' : count)}
</Button>
);
})}
<div className="ml-auto flex flex-wrap items-center gap-2 justify-end">
{bulkAddOptions.map((count) => {
const isAdded = isAddedAction ? isAddedAction(statType, count) : false;
return (
<Button
key={count}
size="xs"
variant={isAdded ? 'outline' : 'primary'}
tone={isAdded ? 'success' : 'brand'}
startIcon={
isAdded ? <CheckCircleIcon className="w-3 h-3" /> : <PlusIcon className="w-3 h-3" />
}
onClick={() => handleAddClick(count)}
disabled={isAdded}
>
{isAdded ? 'Added' : (count === statData.count ? 'All' : count)}
</Button>
);
})}
</div>
</div>
</div>
)}
@@ -285,7 +308,7 @@ export function SectorMetricGrid({
return (
<div className={clsx(
'grid gap-4',
compact ? 'grid-cols-3 sm:grid-cols-6' : 'grid-cols-2 sm:grid-cols-3 lg:grid-cols-6',
compact ? 'grid-cols-2 sm:grid-cols-3 md:grid-cols-5' : 'grid-cols-1 sm:grid-cols-2 md:grid-cols-3 xl:grid-cols-5',
)}>
{statTypes.map((statType) => (
<div
@@ -308,7 +331,7 @@ export function SectorMetricGrid({
return (
<div className={clsx(
'grid gap-4',
compact ? 'grid-cols-3 sm:grid-cols-6' : 'grid-cols-2 sm:grid-cols-3 lg:grid-cols-6',
compact ? 'grid-cols-2 sm:grid-cols-3 md:grid-cols-5' : 'grid-cols-1 sm:grid-cols-2 md:grid-cols-3 xl:grid-cols-5',
)}>
{statTypes.map((statType) => (
<SectorMetricCard

View File

@@ -1,88 +1,27 @@
/**
* SmartSuggestions - Compact card-based suggestions for Keywords Library
* White background with accent colors like Dashboard cards
* Shows keyword categories with counts and bulk add options
* SmartSuggestions - Container for smart suggestions area
* Shows header and optional content, with breathing indicator when empty
*/
import { useState } from 'react';
import { ReactNode, useState } from 'react';
import clsx from 'clsx';
import { ShootingStarIcon, ChevronDownIcon, PlusIcon, BoltIcon, DocsIcon, BoxIcon, CheckCircleIcon } from '../../icons';
import Button from '../ui/button/Button';
interface SmartSuggestion {
id: string;
label: string;
description: string;
count: number;
filterParams: {
statType?: string;
difficulty_max?: number;
volume_min?: number;
word_count_min?: number;
available_only?: boolean;
};
color: 'blue' | 'green' | 'amber' | 'purple' | 'emerald';
}
import { ShootingStarIcon, ChevronDownIcon } from '../../icons';
interface SmartSuggestionsProps {
suggestions: SmartSuggestion[];
onSuggestionClick: (suggestion: SmartSuggestion) => void;
onBulkAdd?: (suggestion: SmartSuggestion, count: number) => void;
isAddedAction?: (suggestionId: string, count: number) => boolean;
enableFilterClick?: boolean;
children?: ReactNode;
showEmptyState?: boolean;
className?: string;
isLoading?: boolean;
}
// Icon mapping
const SUGGESTION_ICONS: Record<string, React.ReactNode> = {
quick_wins: <BoltIcon className="w-5 h-5" />,
long_tail: <DocsIcon className="w-5 h-5" />,
premium_traffic: <BoxIcon className="w-5 h-5" />,
available: <CheckCircleIcon className="w-5 h-5" />,
};
// Color mappings with white bg card style
const colorClasses = {
blue: {
accent: 'bg-brand-500',
text: 'text-brand-600 dark:text-brand-400',
iconBg: 'bg-brand-100 dark:bg-brand-900/40',
},
green: {
accent: 'bg-success-500',
text: 'text-success-600 dark:text-success-400',
iconBg: 'bg-success-100 dark:bg-success-900/40',
},
amber: {
accent: 'bg-warning-500',
text: 'text-warning-600 dark:text-warning-400',
iconBg: 'bg-warning-100 dark:bg-warning-900/40',
},
purple: {
accent: 'bg-purple-500',
text: 'text-purple-600 dark:text-purple-400',
iconBg: 'bg-purple-100 dark:bg-purple-900/40',
},
emerald: {
accent: 'bg-emerald-500',
text: 'text-emerald-600 dark:text-emerald-400',
iconBg: 'bg-emerald-100 dark:bg-emerald-900/40',
},
};
export default function SmartSuggestions({
suggestions,
onSuggestionClick,
onBulkAdd,
isAddedAction,
enableFilterClick = true,
children,
showEmptyState = false,
className,
isLoading = false,
}: SmartSuggestionsProps) {
const [isExpanded, setIsExpanded] = useState(true);
const totalAvailable = suggestions.reduce((sum, s) => sum + s.count, 0);
const hasKeywords = totalAvailable > 0;
const hasContent = Boolean(children);
if (isLoading) {
return (
@@ -98,8 +37,7 @@ export default function SmartSuggestions({
);
}
// Show breathing indicator when no suggestions (waiting for data)
if (suggestions.length === 0) {
if (showEmptyState && !hasContent) {
return (
<div className={clsx(
'rounded-xl border border-gray-200 dark:border-gray-800 bg-white dark:bg-white/[0.03] p-4',
@@ -113,7 +51,6 @@ export default function SmartSuggestions({
<ShootingStarIcon className="w-4 h-4 text-white" />
</div>
<div className="flex items-center gap-3">
{/* Breathing circle indicator */}
<div className="w-3 h-3 rounded-full bg-brand-500 animate-pulse" />
<span className="text-gray-600 dark:text-gray-400 text-sm">
Ready-to-use keywords waiting for you! Search for a keyword or apply any filter to see smart suggestions...
@@ -129,218 +66,40 @@ export default function SmartSuggestions({
'rounded-xl border border-gray-200 dark:border-gray-800 bg-white dark:bg-white/[0.03] overflow-hidden',
className
)}>
{/* Header */}
<button
onClick={() => setIsExpanded(!isExpanded)}
className="flex items-center justify-between w-full p-4 text-left hover:bg-gray-50 dark:hover:bg-gray-800/50 transition-colors"
>
<div className="flex items-center gap-3">
{/* Icon with breathing animation */}
<div className={clsx(
'w-8 h-8 rounded-lg flex items-center justify-center',
'bg-gradient-to-br from-brand-500 to-purple-500',
hasKeywords && 'animate-pulse'
hasContent && 'animate-pulse'
)}>
<ShootingStarIcon className="w-4 h-4 text-white" />
</div>
<div>
<h3 className="font-semibold text-gray-900 dark:text-white text-sm flex items-center gap-2">
<h3 className="font-semibold text-gray-900 dark:text-white text-base flex items-center gap-2">
Smart Suggestions
{hasKeywords && (
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300">
{totalAvailable.toLocaleString()} ready
</span>
)}
</h3>
<p className="text-xs text-gray-500 dark:text-gray-400">
Ready-to-use keywords waiting for you!
</p>
</div>
</div>
<ChevronDownIcon
<ChevronDownIcon
className={clsx(
'w-5 h-5 text-gray-400 transition-transform duration-200',
isExpanded && 'rotate-180'
)}
)}
/>
</button>
{/* Expandable content - Grid layout */}
{isExpanded && (
<div className="px-4 pb-4">
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
{suggestions.map((suggestion) => {
const colors = colorClasses[suggestion.color];
const icon = SUGGESTION_ICONS[suggestion.id] || <ShootingStarIcon className="w-4 h-4" />;
// Bulk add options
const bulkOptions: number[] = [];
if (suggestion.count >= 50) bulkOptions.push(50);
if (suggestion.count >= 100) bulkOptions.push(100);
if (suggestion.count >= 200) bulkOptions.push(200);
if (suggestion.count > 0 && suggestion.count <= 200) bulkOptions.push(suggestion.count);
return (
<div
key={suggestion.id}
className={clsx(
'relative rounded-lg border bg-white dark:bg-white/[0.02] overflow-hidden',
'transition-all duration-200 cursor-pointer',
enableFilterClick
? 'cursor-pointer hover:shadow-md hover:border-gray-300 dark:hover:border-gray-600'
: 'cursor-default',
'border-gray-200 dark:border-gray-800',
)}
>
{/* Accent border */}
<div className={clsx('absolute left-0 top-0 bottom-0 w-1', colors.accent)} />
{/* Main content - clickable to filter */}
<div
onClick={() => {
if (enableFilterClick) {
onSuggestionClick(suggestion);
}
}}
className="p-3 pl-4"
>
<div className="flex items-center justify-between gap-2 mb-1">
<div className="flex items-center gap-2">
<div className={clsx('p-2 rounded', colors.iconBg, colors.text)}>
{icon}
</div>
<span className={clsx('font-semibold text-base', colors.text)}>
{suggestion.label}
</span>
</div>
<span className="text-xl font-bold text-gray-900 dark:text-white tabular-nums">
{suggestion.count.toLocaleString()}
</span>
</div>
<p className="text-xs text-gray-500 dark:text-gray-400 line-clamp-1">
{suggestion.description}
</p>
</div>
{/* Bulk add buttons - always visible */}
{onBulkAdd && bulkOptions.length > 0 && (
<div className="px-3 pb-3 pt-0">
<div className="flex items-center gap-2 pt-2 border-t border-gray-100 dark:border-gray-800">
<span className="text-xs text-gray-500 dark:text-gray-400 flex items-center gap-1">
<PlusIcon className="w-3 h-3" />
Add
</span>
</div>
<div className="flex flex-wrap gap-2 pt-2">
{bulkOptions.map((count) => {
const isAdded = isAddedAction ? isAddedAction(suggestion.id, count) : false;
return (
<Button
key={count}
size="xs"
variant={isAdded ? 'outline' : 'primary'}
tone={isAdded ? 'success' : 'brand'}
startIcon={
isAdded ? <CheckCircleIcon className="w-3 h-3" /> : <PlusIcon className="w-3 h-3" />
}
onClick={() => onBulkAdd(suggestion, count)}
disabled={isAdded}
>
{isAdded ? 'Added' : (count === suggestion.count ? 'All' : count)}
</Button>
);
})}
</div>
</div>
)}
</div>
);
})}
</div>
{children}
</div>
)}
</div>
);
}
// Helper to build suggestions from sector stats
export function buildSmartSuggestions(
stats: {
available: { count: number };
quick_wins: { count: number; threshold?: number };
long_tail: { count: number; threshold?: number };
premium_traffic: { count: number; threshold?: number };
},
options?: {
showOnlyWithResults?: boolean;
}
): SmartSuggestion[] {
const suggestions: SmartSuggestion[] = [];
// Quick Wins - Low difficulty + good volume + available
if (stats.quick_wins.count > 0 || !options?.showOnlyWithResults) {
suggestions.push({
id: 'quick_wins',
label: 'Quick Wins',
description: `Easy to rank keywords with vol > ${(stats.quick_wins.threshold || 1000) / 1000}K`,
count: stats.quick_wins.count,
filterParams: {
statType: 'quick_wins',
difficulty_max: 20,
volume_min: stats.quick_wins.threshold || 1000,
available_only: true,
},
color: 'emerald',
});
}
// Long Tail - 4+ words with volume
if (stats.long_tail.count > 0 || !options?.showOnlyWithResults) {
suggestions.push({
id: 'long_tail',
label: 'Long Tail',
description: `4+ word phrases with vol > ${(stats.long_tail.threshold || 1000) / 1000}K`,
count: stats.long_tail.count,
filterParams: {
statType: 'long_tail',
word_count_min: 4,
volume_min: stats.long_tail.threshold || 1000,
},
color: 'purple',
});
}
// Premium Traffic - High volume
if (stats.premium_traffic.count > 0 || !options?.showOnlyWithResults) {
suggestions.push({
id: 'premium_traffic',
label: 'Premium Traffic',
description: `High volume keywords (${(stats.premium_traffic.threshold || 50000) / 1000}K+ searches)`,
count: stats.premium_traffic.count,
filterParams: {
statType: 'premium_traffic',
volume_min: stats.premium_traffic.threshold || 50000,
},
color: 'amber',
});
}
// Available Only
if (stats.available.count > 0 || !options?.showOnlyWithResults) {
suggestions.push({
id: 'available',
label: 'Available Keywords',
description: 'Keywords not yet added to your site',
count: stats.available.count,
filterParams: {
statType: 'available',
available_only: true,
},
color: 'green',
});
}
return suggestions;
}

View File

@@ -6,6 +6,6 @@
export { default as SectorMetricCard, SectorMetricGrid } from './SectorMetricCard';
export type { StatType, StatResult, SectorStats } from './SectorMetricCard';
export { default as SmartSuggestions, buildSmartSuggestions } from './SmartSuggestions';
export { default as SmartSuggestions } from './SmartSuggestions';
export { default as BulkAddConfirmation } from './BulkAddConfirmation';

View File

@@ -25,6 +25,8 @@ import {
fetchSectorStats,
SectorStats,
SectorStatsItem,
fetchKeywordsLibraryFilterOptions,
FilterOption,
} from '../../services/api';
import Badge from '../../components/ui/badge/Badge';
import { BoltIcon, ShootingStarIcon } from '../../icons';
@@ -41,7 +43,7 @@ import Label from '../../components/form/Label';
import Input from '../../components/form/input/InputField';
import { Card } from '../../components/ui/card';
import { SectorMetricGrid, StatType } from '../../components/keywords-library/SectorMetricCard';
import SmartSuggestions, { buildSmartSuggestions } from '../../components/keywords-library/SmartSuggestions';
import SmartSuggestions from '../../components/keywords-library/SmartSuggestions';
import SectorCardsGrid from '../../components/keywords-library/SectorCardsGrid';
import BulkAddConfirmation from '../../components/keywords-library/BulkAddConfirmation';
@@ -72,9 +74,7 @@ export default function IndustriesSectorsKeywords() {
const [bulkAddKeywordIds, setBulkAddKeywordIds] = useState<number[]>([]);
const [bulkAddStatLabel, setBulkAddStatLabel] = useState<string | undefined>();
const [pendingBulkAddKey, setPendingBulkAddKey] = useState<string | null>(null);
const [pendingBulkAddGroup, setPendingBulkAddGroup] = useState<'stat' | 'suggestion' | null>(null);
const [addedStatActions, setAddedStatActions] = useState<Set<string>>(new Set());
const [addedSuggestionActions, setAddedSuggestionActions] = useState<Set<string>>(new Set());
// Ahrefs banner state
const [showAhrefsBanner, setShowAhrefsBanner] = useState(true);
@@ -95,6 +95,10 @@ export default function IndustriesSectorsKeywords() {
const [showNotAddedOnly, setShowNotAddedOnly] = useState(false);
const [volumeMin, setVolumeMin] = useState('');
const [volumeMax, setVolumeMax] = useState('');
// Dynamic filter options (cascading)
const [countryOptions, setCountryOptions] = useState<FilterOption[] | undefined>(undefined);
const [difficultyOptions, setDifficultyOptions] = useState<FilterOption[] | undefined>(undefined);
// Keyword count tracking
const [addedCount, setAddedCount] = useState(0);
@@ -267,7 +271,6 @@ export default function IndustriesSectorsKeywords() {
setCurrentPage(1);
setSelectedIds([]);
setAddedStatActions(new Set());
setAddedSuggestionActions(new Set());
}, [activeSite?.id]);
// Reset pagination/selection when sector changes
@@ -275,8 +278,44 @@ export default function IndustriesSectorsKeywords() {
setActiveStatFilter(null);
setCurrentPage(1);
setSelectedIds([]);
setAddedStatActions(new Set());
}, [activeSector?.id]);
// Load cascading filter options based on current filters
const loadFilterOptions = useCallback(async () => {
if (!activeSite?.industry) return;
const difficultyLabel = difficultyFilter ? getDifficultyLabelFromNumber(parseInt(difficultyFilter, 10)) : null;
const difficultyRange = difficultyLabel ? getDifficultyRange(difficultyLabel) : null;
try {
const options = await fetchKeywordsLibraryFilterOptions({
industry_id: activeSite.industry,
sector_id: activeSector?.industry_sector || undefined,
country: countryFilter || undefined,
difficulty_min: difficultyRange?.min,
difficulty_max: difficultyRange?.max,
volume_min: volumeMin ? Number(volumeMin) : undefined,
volume_max: volumeMax ? Number(volumeMax) : undefined,
search: searchTerm || undefined,
});
setCountryOptions(options.countries || []);
setDifficultyOptions(
(options.difficulty?.levels || []).map((level) => ({
value: String(level.level),
label: `${level.level} - ${level.label}`,
}))
);
} catch (error) {
console.error('Failed to load filter options:', error);
}
}, [activeSite?.industry, activeSector?.industry_sector, countryFilter, difficultyFilter, volumeMin, volumeMax, searchTerm]);
useEffect(() => {
loadFilterOptions();
}, [loadFilterOptions]);
// Load counts on mount and when site/sector changes
useEffect(() => {
if (activeSite) {
@@ -297,31 +336,39 @@ export default function IndustriesSectorsKeywords() {
setShowContent(false);
try {
// Get already-attached keywords for marking (lightweight check)
// Get already-attached keywords for marking (paginate to ensure persistence)
const attachedSeedKeywordIds = new Set<number>();
try {
const sectors = await fetchSiteSectors(activeSite.id);
// Check keywords in all sectors (needed for isAdded flag)
for (const sector of sectors) {
try {
const keywordsData = await fetchKeywords({
site_id: activeSite.id,
sector_id: sector.id,
page_size: 1000,
});
(keywordsData.results || []).forEach((k: any) => {
const seedKeywordId = k.seed_keyword_id || (k.seed_keyword && k.seed_keyword.id);
if (seedKeywordId) {
attachedSeedKeywordIds.add(Number(seedKeywordId));
}
});
} catch (err) {
console.warn(`Could not fetch attached keywords for sector ${sector.id}:`, err);
const fetchAttachedSeedKeywordIds = async (siteId: number, sectorId?: number) => {
const pageSize = 500;
let page = 1;
while (true) {
const keywordsData = await fetchKeywords({
site_id: siteId,
sector_id: sectorId,
page,
page_size: pageSize,
});
(keywordsData.results || []).forEach((k: any) => {
const seedKeywordId = k.seed_keyword_id || (k.seed_keyword && k.seed_keyword.id);
if (seedKeywordId) {
attachedSeedKeywordIds.add(Number(seedKeywordId));
}
});
if (!keywordsData.next || (keywordsData.results || []).length < pageSize) {
break;
}
page += 1;
}
};
try {
if (activeSector?.id) {
await fetchAttachedSeedKeywordIds(activeSite.id, activeSector.id);
} else {
await fetchAttachedSeedKeywordIds(activeSite.id);
}
} catch (err) {
console.warn('Could not fetch sectors or attached keywords:', err);
console.warn('Could not fetch attached keywords:', err);
}
// Keep attached IDs available for bulk add actions
@@ -342,8 +389,24 @@ export default function IndustriesSectorsKeywords() {
if (searchTerm) filters.search = searchTerm;
if (countryFilter) filters.country = countryFilter;
if (volumeMin) filters.volume_min = parseInt(volumeMin);
if (volumeMax) filters.volume_max = parseInt(volumeMax);
if (volumeMin !== '') {
const parsed = Number(volumeMin);
if (!Number.isNaN(parsed)) {
filters.volume_min = parsed;
}
}
if (volumeMax !== '') {
const parsed = Number(volumeMax);
if (!Number.isNaN(parsed)) {
filters.volume_max = parsed;
}
}
const useServerAvailableFilter = Boolean(showNotAddedOnly && activeSite?.id);
if (useServerAvailableFilter) {
filters.site_id = activeSite.id;
filters.available_only = true;
}
// Apply difficulty filter (if API supports it, otherwise we'll filter client-side)
if (difficultyFilter) {
@@ -386,15 +449,19 @@ export default function IndustriesSectorsKeywords() {
setAddedCount(totalAdded);
setAvailableCount(actualAvailable);
// Apply "not yet added" filter client-side (if API doesn't support it)
// Apply "not yet added" filter client-side only when server filtering isn't used
let filteredResults = results;
if (showNotAddedOnly) {
if (showNotAddedOnly && !useServerAvailableFilter) {
filteredResults = results.filter(sk => !sk.isAdded);
}
const effectiveTotalCount = useServerAvailableFilter
? apiTotalCount
: (showNotAddedOnly && activeSite ? Math.max(apiTotalCount - attachedSeedKeywordIds.size, 0) : apiTotalCount);
setSeedKeywords(filteredResults);
setTotalCount(apiTotalCount);
setTotalPages(Math.ceil(apiTotalCount / pageSizeNum));
setTotalCount(effectiveTotalCount);
setTotalPages(Math.ceil(effectiveTotalCount / pageSizeNum));
setShowContent(true);
} catch (error: any) {
@@ -409,6 +476,42 @@ export default function IndustriesSectorsKeywords() {
}
}, [activeSite, activeSector, currentPage, pageSize, searchTerm, countryFilter, volumeMin, volumeMax, difficultyFilter, showNotAddedOnly, sortBy, sortDirection, toast]);
const getAddedStatStorageKey = useCallback(() => {
if (!activeSite?.id) return null;
const sectorKey = activeSector?.id ? `sector-${activeSector.id}` : 'sector-all';
return `keywordsLibrary:addedStatActions:${activeSite.id}:${sectorKey}`;
}, [activeSite?.id, activeSector?.id]);
useEffect(() => {
const storageKey = getAddedStatStorageKey();
if (!storageKey) {
setAddedStatActions(new Set());
return;
}
try {
const raw = localStorage.getItem(storageKey);
if (raw) {
const parsed = JSON.parse(raw) as string[];
setAddedStatActions(new Set(parsed));
} else {
setAddedStatActions(new Set());
}
} catch (error) {
console.warn('Failed to load added stat actions:', error);
setAddedStatActions(new Set());
}
}, [getAddedStatStorageKey]);
useEffect(() => {
const storageKey = getAddedStatStorageKey();
if (!storageKey) return;
try {
localStorage.setItem(storageKey, JSON.stringify(Array.from(addedStatActions)));
} catch (error) {
console.warn('Failed to persist added stat actions:', error);
}
}, [addedStatActions, getAddedStatStorageKey]);
// Load data when site/sector/filters change (show table by default per plan)
useEffect(() => {
if (activeSite) {
@@ -621,6 +724,32 @@ export default function IndustriesSectorsKeywords() {
const pageConfig = useMemo(() => {
const showSectorColumn = !activeSector;
const defaultCountryOptions: FilterOption[] = [
{ value: 'US', label: 'United States' },
{ value: 'CA', label: 'Canada' },
{ value: 'GB', label: 'United Kingdom' },
{ value: 'AE', label: 'United Arab Emirates' },
{ value: 'AU', label: 'Australia' },
{ value: 'IN', label: 'India' },
{ value: 'PK', label: 'Pakistan' },
];
const countryFilterOptions = countryOptions && countryOptions.length > 0
? countryOptions
: defaultCountryOptions;
const defaultDifficultyOptions: FilterOption[] = [
{ value: '1', label: '1 - Very Easy' },
{ value: '2', label: '2 - Easy' },
{ value: '3', label: '3 - Medium' },
{ value: '4', label: '4 - Hard' },
{ value: '5', label: '5 - Very Hard' },
];
const difficultyFilterOptions = difficultyOptions && difficultyOptions.length > 0
? difficultyOptions
: defaultDifficultyOptions;
return {
columns: [
{
@@ -782,13 +911,7 @@ export default function IndustriesSectorsKeywords() {
type: 'select' as const,
options: [
{ value: '', label: 'All Countries' },
{ value: 'US', label: 'United States' },
{ value: 'CA', label: 'Canada' },
{ value: 'GB', label: 'United Kingdom' },
{ value: 'AE', label: 'United Arab Emirates' },
{ value: 'AU', label: 'Australia' },
{ value: 'IN', label: 'India' },
{ value: 'PK', label: 'Pakistan' },
...countryFilterOptions,
],
},
{
@@ -797,11 +920,7 @@ export default function IndustriesSectorsKeywords() {
type: 'select' as const,
options: [
{ value: '', label: 'All Difficulty' },
{ value: '1', label: '1 - Very Easy' },
{ value: '2', label: '2 - Easy' },
{ value: '3', label: '3 - Medium' },
{ value: '4', label: '4 - Hard' },
{ value: '5', label: '5 - Very Hard' },
...difficultyFilterOptions,
],
},
{
@@ -822,13 +941,7 @@ export default function IndustriesSectorsKeywords() {
},
],
};
}, [activeSector, handleAddToWorkflow, sectors]);
// Build smart suggestions from sector stats
const smartSuggestions = useMemo(() => {
if (!sectorStats) return [];
return buildSmartSuggestions(sectorStats, { showOnlyWithResults: true });
}, [sectorStats]);
}, [activeSector, handleAddToWorkflow, sectors, countryOptions, difficultyOptions, volumeMin, volumeMax]);
// Helper: word count for keyword string
const getWordCount = useCallback((keyword: string) => {
@@ -839,18 +952,10 @@ export default function IndustriesSectorsKeywords() {
return `${statType}:${count}`;
}, []);
const buildSuggestionActionKey = useCallback((suggestionId: string, count: number) => {
return `${suggestionId}:${count}`;
}, []);
const isStatActionAdded = useCallback((statType: StatType, count: number) => {
return addedStatActions.has(buildStatActionKey(statType, count));
}, [addedStatActions, buildStatActionKey]);
const isSuggestionActionAdded = useCallback((suggestionId: string, count: number) => {
return addedSuggestionActions.has(buildSuggestionActionKey(suggestionId, count));
}, [addedSuggestionActions, buildSuggestionActionKey]);
const fetchBulkKeywords = useCallback(async (options: {
ordering: string;
difficultyMax?: number;
@@ -900,7 +1005,6 @@ export default function IndustriesSectorsKeywords() {
label: string;
ids: number[];
actionKey: string;
group: 'stat' | 'suggestion';
}) => {
if (payload.ids.length === 0) {
toast.error('No matching keywords found for this selection');
@@ -910,7 +1014,6 @@ export default function IndustriesSectorsKeywords() {
setBulkAddKeywordIds(payload.ids);
setBulkAddStatLabel(payload.label);
setPendingBulkAddKey(payload.actionKey);
setPendingBulkAddGroup(payload.group);
setShowBulkAddModal(true);
}, [toast]);
@@ -985,48 +1088,6 @@ export default function IndustriesSectorsKeywords() {
}
}, [activeStatFilter, sectorStats]);
const handleSuggestionClick = useCallback((suggestion: any) => {
setActiveStatFilter(null);
setCurrentPage(1);
setSelectedIds([]);
const difficultyMax = suggestion.filterParams?.difficulty_max;
const volumeMinValue = suggestion.filterParams?.volume_min;
const availableOnly = Boolean(suggestion.filterParams?.available_only);
if (availableOnly) {
setShowNotAddedOnly(true);
} else {
setShowNotAddedOnly(false);
}
if (volumeMinValue) {
setVolumeMin(String(volumeMinValue));
} else {
setVolumeMin('');
}
setVolumeMax('');
if (difficultyMax !== undefined) {
if (difficultyMax <= 20) {
setDifficultyFilter('1');
} else if (difficultyMax <= 40) {
setDifficultyFilter('2');
} else {
setDifficultyFilter('');
}
} else {
setDifficultyFilter('');
}
if (suggestion.id === 'quick_wins') {
setSortBy('difficulty');
setSortDirection('asc');
} else {
setSortBy('volume');
setSortDirection('desc');
}
}, []);
// Handle sector card click
const handleSectorSelect = useCallback((sector: Sector | null) => {
@@ -1117,74 +1178,9 @@ export default function IndustriesSectorsKeywords() {
label: statLabelMap[statType],
ids,
actionKey,
group: 'stat',
});
}, [activeSite, activeSector, addedStatActions, buildStatActionKey, fetchBulkKeywords, prepareBulkAdd, sectorStats, toast]);
// Handle bulk add from suggestions
const handleSuggestionBulkAdd = useCallback(async (suggestion: any, count: number) => {
if (!activeSite || !activeSector?.industry_sector) {
toast.error('Please select a site and sector first');
return;
}
const actionKey = buildSuggestionActionKey(suggestion.id, count);
if (addedSuggestionActions.has(actionKey)) {
return;
}
const suggestionThresholds = {
quick_wins: sectorStats?.quick_wins.threshold ?? 1000,
long_tail: sectorStats?.long_tail.threshold ?? 1000,
premium_traffic: sectorStats?.premium_traffic.threshold ?? 50000,
};
let ids: number[] = [];
if (suggestion.id === 'quick_wins') {
ids = await fetchBulkKeywords({
ordering: 'difficulty',
difficultyMax: 20,
minVolume: suggestionThresholds.quick_wins,
availableOnly: true,
count,
});
} else if (suggestion.id === 'long_tail') {
ids = await fetchBulkKeywords({
ordering: '-volume',
minVolume: suggestionThresholds.long_tail,
longTail: true,
availableOnly: true,
count,
});
} else if (suggestion.id === 'premium_traffic') {
ids = await fetchBulkKeywords({
ordering: '-volume',
minVolume: suggestionThresholds.premium_traffic,
availableOnly: true,
count,
});
} else if (suggestion.id === 'available') {
ids = await fetchBulkKeywords({
ordering: '-volume',
availableOnly: true,
count,
});
} else {
ids = await fetchBulkKeywords({
ordering: '-volume',
availableOnly: true,
count,
});
}
await prepareBulkAdd({
label: suggestion.label,
ids,
actionKey,
group: 'suggestion',
});
}, [activeSite, activeSector, addedSuggestionActions, buildSuggestionActionKey, fetchBulkKeywords, prepareBulkAdd, sectorStats, toast]);
const handleConfirmBulkAdd = useCallback(async () => {
if (!activeSite || !activeSector) {
throw new Error('Please select a site and sector first');
@@ -1206,16 +1202,10 @@ export default function IndustriesSectorsKeywords() {
}
if (pendingBulkAddKey) {
if (pendingBulkAddGroup === 'stat') {
setAddedStatActions((prev) => new Set([...prev, pendingBulkAddKey]));
}
if (pendingBulkAddGroup === 'suggestion') {
setAddedSuggestionActions((prev) => new Set([...prev, pendingBulkAddKey]));
}
setAddedStatActions((prev) => new Set([...prev, pendingBulkAddKey]));
}
setPendingBulkAddKey(null);
setPendingBulkAddGroup(null);
setShowBulkAddModal(false);
if (activeSite) {
@@ -1229,7 +1219,7 @@ export default function IndustriesSectorsKeywords() {
skipped: result.skipped || 0,
total_requested: bulkAddKeywordIds.length,
};
}, [activeSite, activeSector, bulkAddKeywordIds, loadKeywordCounts, loadSectorStats, loadSeedKeywords, pendingBulkAddGroup, pendingBulkAddKey]);
}, [activeSite, activeSector, bulkAddKeywordIds, loadKeywordCounts, loadSectorStats, loadSeedKeywords, pendingBulkAddKey]);
// Show WorkflowGuide if no sites
if (sites.length === 0) {
@@ -1269,33 +1259,21 @@ export default function IndustriesSectorsKeywords() {
</div>
)}
{/* Sector Metric Cards - Show when site is selected */}
{activeSite && (
<div className="mx-6 mt-6">
<SectorMetricGrid
stats={sectorStats}
activeStatType={activeStatFilter}
onStatClick={handleStatClick}
onBulkAdd={activeSector ? handleMetricBulkAdd : undefined}
isAddedAction={isStatActionAdded}
clickable={true}
sectorName={activeSector?.name}
isLoading={loadingSectorStats}
/>
</div>
)}
{/* Smart Suggestions Panel - Always show when site is selected */}
{activeSite && sectorStats && (
<div className="mx-6 mt-6">
<SmartSuggestions
suggestions={smartSuggestions}
onSuggestionClick={handleSuggestionClick}
onBulkAdd={activeSector ? handleSuggestionBulkAdd : undefined}
isAddedAction={isSuggestionActionAdded}
enableFilterClick={true}
isLoading={loadingSectorStats}
/>
<SmartSuggestions isLoading={loadingSectorStats}>
<SectorMetricGrid
stats={sectorStats}
activeStatType={activeStatFilter}
onStatClick={handleStatClick}
onBulkAdd={activeSector ? handleMetricBulkAdd : undefined}
isAddedAction={isStatActionAdded}
clickable={true}
sectorName={activeSector?.name}
isLoading={false}
/>
</SmartSuggestions>
</div>
)}
@@ -1512,7 +1490,6 @@ export default function IndustriesSectorsKeywords() {
setBulkAddKeywordIds([]);
setBulkAddStatLabel(undefined);
setPendingBulkAddKey(null);
setPendingBulkAddGroup(null);
}}
onConfirm={handleConfirmBulkAdd}
keywordCount={bulkAddKeywordIds.length}

View File

@@ -2354,6 +2354,7 @@ export interface SectorStats {
total: SectorStatResult;
available: SectorStatResult;
high_volume: SectorStatResult;
mid_volume?: SectorStatResult;
premium_traffic: SectorStatResult;
long_tail: SectorStatResult;
quick_wins: SectorStatResult;
@@ -2411,16 +2412,37 @@ export interface DifficultyLevel {
export interface FilterOptionsResponse {
industries: FilterIndustryOption[];
sectors: FilterSectorOption[];
countries?: FilterOption[];
difficulty: {
range: { min_difficulty: number; max_difficulty: number };
levels: DifficultyLevel[];
levels: Array<DifficultyLevel & { keyword_count?: number }>;
};
volume: { min_volume: number; max_volume: number };
}
export async function fetchKeywordsLibraryFilterOptions(industryId?: number): Promise<FilterOptionsResponse> {
export interface KeywordsLibraryFilterOptionsRequest {
industry_id?: number;
sector_id?: number;
country?: string;
difficulty_min?: number;
difficulty_max?: number;
volume_min?: number;
volume_max?: number;
search?: string;
}
export async function fetchKeywordsLibraryFilterOptions(
filters?: KeywordsLibraryFilterOptionsRequest
): Promise<FilterOptionsResponse> {
const params = new URLSearchParams();
if (industryId) params.append('industry_id', industryId.toString());
if (filters?.industry_id) params.append('industry_id', filters.industry_id.toString());
if (filters?.sector_id) params.append('sector_id', filters.sector_id.toString());
if (filters?.country) params.append('country', filters.country);
if (filters?.difficulty_min !== undefined) params.append('difficulty_min', filters.difficulty_min.toString());
if (filters?.difficulty_max !== undefined) params.append('difficulty_max', filters.difficulty_max.toString());
if (filters?.volume_min !== undefined) params.append('volume_min', filters.volume_min.toString());
if (filters?.volume_max !== undefined) params.append('volume_max', filters.volume_max.toString());
if (filters?.search) params.append('search', filters.search);
const queryString = params.toString();
return fetchAPI(`/v1/auth/keywords-library/filter_options/${queryString ? `?${queryString}` : ''}`);

View File

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