keywrod slibrary page dsigning
This commit is contained in:
@@ -867,6 +867,8 @@ class SeedKeywordViewSet(viewsets.ReadOnlyModelViewSet):
|
|||||||
sector_name = self.request.query_params.get('sector_name')
|
sector_name = self.request.query_params.get('sector_name')
|
||||||
difficulty_min = self.request.query_params.get('difficulty_min')
|
difficulty_min = self.request.query_params.get('difficulty_min')
|
||||||
difficulty_max = self.request.query_params.get('difficulty_max')
|
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:
|
if industry_id:
|
||||||
queryset = queryset.filter(industry_id=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))
|
queryset = queryset.filter(difficulty__lte=int(difficulty_max))
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
pass
|
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
|
return queryset
|
||||||
|
|
||||||
@@ -1106,9 +1120,9 @@ class SeedKeywordViewSet(viewsets.ReadOnlyModelViewSet):
|
|||||||
# Get already-added keyword IDs if site_id provided
|
# Get already-added keyword IDs if site_id provided
|
||||||
already_added_ids = set()
|
already_added_ids = set()
|
||||||
if site_id:
|
if site_id:
|
||||||
from igny8_core.business.models import SiteKeyword
|
from igny8_core.business.planning.models import Keywords
|
||||||
already_added_ids = set(
|
already_added_ids = set(
|
||||||
SiteKeyword.objects.filter(
|
Keywords.objects.filter(
|
||||||
site_id=site_id,
|
site_id=site_id,
|
||||||
seed_keyword__isnull=False
|
seed_keyword__isnull=False
|
||||||
).values_list('seed_keyword_id', flat=True)
|
).values_list('seed_keyword_id', flat=True)
|
||||||
@@ -1230,7 +1244,7 @@ class SeedKeywordViewSet(viewsets.ReadOnlyModelViewSet):
|
|||||||
Get cascading filter options for Keywords Library.
|
Get cascading filter options for Keywords Library.
|
||||||
Returns industries, sectors (filtered by industry), and available filter values.
|
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:
|
try:
|
||||||
industry_id = request.query_params.get('industry_id')
|
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.
|
Accepts a list of seed_keyword IDs and adds them to the specified site.
|
||||||
"""
|
"""
|
||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
from igny8_core.business.models import SiteKeyword
|
from igny8_core.business.planning.models import Keywords
|
||||||
|
|
||||||
try:
|
try:
|
||||||
site_id = request.data.get('site_id')
|
site_id = request.data.get('site_id')
|
||||||
@@ -1331,7 +1345,7 @@ class SeedKeywordViewSet(viewsets.ReadOnlyModelViewSet):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Verify site access
|
# 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()
|
site = Site.objects.filter(id=site_id).first()
|
||||||
if not site:
|
if not site:
|
||||||
return error_response(
|
return error_response(
|
||||||
@@ -1365,12 +1379,19 @@ class SeedKeywordViewSet(viewsets.ReadOnlyModelViewSet):
|
|||||||
|
|
||||||
# Get already existing
|
# Get already existing
|
||||||
existing_seed_ids = set(
|
existing_seed_ids = set(
|
||||||
SiteKeyword.objects.filter(
|
Keywords.objects.filter(
|
||||||
site_id=site_id,
|
site_id=site_id,
|
||||||
seed_keyword_id__in=keyword_ids
|
seed_keyword_id__in=keyword_ids
|
||||||
).values_list('seed_keyword_id', flat=True)
|
).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
|
added_count = 0
|
||||||
skipped_count = 0
|
skipped_count = 0
|
||||||
|
|
||||||
@@ -1380,14 +1401,17 @@ class SeedKeywordViewSet(viewsets.ReadOnlyModelViewSet):
|
|||||||
skipped_count += 1
|
skipped_count += 1
|
||||||
continue
|
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,
|
site=site,
|
||||||
keyword=seed_kw.keyword,
|
sector=site_sector,
|
||||||
seed_keyword=seed_kw,
|
seed_keyword=seed_kw,
|
||||||
volume=seed_kw.volume,
|
|
||||||
difficulty=seed_kw.difficulty,
|
|
||||||
source='library',
|
|
||||||
is_active=True
|
|
||||||
)
|
)
|
||||||
added_count += 1
|
added_count += 1
|
||||||
|
|
||||||
|
|||||||
123
frontend/src/components/keywords-library/SectorCardsGrid.tsx
Normal file
123
frontend/src/components/keywords-library/SectorCardsGrid.tsx
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
/**
|
||||||
|
* SectorCardsGrid - Shows site sectors with keyword stats
|
||||||
|
* Click a card to filter the table by that sector
|
||||||
|
*/
|
||||||
|
|
||||||
|
import clsx from 'clsx';
|
||||||
|
import { Card } from '../ui/card';
|
||||||
|
import Badge from '../ui/badge/Badge';
|
||||||
|
import type { Sector } from '../../store/sectorStore';
|
||||||
|
import type { SectorStats, SectorStatsItem } from '../../services/api';
|
||||||
|
|
||||||
|
interface SectorCardsGridProps {
|
||||||
|
sectors: Sector[];
|
||||||
|
sectorStats: SectorStatsItem[];
|
||||||
|
activeSectorId?: number | null;
|
||||||
|
onSelectSector: (sector: Sector | null) => void;
|
||||||
|
isLoading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildStatsMap(sectorStats: SectorStatsItem[]) {
|
||||||
|
const map = new Map<number, SectorStats>();
|
||||||
|
sectorStats.forEach((item) => {
|
||||||
|
map.set(item.sector_id, item.stats);
|
||||||
|
});
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SectorCardsGrid({
|
||||||
|
sectors,
|
||||||
|
sectorStats,
|
||||||
|
activeSectorId,
|
||||||
|
onSelectSector,
|
||||||
|
isLoading = false,
|
||||||
|
}: SectorCardsGridProps) {
|
||||||
|
const statsMap = buildStatsMap(sectorStats);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="keywords-library-sector-grid">
|
||||||
|
{Array.from({ length: Math.max(1, sectors.length || 4) }).map((_, idx) => (
|
||||||
|
<Card key={idx} className="keywords-library-sector-card animate-pulse">
|
||||||
|
<div className="h-4 w-32 bg-gray-200 dark:bg-gray-700 rounded" />
|
||||||
|
<div className="mt-4 grid grid-cols-3 gap-2">
|
||||||
|
<div className="h-6 bg-gray-200 dark:bg-gray-700 rounded" />
|
||||||
|
<div className="h-6 bg-gray-200 dark:bg-gray-700 rounded" />
|
||||||
|
<div className="h-6 bg-gray-200 dark:bg-gray-700 rounded" />
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sectors.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="keywords-library-sector-grid">
|
||||||
|
{sectors.map((sector) => {
|
||||||
|
const stats = sector.industry_sector ? statsMap.get(sector.industry_sector) : undefined;
|
||||||
|
const total = stats?.total.count ?? 0;
|
||||||
|
const available = stats?.available.count ?? 0;
|
||||||
|
const inWorkflow = Math.max(total - available, 0);
|
||||||
|
const isActive = activeSectorId === sector.id;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
key={sector.id}
|
||||||
|
onClick={() => onSelectSector(sector)}
|
||||||
|
className={clsx(
|
||||||
|
'keywords-library-sector-card',
|
||||||
|
isActive ? 'is-active' : ''
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="sector-card-accent" />
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h4 className="text-base font-semibold text-gray-900 dark:text-white">
|
||||||
|
{sector.name}
|
||||||
|
</h4>
|
||||||
|
{isActive && <span className="sector-card-active-dot" />}
|
||||||
|
</div>
|
||||||
|
{sector.industry_sector ? (
|
||||||
|
<span className="text-xs text-gray-500 dark:text-gray-400">Sector keywords</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-gray-500 dark:text-gray-400">Not linked to template</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{isActive && (
|
||||||
|
<Badge color="info" size="sm" variant="light">
|
||||||
|
Active
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 grid grid-cols-3 gap-2 text-center">
|
||||||
|
<div className="sector-stats-pill">
|
||||||
|
<div className="label">Total</div>
|
||||||
|
<div className="value text-base">
|
||||||
|
{total.toLocaleString()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="sector-stats-pill">
|
||||||
|
<div className="label">Available</div>
|
||||||
|
<div className="value text-base">
|
||||||
|
{available.toLocaleString()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="sector-stats-pill">
|
||||||
|
<div className="label">In Workflow</div>
|
||||||
|
<div className="value text-base">
|
||||||
|
{inWorkflow.toLocaleString()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,12 +1,13 @@
|
|||||||
/**
|
/**
|
||||||
* SectorMetricCard - Clickable metric cards for Keywords Library
|
* SectorMetricCard - Redesigned metric cards for Keywords Library
|
||||||
* Displays 6 stat types with dynamic fallback thresholds
|
* White background with accent colors like Dashboard cards
|
||||||
* Clicking a card filters the table to show matching keywords
|
* Shows 6 stat types with click-to-filter and bulk add options
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { ReactNode, useMemo } from 'react';
|
import { ReactNode, useMemo } from 'react';
|
||||||
import clsx from 'clsx';
|
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';
|
export type StatType = 'total' | 'available' | 'high_volume' | 'premium_traffic' | 'long_tail' | 'quick_wins';
|
||||||
|
|
||||||
@@ -29,88 +30,77 @@ interface SectorMetricCardProps {
|
|||||||
stats: SectorStats;
|
stats: SectorStats;
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
|
onBulkAdd?: (statType: StatType, count: number) => void;
|
||||||
|
isAddedAction?: (statType: StatType, count: number) => boolean;
|
||||||
|
clickable?: boolean;
|
||||||
sectorName?: string;
|
sectorName?: string;
|
||||||
compact?: boolean;
|
compact?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Card configuration for each stat type
|
// Card configuration for each stat type - using dashboard accent colors
|
||||||
const STAT_CONFIG: Record<StatType, {
|
const STAT_CONFIG: Record<StatType, {
|
||||||
label: string;
|
label: string;
|
||||||
description: string;
|
description: string;
|
||||||
icon: ReactNode;
|
icon: ReactNode;
|
||||||
color: string;
|
accentColor: string;
|
||||||
bgColor: string;
|
textColor: string;
|
||||||
borderColor: string;
|
badgeColor: string;
|
||||||
activeColor: string;
|
|
||||||
activeBorder: string;
|
|
||||||
showThreshold?: boolean;
|
showThreshold?: boolean;
|
||||||
thresholdLabel?: string;
|
thresholdPrefix?: string;
|
||||||
}> = {
|
}> = {
|
||||||
total: {
|
total: {
|
||||||
label: 'Total',
|
label: 'Total',
|
||||||
description: 'All keywords in sector',
|
description: 'All keywords in sector',
|
||||||
icon: <PieChartIcon className="w-5 h-5" />,
|
icon: <PieChartIcon className="w-5 h-5" />,
|
||||||
color: 'text-gray-600 dark:text-gray-400',
|
accentColor: 'bg-gray-500',
|
||||||
bgColor: 'bg-gray-50 dark:bg-gray-800/50',
|
textColor: 'text-gray-600 dark:text-gray-400',
|
||||||
borderColor: 'border-gray-200 dark:border-gray-700',
|
badgeColor: 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300',
|
||||||
activeColor: 'bg-gray-100 dark:bg-gray-700/50',
|
|
||||||
activeBorder: 'border-gray-400 dark:border-gray-500',
|
|
||||||
},
|
},
|
||||||
available: {
|
available: {
|
||||||
label: 'Available',
|
label: 'Available',
|
||||||
description: 'Not yet added to your site',
|
description: 'Not yet added to your site',
|
||||||
icon: <CheckCircleIcon className="w-5 h-5" />,
|
icon: <CheckCircleIcon className="w-5 h-5" />,
|
||||||
color: 'text-green-600 dark:text-green-400',
|
accentColor: 'bg-success-500',
|
||||||
bgColor: 'bg-green-50 dark:bg-green-900/20',
|
textColor: 'text-success-600 dark:text-success-400',
|
||||||
borderColor: 'border-green-200 dark:border-green-800',
|
badgeColor: 'bg-success-100 text-success-700 dark:bg-success-900/40 dark:text-success-300',
|
||||||
activeColor: 'bg-green-100 dark:bg-green-800/30',
|
|
||||||
activeBorder: 'border-green-500 dark:border-green-600',
|
|
||||||
},
|
},
|
||||||
high_volume: {
|
high_volume: {
|
||||||
label: 'High Volume',
|
label: 'High Volume',
|
||||||
description: 'Volume ≥ 10K searches/mo',
|
description: 'Volume ≥ 10K searches/mo',
|
||||||
icon: <ShootingStarIcon className="w-5 h-5" />,
|
icon: <ShootingStarIcon className="w-5 h-5" />,
|
||||||
color: 'text-blue-600 dark:text-blue-400',
|
accentColor: 'bg-brand-500',
|
||||||
bgColor: 'bg-blue-50 dark:bg-blue-900/20',
|
textColor: 'text-brand-600 dark:text-brand-400',
|
||||||
borderColor: 'border-blue-200 dark:border-blue-800',
|
badgeColor: 'bg-brand-100 text-brand-700 dark:bg-brand-900/40 dark:text-brand-300',
|
||||||
activeColor: 'bg-blue-100 dark:bg-blue-800/30',
|
|
||||||
activeBorder: 'border-blue-500 dark:border-blue-600',
|
|
||||||
},
|
},
|
||||||
premium_traffic: {
|
premium_traffic: {
|
||||||
label: 'Premium Traffic',
|
label: 'Premium Traffic',
|
||||||
description: 'High volume keywords',
|
description: 'High volume keywords',
|
||||||
icon: <BoxIcon className="w-5 h-5" />,
|
icon: <BoxIcon className="w-5 h-5" />,
|
||||||
color: 'text-amber-600 dark:text-amber-400',
|
accentColor: 'bg-warning-500',
|
||||||
bgColor: 'bg-amber-50 dark:bg-amber-900/20',
|
textColor: 'text-warning-600 dark:text-warning-400',
|
||||||
borderColor: 'border-amber-200 dark:border-amber-800',
|
badgeColor: 'bg-warning-100 text-warning-700 dark:bg-warning-900/40 dark:text-warning-300',
|
||||||
activeColor: 'bg-amber-100 dark:bg-amber-800/30',
|
|
||||||
activeBorder: 'border-amber-500 dark:border-amber-600',
|
|
||||||
showThreshold: true,
|
showThreshold: true,
|
||||||
thresholdLabel: 'Vol ≥',
|
thresholdPrefix: 'Vol ≥',
|
||||||
},
|
},
|
||||||
long_tail: {
|
long_tail: {
|
||||||
label: 'Long Tail',
|
label: 'Long Tail',
|
||||||
description: '4+ words with good volume',
|
description: '4+ words with good volume',
|
||||||
icon: <DocsIcon className="w-5 h-5" />,
|
icon: <DocsIcon className="w-5 h-5" />,
|
||||||
color: 'text-purple-600 dark:text-purple-400',
|
accentColor: 'bg-purple-500',
|
||||||
bgColor: 'bg-purple-50 dark:bg-purple-900/20',
|
textColor: 'text-purple-600 dark:text-purple-400',
|
||||||
borderColor: 'border-purple-200 dark:border-purple-800',
|
badgeColor: 'bg-purple-100 text-purple-700 dark:bg-purple-900/40 dark:text-purple-300',
|
||||||
activeColor: 'bg-purple-100 dark:bg-purple-800/30',
|
|
||||||
activeBorder: 'border-purple-500 dark:border-purple-600',
|
|
||||||
showThreshold: true,
|
showThreshold: true,
|
||||||
thresholdLabel: 'Vol >',
|
thresholdPrefix: 'Vol >',
|
||||||
},
|
},
|
||||||
quick_wins: {
|
quick_wins: {
|
||||||
label: 'Quick Wins',
|
label: 'Quick Wins',
|
||||||
description: 'Low difficulty, good volume',
|
description: 'Low difficulty, good volume',
|
||||||
icon: <BoltIcon className="w-5 h-5" />,
|
icon: <BoltIcon className="w-5 h-5" />,
|
||||||
color: 'text-emerald-600 dark:text-emerald-400',
|
accentColor: 'bg-emerald-500',
|
||||||
bgColor: 'bg-emerald-50 dark:bg-emerald-900/20',
|
textColor: 'text-emerald-600 dark:text-emerald-400',
|
||||||
borderColor: 'border-emerald-200 dark:border-emerald-800',
|
badgeColor: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300',
|
||||||
activeColor: 'bg-emerald-100 dark:bg-emerald-800/30',
|
|
||||||
activeBorder: 'border-emerald-500 dark:border-emerald-600',
|
|
||||||
showThreshold: true,
|
showThreshold: true,
|
||||||
thresholdLabel: 'Vol >',
|
thresholdPrefix: 'Vol >',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -138,79 +128,129 @@ export default function SectorMetricCard({
|
|||||||
stats,
|
stats,
|
||||||
isActive,
|
isActive,
|
||||||
onClick,
|
onClick,
|
||||||
|
onBulkAdd,
|
||||||
|
isAddedAction,
|
||||||
|
clickable = true,
|
||||||
sectorName,
|
sectorName,
|
||||||
compact = false,
|
compact = false,
|
||||||
}: SectorMetricCardProps) {
|
}: SectorMetricCardProps) {
|
||||||
const config = STAT_CONFIG[statType];
|
const config = STAT_CONFIG[statType];
|
||||||
const statData = stats[statType];
|
const statData = stats[statType];
|
||||||
|
|
||||||
// Build description with threshold if applicable
|
// Build description with detailed threshold copy (match Smart Suggestions tone)
|
||||||
const description = useMemo(() => {
|
const description = useMemo(() => {
|
||||||
if (config.showThreshold && statData.threshold) {
|
const threshold = statData.threshold;
|
||||||
return `${config.thresholdLabel} ${formatThreshold(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.description, config.showThreshold, config.thresholdPrefix, statData.threshold, statType]);
|
||||||
}, [config, statData.threshold]);
|
|
||||||
|
// 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 (
|
return (
|
||||||
<button
|
<div
|
||||||
onClick={onClick}
|
onClick={clickable ? onClick : undefined}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
'group relative flex flex-col items-start w-full rounded-xl border-2 transition-all duration-200',
|
'relative rounded-xl border bg-white dark:bg-white/[0.03] overflow-hidden',
|
||||||
'hover:shadow-md hover:scale-[1.02] active:scale-[0.98]',
|
'transition-all duration-200',
|
||||||
'focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-brand-500',
|
clickable
|
||||||
|
? 'cursor-pointer hover:shadow-lg hover:border-gray-300 dark:hover:border-gray-600'
|
||||||
|
: 'cursor-default',
|
||||||
compact ? 'p-3' : 'p-4',
|
compact ? 'p-3' : 'p-4',
|
||||||
isActive ? [config.activeColor, config.activeBorder] : [config.bgColor, config.borderColor],
|
isActive
|
||||||
|
? 'border-brand-500 shadow-sm ring-4 ring-brand-500/15'
|
||||||
|
: 'border-gray-200 dark:border-gray-800',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{/* Icon and Label Row */}
|
{/* Accent Border Left */}
|
||||||
<div className="flex items-center gap-2 mb-2">
|
<div className={clsx('absolute left-0 top-0 bottom-0 w-1', config.accentColor)} />
|
||||||
<div className={clsx(
|
|
||||||
'flex items-center justify-center rounded-lg',
|
|
||||||
compact ? 'w-8 h-8' : 'w-9 h-9',
|
|
||||||
config.color,
|
|
||||||
isActive ? 'bg-white/50 dark:bg-black/20' : 'bg-white/60 dark:bg-black/10',
|
|
||||||
)}>
|
|
||||||
{config.icon}
|
|
||||||
</div>
|
|
||||||
<span className={clsx(
|
|
||||||
'font-semibold',
|
|
||||||
compact ? 'text-sm' : 'text-base',
|
|
||||||
'text-gray-900 dark:text-white',
|
|
||||||
)}>
|
|
||||||
{config.label}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Count */}
|
{/* Header: Icon + Label + Count */}
|
||||||
<div className={clsx(
|
<div className="flex items-start justify-between gap-2">
|
||||||
'font-bold tabular-nums',
|
<div className="flex items-center gap-2">
|
||||||
compact ? 'text-2xl' : 'text-3xl',
|
<div className={clsx('p-2 rounded-lg', config.badgeColor, config.textColor)}>
|
||||||
config.color,
|
{config.icon}
|
||||||
)}>
|
</div>
|
||||||
{formatCount(statData.count)}
|
<span className={clsx('text-base font-semibold', config.textColor)}>
|
||||||
|
{config.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{isActive && (
|
||||||
|
<div className="w-2 h-2 rounded-full bg-brand-500 animate-pulse" />
|
||||||
|
)}
|
||||||
|
<div className={clsx('font-bold tabular-nums', compact ? 'text-2xl' : 'text-3xl', config.textColor)}>
|
||||||
|
{formatCount(statData.count)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Description / Threshold */}
|
{/* Description / Threshold */}
|
||||||
{!compact && (
|
<p className="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||||
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400 line-clamp-1">
|
{description}
|
||||||
{description}
|
</p>
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Active indicator */}
|
{/* Bulk Add Buttons - Always visible */}
|
||||||
{isActive && (
|
{onBulkAdd && statData.count > 0 && statType !== 'total' && (
|
||||||
<div className={clsx(
|
<div className="mt-3 pt-3 border-t border-gray-100 dark:border-gray-800">
|
||||||
'absolute top-2 right-2 w-2 h-2 rounded-full animate-pulse',
|
<div className="flex flex-wrap items-center gap-2 text-xs">
|
||||||
statType === 'total' ? 'bg-gray-500' :
|
<span className="inline-flex items-center gap-1 rounded-full bg-success-100 text-success-700 dark:bg-success-900/40 dark:text-success-300 px-2 py-0.5">
|
||||||
statType === 'available' ? 'bg-green-500' :
|
<PlusIcon className="w-3 h-3" />
|
||||||
statType === 'high_volume' ? 'bg-blue-500' :
|
Add to workflow
|
||||||
statType === 'premium_traffic' ? 'bg-amber-500' :
|
</span>
|
||||||
statType === 'long_tail' ? 'bg-purple-500' :
|
{bulkAddOptions.map((count) => {
|
||||||
'bg-emerald-500'
|
const isAdded = isAddedAction ? isAddedAction(statType, count) : false;
|
||||||
)} />
|
return (
|
||||||
|
<Button
|
||||||
|
key={count}
|
||||||
|
size="xs"
|
||||||
|
variant={isAdded ? 'outline' : 'primary'}
|
||||||
|
tone={isAdded ? 'success' : 'brand'}
|
||||||
|
startIcon={
|
||||||
|
isAdded ? <CheckCircleIcon className="w-3 h-3" /> : <PlusIcon className="w-3 h-3" />
|
||||||
|
}
|
||||||
|
onClick={() => handleAddClick(count)}
|
||||||
|
disabled={isAdded}
|
||||||
|
>
|
||||||
|
{isAdded ? 'Added' : (count === statData.count ? 'All' : count)}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</button>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -219,6 +259,9 @@ interface SectorMetricGridProps {
|
|||||||
stats: SectorStats | null;
|
stats: SectorStats | null;
|
||||||
activeStatType: StatType | null;
|
activeStatType: StatType | null;
|
||||||
onStatClick: (statType: StatType) => void;
|
onStatClick: (statType: StatType) => void;
|
||||||
|
onBulkAdd?: (statType: StatType, count: number) => void;
|
||||||
|
isAddedAction?: (statType: StatType, count: number) => boolean;
|
||||||
|
clickable?: boolean;
|
||||||
sectorName?: string;
|
sectorName?: string;
|
||||||
compact?: boolean;
|
compact?: boolean;
|
||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
@@ -228,27 +271,34 @@ export function SectorMetricGrid({
|
|||||||
stats,
|
stats,
|
||||||
activeStatType,
|
activeStatType,
|
||||||
onStatClick,
|
onStatClick,
|
||||||
|
onBulkAdd,
|
||||||
|
isAddedAction,
|
||||||
|
clickable = true,
|
||||||
sectorName,
|
sectorName,
|
||||||
compact = false,
|
compact = false,
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
}: SectorMetricGridProps) {
|
}: 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
|
// Loading skeleton
|
||||||
if (isLoading || !stats) {
|
if (isLoading || !stats) {
|
||||||
return (
|
return (
|
||||||
<div className={clsx(
|
<div className={clsx(
|
||||||
'grid gap-3',
|
'grid gap-4',
|
||||||
compact ? 'grid-cols-3 sm:grid-cols-6' : 'grid-cols-2 sm:grid-cols-3 lg:grid-cols-6',
|
compact ? 'grid-cols-3 sm:grid-cols-6' : 'grid-cols-2 sm:grid-cols-3 lg:grid-cols-6',
|
||||||
)}>
|
)}>
|
||||||
{statTypes.map((statType) => (
|
{statTypes.map((statType) => (
|
||||||
<div
|
<div
|
||||||
key={statType}
|
key={statType}
|
||||||
className="relative flex flex-col items-center justify-center rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-4 animate-pulse"
|
className="relative rounded-xl border border-gray-200 dark:border-gray-800 bg-white dark:bg-white/[0.03] p-4 animate-pulse overflow-hidden"
|
||||||
>
|
>
|
||||||
<div className="h-5 w-5 bg-gray-200 dark:bg-gray-700 rounded mb-2" />
|
<div className="absolute left-0 top-0 bottom-0 w-1 bg-gray-200 dark:bg-gray-700" />
|
||||||
<div className="h-8 w-16 bg-gray-200 dark:bg-gray-700 rounded mb-1" />
|
<div className="flex items-center gap-2 mb-2">
|
||||||
<div className="h-3 w-24 bg-gray-200 dark:bg-gray-700 rounded" />
|
<div className="h-6 w-6 bg-gray-200 dark:bg-gray-700 rounded" />
|
||||||
|
<div className="h-4 w-16 bg-gray-200 dark:bg-gray-700 rounded" />
|
||||||
|
</div>
|
||||||
|
<div className="h-7 w-12 bg-gray-200 dark:bg-gray-700 rounded mb-1" />
|
||||||
|
<div className="h-3 w-20 bg-gray-200 dark:bg-gray-700 rounded" />
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -257,7 +307,7 @@ export function SectorMetricGrid({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={clsx(
|
<div className={clsx(
|
||||||
'grid gap-3',
|
'grid gap-4',
|
||||||
compact ? 'grid-cols-3 sm:grid-cols-6' : 'grid-cols-2 sm:grid-cols-3 lg:grid-cols-6',
|
compact ? 'grid-cols-3 sm:grid-cols-6' : 'grid-cols-2 sm:grid-cols-3 lg:grid-cols-6',
|
||||||
)}>
|
)}>
|
||||||
{statTypes.map((statType) => (
|
{statTypes.map((statType) => (
|
||||||
@@ -267,6 +317,9 @@ export function SectorMetricGrid({
|
|||||||
stats={stats}
|
stats={stats}
|
||||||
isActive={activeStatType === statType}
|
isActive={activeStatType === statType}
|
||||||
onClick={() => onStatClick(statType)}
|
onClick={() => onStatClick(statType)}
|
||||||
|
onBulkAdd={onBulkAdd}
|
||||||
|
isAddedAction={isAddedAction}
|
||||||
|
clickable={clickable}
|
||||||
sectorName={sectorName}
|
sectorName={sectorName}
|
||||||
compact={compact}
|
compact={compact}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
/**
|
/**
|
||||||
* SmartSuggestions - Breathing indicator panel for Keywords Library
|
* SmartSuggestions - Compact card-based suggestions for Keywords Library
|
||||||
* Shows "Ready-to-use keywords waiting for you!" with animated indicator
|
* White background with accent colors like Dashboard cards
|
||||||
* Clicking navigates to pre-filtered keywords
|
* Shows keyword categories with counts and bulk add options
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import clsx from 'clsx';
|
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';
|
import Button from '../ui/button/Button';
|
||||||
|
|
||||||
interface SmartSuggestion {
|
interface SmartSuggestion {
|
||||||
@@ -27,64 +27,98 @@ interface SmartSuggestion {
|
|||||||
interface SmartSuggestionsProps {
|
interface SmartSuggestionsProps {
|
||||||
suggestions: SmartSuggestion[];
|
suggestions: SmartSuggestion[];
|
||||||
onSuggestionClick: (suggestion: SmartSuggestion) => void;
|
onSuggestionClick: (suggestion: SmartSuggestion) => void;
|
||||||
|
onBulkAdd?: (suggestion: SmartSuggestion, count: number) => void;
|
||||||
|
isAddedAction?: (suggestionId: string, count: number) => boolean;
|
||||||
|
enableFilterClick?: boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Color mappings
|
// Icon mapping
|
||||||
|
const SUGGESTION_ICONS: Record<string, React.ReactNode> = {
|
||||||
|
quick_wins: <BoltIcon className="w-5 h-5" />,
|
||||||
|
long_tail: <DocsIcon className="w-5 h-5" />,
|
||||||
|
premium_traffic: <BoxIcon className="w-5 h-5" />,
|
||||||
|
available: <CheckCircleIcon className="w-5 h-5" />,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Color mappings with white bg card style
|
||||||
const colorClasses = {
|
const colorClasses = {
|
||||||
blue: {
|
blue: {
|
||||||
bg: 'bg-blue-50 dark:bg-blue-900/20',
|
accent: 'bg-brand-500',
|
||||||
border: 'border-blue-200 dark:border-blue-800',
|
text: 'text-brand-600 dark:text-brand-400',
|
||||||
text: 'text-blue-600 dark:text-blue-400',
|
iconBg: 'bg-brand-100 dark:bg-brand-900/40',
|
||||||
badge: 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300',
|
|
||||||
},
|
},
|
||||||
green: {
|
green: {
|
||||||
bg: 'bg-green-50 dark:bg-green-900/20',
|
accent: 'bg-success-500',
|
||||||
border: 'border-green-200 dark:border-green-800',
|
text: 'text-success-600 dark:text-success-400',
|
||||||
text: 'text-green-600 dark:text-green-400',
|
iconBg: 'bg-success-100 dark:bg-success-900/40',
|
||||||
badge: 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300',
|
|
||||||
},
|
},
|
||||||
amber: {
|
amber: {
|
||||||
bg: 'bg-amber-50 dark:bg-amber-900/20',
|
accent: 'bg-warning-500',
|
||||||
border: 'border-amber-200 dark:border-amber-800',
|
text: 'text-warning-600 dark:text-warning-400',
|
||||||
text: 'text-amber-600 dark:text-amber-400',
|
iconBg: 'bg-warning-100 dark:bg-warning-900/40',
|
||||||
badge: 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300',
|
|
||||||
},
|
},
|
||||||
purple: {
|
purple: {
|
||||||
bg: 'bg-purple-50 dark:bg-purple-900/20',
|
accent: 'bg-purple-500',
|
||||||
border: 'border-purple-200 dark:border-purple-800',
|
|
||||||
text: 'text-purple-600 dark:text-purple-400',
|
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: {
|
emerald: {
|
||||||
bg: 'bg-emerald-50 dark:bg-emerald-900/20',
|
accent: 'bg-emerald-500',
|
||||||
border: 'border-emerald-200 dark:border-emerald-800',
|
|
||||||
text: 'text-emerald-600 dark:text-emerald-400',
|
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({
|
export default function SmartSuggestions({
|
||||||
suggestions,
|
suggestions,
|
||||||
onSuggestionClick,
|
onSuggestionClick,
|
||||||
|
onBulkAdd,
|
||||||
|
isAddedAction,
|
||||||
|
enableFilterClick = true,
|
||||||
className,
|
className,
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
}: SmartSuggestionsProps) {
|
}: SmartSuggestionsProps) {
|
||||||
const [isExpanded, setIsExpanded] = useState(true);
|
const [isExpanded, setIsExpanded] = useState(true);
|
||||||
|
|
||||||
const totalAvailable = suggestions.reduce((sum, s) => sum + s.count, 0);
|
const totalAvailable = suggestions.reduce((sum, s) => sum + s.count, 0);
|
||||||
const hasKeywords = totalAvailable > 0;
|
const hasKeywords = totalAvailable > 0;
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className={clsx(
|
<div className={clsx(
|
||||||
'rounded-xl border-2 border-dashed border-gray-200 dark:border-gray-700 p-6',
|
'rounded-xl border border-gray-200 dark:border-gray-800 bg-white dark:bg-white/[0.03] p-4',
|
||||||
className
|
className
|
||||||
)}>
|
)}>
|
||||||
<div className="flex items-center justify-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="w-5 h-5 rounded-full border-2 border-gray-300 border-t-transparent animate-spin" />
|
<div className="w-5 h-5 rounded-full border-2 border-gray-300 border-t-transparent animate-spin" />
|
||||||
<span className="text-gray-500 dark:text-gray-400">Loading suggestions...</span>
|
<span className="text-gray-500 dark:text-gray-400 text-sm">Loading suggestions...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show breathing indicator when no suggestions (waiting for data)
|
||||||
|
if (suggestions.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className={clsx(
|
||||||
|
'rounded-xl border border-gray-200 dark:border-gray-800 bg-white dark:bg-white/[0.03] p-4',
|
||||||
|
className
|
||||||
|
)}>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className={clsx(
|
||||||
|
'w-8 h-8 rounded-lg flex items-center justify-center',
|
||||||
|
'bg-gradient-to-br from-brand-500 to-purple-500'
|
||||||
|
)}>
|
||||||
|
<ShootingStarIcon className="w-4 h-4 text-white" />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{/* Breathing circle indicator */}
|
||||||
|
<div className="w-3 h-3 rounded-full bg-brand-500 animate-pulse" />
|
||||||
|
<span className="text-gray-600 dark:text-gray-400 text-sm">
|
||||||
|
Ready-to-use keywords waiting for you! Search for a keyword or apply any filter to see smart suggestions...
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -92,46 +126,35 @@ export default function SmartSuggestions({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={clsx(
|
<div className={clsx(
|
||||||
'rounded-xl border bg-gradient-to-br from-brand-50/50 to-purple-50/50 dark:from-brand-900/10 dark:to-purple-900/10',
|
'rounded-xl border border-gray-200 dark:border-gray-800 bg-white dark:bg-white/[0.03] overflow-hidden',
|
||||||
'border-brand-200 dark:border-brand-800',
|
|
||||||
className
|
className
|
||||||
)}>
|
)}>
|
||||||
{/* Header with breathing indicator */}
|
{/* Header */}
|
||||||
<button
|
<button
|
||||||
onClick={() => setIsExpanded(!isExpanded)}
|
onClick={() => setIsExpanded(!isExpanded)}
|
||||||
className="flex items-center justify-between w-full p-4 text-left hover:bg-brand-50/50 dark:hover:bg-brand-900/10 transition-colors rounded-t-xl"
|
className="flex items-center justify-between w-full p-4 text-left hover:bg-gray-50 dark:hover:bg-gray-800/50 transition-colors"
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
{/* Breathing indicator */}
|
{/* Icon with breathing animation */}
|
||||||
<div className="relative">
|
<div className={clsx(
|
||||||
<div className={clsx(
|
'w-8 h-8 rounded-lg flex items-center justify-center',
|
||||||
'w-10 h-10 rounded-full flex items-center justify-center',
|
'bg-gradient-to-br from-brand-500 to-purple-500',
|
||||||
'bg-gradient-to-br from-brand-400 to-purple-500',
|
hasKeywords && 'animate-pulse'
|
||||||
hasKeywords && 'animate-pulse'
|
)}>
|
||||||
)}>
|
<ShootingStarIcon className="w-4 h-4 text-white" />
|
||||||
<ShootingStarIcon className="w-5 h-5 text-white" />
|
|
||||||
</div>
|
|
||||||
{hasKeywords && (
|
|
||||||
<div className="absolute -top-1 -right-1 w-4 h-4 bg-emerald-500 rounded-full flex items-center justify-center animate-bounce">
|
|
||||||
<span className="text-[10px] font-bold text-white">!</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<h3 className="font-semibold text-gray-900 dark:text-white flex items-center gap-2">
|
<h3 className="font-semibold text-gray-900 dark:text-white text-sm flex items-center gap-2">
|
||||||
Smart Suggestions
|
Smart Suggestions
|
||||||
{hasKeywords && (
|
{hasKeywords && (
|
||||||
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300">
|
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300">
|
||||||
{totalAvailable} ready
|
{totalAvailable.toLocaleString()} ready
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||||
{hasKeywords
|
Ready-to-use keywords waiting for you!
|
||||||
? 'Ready-to-use keywords waiting for you!'
|
|
||||||
: 'No suggestions available for current filters'
|
|
||||||
}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -144,52 +167,98 @@ export default function SmartSuggestions({
|
|||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Expandable content */}
|
{/* Expandable content - Grid layout */}
|
||||||
{isExpanded && suggestions.length > 0 && (
|
{isExpanded && (
|
||||||
<div className="px-4 pb-4 space-y-2">
|
|
||||||
{suggestions.map((suggestion) => {
|
|
||||||
const colors = colorClasses[suggestion.color];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={suggestion.id}
|
|
||||||
onClick={() => onSuggestionClick(suggestion)}
|
|
||||||
className={clsx(
|
|
||||||
'flex items-center justify-between w-full p-3 rounded-lg border transition-all',
|
|
||||||
'hover:shadow-md hover:scale-[1.01] active:scale-[0.99]',
|
|
||||||
colors.bg,
|
|
||||||
colors.border,
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="flex-1 text-left">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className={clsx('font-medium text-sm', colors.text)}>
|
|
||||||
{suggestion.label}
|
|
||||||
</span>
|
|
||||||
<span className={clsx(
|
|
||||||
'px-2 py-0.5 rounded-full text-xs font-semibold',
|
|
||||||
colors.badge
|
|
||||||
)}>
|
|
||||||
{suggestion.count}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
|
|
||||||
{suggestion.description}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ArrowRightIcon className={clsx('w-4 h-4', colors.text)} />
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isExpanded && suggestions.length === 0 && (
|
|
||||||
<div className="px-4 pb-4">
|
<div className="px-4 pb-4">
|
||||||
<p className="text-sm text-gray-500 dark:text-gray-400 text-center py-4">
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
|
||||||
Select an industry and sector to see smart suggestions.
|
{suggestions.map((suggestion) => {
|
||||||
</p>
|
const colors = colorClasses[suggestion.color];
|
||||||
|
const icon = SUGGESTION_ICONS[suggestion.id] || <ShootingStarIcon className="w-4 h-4" />;
|
||||||
|
// Bulk add options
|
||||||
|
const bulkOptions: number[] = [];
|
||||||
|
if (suggestion.count >= 50) bulkOptions.push(50);
|
||||||
|
if (suggestion.count >= 100) bulkOptions.push(100);
|
||||||
|
if (suggestion.count >= 200) bulkOptions.push(200);
|
||||||
|
if (suggestion.count > 0 && suggestion.count <= 200) bulkOptions.push(suggestion.count);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={suggestion.id}
|
||||||
|
className={clsx(
|
||||||
|
'relative rounded-lg border bg-white dark:bg-white/[0.02] overflow-hidden',
|
||||||
|
'transition-all duration-200 cursor-pointer',
|
||||||
|
enableFilterClick
|
||||||
|
? 'cursor-pointer hover:shadow-md hover:border-gray-300 dark:hover:border-gray-600'
|
||||||
|
: 'cursor-default',
|
||||||
|
'border-gray-200 dark:border-gray-800',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* Accent border */}
|
||||||
|
<div className={clsx('absolute left-0 top-0 bottom-0 w-1', colors.accent)} />
|
||||||
|
|
||||||
|
{/* Main content - clickable to filter */}
|
||||||
|
<div
|
||||||
|
onClick={() => {
|
||||||
|
if (enableFilterClick) {
|
||||||
|
onSuggestionClick(suggestion);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="p-3 pl-4"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between gap-2 mb-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className={clsx('p-2 rounded', colors.iconBg, colors.text)}>
|
||||||
|
{icon}
|
||||||
|
</div>
|
||||||
|
<span className={clsx('font-semibold text-base', colors.text)}>
|
||||||
|
{suggestion.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-xl font-bold text-gray-900 dark:text-white tabular-nums">
|
||||||
|
{suggestion.count.toLocaleString()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400 line-clamp-1">
|
||||||
|
{suggestion.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bulk add buttons - always visible */}
|
||||||
|
{onBulkAdd && bulkOptions.length > 0 && (
|
||||||
|
<div className="px-3 pb-3 pt-0">
|
||||||
|
<div className="flex items-center gap-2 pt-2 border-t border-gray-100 dark:border-gray-800">
|
||||||
|
<span className="text-xs text-gray-500 dark:text-gray-400 flex items-center gap-1">
|
||||||
|
<PlusIcon className="w-3 h-3" />
|
||||||
|
Add
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2 pt-2">
|
||||||
|
{bulkOptions.map((count) => {
|
||||||
|
const isAdded = isAddedAction ? isAddedAction(suggestion.id, count) : false;
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
key={count}
|
||||||
|
size="xs"
|
||||||
|
variant={isAdded ? 'outline' : 'primary'}
|
||||||
|
tone={isAdded ? 'success' : 'brand'}
|
||||||
|
startIcon={
|
||||||
|
isAdded ? <CheckCircleIcon className="w-3 h-3" /> : <PlusIcon className="w-3 h-3" />
|
||||||
|
}
|
||||||
|
onClick={() => onBulkAdd(suggestion, count)}
|
||||||
|
disabled={isAdded}
|
||||||
|
>
|
||||||
|
{isAdded ? 'Added' : (count === suggestion.count ? 'All' : count)}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ export default function SeedKeywords() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageMeta title="Seed Keywords" />
|
<PageMeta title="Seed Keywords" description="Global keyword library for reference" />
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Seed Keywords</h1>
|
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Seed Keywords</h1>
|
||||||
<p className="text-gray-600 dark:text-gray-400 mt-1">Global keyword library for reference</p>
|
<p className="text-gray-600 dark:text-gray-400 mt-1">Global keyword library for reference</p>
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import {
|
|||||||
fetchKeywords,
|
fetchKeywords,
|
||||||
fetchSectorStats,
|
fetchSectorStats,
|
||||||
SectorStats,
|
SectorStats,
|
||||||
|
SectorStatsItem,
|
||||||
} from '../../services/api';
|
} from '../../services/api';
|
||||||
import Badge from '../../components/ui/badge/Badge';
|
import Badge from '../../components/ui/badge/Badge';
|
||||||
import { BoltIcon, ShootingStarIcon } from '../../icons';
|
import { BoltIcon, ShootingStarIcon } from '../../icons';
|
||||||
@@ -32,19 +33,23 @@ import { usePageSizeStore } from '../../store/pageSizeStore';
|
|||||||
import { getDifficultyNumber, getDifficultyRange, getDifficultyLabelFromNumber } from '../../utils/difficulty';
|
import { getDifficultyNumber, getDifficultyRange, getDifficultyLabelFromNumber } from '../../utils/difficulty';
|
||||||
import { useSiteStore } from '../../store/siteStore';
|
import { useSiteStore } from '../../store/siteStore';
|
||||||
import { useSectorStore } from '../../store/sectorStore';
|
import { useSectorStore } from '../../store/sectorStore';
|
||||||
|
import type { Sector } from '../../store/sectorStore';
|
||||||
import Button from '../../components/ui/button/Button';
|
import Button from '../../components/ui/button/Button';
|
||||||
import { Modal } from '../../components/ui/modal';
|
import { Modal } from '../../components/ui/modal';
|
||||||
import FileInput from '../../components/form/input/FileInput';
|
import FileInput from '../../components/form/input/FileInput';
|
||||||
import Label from '../../components/form/Label';
|
import Label from '../../components/form/Label';
|
||||||
|
import Input from '../../components/form/input/InputField';
|
||||||
import { Card } from '../../components/ui/card';
|
import { Card } from '../../components/ui/card';
|
||||||
import { SectorMetricGrid, StatType } from '../../components/keywords-library/SectorMetricCard';
|
import { SectorMetricGrid, StatType } from '../../components/keywords-library/SectorMetricCard';
|
||||||
import SmartSuggestions, { buildSmartSuggestions } from '../../components/keywords-library/SmartSuggestions';
|
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() {
|
export default function IndustriesSectorsKeywords() {
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
const { startLoading, stopLoading } = usePageLoading();
|
const { startLoading, stopLoading } = usePageLoading();
|
||||||
const { activeSite } = useSiteStore();
|
const { activeSite } = useSiteStore();
|
||||||
const { activeSector, loadSectorsForSite } = useSectorStore();
|
const { activeSector, loadSectorsForSite, sectors, setActiveSector } = useSectorStore();
|
||||||
const { pageSize } = usePageSizeStore();
|
const { pageSize } = usePageSizeStore();
|
||||||
|
|
||||||
// Data state
|
// Data state
|
||||||
@@ -54,9 +59,11 @@ export default function IndustriesSectorsKeywords() {
|
|||||||
const [selectedIds, setSelectedIds] = useState<string[]>([]);
|
const [selectedIds, setSelectedIds] = useState<string[]>([]);
|
||||||
// Track recently added keywords to preserve their state during reload
|
// Track recently added keywords to preserve their state during reload
|
||||||
const recentlyAddedRef = useRef<Set<number>>(new Set());
|
const recentlyAddedRef = useRef<Set<number>>(new Set());
|
||||||
|
const attachedSeedKeywordIdsRef = useRef<Set<number>>(new Set());
|
||||||
|
|
||||||
// Sector Stats state (for metric cards)
|
// Sector Stats state (for metric cards)
|
||||||
const [sectorStats, setSectorStats] = useState<SectorStats | null>(null);
|
const [sectorStats, setSectorStats] = useState<SectorStats | null>(null);
|
||||||
|
const [sectorStatsList, setSectorStatsList] = useState<SectorStatsItem[]>([]);
|
||||||
const [loadingSectorStats, setLoadingSectorStats] = useState(false);
|
const [loadingSectorStats, setLoadingSectorStats] = useState(false);
|
||||||
const [activeStatFilter, setActiveStatFilter] = useState<StatType | null>(null);
|
const [activeStatFilter, setActiveStatFilter] = useState<StatType | null>(null);
|
||||||
|
|
||||||
@@ -64,6 +71,10 @@ export default function IndustriesSectorsKeywords() {
|
|||||||
const [showBulkAddModal, setShowBulkAddModal] = useState(false);
|
const [showBulkAddModal, setShowBulkAddModal] = useState(false);
|
||||||
const [bulkAddKeywordIds, setBulkAddKeywordIds] = useState<number[]>([]);
|
const [bulkAddKeywordIds, setBulkAddKeywordIds] = useState<number[]>([]);
|
||||||
const [bulkAddStatLabel, setBulkAddStatLabel] = useState<string | undefined>();
|
const [bulkAddStatLabel, setBulkAddStatLabel] = useState<string | undefined>();
|
||||||
|
const [pendingBulkAddKey, setPendingBulkAddKey] = useState<string | null>(null);
|
||||||
|
const [pendingBulkAddGroup, setPendingBulkAddGroup] = useState<'stat' | 'suggestion' | null>(null);
|
||||||
|
const [addedStatActions, setAddedStatActions] = useState<Set<string>>(new Set());
|
||||||
|
const [addedSuggestionActions, setAddedSuggestionActions] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
// Ahrefs banner state
|
// Ahrefs banner state
|
||||||
const [showAhrefsBanner, setShowAhrefsBanner] = useState(true);
|
const [showAhrefsBanner, setShowAhrefsBanner] = useState(true);
|
||||||
@@ -82,6 +93,8 @@ export default function IndustriesSectorsKeywords() {
|
|||||||
const [countryFilter, setCountryFilter] = useState('');
|
const [countryFilter, setCountryFilter] = useState('');
|
||||||
const [difficultyFilter, setDifficultyFilter] = useState('');
|
const [difficultyFilter, setDifficultyFilter] = useState('');
|
||||||
const [showNotAddedOnly, setShowNotAddedOnly] = useState(false);
|
const [showNotAddedOnly, setShowNotAddedOnly] = useState(false);
|
||||||
|
const [volumeMin, setVolumeMin] = useState('');
|
||||||
|
const [volumeMax, setVolumeMax] = useState('');
|
||||||
|
|
||||||
// Keyword count tracking
|
// Keyword count tracking
|
||||||
const [addedCount, setAddedCount] = useState(0);
|
const [addedCount, setAddedCount] = useState(0);
|
||||||
@@ -176,6 +189,7 @@ export default function IndustriesSectorsKeywords() {
|
|||||||
const loadSectorStats = useCallback(async () => {
|
const loadSectorStats = useCallback(async () => {
|
||||||
if (!activeSite || !activeSite.industry) {
|
if (!activeSite || !activeSite.industry) {
|
||||||
setSectorStats(null);
|
setSectorStats(null);
|
||||||
|
setSectorStatsList([]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -183,15 +197,21 @@ export default function IndustriesSectorsKeywords() {
|
|||||||
try {
|
try {
|
||||||
const response = await fetchSectorStats({
|
const response = await fetchSectorStats({
|
||||||
industry_id: activeSite.industry,
|
industry_id: activeSite.industry,
|
||||||
sector_id: activeSector?.industry_sector ?? undefined,
|
|
||||||
site_id: activeSite.id,
|
site_id: activeSite.id,
|
||||||
});
|
});
|
||||||
|
|
||||||
// If sector-specific stats returned
|
const allSectors = response.sectors || [];
|
||||||
if (response.stats) {
|
setSectorStatsList(allSectors);
|
||||||
setSectorStats(response.stats as SectorStats);
|
|
||||||
} else if (response.sectors && response.sectors.length > 0) {
|
if (activeSector?.industry_sector) {
|
||||||
// Aggregate stats from all sectors
|
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 = {
|
const aggregated: SectorStats = {
|
||||||
total: { count: 0 },
|
total: { count: 0 },
|
||||||
available: { count: 0 },
|
available: { count: 0 },
|
||||||
@@ -200,15 +220,14 @@ export default function IndustriesSectorsKeywords() {
|
|||||||
long_tail: { count: 0, threshold: 1000 },
|
long_tail: { count: 0, threshold: 1000 },
|
||||||
quick_wins: { 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.total.count += sector.stats.total.count;
|
||||||
aggregated.available.count += sector.stats.available.count;
|
aggregated.available.count += sector.stats.available.count;
|
||||||
aggregated.high_volume.count += sector.stats.high_volume.count;
|
aggregated.high_volume.count += sector.stats.high_volume.count;
|
||||||
aggregated.premium_traffic.count += sector.stats.premium_traffic.count;
|
aggregated.premium_traffic.count += sector.stats.premium_traffic.count;
|
||||||
aggregated.long_tail.count += sector.stats.long_tail.count;
|
aggregated.long_tail.count += sector.stats.long_tail.count;
|
||||||
aggregated.quick_wins.count += sector.stats.quick_wins.count;
|
aggregated.quick_wins.count += sector.stats.quick_wins.count;
|
||||||
// Use first sector's thresholds (they should be consistent)
|
|
||||||
if (!aggregated.premium_traffic.threshold) {
|
if (!aggregated.premium_traffic.threshold) {
|
||||||
aggregated.premium_traffic.threshold = sector.stats.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;
|
aggregated.quick_wins.threshold = sector.stats.quick_wins.threshold;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
setSectorStats(aggregated);
|
setSectorStats(aggregated);
|
||||||
|
} else {
|
||||||
|
setSectorStats(null);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load sector stats:', error);
|
console.error('Failed to load sector stats:', error);
|
||||||
@@ -236,6 +257,26 @@ export default function IndustriesSectorsKeywords() {
|
|||||||
}
|
}
|
||||||
}, [activeSite, activeSector, loadSectorStats]);
|
}, [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
|
// Load counts on mount and when site/sector changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (activeSite) {
|
if (activeSite) {
|
||||||
@@ -283,6 +324,9 @@ export default function IndustriesSectorsKeywords() {
|
|||||||
console.warn('Could not fetch sectors or attached keywords:', err);
|
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
|
// Build API filters - use server-side pagination
|
||||||
const pageSizeNum = pageSize || 25;
|
const pageSizeNum = pageSize || 25;
|
||||||
const filters: any = {
|
const filters: any = {
|
||||||
@@ -298,6 +342,8 @@ export default function IndustriesSectorsKeywords() {
|
|||||||
|
|
||||||
if (searchTerm) filters.search = searchTerm;
|
if (searchTerm) filters.search = searchTerm;
|
||||||
if (countryFilter) filters.country = countryFilter;
|
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)
|
// Apply difficulty filter (if API supports it, otherwise we'll filter client-side)
|
||||||
if (difficultyFilter) {
|
if (difficultyFilter) {
|
||||||
@@ -361,7 +407,7 @@ export default function IndustriesSectorsKeywords() {
|
|||||||
setAvailableCount(0);
|
setAvailableCount(0);
|
||||||
setShowContent(true);
|
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)
|
// Load data when site/sector/filters change (show table by default per plan)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -381,6 +427,7 @@ export default function IndustriesSectorsKeywords() {
|
|||||||
// Reset to page 1 on pageSize change
|
// Reset to page 1 on pageSize change
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setCurrentPage(1);
|
setCurrentPage(1);
|
||||||
|
setSelectedIds([]);
|
||||||
}, [pageSize]);
|
}, [pageSize]);
|
||||||
|
|
||||||
// Handle sorting
|
// Handle sorting
|
||||||
@@ -388,6 +435,7 @@ export default function IndustriesSectorsKeywords() {
|
|||||||
setSortBy(field || 'keyword');
|
setSortBy(field || 'keyword');
|
||||||
setSortDirection(direction);
|
setSortDirection(direction);
|
||||||
setCurrentPage(1);
|
setCurrentPage(1);
|
||||||
|
setSelectedIds([]);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handle adding keywords to workflow
|
// Handle adding keywords to workflow
|
||||||
@@ -684,6 +732,50 @@ export default function IndustriesSectorsKeywords() {
|
|||||||
type: 'text' as const,
|
type: 'text' as const,
|
||||||
placeholder: 'Search keywords...',
|
placeholder: 'Search keywords...',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'sector',
|
||||||
|
label: 'Sector',
|
||||||
|
type: 'select' as const,
|
||||||
|
options: [
|
||||||
|
{ value: '', label: 'All Sectors' },
|
||||||
|
...sectors.map((sector) => ({
|
||||||
|
value: String(sector.id),
|
||||||
|
label: sector.name,
|
||||||
|
})),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'volume',
|
||||||
|
label: 'Volume',
|
||||||
|
type: 'custom' as const,
|
||||||
|
customRender: () => (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xs text-gray-500 dark:text-gray-400">Volume</span>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
placeholder="Min"
|
||||||
|
value={volumeMin}
|
||||||
|
onChange={(e) => {
|
||||||
|
setVolumeMin(e.target.value);
|
||||||
|
setCurrentPage(1);
|
||||||
|
setSelectedIds([]);
|
||||||
|
}}
|
||||||
|
className="w-20 h-8"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
placeholder="Max"
|
||||||
|
value={volumeMax}
|
||||||
|
onChange={(e) => {
|
||||||
|
setVolumeMax(e.target.value);
|
||||||
|
setCurrentPage(1);
|
||||||
|
setSelectedIds([]);
|
||||||
|
}}
|
||||||
|
className="w-20 h-8"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: 'country',
|
key: 'country',
|
||||||
label: 'Country',
|
label: 'Country',
|
||||||
@@ -730,7 +822,7 @@ export default function IndustriesSectorsKeywords() {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
}, [activeSector, handleAddToWorkflow]);
|
}, [activeSector, handleAddToWorkflow, sectors]);
|
||||||
|
|
||||||
// Build smart suggestions from sector stats
|
// Build smart suggestions from sector stats
|
||||||
const smartSuggestions = useMemo(() => {
|
const smartSuggestions = useMemo(() => {
|
||||||
@@ -738,6 +830,407 @@ export default function IndustriesSectorsKeywords() {
|
|||||||
return buildSmartSuggestions(sectorStats, { showOnlyWithResults: true });
|
return buildSmartSuggestions(sectorStats, { showOnlyWithResults: true });
|
||||||
}, [sectorStats]);
|
}, [sectorStats]);
|
||||||
|
|
||||||
|
// Helper: word count for keyword string
|
||||||
|
const getWordCount = useCallback((keyword: string) => {
|
||||||
|
return keyword.trim().split(/\s+/).filter(Boolean).length;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const buildStatActionKey = useCallback((statType: StatType, count: number) => {
|
||||||
|
return `${statType}:${count}`;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const buildSuggestionActionKey = useCallback((suggestionId: string, count: number) => {
|
||||||
|
return `${suggestionId}:${count}`;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const isStatActionAdded = useCallback((statType: StatType, count: number) => {
|
||||||
|
return addedStatActions.has(buildStatActionKey(statType, count));
|
||||||
|
}, [addedStatActions, buildStatActionKey]);
|
||||||
|
|
||||||
|
const isSuggestionActionAdded = useCallback((suggestionId: string, count: number) => {
|
||||||
|
return addedSuggestionActions.has(buildSuggestionActionKey(suggestionId, count));
|
||||||
|
}, [addedSuggestionActions, buildSuggestionActionKey]);
|
||||||
|
|
||||||
|
const fetchBulkKeywords = useCallback(async (options: {
|
||||||
|
ordering: string;
|
||||||
|
difficultyMax?: number;
|
||||||
|
minVolume?: number;
|
||||||
|
longTail?: boolean;
|
||||||
|
availableOnly?: boolean;
|
||||||
|
count: number;
|
||||||
|
}) => {
|
||||||
|
if (!activeSite || !activeSector?.industry_sector) {
|
||||||
|
throw new Error('Please select a site and sector first');
|
||||||
|
}
|
||||||
|
|
||||||
|
const { ordering, difficultyMax, minVolume, longTail, availableOnly, count } = options;
|
||||||
|
const pageSize = Math.max(500, count * 2);
|
||||||
|
|
||||||
|
const filters: any = {
|
||||||
|
industry: activeSite.industry,
|
||||||
|
sector: activeSector.industry_sector,
|
||||||
|
page_size: pageSize,
|
||||||
|
ordering,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (difficultyMax !== undefined) {
|
||||||
|
filters.difficulty_max = difficultyMax;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetchKeywordsLibrary(filters);
|
||||||
|
let results = response.results || [];
|
||||||
|
|
||||||
|
if (minVolume !== undefined) {
|
||||||
|
results = results.filter((kw) => (kw.volume || 0) >= minVolume);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (longTail) {
|
||||||
|
results = results.filter((kw) => getWordCount(kw.keyword) >= 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (availableOnly) {
|
||||||
|
results = results.filter((kw) => !attachedSeedKeywordIdsRef.current.has(Number(kw.id)));
|
||||||
|
}
|
||||||
|
|
||||||
|
const topIds = results.slice(0, count).map((kw) => kw.id);
|
||||||
|
return topIds;
|
||||||
|
}, [activeSite, activeSector, getWordCount]);
|
||||||
|
|
||||||
|
const prepareBulkAdd = useCallback(async (payload: {
|
||||||
|
label: string;
|
||||||
|
ids: number[];
|
||||||
|
actionKey: string;
|
||||||
|
group: 'stat' | 'suggestion';
|
||||||
|
}) => {
|
||||||
|
if (payload.ids.length === 0) {
|
||||||
|
toast.error('No matching keywords found for this selection');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setBulkAddKeywordIds(payload.ids);
|
||||||
|
setBulkAddStatLabel(payload.label);
|
||||||
|
setPendingBulkAddKey(payload.actionKey);
|
||||||
|
setPendingBulkAddGroup(payload.group);
|
||||||
|
setShowBulkAddModal(true);
|
||||||
|
}, [toast]);
|
||||||
|
|
||||||
|
// Handle stat card click - filters table to show matching keywords
|
||||||
|
const handleStatClick = useCallback((statType: StatType) => {
|
||||||
|
// Toggle off if clicking same stat
|
||||||
|
if (activeStatFilter === statType) {
|
||||||
|
setActiveStatFilter(null);
|
||||||
|
setShowNotAddedOnly(false);
|
||||||
|
setDifficultyFilter('');
|
||||||
|
setVolumeMin('');
|
||||||
|
setVolumeMax('');
|
||||||
|
setSelectedIds([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setActiveStatFilter(statType);
|
||||||
|
setCurrentPage(1);
|
||||||
|
setSelectedIds([]);
|
||||||
|
|
||||||
|
const statThresholds = {
|
||||||
|
highVolume: sectorStats?.high_volume.threshold ?? 10000,
|
||||||
|
premium: sectorStats?.premium_traffic.threshold ?? 50000,
|
||||||
|
longTail: sectorStats?.long_tail.threshold ?? 1000,
|
||||||
|
quickWins: sectorStats?.quick_wins.threshold ?? 1000,
|
||||||
|
};
|
||||||
|
|
||||||
|
switch (statType) {
|
||||||
|
case 'available':
|
||||||
|
setShowNotAddedOnly(true);
|
||||||
|
setDifficultyFilter('');
|
||||||
|
setVolumeMin('');
|
||||||
|
setVolumeMax('');
|
||||||
|
break;
|
||||||
|
case 'high_volume':
|
||||||
|
setShowNotAddedOnly(false);
|
||||||
|
setDifficultyFilter('');
|
||||||
|
setVolumeMin(String(statThresholds.highVolume));
|
||||||
|
setVolumeMax('');
|
||||||
|
setSortBy('volume');
|
||||||
|
setSortDirection('desc');
|
||||||
|
break;
|
||||||
|
case 'premium_traffic':
|
||||||
|
setShowNotAddedOnly(false);
|
||||||
|
setDifficultyFilter('');
|
||||||
|
setVolumeMin(String(statThresholds.premium));
|
||||||
|
setVolumeMax('');
|
||||||
|
setSortBy('volume');
|
||||||
|
setSortDirection('desc');
|
||||||
|
break;
|
||||||
|
case 'long_tail':
|
||||||
|
setShowNotAddedOnly(false);
|
||||||
|
setDifficultyFilter('');
|
||||||
|
setVolumeMin(String(statThresholds.longTail));
|
||||||
|
setVolumeMax('');
|
||||||
|
setSortBy('keyword');
|
||||||
|
setSortDirection('asc');
|
||||||
|
break;
|
||||||
|
case 'quick_wins':
|
||||||
|
setShowNotAddedOnly(true);
|
||||||
|
setDifficultyFilter('1');
|
||||||
|
setVolumeMin(String(statThresholds.quickWins));
|
||||||
|
setVolumeMax('');
|
||||||
|
setSortBy('difficulty');
|
||||||
|
setSortDirection('asc');
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
setShowNotAddedOnly(false);
|
||||||
|
setDifficultyFilter('');
|
||||||
|
setVolumeMin('');
|
||||||
|
setVolumeMax('');
|
||||||
|
}
|
||||||
|
}, [activeStatFilter, sectorStats]);
|
||||||
|
|
||||||
|
const handleSuggestionClick = useCallback((suggestion: any) => {
|
||||||
|
setActiveStatFilter(null);
|
||||||
|
setCurrentPage(1);
|
||||||
|
setSelectedIds([]);
|
||||||
|
|
||||||
|
const difficultyMax = suggestion.filterParams?.difficulty_max;
|
||||||
|
const volumeMinValue = suggestion.filterParams?.volume_min;
|
||||||
|
const availableOnly = Boolean(suggestion.filterParams?.available_only);
|
||||||
|
|
||||||
|
if (availableOnly) {
|
||||||
|
setShowNotAddedOnly(true);
|
||||||
|
} else {
|
||||||
|
setShowNotAddedOnly(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (volumeMinValue) {
|
||||||
|
setVolumeMin(String(volumeMinValue));
|
||||||
|
} else {
|
||||||
|
setVolumeMin('');
|
||||||
|
}
|
||||||
|
setVolumeMax('');
|
||||||
|
|
||||||
|
if (difficultyMax !== undefined) {
|
||||||
|
if (difficultyMax <= 20) {
|
||||||
|
setDifficultyFilter('1');
|
||||||
|
} else if (difficultyMax <= 40) {
|
||||||
|
setDifficultyFilter('2');
|
||||||
|
} else {
|
||||||
|
setDifficultyFilter('');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setDifficultyFilter('');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (suggestion.id === 'quick_wins') {
|
||||||
|
setSortBy('difficulty');
|
||||||
|
setSortDirection('asc');
|
||||||
|
} else {
|
||||||
|
setSortBy('volume');
|
||||||
|
setSortDirection('desc');
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Handle sector card click
|
||||||
|
const handleSectorSelect = useCallback((sector: Sector | null) => {
|
||||||
|
setActiveStatFilter(null);
|
||||||
|
setSelectedIds([]);
|
||||||
|
setCurrentPage(1);
|
||||||
|
if (!sector) {
|
||||||
|
setActiveSector(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setActiveSector(sector);
|
||||||
|
}, [setActiveSector]);
|
||||||
|
|
||||||
|
// Handle bulk add from metric cards
|
||||||
|
const handleMetricBulkAdd = useCallback(async (statType: StatType, count: number) => {
|
||||||
|
if (!activeSite || !activeSector?.industry_sector) {
|
||||||
|
toast.error('Please select a site and sector first');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const actionKey = buildStatActionKey(statType, count);
|
||||||
|
if (addedStatActions.has(actionKey)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const statLabelMap: Record<StatType, string> = {
|
||||||
|
total: 'Total Keywords',
|
||||||
|
available: 'Available Keywords',
|
||||||
|
high_volume: 'High Volume',
|
||||||
|
premium_traffic: 'Premium Traffic',
|
||||||
|
long_tail: 'Long Tail',
|
||||||
|
quick_wins: 'Quick Wins',
|
||||||
|
};
|
||||||
|
|
||||||
|
const thresholdMap = {
|
||||||
|
high_volume: sectorStats?.high_volume.threshold ?? 10000,
|
||||||
|
premium_traffic: sectorStats?.premium_traffic.threshold ?? 50000,
|
||||||
|
long_tail: sectorStats?.long_tail.threshold ?? 1000,
|
||||||
|
quick_wins: sectorStats?.quick_wins.threshold ?? 1000,
|
||||||
|
};
|
||||||
|
|
||||||
|
let ids: number[] = [];
|
||||||
|
if (statType === 'quick_wins') {
|
||||||
|
ids = await fetchBulkKeywords({
|
||||||
|
ordering: 'difficulty',
|
||||||
|
difficultyMax: 20,
|
||||||
|
minVolume: thresholdMap.quick_wins,
|
||||||
|
availableOnly: true,
|
||||||
|
count,
|
||||||
|
});
|
||||||
|
} else if (statType === 'long_tail') {
|
||||||
|
ids = await fetchBulkKeywords({
|
||||||
|
ordering: '-volume',
|
||||||
|
minVolume: thresholdMap.long_tail,
|
||||||
|
longTail: true,
|
||||||
|
availableOnly: true,
|
||||||
|
count,
|
||||||
|
});
|
||||||
|
} else if (statType === 'premium_traffic') {
|
||||||
|
ids = await fetchBulkKeywords({
|
||||||
|
ordering: '-volume',
|
||||||
|
minVolume: thresholdMap.premium_traffic,
|
||||||
|
availableOnly: true,
|
||||||
|
count,
|
||||||
|
});
|
||||||
|
} else if (statType === 'high_volume') {
|
||||||
|
ids = await fetchBulkKeywords({
|
||||||
|
ordering: '-volume',
|
||||||
|
minVolume: thresholdMap.high_volume,
|
||||||
|
availableOnly: true,
|
||||||
|
count,
|
||||||
|
});
|
||||||
|
} else if (statType === 'available') {
|
||||||
|
ids = await fetchBulkKeywords({
|
||||||
|
ordering: '-volume',
|
||||||
|
availableOnly: true,
|
||||||
|
count,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
ids = await fetchBulkKeywords({
|
||||||
|
ordering: '-volume',
|
||||||
|
availableOnly: true,
|
||||||
|
count,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await prepareBulkAdd({
|
||||||
|
label: statLabelMap[statType],
|
||||||
|
ids,
|
||||||
|
actionKey,
|
||||||
|
group: 'stat',
|
||||||
|
});
|
||||||
|
}, [activeSite, activeSector, addedStatActions, buildStatActionKey, fetchBulkKeywords, prepareBulkAdd, sectorStats, toast]);
|
||||||
|
|
||||||
|
// Handle bulk add from suggestions
|
||||||
|
const handleSuggestionBulkAdd = useCallback(async (suggestion: any, count: number) => {
|
||||||
|
if (!activeSite || !activeSector?.industry_sector) {
|
||||||
|
toast.error('Please select a site and sector first');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const actionKey = buildSuggestionActionKey(suggestion.id, count);
|
||||||
|
if (addedSuggestionActions.has(actionKey)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const suggestionThresholds = {
|
||||||
|
quick_wins: sectorStats?.quick_wins.threshold ?? 1000,
|
||||||
|
long_tail: sectorStats?.long_tail.threshold ?? 1000,
|
||||||
|
premium_traffic: sectorStats?.premium_traffic.threshold ?? 50000,
|
||||||
|
};
|
||||||
|
|
||||||
|
let ids: number[] = [];
|
||||||
|
if (suggestion.id === 'quick_wins') {
|
||||||
|
ids = await fetchBulkKeywords({
|
||||||
|
ordering: 'difficulty',
|
||||||
|
difficultyMax: 20,
|
||||||
|
minVolume: suggestionThresholds.quick_wins,
|
||||||
|
availableOnly: true,
|
||||||
|
count,
|
||||||
|
});
|
||||||
|
} else if (suggestion.id === 'long_tail') {
|
||||||
|
ids = await fetchBulkKeywords({
|
||||||
|
ordering: '-volume',
|
||||||
|
minVolume: suggestionThresholds.long_tail,
|
||||||
|
longTail: true,
|
||||||
|
availableOnly: true,
|
||||||
|
count,
|
||||||
|
});
|
||||||
|
} else if (suggestion.id === 'premium_traffic') {
|
||||||
|
ids = await fetchBulkKeywords({
|
||||||
|
ordering: '-volume',
|
||||||
|
minVolume: suggestionThresholds.premium_traffic,
|
||||||
|
availableOnly: true,
|
||||||
|
count,
|
||||||
|
});
|
||||||
|
} else if (suggestion.id === 'available') {
|
||||||
|
ids = await fetchBulkKeywords({
|
||||||
|
ordering: '-volume',
|
||||||
|
availableOnly: true,
|
||||||
|
count,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
ids = await fetchBulkKeywords({
|
||||||
|
ordering: '-volume',
|
||||||
|
availableOnly: true,
|
||||||
|
count,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await prepareBulkAdd({
|
||||||
|
label: suggestion.label,
|
||||||
|
ids,
|
||||||
|
actionKey,
|
||||||
|
group: 'suggestion',
|
||||||
|
});
|
||||||
|
}, [activeSite, activeSector, addedSuggestionActions, buildSuggestionActionKey, fetchBulkKeywords, prepareBulkAdd, sectorStats, toast]);
|
||||||
|
|
||||||
|
const handleConfirmBulkAdd = useCallback(async () => {
|
||||||
|
if (!activeSite || !activeSector) {
|
||||||
|
throw new Error('Please select a site and sector first');
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await addSeedKeywordsToWorkflow(
|
||||||
|
bulkAddKeywordIds,
|
||||||
|
activeSite.id,
|
||||||
|
activeSector.id
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
const errorMsg = result.errors?.[0] || 'Unable to add keywords. Please try again.';
|
||||||
|
throw new Error(errorMsg);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.created > 0) {
|
||||||
|
bulkAddKeywordIds.forEach((id) => recentlyAddedRef.current.add(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pendingBulkAddKey) {
|
||||||
|
if (pendingBulkAddGroup === 'stat') {
|
||||||
|
setAddedStatActions((prev) => new Set([...prev, pendingBulkAddKey]));
|
||||||
|
}
|
||||||
|
if (pendingBulkAddGroup === 'suggestion') {
|
||||||
|
setAddedSuggestionActions((prev) => new Set([...prev, pendingBulkAddKey]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setPendingBulkAddKey(null);
|
||||||
|
setPendingBulkAddGroup(null);
|
||||||
|
setShowBulkAddModal(false);
|
||||||
|
|
||||||
|
if (activeSite) {
|
||||||
|
loadSeedKeywords();
|
||||||
|
loadKeywordCounts();
|
||||||
|
loadSectorStats();
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
added: result.created || 0,
|
||||||
|
skipped: result.skipped || 0,
|
||||||
|
total_requested: bulkAddKeywordIds.length,
|
||||||
|
};
|
||||||
|
}, [activeSite, activeSector, bulkAddKeywordIds, loadKeywordCounts, loadSectorStats, loadSeedKeywords, pendingBulkAddGroup, pendingBulkAddKey]);
|
||||||
|
|
||||||
// Show WorkflowGuide if no sites
|
// Show WorkflowGuide if no sites
|
||||||
if (sites.length === 0) {
|
if (sites.length === 0) {
|
||||||
return (
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageMeta title="Keywords Library" description="Browse curated keywords and add them to your workflow. Ahrefs research coming soon." />
|
<PageMeta title="Keywords Library" description="Browse curated keywords and add them to your workflow. Ahrefs research coming soon." />
|
||||||
@@ -816,66 +1256,49 @@ export default function IndustriesSectorsKeywords() {
|
|||||||
badge={{ icon: <BoltIcon />, color: 'blue' }}
|
badge={{ icon: <BoltIcon />, color: 'blue' }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Sector Cards - Top of page */}
|
||||||
|
{activeSite && sectors.length > 0 && (
|
||||||
|
<div className="mx-6 mt-6">
|
||||||
|
<SectorCardsGrid
|
||||||
|
sectors={sectors}
|
||||||
|
sectorStats={sectorStatsList}
|
||||||
|
activeSectorId={activeSector?.id}
|
||||||
|
onSelectSector={handleSectorSelect}
|
||||||
|
isLoading={loadingSectorStats}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Sector Metric Cards - Show when site is selected */}
|
{/* Sector Metric Cards - Show when site is selected */}
|
||||||
{activeSite && (
|
{activeSite && (
|
||||||
<div className="mx-6 mt-6">
|
<div className="mx-6 mt-6">
|
||||||
<div className="mb-4">
|
|
||||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-1">
|
|
||||||
Keyword Stats {activeSector ? `— ${activeSector.name}` : '— All Sectors'}
|
|
||||||
</h3>
|
|
||||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
|
||||||
Click a card to filter the table below
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<SectorMetricGrid
|
<SectorMetricGrid
|
||||||
stats={sectorStats}
|
stats={sectorStats}
|
||||||
activeStatType={activeStatFilter}
|
activeStatType={activeStatFilter}
|
||||||
onStatClick={handleStatClick}
|
onStatClick={handleStatClick}
|
||||||
|
onBulkAdd={activeSector ? handleMetricBulkAdd : undefined}
|
||||||
|
isAddedAction={isStatActionAdded}
|
||||||
|
clickable={true}
|
||||||
sectorName={activeSector?.name}
|
sectorName={activeSector?.name}
|
||||||
isLoading={loadingSectorStats}
|
isLoading={loadingSectorStats}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Smart Suggestions Panel */}
|
{/* Smart Suggestions Panel - Always show when site is selected */}
|
||||||
{activeSite && sectorStats && smartSuggestions.length > 0 && (
|
{activeSite && sectorStats && (
|
||||||
<div className="mx-6 mt-6">
|
<div className="mx-6 mt-6">
|
||||||
<SmartSuggestions
|
<SmartSuggestions
|
||||||
suggestions={smartSuggestions}
|
suggestions={smartSuggestions}
|
||||||
onSuggestionClick={(suggestion) => {
|
onSuggestionClick={handleSuggestionClick}
|
||||||
// Apply the filter from the suggestion
|
onBulkAdd={activeSector ? handleSuggestionBulkAdd : undefined}
|
||||||
if (suggestion.filterParams.statType) {
|
isAddedAction={isSuggestionActionAdded}
|
||||||
handleStatClick(suggestion.filterParams.statType as StatType);
|
enableFilterClick={true}
|
||||||
}
|
|
||||||
}}
|
|
||||||
isLoading={loadingSectorStats}
|
isLoading={loadingSectorStats}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Show info banner when no sector is selected */}
|
|
||||||
{!activeSector && activeSite && (
|
|
||||||
<div className="mx-6 mt-6 mb-4">
|
|
||||||
<div className="bg-brand-50 dark:bg-brand-900/20 border border-brand-200 dark:border-brand-800 rounded-lg p-4">
|
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
<div className="flex-shrink-0">
|
|
||||||
<svg className="w-5 h-5 text-brand-600 dark:text-brand-400 mt-0.5" fill="currentColor" viewBox="0 0 20 20">
|
|
||||||
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clipRule="evenodd" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div className="flex-1">
|
|
||||||
<h3 className="text-sm font-medium text-brand-900 dark:text-brand-200">
|
|
||||||
Choose a Topic Area First
|
|
||||||
</h3>
|
|
||||||
<p className="mt-1 text-sm text-brand-700 dark:text-brand-300">
|
|
||||||
Pick a topic area first, then add keywords - You need to choose what you're writing about before adding search terms to target
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Keywords Table - Shown by default (per plan) */}
|
{/* Keywords Table - Shown by default (per plan) */}
|
||||||
{activeSite && (
|
{activeSite && (
|
||||||
<TablePageTemplate
|
<TablePageTemplate
|
||||||
@@ -883,9 +1306,14 @@ export default function IndustriesSectorsKeywords() {
|
|||||||
data={seedKeywords}
|
data={seedKeywords}
|
||||||
loading={!showContent}
|
loading={!showContent}
|
||||||
showContent={showContent}
|
showContent={showContent}
|
||||||
|
defaultShowFilters={true}
|
||||||
|
centerFilters={true}
|
||||||
filters={pageConfig.filters}
|
filters={pageConfig.filters}
|
||||||
filterValues={{
|
filterValues={{
|
||||||
|
sector: activeSector?.id ? String(activeSector.id) : '',
|
||||||
search: searchTerm,
|
search: searchTerm,
|
||||||
|
volume_min: volumeMin,
|
||||||
|
volume_max: volumeMax,
|
||||||
country: countryFilter,
|
country: countryFilter,
|
||||||
difficulty: difficultyFilter,
|
difficulty: difficultyFilter,
|
||||||
showNotAddedOnly: showNotAddedOnly ? 'true' : '',
|
showNotAddedOnly: showNotAddedOnly ? 'true' : '',
|
||||||
@@ -921,19 +1349,49 @@ export default function IndustriesSectorsKeywords() {
|
|||||||
onFilterChange={(key, value) => {
|
onFilterChange={(key, value) => {
|
||||||
const stringValue = value === null || value === undefined ? '' : String(value);
|
const stringValue = value === null || value === undefined ? '' : String(value);
|
||||||
|
|
||||||
|
if (activeStatFilter) {
|
||||||
|
setActiveStatFilter(null);
|
||||||
|
}
|
||||||
|
|
||||||
if (key === 'search') {
|
if (key === 'search') {
|
||||||
setSearchTerm(stringValue);
|
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') {
|
} else if (key === 'country') {
|
||||||
setCountryFilter(stringValue);
|
setCountryFilter(stringValue);
|
||||||
setCurrentPage(1);
|
setCurrentPage(1);
|
||||||
|
setSelectedIds([]);
|
||||||
} else if (key === 'difficulty') {
|
} else if (key === 'difficulty') {
|
||||||
setDifficultyFilter(stringValue);
|
setDifficultyFilter(stringValue);
|
||||||
setCurrentPage(1);
|
setCurrentPage(1);
|
||||||
|
setSelectedIds([]);
|
||||||
} else if (key === 'showNotAddedOnly') {
|
} else if (key === 'showNotAddedOnly') {
|
||||||
setShowNotAddedOnly(stringValue === 'true');
|
setShowNotAddedOnly(stringValue === 'true');
|
||||||
setCurrentPage(1);
|
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[]) => {
|
onBulkAction={async (actionKey: string, ids: string[]) => {
|
||||||
if (actionKey === 'add_selected_to_workflow') {
|
if (actionKey === 'add_selected_to_workflow') {
|
||||||
await handleBulkAddSelected(ids);
|
await handleBulkAddSelected(ids);
|
||||||
@@ -1045,6 +1503,22 @@ export default function IndustriesSectorsKeywords() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
{/* Bulk Add Confirmation */}
|
||||||
|
<BulkAddConfirmation
|
||||||
|
isOpen={showBulkAddModal}
|
||||||
|
onClose={() => {
|
||||||
|
setShowBulkAddModal(false);
|
||||||
|
setBulkAddKeywordIds([]);
|
||||||
|
setBulkAddStatLabel(undefined);
|
||||||
|
setPendingBulkAddKey(null);
|
||||||
|
setPendingBulkAddGroup(null);
|
||||||
|
}}
|
||||||
|
onConfirm={handleConfirmBulkAdd}
|
||||||
|
keywordCount={bulkAddKeywordIds.length}
|
||||||
|
sectorName={activeSector?.name}
|
||||||
|
statTypeLabel={bulkAddStatLabel}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2281,6 +2281,8 @@ export async function fetchKeywordsLibrary(filters?: {
|
|||||||
ordering?: string;
|
ordering?: string;
|
||||||
difficulty_min?: number;
|
difficulty_min?: number;
|
||||||
difficulty_max?: number;
|
difficulty_max?: number;
|
||||||
|
volume_min?: number;
|
||||||
|
volume_max?: number;
|
||||||
}): Promise<SeedKeywordResponse> {
|
}): Promise<SeedKeywordResponse> {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
// Use industry_id and sector_id as per backend get_queryset, but also try industry/sector for filterset_fields
|
// 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
|
// Difficulty range filtering
|
||||||
if (filters?.difficulty_min !== undefined) params.append('difficulty_min', filters.difficulty_min.toString());
|
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());
|
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();
|
const queryString = params.toString();
|
||||||
return fetchAPI(`/v1/auth/keywords-library/${queryString ? `?${queryString}` : ''}`);
|
return fetchAPI(`/v1/auth/keywords-library/${queryString ? `?${queryString}` : ''}`);
|
||||||
|
|||||||
@@ -184,6 +184,81 @@
|
|||||||
SECTION 3: TAILWIND CONFIGURATION
|
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
|
🚫 TAILWIND DEFAULT COLORS ARE DISABLED
|
||||||
|
|
||||||
|
|||||||
@@ -183,6 +183,9 @@ interface TablePageTemplateProps {
|
|||||||
getRowClassName?: (row: any) => string;
|
getRowClassName?: (row: any) => string;
|
||||||
// Custom checkbox column width (default: w-12 = 48px)
|
// Custom checkbox column width (default: w-12 = 48px)
|
||||||
checkboxColumnWidth?: string;
|
checkboxColumnWidth?: string;
|
||||||
|
// Filter bar behavior
|
||||||
|
defaultShowFilters?: boolean;
|
||||||
|
centerFilters?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function TablePageTemplate({
|
export default function TablePageTemplate({
|
||||||
@@ -222,6 +225,8 @@ export default function TablePageTemplate({
|
|||||||
primaryAction,
|
primaryAction,
|
||||||
getRowClassName,
|
getRowClassName,
|
||||||
checkboxColumnWidth = '48px',
|
checkboxColumnWidth = '48px',
|
||||||
|
defaultShowFilters = false,
|
||||||
|
centerFilters = false,
|
||||||
}: TablePageTemplateProps) {
|
}: TablePageTemplateProps) {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const [isBulkActionsDropdownOpen, setIsBulkActionsDropdownOpen] = useState(false);
|
const [isBulkActionsDropdownOpen, setIsBulkActionsDropdownOpen] = useState(false);
|
||||||
@@ -230,7 +235,7 @@ export default function TablePageTemplate({
|
|||||||
const bulkActionsButtonRef = React.useRef<HTMLButtonElement>(null);
|
const bulkActionsButtonRef = React.useRef<HTMLButtonElement>(null);
|
||||||
|
|
||||||
// Filter toggle state - hidden by default
|
// Filter toggle state - hidden by default
|
||||||
const [showFilters, setShowFilters] = useState(false);
|
const [showFilters, setShowFilters] = useState(defaultShowFilters);
|
||||||
|
|
||||||
// Get notification config for current page
|
// Get notification config for current page
|
||||||
const deleteModalConfig = getDeleteModalConfig(location.pathname);
|
const deleteModalConfig = getDeleteModalConfig(location.pathname);
|
||||||
@@ -765,8 +770,8 @@ export default function TablePageTemplate({
|
|||||||
|
|
||||||
{/* Filters Row - Below action buttons, left aligned with shadow */}
|
{/* Filters Row - Below action buttons, left aligned with shadow */}
|
||||||
{showFilters && (renderFilters || filters.length > 0) && (
|
{showFilters && (renderFilters || filters.length > 0) && (
|
||||||
<div className="flex justify-start py-1.5 mb-2.5">
|
<div className={`flex py-1.5 mb-2.5 ${centerFilters ? 'justify-center' : 'justify-start'}`}>
|
||||||
<div className="inline-flex bg-gray-50 dark:bg-gray-800/30 rounded-lg px-4 py-2 border border-gray-200 dark:border-gray-700 shadow-md">
|
<div className={`inline-flex bg-gray-50 dark:bg-gray-800/30 rounded-lg px-4 py-2 border border-gray-200 dark:border-gray-700 shadow-md ${centerFilters ? 'mx-auto' : ''}`}>
|
||||||
<div className="flex gap-2 items-center flex-wrap">
|
<div className="flex gap-2 items-center flex-wrap">
|
||||||
{renderFilters ? (
|
{renderFilters ? (
|
||||||
renderFilters
|
renderFilters
|
||||||
|
|||||||
Reference in New Issue
Block a user