diff --git a/frontend/src/components/common/SiteAndSectorSelector.tsx b/frontend/src/components/common/SiteAndSectorSelector.tsx index 2d7f8c86..0e50c79e 100644 --- a/frontend/src/components/common/SiteAndSectorSelector.tsx +++ b/frontend/src/components/common/SiteAndSectorSelector.tsx @@ -3,6 +3,7 @@ * Displays both site switcher and sector selector side by side with accent colors */ import { useState, useEffect, useRef } from 'react'; +import { useNavigate } from 'react-router'; import { Dropdown } from '../ui/dropdown/Dropdown'; import { DropdownItem } from '../ui/dropdown/DropdownItem'; import { fetchSites, Site, setActiveSite as apiSetActiveSite } from '../../services/api'; @@ -10,6 +11,7 @@ import { useToast } from '../ui/toast/ToastContainer'; import { useSiteStore } from '../../store/siteStore'; import { useSectorStore } from '../../store/sectorStore'; import { useAuthStore } from '../../store/authStore'; +import Button from '../ui/button/Button'; interface SiteAndSectorSelectorProps { hideSectorSelector?: boolean; @@ -19,6 +21,7 @@ export default function SiteAndSectorSelector({ hideSectorSelector = false, }: SiteAndSectorSelectorProps) { const toast = useToast(); + const navigate = useNavigate(); const { activeSite, setActiveSite, loadActiveSite } = useSiteStore(); const { activeSector, sectors, setActiveSector, loading: sectorsLoading } = useSectorStore(); const { user, refreshUser, isAuthenticated } = useAuthStore(); @@ -32,6 +35,7 @@ export default function SiteAndSectorSelector({ // Sector selector state const [sectorsOpen, setSectorsOpen] = useState(false); const sectorButtonRef = useRef(null); + const noSitesAvailable = !sitesLoading && sites.length === 0; // Load sites useEffect(() => { @@ -90,9 +94,25 @@ export default function SiteAndSectorSelector({ } }; - // Don't render if no active site - if (!activeSite) { - return null; + const handleCreateSite = () => navigate('/sites'); + + if (sitesLoading && sites.length === 0) { + return ( +
+ Loading sites... +
+ ); + } + + if (noSitesAvailable) { + return ( +
+ No active sites yet. Create a site to unlock planner and writer modules. + +
+ ); } return ( @@ -176,106 +196,122 @@ export default function SiteAndSectorSelector({ {/* Sector Selector */} - {!hideSectorSelector && !sectorsLoading && sectors.length > 0 && ( + {!hideSectorSelector && (
-
+ ) : sectorsLoading ? ( +
+ Loading sectors... +
+ ) : sectors.length === 0 ? ( +
+ This site has no sectors yet. +
+ ) : ( + <> + - - setSectorsOpen(false)} - anchorRef={sectorButtonRef} - placement="bottom-right" - className="w-64 p-2 overflow-y-auto max-h-[300px]" - > - {/* "All Sectors" option */} - handleSectorSelect(null)} - className={`flex items-center gap-3 px-3 py-2 font-medium rounded-lg text-sm text-left ${ - !activeSector - ? "bg-brand-50 text-brand-700 dark:bg-brand-500/20 dark:text-brand-300" - : "text-gray-700 hover:bg-gray-100 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-white/5 dark:hover:text-gray-300" - }`} - > - All Sectors - {!activeSector && ( - - - - )} - - {sectors.map((sector) => ( - handleSectorSelect(sector.id)} - className={`flex items-center gap-3 px-3 py-2 font-medium rounded-lg text-sm text-left ${ - activeSector?.id === sector.id - ? "bg-brand-50 text-brand-700 dark:bg-brand-500/20 dark:text-brand-300" - : "text-gray-700 hover:bg-gray-100 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-white/5 dark:hover:text-gray-300" - }`} - > - {sector.name} - {activeSector?.id === sector.id && ( + - )} - - ))} - + + {activeSector?.name || 'All Sectors'} + + + + + + + + setSectorsOpen(false)} + anchorRef={sectorButtonRef} + placement="bottom-right" + className="w-64 p-2 overflow-y-auto max-h-[300px]" + > + {/* "All Sectors" option */} + handleSectorSelect(null)} + className={`flex items-center gap-3 px-3 py-2 font-medium rounded-lg text-sm text-left ${ + !activeSector + ? "bg-brand-50 text-brand-700 dark:bg-brand-500/20 dark:text-brand-300" + : "text-gray-700 hover:bg-gray-100 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-white/5 dark:hover:text-gray-300" + }`} + > + All Sectors + {!activeSector && ( + + + + )} + + {sectors.map((sector) => ( + handleSectorSelect(sector.id)} + className={`flex items-center gap-3 px-3 py-2 font-medium rounded-lg text-sm text-left ${ + activeSector?.id === sector.id + ? "bg-brand-50 text-brand-700 dark:bg-brand-500/20 dark:text-brand-300" + : "text-gray-700 hover:bg-gray-100 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-white/5 dark:hover:text-gray-300" + }`} + > + {sector.name} + {activeSector?.id === sector.id && ( + + + + )} + + ))} + + + )} )} diff --git a/frontend/src/components/header/SiteSwitcher.tsx b/frontend/src/components/header/SiteSwitcher.tsx index b3c5f516..c8c1dd23 100644 --- a/frontend/src/components/header/SiteSwitcher.tsx +++ b/frontend/src/components/header/SiteSwitcher.tsx @@ -1,11 +1,12 @@ import { useState, useEffect, useRef } from "react"; -import { useLocation } from "react-router"; +import { useLocation, useNavigate } from "react-router"; import { Dropdown } from "../ui/dropdown/Dropdown"; import { DropdownItem } from "../ui/dropdown/DropdownItem"; import { fetchSites, Site, setActiveSite as apiSetActiveSite } from "../../services/api"; import { useToast } from "../ui/toast/ToastContainer"; import { useSiteStore } from "../../store/siteStore"; import { useAuthStore } from "../../store/authStore"; +import Button from "../ui/button/Button"; /** * SiteSwitcher Component @@ -45,6 +46,7 @@ interface SiteSwitcherProps { export default function SiteSwitcher({ hiddenPaths }: SiteSwitcherProps) { const location = useLocation(); + const navigate = useNavigate(); const toast = useToast(); const { activeSite, setActiveSite, loadActiveSite } = useSiteStore(); const { user, refreshUser, isAuthenticated } = useAuthStore(); @@ -131,9 +133,22 @@ export default function SiteSwitcher({ hiddenPaths }: SiteSwitcherProps) { return null; } - // Don't render if loading or no sites - if (loading || sites.length === 0) { - return null; + const noSitesAvailable = !loading && sites.length === 0; + + if (loading && sites.length === 0) { + return ( +
+ Loading sites... +
+ ); + } + + if (noSitesAvailable) { + return ( + + ); } return ( diff --git a/frontend/src/pages/Setup/IndustriesSectorsKeywords.tsx b/frontend/src/pages/Setup/IndustriesSectorsKeywords.tsx index 22d3de68..88260f8a 100644 --- a/frontend/src/pages/Setup/IndustriesSectorsKeywords.tsx +++ b/frontend/src/pages/Setup/IndustriesSectorsKeywords.tsx @@ -19,7 +19,8 @@ import { SeedKeyword, fetchAccountSetting, createAccountSetting, - updateAccountSetting + updateAccountSetting, + AccountSettingsError } from '../../services/api'; import { Card } from '../../components/ui/card'; import Badge from '../../components/ui/badge/Badge'; @@ -53,6 +54,15 @@ const formatVolume = (volume: number): string => { 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(); @@ -98,9 +108,18 @@ export default function IndustriesSectorsKeywords() { } } } - } catch (error) { - // If preferences don't exist yet, that's fine - console.log('No user preferences found'); + } catch (error: any) { + if (error instanceof AccountSettingsError) { + if (error.type === 'ACCOUNT_SETTINGS_NOT_FOUND') { + console.debug('No saved user preferences yet.'); + return; + } + console.warn('Failed to load user preferences:', error); + toast.error(getAccountSettingsPreferenceMessage(error)); + return; + } + console.warn('Failed to load user preferences:', error); + toast.error('Unable to load your saved preferences right now.'); } }; @@ -266,8 +285,7 @@ export default function IndustriesSectorsKeywords() { is_active: true, }); } catch (error: any) { - // If setting doesn't exist, create it - if (error.status === 404) { + if (error instanceof AccountSettingsError && error.type === 'ACCOUNT_SETTINGS_NOT_FOUND') { await createAccountSetting({ key: 'user_preferences', config: preferences, @@ -280,7 +298,11 @@ export default function IndustriesSectorsKeywords() { toast.success('Preferences saved successfully! These will be used when creating new sites.'); } catch (error: any) { - toast.error(`Failed to save preferences: ${error.message}`); + if (error instanceof AccountSettingsError) { + toast.error(getAccountSettingsPreferenceMessage(error)); + } else { + toast.error(`Failed to save preferences: ${error.message}`); + } } finally { setSaving(false); } diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 784debe1..00997ec6 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -1476,32 +1476,108 @@ export interface AccountSettingsResponse { results: AccountSetting[]; } +export type AccountSettingsErrorType = + | 'ACCOUNT_SETTINGS_API_ERROR' + | 'ACCOUNT_SETTINGS_NOT_FOUND' + | 'ACCOUNT_SETTINGS_VALIDATION_ERROR'; + +export class AccountSettingsError extends Error { + type: AccountSettingsErrorType; + status?: number; + details?: unknown; + + constructor(type: AccountSettingsErrorType, message: string, status?: number, details?: unknown) { + super(message); + this.name = 'AccountSettingsError'; + this.type = type; + this.status = status; + this.details = details; + } +} + +function buildAccountSettingsError(error: any, fallbackMessage: string): AccountSettingsError { + const status = error?.status; + const response = error?.response; + const details = response || error; + + if (status === 404) { + return new AccountSettingsError( + 'ACCOUNT_SETTINGS_NOT_FOUND', + 'No account settings were found for this account yet.', + status, + details + ); + } + + if (status === 400 || response?.errors) { + const validationMessage = + response?.error || + response?.message || + response?.detail || + 'The account settings request is invalid. Please review the submitted data.'; + + return new AccountSettingsError( + 'ACCOUNT_SETTINGS_VALIDATION_ERROR', + validationMessage, + status, + details + ); + } + + return new AccountSettingsError( + 'ACCOUNT_SETTINGS_API_ERROR', + error?.message || fallbackMessage, + status, + details + ); +} + export async function fetchAccountSettings(): Promise { - return fetchAPI('/v1/system/settings/account/'); + try { + return await fetchAPI('/v1/system/settings/account/'); + } catch (error: any) { + throw buildAccountSettingsError(error, 'Unable to load account settings right now.'); + } } export async function fetchAccountSetting(key: string): Promise { - return fetchAPI(`/v1/system/settings/account/${key}/`); + try { + return await fetchAPI(`/v1/system/settings/account/${key}/`); + } catch (error: any) { + throw buildAccountSettingsError(error, `Account setting "${key}" is not available.`); + } } export async function createAccountSetting(data: { key: string; config: Record; is_active?: boolean }): Promise { - return fetchAPI('/v1/system/settings/account/', { - method: 'POST', - body: JSON.stringify(data), - }); + try { + return await fetchAPI('/v1/system/settings/account/', { + method: 'POST', + body: JSON.stringify(data), + }); + } catch (error: any) { + throw buildAccountSettingsError(error, 'Unable to create the account setting.'); + } } export async function updateAccountSetting(key: string, data: Partial<{ config: Record; is_active: boolean }>): Promise { - return fetchAPI(`/v1/system/settings/account/${key}/`, { - method: 'PUT', - body: JSON.stringify(data), - }); + try { + return await fetchAPI(`/v1/system/settings/account/${key}/`, { + method: 'PUT', + body: JSON.stringify(data), + }); + } catch (error: any) { + throw buildAccountSettingsError(error, `Unable to update account setting "${key}".`); + } } export async function deleteAccountSetting(key: string): Promise { - return fetchAPI(`/v1/system/settings/account/${key}/`, { - method: 'DELETE', - }); + try { + await fetchAPI(`/v1/system/settings/account/${key}/`, { + method: 'DELETE', + }); + } catch (error: any) { + throw buildAccountSettingsError(error, `Unable to delete account setting "${key}".`); + } } // Module Settings diff --git a/frontend/src/store/settingsStore.ts b/frontend/src/store/settingsStore.ts index 453d9f74..f98ecdba 100644 --- a/frontend/src/store/settingsStore.ts +++ b/frontend/src/store/settingsStore.ts @@ -18,8 +18,20 @@ import { AccountSetting, ModuleSetting, ModuleEnableSettings, + AccountSettingsError, } from '../services/api'; +const getAccountSettingsErrorMessage = (error: AccountSettingsError): string => { + switch (error.type) { + case 'ACCOUNT_SETTINGS_NOT_FOUND': + return 'No account-level settings have been created yet.'; + case 'ACCOUNT_SETTINGS_VALIDATION_ERROR': + return error.message || 'The account settings request contained invalid data.'; + default: + return error.message || 'Unexpected error while loading account settings.'; + } +}; + interface SettingsState { accountSettings: Record; moduleSettings: Record>; @@ -58,7 +70,17 @@ export const useSettingsStore = create()( }); set({ accountSettings: settingsMap, loading: false }); } catch (error: any) { - set({ error: error.message, loading: false }); + if (error instanceof AccountSettingsError) { + const message = getAccountSettingsErrorMessage(error); + // Not found should not be treated as failure – just indicate no settings yet + if (error.type === 'ACCOUNT_SETTINGS_NOT_FOUND') { + set({ accountSettings: {}, loading: false, error: null }); + } else { + set({ error: message, loading: false }); + } + } else { + set({ error: error.message, loading: false }); + } } }, @@ -69,6 +91,14 @@ export const useSettingsStore = create()( accountSettings: { ...state.accountSettings, [key]: setting } })); } catch (error: any) { + if (error instanceof AccountSettingsError) { + if (error.type === 'ACCOUNT_SETTINGS_NOT_FOUND') { + // Silently ignore missing setting – it just hasn't been created yet + return; + } + set({ error: getAccountSettingsErrorMessage(error) }); + return; + } set({ error: error.message }); } }, @@ -90,6 +120,11 @@ export const useSettingsStore = create()( loading: false })); } catch (error: any) { + if (error instanceof AccountSettingsError) { + const message = getAccountSettingsErrorMessage(error); + set({ error: message, loading: false }); + throw error; + } set({ error: error.message, loading: false }); throw error; }