keywrods libarry update
This commit is contained in:
@@ -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.)
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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' },
|
||||
|
||||
185
frontend/src/components/keywords-library/BulkAddConfirmation.tsx
Normal file
185
frontend/src/components/keywords-library/BulkAddConfirmation.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
276
frontend/src/components/keywords-library/SectorMetricCard.tsx
Normal file
276
frontend/src/components/keywords-library/SectorMetricCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
277
frontend/src/components/keywords-library/SmartSuggestions.tsx
Normal file
277
frontend/src/components/keywords-library/SmartSuggestions.tsx
Normal 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;
|
||||
}
|
||||
11
frontend/src/components/keywords-library/index.tsx
Normal file
11
frontend/src/components/keywords-library/index.tsx
Normal 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';
|
||||
@@ -59,7 +59,7 @@ export default function SiteSetupChecklist({
|
||||
id: 'keywords',
|
||||
label: 'Keywords added',
|
||||
completed: hasKeywords,
|
||||
href: '/setup/add-keywords',
|
||||
href: '/keywords-library',
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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?",
|
||||
|
||||
@@ -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={
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user