619 lines
23 KiB
TypeScript
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>
|
|
</>
|
|
);
|
|
}
|
|
|