Files
igny8/frontend/src/pages/Settings/Sites.tsx
IGNY8 VPS (Salman) de0e42cca8 Phase 1 fixes
2026-01-05 04:52:16 +00:00

610 lines
20 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 Select from '../../components/form/Select';
import Checkbox from '../../components/form/input/Checkbox';
import {
fetchSites,
createSite,
updateSite,
deleteSite,
setActiveSite,
selectSectorsForSite,
fetchIndustries,
fetchSiteSectors,
Site,
Industry,
Sector,
} from '../../services/api';
import Badge from '../../components/ui/badge/Badge';
// Site Icon SVG - Globe
const SiteIcon = () => (
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" className="text-brand-500">
<circle cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
<path d="M2 12h20" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" stroke="currentColor" 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
industry: undefined as number | undefined, // Industry ID - required by backend
});
// 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,
industry: site.industry,
});
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
industry: undefined,
});
setShowSiteModal(true);
};
const handleEditSite = (site: Site) => {
setSelectedSite(site);
setFormData({
name: site.name || '',
domain: site.domain || '',
description: site.description || '',
is_active: site.is_active || false,
industry: site.industry,
});
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,
industry: undefined,
});
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: 'industry',
label: 'Industry',
type: 'select',
value: formData.industry?.toString() || '',
onChange: (value: any) => setFormData({ ...formData, industry: value ? parseInt(value) : undefined }),
required: true,
placeholder: 'Select an industry',
options: [
{ value: '', label: 'Select an industry...' },
...industries.map(industry => ({
value: industry.id.toString(),
label: industry.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 || [];
};
return (
<>
<PageMeta title="Your Websites" description="Manage all your websites here - Add new sites, configure settings, and track content for each one" />
<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">Your Websites</h1>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
Manage all your websites here - Add new sites, configure settings, and track content for each one
</p>
</div>
<Button onClick={handleCreateSite} variant="primary">
+ Add Another Website
</Button>
</div>
{/* Info Alert */}
<Alert
variant="info"
title="About Your Sites"
message="Active sites can receive new content. Inactive sites are paused. Each site can have up to 5 sectors selected from 15 major industries."
/>
{/* 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,
industry: undefined,
});
}}
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
options={[
{ value: '', label: 'Select an industry...' },
...industries.map((industry) => ({
value: industry.slug,
label: industry.name,
})),
]}
value={selectedIndustry}
onChange={(value) => {
setSelectedIndustry(value);
setSelectedSectors([]); // Reset sectors when industry changes
}}
/>
{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) => (
<div
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"
>
<Checkbox
checked={selectedSectors.includes(sector.slug)}
onChange={(checked) => {
if (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));
}
}}
label={
<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>
}
/>
</div>
))}
</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>
</>
);
}