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')
difficulty_min = self.request.query_params.get('difficulty_min')
difficulty_max = self.request.query_params.get('difficulty_max')
volume_min = self.request.query_params.get('volume_min')
volume_max = self.request.query_params.get('volume_max')
if industry_id:
queryset = queryset.filter(industry_id=industry_id)
@@ -888,6 +890,18 @@ class SeedKeywordViewSet(viewsets.ReadOnlyModelViewSet):
queryset = queryset.filter(difficulty__lte=int(difficulty_max))
except (ValueError, TypeError):
pass
# Volume range filtering
if volume_min is not None:
try:
queryset = queryset.filter(volume__gte=int(volume_min))
except (ValueError, TypeError):
pass
if volume_max is not None:
try:
queryset = queryset.filter(volume__lte=int(volume_max))
except (ValueError, TypeError):
pass
return queryset
@@ -1106,9 +1120,9 @@ class SeedKeywordViewSet(viewsets.ReadOnlyModelViewSet):
# Get already-added keyword IDs if site_id provided
already_added_ids = set()
if site_id:
from igny8_core.business.models import SiteKeyword
from igny8_core.business.planning.models import Keywords
already_added_ids = set(
SiteKeyword.objects.filter(
Keywords.objects.filter(
site_id=site_id,
seed_keyword__isnull=False
).values_list('seed_keyword_id', flat=True)
@@ -1230,7 +1244,7 @@ class SeedKeywordViewSet(viewsets.ReadOnlyModelViewSet):
Get cascading filter options for Keywords Library.
Returns industries, sectors (filtered by industry), and available filter values.
"""
from django.db.models import Count, Min, Max
from django.db.models import Count, Min, Max, Q
try:
industry_id = request.query_params.get('industry_id')
@@ -1310,7 +1324,7 @@ class SeedKeywordViewSet(viewsets.ReadOnlyModelViewSet):
Accepts a list of seed_keyword IDs and adds them to the specified site.
"""
from django.db import transaction
from igny8_core.business.models import SiteKeyword
from igny8_core.business.planning.models import Keywords
try:
site_id = request.data.get('site_id')
@@ -1331,7 +1345,7 @@ class SeedKeywordViewSet(viewsets.ReadOnlyModelViewSet):
)
# Verify site access
from igny8_core.business.models import Site
from igny8_core.auth.models import Site
site = Site.objects.filter(id=site_id).first()
if not site:
return error_response(
@@ -1365,12 +1379,19 @@ class SeedKeywordViewSet(viewsets.ReadOnlyModelViewSet):
# Get already existing
existing_seed_ids = set(
SiteKeyword.objects.filter(
Keywords.objects.filter(
site_id=site_id,
seed_keyword_id__in=keyword_ids
).values_list('seed_keyword_id', flat=True)
)
# Get site sectors mapped by industry_sector_id for fast lookup
from igny8_core.auth.models import Sector
site_sectors = {
s.industry_sector_id: s
for s in Sector.objects.filter(site=site, is_deleted=False, is_active=True)
}
added_count = 0
skipped_count = 0
@@ -1380,14 +1401,17 @@ class SeedKeywordViewSet(viewsets.ReadOnlyModelViewSet):
skipped_count += 1
continue
SiteKeyword.objects.create(
# Find the site's sector that matches this keyword's industry_sector
site_sector = site_sectors.get(seed_kw.sector_id)
if not site_sector:
# Skip if site doesn't have this sector
skipped_count += 1
continue
Keywords.objects.create(
site=site,
keyword=seed_kw.keyword,
sector=site_sector,
seed_keyword=seed_kw,
volume=seed_kw.volume,
difficulty=seed_kw.difficulty,
source='library',
is_active=True
)
added_count += 1

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

View File

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

View File

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

View File

@@ -24,6 +24,7 @@ import {
fetchKeywords,
fetchSectorStats,
SectorStats,
SectorStatsItem,
} from '../../services/api';
import Badge from '../../components/ui/badge/Badge';
import { BoltIcon, ShootingStarIcon } from '../../icons';
@@ -32,19 +33,23 @@ import { usePageSizeStore } from '../../store/pageSizeStore';
import { getDifficultyNumber, getDifficultyRange, getDifficultyLabelFromNumber } from '../../utils/difficulty';
import { useSiteStore } from '../../store/siteStore';
import { useSectorStore } from '../../store/sectorStore';
import type { Sector } from '../../store/sectorStore';
import Button from '../../components/ui/button/Button';
import { Modal } from '../../components/ui/modal';
import FileInput from '../../components/form/input/FileInput';
import Label from '../../components/form/Label';
import Input from '../../components/form/input/InputField';
import { Card } from '../../components/ui/card';
import { SectorMetricGrid, StatType } from '../../components/keywords-library/SectorMetricCard';
import SmartSuggestions, { buildSmartSuggestions } from '../../components/keywords-library/SmartSuggestions';
import SectorCardsGrid from '../../components/keywords-library/SectorCardsGrid';
import BulkAddConfirmation from '../../components/keywords-library/BulkAddConfirmation';
export default function IndustriesSectorsKeywords() {
const toast = useToast();
const { startLoading, stopLoading } = usePageLoading();
const { activeSite } = useSiteStore();
const { activeSector, loadSectorsForSite } = useSectorStore();
const { activeSector, loadSectorsForSite, sectors, setActiveSector } = useSectorStore();
const { pageSize } = usePageSizeStore();
// Data state
@@ -54,9 +59,11 @@ export default function IndustriesSectorsKeywords() {
const [selectedIds, setSelectedIds] = useState<string[]>([]);
// Track recently added keywords to preserve their state during reload
const recentlyAddedRef = useRef<Set<number>>(new Set());
const attachedSeedKeywordIdsRef = useRef<Set<number>>(new Set());
// Sector Stats state (for metric cards)
const [sectorStats, setSectorStats] = useState<SectorStats | null>(null);
const [sectorStatsList, setSectorStatsList] = useState<SectorStatsItem[]>([]);
const [loadingSectorStats, setLoadingSectorStats] = useState(false);
const [activeStatFilter, setActiveStatFilter] = useState<StatType | null>(null);
@@ -64,6 +71,10 @@ export default function IndustriesSectorsKeywords() {
const [showBulkAddModal, setShowBulkAddModal] = useState(false);
const [bulkAddKeywordIds, setBulkAddKeywordIds] = useState<number[]>([]);
const [bulkAddStatLabel, setBulkAddStatLabel] = useState<string | undefined>();
const [pendingBulkAddKey, setPendingBulkAddKey] = useState<string | null>(null);
const [pendingBulkAddGroup, setPendingBulkAddGroup] = useState<'stat' | 'suggestion' | null>(null);
const [addedStatActions, setAddedStatActions] = useState<Set<string>>(new Set());
const [addedSuggestionActions, setAddedSuggestionActions] = useState<Set<string>>(new Set());
// Ahrefs banner state
const [showAhrefsBanner, setShowAhrefsBanner] = useState(true);
@@ -82,6 +93,8 @@ export default function IndustriesSectorsKeywords() {
const [countryFilter, setCountryFilter] = useState('');
const [difficultyFilter, setDifficultyFilter] = useState('');
const [showNotAddedOnly, setShowNotAddedOnly] = useState(false);
const [volumeMin, setVolumeMin] = useState('');
const [volumeMax, setVolumeMax] = useState('');
// Keyword count tracking
const [addedCount, setAddedCount] = useState(0);
@@ -176,6 +189,7 @@ export default function IndustriesSectorsKeywords() {
const loadSectorStats = useCallback(async () => {
if (!activeSite || !activeSite.industry) {
setSectorStats(null);
setSectorStatsList([]);
return;
}
@@ -183,15 +197,21 @@ export default function IndustriesSectorsKeywords() {
try {
const response = await fetchSectorStats({
industry_id: activeSite.industry,
sector_id: activeSector?.industry_sector ?? undefined,
site_id: activeSite.id,
});
// If sector-specific stats returned
if (response.stats) {
setSectorStats(response.stats as SectorStats);
} else if (response.sectors && response.sectors.length > 0) {
// Aggregate stats from all sectors
const allSectors = response.sectors || [];
setSectorStatsList(allSectors);
if (activeSector?.industry_sector) {
const matching = allSectors.find((sector) => sector.sector_id === activeSector.industry_sector);
if (matching) {
setSectorStats(matching.stats as SectorStats);
return;
}
}
if (allSectors.length > 0) {
const aggregated: SectorStats = {
total: { count: 0 },
available: { count: 0 },
@@ -200,15 +220,14 @@ export default function IndustriesSectorsKeywords() {
long_tail: { count: 0, threshold: 1000 },
quick_wins: { count: 0, threshold: 1000 },
};
response.sectors.forEach((sector) => {
allSectors.forEach((sector) => {
aggregated.total.count += sector.stats.total.count;
aggregated.available.count += sector.stats.available.count;
aggregated.high_volume.count += sector.stats.high_volume.count;
aggregated.premium_traffic.count += sector.stats.premium_traffic.count;
aggregated.long_tail.count += sector.stats.long_tail.count;
aggregated.quick_wins.count += sector.stats.quick_wins.count;
// Use first sector's thresholds (they should be consistent)
if (!aggregated.premium_traffic.threshold) {
aggregated.premium_traffic.threshold = sector.stats.premium_traffic.threshold;
}
@@ -219,8 +238,10 @@ export default function IndustriesSectorsKeywords() {
aggregated.quick_wins.threshold = sector.stats.quick_wins.threshold;
}
});
setSectorStats(aggregated);
} else {
setSectorStats(null);
}
} catch (error) {
console.error('Failed to load sector stats:', error);
@@ -236,6 +257,26 @@ export default function IndustriesSectorsKeywords() {
}
}, [activeSite, activeSector, loadSectorStats]);
// Reset filters and state when site changes
useEffect(() => {
setActiveStatFilter(null);
setSearchTerm('');
setCountryFilter('');
setDifficultyFilter('');
setShowNotAddedOnly(false);
setCurrentPage(1);
setSelectedIds([]);
setAddedStatActions(new Set());
setAddedSuggestionActions(new Set());
}, [activeSite?.id]);
// Reset pagination/selection when sector changes
useEffect(() => {
setActiveStatFilter(null);
setCurrentPage(1);
setSelectedIds([]);
}, [activeSector?.id]);
// Load counts on mount and when site/sector changes
useEffect(() => {
if (activeSite) {
@@ -283,6 +324,9 @@ export default function IndustriesSectorsKeywords() {
console.warn('Could not fetch sectors or attached keywords:', err);
}
// Keep attached IDs available for bulk add actions
attachedSeedKeywordIdsRef.current = attachedSeedKeywordIds;
// Build API filters - use server-side pagination
const pageSizeNum = pageSize || 25;
const filters: any = {
@@ -298,6 +342,8 @@ export default function IndustriesSectorsKeywords() {
if (searchTerm) filters.search = searchTerm;
if (countryFilter) filters.country = countryFilter;
if (volumeMin) filters.volume_min = parseInt(volumeMin);
if (volumeMax) filters.volume_max = parseInt(volumeMax);
// Apply difficulty filter (if API supports it, otherwise we'll filter client-side)
if (difficultyFilter) {
@@ -361,7 +407,7 @@ export default function IndustriesSectorsKeywords() {
setAvailableCount(0);
setShowContent(true);
}
}, [activeSite, activeSector, currentPage, pageSize, searchTerm, countryFilter, difficultyFilter, showNotAddedOnly, sortBy, sortDirection, toast]);
}, [activeSite, activeSector, currentPage, pageSize, searchTerm, countryFilter, volumeMin, volumeMax, difficultyFilter, showNotAddedOnly, sortBy, sortDirection, toast]);
// Load data when site/sector/filters change (show table by default per plan)
useEffect(() => {
@@ -381,6 +427,7 @@ export default function IndustriesSectorsKeywords() {
// Reset to page 1 on pageSize change
useEffect(() => {
setCurrentPage(1);
setSelectedIds([]);
}, [pageSize]);
// Handle sorting
@@ -388,6 +435,7 @@ export default function IndustriesSectorsKeywords() {
setSortBy(field || 'keyword');
setSortDirection(direction);
setCurrentPage(1);
setSelectedIds([]);
};
// Handle adding keywords to workflow
@@ -684,6 +732,50 @@ export default function IndustriesSectorsKeywords() {
type: 'text' as const,
placeholder: 'Search keywords...',
},
{
key: 'sector',
label: 'Sector',
type: 'select' as const,
options: [
{ value: '', label: 'All Sectors' },
...sectors.map((sector) => ({
value: String(sector.id),
label: sector.name,
})),
],
},
{
key: 'volume',
label: 'Volume',
type: 'custom' as const,
customRender: () => (
<div className="flex items-center gap-2">
<span className="text-xs text-gray-500 dark:text-gray-400">Volume</span>
<Input
type="number"
placeholder="Min"
value={volumeMin}
onChange={(e) => {
setVolumeMin(e.target.value);
setCurrentPage(1);
setSelectedIds([]);
}}
className="w-20 h-8"
/>
<Input
type="number"
placeholder="Max"
value={volumeMax}
onChange={(e) => {
setVolumeMax(e.target.value);
setCurrentPage(1);
setSelectedIds([]);
}}
className="w-20 h-8"
/>
</div>
),
},
{
key: 'country',
label: 'Country',
@@ -730,7 +822,7 @@ export default function IndustriesSectorsKeywords() {
},
],
};
}, [activeSector, handleAddToWorkflow]);
}, [activeSector, handleAddToWorkflow, sectors]);
// Build smart suggestions from sector stats
const smartSuggestions = useMemo(() => {
@@ -738,6 +830,407 @@ export default function IndustriesSectorsKeywords() {
return buildSmartSuggestions(sectorStats, { showOnlyWithResults: true });
}, [sectorStats]);
// Helper: word count for keyword string
const getWordCount = useCallback((keyword: string) => {
return keyword.trim().split(/\s+/).filter(Boolean).length;
}, []);
const buildStatActionKey = useCallback((statType: StatType, count: number) => {
return `${statType}:${count}`;
}, []);
const buildSuggestionActionKey = useCallback((suggestionId: string, count: number) => {
return `${suggestionId}:${count}`;
}, []);
const isStatActionAdded = useCallback((statType: StatType, count: number) => {
return addedStatActions.has(buildStatActionKey(statType, count));
}, [addedStatActions, buildStatActionKey]);
const isSuggestionActionAdded = useCallback((suggestionId: string, count: number) => {
return addedSuggestionActions.has(buildSuggestionActionKey(suggestionId, count));
}, [addedSuggestionActions, buildSuggestionActionKey]);
const fetchBulkKeywords = useCallback(async (options: {
ordering: string;
difficultyMax?: number;
minVolume?: number;
longTail?: boolean;
availableOnly?: boolean;
count: number;
}) => {
if (!activeSite || !activeSector?.industry_sector) {
throw new Error('Please select a site and sector first');
}
const { ordering, difficultyMax, minVolume, longTail, availableOnly, count } = options;
const pageSize = Math.max(500, count * 2);
const filters: any = {
industry: activeSite.industry,
sector: activeSector.industry_sector,
page_size: pageSize,
ordering,
};
if (difficultyMax !== undefined) {
filters.difficulty_max = difficultyMax;
}
const response = await fetchKeywordsLibrary(filters);
let results = response.results || [];
if (minVolume !== undefined) {
results = results.filter((kw) => (kw.volume || 0) >= minVolume);
}
if (longTail) {
results = results.filter((kw) => getWordCount(kw.keyword) >= 4);
}
if (availableOnly) {
results = results.filter((kw) => !attachedSeedKeywordIdsRef.current.has(Number(kw.id)));
}
const topIds = results.slice(0, count).map((kw) => kw.id);
return topIds;
}, [activeSite, activeSector, getWordCount]);
const prepareBulkAdd = useCallback(async (payload: {
label: string;
ids: number[];
actionKey: string;
group: 'stat' | 'suggestion';
}) => {
if (payload.ids.length === 0) {
toast.error('No matching keywords found for this selection');
return;
}
setBulkAddKeywordIds(payload.ids);
setBulkAddStatLabel(payload.label);
setPendingBulkAddKey(payload.actionKey);
setPendingBulkAddGroup(payload.group);
setShowBulkAddModal(true);
}, [toast]);
// Handle stat card click - filters table to show matching keywords
const handleStatClick = useCallback((statType: StatType) => {
// Toggle off if clicking same stat
if (activeStatFilter === statType) {
setActiveStatFilter(null);
setShowNotAddedOnly(false);
setDifficultyFilter('');
setVolumeMin('');
setVolumeMax('');
setSelectedIds([]);
return;
}
setActiveStatFilter(statType);
setCurrentPage(1);
setSelectedIds([]);
const statThresholds = {
highVolume: sectorStats?.high_volume.threshold ?? 10000,
premium: sectorStats?.premium_traffic.threshold ?? 50000,
longTail: sectorStats?.long_tail.threshold ?? 1000,
quickWins: sectorStats?.quick_wins.threshold ?? 1000,
};
switch (statType) {
case 'available':
setShowNotAddedOnly(true);
setDifficultyFilter('');
setVolumeMin('');
setVolumeMax('');
break;
case 'high_volume':
setShowNotAddedOnly(false);
setDifficultyFilter('');
setVolumeMin(String(statThresholds.highVolume));
setVolumeMax('');
setSortBy('volume');
setSortDirection('desc');
break;
case 'premium_traffic':
setShowNotAddedOnly(false);
setDifficultyFilter('');
setVolumeMin(String(statThresholds.premium));
setVolumeMax('');
setSortBy('volume');
setSortDirection('desc');
break;
case 'long_tail':
setShowNotAddedOnly(false);
setDifficultyFilter('');
setVolumeMin(String(statThresholds.longTail));
setVolumeMax('');
setSortBy('keyword');
setSortDirection('asc');
break;
case 'quick_wins':
setShowNotAddedOnly(true);
setDifficultyFilter('1');
setVolumeMin(String(statThresholds.quickWins));
setVolumeMax('');
setSortBy('difficulty');
setSortDirection('asc');
break;
default:
setShowNotAddedOnly(false);
setDifficultyFilter('');
setVolumeMin('');
setVolumeMax('');
}
}, [activeStatFilter, sectorStats]);
const handleSuggestionClick = useCallback((suggestion: any) => {
setActiveStatFilter(null);
setCurrentPage(1);
setSelectedIds([]);
const difficultyMax = suggestion.filterParams?.difficulty_max;
const volumeMinValue = suggestion.filterParams?.volume_min;
const availableOnly = Boolean(suggestion.filterParams?.available_only);
if (availableOnly) {
setShowNotAddedOnly(true);
} else {
setShowNotAddedOnly(false);
}
if (volumeMinValue) {
setVolumeMin(String(volumeMinValue));
} else {
setVolumeMin('');
}
setVolumeMax('');
if (difficultyMax !== undefined) {
if (difficultyMax <= 20) {
setDifficultyFilter('1');
} else if (difficultyMax <= 40) {
setDifficultyFilter('2');
} else {
setDifficultyFilter('');
}
} else {
setDifficultyFilter('');
}
if (suggestion.id === 'quick_wins') {
setSortBy('difficulty');
setSortDirection('asc');
} else {
setSortBy('volume');
setSortDirection('desc');
}
}, []);
// Handle sector card click
const handleSectorSelect = useCallback((sector: Sector | null) => {
setActiveStatFilter(null);
setSelectedIds([]);
setCurrentPage(1);
if (!sector) {
setActiveSector(null);
return;
}
setActiveSector(sector);
}, [setActiveSector]);
// Handle bulk add from metric cards
const handleMetricBulkAdd = useCallback(async (statType: StatType, count: number) => {
if (!activeSite || !activeSector?.industry_sector) {
toast.error('Please select a site and sector first');
return;
}
const actionKey = buildStatActionKey(statType, count);
if (addedStatActions.has(actionKey)) {
return;
}
const statLabelMap: Record<StatType, string> = {
total: 'Total Keywords',
available: 'Available Keywords',
high_volume: 'High Volume',
premium_traffic: 'Premium Traffic',
long_tail: 'Long Tail',
quick_wins: 'Quick Wins',
};
const thresholdMap = {
high_volume: sectorStats?.high_volume.threshold ?? 10000,
premium_traffic: sectorStats?.premium_traffic.threshold ?? 50000,
long_tail: sectorStats?.long_tail.threshold ?? 1000,
quick_wins: sectorStats?.quick_wins.threshold ?? 1000,
};
let ids: number[] = [];
if (statType === 'quick_wins') {
ids = await fetchBulkKeywords({
ordering: 'difficulty',
difficultyMax: 20,
minVolume: thresholdMap.quick_wins,
availableOnly: true,
count,
});
} else if (statType === 'long_tail') {
ids = await fetchBulkKeywords({
ordering: '-volume',
minVolume: thresholdMap.long_tail,
longTail: true,
availableOnly: true,
count,
});
} else if (statType === 'premium_traffic') {
ids = await fetchBulkKeywords({
ordering: '-volume',
minVolume: thresholdMap.premium_traffic,
availableOnly: true,
count,
});
} else if (statType === 'high_volume') {
ids = await fetchBulkKeywords({
ordering: '-volume',
minVolume: thresholdMap.high_volume,
availableOnly: true,
count,
});
} else if (statType === 'available') {
ids = await fetchBulkKeywords({
ordering: '-volume',
availableOnly: true,
count,
});
} else {
ids = await fetchBulkKeywords({
ordering: '-volume',
availableOnly: true,
count,
});
}
await prepareBulkAdd({
label: statLabelMap[statType],
ids,
actionKey,
group: 'stat',
});
}, [activeSite, activeSector, addedStatActions, buildStatActionKey, fetchBulkKeywords, prepareBulkAdd, sectorStats, toast]);
// Handle bulk add from suggestions
const handleSuggestionBulkAdd = useCallback(async (suggestion: any, count: number) => {
if (!activeSite || !activeSector?.industry_sector) {
toast.error('Please select a site and sector first');
return;
}
const actionKey = buildSuggestionActionKey(suggestion.id, count);
if (addedSuggestionActions.has(actionKey)) {
return;
}
const suggestionThresholds = {
quick_wins: sectorStats?.quick_wins.threshold ?? 1000,
long_tail: sectorStats?.long_tail.threshold ?? 1000,
premium_traffic: sectorStats?.premium_traffic.threshold ?? 50000,
};
let ids: number[] = [];
if (suggestion.id === 'quick_wins') {
ids = await fetchBulkKeywords({
ordering: 'difficulty',
difficultyMax: 20,
minVolume: suggestionThresholds.quick_wins,
availableOnly: true,
count,
});
} else if (suggestion.id === 'long_tail') {
ids = await fetchBulkKeywords({
ordering: '-volume',
minVolume: suggestionThresholds.long_tail,
longTail: true,
availableOnly: true,
count,
});
} else if (suggestion.id === 'premium_traffic') {
ids = await fetchBulkKeywords({
ordering: '-volume',
minVolume: suggestionThresholds.premium_traffic,
availableOnly: true,
count,
});
} else if (suggestion.id === 'available') {
ids = await fetchBulkKeywords({
ordering: '-volume',
availableOnly: true,
count,
});
} else {
ids = await fetchBulkKeywords({
ordering: '-volume',
availableOnly: true,
count,
});
}
await prepareBulkAdd({
label: suggestion.label,
ids,
actionKey,
group: 'suggestion',
});
}, [activeSite, activeSector, addedSuggestionActions, buildSuggestionActionKey, fetchBulkKeywords, prepareBulkAdd, sectorStats, toast]);
const handleConfirmBulkAdd = useCallback(async () => {
if (!activeSite || !activeSector) {
throw new Error('Please select a site and sector first');
}
const result = await addSeedKeywordsToWorkflow(
bulkAddKeywordIds,
activeSite.id,
activeSector.id
);
if (!result.success) {
const errorMsg = result.errors?.[0] || 'Unable to add keywords. Please try again.';
throw new Error(errorMsg);
}
if (result.created > 0) {
bulkAddKeywordIds.forEach((id) => recentlyAddedRef.current.add(id));
}
if (pendingBulkAddKey) {
if (pendingBulkAddGroup === 'stat') {
setAddedStatActions((prev) => new Set([...prev, pendingBulkAddKey]));
}
if (pendingBulkAddGroup === 'suggestion') {
setAddedSuggestionActions((prev) => new Set([...prev, pendingBulkAddKey]));
}
}
setPendingBulkAddKey(null);
setPendingBulkAddGroup(null);
setShowBulkAddModal(false);
if (activeSite) {
loadSeedKeywords();
loadKeywordCounts();
loadSectorStats();
}
return {
added: result.created || 0,
skipped: result.skipped || 0,
total_requested: bulkAddKeywordIds.length,
};
}, [activeSite, activeSector, bulkAddKeywordIds, loadKeywordCounts, loadSectorStats, loadSeedKeywords, pendingBulkAddGroup, pendingBulkAddKey]);
// Show WorkflowGuide if no sites
if (sites.length === 0) {
return (
@@ -754,59 +1247,6 @@ export default function IndustriesSectorsKeywords() {
);
}
// Handle stat card click - filters table to show matching keywords
const handleStatClick = (statType: StatType) => {
// Toggle off if clicking same stat
if (activeStatFilter === statType) {
setActiveStatFilter(null);
setShowNotAddedOnly(false);
setDifficultyFilter('');
return;
}
setActiveStatFilter(statType);
setCurrentPage(1);
// Apply filters based on stat type
switch (statType) {
case 'available':
setShowNotAddedOnly(true);
setDifficultyFilter('');
break;
case 'high_volume':
// Volume >= 10K - needs sort by volume desc
setShowNotAddedOnly(false);
setDifficultyFilter('');
setSortBy('volume');
setSortDirection('desc');
break;
case 'premium_traffic':
// Premium traffic - sort by volume
setShowNotAddedOnly(false);
setDifficultyFilter('');
setSortBy('volume');
setSortDirection('desc');
break;
case 'long_tail':
// Long tail - can't filter by word count server-side, just show all sorted
setShowNotAddedOnly(false);
setDifficultyFilter('');
setSortBy('keyword');
setSortDirection('asc');
break;
case 'quick_wins':
// Quick wins - low difficulty + available
setShowNotAddedOnly(true);
setDifficultyFilter('1'); // Very Easy (level 1 = backend 0-20)
setSortBy('difficulty');
setSortDirection('asc');
break;
default:
setShowNotAddedOnly(false);
setDifficultyFilter('');
}
};
return (
<>
<PageMeta title="Keywords Library" description="Browse curated keywords and add them to your workflow. Ahrefs research coming soon." />
@@ -816,66 +1256,49 @@ export default function IndustriesSectorsKeywords() {
badge={{ icon: <BoltIcon />, color: 'blue' }}
/>
{/* Sector Cards - Top of page */}
{activeSite && sectors.length > 0 && (
<div className="mx-6 mt-6">
<SectorCardsGrid
sectors={sectors}
sectorStats={sectorStatsList}
activeSectorId={activeSector?.id}
onSelectSector={handleSectorSelect}
isLoading={loadingSectorStats}
/>
</div>
)}
{/* Sector Metric Cards - Show when site is selected */}
{activeSite && (
<div className="mx-6 mt-6">
<div className="mb-4">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-1">
Keyword Stats {activeSector ? `${activeSector.name}` : '— All Sectors'}
</h3>
<p className="text-sm text-gray-600 dark:text-gray-400">
Click a card to filter the table below
</p>
</div>
<SectorMetricGrid
stats={sectorStats}
activeStatType={activeStatFilter}
onStatClick={handleStatClick}
onBulkAdd={activeSector ? handleMetricBulkAdd : undefined}
isAddedAction={isStatActionAdded}
clickable={true}
sectorName={activeSector?.name}
isLoading={loadingSectorStats}
/>
</div>
)}
{/* Smart Suggestions Panel */}
{activeSite && sectorStats && smartSuggestions.length > 0 && (
{/* Smart Suggestions Panel - Always show when site is selected */}
{activeSite && sectorStats && (
<div className="mx-6 mt-6">
<SmartSuggestions
suggestions={smartSuggestions}
onSuggestionClick={(suggestion) => {
// Apply the filter from the suggestion
if (suggestion.filterParams.statType) {
handleStatClick(suggestion.filterParams.statType as StatType);
}
}}
onSuggestionClick={handleSuggestionClick}
onBulkAdd={activeSector ? handleSuggestionBulkAdd : undefined}
isAddedAction={isSuggestionActionAdded}
enableFilterClick={true}
isLoading={loadingSectorStats}
/>
</div>
)}
{/* Show info banner when no sector is selected */}
{!activeSector && activeSite && (
<div className="mx-6 mt-6 mb-4">
<div className="bg-brand-50 dark:bg-brand-900/20 border border-brand-200 dark:border-brand-800 rounded-lg p-4">
<div className="flex items-start gap-3">
<div className="flex-shrink-0">
<svg className="w-5 h-5 text-brand-600 dark:text-brand-400 mt-0.5" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clipRule="evenodd" />
</svg>
</div>
<div className="flex-1">
<h3 className="text-sm font-medium text-brand-900 dark:text-brand-200">
Choose a Topic Area First
</h3>
<p className="mt-1 text-sm text-brand-700 dark:text-brand-300">
Pick a topic area first, then add keywords - You need to choose what you're writing about before adding search terms to target
</p>
</div>
</div>
</div>
</div>
)}
{/* Keywords Table - Shown by default (per plan) */}
{activeSite && (
<TablePageTemplate
@@ -883,9 +1306,14 @@ export default function IndustriesSectorsKeywords() {
data={seedKeywords}
loading={!showContent}
showContent={showContent}
defaultShowFilters={true}
centerFilters={true}
filters={pageConfig.filters}
filterValues={{
sector: activeSector?.id ? String(activeSector.id) : '',
search: searchTerm,
volume_min: volumeMin,
volume_max: volumeMax,
country: countryFilter,
difficulty: difficultyFilter,
showNotAddedOnly: showNotAddedOnly ? 'true' : '',
@@ -921,19 +1349,49 @@ export default function IndustriesSectorsKeywords() {
onFilterChange={(key, value) => {
const stringValue = value === null || value === undefined ? '' : String(value);
if (activeStatFilter) {
setActiveStatFilter(null);
}
if (key === 'search') {
setSearchTerm(stringValue);
} else if (key === 'sector') {
if (!stringValue) {
handleSectorSelect(null);
} else {
const selectedSector = sectors.find((sector) => String(sector.id) === stringValue) || null;
handleSectorSelect(selectedSector);
}
} else if (key === 'country') {
setCountryFilter(stringValue);
setCurrentPage(1);
setSelectedIds([]);
} else if (key === 'difficulty') {
setDifficultyFilter(stringValue);
setCurrentPage(1);
setSelectedIds([]);
} else if (key === 'showNotAddedOnly') {
setShowNotAddedOnly(stringValue === 'true');
setCurrentPage(1);
setSelectedIds([]);
}
}}
onFilterReset={() => {
// Clear all filters
setSearchTerm('');
setCountryFilter('');
setVolumeMin('');
setVolumeMax('');
setDifficultyFilter('');
setShowNotAddedOnly(false);
setActiveStatFilter(null);
setCurrentPage(1);
setSelectedIds([]);
setActiveSector(null);
// Reset sorting to default
setSortBy('keyword');
setSortDirection('asc');
}}
onBulkAction={async (actionKey: string, ids: string[]) => {
if (actionKey === 'add_selected_to_workflow') {
await handleBulkAddSelected(ids);
@@ -1045,6 +1503,22 @@ export default function IndustriesSectorsKeywords() {
</div>
</div>
</Modal>
{/* Bulk Add Confirmation */}
<BulkAddConfirmation
isOpen={showBulkAddModal}
onClose={() => {
setShowBulkAddModal(false);
setBulkAddKeywordIds([]);
setBulkAddStatLabel(undefined);
setPendingBulkAddKey(null);
setPendingBulkAddGroup(null);
}}
onConfirm={handleConfirmBulkAdd}
keywordCount={bulkAddKeywordIds.length}
sectorName={activeSector?.name}
statTypeLabel={bulkAddStatLabel}
/>
</>
);
}

View File

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

View File

@@ -184,6 +184,81 @@
SECTION 3: TAILWIND CONFIGURATION
=================================================================== */
/* ===================================================================
KEYWORDS LIBRARY - SECTOR CARDS
=================================================================== */
.keywords-library-sector-grid {
display: grid;
gap: 16px;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
}
@media (min-width: 1024px) {
.keywords-library-sector-grid {
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
}
}
.keywords-library-sector-card {
padding: 16px;
border-radius: var(--radius-xl);
border: 1px solid var(--color-stroke);
background: var(--color-panel);
box-shadow: var(--shadow-sm);
transition: box-shadow 0.2s ease, border-color 0.2s ease, transform 0.2s ease;
cursor: pointer;
position: relative;
overflow: hidden;
}
.keywords-library-sector-card:hover {
box-shadow: var(--shadow-md);
border-color: color-mix(in srgb, var(--color-primary) 35%, var(--color-stroke));
transform: translateY(-1px);
}
.keywords-library-sector-card.is-active {
border-color: var(--color-primary);
box-shadow: 0 0 0 4px color-mix(in srgb, var(--color-primary) 18%, transparent), var(--shadow-md);
}
.keywords-library-sector-card .sector-card-accent {
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 4px;
background: var(--gradient-primary);
opacity: 0.9;
}
.keywords-library-sector-card .sector-card-active-dot {
width: 8px;
height: 8px;
border-radius: 999px;
background: var(--color-primary);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-primary) 20%, transparent);
}
.keywords-library-sector-card .sector-stats-pill {
border-radius: var(--radius-lg);
background: var(--color-panel-alt);
border: 1px solid var(--color-stroke);
padding: 8px;
}
.keywords-library-sector-card .sector-stats-pill .label {
color: var(--color-text-dim);
font-size: 12px;
}
.keywords-library-sector-card .sector-stats-pill .value {
color: var(--color-text);
font-weight: 600;
font-size: 14px;
}
/*
🚫 TAILWIND DEFAULT COLORS ARE DISABLED

View File

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