Files
HOA_Financial_Platform/frontend/src/components/layout/AppLayout.tsx
olsch01 1d1073cba1 style: add white glow outline to logo in dark mode
Use CSS drop-shadow filter on the logo img in dark mode to create a
subtle white outline that helps the transparent-background logo stand
out against the dark header and login page backgrounds.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 15:02:04 -04:00

215 lines
7.4 KiB
TypeScript

import { useState, useEffect } from 'react';
import { AppShell, Burger, Group, Text, Menu, UnstyledButton, Avatar, Alert, Button, ActionIcon, Tooltip } from '@mantine/core';
import { useDisclosure } from '@mantine/hooks';
import {
IconLogout,
IconSwitchHorizontal,
IconChevronDown,
IconSettings,
IconUserCog,
IconUsersGroup,
IconEyeOff,
IconSun,
IconMoon,
} from '@tabler/icons-react';
import { Outlet, useNavigate, useLocation } from 'react-router-dom';
import { useAuthStore } from '../../stores/authStore';
import { usePreferencesStore } from '../../stores/preferencesStore';
import { Sidebar } from './Sidebar';
import { AppTour } from '../onboarding/AppTour';
import { OnboardingWizard } from '../onboarding/OnboardingWizard';
import logoSrc from '../../assets/logo.png';
export function AppLayout() {
const [opened, { toggle, close }] = useDisclosure();
const { user, currentOrg, logout, impersonationOriginal, stopImpersonation } = useAuthStore();
const { colorScheme, toggleColorScheme } = usePreferencesStore();
const navigate = useNavigate();
const location = useLocation();
const isImpersonating = !!impersonationOriginal;
// ── Onboarding State ──
const [showTour, setShowTour] = useState(false);
const [showWizard, setShowWizard] = useState(false);
useEffect(() => {
// Only run for non-impersonating users with an org selected, on dashboard
if (isImpersonating || !currentOrg || !user) return;
if (!location.pathname.startsWith('/dashboard')) return;
// Read-only users (viewers) skip onboarding entirely
if (currentOrg.role === 'viewer') return;
if (user.hasSeenIntro === false || user.hasSeenIntro === undefined) {
// Delay to ensure DOM elements are rendered for tour targeting
const timer = setTimeout(() => setShowTour(true), 800);
return () => clearTimeout(timer);
} else if (currentOrg.settings?.onboardingComplete !== true) {
setShowWizard(true);
}
}, [user?.hasSeenIntro, currentOrg?.id, currentOrg?.role, currentOrg?.settings?.onboardingComplete, isImpersonating, location.pathname]);
const handleTourComplete = () => {
setShowTour(false);
// After tour, check if onboarding wizard should run
if (currentOrg && currentOrg.settings?.onboardingComplete !== true) {
// Small delay before showing wizard
setTimeout(() => setShowWizard(true), 500);
}
};
const handleWizardComplete = () => {
setShowWizard(false);
};
const handleLogout = () => {
logout();
navigate('/login');
};
const handleStopImpersonation = () => {
stopImpersonation();
navigate('/admin');
};
// Tenant admins (president role) can manage org members
const isTenantAdmin = currentOrg?.role === 'president' || currentOrg?.role === 'admin';
return (
<AppShell
header={{ height: isImpersonating ? 100 : 60 }}
navbar={{ width: 260, breakpoint: 'sm', collapsed: { mobile: !opened } }}
padding="md"
>
<AppShell.Header>
{isImpersonating && (
<Group
h={40}
px="md"
justify="center"
gap="xs"
style={{ backgroundColor: 'var(--mantine-color-orange-6)' }}
>
<Text size="sm" fw={600} c="white">
Impersonating {user?.firstName} {user?.lastName} ({user?.email})
</Text>
<Button
size="xs"
variant="white"
color="orange"
leftSection={<IconEyeOff size={14} />}
onClick={handleStopImpersonation}
>
Stop Impersonating
</Button>
</Group>
)}
<Group h={60} px="md" justify="space-between">
<Group>
<Burger opened={opened} onClick={toggle} hiddenFrom="sm" size="sm" />
<img
src={logoSrc}
alt="HOA LedgerIQ"
style={{
height: 40,
...(colorScheme === 'dark' ? {
filter: 'drop-shadow(0 0 1px rgba(255,255,255,0.8)) drop-shadow(0 0 2px rgba(255,255,255,0.4))',
} : {}),
}}
/>
</Group>
<Group>
{currentOrg && (
<Text size="sm" c="dimmed">{currentOrg.name}</Text>
)}
<Tooltip label={colorScheme === 'dark' ? 'Light mode' : 'Dark mode'}>
<ActionIcon
variant="default"
size="lg"
onClick={toggleColorScheme}
aria-label="Toggle color scheme"
>
{colorScheme === 'dark' ? <IconSun size={18} /> : <IconMoon size={18} />}
</ActionIcon>
</Tooltip>
<Menu shadow="md" width={220}>
<Menu.Target>
<UnstyledButton>
<Group gap="xs">
<Avatar size="sm" radius="xl" color={isImpersonating ? 'orange' : 'blue'}>
{user?.firstName?.[0]}{user?.lastName?.[0]}
</Avatar>
<Text size="sm">{user?.firstName} {user?.lastName}</Text>
<IconChevronDown size={14} />
</Group>
</UnstyledButton>
</Menu.Target>
<Menu.Dropdown>
{isImpersonating && (
<>
<Menu.Item
color="orange"
leftSection={<IconEyeOff size={14} />}
onClick={handleStopImpersonation}
>
Stop Impersonating
</Menu.Item>
<Menu.Divider />
</>
)}
<Menu.Label>Account</Menu.Label>
<Menu.Item
leftSection={<IconUserCog size={14} />}
onClick={() => navigate('/preferences')}
>
User Preferences
</Menu.Item>
<Menu.Item
leftSection={<IconSettings size={14} />}
onClick={() => navigate('/settings')}
>
Settings
</Menu.Item>
{isTenantAdmin && (
<Menu.Item
leftSection={<IconUsersGroup size={14} />}
onClick={() => navigate('/org-members')}
>
Manage Members
</Menu.Item>
)}
<Menu.Divider />
<Menu.Item
leftSection={<IconSwitchHorizontal size={14} />}
onClick={() => navigate('/select-org')}
>
Switch Organization
</Menu.Item>
<Menu.Divider />
<Menu.Item
color="red"
leftSection={<IconLogout size={14} />}
onClick={handleLogout}
>
Logout
</Menu.Item>
</Menu.Dropdown>
</Menu>
</Group>
</Group>
</AppShell.Header>
<AppShell.Navbar>
<Sidebar onNavigate={close} />
</AppShell.Navbar>
<AppShell.Main>
<Outlet />
</AppShell.Main>
{/* ── Onboarding Components ── */}
<AppTour run={showTour} onComplete={handleTourComplete} />
<OnboardingWizard opened={showWizard} onComplete={handleWizardComplete} />
</AppShell>
);
}