From 43df7af9893a98e2bfa941061341e59fcfa4d38c Mon Sep 17 00:00:00 2001 From: "IGNY8 VPS (Salman)" Date: Sun, 18 Jan 2026 17:57:56 +0000 Subject: [PATCH] keywrods libarry update --- backend/igny8_core/auth/urls.py | 2 +- backend/igny8_core/auth/views.py | 344 ++++++++++++++++++ backend/igny8_core/settings.py | 2 +- docs/plans/KEYWORDS-LIBRARY-REDESIGN-PLAN.md | 22 +- frontend/src/App.tsx | 9 +- .../src/components/common/SearchModal.tsx | 10 +- .../keywords-library/BulkAddConfirmation.tsx | 185 ++++++++++ .../keywords-library/SectorMetricCard.tsx | 276 ++++++++++++++ .../keywords-library/SmartSuggestions.tsx | 277 ++++++++++++++ .../src/components/keywords-library/index.tsx | 11 + .../components/sites/SiteSetupChecklist.tsx | 2 +- frontend/src/layout/AppHeader.tsx | 2 +- frontend/src/layout/AppSidebar.tsx | 6 +- frontend/src/pages/Help/Help.tsx | 4 +- .../pages/Setup/IndustriesSectorsKeywords.tsx | 208 ++++++++++- frontend/src/services/api.ts | 113 +++++- 16 files changed, 1428 insertions(+), 45 deletions(-) create mode 100644 frontend/src/components/keywords-library/BulkAddConfirmation.tsx create mode 100644 frontend/src/components/keywords-library/SectorMetricCard.tsx create mode 100644 frontend/src/components/keywords-library/SmartSuggestions.tsx create mode 100644 frontend/src/components/keywords-library/index.tsx diff --git a/backend/igny8_core/auth/urls.py b/backend/igny8_core/auth/urls.py index 140e4756..4ce2c2b6 100644 --- a/backend/igny8_core/auth/urls.py +++ b/backend/igny8_core/auth/urls.py @@ -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.) diff --git a/backend/igny8_core/auth/views.py b/backend/igny8_core/auth/views.py index a46c2c64..a5c79056 100644 --- a/backend/igny8_core/auth/views.py +++ b/backend/igny8_core/auth/views.py @@ -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) diff --git a/backend/igny8_core/settings.py b/backend/igny8_core/settings.py index 36c679b8..7afaa87a 100644 --- a/backend/igny8_core/settings.py +++ b/backend/igny8_core/settings.py @@ -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) diff --git a/docs/plans/KEYWORDS-LIBRARY-REDESIGN-PLAN.md b/docs/plans/KEYWORDS-LIBRARY-REDESIGN-PLAN.md index 406cef3e..2d0d5016 100644 --- a/docs/plans/KEYWORDS-LIBRARY-REDESIGN-PLAN.md +++ b/docs/plans/KEYWORDS-LIBRARY-REDESIGN-PLAN.md @@ -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 --- diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 7815e250..805d5390 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -266,10 +266,11 @@ export default function App() { } /> } /> - {/* Setup Pages */} - } /> - {/* Legacy redirect */} - } /> + {/* Keywords Library - primary route */} + } /> + {/* Legacy redirects */} + } /> + } /> {/* Settings */} {/* Legacy redirect - Profile is now a tab in Account Settings */} diff --git a/frontend/src/components/common/SearchModal.tsx b/frontend/src/components/common/SearchModal.tsx index 46553c2c..96ce3969 100644 --- a/frontend/src/components/common/SearchModal.tsx +++ b/frontend/src/components/common/SearchModal.tsx @@ -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' }, diff --git a/frontend/src/components/keywords-library/BulkAddConfirmation.tsx b/frontend/src/components/keywords-library/BulkAddConfirmation.tsx new file mode 100644 index 00000000..c9c8b134 --- /dev/null +++ b/frontend/src/components/keywords-library/BulkAddConfirmation.tsx @@ -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(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 ( + +
+
+ +
+ +

+ Keywords Added Successfully! +

+ +
+

+ {result.added} +

+

+ keywords added to your site +

+ + {result.skipped > 0 && ( +

+ {result.skipped} keywords were skipped (already exist) +

+ )} +
+ + +
+
+ ); + } + + // Error state + if (error) { + return ( + +
+
+ +
+ +

+ Failed to Add Keywords +

+ +

+ {error} +

+ +
+ + +
+
+
+ ); + } + + // Confirmation state + return ( + +
+
+
+ +
+ +

+ Add Keywords to Your Site +

+ +

+ You're about to add keywords to your site's keyword list. +

+
+ + {/* Summary */} +
+
+ Keywords to add: + {keywordCount} +
+ + {sectorName && ( +
+ Sector: + {sectorName} +
+ )} + + {statTypeLabel && ( +
+ Category: + {statTypeLabel} +
+ )} +
+ + {/* Actions */} +
+ + +
+
+
+ ); +} diff --git a/frontend/src/components/keywords-library/SectorMetricCard.tsx b/frontend/src/components/keywords-library/SectorMetricCard.tsx new file mode 100644 index 00000000..93e65904 --- /dev/null +++ b/frontend/src/components/keywords-library/SectorMetricCard.tsx @@ -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 = { + total: { + label: 'Total', + description: 'All keywords in sector', + icon: , + 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: , + 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: , + 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: , + 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: , + 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: , + 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 ( + + ); +} + +// 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 ( +
+ {statTypes.map((statType) => ( +
+
+
+
+
+ ))} +
+ ); + } + + return ( +
+ {statTypes.map((statType) => ( + onStatClick(statType)} + sectorName={sectorName} + compact={compact} + /> + ))} +
+ ); +} diff --git a/frontend/src/components/keywords-library/SmartSuggestions.tsx b/frontend/src/components/keywords-library/SmartSuggestions.tsx new file mode 100644 index 00000000..b2bd2b0b --- /dev/null +++ b/frontend/src/components/keywords-library/SmartSuggestions.tsx @@ -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 ( +
+
+
+ Loading suggestions... +
+
+ ); + } + + return ( +
+ {/* Header with breathing indicator */} + + + {/* Expandable content */} + {isExpanded && suggestions.length > 0 && ( +
+ {suggestions.map((suggestion) => { + const colors = colorClasses[suggestion.color]; + + return ( + + ); + })} +
+ )} + + {isExpanded && suggestions.length === 0 && ( +
+

+ Select an industry and sector to see smart suggestions. +

+
+ )} +
+ ); +} + +// 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; +} diff --git a/frontend/src/components/keywords-library/index.tsx b/frontend/src/components/keywords-library/index.tsx new file mode 100644 index 00000000..39a442c3 --- /dev/null +++ b/frontend/src/components/keywords-library/index.tsx @@ -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'; diff --git a/frontend/src/components/sites/SiteSetupChecklist.tsx b/frontend/src/components/sites/SiteSetupChecklist.tsx index 2c73ce85..77be6cef 100644 --- a/frontend/src/components/sites/SiteSetupChecklist.tsx +++ b/frontend/src/components/sites/SiteSetupChecklist.tsx @@ -59,7 +59,7 @@ export default function SiteSetupChecklist({ id: 'keywords', label: 'Keywords added', completed: hasKeywords, - href: '/setup/add-keywords', + href: '/keywords-library', }, ]; diff --git a/frontend/src/layout/AppHeader.tsx b/frontend/src/layout/AppHeader.tsx index ae7c5310..4dae5d61 100644 --- a/frontend/src/layout/AppHeader.tsx +++ b/frontend/src/layout/AppHeader.tsx @@ -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 = [ diff --git a/frontend/src/layout/AppSidebar.tsx b/frontend/src/layout/AppSidebar.tsx index 889d37a1..901e46c5 100644 --- a/frontend/src/layout/AppSidebar.tsx +++ b/frontend/src/layout/AppSidebar.tsx @@ -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: , - name: "Keyword Library", - path: "/setup/add-keywords", + name: "Keywords Library", + path: "/keywords-library", }); // Content Settings moved to Site Settings tabs - removed from sidebar diff --git a/frontend/src/pages/Help/Help.tsx b/frontend/src/pages/Help/Help.tsx index 4101ea2c..69d77154 100644 --- a/frontend/src/pages/Help/Help.tsx +++ b/frontend/src/pages/Help/Help.tsx @@ -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?", diff --git a/frontend/src/pages/Setup/IndustriesSectorsKeywords.tsx b/frontend/src/pages/Setup/IndustriesSectorsKeywords.tsx index aaa3d7e3..2ca92e5e 100644 --- a/frontend/src/pages/Setup/IndustriesSectorsKeywords.tsx +++ b/frontend/src/pages/Setup/IndustriesSectorsKeywords.tsx @@ -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>(new Set()); + // Sector Stats state (for metric cards) + const [sectorStats, setSectorStats] = useState(null); + const [loadingSectorStats, setLoadingSectorStats] = useState(false); + const [activeStatFilter, setActiveStatFilter] = useState(null); + + // Bulk Add confirmation modal state + const [showBulkAddModal, setShowBulkAddModal] = useState(false); + const [bulkAddKeywordIds, setBulkAddKeywordIds] = useState([]); + const [bulkAddStatLabel, setBulkAddStatLabel] = useState(); + // 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} @@ -1116,9 +1195,9 @@ export default function IndustriesSectorsKeywords() { if (highOpportunityLoaded && sites.length === 0) { return ( <> - + , color: 'blue' }} />
@@ -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 ( <> - + + + {/* TEST TEXT - REMOVE AFTER VERIFICATION */} +
+ Test Text +
+ , color: 'blue' }} /> + {/* Sector Metric Cards - Show when site is selected */} + {activeSite && ( +
+
+

+ Keyword Stats {activeSector ? `— ${activeSector.name}` : '— All Sectors'} +

+

+ Click a card to filter the table below +

+
+ +
+ )} + + {/* Smart Suggestions Panel */} + {activeSite && sectorStats && smartSuggestions.length > 0 && ( +
+ { + // Apply the filter from the suggestion + if (suggestion.filterParams.statType) { + handleStatClick(suggestion.filterParams.statType as StatType); + } + }} + isLoading={loadingSectorStats} + /> +
+ )} + {/* High Opportunity Keywords Section - Loads First */} @@ -1218,7 +1400,7 @@ export default function IndustriesSectorsKeywords() { <>