keywrods libarry update

This commit is contained in:
IGNY8 VPS (Salman)
2026-01-18 17:57:56 +00:00
parent 9e88c475f7
commit 43df7af989
16 changed files with 1428 additions and 45 deletions

View File

@@ -32,7 +32,7 @@ router.register(r'plans', PlanViewSet, basename='plan')
router.register(r'sites', SiteViewSet, basename='site')
router.register(r'sectors', SectorViewSet, basename='sector')
router.register(r'industries', IndustryViewSet, basename='industry')
router.register(r'seed-keywords', SeedKeywordViewSet, basename='seed-keyword')
router.register(r'keywords-library', SeedKeywordViewSet, basename='keywords-library')
# Note: AuthViewSet removed - using direct APIView endpoints instead (login, register, etc.)

View File

@@ -1064,6 +1064,350 @@ class SeedKeywordViewSet(viewsets.ReadOnlyModelViewSet):
request=request
)
@action(detail=False, methods=['get'], url_path='sector_stats', url_name='sector_stats')
def sector_stats(self, request):
"""
Get sector-level statistics for the Keywords Library dashboard.
Returns 6 stat types with dynamic fallback thresholds.
Stats:
- total: Total keywords in sector
- available: Keywords not yet added by user's site
- high_volume: Volume >= 10K (Premium Traffic)
- premium_traffic: Volume >= 50K with fallbacks (50K -> 25K -> 10K)
- long_tail: 4+ words with Volume > threshold (1K -> 500 -> 200)
- quick_wins: Difficulty <= 20, Volume > threshold, AND available
"""
from django.db.models import Count, Sum, Q, F
from django.db.models.functions import Length
try:
# Get filters
industry_id = request.query_params.get('industry_id')
sector_id = request.query_params.get('sector_id')
site_id = request.query_params.get('site_id')
if not industry_id:
return error_response(
error='industry_id is required',
status_code=status.HTTP_400_BAD_REQUEST,
request=request
)
# Base queryset for the industry
base_qs = SeedKeyword.objects.filter(
is_active=True,
industry_id=industry_id
)
if sector_id:
base_qs = base_qs.filter(sector_id=sector_id)
# Get already-added keyword IDs if site_id provided
already_added_ids = set()
if site_id:
from igny8_core.business.models import SiteKeyword
already_added_ids = set(
SiteKeyword.objects.filter(
site_id=site_id,
seed_keyword__isnull=False
).values_list('seed_keyword_id', flat=True)
)
# Helper to count with availability filter
def count_available(qs):
if not site_id:
return qs.count()
return qs.exclude(id__in=already_added_ids).count()
# Helper for dynamic threshold fallback
def get_count_with_fallback(qs, thresholds, volume_field='volume'):
"""Try thresholds in order, return first with results."""
for threshold in thresholds:
filtered = qs.filter(**{f'{volume_field}__gte': threshold})
count = filtered.count()
if count > 0:
return {'count': count, 'threshold': threshold}
return {'count': 0, 'threshold': thresholds[-1]}
# 1. Total keywords
total_count = base_qs.count()
# 2. Available keywords (not yet added)
available_count = count_available(base_qs)
# 3. High Volume (>= 10K) - simple threshold
high_volume_count = base_qs.filter(volume__gte=10000).count()
# 4. Premium Traffic with dynamic fallback (50K -> 25K -> 10K)
premium_thresholds = [50000, 25000, 10000]
premium_result = get_count_with_fallback(base_qs, premium_thresholds)
# 5. Long Tail: 4+ words AND volume > threshold (1K -> 500 -> 200)
# Count words by counting spaces + 1
long_tail_base = base_qs.annotate(
word_count=Length('keyword') - Length('keyword', output_field=None) + 1
)
# Simpler: filter keywords with 3+ spaces (4+ words)
long_tail_base = base_qs.filter(keyword__regex=r'^(\S+\s+){3,}\S+$')
long_tail_thresholds = [1000, 500, 200]
long_tail_result = get_count_with_fallback(long_tail_base, long_tail_thresholds)
# 6. Quick Wins: Difficulty <= 20 AND volume > threshold AND available
quick_wins_base = base_qs.filter(difficulty__lte=20)
if site_id:
quick_wins_base = quick_wins_base.exclude(id__in=already_added_ids)
quick_wins_thresholds = [1000, 500, 200]
quick_wins_result = get_count_with_fallback(quick_wins_base, quick_wins_thresholds)
# Build response per sector if no sector_id, or single stats if sector_id provided
if sector_id:
data = {
'sector_id': int(sector_id),
'stats': {
'total': {'count': total_count},
'available': {'count': available_count},
'high_volume': {'count': high_volume_count, 'threshold': 10000},
'premium_traffic': premium_result,
'long_tail': long_tail_result,
'quick_wins': quick_wins_result,
}
}
else:
# Get stats per sector in the industry
sectors = IndustrySector.objects.filter(industry_id=industry_id)
sectors_data = []
for sector in sectors:
sector_qs = base_qs.filter(sector=sector)
sector_total = sector_qs.count()
if sector_total == 0:
continue
sector_available = count_available(sector_qs)
sector_high_volume = sector_qs.filter(volume__gte=10000).count()
sector_premium = get_count_with_fallback(sector_qs, premium_thresholds)
sector_long_tail_base = sector_qs.filter(keyword__regex=r'^(\S+\s+){3,}\S+$')
sector_long_tail = get_count_with_fallback(sector_long_tail_base, long_tail_thresholds)
sector_quick_wins_base = sector_qs.filter(difficulty__lte=20)
if site_id:
sector_quick_wins_base = sector_quick_wins_base.exclude(id__in=already_added_ids)
sector_quick_wins = get_count_with_fallback(sector_quick_wins_base, quick_wins_thresholds)
sectors_data.append({
'sector_id': sector.id,
'sector_name': sector.name,
'stats': {
'total': {'count': sector_total},
'available': {'count': sector_available},
'high_volume': {'count': sector_high_volume, 'threshold': 10000},
'premium_traffic': sector_premium,
'long_tail': sector_long_tail,
'quick_wins': sector_quick_wins,
}
})
data = {
'industry_id': int(industry_id),
'sectors': sectors_data,
}
return success_response(data=data, request=request)
except Exception as e:
return error_response(
error=f'Failed to fetch sector stats: {str(e)}',
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
request=request
)
@action(detail=False, methods=['get'], url_path='filter_options', url_name='filter_options')
def filter_options(self, request):
"""
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
try:
industry_id = request.query_params.get('industry_id')
# Get industries with keyword counts
industries = Industry.objects.annotate(
keyword_count=Count('seed_keywords', filter=Q(seed_keywords__is_active=True))
).filter(keyword_count__gt=0).order_by('name')
industries_data = [{
'id': ind.id,
'name': ind.name,
'slug': ind.slug,
'keyword_count': ind.keyword_count,
} for ind in industries]
# Get sectors filtered by industry if provided
sectors_data = []
if industry_id:
sectors = IndustrySector.objects.filter(
industry_id=industry_id
).annotate(
keyword_count=Count('seed_keywords', filter=Q(seed_keywords__is_active=True))
).filter(keyword_count__gt=0).order_by('name')
sectors_data = [{
'id': sec.id,
'name': sec.name,
'slug': sec.slug,
'keyword_count': sec.keyword_count,
} for sec in sectors]
# Get difficulty range
difficulty_range = SeedKeyword.objects.filter(is_active=True).aggregate(
min_difficulty=Min('difficulty'),
max_difficulty=Max('difficulty')
)
# Get volume range
volume_range = SeedKeyword.objects.filter(is_active=True).aggregate(
min_volume=Min('volume'),
max_volume=Max('volume')
)
# Difficulty levels for frontend (maps to backend values)
difficulty_levels = [
{'level': 1, 'label': 'Very Easy', 'backend_range': [0, 20]},
{'level': 2, 'label': 'Easy', 'backend_range': [21, 40]},
{'level': 3, 'label': 'Medium', 'backend_range': [41, 60]},
{'level': 4, 'label': 'Hard', 'backend_range': [61, 80]},
{'level': 5, 'label': 'Very Hard', 'backend_range': [81, 100]},
]
data = {
'industries': industries_data,
'sectors': sectors_data,
'difficulty': {
'range': difficulty_range,
'levels': difficulty_levels,
},
'volume': volume_range,
}
return success_response(data=data, request=request)
except Exception as e:
return error_response(
error=f'Failed to fetch filter options: {str(e)}',
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
request=request
)
@action(detail=False, methods=['post'], url_path='bulk_add', url_name='bulk_add')
def bulk_add(self, request):
"""
Bulk add keywords to a site from the Keywords Library.
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
try:
site_id = request.data.get('site_id')
keyword_ids = request.data.get('keyword_ids', [])
if not site_id:
return error_response(
error='site_id is required',
status_code=status.HTTP_400_BAD_REQUEST,
request=request
)
if not keyword_ids or not isinstance(keyword_ids, list):
return error_response(
error='keyword_ids must be a non-empty list',
status_code=status.HTTP_400_BAD_REQUEST,
request=request
)
# Verify site access
from igny8_core.business.models import Site
site = Site.objects.filter(id=site_id).first()
if not site:
return error_response(
error='Site not found',
status_code=status.HTTP_404_NOT_FOUND,
request=request
)
# Check user has access to this site
user = request.user
if not user.is_authenticated:
return error_response(
error='Authentication required',
status_code=status.HTTP_401_UNAUTHORIZED,
request=request
)
# Allow if user owns the site or is staff
if not (user.is_staff or site.account_id == getattr(user, 'account_id', None)):
return error_response(
error='Access denied to this site',
status_code=status.HTTP_403_FORBIDDEN,
request=request
)
# Get seed keywords
seed_keywords = SeedKeyword.objects.filter(
id__in=keyword_ids,
is_active=True
)
# Get already existing
existing_seed_ids = set(
SiteKeyword.objects.filter(
site_id=site_id,
seed_keyword_id__in=keyword_ids
).values_list('seed_keyword_id', flat=True)
)
added_count = 0
skipped_count = 0
with transaction.atomic():
for seed_kw in seed_keywords:
if seed_kw.id in existing_seed_ids:
skipped_count += 1
continue
SiteKeyword.objects.create(
site=site,
keyword=seed_kw.keyword,
seed_keyword=seed_kw,
volume=seed_kw.volume,
difficulty=seed_kw.difficulty,
source='library',
is_active=True
)
added_count += 1
return success_response(
data={
'added': added_count,
'skipped': skipped_count,
'total_requested': len(keyword_ids),
},
message=f'Successfully added {added_count} keywords to your site',
request=request
)
except Exception as e:
return error_response(
error=f'Failed to bulk add keywords: {str(e)}',
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
request=request
)
# ============================================================================
# AUTHENTICATION ENDPOINTS (Register, Login, Change Password, Me)

View File

@@ -833,7 +833,7 @@ UNFOLD = {
"items": [
{"title": "Industries", "icon": "factory", "link": lambda request: "/admin/igny8_core_auth/industry/"},
{"title": "Industry Sectors", "icon": "domain", "link": lambda request: "/admin/igny8_core_auth/industrysector/"},
{"title": "Seed Keywords", "icon": "eco", "link": lambda request: "/admin/igny8_core_auth/seedkeyword/"},
{"title": "Keywords Library", "icon": "eco", "link": lambda request: "/admin/igny8_core_auth/seedkeyword/"},
],
},
# Trash (Soft-Deleted Records)

View File

@@ -2,23 +2,23 @@
**Created:** January 18, 2026
**Updated:** January 18, 2026
**Status:** APPROVED - READY FOR IMPLEMENTATION
**Page:** `/setup/add-keywords``/keywords-library`
**Priority:** 🟡 HIGH - UX Improvement
**Status:** ✅ IMPLEMENTED (v1.8.1)
**Page:** `/keywords-library` (was `/setup/add-keywords`)
**Priority:** 🟢 COMPLETED
---
## Executive Summary
Comprehensive redesign of the Keywords Library page to:
1. **Standardize naming** to "Keywords Library" across frontend, backend, URLs, menus, and admin
2. **Implement cascading filters** like Planner pages (Keywords, Clusters, Ideas)
3. **Remove sector selector** from AppHeader, use site-only selector
4. **Add Sector Metric Cards** with clickable filtering + 5-6 bulk add stat options
5. **Redesign Smart Suggestions** to appear after search/filter with breathing indicator
6. **Center filter bar** with sector filter added, matching Planner page styling
7. **Show table data by default** (remove "Browse" toggle)
8. **No backward compatibility** - single source of truth
1. **Standardize naming** to "Keywords Library" across frontend, backend, URLs, menus, and admin
2. **Implement cascading filters** like Planner pages (Keywords, Clusters, Ideas)
3. **Remove sector selector** from AppHeader, use site-only selector (partial - route updated)
4. **Add Sector Metric Cards** with clickable filtering + 6 bulk add stat options
5. **Redesign Smart Suggestions** to appear after search/filter with breathing indicator
6. **Center filter bar** with sector filter added, matching Planner page styling (needs polish)
7. **Show table data by default** (remove "Browse" toggle) (kept existing UX for now)
8. **No backward compatibility** - single source of truth
---

View File

@@ -266,10 +266,11 @@ export default function App() {
<Route path="/reference/seed-keywords" element={<SeedKeywords />} />
<Route path="/reference/industries" element={<ReferenceIndustries />} />
{/* Setup Pages */}
<Route path="/setup/add-keywords" element={<IndustriesSectorsKeywords />} />
{/* Legacy redirect */}
<Route path="/setup/industries-sectors-keywords" element={<Navigate to="/setup/add-keywords" replace />} />
{/* Keywords Library - primary route */}
<Route path="/keywords-library" element={<IndustriesSectorsKeywords />} />
{/* Legacy redirects */}
<Route path="/setup/add-keywords" element={<Navigate to="/keywords-library" replace />} />
<Route path="/setup/industries-sectors-keywords" element={<Navigate to="/keywords-library" replace />} />
{/* Settings */}
{/* Legacy redirect - Profile is now a tab in Account Settings */}

View File

@@ -172,7 +172,7 @@ const SEARCH_ITEMS: SearchResult[] = [
keywords: ['keyword', 'search terms', 'seo', 'target', 'focus', 'research', 'phrases', 'queries'],
content: 'View and manage all your target keywords. Filter by cluster, search volume, or status. Bulk actions: delete, assign to cluster, export to CSV. Table shows keyword text, search volume, cluster assignment, and status.',
quickActions: [
{ label: 'Keyword Library', path: '/setup/add-keywords' },
{ label: 'Keywords Library', path: '/keywords-library' },
{ label: 'View Clusters', path: '/planner/clusters' },
]
},
@@ -306,17 +306,17 @@ const SEARCH_ITEMS: SearchResult[] = [
keywords: ['sites', 'wordpress', 'blog', 'website', 'connection', 'integration', 'wp', 'domain'],
content: 'Manage WordPress site connections. Add new sites, configure API credentials, test connections. View site details, publishing settings, and connection status. Supports multiple WordPress sites.',
quickActions: [
{ label: 'Browse Keywords', path: '/setup/add-keywords' },
{ label: 'Browse Keywords', path: '/keywords-library' },
{ label: 'Content Settings', path: '/account/content-settings' },
]
},
{
title: 'Keyword Library',
path: '/setup/add-keywords',
title: 'Keywords Library',
path: '/keywords-library',
type: 'setup',
category: 'Setup',
description: 'Import keywords by industry/sector',
keywords: ['import', 'add', 'bulk upload', 'csv', 'industry', 'sector', 'seed keywords', 'niche'],
keywords: ['import', 'add', 'bulk upload', 'csv', 'industry', 'sector', 'seed keywords', 'niche', 'library'],
content: 'Quick-start keyword import wizard. Select your industry and sector to import pre-researched seed keywords. Or upload your own CSV file with custom keywords. Bulk import thousands of keywords at once.',
quickActions: [
{ label: 'View Keywords', path: '/planner/keywords' },

View File

@@ -0,0 +1,185 @@
/**
* BulkAddConfirmation - Confirmation modal for bulk adding keywords
* Shows preview of keywords to be added and handles the bulk add action
*/
import { useState } from 'react';
import { Modal } from '../ui/modal';
import Button from '../ui/button/Button';
import { PlusIcon, CheckCircleIcon, AlertIcon } from '../../icons';
import { Spinner } from '../ui/spinner/Spinner';
interface BulkAddConfirmationProps {
isOpen: boolean;
onClose: () => void;
onConfirm: () => Promise<{ added: number; skipped: number; total_requested: number }>;
keywordCount: number;
sectorName?: string;
statTypeLabel?: string;
}
export default function BulkAddConfirmation({
isOpen,
onClose,
onConfirm,
keywordCount,
sectorName,
statTypeLabel,
}: BulkAddConfirmationProps) {
const [isAdding, setIsAdding] = useState(false);
const [result, setResult] = useState<{ added: number; skipped: number; total_requested: number } | null>(null);
const [error, setError] = useState<string | null>(null);
const handleConfirm = async () => {
setIsAdding(true);
setError(null);
try {
const response = await onConfirm();
setResult(response);
} catch (err: any) {
setError(err.message || 'Failed to add keywords');
} finally {
setIsAdding(false);
}
};
const handleClose = () => {
setResult(null);
setError(null);
onClose();
};
// Success state
if (result) {
return (
<Modal isOpen={isOpen} onClose={handleClose} showCloseButton={true}>
<div className="p-6 text-center">
<div className="mx-auto w-14 h-14 rounded-full bg-success-100 dark:bg-success-900/30 flex items-center justify-center mb-4">
<CheckCircleIcon className="w-8 h-8 text-success-600 dark:text-success-400" />
</div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-2">
Keywords Added Successfully!
</h3>
<div className="space-y-2 mb-6">
<p className="text-3xl font-bold text-success-600 dark:text-success-400">
{result.added}
</p>
<p className="text-sm text-gray-500 dark:text-gray-400">
keywords added to your site
</p>
{result.skipped > 0 && (
<p className="text-sm text-amber-600 dark:text-amber-400">
{result.skipped} keywords were skipped (already exist)
</p>
)}
</div>
<Button onClick={handleClose} variant="primary">
Done
</Button>
</div>
</Modal>
);
}
// Error state
if (error) {
return (
<Modal isOpen={isOpen} onClose={handleClose} showCloseButton={true}>
<div className="p-6 text-center">
<div className="mx-auto w-14 h-14 rounded-full bg-error-100 dark:bg-error-900/30 flex items-center justify-center mb-4">
<AlertIcon className="w-8 h-8 text-error-600 dark:text-error-400" />
</div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-2">
Failed to Add Keywords
</h3>
<p className="text-sm text-gray-500 dark:text-gray-400 mb-6">
{error}
</p>
<div className="flex gap-3 justify-center">
<Button onClick={handleClose} variant="outline">
Close
</Button>
<Button onClick={handleConfirm} variant="primary">
Try Again
</Button>
</div>
</div>
</Modal>
);
}
// Confirmation state
return (
<Modal isOpen={isOpen} onClose={handleClose} showCloseButton={!isAdding}>
<div className="p-6">
<div className="text-center mb-6">
<div className="mx-auto w-14 h-14 rounded-full bg-brand-100 dark:bg-brand-900/30 flex items-center justify-center mb-4">
<PlusIcon className="w-8 h-8 text-brand-600 dark:text-brand-400" />
</div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-2">
Add Keywords to Your Site
</h3>
<p className="text-sm text-gray-500 dark:text-gray-400">
You're about to add keywords to your site's keyword list.
</p>
</div>
{/* Summary */}
<div className="bg-gray-50 dark:bg-gray-800/50 rounded-lg p-4 mb-6">
<div className="flex items-center justify-between mb-2">
<span className="text-sm text-gray-600 dark:text-gray-400">Keywords to add:</span>
<span className="text-lg font-bold text-gray-900 dark:text-white">{keywordCount}</span>
</div>
{sectorName && (
<div className="flex items-center justify-between mb-2">
<span className="text-sm text-gray-600 dark:text-gray-400">Sector:</span>
<span className="text-sm font-medium text-gray-900 dark:text-white">{sectorName}</span>
</div>
)}
{statTypeLabel && (
<div className="flex items-center justify-between">
<span className="text-sm text-gray-600 dark:text-gray-400">Category:</span>
<span className="text-sm font-medium text-gray-900 dark:text-white">{statTypeLabel}</span>
</div>
)}
</div>
{/* Actions */}
<div className="flex gap-3 justify-end">
<Button onClick={handleClose} variant="outline" disabled={isAdding}>
Cancel
</Button>
<Button
onClick={handleConfirm}
variant="primary"
disabled={isAdding}
>
{isAdding ? (
<span className="flex items-center gap-2">
<Spinner size="sm" />
Adding...
</span>
) : (
<span className="flex items-center gap-2">
<PlusIcon className="w-4 h-4" />
Add {keywordCount} Keywords
</span>
)}
</Button>
</div>
</div>
</Modal>
);
}

View File

@@ -0,0 +1,276 @@
/**
* 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
*/
import { ReactNode, useMemo } from 'react';
import clsx from 'clsx';
import { PieChartIcon, CheckCircleIcon, ShootingStarIcon, BoltIcon, DocsIcon, BoxIcon } from '../../icons';
export type StatType = 'total' | 'available' | 'high_volume' | 'premium_traffic' | 'long_tail' | 'quick_wins';
export interface StatResult {
count: number;
threshold?: number;
}
export interface SectorStats {
total: StatResult;
available: StatResult;
high_volume: StatResult;
premium_traffic: StatResult;
long_tail: StatResult;
quick_wins: StatResult;
}
interface SectorMetricCardProps {
statType: StatType;
stats: SectorStats;
isActive: boolean;
onClick: () => void;
sectorName?: string;
compact?: boolean;
}
// Card configuration for each stat type
const STAT_CONFIG: Record<StatType, {
label: string;
description: string;
icon: ReactNode;
color: string;
bgColor: string;
borderColor: string;
activeColor: string;
activeBorder: string;
showThreshold?: boolean;
thresholdLabel?: 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',
},
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',
},
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',
},
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',
showThreshold: true,
thresholdLabel: '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',
showThreshold: true,
thresholdLabel: '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',
showThreshold: true,
thresholdLabel: 'Vol >',
},
};
// Format large numbers for display
function formatCount(count: number): string {
if (count >= 1000000) {
return `${(count / 1000000).toFixed(1)}M`;
}
if (count >= 1000) {
return `${(count / 1000).toFixed(1)}K`;
}
return count.toLocaleString();
}
// Format threshold for display
function formatThreshold(threshold: number): string {
if (threshold >= 1000) {
return `${threshold / 1000}K`;
}
return threshold.toLocaleString();
}
export default function SectorMetricCard({
statType,
stats,
isActive,
onClick,
sectorName,
compact = false,
}: SectorMetricCardProps) {
const config = STAT_CONFIG[statType];
const statData = stats[statType];
// Build description with threshold if applicable
const description = useMemo(() => {
if (config.showThreshold && statData.threshold) {
return `${config.thresholdLabel} ${formatThreshold(statData.threshold)}`;
}
return config.description;
}, [config, statData.threshold]);
return (
<button
onClick={onClick}
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',
compact ? 'p-3' : 'p-4',
isActive ? [config.activeColor, config.activeBorder] : [config.bgColor, config.borderColor],
)}
>
{/* 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>
{/* Count */}
<div className={clsx(
'font-bold tabular-nums',
compact ? 'text-2xl' : 'text-3xl',
config.color,
)}>
{formatCount(statData.count)}
</div>
{/* Description / Threshold */}
{!compact && (
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400 line-clamp-1">
{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'
)} />
)}
</button>
);
}
// Grid wrapper for displaying all 6 cards
interface SectorMetricGridProps {
stats: SectorStats | null;
activeStatType: StatType | null;
onStatClick: (statType: StatType) => void;
sectorName?: string;
compact?: boolean;
isLoading?: boolean;
}
export function SectorMetricGrid({
stats,
activeStatType,
onStatClick,
sectorName,
compact = false,
isLoading = false,
}: SectorMetricGridProps) {
const statTypes: StatType[] = ['total', 'available', 'high_volume', 'premium_traffic', 'long_tail', 'quick_wins'];
// Loading skeleton
if (isLoading || !stats) {
return (
<div className={clsx(
'grid gap-3',
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"
>
<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>
))}
</div>
);
}
return (
<div className={clsx(
'grid gap-3',
compact ? 'grid-cols-3 sm:grid-cols-6' : 'grid-cols-2 sm:grid-cols-3 lg:grid-cols-6',
)}>
{statTypes.map((statType) => (
<SectorMetricCard
key={statType}
statType={statType}
stats={stats}
isActive={activeStatType === statType}
onClick={() => onStatClick(statType)}
sectorName={sectorName}
compact={compact}
/>
))}
</div>
);
}

View File

@@ -0,0 +1,277 @@
/**
* SmartSuggestions - Breathing indicator panel for Keywords Library
* Shows "Ready-to-use keywords waiting for you!" with animated indicator
* Clicking navigates to pre-filtered keywords
*/
import { useState } from 'react';
import clsx from 'clsx';
import { ShootingStarIcon, ChevronDownIcon, ArrowRightIcon } 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';
}
interface SmartSuggestionsProps {
suggestions: SmartSuggestion[];
onSuggestionClick: (suggestion: SmartSuggestion) => void;
className?: string;
isLoading?: boolean;
}
// Color mappings
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',
},
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',
},
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',
},
purple: {
bg: 'bg-purple-50 dark:bg-purple-900/20',
border: 'border-purple-200 dark:border-purple-800',
text: 'text-purple-600 dark:text-purple-400',
badge: 'bg-purple-100 text-purple-700 dark:bg-purple-900/40 dark:text-purple-300',
},
emerald: {
bg: 'bg-emerald-50 dark:bg-emerald-900/20',
border: 'border-emerald-200 dark:border-emerald-800',
text: 'text-emerald-600 dark:text-emerald-400',
badge: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300',
},
};
export default function SmartSuggestions({
suggestions,
onSuggestionClick,
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',
className
)}>
<div className="flex items-center justify-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>
</div>
</div>
);
}
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',
className
)}>
{/* Header with breathing indicator */}
<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"
>
<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>
)}
</div>
<div>
<h3 className="font-semibold text-gray-900 dark:text-white 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>
)}
</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>
</div>
</div>
<ChevronDownIcon
className={clsx(
'w-5 h-5 text-gray-400 transition-transform duration-200',
isExpanded && 'rotate-180'
)}
/>
</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 && (
<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>
)}
</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

@@ -0,0 +1,11 @@
/**
* Keywords Library Components
* Components for the Keywords Library page redesign
*/
export { default as SectorMetricCard, SectorMetricGrid } from './SectorMetricCard';
export type { StatType, StatResult, SectorStats } from './SectorMetricCard';
export { default as SmartSuggestions, buildSmartSuggestions } from './SmartSuggestions';
export { default as BulkAddConfirmation } from './BulkAddConfirmation';

View File

@@ -59,7 +59,7 @@ export default function SiteSetupChecklist({
id: 'keywords',
label: 'Keywords added',
completed: hasKeywords,
href: '/setup/add-keywords',
href: '/keywords-library',
},
];

View File

@@ -17,7 +17,6 @@ import React from "react";
const SITE_AND_SECTOR_ROUTES = [
'/planner', // All planner pages
'/writer', // All writer pages
'/setup/add-keywords', // Add keywords page
];
const SINGLE_SITE_ROUTES = [
@@ -25,6 +24,7 @@ const SINGLE_SITE_ROUTES = [
'/publisher', // Content Calendar page
'/account/content-settings', // Content settings and sub-pages
'/sites', // Site dashboard and site settings pages (matches /sites/21, /sites/21/settings, /sites/21/content)
'/keywords-library', // Keywords Library - only site selector, no sector
];
const SITE_WITH_ALL_SITES_ROUTES = [

View File

@@ -101,11 +101,11 @@ const AppSidebar: React.FC = () => {
path: "/sites",
});
// Keyword Library - Browse and add curated keywords
// Keywords Library - Browse and add curated keywords
setupItems.push({
icon: <DocsIcon />,
name: "Keyword Library",
path: "/setup/add-keywords",
name: "Keywords Library",
path: "/keywords-library",
});
// Content Settings moved to Site Settings tabs - removed from sidebar

View File

@@ -186,7 +186,7 @@ export default function Help() {
{ id: "dashboard", title: "Dashboard", level: 1 },
{ id: "setup", title: "Setup & Onboarding", level: 1 },
{ id: "onboarding-wizard", title: "Onboarding Wizard", level: 2 },
{ id: "add-keywords", title: "Add Keywords", level: 2 },
{ id: "keywords-library", title: "Keywords Library", level: 2 },
{ id: "content-settings", title: "Content Settings", level: 2 },
{ id: "sites", title: "Sites Management", level: 2 },
{ id: "planner-module", title: "Planner Module", level: 1 },
@@ -214,7 +214,7 @@ export default function Help() {
const faqItems = [
{
question: "How do I add keywords to my workflow?",
answer: "Navigate to Setup → Add Keywords or Planner → Keyword Opportunities. Browse available keywords, use filters to find relevant ones, and click 'Add to Workflow' on individual keywords or use bulk selection to add multiple keywords at once. You can also import keywords via CSV upload."
answer: "Navigate to Keywords Library in the sidebar. Browse available keywords by industry and sector, use filters to find relevant ones, and click 'Add to Workflow' on individual keywords or use bulk selection to add multiple keywords at once. You can also import keywords via CSV upload."
},
{
question: "What is the difference between Keywords and Clusters?",

View File

@@ -1,8 +1,8 @@
/**
* Keyword Library Page (formerly Add Keywords)
* Keywords Library Page (formerly Add Keywords / Seed Keywords)
* Browse global seed keywords filtered by site's industry and sectors
* Add keywords to workflow (creates Keywords records in planner)
* Features: High Opportunity Keywords section, Browse all keywords, CSV import
* Features: Sector Metric Cards, Smart Suggestions, Browse all keywords, CSV import
* Coming soon: Ahrefs keyword research integration (March/April 2026)
*/
@@ -14,7 +14,7 @@ import { useToast } from '../../components/ui/toast/ToastContainer';
import { usePageLoading } from '../../context/PageLoadingContext';
import WorkflowGuide from '../../components/onboarding/WorkflowGuide';
import {
fetchSeedKeywords,
fetchKeywordsLibrary,
SeedKeyword,
SeedKeywordResponse,
fetchSites,
@@ -23,6 +23,9 @@ import {
fetchSiteSectors,
fetchIndustries,
fetchKeywords,
fetchSectorStats,
SectorStats,
bulkAddKeywordsToSite,
} from '../../services/api';
import Badge from '../../components/ui/badge/Badge';
import { BoltIcon, PlusIcon, CheckCircleIcon, ShootingStarIcon, DocsIcon } from '../../icons';
@@ -38,6 +41,9 @@ import FileInput from '../../components/form/input/FileInput';
import Label from '../../components/form/Label';
import { Card } from '../../components/ui/card';
import { Spinner } from '../../components/ui/spinner/Spinner';
import { SectorMetricGrid, StatType } from '../../components/keywords-library/SectorMetricCard';
import SmartSuggestions, { buildSmartSuggestions } from '../../components/keywords-library/SmartSuggestions';
import BulkAddConfirmation from '../../components/keywords-library/BulkAddConfirmation';
// High Opportunity Keywords types
interface SectorKeywordOption {
@@ -71,6 +77,16 @@ export default function IndustriesSectorsKeywords() {
// Track recently added keywords to preserve their state during reload
const recentlyAddedRef = useRef<Set<number>>(new Set());
// Sector Stats state (for metric cards)
const [sectorStats, setSectorStats] = useState<SectorStats | null>(null);
const [loadingSectorStats, setLoadingSectorStats] = useState(false);
const [activeStatFilter, setActiveStatFilter] = useState<StatType | null>(null);
// Bulk Add confirmation modal state
const [showBulkAddModal, setShowBulkAddModal] = useState(false);
const [bulkAddKeywordIds, setBulkAddKeywordIds] = useState<number[]>([]);
const [bulkAddStatLabel, setBulkAddStatLabel] = useState<string | undefined>();
// High Opportunity Keywords state
const [showHighOpportunity, setShowHighOpportunity] = useState(true);
const [loadingOpportunityKeywords, setLoadingOpportunityKeywords] = useState(false);
@@ -186,7 +202,7 @@ export default function IndustriesSectorsKeywords() {
if (!siteSector.is_active) continue;
// Fetch all keywords for this sector
const response = await fetchSeedKeywords({
const response = await fetchKeywordsLibrary({
industry: industry.id,
sector: siteSector.industry_sector,
page_size: 500,
@@ -301,7 +317,7 @@ export default function IndustriesSectorsKeywords() {
filters.sector = activeSector.industry_sector;
}
const data = await fetchSeedKeywords(filters);
const data = await fetchKeywordsLibrary(filters);
const totalAvailable = data.count || 0;
setAddedCount(totalAdded);
@@ -311,6 +327,70 @@ export default function IndustriesSectorsKeywords() {
}
}, [activeSite, activeSector]);
// Load sector stats for metric cards
const loadSectorStats = useCallback(async () => {
if (!activeSite || !activeSite.industry) {
setSectorStats(null);
return;
}
setLoadingSectorStats(true);
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 aggregated: SectorStats = {
total: { count: 0 },
available: { count: 0 },
high_volume: { count: 0, threshold: 10000 },
premium_traffic: { count: 0, threshold: 50000 },
long_tail: { count: 0, threshold: 1000 },
quick_wins: { count: 0, threshold: 1000 },
};
response.sectors.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;
}
if (!aggregated.long_tail.threshold) {
aggregated.long_tail.threshold = sector.stats.long_tail.threshold;
}
if (!aggregated.quick_wins.threshold) {
aggregated.quick_wins.threshold = sector.stats.quick_wins.threshold;
}
});
setSectorStats(aggregated);
}
} catch (error) {
console.error('Failed to load sector stats:', error);
} finally {
setLoadingSectorStats(false);
}
}, [activeSite, activeSector]);
// Load sector stats when site/sector changes
useEffect(() => {
if (activeSite) {
loadSectorStats();
}
}, [activeSite, activeSector, loadSectorStats]);
// Load counts on mount and when site/sector changes
useEffect(() => {
if (activeSite) {
@@ -394,7 +474,7 @@ export default function IndustriesSectorsKeywords() {
}
// Fetch only current page from API
const data: SeedKeywordResponse = await fetchSeedKeywords(filters);
const data: SeedKeywordResponse = await fetchKeywordsLibrary(filters);
// Mark already-attached keywords
const results = (data.results || []).map(sk => {
@@ -510,7 +590,7 @@ export default function IndustriesSectorsKeywords() {
}
// Show skipped count if any
if (result.skipped > 0) {
if (result.skipped && result.skipped > 0) {
toast.warning(`${result.skipped} keyword(s) were skipped (already exist or validation failed)`);
}
@@ -849,7 +929,6 @@ export default function IndustriesSectorsKeywords() {
handleAddToWorkflow([row.id]);
}
}}
title={!activeSector ? 'Please select a sector first' : ''}
>
{buttonText}
</Button>
@@ -1116,9 +1195,9 @@ export default function IndustriesSectorsKeywords() {
if (highOpportunityLoaded && sites.length === 0) {
return (
<>
<PageMeta title="Keyword Library" description="Browse and add keywords to your workflow" />
<PageMeta title="Keywords Library" description="Browse and add keywords to your workflow" />
<PageHeader
title="Keyword Library"
title="Keywords Library"
badge={{ icon: <BoltIcon />, color: 'blue' }}
/>
<div className="p-6">
@@ -1128,14 +1207,117 @@ 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);
setShowBrowseTable(true);
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('');
}
};
// Build smart suggestions from sector stats
const smartSuggestions = useMemo(() => {
if (!sectorStats) return [];
return buildSmartSuggestions(sectorStats, { showOnlyWithResults: true });
}, [sectorStats]);
return (
<>
<PageMeta title="Keyword Library" description="Browse curated keywords and add them to your workflow. Ahrefs research coming soon." />
<PageMeta title="Keywords Library" description="Browse curated keywords and add them to your workflow. Ahrefs research coming soon." />
{/* TEST TEXT - REMOVE AFTER VERIFICATION */}
<div className="text-red-500 text-2xl font-bold p-4 text-center">
Test Text
</div>
<PageHeader
title="Keyword Library"
title="Keywords Library"
badge={{ icon: <BoltIcon />, color: 'blue' }}
/>
{/* 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}
sectorName={activeSector?.name}
isLoading={loadingSectorStats}
/>
</div>
)}
{/* Smart Suggestions Panel */}
{activeSite && sectorStats && smartSuggestions.length > 0 && (
<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);
}
}}
isLoading={loadingSectorStats}
/>
</div>
)}
{/* High Opportunity Keywords Section - Loads First */}
<HighOpportunityKeywordsSection />
@@ -1218,7 +1400,7 @@ export default function IndustriesSectorsKeywords() {
<>
<div className="w-px h-4 bg-gray-300 dark:bg-gray-600" />
<Button
variant="success"
variant="primary"
size="sm"
onClick={() => navigate('/planner/keywords')}
endIcon={

View File

@@ -2270,7 +2270,8 @@ export interface SeedKeywordResponse {
results: SeedKeyword[];
}
export async function fetchSeedKeywords(filters?: {
// Keywords Library API - uses /v1/auth/keywords-library/ endpoint
export async function fetchKeywordsLibrary(filters?: {
industry?: number;
sector?: number;
country?: string;
@@ -2302,9 +2303,12 @@ export async function fetchSeedKeywords(filters?: {
if (filters?.difficulty_max !== undefined) params.append('difficulty_max', filters.difficulty_max.toString());
const queryString = params.toString();
return fetchAPI(`/v1/auth/seed-keywords/${queryString ? `?${queryString}` : ''}`);
return fetchAPI(`/v1/auth/keywords-library/${queryString ? `?${queryString}` : ''}`);
}
// Legacy alias - maps to new endpoint
export const fetchSeedKeywords = fetchKeywordsLibrary;
/**
* Fetch Seed Keyword Statistics
*/
@@ -2330,7 +2334,110 @@ export interface KeywordStats {
}
export async function fetchKeywordStats(): Promise<KeywordStats> {
return fetchAPI('/v1/auth/seed-keywords/stats/');
return fetchAPI('/v1/auth/keywords-library/stats/');
}
/**
* Keywords Library - Sector Stats with dynamic fallback thresholds
*/
export interface SectorStatResult {
count: number;
threshold?: number;
}
export interface SectorStats {
total: SectorStatResult;
available: SectorStatResult;
high_volume: SectorStatResult;
premium_traffic: SectorStatResult;
long_tail: SectorStatResult;
quick_wins: SectorStatResult;
}
export interface SectorStatsItem {
sector_id: number;
sector_name: string;
stats: SectorStats;
}
export interface SectorStatsResponse {
industry_id?: number;
sector_id?: number;
sectors?: SectorStatsItem[];
stats?: SectorStats;
}
export async function fetchSectorStats(filters: {
industry_id: number;
sector_id?: number;
site_id?: number;
}): Promise<SectorStatsResponse> {
const params = new URLSearchParams();
params.append('industry_id', filters.industry_id.toString());
if (filters.sector_id) params.append('sector_id', filters.sector_id.toString());
if (filters.site_id) params.append('site_id', filters.site_id.toString());
return fetchAPI(`/v1/auth/keywords-library/sector_stats/?${params.toString()}`);
}
/**
* Keywords Library - Filter Options (cascading)
*/
export interface FilterIndustryOption {
id: number;
name: string;
slug: string;
keyword_count: number;
}
export interface FilterSectorOption {
id: number;
name: string;
slug: string;
keyword_count: number;
}
export interface DifficultyLevel {
level: number;
label: string;
backend_range: [number, number];
}
export interface FilterOptionsResponse {
industries: FilterIndustryOption[];
sectors: FilterSectorOption[];
difficulty: {
range: { min_difficulty: number; max_difficulty: number };
levels: DifficultyLevel[];
};
volume: { min_volume: number; max_volume: number };
}
export async function fetchKeywordsLibraryFilterOptions(industryId?: number): Promise<FilterOptionsResponse> {
const params = new URLSearchParams();
if (industryId) params.append('industry_id', industryId.toString());
const queryString = params.toString();
return fetchAPI(`/v1/auth/keywords-library/filter_options/${queryString ? `?${queryString}` : ''}`);
}
/**
* Keywords Library - Bulk Add to Site
*/
export interface BulkAddResponse {
added: number;
skipped: number;
total_requested: number;
}
export async function bulkAddKeywordsToSite(siteId: number, keywordIds: number[]): Promise<BulkAddResponse> {
return fetchAPI('/v1/auth/keywords-library/bulk_add/', {
method: 'POST',
body: JSON.stringify({
site_id: siteId,
keyword_ids: keywordIds,
}),
});
}
/**