Files
igny8/frontend/src/pages/Setup/IndustriesSectorsKeywords.tsx

619 lines
23 KiB
TypeScript

/**
* Industries, Sectors & Keywords Setup Page
* Merged page combining:
* - Industry selection
* - Sector selection (from selected industry)
* - Keyword opportunities (filtered by selected industry/sectors)
*
* Saves selections to user account for use in site creation
*/
import { useState, useEffect, useCallback, useMemo } from 'react';
import PageMeta from '../../components/common/PageMeta';
import PageHeader from '../../components/common/PageHeader';
import { useToast } from '../../components/ui/toast/ToastContainer';
import {
fetchIndustries,
Industry,
fetchSeedKeywords,
SeedKeyword,
fetchAccountSetting,
createAccountSetting,
updateAccountSetting,
AccountSettingsError
} from '../../services/api';
import { Card } from '../../components/ui/card';
import Badge from '../../components/ui/badge/Badge';
import Button from '../../components/ui/button/Button';
import { PieChartIcon, CheckCircleIcon, BoltIcon } from '../../icons';
import { Tooltip } from '../../components/ui/tooltip/Tooltip';
import { Tabs, TabList, Tab, TabPanel } from '../../components/ui/tabs/Tabs';
import TablePageTemplate from '../../templates/TablePageTemplate';
import { usePageSizeStore } from '../../store/pageSizeStore';
import { getDifficultyLabelFromNumber, getDifficultyRange } from '../../utils/difficulty';
interface IndustryWithData extends Industry {
keywordsCount: number;
totalVolume: number;
sectors?: Array<{ slug: string; name: string; description?: string }>;
}
interface UserPreferences {
selectedIndustry?: string;
selectedSectors?: string[];
selectedKeywords?: number[];
}
// Format volume with k for thousands and m for millions
const formatVolume = (volume: number): string => {
if (volume >= 1000000) {
return `${(volume / 1000000).toFixed(1)}m`;
} else if (volume >= 1000) {
return `${(volume / 1000).toFixed(1)}k`;
}
return volume.toString();
};
const getAccountSettingsPreferenceMessage = (error: AccountSettingsError): string => {
switch (error.type) {
case 'ACCOUNT_SETTINGS_VALIDATION_ERROR':
return error.message || 'The saved preferences could not be loaded because the data is invalid.';
default:
return error.message || 'Unable to load your saved preferences right now.';
}
};
export default function IndustriesSectorsKeywords() {
const toast = useToast();
const { pageSize } = usePageSizeStore();
// Data state
const [industries, setIndustries] = useState<IndustryWithData[]>([]);
const [selectedIndustry, setSelectedIndustry] = useState<Industry | null>(null);
const [selectedSectors, setSelectedSectors] = useState<string[]>([]);
const [seedKeywords, setSeedKeywords] = useState<SeedKeyword[]>([]);
const [loading, setLoading] = useState(true);
const [keywordsLoading, setKeywordsLoading] = useState(false);
const [saving, setSaving] = useState(false);
// Keywords table state
const [currentPage, setCurrentPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [totalCount, setTotalCount] = useState(0);
const [searchTerm, setSearchTerm] = useState('');
const [intentFilter, setIntentFilter] = useState('');
const [difficultyFilter, setDifficultyFilter] = useState('');
const [selectedKeywordIds, setSelectedKeywordIds] = useState<string[]>([]);
// Load industries on mount
useEffect(() => {
loadIndustries();
loadUserPreferences();
}, []);
// Load user preferences from account settings
const loadUserPreferences = async () => {
try {
const setting = await fetchAccountSetting('user_preferences');
const preferences = setting.config as UserPreferences | undefined;
if (preferences) {
if (preferences.selectedIndustry) {
// Find and select the industry
const industry = industries.find(i => i.slug === preferences.selectedIndustry);
if (industry) {
setSelectedIndustry(industry);
if (preferences.selectedSectors) {
setSelectedSectors(preferences.selectedSectors);
}
}
}
}
} catch (error: any) {
if (error instanceof AccountSettingsError) {
if (error.type === 'ACCOUNT_SETTINGS_NOT_FOUND') {
// Preferences don't exist yet - this is expected for new users
return;
}
// For other errors (500, etc.), silently handle - user can still use the page
// Don't show error toast for server errors - graceful degradation
return;
}
// For non-AccountSettingsError errors, silently handle - graceful degradation
}
};
// Load industries
const loadIndustries = async () => {
try {
setLoading(true);
const response = await fetchIndustries();
const industriesList = response.industries || [];
// Fetch keywords to calculate counts
let allKeywords: SeedKeyword[] = [];
try {
const keywordsResponse = await fetchSeedKeywords({
page_size: 1000,
});
allKeywords = keywordsResponse.results || [];
} catch (error) {
console.warn('Failed to fetch keywords for counts:', error);
}
// Process each industry with its keywords data
const industriesWithData = industriesList.map((industry) => {
const industryKeywords = allKeywords.filter(
(kw: SeedKeyword) => kw.industry_name === industry.name
);
const totalVolume = industryKeywords.reduce(
(sum, kw) => sum + (kw.volume || 0),
0
);
return {
...industry,
keywordsCount: industryKeywords.length,
totalVolume,
};
});
setIndustries(industriesWithData.filter(i => i.keywordsCount > 0));
} catch (error: any) {
toast.error(`Failed to load industries: ${error.message}`);
} finally {
setLoading(false);
}
};
// Load sectors for selected industry
const loadSectorsForIndustry = useCallback(async () => {
if (!selectedIndustry) {
return;
}
try {
// Fetch sectors from the industry's related data
// The industry object should have sectors in the response
const response = await fetchIndustries();
const industryData = response.industries?.find(i => i.slug === selectedIndustry.slug);
if (industryData?.sectors) {
// Sectors are already in the industry data
}
} catch (error: any) {
console.error('Error loading sectors:', error);
}
}, [selectedIndustry]);
// Load keywords when industry/sectors change
useEffect(() => {
if (selectedIndustry) {
loadKeywords();
} else {
setSeedKeywords([]);
setTotalCount(0);
}
}, [selectedIndustry, selectedSectors, currentPage, searchTerm, intentFilter, difficultyFilter]);
// Load seed keywords
const loadKeywords = async () => {
if (!selectedIndustry) {
return;
}
setKeywordsLoading(true);
try {
const filters: any = {
industry_name: selectedIndustry.name,
page: currentPage,
page_size: pageSize,
};
if (selectedSectors.length > 0) {
// Filter by selected sectors if any
// Note: API might need sector_name filter
}
if (searchTerm) {
filters.search = searchTerm;
}
if (intentFilter) {
filters.intent = intentFilter;
}
if (difficultyFilter) {
const difficultyNum = parseInt(difficultyFilter);
const label = getDifficultyLabelFromNumber(difficultyNum);
if (label !== null) {
const range = getDifficultyRange(label);
if (range) {
filters.difficulty_min = range.min;
filters.difficulty_max = range.max;
}
}
}
const data = await fetchSeedKeywords(filters);
setSeedKeywords(data.results || []);
setTotalCount(data.count || 0);
setTotalPages(Math.ceil((data.count || 0) / pageSize));
} catch (error: any) {
toast.error(`Failed to load keywords: ${error.message}`);
} finally {
setKeywordsLoading(false);
}
};
// Handle industry selection
const handleIndustrySelect = (industry: Industry) => {
setSelectedIndustry(industry);
setSelectedSectors([]); // Reset sectors when industry changes
setCurrentPage(1);
};
// Handle sector toggle
const handleSectorToggle = (sectorSlug: string) => {
setSelectedSectors(prev =>
prev.includes(sectorSlug)
? prev.filter(s => s !== sectorSlug)
: [...prev, sectorSlug]
);
setCurrentPage(1);
};
// Save preferences to account
const handleSavePreferences = async () => {
if (!selectedIndustry) {
toast.error('Please select an industry first');
return;
}
setSaving(true);
try {
const preferences: UserPreferences = {
selectedIndustry: selectedIndustry.slug,
selectedSectors: selectedSectors,
selectedKeywords: selectedKeywordIds.map(id => parseInt(id)),
};
// Try to update existing setting, or create if it doesn't exist
try {
await updateAccountSetting('user_preferences', {
config: preferences,
is_active: true,
});
} catch (error: any) {
if (error instanceof AccountSettingsError && error.type === 'ACCOUNT_SETTINGS_NOT_FOUND') {
await createAccountSetting({
key: 'user_preferences',
config: preferences,
is_active: true,
});
} else {
throw error;
}
}
toast.success('Preferences saved successfully! These will be used when creating new sites.');
} catch (error: any) {
if (error instanceof AccountSettingsError) {
toast.error(getAccountSettingsPreferenceMessage(error));
} else {
toast.error(`Failed to save preferences: ${error.message}`);
}
} finally {
setSaving(false);
}
};
// Get available sectors for selected industry
const availableSectors = useMemo(() => {
if (!selectedIndustry) return [];
// Get sectors from industry data or fetch separately
// For now, we'll use the industry's sectors if available
return selectedIndustry.sectors || [];
}, [selectedIndustry]);
// Keywords table columns
const keywordColumns = useMemo(() => [
{
key: 'keyword',
label: 'Keyword',
sortable: true,
render: (row: SeedKeyword) => (
<div className="font-medium text-gray-900 dark:text-white">
{row.keyword}
</div>
),
},
{
key: 'industry_name',
label: 'Industry',
sortable: true,
render: (row: SeedKeyword) => (
<Badge variant="light" color="blue">{row.industry_name}</Badge>
),
},
{
key: 'sector_name',
label: 'Sector',
sortable: true,
render: (row: SeedKeyword) => (
<Badge variant="light" color="green">{row.sector_name}</Badge>
),
},
{
key: 'volume',
label: 'Volume',
sortable: true,
render: (row: SeedKeyword) => (
<span className="text-gray-900 dark:text-white">
{row.volume ? formatVolume(row.volume) : '-'}
</span>
),
},
{
key: 'difficulty',
label: 'Difficulty',
sortable: true,
render: (row: SeedKeyword) => {
const difficulty = row.difficulty || 0;
const label = difficulty < 30 ? 'Easy' : difficulty < 70 ? 'Medium' : 'Hard';
const color = difficulty < 30 ? 'success' : difficulty < 70 ? 'warning' : 'error';
return <Badge variant="light" color={color}>{label}</Badge>;
},
},
{
key: 'intent',
label: 'Intent',
sortable: true,
render: (row: SeedKeyword) => (
<Badge variant="light" color="purple">{row.intent || 'N/A'}</Badge>
),
},
], []);
return (
<>
<PageMeta title="Industries, Sectors & Keywords Setup" />
<PageHeader
title="Industries, Sectors & Keywords"
badge={{ icon: <PieChartIcon />, color: 'blue' }}
hideSiteSector={true}
/>
<div className="p-6">
<Tabs defaultTab="industries">
{(activeTab, setActiveTab) => (
<>
<TabList className="mb-6">
<Tab tabId="industries" isActive={activeTab === 'industries'} onClick={() => setActiveTab('industries')}>
Industries
</Tab>
<Tab tabId="sectors" isActive={activeTab === 'sectors'} onClick={() => setActiveTab('sectors')} disabled={!selectedIndustry}>
Sectors {selectedIndustry && `(${selectedSectors.length})`}
</Tab>
<Tab tabId="keywords" isActive={activeTab === 'keywords'} onClick={() => setActiveTab('keywords')} disabled={!selectedIndustry}>
Keywords {selectedIndustry && `(${totalCount})`}
</Tab>
</TabList>
<TabPanel tabId="industries" isActive={activeTab === 'industries'}>
<div className="space-y-6">
<div>
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-2">
Select Your Industry
</h2>
<p className="text-sm text-gray-600 dark:text-gray-400">
Choose the industry that best matches your business. This will be used as a default when creating new sites.
</p>
</div>
{loading ? (
<div className="flex items-center justify-center h-64">
<div className="text-gray-500">Loading industries...</div>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{industries.map((industry) => (
<Card
key={industry.slug}
className={`p-4 hover:shadow-lg transition-all duration-200 border-2 cursor-pointer ${
selectedIndustry?.slug === industry.slug
? 'border-[var(--color-primary)] bg-blue-50 dark:bg-blue-900/20'
: 'border-gray-200 dark:border-gray-700'
}`}
onClick={() => handleIndustrySelect(industry)}
>
<div className="flex justify-between items-start mb-3">
<h3 className="text-base font-bold text-gray-900 dark:text-white leading-tight">
{industry.name}
</h3>
{selectedIndustry?.slug === industry.slug && (
<CheckCircleIcon className="w-5 h-5 text-[var(--color-primary)] flex-shrink-0" />
)}
</div>
{industry.description && (
<p className="text-xs text-gray-600 dark:text-gray-400 mb-3 line-clamp-2">
{industry.description}
</p>
)}
<div className="flex items-center gap-4 mb-3 text-xs">
<div className="flex items-center gap-1 text-gray-600 dark:text-gray-400">
<span className="font-medium">{industry.sectors?.length || 0}</span>
<span className="text-gray-500">sectors</span>
</div>
<div className="flex items-center gap-1 text-gray-600 dark:text-gray-400">
<span className="font-medium">{industry.keywordsCount || 0}</span>
<span className="text-gray-500">keywords</span>
</div>
</div>
{industry.totalVolume > 0 && (
<div className="mt-3 pt-3 border-t border-gray-200 dark:border-gray-700">
<div className="flex items-center gap-1 text-xs text-gray-600 dark:text-gray-400">
<BoltIcon className="w-3.5 h-3.5" />
<span>Total volume: {formatVolume(industry.totalVolume)}</span>
</div>
</div>
)}
</Card>
))}
</div>
)}
</div>
</TabPanel>
<TabPanel tabId="sectors" isActive={activeTab === 'sectors'}>
<div className="space-y-6">
<div>
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-2">
Select Sectors for {selectedIndustry?.name}
</h2>
<p className="text-sm text-gray-600 dark:text-gray-400">
Choose one or more sectors within your selected industry. These will be used as defaults when creating new sites.
</p>
</div>
{!selectedIndustry ? (
<div className="text-center py-12 text-gray-500">
Please select an industry first
</div>
) : availableSectors.length === 0 ? (
<div className="text-center py-12 text-gray-500">
No sectors available for this industry
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{availableSectors.map((sector) => (
<Card
key={sector.slug}
className={`p-4 hover:shadow-lg transition-all duration-200 border-2 cursor-pointer ${
selectedSectors.includes(sector.slug)
? 'border-[var(--color-primary)] bg-blue-50 dark:bg-blue-900/20'
: 'border-gray-200 dark:border-gray-700'
}`}
onClick={() => handleSectorToggle(sector.slug)}
>
<div className="flex justify-between items-start mb-2">
<h3 className="text-base font-semibold text-gray-900 dark:text-white">
{sector.name}
</h3>
{selectedSectors.includes(sector.slug) && (
<CheckCircleIcon className="w-5 h-5 text-[var(--color-primary)] flex-shrink-0" />
)}
</div>
{sector.description && (
<p className="text-xs text-gray-600 dark:text-gray-400">
{sector.description}
</p>
)}
</Card>
))}
</div>
)}
</div>
</TabPanel>
<TabPanel tabId="keywords" isActive={activeTab === 'keywords'}>
<div className="space-y-6">
<div>
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-2">
Keyword Opportunities for {selectedIndustry?.name}
</h2>
<p className="text-sm text-gray-600 dark:text-gray-400">
Browse and select keywords that match your selected industry and sectors. These will be available when creating new sites.
</p>
</div>
{!selectedIndustry ? (
<div className="text-center py-12 text-gray-500">
Please select an industry first
</div>
) : (
<TablePageTemplate
columns={keywordColumns}
data={seedKeywords}
loading={keywordsLoading}
showContent={!keywordsLoading}
filters={[
{
key: 'search',
label: 'Search',
type: 'text',
placeholder: 'Search keywords...',
},
{
key: 'intent',
label: 'Intent',
type: 'select',
options: [
{ value: '', label: 'All' },
{ value: 'informational', label: 'Informational' },
{ value: 'navigational', label: 'Navigational' },
{ value: 'transactional', label: 'Transactional' },
{ value: 'commercial', label: 'Commercial' },
],
},
{
key: 'difficulty',
label: 'Difficulty',
type: 'select',
options: [
{ value: '', label: 'All' },
{ value: '0', label: 'Easy (0-30)' },
{ value: '30', label: 'Medium (30-70)' },
{ value: '70', label: 'Hard (70-100)' },
],
},
]}
filterValues={{
search: searchTerm,
intent: intentFilter,
difficulty: difficultyFilter,
}}
onFilterChange={(key, value) => {
if (key === 'search') setSearchTerm(value as string);
else if (key === 'intent') setIntentFilter(value as string);
else if (key === 'difficulty') setDifficultyFilter(value as string);
setCurrentPage(1);
}}
pagination={{
currentPage,
totalPages,
totalCount,
onPageChange: setCurrentPage,
}}
selection={{
selectedIds: selectedKeywordIds,
onSelectionChange: setSelectedKeywordIds,
}}
/>
)}
</div>
</TabPanel>
</>
)}
</Tabs>
{/* Save Button */}
{selectedIndustry && (
<div className="mt-6 flex justify-end">
<Button
onClick={handleSavePreferences}
disabled={saving}
variant="primary"
>
{saving ? 'Saving...' : 'Save Preferences'}
</Button>
</div>
)}
</div>
</>
);
}