keywrods libarry update
This commit is contained in:
@@ -266,10 +266,11 @@ export default function App() {
|
||||
<Route path="/reference/seed-keywords" element={<SeedKeywords />} />
|
||||
<Route path="/reference/industries" element={<ReferenceIndustries />} />
|
||||
|
||||
{/* Setup Pages */}
|
||||
<Route path="/setup/add-keywords" element={<IndustriesSectorsKeywords />} />
|
||||
{/* Legacy redirect */}
|
||||
<Route path="/setup/industries-sectors-keywords" element={<Navigate to="/setup/add-keywords" replace />} />
|
||||
{/* Keywords Library - primary route */}
|
||||
<Route path="/keywords-library" element={<IndustriesSectorsKeywords />} />
|
||||
{/* Legacy redirects */}
|
||||
<Route path="/setup/add-keywords" element={<Navigate to="/keywords-library" replace />} />
|
||||
<Route path="/setup/industries-sectors-keywords" element={<Navigate to="/keywords-library" replace />} />
|
||||
|
||||
{/* Settings */}
|
||||
{/* Legacy redirect - Profile is now a tab in Account Settings */}
|
||||
|
||||
@@ -172,7 +172,7 @@ const SEARCH_ITEMS: SearchResult[] = [
|
||||
keywords: ['keyword', 'search terms', 'seo', 'target', 'focus', 'research', 'phrases', 'queries'],
|
||||
content: 'View and manage all your target keywords. Filter by cluster, search volume, or status. Bulk actions: delete, assign to cluster, export to CSV. Table shows keyword text, search volume, cluster assignment, and status.',
|
||||
quickActions: [
|
||||
{ label: 'Keyword Library', path: '/setup/add-keywords' },
|
||||
{ label: 'Keywords Library', path: '/keywords-library' },
|
||||
{ label: 'View Clusters', path: '/planner/clusters' },
|
||||
]
|
||||
},
|
||||
@@ -306,17 +306,17 @@ const SEARCH_ITEMS: SearchResult[] = [
|
||||
keywords: ['sites', 'wordpress', 'blog', 'website', 'connection', 'integration', 'wp', 'domain'],
|
||||
content: 'Manage WordPress site connections. Add new sites, configure API credentials, test connections. View site details, publishing settings, and connection status. Supports multiple WordPress sites.',
|
||||
quickActions: [
|
||||
{ label: 'Browse Keywords', path: '/setup/add-keywords' },
|
||||
{ label: 'Browse Keywords', path: '/keywords-library' },
|
||||
{ label: 'Content Settings', path: '/account/content-settings' },
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Keyword Library',
|
||||
path: '/setup/add-keywords',
|
||||
title: 'Keywords Library',
|
||||
path: '/keywords-library',
|
||||
type: 'setup',
|
||||
category: 'Setup',
|
||||
description: 'Import keywords by industry/sector',
|
||||
keywords: ['import', 'add', 'bulk upload', 'csv', 'industry', 'sector', 'seed keywords', 'niche'],
|
||||
keywords: ['import', 'add', 'bulk upload', 'csv', 'industry', 'sector', 'seed keywords', 'niche', 'library'],
|
||||
content: 'Quick-start keyword import wizard. Select your industry and sector to import pre-researched seed keywords. Or upload your own CSV file with custom keywords. Bulk import thousands of keywords at once.',
|
||||
quickActions: [
|
||||
{ label: 'View Keywords', path: '/planner/keywords' },
|
||||
|
||||
185
frontend/src/components/keywords-library/BulkAddConfirmation.tsx
Normal file
185
frontend/src/components/keywords-library/BulkAddConfirmation.tsx
Normal file
@@ -0,0 +1,185 @@
|
||||
/**
|
||||
* BulkAddConfirmation - Confirmation modal for bulk adding keywords
|
||||
* Shows preview of keywords to be added and handles the bulk add action
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Modal } from '../ui/modal';
|
||||
import Button from '../ui/button/Button';
|
||||
import { PlusIcon, CheckCircleIcon, AlertIcon } from '../../icons';
|
||||
import { Spinner } from '../ui/spinner/Spinner';
|
||||
|
||||
interface BulkAddConfirmationProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: () => Promise<{ added: number; skipped: number; total_requested: number }>;
|
||||
keywordCount: number;
|
||||
sectorName?: string;
|
||||
statTypeLabel?: string;
|
||||
}
|
||||
|
||||
export default function BulkAddConfirmation({
|
||||
isOpen,
|
||||
onClose,
|
||||
onConfirm,
|
||||
keywordCount,
|
||||
sectorName,
|
||||
statTypeLabel,
|
||||
}: BulkAddConfirmationProps) {
|
||||
const [isAdding, setIsAdding] = useState(false);
|
||||
const [result, setResult] = useState<{ added: number; skipped: number; total_requested: number } | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleConfirm = async () => {
|
||||
setIsAdding(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await onConfirm();
|
||||
setResult(response);
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Failed to add keywords');
|
||||
} finally {
|
||||
setIsAdding(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setResult(null);
|
||||
setError(null);
|
||||
onClose();
|
||||
};
|
||||
|
||||
// Success state
|
||||
if (result) {
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={handleClose} showCloseButton={true}>
|
||||
<div className="p-6 text-center">
|
||||
<div className="mx-auto w-14 h-14 rounded-full bg-success-100 dark:bg-success-900/30 flex items-center justify-center mb-4">
|
||||
<CheckCircleIcon className="w-8 h-8 text-success-600 dark:text-success-400" />
|
||||
</div>
|
||||
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-2">
|
||||
Keywords Added Successfully!
|
||||
</h3>
|
||||
|
||||
<div className="space-y-2 mb-6">
|
||||
<p className="text-3xl font-bold text-success-600 dark:text-success-400">
|
||||
{result.added}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
keywords added to your site
|
||||
</p>
|
||||
|
||||
{result.skipped > 0 && (
|
||||
<p className="text-sm text-amber-600 dark:text-amber-400">
|
||||
{result.skipped} keywords were skipped (already exist)
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button onClick={handleClose} variant="primary">
|
||||
Done
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
// Error state
|
||||
if (error) {
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={handleClose} showCloseButton={true}>
|
||||
<div className="p-6 text-center">
|
||||
<div className="mx-auto w-14 h-14 rounded-full bg-error-100 dark:bg-error-900/30 flex items-center justify-center mb-4">
|
||||
<AlertIcon className="w-8 h-8 text-error-600 dark:text-error-400" />
|
||||
</div>
|
||||
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-2">
|
||||
Failed to Add Keywords
|
||||
</h3>
|
||||
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mb-6">
|
||||
{error}
|
||||
</p>
|
||||
|
||||
<div className="flex gap-3 justify-center">
|
||||
<Button onClick={handleClose} variant="outline">
|
||||
Close
|
||||
</Button>
|
||||
<Button onClick={handleConfirm} variant="primary">
|
||||
Try Again
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
// Confirmation state
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={handleClose} showCloseButton={!isAdding}>
|
||||
<div className="p-6">
|
||||
<div className="text-center mb-6">
|
||||
<div className="mx-auto w-14 h-14 rounded-full bg-brand-100 dark:bg-brand-900/30 flex items-center justify-center mb-4">
|
||||
<PlusIcon className="w-8 h-8 text-brand-600 dark:text-brand-400" />
|
||||
</div>
|
||||
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-2">
|
||||
Add Keywords to Your Site
|
||||
</h3>
|
||||
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
You're about to add keywords to your site's keyword list.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Summary */}
|
||||
<div className="bg-gray-50 dark:bg-gray-800/50 rounded-lg p-4 mb-6">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm text-gray-600 dark:text-gray-400">Keywords to add:</span>
|
||||
<span className="text-lg font-bold text-gray-900 dark:text-white">{keywordCount}</span>
|
||||
</div>
|
||||
|
||||
{sectorName && (
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm text-gray-600 dark:text-gray-400">Sector:</span>
|
||||
<span className="text-sm font-medium text-gray-900 dark:text-white">{sectorName}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{statTypeLabel && (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-gray-600 dark:text-gray-400">Category:</span>
|
||||
<span className="text-sm font-medium text-gray-900 dark:text-white">{statTypeLabel}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-3 justify-end">
|
||||
<Button onClick={handleClose} variant="outline" disabled={isAdding}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleConfirm}
|
||||
variant="primary"
|
||||
disabled={isAdding}
|
||||
>
|
||||
{isAdding ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<Spinner size="sm" />
|
||||
Adding...
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex items-center gap-2">
|
||||
<PlusIcon className="w-4 h-4" />
|
||||
Add {keywordCount} Keywords
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
276
frontend/src/components/keywords-library/SectorMetricCard.tsx
Normal file
276
frontend/src/components/keywords-library/SectorMetricCard.tsx
Normal file
@@ -0,0 +1,276 @@
|
||||
/**
|
||||
* SectorMetricCard - Clickable metric cards for Keywords Library
|
||||
* Displays 6 stat types with dynamic fallback thresholds
|
||||
* Clicking a card filters the table to show matching keywords
|
||||
*/
|
||||
|
||||
import { ReactNode, useMemo } from 'react';
|
||||
import clsx from 'clsx';
|
||||
import { PieChartIcon, CheckCircleIcon, ShootingStarIcon, BoltIcon, DocsIcon, BoxIcon } from '../../icons';
|
||||
|
||||
export type StatType = 'total' | 'available' | 'high_volume' | 'premium_traffic' | 'long_tail' | 'quick_wins';
|
||||
|
||||
export interface StatResult {
|
||||
count: number;
|
||||
threshold?: number;
|
||||
}
|
||||
|
||||
export interface SectorStats {
|
||||
total: StatResult;
|
||||
available: StatResult;
|
||||
high_volume: StatResult;
|
||||
premium_traffic: StatResult;
|
||||
long_tail: StatResult;
|
||||
quick_wins: StatResult;
|
||||
}
|
||||
|
||||
interface SectorMetricCardProps {
|
||||
statType: StatType;
|
||||
stats: SectorStats;
|
||||
isActive: boolean;
|
||||
onClick: () => void;
|
||||
sectorName?: string;
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
// Card configuration for each stat type
|
||||
const STAT_CONFIG: Record<StatType, {
|
||||
label: string;
|
||||
description: string;
|
||||
icon: ReactNode;
|
||||
color: string;
|
||||
bgColor: string;
|
||||
borderColor: string;
|
||||
activeColor: string;
|
||||
activeBorder: string;
|
||||
showThreshold?: boolean;
|
||||
thresholdLabel?: string;
|
||||
}> = {
|
||||
total: {
|
||||
label: 'Total',
|
||||
description: 'All keywords in sector',
|
||||
icon: <PieChartIcon className="w-5 h-5" />,
|
||||
color: 'text-gray-600 dark:text-gray-400',
|
||||
bgColor: 'bg-gray-50 dark:bg-gray-800/50',
|
||||
borderColor: 'border-gray-200 dark:border-gray-700',
|
||||
activeColor: 'bg-gray-100 dark:bg-gray-700/50',
|
||||
activeBorder: 'border-gray-400 dark:border-gray-500',
|
||||
},
|
||||
available: {
|
||||
label: 'Available',
|
||||
description: 'Not yet added to your site',
|
||||
icon: <CheckCircleIcon className="w-5 h-5" />,
|
||||
color: 'text-green-600 dark:text-green-400',
|
||||
bgColor: 'bg-green-50 dark:bg-green-900/20',
|
||||
borderColor: 'border-green-200 dark:border-green-800',
|
||||
activeColor: 'bg-green-100 dark:bg-green-800/30',
|
||||
activeBorder: 'border-green-500 dark:border-green-600',
|
||||
},
|
||||
high_volume: {
|
||||
label: 'High Volume',
|
||||
description: 'Volume ≥ 10K searches/mo',
|
||||
icon: <ShootingStarIcon className="w-5 h-5" />,
|
||||
color: 'text-blue-600 dark:text-blue-400',
|
||||
bgColor: 'bg-blue-50 dark:bg-blue-900/20',
|
||||
borderColor: 'border-blue-200 dark:border-blue-800',
|
||||
activeColor: 'bg-blue-100 dark:bg-blue-800/30',
|
||||
activeBorder: 'border-blue-500 dark:border-blue-600',
|
||||
},
|
||||
premium_traffic: {
|
||||
label: 'Premium Traffic',
|
||||
description: 'High volume keywords',
|
||||
icon: <BoxIcon className="w-5 h-5" />,
|
||||
color: 'text-amber-600 dark:text-amber-400',
|
||||
bgColor: 'bg-amber-50 dark:bg-amber-900/20',
|
||||
borderColor: 'border-amber-200 dark:border-amber-800',
|
||||
activeColor: 'bg-amber-100 dark:bg-amber-800/30',
|
||||
activeBorder: 'border-amber-500 dark:border-amber-600',
|
||||
showThreshold: true,
|
||||
thresholdLabel: 'Vol ≥',
|
||||
},
|
||||
long_tail: {
|
||||
label: 'Long Tail',
|
||||
description: '4+ words with good volume',
|
||||
icon: <DocsIcon className="w-5 h-5" />,
|
||||
color: 'text-purple-600 dark:text-purple-400',
|
||||
bgColor: 'bg-purple-50 dark:bg-purple-900/20',
|
||||
borderColor: 'border-purple-200 dark:border-purple-800',
|
||||
activeColor: 'bg-purple-100 dark:bg-purple-800/30',
|
||||
activeBorder: 'border-purple-500 dark:border-purple-600',
|
||||
showThreshold: true,
|
||||
thresholdLabel: 'Vol >',
|
||||
},
|
||||
quick_wins: {
|
||||
label: 'Quick Wins',
|
||||
description: 'Low difficulty, good volume',
|
||||
icon: <BoltIcon className="w-5 h-5" />,
|
||||
color: 'text-emerald-600 dark:text-emerald-400',
|
||||
bgColor: 'bg-emerald-50 dark:bg-emerald-900/20',
|
||||
borderColor: 'border-emerald-200 dark:border-emerald-800',
|
||||
activeColor: 'bg-emerald-100 dark:bg-emerald-800/30',
|
||||
activeBorder: 'border-emerald-500 dark:border-emerald-600',
|
||||
showThreshold: true,
|
||||
thresholdLabel: 'Vol >',
|
||||
},
|
||||
};
|
||||
|
||||
// Format large numbers for display
|
||||
function formatCount(count: number): string {
|
||||
if (count >= 1000000) {
|
||||
return `${(count / 1000000).toFixed(1)}M`;
|
||||
}
|
||||
if (count >= 1000) {
|
||||
return `${(count / 1000).toFixed(1)}K`;
|
||||
}
|
||||
return count.toLocaleString();
|
||||
}
|
||||
|
||||
// Format threshold for display
|
||||
function formatThreshold(threshold: number): string {
|
||||
if (threshold >= 1000) {
|
||||
return `${threshold / 1000}K`;
|
||||
}
|
||||
return threshold.toLocaleString();
|
||||
}
|
||||
|
||||
export default function SectorMetricCard({
|
||||
statType,
|
||||
stats,
|
||||
isActive,
|
||||
onClick,
|
||||
sectorName,
|
||||
compact = false,
|
||||
}: SectorMetricCardProps) {
|
||||
const config = STAT_CONFIG[statType];
|
||||
const statData = stats[statType];
|
||||
|
||||
// Build description with threshold if applicable
|
||||
const description = useMemo(() => {
|
||||
if (config.showThreshold && statData.threshold) {
|
||||
return `${config.thresholdLabel} ${formatThreshold(statData.threshold)}`;
|
||||
}
|
||||
return config.description;
|
||||
}, [config, statData.threshold]);
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={clsx(
|
||||
'group relative flex flex-col items-start w-full rounded-xl border-2 transition-all duration-200',
|
||||
'hover:shadow-md hover:scale-[1.02] active:scale-[0.98]',
|
||||
'focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-brand-500',
|
||||
compact ? 'p-3' : 'p-4',
|
||||
isActive ? [config.activeColor, config.activeBorder] : [config.bgColor, config.borderColor],
|
||||
)}
|
||||
>
|
||||
{/* Icon and Label Row */}
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<div className={clsx(
|
||||
'flex items-center justify-center rounded-lg',
|
||||
compact ? 'w-8 h-8' : 'w-9 h-9',
|
||||
config.color,
|
||||
isActive ? 'bg-white/50 dark:bg-black/20' : 'bg-white/60 dark:bg-black/10',
|
||||
)}>
|
||||
{config.icon}
|
||||
</div>
|
||||
<span className={clsx(
|
||||
'font-semibold',
|
||||
compact ? 'text-sm' : 'text-base',
|
||||
'text-gray-900 dark:text-white',
|
||||
)}>
|
||||
{config.label}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Count */}
|
||||
<div className={clsx(
|
||||
'font-bold tabular-nums',
|
||||
compact ? 'text-2xl' : 'text-3xl',
|
||||
config.color,
|
||||
)}>
|
||||
{formatCount(statData.count)}
|
||||
</div>
|
||||
|
||||
{/* Description / Threshold */}
|
||||
{!compact && (
|
||||
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400 line-clamp-1">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Active indicator */}
|
||||
{isActive && (
|
||||
<div className={clsx(
|
||||
'absolute top-2 right-2 w-2 h-2 rounded-full animate-pulse',
|
||||
statType === 'total' ? 'bg-gray-500' :
|
||||
statType === 'available' ? 'bg-green-500' :
|
||||
statType === 'high_volume' ? 'bg-blue-500' :
|
||||
statType === 'premium_traffic' ? 'bg-amber-500' :
|
||||
statType === 'long_tail' ? 'bg-purple-500' :
|
||||
'bg-emerald-500'
|
||||
)} />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// Grid wrapper for displaying all 6 cards
|
||||
interface SectorMetricGridProps {
|
||||
stats: SectorStats | null;
|
||||
activeStatType: StatType | null;
|
||||
onStatClick: (statType: StatType) => void;
|
||||
sectorName?: string;
|
||||
compact?: boolean;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
export function SectorMetricGrid({
|
||||
stats,
|
||||
activeStatType,
|
||||
onStatClick,
|
||||
sectorName,
|
||||
compact = false,
|
||||
isLoading = false,
|
||||
}: SectorMetricGridProps) {
|
||||
const statTypes: StatType[] = ['total', 'available', 'high_volume', 'premium_traffic', 'long_tail', 'quick_wins'];
|
||||
|
||||
// Loading skeleton
|
||||
if (isLoading || !stats) {
|
||||
return (
|
||||
<div className={clsx(
|
||||
'grid gap-3',
|
||||
compact ? 'grid-cols-3 sm:grid-cols-6' : 'grid-cols-2 sm:grid-cols-3 lg:grid-cols-6',
|
||||
)}>
|
||||
{statTypes.map((statType) => (
|
||||
<div
|
||||
key={statType}
|
||||
className="relative flex flex-col items-center justify-center rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-4 animate-pulse"
|
||||
>
|
||||
<div className="h-5 w-5 bg-gray-200 dark:bg-gray-700 rounded mb-2" />
|
||||
<div className="h-8 w-16 bg-gray-200 dark:bg-gray-700 rounded mb-1" />
|
||||
<div className="h-3 w-24 bg-gray-200 dark:bg-gray-700 rounded" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={clsx(
|
||||
'grid gap-3',
|
||||
compact ? 'grid-cols-3 sm:grid-cols-6' : 'grid-cols-2 sm:grid-cols-3 lg:grid-cols-6',
|
||||
)}>
|
||||
{statTypes.map((statType) => (
|
||||
<SectorMetricCard
|
||||
key={statType}
|
||||
statType={statType}
|
||||
stats={stats}
|
||||
isActive={activeStatType === statType}
|
||||
onClick={() => onStatClick(statType)}
|
||||
sectorName={sectorName}
|
||||
compact={compact}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
277
frontend/src/components/keywords-library/SmartSuggestions.tsx
Normal file
277
frontend/src/components/keywords-library/SmartSuggestions.tsx
Normal file
@@ -0,0 +1,277 @@
|
||||
/**
|
||||
* SmartSuggestions - Breathing indicator panel for Keywords Library
|
||||
* Shows "Ready-to-use keywords waiting for you!" with animated indicator
|
||||
* Clicking navigates to pre-filtered keywords
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import clsx from 'clsx';
|
||||
import { ShootingStarIcon, ChevronDownIcon, ArrowRightIcon } from '../../icons';
|
||||
import Button from '../ui/button/Button';
|
||||
|
||||
interface SmartSuggestion {
|
||||
id: string;
|
||||
label: string;
|
||||
description: string;
|
||||
count: number;
|
||||
filterParams: {
|
||||
statType?: string;
|
||||
difficulty_max?: number;
|
||||
volume_min?: number;
|
||||
word_count_min?: number;
|
||||
available_only?: boolean;
|
||||
};
|
||||
color: 'blue' | 'green' | 'amber' | 'purple' | 'emerald';
|
||||
}
|
||||
|
||||
interface SmartSuggestionsProps {
|
||||
suggestions: SmartSuggestion[];
|
||||
onSuggestionClick: (suggestion: SmartSuggestion) => void;
|
||||
className?: string;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
// Color mappings
|
||||
const colorClasses = {
|
||||
blue: {
|
||||
bg: 'bg-blue-50 dark:bg-blue-900/20',
|
||||
border: 'border-blue-200 dark:border-blue-800',
|
||||
text: 'text-blue-600 dark:text-blue-400',
|
||||
badge: 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300',
|
||||
},
|
||||
green: {
|
||||
bg: 'bg-green-50 dark:bg-green-900/20',
|
||||
border: 'border-green-200 dark:border-green-800',
|
||||
text: 'text-green-600 dark:text-green-400',
|
||||
badge: 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300',
|
||||
},
|
||||
amber: {
|
||||
bg: 'bg-amber-50 dark:bg-amber-900/20',
|
||||
border: 'border-amber-200 dark:border-amber-800',
|
||||
text: 'text-amber-600 dark:text-amber-400',
|
||||
badge: 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300',
|
||||
},
|
||||
purple: {
|
||||
bg: 'bg-purple-50 dark:bg-purple-900/20',
|
||||
border: 'border-purple-200 dark:border-purple-800',
|
||||
text: 'text-purple-600 dark:text-purple-400',
|
||||
badge: 'bg-purple-100 text-purple-700 dark:bg-purple-900/40 dark:text-purple-300',
|
||||
},
|
||||
emerald: {
|
||||
bg: 'bg-emerald-50 dark:bg-emerald-900/20',
|
||||
border: 'border-emerald-200 dark:border-emerald-800',
|
||||
text: 'text-emerald-600 dark:text-emerald-400',
|
||||
badge: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300',
|
||||
},
|
||||
};
|
||||
|
||||
export default function SmartSuggestions({
|
||||
suggestions,
|
||||
onSuggestionClick,
|
||||
className,
|
||||
isLoading = false,
|
||||
}: SmartSuggestionsProps) {
|
||||
const [isExpanded, setIsExpanded] = useState(true);
|
||||
|
||||
const totalAvailable = suggestions.reduce((sum, s) => sum + s.count, 0);
|
||||
const hasKeywords = totalAvailable > 0;
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className={clsx(
|
||||
'rounded-xl border-2 border-dashed border-gray-200 dark:border-gray-700 p-6',
|
||||
className
|
||||
)}>
|
||||
<div className="flex items-center justify-center gap-3">
|
||||
<div className="w-5 h-5 rounded-full border-2 border-gray-300 border-t-transparent animate-spin" />
|
||||
<span className="text-gray-500 dark:text-gray-400">Loading suggestions...</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={clsx(
|
||||
'rounded-xl border bg-gradient-to-br from-brand-50/50 to-purple-50/50 dark:from-brand-900/10 dark:to-purple-900/10',
|
||||
'border-brand-200 dark:border-brand-800',
|
||||
className
|
||||
)}>
|
||||
{/* Header with breathing indicator */}
|
||||
<button
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
className="flex items-center justify-between w-full p-4 text-left hover:bg-brand-50/50 dark:hover:bg-brand-900/10 transition-colors rounded-t-xl"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Breathing indicator */}
|
||||
<div className="relative">
|
||||
<div className={clsx(
|
||||
'w-10 h-10 rounded-full flex items-center justify-center',
|
||||
'bg-gradient-to-br from-brand-400 to-purple-500',
|
||||
hasKeywords && 'animate-pulse'
|
||||
)}>
|
||||
<ShootingStarIcon className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
{hasKeywords && (
|
||||
<div className="absolute -top-1 -right-1 w-4 h-4 bg-emerald-500 rounded-full flex items-center justify-center animate-bounce">
|
||||
<span className="text-[10px] font-bold text-white">!</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-900 dark:text-white flex items-center gap-2">
|
||||
Smart Suggestions
|
||||
{hasKeywords && (
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300">
|
||||
{totalAvailable} ready
|
||||
</span>
|
||||
)}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{hasKeywords
|
||||
? 'Ready-to-use keywords waiting for you!'
|
||||
: 'No suggestions available for current filters'
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ChevronDownIcon
|
||||
className={clsx(
|
||||
'w-5 h-5 text-gray-400 transition-transform duration-200',
|
||||
isExpanded && 'rotate-180'
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{/* Expandable content */}
|
||||
{isExpanded && suggestions.length > 0 && (
|
||||
<div className="px-4 pb-4 space-y-2">
|
||||
{suggestions.map((suggestion) => {
|
||||
const colors = colorClasses[suggestion.color];
|
||||
|
||||
return (
|
||||
<button
|
||||
key={suggestion.id}
|
||||
onClick={() => onSuggestionClick(suggestion)}
|
||||
className={clsx(
|
||||
'flex items-center justify-between w-full p-3 rounded-lg border transition-all',
|
||||
'hover:shadow-md hover:scale-[1.01] active:scale-[0.99]',
|
||||
colors.bg,
|
||||
colors.border,
|
||||
)}
|
||||
>
|
||||
<div className="flex-1 text-left">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={clsx('font-medium text-sm', colors.text)}>
|
||||
{suggestion.label}
|
||||
</span>
|
||||
<span className={clsx(
|
||||
'px-2 py-0.5 rounded-full text-xs font-semibold',
|
||||
colors.badge
|
||||
)}>
|
||||
{suggestion.count}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
|
||||
{suggestion.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ArrowRightIcon className={clsx('w-4 h-4', colors.text)} />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isExpanded && suggestions.length === 0 && (
|
||||
<div className="px-4 pb-4">
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 text-center py-4">
|
||||
Select an industry and sector to see smart suggestions.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Helper to build suggestions from sector stats
|
||||
export function buildSmartSuggestions(
|
||||
stats: {
|
||||
available: { count: number };
|
||||
quick_wins: { count: number; threshold?: number };
|
||||
long_tail: { count: number; threshold?: number };
|
||||
premium_traffic: { count: number; threshold?: number };
|
||||
},
|
||||
options?: {
|
||||
showOnlyWithResults?: boolean;
|
||||
}
|
||||
): SmartSuggestion[] {
|
||||
const suggestions: SmartSuggestion[] = [];
|
||||
|
||||
// Quick Wins - Low difficulty + good volume + available
|
||||
if (stats.quick_wins.count > 0 || !options?.showOnlyWithResults) {
|
||||
suggestions.push({
|
||||
id: 'quick_wins',
|
||||
label: 'Quick Wins',
|
||||
description: `Easy to rank keywords with vol > ${(stats.quick_wins.threshold || 1000) / 1000}K`,
|
||||
count: stats.quick_wins.count,
|
||||
filterParams: {
|
||||
statType: 'quick_wins',
|
||||
difficulty_max: 20,
|
||||
volume_min: stats.quick_wins.threshold || 1000,
|
||||
available_only: true,
|
||||
},
|
||||
color: 'emerald',
|
||||
});
|
||||
}
|
||||
|
||||
// Long Tail - 4+ words with volume
|
||||
if (stats.long_tail.count > 0 || !options?.showOnlyWithResults) {
|
||||
suggestions.push({
|
||||
id: 'long_tail',
|
||||
label: 'Long Tail',
|
||||
description: `4+ word phrases with vol > ${(stats.long_tail.threshold || 1000) / 1000}K`,
|
||||
count: stats.long_tail.count,
|
||||
filterParams: {
|
||||
statType: 'long_tail',
|
||||
word_count_min: 4,
|
||||
volume_min: stats.long_tail.threshold || 1000,
|
||||
},
|
||||
color: 'purple',
|
||||
});
|
||||
}
|
||||
|
||||
// Premium Traffic - High volume
|
||||
if (stats.premium_traffic.count > 0 || !options?.showOnlyWithResults) {
|
||||
suggestions.push({
|
||||
id: 'premium_traffic',
|
||||
label: 'Premium Traffic',
|
||||
description: `High volume keywords (${(stats.premium_traffic.threshold || 50000) / 1000}K+ searches)`,
|
||||
count: stats.premium_traffic.count,
|
||||
filterParams: {
|
||||
statType: 'premium_traffic',
|
||||
volume_min: stats.premium_traffic.threshold || 50000,
|
||||
},
|
||||
color: 'amber',
|
||||
});
|
||||
}
|
||||
|
||||
// Available Only
|
||||
if (stats.available.count > 0 || !options?.showOnlyWithResults) {
|
||||
suggestions.push({
|
||||
id: 'available',
|
||||
label: 'Available Keywords',
|
||||
description: 'Keywords not yet added to your site',
|
||||
count: stats.available.count,
|
||||
filterParams: {
|
||||
statType: 'available',
|
||||
available_only: true,
|
||||
},
|
||||
color: 'green',
|
||||
});
|
||||
}
|
||||
|
||||
return suggestions;
|
||||
}
|
||||
11
frontend/src/components/keywords-library/index.tsx
Normal file
11
frontend/src/components/keywords-library/index.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* Keywords Library Components
|
||||
* Components for the Keywords Library page redesign
|
||||
*/
|
||||
|
||||
export { default as SectorMetricCard, SectorMetricGrid } from './SectorMetricCard';
|
||||
export type { StatType, StatResult, SectorStats } from './SectorMetricCard';
|
||||
|
||||
export { default as SmartSuggestions, buildSmartSuggestions } from './SmartSuggestions';
|
||||
|
||||
export { default as BulkAddConfirmation } from './BulkAddConfirmation';
|
||||
@@ -59,7 +59,7 @@ export default function SiteSetupChecklist({
|
||||
id: 'keywords',
|
||||
label: 'Keywords added',
|
||||
completed: hasKeywords,
|
||||
href: '/setup/add-keywords',
|
||||
href: '/keywords-library',
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -17,7 +17,6 @@ import React from "react";
|
||||
const SITE_AND_SECTOR_ROUTES = [
|
||||
'/planner', // All planner pages
|
||||
'/writer', // All writer pages
|
||||
'/setup/add-keywords', // Add keywords page
|
||||
];
|
||||
|
||||
const SINGLE_SITE_ROUTES = [
|
||||
@@ -25,6 +24,7 @@ const SINGLE_SITE_ROUTES = [
|
||||
'/publisher', // Content Calendar page
|
||||
'/account/content-settings', // Content settings and sub-pages
|
||||
'/sites', // Site dashboard and site settings pages (matches /sites/21, /sites/21/settings, /sites/21/content)
|
||||
'/keywords-library', // Keywords Library - only site selector, no sector
|
||||
];
|
||||
|
||||
const SITE_WITH_ALL_SITES_ROUTES = [
|
||||
|
||||
@@ -101,11 +101,11 @@ const AppSidebar: React.FC = () => {
|
||||
path: "/sites",
|
||||
});
|
||||
|
||||
// Keyword Library - Browse and add curated keywords
|
||||
// Keywords Library - Browse and add curated keywords
|
||||
setupItems.push({
|
||||
icon: <DocsIcon />,
|
||||
name: "Keyword Library",
|
||||
path: "/setup/add-keywords",
|
||||
name: "Keywords Library",
|
||||
path: "/keywords-library",
|
||||
});
|
||||
|
||||
// Content Settings moved to Site Settings tabs - removed from sidebar
|
||||
|
||||
@@ -186,7 +186,7 @@ export default function Help() {
|
||||
{ id: "dashboard", title: "Dashboard", level: 1 },
|
||||
{ id: "setup", title: "Setup & Onboarding", level: 1 },
|
||||
{ id: "onboarding-wizard", title: "Onboarding Wizard", level: 2 },
|
||||
{ id: "add-keywords", title: "Add Keywords", level: 2 },
|
||||
{ id: "keywords-library", title: "Keywords Library", level: 2 },
|
||||
{ id: "content-settings", title: "Content Settings", level: 2 },
|
||||
{ id: "sites", title: "Sites Management", level: 2 },
|
||||
{ id: "planner-module", title: "Planner Module", level: 1 },
|
||||
@@ -214,7 +214,7 @@ export default function Help() {
|
||||
const faqItems = [
|
||||
{
|
||||
question: "How do I add keywords to my workflow?",
|
||||
answer: "Navigate to Setup → Add Keywords or Planner → Keyword Opportunities. Browse available keywords, use filters to find relevant ones, and click 'Add to Workflow' on individual keywords or use bulk selection to add multiple keywords at once. You can also import keywords via CSV upload."
|
||||
answer: "Navigate to Keywords Library in the sidebar. Browse available keywords by industry and sector, use filters to find relevant ones, and click 'Add to Workflow' on individual keywords or use bulk selection to add multiple keywords at once. You can also import keywords via CSV upload."
|
||||
},
|
||||
{
|
||||
question: "What is the difference between Keywords and Clusters?",
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/**
|
||||
* Keyword Library Page (formerly Add Keywords)
|
||||
* Keywords Library Page (formerly Add Keywords / Seed Keywords)
|
||||
* Browse global seed keywords filtered by site's industry and sectors
|
||||
* Add keywords to workflow (creates Keywords records in planner)
|
||||
* Features: High Opportunity Keywords section, Browse all keywords, CSV import
|
||||
* Features: Sector Metric Cards, Smart Suggestions, Browse all keywords, CSV import
|
||||
* Coming soon: Ahrefs keyword research integration (March/April 2026)
|
||||
*/
|
||||
|
||||
@@ -14,7 +14,7 @@ import { useToast } from '../../components/ui/toast/ToastContainer';
|
||||
import { usePageLoading } from '../../context/PageLoadingContext';
|
||||
import WorkflowGuide from '../../components/onboarding/WorkflowGuide';
|
||||
import {
|
||||
fetchSeedKeywords,
|
||||
fetchKeywordsLibrary,
|
||||
SeedKeyword,
|
||||
SeedKeywordResponse,
|
||||
fetchSites,
|
||||
@@ -23,6 +23,9 @@ import {
|
||||
fetchSiteSectors,
|
||||
fetchIndustries,
|
||||
fetchKeywords,
|
||||
fetchSectorStats,
|
||||
SectorStats,
|
||||
bulkAddKeywordsToSite,
|
||||
} from '../../services/api';
|
||||
import Badge from '../../components/ui/badge/Badge';
|
||||
import { BoltIcon, PlusIcon, CheckCircleIcon, ShootingStarIcon, DocsIcon } from '../../icons';
|
||||
@@ -38,6 +41,9 @@ import FileInput from '../../components/form/input/FileInput';
|
||||
import Label from '../../components/form/Label';
|
||||
import { Card } from '../../components/ui/card';
|
||||
import { Spinner } from '../../components/ui/spinner/Spinner';
|
||||
import { SectorMetricGrid, StatType } from '../../components/keywords-library/SectorMetricCard';
|
||||
import SmartSuggestions, { buildSmartSuggestions } from '../../components/keywords-library/SmartSuggestions';
|
||||
import BulkAddConfirmation from '../../components/keywords-library/BulkAddConfirmation';
|
||||
|
||||
// High Opportunity Keywords types
|
||||
interface SectorKeywordOption {
|
||||
@@ -71,6 +77,16 @@ export default function IndustriesSectorsKeywords() {
|
||||
// Track recently added keywords to preserve their state during reload
|
||||
const recentlyAddedRef = useRef<Set<number>>(new Set());
|
||||
|
||||
// Sector Stats state (for metric cards)
|
||||
const [sectorStats, setSectorStats] = useState<SectorStats | null>(null);
|
||||
const [loadingSectorStats, setLoadingSectorStats] = useState(false);
|
||||
const [activeStatFilter, setActiveStatFilter] = useState<StatType | null>(null);
|
||||
|
||||
// Bulk Add confirmation modal state
|
||||
const [showBulkAddModal, setShowBulkAddModal] = useState(false);
|
||||
const [bulkAddKeywordIds, setBulkAddKeywordIds] = useState<number[]>([]);
|
||||
const [bulkAddStatLabel, setBulkAddStatLabel] = useState<string | undefined>();
|
||||
|
||||
// High Opportunity Keywords state
|
||||
const [showHighOpportunity, setShowHighOpportunity] = useState(true);
|
||||
const [loadingOpportunityKeywords, setLoadingOpportunityKeywords] = useState(false);
|
||||
@@ -186,7 +202,7 @@ export default function IndustriesSectorsKeywords() {
|
||||
if (!siteSector.is_active) continue;
|
||||
|
||||
// Fetch all keywords for this sector
|
||||
const response = await fetchSeedKeywords({
|
||||
const response = await fetchKeywordsLibrary({
|
||||
industry: industry.id,
|
||||
sector: siteSector.industry_sector,
|
||||
page_size: 500,
|
||||
@@ -301,7 +317,7 @@ export default function IndustriesSectorsKeywords() {
|
||||
filters.sector = activeSector.industry_sector;
|
||||
}
|
||||
|
||||
const data = await fetchSeedKeywords(filters);
|
||||
const data = await fetchKeywordsLibrary(filters);
|
||||
const totalAvailable = data.count || 0;
|
||||
|
||||
setAddedCount(totalAdded);
|
||||
@@ -311,6 +327,70 @@ export default function IndustriesSectorsKeywords() {
|
||||
}
|
||||
}, [activeSite, activeSector]);
|
||||
|
||||
// Load sector stats for metric cards
|
||||
const loadSectorStats = useCallback(async () => {
|
||||
if (!activeSite || !activeSite.industry) {
|
||||
setSectorStats(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoadingSectorStats(true);
|
||||
try {
|
||||
const response = await fetchSectorStats({
|
||||
industry_id: activeSite.industry,
|
||||
sector_id: activeSector?.industry_sector ?? undefined,
|
||||
site_id: activeSite.id,
|
||||
});
|
||||
|
||||
// If sector-specific stats returned
|
||||
if (response.stats) {
|
||||
setSectorStats(response.stats as SectorStats);
|
||||
} else if (response.sectors && response.sectors.length > 0) {
|
||||
// Aggregate stats from all sectors
|
||||
const aggregated: SectorStats = {
|
||||
total: { count: 0 },
|
||||
available: { count: 0 },
|
||||
high_volume: { count: 0, threshold: 10000 },
|
||||
premium_traffic: { count: 0, threshold: 50000 },
|
||||
long_tail: { count: 0, threshold: 1000 },
|
||||
quick_wins: { count: 0, threshold: 1000 },
|
||||
};
|
||||
|
||||
response.sectors.forEach((sector) => {
|
||||
aggregated.total.count += sector.stats.total.count;
|
||||
aggregated.available.count += sector.stats.available.count;
|
||||
aggregated.high_volume.count += sector.stats.high_volume.count;
|
||||
aggregated.premium_traffic.count += sector.stats.premium_traffic.count;
|
||||
aggregated.long_tail.count += sector.stats.long_tail.count;
|
||||
aggregated.quick_wins.count += sector.stats.quick_wins.count;
|
||||
// Use first sector's thresholds (they should be consistent)
|
||||
if (!aggregated.premium_traffic.threshold) {
|
||||
aggregated.premium_traffic.threshold = sector.stats.premium_traffic.threshold;
|
||||
}
|
||||
if (!aggregated.long_tail.threshold) {
|
||||
aggregated.long_tail.threshold = sector.stats.long_tail.threshold;
|
||||
}
|
||||
if (!aggregated.quick_wins.threshold) {
|
||||
aggregated.quick_wins.threshold = sector.stats.quick_wins.threshold;
|
||||
}
|
||||
});
|
||||
|
||||
setSectorStats(aggregated);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load sector stats:', error);
|
||||
} finally {
|
||||
setLoadingSectorStats(false);
|
||||
}
|
||||
}, [activeSite, activeSector]);
|
||||
|
||||
// Load sector stats when site/sector changes
|
||||
useEffect(() => {
|
||||
if (activeSite) {
|
||||
loadSectorStats();
|
||||
}
|
||||
}, [activeSite, activeSector, loadSectorStats]);
|
||||
|
||||
// Load counts on mount and when site/sector changes
|
||||
useEffect(() => {
|
||||
if (activeSite) {
|
||||
@@ -394,7 +474,7 @@ export default function IndustriesSectorsKeywords() {
|
||||
}
|
||||
|
||||
// Fetch only current page from API
|
||||
const data: SeedKeywordResponse = await fetchSeedKeywords(filters);
|
||||
const data: SeedKeywordResponse = await fetchKeywordsLibrary(filters);
|
||||
|
||||
// Mark already-attached keywords
|
||||
const results = (data.results || []).map(sk => {
|
||||
@@ -510,7 +590,7 @@ export default function IndustriesSectorsKeywords() {
|
||||
}
|
||||
|
||||
// Show skipped count if any
|
||||
if (result.skipped > 0) {
|
||||
if (result.skipped && result.skipped > 0) {
|
||||
toast.warning(`${result.skipped} keyword(s) were skipped (already exist or validation failed)`);
|
||||
}
|
||||
|
||||
@@ -849,7 +929,6 @@ export default function IndustriesSectorsKeywords() {
|
||||
handleAddToWorkflow([row.id]);
|
||||
}
|
||||
}}
|
||||
title={!activeSector ? 'Please select a sector first' : ''}
|
||||
>
|
||||
{buttonText}
|
||||
</Button>
|
||||
@@ -1116,9 +1195,9 @@ export default function IndustriesSectorsKeywords() {
|
||||
if (highOpportunityLoaded && sites.length === 0) {
|
||||
return (
|
||||
<>
|
||||
<PageMeta title="Keyword Library" description="Browse and add keywords to your workflow" />
|
||||
<PageMeta title="Keywords Library" description="Browse and add keywords to your workflow" />
|
||||
<PageHeader
|
||||
title="Keyword Library"
|
||||
title="Keywords Library"
|
||||
badge={{ icon: <BoltIcon />, color: 'blue' }}
|
||||
/>
|
||||
<div className="p-6">
|
||||
@@ -1128,14 +1207,117 @@ export default function IndustriesSectorsKeywords() {
|
||||
);
|
||||
}
|
||||
|
||||
// Handle stat card click - filters table to show matching keywords
|
||||
const handleStatClick = (statType: StatType) => {
|
||||
// Toggle off if clicking same stat
|
||||
if (activeStatFilter === statType) {
|
||||
setActiveStatFilter(null);
|
||||
setShowNotAddedOnly(false);
|
||||
setDifficultyFilter('');
|
||||
return;
|
||||
}
|
||||
|
||||
setActiveStatFilter(statType);
|
||||
setShowBrowseTable(true);
|
||||
setCurrentPage(1);
|
||||
|
||||
// Apply filters based on stat type
|
||||
switch (statType) {
|
||||
case 'available':
|
||||
setShowNotAddedOnly(true);
|
||||
setDifficultyFilter('');
|
||||
break;
|
||||
case 'high_volume':
|
||||
// Volume >= 10K - needs sort by volume desc
|
||||
setShowNotAddedOnly(false);
|
||||
setDifficultyFilter('');
|
||||
setSortBy('volume');
|
||||
setSortDirection('desc');
|
||||
break;
|
||||
case 'premium_traffic':
|
||||
// Premium traffic - sort by volume
|
||||
setShowNotAddedOnly(false);
|
||||
setDifficultyFilter('');
|
||||
setSortBy('volume');
|
||||
setSortDirection('desc');
|
||||
break;
|
||||
case 'long_tail':
|
||||
// Long tail - can't filter by word count server-side, just show all sorted
|
||||
setShowNotAddedOnly(false);
|
||||
setDifficultyFilter('');
|
||||
setSortBy('keyword');
|
||||
setSortDirection('asc');
|
||||
break;
|
||||
case 'quick_wins':
|
||||
// Quick wins - low difficulty + available
|
||||
setShowNotAddedOnly(true);
|
||||
setDifficultyFilter('1'); // Very Easy (level 1 = backend 0-20)
|
||||
setSortBy('difficulty');
|
||||
setSortDirection('asc');
|
||||
break;
|
||||
default:
|
||||
setShowNotAddedOnly(false);
|
||||
setDifficultyFilter('');
|
||||
}
|
||||
};
|
||||
|
||||
// Build smart suggestions from sector stats
|
||||
const smartSuggestions = useMemo(() => {
|
||||
if (!sectorStats) return [];
|
||||
return buildSmartSuggestions(sectorStats, { showOnlyWithResults: true });
|
||||
}, [sectorStats]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageMeta title="Keyword Library" description="Browse curated keywords and add them to your workflow. Ahrefs research coming soon." />
|
||||
<PageMeta title="Keywords Library" description="Browse curated keywords and add them to your workflow. Ahrefs research coming soon." />
|
||||
|
||||
{/* TEST TEXT - REMOVE AFTER VERIFICATION */}
|
||||
<div className="text-red-500 text-2xl font-bold p-4 text-center">
|
||||
Test Text
|
||||
</div>
|
||||
|
||||
<PageHeader
|
||||
title="Keyword Library"
|
||||
title="Keywords Library"
|
||||
badge={{ icon: <BoltIcon />, color: 'blue' }}
|
||||
/>
|
||||
|
||||
{/* Sector Metric Cards - Show when site is selected */}
|
||||
{activeSite && (
|
||||
<div className="mx-6 mt-6">
|
||||
<div className="mb-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-1">
|
||||
Keyword Stats {activeSector ? `— ${activeSector.name}` : '— All Sectors'}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Click a card to filter the table below
|
||||
</p>
|
||||
</div>
|
||||
<SectorMetricGrid
|
||||
stats={sectorStats}
|
||||
activeStatType={activeStatFilter}
|
||||
onStatClick={handleStatClick}
|
||||
sectorName={activeSector?.name}
|
||||
isLoading={loadingSectorStats}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Smart Suggestions Panel */}
|
||||
{activeSite && sectorStats && smartSuggestions.length > 0 && (
|
||||
<div className="mx-6 mt-6">
|
||||
<SmartSuggestions
|
||||
suggestions={smartSuggestions}
|
||||
onSuggestionClick={(suggestion) => {
|
||||
// Apply the filter from the suggestion
|
||||
if (suggestion.filterParams.statType) {
|
||||
handleStatClick(suggestion.filterParams.statType as StatType);
|
||||
}
|
||||
}}
|
||||
isLoading={loadingSectorStats}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* High Opportunity Keywords Section - Loads First */}
|
||||
<HighOpportunityKeywordsSection />
|
||||
|
||||
@@ -1218,7 +1400,7 @@ export default function IndustriesSectorsKeywords() {
|
||||
<>
|
||||
<div className="w-px h-4 bg-gray-300 dark:bg-gray-600" />
|
||||
<Button
|
||||
variant="success"
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={() => navigate('/planner/keywords')}
|
||||
endIcon={
|
||||
|
||||
@@ -2270,7 +2270,8 @@ export interface SeedKeywordResponse {
|
||||
results: SeedKeyword[];
|
||||
}
|
||||
|
||||
export async function fetchSeedKeywords(filters?: {
|
||||
// Keywords Library API - uses /v1/auth/keywords-library/ endpoint
|
||||
export async function fetchKeywordsLibrary(filters?: {
|
||||
industry?: number;
|
||||
sector?: number;
|
||||
country?: string;
|
||||
@@ -2302,9 +2303,12 @@ export async function fetchSeedKeywords(filters?: {
|
||||
if (filters?.difficulty_max !== undefined) params.append('difficulty_max', filters.difficulty_max.toString());
|
||||
|
||||
const queryString = params.toString();
|
||||
return fetchAPI(`/v1/auth/seed-keywords/${queryString ? `?${queryString}` : ''}`);
|
||||
return fetchAPI(`/v1/auth/keywords-library/${queryString ? `?${queryString}` : ''}`);
|
||||
}
|
||||
|
||||
// Legacy alias - maps to new endpoint
|
||||
export const fetchSeedKeywords = fetchKeywordsLibrary;
|
||||
|
||||
/**
|
||||
* Fetch Seed Keyword Statistics
|
||||
*/
|
||||
@@ -2330,7 +2334,110 @@ export interface KeywordStats {
|
||||
}
|
||||
|
||||
export async function fetchKeywordStats(): Promise<KeywordStats> {
|
||||
return fetchAPI('/v1/auth/seed-keywords/stats/');
|
||||
return fetchAPI('/v1/auth/keywords-library/stats/');
|
||||
}
|
||||
|
||||
/**
|
||||
* Keywords Library - Sector Stats with dynamic fallback thresholds
|
||||
*/
|
||||
export interface SectorStatResult {
|
||||
count: number;
|
||||
threshold?: number;
|
||||
}
|
||||
|
||||
export interface SectorStats {
|
||||
total: SectorStatResult;
|
||||
available: SectorStatResult;
|
||||
high_volume: SectorStatResult;
|
||||
premium_traffic: SectorStatResult;
|
||||
long_tail: SectorStatResult;
|
||||
quick_wins: SectorStatResult;
|
||||
}
|
||||
|
||||
export interface SectorStatsItem {
|
||||
sector_id: number;
|
||||
sector_name: string;
|
||||
stats: SectorStats;
|
||||
}
|
||||
|
||||
export interface SectorStatsResponse {
|
||||
industry_id?: number;
|
||||
sector_id?: number;
|
||||
sectors?: SectorStatsItem[];
|
||||
stats?: SectorStats;
|
||||
}
|
||||
|
||||
export async function fetchSectorStats(filters: {
|
||||
industry_id: number;
|
||||
sector_id?: number;
|
||||
site_id?: number;
|
||||
}): Promise<SectorStatsResponse> {
|
||||
const params = new URLSearchParams();
|
||||
params.append('industry_id', filters.industry_id.toString());
|
||||
if (filters.sector_id) params.append('sector_id', filters.sector_id.toString());
|
||||
if (filters.site_id) params.append('site_id', filters.site_id.toString());
|
||||
|
||||
return fetchAPI(`/v1/auth/keywords-library/sector_stats/?${params.toString()}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Keywords Library - Filter Options (cascading)
|
||||
*/
|
||||
export interface FilterIndustryOption {
|
||||
id: number;
|
||||
name: string;
|
||||
slug: string;
|
||||
keyword_count: number;
|
||||
}
|
||||
|
||||
export interface FilterSectorOption {
|
||||
id: number;
|
||||
name: string;
|
||||
slug: string;
|
||||
keyword_count: number;
|
||||
}
|
||||
|
||||
export interface DifficultyLevel {
|
||||
level: number;
|
||||
label: string;
|
||||
backend_range: [number, number];
|
||||
}
|
||||
|
||||
export interface FilterOptionsResponse {
|
||||
industries: FilterIndustryOption[];
|
||||
sectors: FilterSectorOption[];
|
||||
difficulty: {
|
||||
range: { min_difficulty: number; max_difficulty: number };
|
||||
levels: DifficultyLevel[];
|
||||
};
|
||||
volume: { min_volume: number; max_volume: number };
|
||||
}
|
||||
|
||||
export async function fetchKeywordsLibraryFilterOptions(industryId?: number): Promise<FilterOptionsResponse> {
|
||||
const params = new URLSearchParams();
|
||||
if (industryId) params.append('industry_id', industryId.toString());
|
||||
const queryString = params.toString();
|
||||
|
||||
return fetchAPI(`/v1/auth/keywords-library/filter_options/${queryString ? `?${queryString}` : ''}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Keywords Library - Bulk Add to Site
|
||||
*/
|
||||
export interface BulkAddResponse {
|
||||
added: number;
|
||||
skipped: number;
|
||||
total_requested: number;
|
||||
}
|
||||
|
||||
export async function bulkAddKeywordsToSite(siteId: number, keywordIds: number[]): Promise<BulkAddResponse> {
|
||||
return fetchAPI('/v1/auth/keywords-library/bulk_add/', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
site_id: siteId,
|
||||
keyword_ids: keywordIds,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user