diff --git a/backend/igny8_core/auth/views.py b/backend/igny8_core/auth/views.py index a5c79056..7c393a82 100644 --- a/backend/igny8_core/auth/views.py +++ b/backend/igny8_core/auth/views.py @@ -867,6 +867,8 @@ class SeedKeywordViewSet(viewsets.ReadOnlyModelViewSet): sector_name = self.request.query_params.get('sector_name') difficulty_min = self.request.query_params.get('difficulty_min') difficulty_max = self.request.query_params.get('difficulty_max') + volume_min = self.request.query_params.get('volume_min') + volume_max = self.request.query_params.get('volume_max') if industry_id: queryset = queryset.filter(industry_id=industry_id) @@ -888,6 +890,18 @@ class SeedKeywordViewSet(viewsets.ReadOnlyModelViewSet): queryset = queryset.filter(difficulty__lte=int(difficulty_max)) except (ValueError, TypeError): pass + + # Volume range filtering + if volume_min is not None: + try: + queryset = queryset.filter(volume__gte=int(volume_min)) + except (ValueError, TypeError): + pass + if volume_max is not None: + try: + queryset = queryset.filter(volume__lte=int(volume_max)) + except (ValueError, TypeError): + pass return queryset @@ -1106,9 +1120,9 @@ class SeedKeywordViewSet(viewsets.ReadOnlyModelViewSet): # Get already-added keyword IDs if site_id provided already_added_ids = set() if site_id: - from igny8_core.business.models import SiteKeyword + from igny8_core.business.planning.models import Keywords already_added_ids = set( - SiteKeyword.objects.filter( + Keywords.objects.filter( site_id=site_id, seed_keyword__isnull=False ).values_list('seed_keyword_id', flat=True) @@ -1230,7 +1244,7 @@ class SeedKeywordViewSet(viewsets.ReadOnlyModelViewSet): Get cascading filter options for Keywords Library. Returns industries, sectors (filtered by industry), and available filter values. """ - from django.db.models import Count, Min, Max + from django.db.models import Count, Min, Max, Q try: industry_id = request.query_params.get('industry_id') @@ -1310,7 +1324,7 @@ class SeedKeywordViewSet(viewsets.ReadOnlyModelViewSet): Accepts a list of seed_keyword IDs and adds them to the specified site. """ from django.db import transaction - from igny8_core.business.models import SiteKeyword + from igny8_core.business.planning.models import Keywords try: site_id = request.data.get('site_id') @@ -1331,7 +1345,7 @@ class SeedKeywordViewSet(viewsets.ReadOnlyModelViewSet): ) # Verify site access - from igny8_core.business.models import Site + from igny8_core.auth.models import Site site = Site.objects.filter(id=site_id).first() if not site: return error_response( @@ -1365,12 +1379,19 @@ class SeedKeywordViewSet(viewsets.ReadOnlyModelViewSet): # Get already existing existing_seed_ids = set( - SiteKeyword.objects.filter( + Keywords.objects.filter( site_id=site_id, seed_keyword_id__in=keyword_ids ).values_list('seed_keyword_id', flat=True) ) + # Get site sectors mapped by industry_sector_id for fast lookup + from igny8_core.auth.models import Sector + site_sectors = { + s.industry_sector_id: s + for s in Sector.objects.filter(site=site, is_deleted=False, is_active=True) + } + added_count = 0 skipped_count = 0 @@ -1380,14 +1401,17 @@ class SeedKeywordViewSet(viewsets.ReadOnlyModelViewSet): skipped_count += 1 continue - SiteKeyword.objects.create( + # Find the site's sector that matches this keyword's industry_sector + site_sector = site_sectors.get(seed_kw.sector_id) + if not site_sector: + # Skip if site doesn't have this sector + skipped_count += 1 + continue + + Keywords.objects.create( site=site, - keyword=seed_kw.keyword, + sector=site_sector, seed_keyword=seed_kw, - volume=seed_kw.volume, - difficulty=seed_kw.difficulty, - source='library', - is_active=True ) added_count += 1 diff --git a/frontend/src/components/keywords-library/SectorCardsGrid.tsx b/frontend/src/components/keywords-library/SectorCardsGrid.tsx new file mode 100644 index 00000000..cfdf4874 --- /dev/null +++ b/frontend/src/components/keywords-library/SectorCardsGrid.tsx @@ -0,0 +1,123 @@ +/** + * SectorCardsGrid - Shows site sectors with keyword stats + * Click a card to filter the table by that sector + */ + +import clsx from 'clsx'; +import { Card } from '../ui/card'; +import Badge from '../ui/badge/Badge'; +import type { Sector } from '../../store/sectorStore'; +import type { SectorStats, SectorStatsItem } from '../../services/api'; + +interface SectorCardsGridProps { + sectors: Sector[]; + sectorStats: SectorStatsItem[]; + activeSectorId?: number | null; + onSelectSector: (sector: Sector | null) => void; + isLoading?: boolean; +} + +function buildStatsMap(sectorStats: SectorStatsItem[]) { + const map = new Map(); + sectorStats.forEach((item) => { + map.set(item.sector_id, item.stats); + }); + return map; +} + +export default function SectorCardsGrid({ + sectors, + sectorStats, + activeSectorId, + onSelectSector, + isLoading = false, +}: SectorCardsGridProps) { + const statsMap = buildStatsMap(sectorStats); + + if (isLoading) { + return ( +
+ {Array.from({ length: Math.max(1, sectors.length || 4) }).map((_, idx) => ( + +
+
+
+
+
+
+ + ))} +
+ ); + } + + if (sectors.length === 0) { + return null; + } + + return ( +
+ {sectors.map((sector) => { + const stats = sector.industry_sector ? statsMap.get(sector.industry_sector) : undefined; + const total = stats?.total.count ?? 0; + const available = stats?.available.count ?? 0; + const inWorkflow = Math.max(total - available, 0); + const isActive = activeSectorId === sector.id; + + return ( + onSelectSector(sector)} + className={clsx( + 'keywords-library-sector-card', + isActive ? 'is-active' : '' + )} + > +
+
+
+
+

+ {sector.name} +

+ {isActive && } +
+ {sector.industry_sector ? ( + Sector keywords + ) : ( + Not linked to template + )} +
+ {isActive && ( + + Active + + )} +
+ +
+
+
Total
+
+ {total.toLocaleString()} +
+
+
+
Available
+
+ {available.toLocaleString()} +
+
+
+
In Workflow
+
+ {inWorkflow.toLocaleString()} +
+
+
+ + ); + })} +
+ ); +} diff --git a/frontend/src/components/keywords-library/SectorMetricCard.tsx b/frontend/src/components/keywords-library/SectorMetricCard.tsx index 93e65904..338e4ba7 100644 --- a/frontend/src/components/keywords-library/SectorMetricCard.tsx +++ b/frontend/src/components/keywords-library/SectorMetricCard.tsx @@ -1,12 +1,13 @@ /** - * SectorMetricCard - Clickable metric cards for Keywords Library - * Displays 6 stat types with dynamic fallback thresholds - * Clicking a card filters the table to show matching keywords + * SectorMetricCard - Redesigned metric cards for Keywords Library + * White background with accent colors like Dashboard cards + * Shows 6 stat types with click-to-filter and bulk add options */ import { ReactNode, useMemo } from 'react'; import clsx from 'clsx'; -import { PieChartIcon, CheckCircleIcon, ShootingStarIcon, BoltIcon, DocsIcon, BoxIcon } from '../../icons'; +import { PieChartIcon, CheckCircleIcon, ShootingStarIcon, BoltIcon, DocsIcon, BoxIcon, PlusIcon } from '../../icons'; +import Button from '../ui/button/Button'; export type StatType = 'total' | 'available' | 'high_volume' | 'premium_traffic' | 'long_tail' | 'quick_wins'; @@ -29,88 +30,77 @@ interface SectorMetricCardProps { stats: SectorStats; isActive: boolean; onClick: () => void; + onBulkAdd?: (statType: StatType, count: number) => void; + isAddedAction?: (statType: StatType, count: number) => boolean; + clickable?: boolean; sectorName?: string; compact?: boolean; } -// Card configuration for each stat type +// Card configuration for each stat type - using dashboard accent colors const STAT_CONFIG: Record = { 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', + accentColor: 'bg-gray-500', + textColor: 'text-gray-600 dark:text-gray-400', + badgeColor: 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300', }, available: { label: 'Available', description: 'Not yet added to your site', icon: , - color: 'text-green-600 dark:text-green-400', - bgColor: 'bg-green-50 dark:bg-green-900/20', - borderColor: 'border-green-200 dark:border-green-800', - activeColor: 'bg-green-100 dark:bg-green-800/30', - activeBorder: 'border-green-500 dark:border-green-600', + accentColor: 'bg-success-500', + textColor: 'text-success-600 dark:text-success-400', + badgeColor: 'bg-success-100 text-success-700 dark:bg-success-900/40 dark:text-success-300', }, high_volume: { label: 'High Volume', description: 'Volume ≥ 10K searches/mo', icon: , - color: 'text-blue-600 dark:text-blue-400', - bgColor: 'bg-blue-50 dark:bg-blue-900/20', - borderColor: 'border-blue-200 dark:border-blue-800', - activeColor: 'bg-blue-100 dark:bg-blue-800/30', - activeBorder: 'border-blue-500 dark:border-blue-600', + accentColor: 'bg-brand-500', + textColor: 'text-brand-600 dark:text-brand-400', + badgeColor: 'bg-brand-100 text-brand-700 dark:bg-brand-900/40 dark:text-brand-300', }, premium_traffic: { label: 'Premium Traffic', description: 'High volume keywords', icon: , - color: 'text-amber-600 dark:text-amber-400', - bgColor: 'bg-amber-50 dark:bg-amber-900/20', - borderColor: 'border-amber-200 dark:border-amber-800', - activeColor: 'bg-amber-100 dark:bg-amber-800/30', - activeBorder: 'border-amber-500 dark:border-amber-600', + accentColor: 'bg-warning-500', + textColor: 'text-warning-600 dark:text-warning-400', + badgeColor: 'bg-warning-100 text-warning-700 dark:bg-warning-900/40 dark:text-warning-300', showThreshold: true, - thresholdLabel: 'Vol ≥', + thresholdPrefix: 'Vol ≥', }, long_tail: { label: 'Long Tail', description: '4+ words with good volume', icon: , - color: 'text-purple-600 dark:text-purple-400', - bgColor: 'bg-purple-50 dark:bg-purple-900/20', - borderColor: 'border-purple-200 dark:border-purple-800', - activeColor: 'bg-purple-100 dark:bg-purple-800/30', - activeBorder: 'border-purple-500 dark:border-purple-600', + accentColor: 'bg-purple-500', + textColor: 'text-purple-600 dark:text-purple-400', + badgeColor: 'bg-purple-100 text-purple-700 dark:bg-purple-900/40 dark:text-purple-300', showThreshold: true, - thresholdLabel: 'Vol >', + thresholdPrefix: 'Vol >', }, quick_wins: { label: 'Quick Wins', description: 'Low difficulty, good volume', icon: , - color: 'text-emerald-600 dark:text-emerald-400', - bgColor: 'bg-emerald-50 dark:bg-emerald-900/20', - borderColor: 'border-emerald-200 dark:border-emerald-800', - activeColor: 'bg-emerald-100 dark:bg-emerald-800/30', - activeBorder: 'border-emerald-500 dark:border-emerald-600', + accentColor: 'bg-emerald-500', + textColor: 'text-emerald-600 dark:text-emerald-400', + badgeColor: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300', showThreshold: true, - thresholdLabel: 'Vol >', + thresholdPrefix: 'Vol >', }, }; @@ -138,79 +128,129 @@ export default function SectorMetricCard({ stats, isActive, onClick, + onBulkAdd, + isAddedAction, + clickable = true, sectorName, compact = false, }: SectorMetricCardProps) { const config = STAT_CONFIG[statType]; const statData = stats[statType]; - // Build description with threshold if applicable + // Build description with detailed threshold copy (match Smart Suggestions tone) const description = useMemo(() => { - if (config.showThreshold && statData.threshold) { - return `${config.thresholdLabel} ${formatThreshold(statData.threshold)}`; + const threshold = statData.threshold; + switch (statType) { + case 'long_tail': + return `4+ word phrases with vol > ${formatThreshold(threshold || 1000)}`; + case 'quick_wins': + return `Easy to rank keywords with vol > ${formatThreshold(threshold || 1000)}`; + case 'premium_traffic': + return `High volume keywords (vol > ${formatThreshold(threshold || 50000)})`; + case 'high_volume': + return `Volume ≥ ${formatThreshold(threshold || 10000)} searches/mo`; + case 'available': + return 'Not yet added to your site'; + default: + if (config.showThreshold && threshold) { + return `${config.thresholdPrefix} ${formatThreshold(threshold)}`; + } + return config.description; } - return config.description; - }, [config, statData.threshold]); + }, [config.description, config.showThreshold, config.thresholdPrefix, statData.threshold, statType]); + + // Determine bulk add options based on count + const bulkAddOptions = useMemo(() => { + const count = statData.count; + const options: number[] = []; + if (count >= 50) options.push(50); + if (count >= 100) options.push(100); + if (count >= 200) options.push(200); + if (count > 0 && count <= 200 && !options.includes(count)) { + options.push(count); + } + return options.sort((a, b) => a - b); + }, [statData.count]); + + const handleAddClick = (count: number) => { + if (onBulkAdd) { + onBulkAdd(statType, count); + } + }; return ( - + ); + })} +
+
)} - +
); } @@ -219,6 +259,9 @@ interface SectorMetricGridProps { stats: SectorStats | null; activeStatType: StatType | null; onStatClick: (statType: StatType) => void; + onBulkAdd?: (statType: StatType, count: number) => void; + isAddedAction?: (statType: StatType, count: number) => boolean; + clickable?: boolean; sectorName?: string; compact?: boolean; isLoading?: boolean; @@ -228,27 +271,34 @@ export function SectorMetricGrid({ stats, activeStatType, onStatClick, + onBulkAdd, + isAddedAction, + clickable = true, sectorName, compact = false, isLoading = false, }: SectorMetricGridProps) { - const statTypes: StatType[] = ['total', 'available', 'high_volume', 'premium_traffic', 'long_tail', 'quick_wins']; + const statTypes: StatType[] = ['available', 'high_volume', 'premium_traffic', 'long_tail', 'quick_wins']; // Loading skeleton if (isLoading || !stats) { return (
{statTypes.map((statType) => (
-
-
-
+
+
+
+
+
+
+
))}
@@ -257,7 +307,7 @@ export function SectorMetricGrid({ return (
{statTypes.map((statType) => ( @@ -267,6 +317,9 @@ export function SectorMetricGrid({ stats={stats} isActive={activeStatType === statType} onClick={() => onStatClick(statType)} + onBulkAdd={onBulkAdd} + isAddedAction={isAddedAction} + clickable={clickable} sectorName={sectorName} compact={compact} /> diff --git a/frontend/src/components/keywords-library/SmartSuggestions.tsx b/frontend/src/components/keywords-library/SmartSuggestions.tsx index b2bd2b0b..842de746 100644 --- a/frontend/src/components/keywords-library/SmartSuggestions.tsx +++ b/frontend/src/components/keywords-library/SmartSuggestions.tsx @@ -1,12 +1,12 @@ /** - * SmartSuggestions - Breathing indicator panel for Keywords Library - * Shows "Ready-to-use keywords waiting for you!" with animated indicator - * Clicking navigates to pre-filtered keywords + * SmartSuggestions - Compact card-based suggestions for Keywords Library + * White background with accent colors like Dashboard cards + * Shows keyword categories with counts and bulk add options */ import { useState } from 'react'; import clsx from 'clsx'; -import { ShootingStarIcon, ChevronDownIcon, ArrowRightIcon } from '../../icons'; +import { ShootingStarIcon, ChevronDownIcon, PlusIcon, BoltIcon, DocsIcon, BoxIcon, CheckCircleIcon } from '../../icons'; import Button from '../ui/button/Button'; interface SmartSuggestion { @@ -27,64 +27,98 @@ interface SmartSuggestion { interface SmartSuggestionsProps { suggestions: SmartSuggestion[]; onSuggestionClick: (suggestion: SmartSuggestion) => void; + onBulkAdd?: (suggestion: SmartSuggestion, count: number) => void; + isAddedAction?: (suggestionId: string, count: number) => boolean; + enableFilterClick?: boolean; className?: string; isLoading?: boolean; } -// Color mappings +// Icon mapping +const SUGGESTION_ICONS: Record = { + quick_wins: , + long_tail: , + premium_traffic: , + available: , +}; + +// Color mappings with white bg card style const colorClasses = { blue: { - bg: 'bg-blue-50 dark:bg-blue-900/20', - border: 'border-blue-200 dark:border-blue-800', - text: 'text-blue-600 dark:text-blue-400', - badge: 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300', + accent: 'bg-brand-500', + text: 'text-brand-600 dark:text-brand-400', + iconBg: 'bg-brand-100 dark:bg-brand-900/40', }, green: { - bg: 'bg-green-50 dark:bg-green-900/20', - border: 'border-green-200 dark:border-green-800', - text: 'text-green-600 dark:text-green-400', - badge: 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300', + accent: 'bg-success-500', + text: 'text-success-600 dark:text-success-400', + iconBg: 'bg-success-100 dark:bg-success-900/40', }, amber: { - bg: 'bg-amber-50 dark:bg-amber-900/20', - border: 'border-amber-200 dark:border-amber-800', - text: 'text-amber-600 dark:text-amber-400', - badge: 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300', + accent: 'bg-warning-500', + text: 'text-warning-600 dark:text-warning-400', + iconBg: 'bg-warning-100 dark:bg-warning-900/40', }, purple: { - bg: 'bg-purple-50 dark:bg-purple-900/20', - border: 'border-purple-200 dark:border-purple-800', + accent: 'bg-purple-500', text: 'text-purple-600 dark:text-purple-400', - badge: 'bg-purple-100 text-purple-700 dark:bg-purple-900/40 dark:text-purple-300', + iconBg: 'bg-purple-100 dark:bg-purple-900/40', }, emerald: { - bg: 'bg-emerald-50 dark:bg-emerald-900/20', - border: 'border-emerald-200 dark:border-emerald-800', + accent: 'bg-emerald-500', text: 'text-emerald-600 dark:text-emerald-400', - badge: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300', + iconBg: 'bg-emerald-100 dark:bg-emerald-900/40', }, }; export default function SmartSuggestions({ suggestions, onSuggestionClick, + onBulkAdd, + isAddedAction, + enableFilterClick = true, className, isLoading = false, }: SmartSuggestionsProps) { const [isExpanded, setIsExpanded] = useState(true); - const totalAvailable = suggestions.reduce((sum, s) => sum + s.count, 0); const hasKeywords = totalAvailable > 0; if (isLoading) { return (
-
+
- Loading suggestions... + Loading suggestions... +
+
+ ); + } + + // Show breathing indicator when no suggestions (waiting for data) + if (suggestions.length === 0) { + return ( +
+
+
+ +
+
+ {/* Breathing circle indicator */} +
+ + Ready-to-use keywords waiting for you! Search for a keyword or apply any filter to see smart suggestions... + +
); @@ -92,46 +126,35 @@ export default function SmartSuggestions({ return (
- {/* Header with breathing indicator */} + {/* Header */} - {/* Expandable content */} - {isExpanded && suggestions.length > 0 && ( -
- {suggestions.map((suggestion) => { - const colors = colorClasses[suggestion.color]; - - return ( - - ); - })} -
- )} - - {isExpanded && suggestions.length === 0 && ( + {/* Expandable content - Grid layout */} + {isExpanded && (
-

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

+
+ {suggestions.map((suggestion) => { + const colors = colorClasses[suggestion.color]; + const icon = SUGGESTION_ICONS[suggestion.id] || ; + // Bulk add options + const bulkOptions: number[] = []; + if (suggestion.count >= 50) bulkOptions.push(50); + if (suggestion.count >= 100) bulkOptions.push(100); + if (suggestion.count >= 200) bulkOptions.push(200); + if (suggestion.count > 0 && suggestion.count <= 200) bulkOptions.push(suggestion.count); + + return ( +
+ {/* Accent border */} +
+ + {/* Main content - clickable to filter */} +
{ + if (enableFilterClick) { + onSuggestionClick(suggestion); + } + }} + className="p-3 pl-4" + > +
+
+
+ {icon} +
+ + {suggestion.label} + +
+ + {suggestion.count.toLocaleString()} + +
+ +

+ {suggestion.description} +

+
+ + {/* Bulk add buttons - always visible */} + {onBulkAdd && bulkOptions.length > 0 && ( +
+
+ + + Add + +
+
+ {bulkOptions.map((count) => { + const isAdded = isAddedAction ? isAddedAction(suggestion.id, count) : false; + return ( + + ); + })} +
+
+ )} +
+ ); + })} +
)}
diff --git a/frontend/src/pages/Reference/SeedKeywords.tsx b/frontend/src/pages/Reference/SeedKeywords.tsx index 1f9a1c2e..f3cd3d54 100644 --- a/frontend/src/pages/Reference/SeedKeywords.tsx +++ b/frontend/src/pages/Reference/SeedKeywords.tsx @@ -45,7 +45,7 @@ export default function SeedKeywords() { return ( <> - +

Seed Keywords

Global keyword library for reference

diff --git a/frontend/src/pages/Setup/IndustriesSectorsKeywords.tsx b/frontend/src/pages/Setup/IndustriesSectorsKeywords.tsx index 2e20bcdb..0342d2bf 100644 --- a/frontend/src/pages/Setup/IndustriesSectorsKeywords.tsx +++ b/frontend/src/pages/Setup/IndustriesSectorsKeywords.tsx @@ -24,6 +24,7 @@ import { fetchKeywords, fetchSectorStats, SectorStats, + SectorStatsItem, } from '../../services/api'; import Badge from '../../components/ui/badge/Badge'; import { BoltIcon, ShootingStarIcon } from '../../icons'; @@ -32,19 +33,23 @@ import { usePageSizeStore } from '../../store/pageSizeStore'; import { getDifficultyNumber, getDifficultyRange, getDifficultyLabelFromNumber } from '../../utils/difficulty'; import { useSiteStore } from '../../store/siteStore'; import { useSectorStore } from '../../store/sectorStore'; +import type { Sector } from '../../store/sectorStore'; import Button from '../../components/ui/button/Button'; import { Modal } from '../../components/ui/modal'; import FileInput from '../../components/form/input/FileInput'; import Label from '../../components/form/Label'; +import Input from '../../components/form/input/InputField'; import { Card } from '../../components/ui/card'; import { SectorMetricGrid, StatType } from '../../components/keywords-library/SectorMetricCard'; import SmartSuggestions, { buildSmartSuggestions } from '../../components/keywords-library/SmartSuggestions'; +import SectorCardsGrid from '../../components/keywords-library/SectorCardsGrid'; +import BulkAddConfirmation from '../../components/keywords-library/BulkAddConfirmation'; export default function IndustriesSectorsKeywords() { const toast = useToast(); const { startLoading, stopLoading } = usePageLoading(); const { activeSite } = useSiteStore(); - const { activeSector, loadSectorsForSite } = useSectorStore(); + const { activeSector, loadSectorsForSite, sectors, setActiveSector } = useSectorStore(); const { pageSize } = usePageSizeStore(); // Data state @@ -54,9 +59,11 @@ export default function IndustriesSectorsKeywords() { const [selectedIds, setSelectedIds] = useState([]); // Track recently added keywords to preserve their state during reload const recentlyAddedRef = useRef>(new Set()); + const attachedSeedKeywordIdsRef = useRef>(new Set()); // Sector Stats state (for metric cards) const [sectorStats, setSectorStats] = useState(null); + const [sectorStatsList, setSectorStatsList] = useState([]); const [loadingSectorStats, setLoadingSectorStats] = useState(false); const [activeStatFilter, setActiveStatFilter] = useState(null); @@ -64,6 +71,10 @@ export default function IndustriesSectorsKeywords() { const [showBulkAddModal, setShowBulkAddModal] = useState(false); const [bulkAddKeywordIds, setBulkAddKeywordIds] = useState([]); const [bulkAddStatLabel, setBulkAddStatLabel] = useState(); + const [pendingBulkAddKey, setPendingBulkAddKey] = useState(null); + const [pendingBulkAddGroup, setPendingBulkAddGroup] = useState<'stat' | 'suggestion' | null>(null); + const [addedStatActions, setAddedStatActions] = useState>(new Set()); + const [addedSuggestionActions, setAddedSuggestionActions] = useState>(new Set()); // Ahrefs banner state const [showAhrefsBanner, setShowAhrefsBanner] = useState(true); @@ -82,6 +93,8 @@ export default function IndustriesSectorsKeywords() { const [countryFilter, setCountryFilter] = useState(''); const [difficultyFilter, setDifficultyFilter] = useState(''); const [showNotAddedOnly, setShowNotAddedOnly] = useState(false); + const [volumeMin, setVolumeMin] = useState(''); + const [volumeMax, setVolumeMax] = useState(''); // Keyword count tracking const [addedCount, setAddedCount] = useState(0); @@ -176,6 +189,7 @@ export default function IndustriesSectorsKeywords() { const loadSectorStats = useCallback(async () => { if (!activeSite || !activeSite.industry) { setSectorStats(null); + setSectorStatsList([]); return; } @@ -183,15 +197,21 @@ export default function IndustriesSectorsKeywords() { try { const response = await fetchSectorStats({ industry_id: activeSite.industry, - sector_id: activeSector?.industry_sector ?? undefined, site_id: activeSite.id, }); - // If sector-specific stats returned - if (response.stats) { - setSectorStats(response.stats as SectorStats); - } else if (response.sectors && response.sectors.length > 0) { - // Aggregate stats from all sectors + const allSectors = response.sectors || []; + setSectorStatsList(allSectors); + + if (activeSector?.industry_sector) { + const matching = allSectors.find((sector) => sector.sector_id === activeSector.industry_sector); + if (matching) { + setSectorStats(matching.stats as SectorStats); + return; + } + } + + if (allSectors.length > 0) { const aggregated: SectorStats = { total: { count: 0 }, available: { count: 0 }, @@ -200,15 +220,14 @@ export default function IndustriesSectorsKeywords() { long_tail: { count: 0, threshold: 1000 }, quick_wins: { count: 0, threshold: 1000 }, }; - - response.sectors.forEach((sector) => { + + allSectors.forEach((sector) => { aggregated.total.count += sector.stats.total.count; aggregated.available.count += sector.stats.available.count; aggregated.high_volume.count += sector.stats.high_volume.count; aggregated.premium_traffic.count += sector.stats.premium_traffic.count; aggregated.long_tail.count += sector.stats.long_tail.count; aggregated.quick_wins.count += sector.stats.quick_wins.count; - // Use first sector's thresholds (they should be consistent) if (!aggregated.premium_traffic.threshold) { aggregated.premium_traffic.threshold = sector.stats.premium_traffic.threshold; } @@ -219,8 +238,10 @@ export default function IndustriesSectorsKeywords() { aggregated.quick_wins.threshold = sector.stats.quick_wins.threshold; } }); - + setSectorStats(aggregated); + } else { + setSectorStats(null); } } catch (error) { console.error('Failed to load sector stats:', error); @@ -236,6 +257,26 @@ export default function IndustriesSectorsKeywords() { } }, [activeSite, activeSector, loadSectorStats]); + // Reset filters and state when site changes + useEffect(() => { + setActiveStatFilter(null); + setSearchTerm(''); + setCountryFilter(''); + setDifficultyFilter(''); + setShowNotAddedOnly(false); + setCurrentPage(1); + setSelectedIds([]); + setAddedStatActions(new Set()); + setAddedSuggestionActions(new Set()); + }, [activeSite?.id]); + + // Reset pagination/selection when sector changes + useEffect(() => { + setActiveStatFilter(null); + setCurrentPage(1); + setSelectedIds([]); + }, [activeSector?.id]); + // Load counts on mount and when site/sector changes useEffect(() => { if (activeSite) { @@ -283,6 +324,9 @@ export default function IndustriesSectorsKeywords() { console.warn('Could not fetch sectors or attached keywords:', err); } + // Keep attached IDs available for bulk add actions + attachedSeedKeywordIdsRef.current = attachedSeedKeywordIds; + // Build API filters - use server-side pagination const pageSizeNum = pageSize || 25; const filters: any = { @@ -298,6 +342,8 @@ export default function IndustriesSectorsKeywords() { if (searchTerm) filters.search = searchTerm; if (countryFilter) filters.country = countryFilter; + if (volumeMin) filters.volume_min = parseInt(volumeMin); + if (volumeMax) filters.volume_max = parseInt(volumeMax); // Apply difficulty filter (if API supports it, otherwise we'll filter client-side) if (difficultyFilter) { @@ -361,7 +407,7 @@ export default function IndustriesSectorsKeywords() { setAvailableCount(0); setShowContent(true); } - }, [activeSite, activeSector, currentPage, pageSize, searchTerm, countryFilter, difficultyFilter, showNotAddedOnly, sortBy, sortDirection, toast]); + }, [activeSite, activeSector, currentPage, pageSize, searchTerm, countryFilter, volumeMin, volumeMax, difficultyFilter, showNotAddedOnly, sortBy, sortDirection, toast]); // Load data when site/sector/filters change (show table by default per plan) useEffect(() => { @@ -381,6 +427,7 @@ export default function IndustriesSectorsKeywords() { // Reset to page 1 on pageSize change useEffect(() => { setCurrentPage(1); + setSelectedIds([]); }, [pageSize]); // Handle sorting @@ -388,6 +435,7 @@ export default function IndustriesSectorsKeywords() { setSortBy(field || 'keyword'); setSortDirection(direction); setCurrentPage(1); + setSelectedIds([]); }; // Handle adding keywords to workflow @@ -684,6 +732,50 @@ export default function IndustriesSectorsKeywords() { type: 'text' as const, placeholder: 'Search keywords...', }, + { + key: 'sector', + label: 'Sector', + type: 'select' as const, + options: [ + { value: '', label: 'All Sectors' }, + ...sectors.map((sector) => ({ + value: String(sector.id), + label: sector.name, + })), + ], + }, + { + key: 'volume', + label: 'Volume', + type: 'custom' as const, + customRender: () => ( +
+ Volume + { + setVolumeMin(e.target.value); + setCurrentPage(1); + setSelectedIds([]); + }} + className="w-20 h-8" + /> + { + setVolumeMax(e.target.value); + setCurrentPage(1); + setSelectedIds([]); + }} + className="w-20 h-8" + /> +
+ ), + }, { key: 'country', label: 'Country', @@ -730,7 +822,7 @@ export default function IndustriesSectorsKeywords() { }, ], }; - }, [activeSector, handleAddToWorkflow]); + }, [activeSector, handleAddToWorkflow, sectors]); // Build smart suggestions from sector stats const smartSuggestions = useMemo(() => { @@ -738,6 +830,407 @@ export default function IndustriesSectorsKeywords() { return buildSmartSuggestions(sectorStats, { showOnlyWithResults: true }); }, [sectorStats]); + // Helper: word count for keyword string + const getWordCount = useCallback((keyword: string) => { + return keyword.trim().split(/\s+/).filter(Boolean).length; + }, []); + + const buildStatActionKey = useCallback((statType: StatType, count: number) => { + return `${statType}:${count}`; + }, []); + + const buildSuggestionActionKey = useCallback((suggestionId: string, count: number) => { + return `${suggestionId}:${count}`; + }, []); + + const isStatActionAdded = useCallback((statType: StatType, count: number) => { + return addedStatActions.has(buildStatActionKey(statType, count)); + }, [addedStatActions, buildStatActionKey]); + + const isSuggestionActionAdded = useCallback((suggestionId: string, count: number) => { + return addedSuggestionActions.has(buildSuggestionActionKey(suggestionId, count)); + }, [addedSuggestionActions, buildSuggestionActionKey]); + + const fetchBulkKeywords = useCallback(async (options: { + ordering: string; + difficultyMax?: number; + minVolume?: number; + longTail?: boolean; + availableOnly?: boolean; + count: number; + }) => { + if (!activeSite || !activeSector?.industry_sector) { + throw new Error('Please select a site and sector first'); + } + + const { ordering, difficultyMax, minVolume, longTail, availableOnly, count } = options; + const pageSize = Math.max(500, count * 2); + + const filters: any = { + industry: activeSite.industry, + sector: activeSector.industry_sector, + page_size: pageSize, + ordering, + }; + + if (difficultyMax !== undefined) { + filters.difficulty_max = difficultyMax; + } + + const response = await fetchKeywordsLibrary(filters); + let results = response.results || []; + + if (minVolume !== undefined) { + results = results.filter((kw) => (kw.volume || 0) >= minVolume); + } + + if (longTail) { + results = results.filter((kw) => getWordCount(kw.keyword) >= 4); + } + + if (availableOnly) { + results = results.filter((kw) => !attachedSeedKeywordIdsRef.current.has(Number(kw.id))); + } + + const topIds = results.slice(0, count).map((kw) => kw.id); + return topIds; + }, [activeSite, activeSector, getWordCount]); + + const prepareBulkAdd = useCallback(async (payload: { + label: string; + ids: number[]; + actionKey: string; + group: 'stat' | 'suggestion'; + }) => { + if (payload.ids.length === 0) { + toast.error('No matching keywords found for this selection'); + return; + } + + setBulkAddKeywordIds(payload.ids); + setBulkAddStatLabel(payload.label); + setPendingBulkAddKey(payload.actionKey); + setPendingBulkAddGroup(payload.group); + setShowBulkAddModal(true); + }, [toast]); + + // Handle stat card click - filters table to show matching keywords + const handleStatClick = useCallback((statType: StatType) => { + // Toggle off if clicking same stat + if (activeStatFilter === statType) { + setActiveStatFilter(null); + setShowNotAddedOnly(false); + setDifficultyFilter(''); + setVolumeMin(''); + setVolumeMax(''); + setSelectedIds([]); + return; + } + + setActiveStatFilter(statType); + setCurrentPage(1); + setSelectedIds([]); + + const statThresholds = { + highVolume: sectorStats?.high_volume.threshold ?? 10000, + premium: sectorStats?.premium_traffic.threshold ?? 50000, + longTail: sectorStats?.long_tail.threshold ?? 1000, + quickWins: sectorStats?.quick_wins.threshold ?? 1000, + }; + + switch (statType) { + case 'available': + setShowNotAddedOnly(true); + setDifficultyFilter(''); + setVolumeMin(''); + setVolumeMax(''); + break; + case 'high_volume': + setShowNotAddedOnly(false); + setDifficultyFilter(''); + setVolumeMin(String(statThresholds.highVolume)); + setVolumeMax(''); + setSortBy('volume'); + setSortDirection('desc'); + break; + case 'premium_traffic': + setShowNotAddedOnly(false); + setDifficultyFilter(''); + setVolumeMin(String(statThresholds.premium)); + setVolumeMax(''); + setSortBy('volume'); + setSortDirection('desc'); + break; + case 'long_tail': + setShowNotAddedOnly(false); + setDifficultyFilter(''); + setVolumeMin(String(statThresholds.longTail)); + setVolumeMax(''); + setSortBy('keyword'); + setSortDirection('asc'); + break; + case 'quick_wins': + setShowNotAddedOnly(true); + setDifficultyFilter('1'); + setVolumeMin(String(statThresholds.quickWins)); + setVolumeMax(''); + setSortBy('difficulty'); + setSortDirection('asc'); + break; + default: + setShowNotAddedOnly(false); + setDifficultyFilter(''); + setVolumeMin(''); + setVolumeMax(''); + } + }, [activeStatFilter, sectorStats]); + + const handleSuggestionClick = useCallback((suggestion: any) => { + setActiveStatFilter(null); + setCurrentPage(1); + setSelectedIds([]); + + const difficultyMax = suggestion.filterParams?.difficulty_max; + const volumeMinValue = suggestion.filterParams?.volume_min; + const availableOnly = Boolean(suggestion.filterParams?.available_only); + + if (availableOnly) { + setShowNotAddedOnly(true); + } else { + setShowNotAddedOnly(false); + } + + if (volumeMinValue) { + setVolumeMin(String(volumeMinValue)); + } else { + setVolumeMin(''); + } + setVolumeMax(''); + + if (difficultyMax !== undefined) { + if (difficultyMax <= 20) { + setDifficultyFilter('1'); + } else if (difficultyMax <= 40) { + setDifficultyFilter('2'); + } else { + setDifficultyFilter(''); + } + } else { + setDifficultyFilter(''); + } + + if (suggestion.id === 'quick_wins') { + setSortBy('difficulty'); + setSortDirection('asc'); + } else { + setSortBy('volume'); + setSortDirection('desc'); + } + }, []); + + // Handle sector card click + const handleSectorSelect = useCallback((sector: Sector | null) => { + setActiveStatFilter(null); + setSelectedIds([]); + setCurrentPage(1); + if (!sector) { + setActiveSector(null); + return; + } + setActiveSector(sector); + }, [setActiveSector]); + + // Handle bulk add from metric cards + const handleMetricBulkAdd = useCallback(async (statType: StatType, count: number) => { + if (!activeSite || !activeSector?.industry_sector) { + toast.error('Please select a site and sector first'); + return; + } + + const actionKey = buildStatActionKey(statType, count); + if (addedStatActions.has(actionKey)) { + return; + } + + const statLabelMap: Record = { + total: 'Total Keywords', + available: 'Available Keywords', + high_volume: 'High Volume', + premium_traffic: 'Premium Traffic', + long_tail: 'Long Tail', + quick_wins: 'Quick Wins', + }; + + const thresholdMap = { + high_volume: sectorStats?.high_volume.threshold ?? 10000, + premium_traffic: sectorStats?.premium_traffic.threshold ?? 50000, + long_tail: sectorStats?.long_tail.threshold ?? 1000, + quick_wins: sectorStats?.quick_wins.threshold ?? 1000, + }; + + let ids: number[] = []; + if (statType === 'quick_wins') { + ids = await fetchBulkKeywords({ + ordering: 'difficulty', + difficultyMax: 20, + minVolume: thresholdMap.quick_wins, + availableOnly: true, + count, + }); + } else if (statType === 'long_tail') { + ids = await fetchBulkKeywords({ + ordering: '-volume', + minVolume: thresholdMap.long_tail, + longTail: true, + availableOnly: true, + count, + }); + } else if (statType === 'premium_traffic') { + ids = await fetchBulkKeywords({ + ordering: '-volume', + minVolume: thresholdMap.premium_traffic, + availableOnly: true, + count, + }); + } else if (statType === 'high_volume') { + ids = await fetchBulkKeywords({ + ordering: '-volume', + minVolume: thresholdMap.high_volume, + availableOnly: true, + count, + }); + } else if (statType === 'available') { + ids = await fetchBulkKeywords({ + ordering: '-volume', + availableOnly: true, + count, + }); + } else { + ids = await fetchBulkKeywords({ + ordering: '-volume', + availableOnly: true, + count, + }); + } + + await prepareBulkAdd({ + label: statLabelMap[statType], + ids, + actionKey, + group: 'stat', + }); + }, [activeSite, activeSector, addedStatActions, buildStatActionKey, fetchBulkKeywords, prepareBulkAdd, sectorStats, toast]); + + // Handle bulk add from suggestions + const handleSuggestionBulkAdd = useCallback(async (suggestion: any, count: number) => { + if (!activeSite || !activeSector?.industry_sector) { + toast.error('Please select a site and sector first'); + return; + } + + const actionKey = buildSuggestionActionKey(suggestion.id, count); + if (addedSuggestionActions.has(actionKey)) { + return; + } + + const suggestionThresholds = { + quick_wins: sectorStats?.quick_wins.threshold ?? 1000, + long_tail: sectorStats?.long_tail.threshold ?? 1000, + premium_traffic: sectorStats?.premium_traffic.threshold ?? 50000, + }; + + let ids: number[] = []; + if (suggestion.id === 'quick_wins') { + ids = await fetchBulkKeywords({ + ordering: 'difficulty', + difficultyMax: 20, + minVolume: suggestionThresholds.quick_wins, + availableOnly: true, + count, + }); + } else if (suggestion.id === 'long_tail') { + ids = await fetchBulkKeywords({ + ordering: '-volume', + minVolume: suggestionThresholds.long_tail, + longTail: true, + availableOnly: true, + count, + }); + } else if (suggestion.id === 'premium_traffic') { + ids = await fetchBulkKeywords({ + ordering: '-volume', + minVolume: suggestionThresholds.premium_traffic, + availableOnly: true, + count, + }); + } else if (suggestion.id === 'available') { + ids = await fetchBulkKeywords({ + ordering: '-volume', + availableOnly: true, + count, + }); + } else { + ids = await fetchBulkKeywords({ + ordering: '-volume', + availableOnly: true, + count, + }); + } + + await prepareBulkAdd({ + label: suggestion.label, + ids, + actionKey, + group: 'suggestion', + }); + }, [activeSite, activeSector, addedSuggestionActions, buildSuggestionActionKey, fetchBulkKeywords, prepareBulkAdd, sectorStats, toast]); + + const handleConfirmBulkAdd = useCallback(async () => { + if (!activeSite || !activeSector) { + throw new Error('Please select a site and sector first'); + } + + const result = await addSeedKeywordsToWorkflow( + bulkAddKeywordIds, + activeSite.id, + activeSector.id + ); + + if (!result.success) { + const errorMsg = result.errors?.[0] || 'Unable to add keywords. Please try again.'; + throw new Error(errorMsg); + } + + if (result.created > 0) { + bulkAddKeywordIds.forEach((id) => recentlyAddedRef.current.add(id)); + } + + if (pendingBulkAddKey) { + if (pendingBulkAddGroup === 'stat') { + setAddedStatActions((prev) => new Set([...prev, pendingBulkAddKey])); + } + if (pendingBulkAddGroup === 'suggestion') { + setAddedSuggestionActions((prev) => new Set([...prev, pendingBulkAddKey])); + } + } + + setPendingBulkAddKey(null); + setPendingBulkAddGroup(null); + setShowBulkAddModal(false); + + if (activeSite) { + loadSeedKeywords(); + loadKeywordCounts(); + loadSectorStats(); + } + + return { + added: result.created || 0, + skipped: result.skipped || 0, + total_requested: bulkAddKeywordIds.length, + }; + }, [activeSite, activeSector, bulkAddKeywordIds, loadKeywordCounts, loadSectorStats, loadSeedKeywords, pendingBulkAddGroup, pendingBulkAddKey]); + // Show WorkflowGuide if no sites if (sites.length === 0) { return ( @@ -754,59 +1247,6 @@ export default function IndustriesSectorsKeywords() { ); } - // Handle stat card click - filters table to show matching keywords - const handleStatClick = (statType: StatType) => { - // Toggle off if clicking same stat - if (activeStatFilter === statType) { - setActiveStatFilter(null); - setShowNotAddedOnly(false); - setDifficultyFilter(''); - return; - } - - setActiveStatFilter(statType); - setCurrentPage(1); - - // Apply filters based on stat type - switch (statType) { - case 'available': - setShowNotAddedOnly(true); - setDifficultyFilter(''); - break; - case 'high_volume': - // Volume >= 10K - needs sort by volume desc - setShowNotAddedOnly(false); - setDifficultyFilter(''); - setSortBy('volume'); - setSortDirection('desc'); - break; - case 'premium_traffic': - // Premium traffic - sort by volume - setShowNotAddedOnly(false); - setDifficultyFilter(''); - setSortBy('volume'); - setSortDirection('desc'); - break; - case 'long_tail': - // Long tail - can't filter by word count server-side, just show all sorted - setShowNotAddedOnly(false); - setDifficultyFilter(''); - setSortBy('keyword'); - setSortDirection('asc'); - break; - case 'quick_wins': - // Quick wins - low difficulty + available - setShowNotAddedOnly(true); - setDifficultyFilter('1'); // Very Easy (level 1 = backend 0-20) - setSortBy('difficulty'); - setSortDirection('asc'); - break; - default: - setShowNotAddedOnly(false); - setDifficultyFilter(''); - } - }; - return ( <> @@ -816,66 +1256,49 @@ export default function IndustriesSectorsKeywords() { badge={{ icon: , color: 'blue' }} /> + {/* Sector Cards - Top of page */} + {activeSite && sectors.length > 0 && ( +
+ +
+ )} + {/* 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 && ( + {/* Smart Suggestions Panel - Always show when site is selected */} + {activeSite && sectorStats && (
{ - // Apply the filter from the suggestion - if (suggestion.filterParams.statType) { - handleStatClick(suggestion.filterParams.statType as StatType); - } - }} + onSuggestionClick={handleSuggestionClick} + onBulkAdd={activeSector ? handleSuggestionBulkAdd : undefined} + isAddedAction={isSuggestionActionAdded} + enableFilterClick={true} isLoading={loadingSectorStats} />
)} - {/* Show info banner when no sector is selected */} - {!activeSector && activeSite && ( -
-
-
-
- - - -
-
-

- Choose a Topic Area First -

-

- Pick a topic area first, then add keywords - You need to choose what you're writing about before adding search terms to target -

-
-
-
-
- )} - {/* Keywords Table - Shown by default (per plan) */} {activeSite && ( { const stringValue = value === null || value === undefined ? '' : String(value); + if (activeStatFilter) { + setActiveStatFilter(null); + } + if (key === 'search') { setSearchTerm(stringValue); + } else if (key === 'sector') { + if (!stringValue) { + handleSectorSelect(null); + } else { + const selectedSector = sectors.find((sector) => String(sector.id) === stringValue) || null; + handleSectorSelect(selectedSector); + } } else if (key === 'country') { setCountryFilter(stringValue); setCurrentPage(1); + setSelectedIds([]); } else if (key === 'difficulty') { setDifficultyFilter(stringValue); setCurrentPage(1); + setSelectedIds([]); } else if (key === 'showNotAddedOnly') { setShowNotAddedOnly(stringValue === 'true'); setCurrentPage(1); + setSelectedIds([]); } }} + onFilterReset={() => { + // Clear all filters + setSearchTerm(''); + setCountryFilter(''); + setVolumeMin(''); + setVolumeMax(''); + setDifficultyFilter(''); + setShowNotAddedOnly(false); + setActiveStatFilter(null); + setCurrentPage(1); + setSelectedIds([]); + setActiveSector(null); + // Reset sorting to default + setSortBy('keyword'); + setSortDirection('asc'); + }} onBulkAction={async (actionKey: string, ids: string[]) => { if (actionKey === 'add_selected_to_workflow') { await handleBulkAddSelected(ids); @@ -1045,6 +1503,22 @@ export default function IndustriesSectorsKeywords() {
+ + {/* Bulk Add Confirmation */} + { + setShowBulkAddModal(false); + setBulkAddKeywordIds([]); + setBulkAddStatLabel(undefined); + setPendingBulkAddKey(null); + setPendingBulkAddGroup(null); + }} + onConfirm={handleConfirmBulkAdd} + keywordCount={bulkAddKeywordIds.length} + sectorName={activeSector?.name} + statTypeLabel={bulkAddStatLabel} + /> ); } diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 8a29f6a5..f5a717a2 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -2281,6 +2281,8 @@ export async function fetchKeywordsLibrary(filters?: { ordering?: string; difficulty_min?: number; difficulty_max?: number; + volume_min?: number; + volume_max?: number; }): Promise { const params = new URLSearchParams(); // Use industry_id and sector_id as per backend get_queryset, but also try industry/sector for filterset_fields @@ -2301,6 +2303,9 @@ export async function fetchKeywordsLibrary(filters?: { // Difficulty range filtering if (filters?.difficulty_min !== undefined) params.append('difficulty_min', filters.difficulty_min.toString()); if (filters?.difficulty_max !== undefined) params.append('difficulty_max', filters.difficulty_max.toString()); + // Volume range filtering + if (filters?.volume_min !== undefined) params.append('volume_min', filters.volume_min.toString()); + if (filters?.volume_max !== undefined) params.append('volume_max', filters.volume_max.toString()); const queryString = params.toString(); return fetchAPI(`/v1/auth/keywords-library/${queryString ? `?${queryString}` : ''}`); diff --git a/frontend/src/styles/design-system.css b/frontend/src/styles/design-system.css index 31da560b..522ddf7b 100644 --- a/frontend/src/styles/design-system.css +++ b/frontend/src/styles/design-system.css @@ -184,6 +184,81 @@ SECTION 3: TAILWIND CONFIGURATION =================================================================== */ +/* =================================================================== + KEYWORDS LIBRARY - SECTOR CARDS + =================================================================== */ + +.keywords-library-sector-grid { + display: grid; + gap: 16px; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); +} + +@media (min-width: 1024px) { + .keywords-library-sector-grid { + grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); + } +} + +.keywords-library-sector-card { + padding: 16px; + border-radius: var(--radius-xl); + border: 1px solid var(--color-stroke); + background: var(--color-panel); + box-shadow: var(--shadow-sm); + transition: box-shadow 0.2s ease, border-color 0.2s ease, transform 0.2s ease; + cursor: pointer; + position: relative; + overflow: hidden; +} + +.keywords-library-sector-card:hover { + box-shadow: var(--shadow-md); + border-color: color-mix(in srgb, var(--color-primary) 35%, var(--color-stroke)); + transform: translateY(-1px); +} + +.keywords-library-sector-card.is-active { + border-color: var(--color-primary); + box-shadow: 0 0 0 4px color-mix(in srgb, var(--color-primary) 18%, transparent), var(--shadow-md); +} + +.keywords-library-sector-card .sector-card-accent { + position: absolute; + left: 0; + top: 0; + bottom: 0; + width: 4px; + background: var(--gradient-primary); + opacity: 0.9; +} + +.keywords-library-sector-card .sector-card-active-dot { + width: 8px; + height: 8px; + border-radius: 999px; + background: var(--color-primary); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-primary) 20%, transparent); +} + +.keywords-library-sector-card .sector-stats-pill { + border-radius: var(--radius-lg); + background: var(--color-panel-alt); + border: 1px solid var(--color-stroke); + padding: 8px; +} + +.keywords-library-sector-card .sector-stats-pill .label { + color: var(--color-text-dim); + font-size: 12px; +} + +.keywords-library-sector-card .sector-stats-pill .value { + color: var(--color-text); + font-weight: 600; + font-size: 14px; +} + /* 🚫 TAILWIND DEFAULT COLORS ARE DISABLED diff --git a/frontend/src/templates/TablePageTemplate.tsx b/frontend/src/templates/TablePageTemplate.tsx index 41a834bd..5a8e4b25 100644 --- a/frontend/src/templates/TablePageTemplate.tsx +++ b/frontend/src/templates/TablePageTemplate.tsx @@ -183,6 +183,9 @@ interface TablePageTemplateProps { getRowClassName?: (row: any) => string; // Custom checkbox column width (default: w-12 = 48px) checkboxColumnWidth?: string; + // Filter bar behavior + defaultShowFilters?: boolean; + centerFilters?: boolean; } export default function TablePageTemplate({ @@ -222,6 +225,8 @@ export default function TablePageTemplate({ primaryAction, getRowClassName, checkboxColumnWidth = '48px', + defaultShowFilters = false, + centerFilters = false, }: TablePageTemplateProps) { const location = useLocation(); const [isBulkActionsDropdownOpen, setIsBulkActionsDropdownOpen] = useState(false); @@ -230,7 +235,7 @@ export default function TablePageTemplate({ const bulkActionsButtonRef = React.useRef(null); // Filter toggle state - hidden by default - const [showFilters, setShowFilters] = useState(false); + const [showFilters, setShowFilters] = useState(defaultShowFilters); // Get notification config for current page const deleteModalConfig = getDeleteModalConfig(location.pathname); @@ -765,8 +770,8 @@ export default function TablePageTemplate({ {/* Filters Row - Below action buttons, left aligned with shadow */} {showFilters && (renderFilters || filters.length > 0) && ( -
-
+
+
{renderFilters ? ( renderFilters