Files
igny8/frontend/src/pages/Setup/IndustriesSectorsKeywords.tsx
IGNY8 VPS (Salman) 33ac4be8df sdsd
2025-12-13 11:34:36 +00:00

809 lines
26 KiB
TypeScript

/**
* Add Keywords Page
* Browse global seed keywords filtered by site's industry and sectors
* Add keywords to workflow (creates Keywords records in planner)
* Admin users can import seed keywords via CSV
*/
import { useState, useEffect, useRef, useMemo, useCallback } from 'react';
import PageMeta from '../../components/common/PageMeta';
import PageHeader from '../../components/common/PageHeader';
import { useToast } from '../../components/ui/toast/ToastContainer';
import WorkflowGuide from '../../components/onboarding/WorkflowGuide';
import {
fetchSeedKeywords,
SeedKeyword,
SeedKeywordResponse,
fetchSites,
Site,
addSeedKeywordsToWorkflow,
} from '../../services/api';
import Badge from '../../components/ui/badge/Badge';
import { BoltIcon, PlusIcon } from '../../icons';
import TablePageTemplate from '../../templates/TablePageTemplate';
import { usePageSizeStore } from '../../store/pageSizeStore';
import { getDifficultyNumber, getDifficultyRange, getDifficultyLabelFromNumber } from '../../utils/difficulty';
import { useSiteStore } from '../../store/siteStore';
import { useSectorStore } from '../../store/sectorStore';
import { useAuthStore } from '../../store/authStore';
import Button from '../../components/ui/button/Button';
import { Modal } from '../../components/ui/modal';
import FileInput from '../../components/form/input/FileInput';
import Label from '../../components/form/Label';
export default function IndustriesSectorsKeywords() {
const toast = useToast();
const { activeSite } = useSiteStore();
const { activeSector, loadSectorsForSite } = useSectorStore();
const { pageSize } = usePageSizeStore();
const { user } = useAuthStore();
// Data state
const [sites, setSites] = useState<Site[]>([]);
const [seedKeywords, setSeedKeywords] = useState<(SeedKeyword & { isAdded?: boolean })[]>([]);
const [loading, setLoading] = useState(true);
const [showContent, setShowContent] = useState(false);
const [selectedIds, setSelectedIds] = useState<string[]>([]);
// Track recently added keywords to preserve their state during reload
const recentlyAddedRef = useRef<Set<number>>(new Set());
// Pagination state
const [currentPage, setCurrentPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [totalCount, setTotalCount] = useState(0);
// Sorting state
const [sortBy, setSortBy] = useState<string>('keyword');
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc');
// Filter state
const [searchTerm, setSearchTerm] = useState('');
const [intentFilter, setIntentFilter] = useState('');
const [difficultyFilter, setDifficultyFilter] = useState('');
// Check if user is admin/superuser (role is 'admin' or 'developer')
const isAdmin = user?.role === 'admin' || user?.role === 'developer';
// Import modal state
const [isImportModalOpen, setIsImportModalOpen] = useState(false);
const [isImporting, setIsImporting] = useState(false);
const [importFile, setImportFile] = useState<File | null>(null);
// Load sites on mount
useEffect(() => {
loadInitialData();
}, []);
const loadInitialData = async () => {
try {
setLoading(true);
const response = await fetchSites();
const activeSites = (response.results || []).filter(site => site.is_active);
setSites(activeSites);
} catch (error: any) {
toast.error(`Failed to load sites: ${error.message}`);
} finally {
setLoading(false);
}
};
// Handle site added from WorkflowGuide
const handleSiteAdded = () => {
loadInitialData();
};
// Load sectors for active site
useEffect(() => {
if (activeSite?.id) {
loadSectorsForSite(activeSite.id);
}
}, [activeSite?.id, loadSectorsForSite]);
// Load seed keywords
const loadSeedKeywords = useCallback(async () => {
if (!activeSite || !activeSite.industry) {
setSeedKeywords([]);
setTotalCount(0);
setTotalPages(1);
setShowContent(true);
return;
}
setShowContent(false);
try {
// Get already-attached keywords across ALL sectors for this site
let attachedSeedKeywordIds = new Set<number>();
try {
const { fetchKeywords, fetchSiteSectors } = await import('../../services/api');
// Get all sectors for the site
const sectors = await fetchSiteSectors(activeSite.id);
// Check keywords in all sectors
for (const sector of sectors) {
try {
const keywordsData = await fetchKeywords({
site_id: activeSite.id,
sector_id: sector.id,
page_size: 1000,
});
(keywordsData.results || []).forEach((k: any) => {
const seedKeywordId = k.seed_keyword_id || (k.seed_keyword && k.seed_keyword.id);
if (seedKeywordId) {
attachedSeedKeywordIds.add(Number(seedKeywordId));
}
});
} catch (err) {
console.warn(`Could not fetch attached keywords for sector ${sector.id}:`, err);
}
}
} catch (err) {
console.warn('Could not fetch sectors or attached keywords:', err);
}
// Build filters - fetch ALL results by paginating through all pages
const baseFilters: any = {
industry: activeSite.industry,
page_size: 1000,
};
// Add sector filter if active sector is selected
if (activeSector && activeSector.industry_sector) {
baseFilters.sector = activeSector.industry_sector;
}
if (searchTerm) baseFilters.search = searchTerm;
if (intentFilter) baseFilters.intent = intentFilter;
// Fetch ALL pages to get complete dataset
let allResults: SeedKeyword[] = [];
let currentPageNum = 1;
let hasMore = true;
while (hasMore) {
const filters = { ...baseFilters, page: currentPageNum };
const data: SeedKeywordResponse = await fetchSeedKeywords(filters);
if (data.results && data.results.length > 0) {
allResults = [...allResults, ...data.results];
}
hasMore = data.next !== null && data.next !== undefined;
currentPageNum++;
if (currentPageNum > 100) {
console.warn('Reached maximum page limit (100) while fetching seed keywords');
break;
}
}
// Mark already-attached keywords
let filteredResults = allResults.map(sk => {
const isAdded = attachedSeedKeywordIds.has(Number(sk.id)) || recentlyAddedRef.current.has(Number(sk.id));
return {
...sk,
isAdded: Boolean(isAdded)
};
});
// Apply difficulty filter
if (difficultyFilter) {
const difficultyNum = parseInt(difficultyFilter);
const label = getDifficultyLabelFromNumber(difficultyNum);
if (label !== null) {
const range = getDifficultyRange(label);
if (range) {
filteredResults = filteredResults.filter(
sk => sk.difficulty >= range.min && sk.difficulty <= range.max
);
}
}
}
// Apply client-side sorting
if (sortBy) {
filteredResults.sort((a, b) => {
let aVal: any;
let bVal: any;
if (sortBy === 'keyword') {
aVal = a.keyword.toLowerCase();
bVal = b.keyword.toLowerCase();
} else if (sortBy === 'volume') {
aVal = a.volume;
bVal = b.volume;
} else if (sortBy === 'difficulty') {
aVal = a.difficulty;
bVal = b.difficulty;
} else if (sortBy === 'intent') {
aVal = a.intent.toLowerCase();
bVal = b.intent.toLowerCase();
} else {
return 0;
}
if (aVal < bVal) return sortDirection === 'asc' ? -1 : 1;
if (aVal > bVal) return sortDirection === 'asc' ? 1 : -1;
return 0;
});
}
// Calculate total count and pages from filtered results
const totalFiltered = filteredResults.length;
const pageSizeNum = pageSize || 10;
// Apply client-side pagination
const startIndex = (currentPage - 1) * pageSizeNum;
const endIndex = startIndex + pageSizeNum;
const paginatedResults = filteredResults.slice(startIndex, endIndex);
setSeedKeywords(paginatedResults);
setTotalCount(totalFiltered);
setTotalPages(Math.ceil(totalFiltered / pageSizeNum));
setShowContent(true);
} catch (error: any) {
console.error('Error loading seed keywords:', error);
toast.error(`Failed to load keywords: ${error.message}`);
setSeedKeywords([]);
setTotalCount(0);
setTotalPages(1);
}
}, [activeSite, activeSector, currentPage, pageSize, searchTerm, intentFilter, difficultyFilter, sortBy, sortDirection, toast]);
// Load data on mount and when filters change
useEffect(() => {
if (activeSite) {
loadSeedKeywords();
}
}, [loadSeedKeywords, activeSite]);
// Debounced search
useEffect(() => {
const timer = setTimeout(() => {
setCurrentPage(1);
}, 500);
return () => clearTimeout(timer);
}, [searchTerm]);
// Reset to page 1 on pageSize change
useEffect(() => {
setCurrentPage(1);
}, [pageSize]);
// Handle sorting
const handleSort = (field: string, direction: 'asc' | 'desc') => {
setSortBy(field || 'keyword');
setSortDirection(direction);
setCurrentPage(1);
};
// Handle adding keywords to workflow
const handleAddToWorkflow = useCallback(async (seedKeywordIds: number[]) => {
if (!activeSite) {
toast.error('Please select an active site first');
return;
}
// Get sector to use
let sectorToUse = activeSector;
if (!sectorToUse) {
try {
const { fetchSiteSectors } = await import('../../services/api');
const sectors = await fetchSiteSectors(activeSite.id);
if (sectors.length === 0) {
toast.error('No sectors available for this site. Please create a sector first.');
return;
}
sectorToUse = {
id: sectors[0].id,
name: sectors[0].name,
slug: sectors[0].slug,
site_id: activeSite.id,
is_active: sectors[0].is_active !== false,
industry_sector: sectors[0].industry_sector || null,
};
} catch (error: any) {
toast.error(`Failed to get sectors: ${error.message}`);
return;
}
}
try {
const result = await addSeedKeywordsToWorkflow(
seedKeywordIds,
activeSite.id,
sectorToUse.id
);
if (result.success) {
// Show success message with created count
if (result.created > 0) {
toast.success(`Successfully added ${result.created} keyword(s) to workflow`);
}
// Show skipped count if any
if (result.skipped > 0) {
toast.warning(`${result.skipped} keyword(s) were skipped (already exist or validation failed)`);
}
// Show detailed errors if any
if (result.errors && result.errors.length > 0) {
result.errors.forEach((error: string) => {
toast.error(error);
});
}
// Only track and mark as added if actually created
if (result.created > 0) {
// Track as recently added
seedKeywordIds.forEach(id => {
recentlyAddedRef.current.add(id);
});
// Update state - mark as added
setSeedKeywords(prevKeywords =>
prevKeywords.map(kw =>
seedKeywordIds.includes(kw.id)
? { ...kw, isAdded: true }
: kw
)
);
}
// Clear selection
setSelectedIds([]);
} else {
// Show user-friendly error message (errors array already contains clean messages)
const errorMsg = result.errors?.[0] || 'Unable to add keywords. Please try again.';
toast.error(errorMsg);
}
} catch (error: any) {
// Show user-friendly error message (error.message is already cleaned)
toast.error(error.message || 'Unable to add keywords. Please try again.');
}
}, [activeSite, activeSector, toast]);
// Handle bulk add selected
const handleBulkAddSelected = useCallback(async (ids: string[]) => {
if (ids.length === 0) {
toast.error('Please select at least one keyword');
return;
}
// Filter out already added keywords
const availableIds = ids.filter(id => {
const keyword = seedKeywords.find(sk => String(sk.id) === id);
return keyword && !keyword.isAdded;
});
if (availableIds.length === 0) {
toast.error('All selected keywords are already added to workflow');
return;
}
if (availableIds.length < ids.length) {
toast.info(`${ids.length - availableIds.length} keyword(s) were already added and were skipped`);
}
const seedKeywordIds = availableIds.map(id => parseInt(id));
await handleAddToWorkflow(seedKeywordIds);
}, [handleAddToWorkflow, toast, seedKeywords]);
// Handle add all
const handleAddAll = useCallback(async () => {
if (!activeSite || !activeSite.industry) {
toast.error('Please select an active site first');
return;
}
const availableKeywords = seedKeywords.filter(sk => !sk.isAdded);
if (availableKeywords.length === 0) {
toast.error('All keywords are already added to workflow');
return;
}
const seedKeywordIds = availableKeywords.map(sk => sk.id);
await handleAddToWorkflow(seedKeywordIds);
}, [activeSite, seedKeywords, handleAddToWorkflow, toast]);
// Handle import click
const handleImportClick = useCallback(() => {
setIsImportModalOpen(true);
}, []);
// Handle import file upload
const handleImportSubmit = useCallback(async () => {
if (!importFile) {
toast.error('Please select a file to import');
return;
}
setIsImporting(true);
try {
const formData = new FormData();
formData.append('file', importFile);
const response = await fetch('/api/v1/auth/seed-keywords/import_seed_keywords/', {
method: 'POST',
headers: {
'Authorization': `Token ${localStorage.getItem('authToken')}`,
},
body: formData,
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || 'Import failed');
}
const result = await response.json();
toast.success(`Successfully imported ${result.created || 0} keywords`);
// Reset and close modal
setImportFile(null);
setIsImportModalOpen(false);
// Reload keywords
if (activeSite) {
loadSeedKeywords();
}
} catch (error: any) {
console.error('Import error:', error);
toast.error(`Import failed: ${error.message}`);
} finally {
setIsImporting(false);
}
}, [importFile, toast, activeSite, loadSeedKeywords]);
// Page config
const pageConfig = useMemo(() => {
const showSectorColumn = !activeSector;
return {
columns: [
{
key: 'keyword',
label: 'Keyword',
sortable: true,
sortField: 'keyword',
},
...(showSectorColumn ? [{
key: 'sector_name',
label: 'Sector',
sortable: false,
render: (_value: string, row: SeedKeyword) => (
<Badge color="info" size="sm" variant="light">
{row.sector_name || '-'}
</Badge>
),
}] : []),
{
key: 'volume',
label: 'Volume',
sortable: true,
sortField: 'volume',
render: (value: number) => value.toLocaleString(),
},
{
key: 'difficulty',
label: 'Difficulty',
sortable: true,
sortField: 'difficulty',
align: 'center' as const,
render: (value: number) => {
const difficultyNum = getDifficultyNumber(value);
const difficultyBadgeVariant = 'light';
const difficultyBadgeColor =
typeof difficultyNum === 'number' && difficultyNum === 1
? 'success'
: typeof difficultyNum === 'number' && difficultyNum === 2
? 'success'
: typeof difficultyNum === 'number' && difficultyNum === 3
? 'warning'
: typeof difficultyNum === 'number' && difficultyNum === 4
? 'error'
: typeof difficultyNum === 'number' && difficultyNum === 5
? 'error'
: 'light';
return typeof difficultyNum === 'number' ? (
<Badge
color={difficultyBadgeColor}
variant={difficultyBadgeVariant}
size="sm"
>
{difficultyNum}
</Badge>
) : (
difficultyNum
);
},
},
{
key: 'intent',
label: 'Intent',
sortable: true,
sortField: 'intent',
render: (value: string) => {
const getIntentColor = (intent: string) => {
const lowerIntent = intent?.toLowerCase() || '';
if (lowerIntent === 'transactional' || lowerIntent === 'commercial') {
return 'success';
} else if (lowerIntent === 'navigational') {
return 'warning';
}
return 'info';
};
return (
<Badge
color={getIntentColor(value)}
size="sm"
variant={value?.toLowerCase() === 'informational' ? 'light' : undefined}
>
{value}
</Badge>
);
},
},
{
key: 'actions',
label: '',
sortable: false,
align: 'right' as const,
render: (_value: any, row: SeedKeyword & { isAdded?: boolean }) => {
const isDisabled = !activeSector || row.isAdded;
const buttonText = row.isAdded ? 'Added' : 'Add to Workflow';
return (
<div className="flex items-center justify-end gap-2">
<Button
size="sm"
variant={row.isAdded ? 'ghost' : 'primary'}
disabled={isDisabled}
onClick={() => {
if (!row.isAdded && activeSector) {
handleAddToWorkflow([row.id]);
}
}}
title={!activeSector ? 'Please select a sector first' : ''}
>
{buttonText}
</Button>
</div>
);
},
},
],
filters: [
{
key: 'search',
label: 'Search',
type: 'text' as const,
placeholder: 'Search keywords...',
},
{
key: 'intent',
label: 'Intent',
type: 'select' as const,
options: [
{ value: '', label: 'All Intent' },
{ value: 'informational', label: 'Informational' },
{ value: 'navigational', label: 'Navigational' },
{ value: 'transactional', label: 'Transactional' },
{ value: 'commercial', label: 'Commercial' },
],
},
{
key: 'difficulty',
label: 'Difficulty',
type: 'select' as const,
options: [
{ value: '', label: 'All Difficulty' },
{ value: '1', label: '1 - Very Easy' },
{ value: '2', label: '2 - Easy' },
{ value: '3', label: '3 - Medium' },
{ value: '4', label: '4 - Hard' },
{ value: '5', label: '5 - Very Hard' },
],
},
],
bulkActions: !activeSector ? [] : [
{
key: 'add_selected_to_workflow',
label: 'Add Selected to Workflow',
variant: 'primary' as const,
},
],
};
}, [activeSector, handleAddToWorkflow]);
// Show loading state
if (loading) {
return (
<>
<PageMeta title="Add Keywords" description="Browse and add keywords to your workflow" />
<PageHeader
title="Add Keywords"
badge={{ icon: <BoltIcon />, color: 'blue' }}
/>
<div className="p-6">
<div className="flex items-center justify-center h-64">
<div className="text-gray-500">Loading...</div>
</div>
</div>
</>
);
}
// Show WorkflowGuide if no sites
if (sites.length === 0) {
return (
<>
<PageMeta title="Add Keywords" description="Browse and add keywords to your workflow" />
<PageHeader
title="Add Keywords"
badge={{ icon: <BoltIcon />, color: 'blue' }}
/>
<div className="p-6">
<WorkflowGuide onSiteAdded={handleSiteAdded} />
</div>
</>
);
}
return (
<>
<PageMeta title="Add Keywords" description="Browse and add keywords to your workflow" />
<PageHeader
title="Add Keywords"
badge={{ icon: <BoltIcon />, color: 'blue' }}
/>
{/* Show info banner when no sector is selected */}
{!activeSector && activeSite && (
<div className="mx-6 mt-6 mb-4">
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
<div className="flex items-start gap-3">
<div className="flex-shrink-0">
<svg className="w-5 h-5 text-blue-600 dark:text-blue-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-blue-900 dark:text-blue-200">
Select a Sector to Add Keywords
</h3>
<p className="mt-1 text-sm text-blue-700 dark:text-blue-300">
Please select a sector from the dropdown above to enable adding keywords to your workflow. Keywords must be added to a specific sector.
</p>
</div>
</div>
</div>
</div>
)}
<TablePageTemplate
columns={pageConfig.columns}
data={seedKeywords}
loading={!showContent}
showContent={showContent}
filters={pageConfig.filters}
filterValues={{
search: searchTerm,
intent: intentFilter,
difficulty: difficultyFilter,
}}
onFilterChange={(key, value) => {
const stringValue = value === null || value === undefined ? '' : String(value);
if (key === 'search') {
setSearchTerm(stringValue);
} else if (key === 'intent') {
setIntentFilter(stringValue);
setCurrentPage(1);
} else if (key === 'difficulty') {
setDifficultyFilter(stringValue);
setCurrentPage(1);
}
}}
onBulkAction={async (actionKey: string, ids: string[]) => {
if (actionKey === 'add_selected_to_workflow') {
await handleBulkAddSelected(ids);
}
}}
bulkActions={pageConfig.bulkActions}
customActions={
isAdmin ? (
<Button
variant="secondary"
size="sm"
onClick={handleImportClick}
>
<PlusIcon className="w-4 h-4 mr-2" />
Import Keywords
</Button>
) : undefined
}
pagination={{
currentPage,
totalPages,
totalCount,
onPageChange: setCurrentPage,
}}
sorting={{
sortBy,
sortDirection,
onSort: handleSort,
}}
selection={{
selectedIds,
onSelectionChange: setSelectedIds,
}}
// Only show row actions for admin users
onEdit={isAdmin ? undefined : undefined}
onDelete={undefined}
/>
{/* Import Modal */}
<Modal
isOpen={isImportModalOpen}
onClose={() => {
if (!isImporting) {
setIsImportModalOpen(false);
setImportFile(null);
}
}}
>
<div className="p-6 space-y-4">
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-4">
Import Seed Keywords
</h2>
<div>
<Label htmlFor="import-file">Upload CSV File</Label>
<p className="text-sm text-gray-500 dark:text-gray-400 mb-2">
Expected columns: keyword, volume, difficulty, intent, industry_name, sector_name
</p>
<FileInput
accept=".csv"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) {
setImportFile(file);
}
}}
disabled={isImporting}
/>
{importFile && (
<p className="text-sm text-gray-600 dark:text-gray-400 mt-2">
Selected: {importFile.name}
</p>
)}
</div>
<div className="flex justify-end gap-2 pt-4">
<Button
variant="secondary"
onClick={() => {
setIsImportModalOpen(false);
setImportFile(null);
}}
disabled={isImporting}
>
Cancel
</Button>
<Button
variant="primary"
onClick={handleImportSubmit}
disabled={!importFile || isImporting}
>
{isImporting ? 'Importing...' : 'Import'}
</Button>
</div>
</div>
</Modal>
</>
);
}