refactor phase 6
This commit is contained in:
@@ -3,6 +3,7 @@
|
|||||||
* Displays both site switcher and sector selector side by side with accent colors
|
* Displays both site switcher and sector selector side by side with accent colors
|
||||||
*/
|
*/
|
||||||
import { useState, useEffect, useRef } from 'react';
|
import { useState, useEffect, useRef } from 'react';
|
||||||
|
import { useNavigate } from 'react-router';
|
||||||
import { Dropdown } from '../ui/dropdown/Dropdown';
|
import { Dropdown } from '../ui/dropdown/Dropdown';
|
||||||
import { DropdownItem } from '../ui/dropdown/DropdownItem';
|
import { DropdownItem } from '../ui/dropdown/DropdownItem';
|
||||||
import { fetchSites, Site, setActiveSite as apiSetActiveSite } from '../../services/api';
|
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 { useSiteStore } from '../../store/siteStore';
|
||||||
import { useSectorStore } from '../../store/sectorStore';
|
import { useSectorStore } from '../../store/sectorStore';
|
||||||
import { useAuthStore } from '../../store/authStore';
|
import { useAuthStore } from '../../store/authStore';
|
||||||
|
import Button from '../ui/button/Button';
|
||||||
|
|
||||||
interface SiteAndSectorSelectorProps {
|
interface SiteAndSectorSelectorProps {
|
||||||
hideSectorSelector?: boolean;
|
hideSectorSelector?: boolean;
|
||||||
@@ -19,6 +21,7 @@ export default function SiteAndSectorSelector({
|
|||||||
hideSectorSelector = false,
|
hideSectorSelector = false,
|
||||||
}: SiteAndSectorSelectorProps) {
|
}: SiteAndSectorSelectorProps) {
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
|
const navigate = useNavigate();
|
||||||
const { activeSite, setActiveSite, loadActiveSite } = useSiteStore();
|
const { activeSite, setActiveSite, loadActiveSite } = useSiteStore();
|
||||||
const { activeSector, sectors, setActiveSector, loading: sectorsLoading } = useSectorStore();
|
const { activeSector, sectors, setActiveSector, loading: sectorsLoading } = useSectorStore();
|
||||||
const { user, refreshUser, isAuthenticated } = useAuthStore();
|
const { user, refreshUser, isAuthenticated } = useAuthStore();
|
||||||
@@ -32,6 +35,7 @@ export default function SiteAndSectorSelector({
|
|||||||
// Sector selector state
|
// Sector selector state
|
||||||
const [sectorsOpen, setSectorsOpen] = useState(false);
|
const [sectorsOpen, setSectorsOpen] = useState(false);
|
||||||
const sectorButtonRef = useRef<HTMLButtonElement>(null);
|
const sectorButtonRef = useRef<HTMLButtonElement>(null);
|
||||||
|
const noSitesAvailable = !sitesLoading && sites.length === 0;
|
||||||
|
|
||||||
// Load sites
|
// Load sites
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -90,9 +94,25 @@ export default function SiteAndSectorSelector({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Don't render if no active site
|
const handleCreateSite = () => navigate('/sites');
|
||||||
if (!activeSite) {
|
|
||||||
return null;
|
if (sitesLoading && sites.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Loading sites...
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (noSitesAvailable) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center gap-2 text-sm text-gray-600 dark:text-gray-300">
|
||||||
|
<span>No active sites yet. Create a site to unlock planner and writer modules.</span>
|
||||||
|
<Button size="sm" variant="primary" onClick={handleCreateSite}>
|
||||||
|
Create Site
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -176,106 +196,122 @@ export default function SiteAndSectorSelector({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Sector Selector */}
|
{/* Sector Selector */}
|
||||||
{!hideSectorSelector && !sectorsLoading && sectors.length > 0 && (
|
{!hideSectorSelector && (
|
||||||
<div className="relative inline-block">
|
<div className="relative inline-block">
|
||||||
<button
|
{!activeSite ? (
|
||||||
ref={sectorButtonRef}
|
<div className="px-3 py-2 text-xs font-medium text-gray-500 dark:text-gray-400 border border-dashed border-gray-300 rounded-lg">
|
||||||
onClick={() => setSectorsOpen(!sectorsOpen)}
|
Select a site to choose sectors
|
||||||
className="flex items-center gap-2 px-3 py-2 text-sm font-medium text-gray-700 bg-white border border-brand-200 rounded-lg hover:bg-brand-50 hover:border-brand-300 dark:bg-gray-800 dark:text-gray-300 dark:border-brand-700/50 dark:hover:bg-brand-500/10 dark:hover:border-brand-600/50 transition-colors dropdown-toggle"
|
</div>
|
||||||
aria-label="Select sector"
|
) : sectorsLoading ? (
|
||||||
disabled={sectorsLoading || sectors.length === 0}
|
<div className="px-3 py-2 text-sm text-gray-500 dark:text-gray-400 border border-brand-200 rounded-lg">
|
||||||
>
|
Loading sectors...
|
||||||
<span className="flex items-center gap-2">
|
</div>
|
||||||
<svg
|
) : sectors.length === 0 ? (
|
||||||
className="w-4 h-4 text-brand-500 dark:text-brand-400"
|
<div className="px-3 py-2 text-sm text-gray-500 dark:text-gray-400 border border-dashed border-gray-300 rounded-lg">
|
||||||
fill="none"
|
This site has no sectors yet.
|
||||||
stroke="currentColor"
|
</div>
|
||||||
viewBox="0 0 24 24"
|
) : (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
ref={sectorButtonRef}
|
||||||
|
onClick={() => setSectorsOpen(!sectorsOpen)}
|
||||||
|
className="flex items-center gap-2 px-3 py-2 text-sm font-medium text-gray-700 bg-white border border-brand-200 rounded-lg hover:bg-brand-50 hover:border-brand-300 dark:bg-gray-800 dark:text-gray-300 dark:border-brand-700/50 dark:hover:bg-brand-500/10 dark:hover:border-brand-600/50 transition-colors dropdown-toggle"
|
||||||
|
aria-label="Select sector"
|
||||||
|
disabled={sectorsLoading || sectors.length === 0}
|
||||||
>
|
>
|
||||||
<path
|
<span className="flex items-center gap-2">
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth={2}
|
|
||||||
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
<span className="max-w-[150px] truncate">
|
|
||||||
{sectorsLoading ? 'Loading...' : activeSector?.name || 'All Sectors'}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
<svg
|
|
||||||
className={`w-4 h-4 text-brand-500 dark:text-brand-400 transition-transform ${sectorsOpen ? 'rotate-180' : ''}`}
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth={2}
|
|
||||||
d="M19 9l-7 7-7-7"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<Dropdown
|
|
||||||
isOpen={sectorsOpen}
|
|
||||||
onClose={() => setSectorsOpen(false)}
|
|
||||||
anchorRef={sectorButtonRef}
|
|
||||||
placement="bottom-right"
|
|
||||||
className="w-64 p-2 overflow-y-auto max-h-[300px]"
|
|
||||||
>
|
|
||||||
{/* "All Sectors" option */}
|
|
||||||
<DropdownItem
|
|
||||||
onItemClick={() => 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"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<span className="flex-1">All Sectors</span>
|
|
||||||
{!activeSector && (
|
|
||||||
<svg
|
|
||||||
className="w-4 h-4 text-brand-600 dark:text-brand-400"
|
|
||||||
fill="currentColor"
|
|
||||||
viewBox="0 0 20 20"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
fillRule="evenodd"
|
|
||||||
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
|
|
||||||
clipRule="evenodd"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
)}
|
|
||||||
</DropdownItem>
|
|
||||||
{sectors.map((sector) => (
|
|
||||||
<DropdownItem
|
|
||||||
key={sector.id}
|
|
||||||
onItemClick={() => 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"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<span className="flex-1">{sector.name}</span>
|
|
||||||
{activeSector?.id === sector.id && (
|
|
||||||
<svg
|
<svg
|
||||||
className="w-4 h-4 text-brand-600 dark:text-brand-400"
|
className="w-4 h-4 text-brand-500 dark:text-brand-400"
|
||||||
fill="currentColor"
|
fill="none"
|
||||||
viewBox="0 0 20 20"
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
>
|
>
|
||||||
<path
|
<path
|
||||||
fillRule="evenodd"
|
strokeLinecap="round"
|
||||||
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
|
strokeLinejoin="round"
|
||||||
clipRule="evenodd"
|
strokeWidth={2}
|
||||||
|
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
)}
|
<span className="max-w-[150px] truncate">
|
||||||
</DropdownItem>
|
{activeSector?.name || 'All Sectors'}
|
||||||
))}
|
</span>
|
||||||
</Dropdown>
|
</span>
|
||||||
|
<svg
|
||||||
|
className={`w-4 h-4 text-brand-500 dark:text-brand-400 transition-transform ${sectorsOpen ? 'rotate-180' : ''}`}
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M19 9l-7 7-7-7"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<Dropdown
|
||||||
|
isOpen={sectorsOpen}
|
||||||
|
onClose={() => setSectorsOpen(false)}
|
||||||
|
anchorRef={sectorButtonRef}
|
||||||
|
placement="bottom-right"
|
||||||
|
className="w-64 p-2 overflow-y-auto max-h-[300px]"
|
||||||
|
>
|
||||||
|
{/* "All Sectors" option */}
|
||||||
|
<DropdownItem
|
||||||
|
onItemClick={() => 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"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="flex-1">All Sectors</span>
|
||||||
|
{!activeSector && (
|
||||||
|
<svg
|
||||||
|
className="w-4 h-4 text-brand-600 dark:text-brand-400"
|
||||||
|
fill="currentColor"
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fillRule="evenodd"
|
||||||
|
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
|
||||||
|
clipRule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</DropdownItem>
|
||||||
|
{sectors.map((sector) => (
|
||||||
|
<DropdownItem
|
||||||
|
key={sector.id}
|
||||||
|
onItemClick={() => 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"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="flex-1">{sector.name}</span>
|
||||||
|
{activeSector?.id === sector.id && (
|
||||||
|
<svg
|
||||||
|
className="w-4 h-4 text-brand-600 dark:text-brand-400"
|
||||||
|
fill="currentColor"
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fillRule="evenodd"
|
||||||
|
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
|
||||||
|
clipRule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</DropdownItem>
|
||||||
|
))}
|
||||||
|
</Dropdown>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { useState, useEffect, useRef } from "react";
|
import { useState, useEffect, useRef } from "react";
|
||||||
import { useLocation } from "react-router";
|
import { useLocation, useNavigate } from "react-router";
|
||||||
import { Dropdown } from "../ui/dropdown/Dropdown";
|
import { Dropdown } from "../ui/dropdown/Dropdown";
|
||||||
import { DropdownItem } from "../ui/dropdown/DropdownItem";
|
import { DropdownItem } from "../ui/dropdown/DropdownItem";
|
||||||
import { fetchSites, Site, setActiveSite as apiSetActiveSite } from "../../services/api";
|
import { fetchSites, Site, setActiveSite as apiSetActiveSite } from "../../services/api";
|
||||||
import { useToast } from "../ui/toast/ToastContainer";
|
import { useToast } from "../ui/toast/ToastContainer";
|
||||||
import { useSiteStore } from "../../store/siteStore";
|
import { useSiteStore } from "../../store/siteStore";
|
||||||
import { useAuthStore } from "../../store/authStore";
|
import { useAuthStore } from "../../store/authStore";
|
||||||
|
import Button from "../ui/button/Button";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* SiteSwitcher Component
|
* SiteSwitcher Component
|
||||||
@@ -45,6 +46,7 @@ interface SiteSwitcherProps {
|
|||||||
|
|
||||||
export default function SiteSwitcher({ hiddenPaths }: SiteSwitcherProps) {
|
export default function SiteSwitcher({ hiddenPaths }: SiteSwitcherProps) {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
const navigate = useNavigate();
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
const { activeSite, setActiveSite, loadActiveSite } = useSiteStore();
|
const { activeSite, setActiveSite, loadActiveSite } = useSiteStore();
|
||||||
const { user, refreshUser, isAuthenticated } = useAuthStore();
|
const { user, refreshUser, isAuthenticated } = useAuthStore();
|
||||||
@@ -131,9 +133,22 @@ export default function SiteSwitcher({ hiddenPaths }: SiteSwitcherProps) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Don't render if loading or no sites
|
const noSitesAvailable = !loading && sites.length === 0;
|
||||||
if (loading || sites.length === 0) {
|
|
||||||
return null;
|
if (loading && sites.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="text-xs font-medium text-gray-500 dark:text-gray-400 px-3 py-2 border border-dashed border-gray-300 rounded-lg">
|
||||||
|
Loading sites...
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (noSitesAvailable) {
|
||||||
|
return (
|
||||||
|
<Button size="sm" variant="outline" onClick={() => navigate('/sites')}>
|
||||||
|
Create Site
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -19,7 +19,8 @@ import {
|
|||||||
SeedKeyword,
|
SeedKeyword,
|
||||||
fetchAccountSetting,
|
fetchAccountSetting,
|
||||||
createAccountSetting,
|
createAccountSetting,
|
||||||
updateAccountSetting
|
updateAccountSetting,
|
||||||
|
AccountSettingsError
|
||||||
} from '../../services/api';
|
} from '../../services/api';
|
||||||
import { Card } from '../../components/ui/card';
|
import { Card } from '../../components/ui/card';
|
||||||
import Badge from '../../components/ui/badge/Badge';
|
import Badge from '../../components/ui/badge/Badge';
|
||||||
@@ -53,6 +54,15 @@ const formatVolume = (volume: number): string => {
|
|||||||
return volume.toString();
|
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() {
|
export default function IndustriesSectorsKeywords() {
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
const { pageSize } = usePageSizeStore();
|
const { pageSize } = usePageSizeStore();
|
||||||
@@ -98,9 +108,18 @@ export default function IndustriesSectorsKeywords() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error: any) {
|
||||||
// If preferences don't exist yet, that's fine
|
if (error instanceof AccountSettingsError) {
|
||||||
console.log('No user preferences found');
|
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,
|
is_active: true,
|
||||||
});
|
});
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
// If setting doesn't exist, create it
|
if (error instanceof AccountSettingsError && error.type === 'ACCOUNT_SETTINGS_NOT_FOUND') {
|
||||||
if (error.status === 404) {
|
|
||||||
await createAccountSetting({
|
await createAccountSetting({
|
||||||
key: 'user_preferences',
|
key: 'user_preferences',
|
||||||
config: preferences,
|
config: preferences,
|
||||||
@@ -280,7 +298,11 @@ export default function IndustriesSectorsKeywords() {
|
|||||||
|
|
||||||
toast.success('Preferences saved successfully! These will be used when creating new sites.');
|
toast.success('Preferences saved successfully! These will be used when creating new sites.');
|
||||||
} catch (error: any) {
|
} 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 {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1476,32 +1476,108 @@ export interface AccountSettingsResponse {
|
|||||||
results: AccountSetting[];
|
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<AccountSettingsResponse> {
|
export async function fetchAccountSettings(): Promise<AccountSettingsResponse> {
|
||||||
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<AccountSetting> {
|
export async function fetchAccountSetting(key: string): Promise<AccountSetting> {
|
||||||
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<string, any>; is_active?: boolean }): Promise<AccountSetting> {
|
export async function createAccountSetting(data: { key: string; config: Record<string, any>; is_active?: boolean }): Promise<AccountSetting> {
|
||||||
return fetchAPI('/v1/system/settings/account/', {
|
try {
|
||||||
method: 'POST',
|
return await fetchAPI('/v1/system/settings/account/', {
|
||||||
body: JSON.stringify(data),
|
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<string, any>; is_active: boolean }>): Promise<AccountSetting> {
|
export async function updateAccountSetting(key: string, data: Partial<{ config: Record<string, any>; is_active: boolean }>): Promise<AccountSetting> {
|
||||||
return fetchAPI(`/v1/system/settings/account/${key}/`, {
|
try {
|
||||||
method: 'PUT',
|
return await fetchAPI(`/v1/system/settings/account/${key}/`, {
|
||||||
body: JSON.stringify(data),
|
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<void> {
|
export async function deleteAccountSetting(key: string): Promise<void> {
|
||||||
return fetchAPI(`/v1/system/settings/account/${key}/`, {
|
try {
|
||||||
method: 'DELETE',
|
await fetchAPI(`/v1/system/settings/account/${key}/`, {
|
||||||
});
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
throw buildAccountSettingsError(error, `Unable to delete account setting "${key}".`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Module Settings
|
// Module Settings
|
||||||
|
|||||||
@@ -18,8 +18,20 @@ import {
|
|||||||
AccountSetting,
|
AccountSetting,
|
||||||
ModuleSetting,
|
ModuleSetting,
|
||||||
ModuleEnableSettings,
|
ModuleEnableSettings,
|
||||||
|
AccountSettingsError,
|
||||||
} from '../services/api';
|
} 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 {
|
interface SettingsState {
|
||||||
accountSettings: Record<string, AccountSetting>;
|
accountSettings: Record<string, AccountSetting>;
|
||||||
moduleSettings: Record<string, Record<string, ModuleSetting>>;
|
moduleSettings: Record<string, Record<string, ModuleSetting>>;
|
||||||
@@ -58,7 +70,17 @@ export const useSettingsStore = create<SettingsState>()(
|
|||||||
});
|
});
|
||||||
set({ accountSettings: settingsMap, loading: false });
|
set({ accountSettings: settingsMap, loading: false });
|
||||||
} catch (error: any) {
|
} 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<SettingsState>()(
|
|||||||
accountSettings: { ...state.accountSettings, [key]: setting }
|
accountSettings: { ...state.accountSettings, [key]: setting }
|
||||||
}));
|
}));
|
||||||
} catch (error: any) {
|
} 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 });
|
set({ error: error.message });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -90,6 +120,11 @@ export const useSettingsStore = create<SettingsState>()(
|
|||||||
loading: false
|
loading: false
|
||||||
}));
|
}));
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
|
if (error instanceof AccountSettingsError) {
|
||||||
|
const message = getAccountSettingsErrorMessage(error);
|
||||||
|
set({ error: message, loading: false });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
set({ error: error.message, loading: false });
|
set({ error: error.message, loading: false });
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user