609 lines
21 KiB
TypeScript
609 lines
21 KiB
TypeScript
import { useState, useEffect, useCallback } from 'react';
|
|
import PageMeta from '../../components/common/PageMeta';
|
|
import SiteCard from '../../components/common/SiteCard';
|
|
import FormModal, { FormField } from '../../components/common/FormModal';
|
|
import Button from '../../components/ui/button/Button';
|
|
import { useToast } from '../../components/ui/toast/ToastContainer';
|
|
import Alert from '../../components/ui/alert/Alert';
|
|
import {
|
|
fetchSites,
|
|
createSite,
|
|
updateSite,
|
|
deleteSite,
|
|
setActiveSite,
|
|
selectSectorsForSite,
|
|
fetchIndustries,
|
|
fetchSiteSectors,
|
|
Site,
|
|
Industry,
|
|
Sector,
|
|
} from '../../services/api';
|
|
import Badge from '../../components/ui/badge/Badge';
|
|
import ApiStatusMonitor from '../../components/debug/ApiStatusMonitor';
|
|
|
|
// Site Icon SVG
|
|
const SiteIcon = () => (
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="40" height="40" viewBox="0 0 40 40" fill="none">
|
|
<rect width="40" height="40" rx="8" fill="#3B82F6"/>
|
|
<path d="M12 16L20 10L28 16V28C28 28.5304 27.7893 29.0391 27.4142 29.4142C27.0391 29.7893 26.5304 30 26 30H14C13.4696 30 12.9609 29.7893 12.5858 29.4142C12.2107 29.0391 12 28.5304 12 28V16Z" stroke="white" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
|
<path d="M16 30V20H24V30" stroke="white" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
|
</svg>
|
|
);
|
|
|
|
export default function Sites() {
|
|
const toast = useToast();
|
|
const [sites, setSites] = useState<Site[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [selectedSite, setSelectedSite] = useState<Site | null>(null);
|
|
const [showSiteModal, setShowSiteModal] = useState(false);
|
|
const [showSectorsModal, setShowSectorsModal] = useState(false);
|
|
const [showDetailsModal, setShowDetailsModal] = useState(false);
|
|
const [isSaving, setIsSaving] = useState(false);
|
|
const [togglingSiteId, setTogglingSiteId] = useState<number | null>(null);
|
|
const [industries, setIndustries] = useState<Industry[]>([]);
|
|
const [selectedIndustry, setSelectedIndustry] = useState<string>('');
|
|
const [selectedSectors, setSelectedSectors] = useState<string[]>([]);
|
|
const [isSelectingSectors, setIsSelectingSectors] = useState(false);
|
|
// Form state for site creation/editing
|
|
const [formData, setFormData] = useState({
|
|
name: '',
|
|
domain: '',
|
|
description: '',
|
|
is_active: true, // Default to true to match backend model default
|
|
});
|
|
|
|
// Load sites and industries
|
|
useEffect(() => {
|
|
loadSites();
|
|
loadIndustries();
|
|
}, []);
|
|
|
|
const loadSites = async () => {
|
|
try {
|
|
setLoading(true);
|
|
const response = await fetchSites();
|
|
setSites(response.results || []);
|
|
} catch (error: any) {
|
|
toast.error(`Failed to load sites: ${error.message}`);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const loadIndustries = async () => {
|
|
try {
|
|
const response = await fetchIndustries();
|
|
setIndustries(response.industries || []);
|
|
} catch (error: any) {
|
|
toast.error(`Failed to load industries: ${error.message}`);
|
|
}
|
|
};
|
|
|
|
const handleToggle = async (siteId: number, enabled: boolean) => {
|
|
// Prevent multiple simultaneous toggle operations
|
|
if (togglingSiteId !== null) {
|
|
toast.error('Please wait for the current operation to complete');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
setTogglingSiteId(siteId);
|
|
if (enabled) {
|
|
// Activate site (multiple sites can be active simultaneously)
|
|
await setActiveSite(siteId);
|
|
toast.success('Site activated successfully');
|
|
} else {
|
|
// Deactivate site - only this specific site
|
|
const site = sites.find(s => s.id === siteId);
|
|
if (site) {
|
|
await updateSite(siteId, { is_active: false });
|
|
toast.success('Site deactivated successfully');
|
|
}
|
|
}
|
|
await loadSites();
|
|
} catch (error: any) {
|
|
toast.error(`Failed to update site: ${error.message}`);
|
|
} finally {
|
|
setTogglingSiteId(null);
|
|
}
|
|
};
|
|
|
|
const handleSettings = (site: Site) => {
|
|
setSelectedSite(site);
|
|
setShowSectorsModal(true);
|
|
// Load current sectors for this site
|
|
loadSiteSectors(site);
|
|
};
|
|
|
|
const loadSiteSectors = async (site: Site) => {
|
|
try {
|
|
const sectors = await fetchSiteSectors(site.id);
|
|
const sectorSlugs = sectors.map((s: any) => s.slug);
|
|
setSelectedSectors(sectorSlugs);
|
|
|
|
// Use site's industry if available, otherwise try to determine from sectors
|
|
if (site.industry_slug) {
|
|
setSelectedIndustry(site.industry_slug);
|
|
} else {
|
|
// Fallback: try to determine industry from sectors
|
|
for (const industry of industries) {
|
|
const matchingSectors = industry.sectors.filter(s => sectorSlugs.includes(s.slug));
|
|
if (matchingSectors.length > 0) {
|
|
setSelectedIndustry(industry.slug);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
} catch (error: any) {
|
|
console.error('Failed to load site sectors:', error);
|
|
}
|
|
};
|
|
|
|
const handleDetails = (site: Site) => {
|
|
setSelectedSite(site);
|
|
setFormData({
|
|
name: site.name || '',
|
|
domain: site.domain || '',
|
|
description: site.description || '',
|
|
is_active: site.is_active || false,
|
|
});
|
|
setShowDetailsModal(true);
|
|
};
|
|
|
|
const handleSaveDetails = async () => {
|
|
if (!selectedSite) return;
|
|
|
|
try {
|
|
setIsSaving(true);
|
|
// Normalize domain before sending
|
|
const normalizedFormData = {
|
|
...formData,
|
|
domain: formData.domain ? normalizeDomain(formData.domain) : formData.domain,
|
|
};
|
|
await updateSite(selectedSite.id, normalizedFormData);
|
|
toast.success('Site updated successfully');
|
|
setShowDetailsModal(false);
|
|
await loadSites();
|
|
} catch (error: any) {
|
|
toast.error(`Failed to update site: ${error.message}`);
|
|
} finally {
|
|
setIsSaving(false);
|
|
}
|
|
};
|
|
|
|
const handleCreateSite = () => {
|
|
setSelectedSite(null);
|
|
setFormData({
|
|
name: '',
|
|
domain: '',
|
|
description: '',
|
|
is_active: true, // Default to true to match backend model default
|
|
});
|
|
setShowSiteModal(true);
|
|
};
|
|
|
|
const handleEditSite = (site: Site) => {
|
|
setSelectedSite(site);
|
|
setFormData({
|
|
name: site.name || '',
|
|
domain: site.domain || '',
|
|
description: site.description || '',
|
|
is_active: site.is_active || false,
|
|
});
|
|
setShowSiteModal(true);
|
|
};
|
|
|
|
// Helper function to normalize domain URL
|
|
const normalizeDomain = (domain: string): string => {
|
|
if (!domain || !domain.trim()) {
|
|
return domain;
|
|
}
|
|
|
|
const trimmed = domain.trim();
|
|
|
|
// If it already starts with https://, keep it as is
|
|
if (trimmed.startsWith('https://')) {
|
|
return trimmed;
|
|
}
|
|
|
|
// If it starts with http://, replace with https://
|
|
if (trimmed.startsWith('http://')) {
|
|
return trimmed.replace('http://', 'https://');
|
|
}
|
|
|
|
// Otherwise, add https://
|
|
return `https://${trimmed}`;
|
|
};
|
|
|
|
const handleSaveSite = async () => {
|
|
try {
|
|
setIsSaving(true);
|
|
// Normalize domain before sending
|
|
const normalizedFormData = {
|
|
...formData,
|
|
domain: formData.domain ? normalizeDomain(formData.domain) : formData.domain,
|
|
};
|
|
|
|
if (selectedSite) {
|
|
// Update existing site
|
|
await updateSite(selectedSite.id, normalizedFormData);
|
|
toast.success('Site updated successfully');
|
|
} else {
|
|
// Create new site
|
|
const newSite = await createSite({
|
|
...normalizedFormData,
|
|
is_active: normalizedFormData.is_active || false,
|
|
});
|
|
toast.success('Site created successfully');
|
|
|
|
// If this is the first site or user wants it active, activate it
|
|
if (sites.length === 0 || normalizedFormData.is_active) {
|
|
await setActiveSite(newSite.id);
|
|
}
|
|
}
|
|
setShowSiteModal(false);
|
|
setSelectedSite(null);
|
|
setFormData({
|
|
name: '',
|
|
domain: '',
|
|
description: '',
|
|
is_active: false,
|
|
});
|
|
await loadSites();
|
|
} catch (error: any) {
|
|
toast.error(`Failed to save site: ${error.message}`);
|
|
} finally {
|
|
setIsSaving(false);
|
|
}
|
|
};
|
|
|
|
const handleSelectSectors = async () => {
|
|
if (!selectedSite || !selectedIndustry || selectedSectors.length === 0) {
|
|
toast.error('Please select an industry and at least one sector');
|
|
return;
|
|
}
|
|
|
|
if (selectedSectors.length > 5) {
|
|
toast.error('Maximum 5 sectors allowed per site');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
setIsSelectingSectors(true);
|
|
const result = await selectSectorsForSite(
|
|
selectedSite.id,
|
|
selectedIndustry,
|
|
selectedSectors
|
|
);
|
|
|
|
toast.success(result.message || 'Sectors selected successfully');
|
|
setShowSectorsModal(false);
|
|
await loadSites();
|
|
} catch (error: any) {
|
|
toast.error(`Failed to select sectors: ${error.message}`);
|
|
} finally {
|
|
setIsSelectingSectors(false);
|
|
}
|
|
};
|
|
|
|
|
|
const handleDeleteSite = async (site: Site) => {
|
|
if (!window.confirm(`Are you sure you want to delete "${site.name}"? This action cannot be undone.`)) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
await deleteSite(site.id);
|
|
toast.success('Site deleted successfully');
|
|
await loadSites();
|
|
if (showDetailsModal) {
|
|
setShowDetailsModal(false);
|
|
}
|
|
} catch (error: any) {
|
|
toast.error(`Failed to delete site: ${error.message}`);
|
|
}
|
|
};
|
|
|
|
const getSiteFormFields = (): FormField[] => [
|
|
{
|
|
key: 'name',
|
|
label: 'Site Name',
|
|
type: 'text',
|
|
value: formData.name,
|
|
onChange: (value: any) => setFormData({ ...formData, name: value }),
|
|
required: true,
|
|
placeholder: 'Enter site name',
|
|
},
|
|
{
|
|
key: 'domain',
|
|
label: 'Domain',
|
|
type: 'text',
|
|
value: formData.domain,
|
|
onChange: (value: any) => setFormData({ ...formData, domain: value }),
|
|
required: false,
|
|
placeholder: 'example.com (https:// will be added automatically)',
|
|
},
|
|
{
|
|
key: 'description',
|
|
label: 'Description',
|
|
type: 'textarea',
|
|
value: formData.description,
|
|
onChange: (value: any) => setFormData({ ...formData, description: value }),
|
|
required: false,
|
|
placeholder: 'Enter site description',
|
|
rows: 4,
|
|
},
|
|
{
|
|
key: 'is_active',
|
|
label: 'Set as Active Site',
|
|
type: 'select',
|
|
value: formData.is_active ? 'true' : 'false',
|
|
onChange: (value: any) => setFormData({ ...formData, is_active: value === 'true' }),
|
|
required: false,
|
|
options: [
|
|
{ value: 'true', label: 'Active' },
|
|
{ value: 'false', label: 'Inactive' },
|
|
],
|
|
},
|
|
];
|
|
|
|
const getIndustrySectors = () => {
|
|
if (!selectedIndustry) return [];
|
|
const industry = industries.find(i => i.slug === selectedIndustry);
|
|
return industry?.sectors || [];
|
|
};
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="flex h-screen items-center justify-center">
|
|
<div className="text-center">
|
|
<div className="mb-4 h-8 w-8 animate-spin rounded-full border-4 border-gray-300 border-t-blue-600 mx-auto"></div>
|
|
<p className="text-gray-600 dark:text-gray-400">Loading sites...</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<PageMeta title="Sites Management" description="Manage your sites and configure industries and sectors" />
|
|
|
|
<div className="space-y-8">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Sites Management</h1>
|
|
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
|
Manage your sites, configure industries, and select sectors. Multiple sites can be active simultaneously.
|
|
</p>
|
|
</div>
|
|
<Button onClick={handleCreateSite} variant="primary">
|
|
+ Add Site
|
|
</Button>
|
|
</div>
|
|
|
|
{/* Info Alert */}
|
|
<Alert
|
|
variant="info"
|
|
title="Sites Configuration"
|
|
message="Each site can have up to 5 sectors selected from 15 major industries. Keywords and clusters are automatically associated with sectors. Multiple sites can be active simultaneously."
|
|
/>
|
|
|
|
{/* Sites Grid */}
|
|
{sites.length === 0 ? (
|
|
<div className="rounded-2xl border border-gray-200 bg-white p-12 text-center dark:border-gray-800 dark:bg-white/3">
|
|
<SiteIcon />
|
|
<h3 className="mt-4 text-lg font-semibold text-gray-900 dark:text-white">
|
|
No sites yet
|
|
</h3>
|
|
<p className="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
|
Create your first site to get started
|
|
</p>
|
|
<Button onClick={handleCreateSite} variant="primary" className="mt-4">
|
|
Create Site
|
|
</Button>
|
|
</div>
|
|
) : (
|
|
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 xl:grid-cols-3">
|
|
{sites.map((site) => (
|
|
<SiteCard
|
|
key={site.id}
|
|
site={site}
|
|
icon={<SiteIcon />}
|
|
onToggle={handleToggle}
|
|
onSettings={handleSettings}
|
|
onDetails={handleDetails}
|
|
isToggling={togglingSiteId === site.id}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* Create/Edit Site Modal */}
|
|
<FormModal
|
|
isOpen={showSiteModal}
|
|
onClose={() => {
|
|
setShowSiteModal(false);
|
|
setSelectedSite(null);
|
|
setFormData({
|
|
name: '',
|
|
domain: '',
|
|
description: '',
|
|
is_active: false,
|
|
});
|
|
}}
|
|
onSubmit={handleSaveSite}
|
|
title={selectedSite ? 'Edit Site' : 'Create New Site'}
|
|
submitLabel={selectedSite ? 'Update Site' : 'Create Site'}
|
|
fields={getSiteFormFields()}
|
|
isLoading={isSaving}
|
|
/>
|
|
|
|
{/* Sectors Selection Modal */}
|
|
<FormModal
|
|
isOpen={showSectorsModal}
|
|
onClose={() => setShowSectorsModal(false)}
|
|
onSubmit={handleSelectSectors}
|
|
title={selectedSite ? `Configure Sectors for ${selectedSite.name}` : 'Configure Sectors'}
|
|
submitLabel={isSelectingSectors ? 'Saving...' : 'Save Sectors'}
|
|
cancelLabel="Cancel"
|
|
isLoading={isSelectingSectors}
|
|
className="max-w-2xl"
|
|
customBody={
|
|
<div className="space-y-6">
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
|
Select Industry
|
|
</label>
|
|
<select
|
|
value={selectedIndustry}
|
|
onChange={(e) => {
|
|
setSelectedIndustry(e.target.value);
|
|
setSelectedSectors([]); // Reset sectors when industry changes
|
|
}}
|
|
className="h-9 w-full rounded-lg border border-gray-300 bg-transparent px-3 py-2 text-sm shadow-theme-xs text-gray-800 placeholder:text-gray-400 focus:border-brand-300 focus:outline-hidden focus:ring-3 focus:ring-brand-500/10 dark:border-gray-700 dark:bg-gray-900 dark:text-white/90 dark:placeholder:text-white/30 dark:focus:border-brand-800"
|
|
>
|
|
<option value="">Select an industry...</option>
|
|
{industries.map((industry) => (
|
|
<option key={industry.slug} value={industry.slug}>
|
|
{industry.name}
|
|
</option>
|
|
))}
|
|
</select>
|
|
{selectedIndustry && (
|
|
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
|
{industries.find(i => i.slug === selectedIndustry)?.description}
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
{selectedIndustry && (
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
|
Select Sectors (max 5)
|
|
</label>
|
|
<div className="space-y-2 max-h-64 overflow-y-auto border border-gray-200 rounded-lg p-4 dark:border-gray-700">
|
|
{getIndustrySectors().map((sector) => (
|
|
<label
|
|
key={sector.slug}
|
|
className="flex items-start space-x-3 p-3 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-800 cursor-pointer"
|
|
>
|
|
<input
|
|
type="checkbox"
|
|
checked={selectedSectors.includes(sector.slug)}
|
|
onChange={(e) => {
|
|
if (e.target.checked) {
|
|
if (selectedSectors.length >= 5) {
|
|
toast.error('Maximum 5 sectors allowed per site');
|
|
return;
|
|
}
|
|
setSelectedSectors([...selectedSectors, sector.slug]);
|
|
} else {
|
|
setSelectedSectors(selectedSectors.filter(s => s !== sector.slug));
|
|
}
|
|
}}
|
|
className="mt-1 h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
|
/>
|
|
<div className="flex-1">
|
|
<div className="font-medium text-sm text-gray-900 dark:text-white">
|
|
{sector.name}
|
|
</div>
|
|
<div className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
|
{sector.description}
|
|
</div>
|
|
</div>
|
|
</label>
|
|
))}
|
|
</div>
|
|
<p className="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
|
Selected: {selectedSectors.length} / 5 sectors
|
|
</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
}
|
|
customFooter={
|
|
<div className="flex justify-end gap-3 pt-4 border-t border-gray-200 dark:border-gray-700">
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
onClick={() => setShowSectorsModal(false)}
|
|
disabled={isSelectingSectors}
|
|
>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
type="submit"
|
|
variant="primary"
|
|
disabled={!selectedIndustry || selectedSectors.length === 0 || isSelectingSectors}
|
|
>
|
|
{isSelectingSectors ? 'Saving...' : 'Save Sectors'}
|
|
</Button>
|
|
</div>
|
|
}
|
|
/>
|
|
|
|
{/* Site Details Modal - Editable */}
|
|
{selectedSite && (
|
|
<FormModal
|
|
isOpen={showDetailsModal}
|
|
onClose={() => {
|
|
setShowDetailsModal(false);
|
|
setSelectedSite(null);
|
|
}}
|
|
onSubmit={handleSaveDetails}
|
|
title={`Edit Site: ${selectedSite.name}`}
|
|
submitLabel="Save Changes"
|
|
fields={getSiteFormFields()}
|
|
isLoading={isSaving}
|
|
customFooter={
|
|
<div className="flex justify-between items-center pt-4 border-t border-gray-200 dark:border-gray-700">
|
|
<Button
|
|
variant="danger"
|
|
onClick={() => {
|
|
if (selectedSite) {
|
|
handleDeleteSite(selectedSite);
|
|
}
|
|
}}
|
|
disabled={isSaving}
|
|
>
|
|
Delete Site
|
|
</Button>
|
|
<div className="flex gap-3">
|
|
<Button
|
|
variant="outline"
|
|
onClick={() => {
|
|
setShowDetailsModal(false);
|
|
setSelectedSite(null);
|
|
}}
|
|
disabled={isSaving}
|
|
>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
variant="primary"
|
|
onClick={handleSaveDetails}
|
|
disabled={isSaving}
|
|
>
|
|
{isSaving ? 'Saving...' : 'Save Changes'}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
}
|
|
/>
|
|
)}
|
|
</div>
|
|
|
|
{/* API Status Monitor - Only shows when debug toggle is enabled */}
|
|
<ApiStatusMonitor
|
|
title="Auth"
|
|
endpoints={[
|
|
{ name: 'List Sites', method: 'GET', endpoint: '/v1/auth/sites/' },
|
|
{ name: 'Get Industries', method: 'GET', endpoint: '/v1/auth/industries/' },
|
|
{ name: 'Get Seed Keywords', method: 'GET', endpoint: '/v1/auth/seed-keywords/' },
|
|
]}
|
|
/>
|
|
</>
|
|
);
|
|
}
|
|
|