shoc-frontend-new/src/components/layout/app-sidebar.tsx
Alexandre Brandizzi e6250fba88 feat(account): My Account page with shared password checklist (SH-386)
Adds a My Account page, reachable from the sidebar, with the signed-in
user's read-only details and a Change Password section. The new password
is checked live against the shared password rules (6+ characters, an
uppercase letter, a number and a special character), which the invite
registration flow reuses. The current password is still verified by the
server first, and its rejection is shown on the current password field.
2026-09-25 11:16:59 -03:00

246 lines
7.2 KiB
TypeScript

import { useState } from "react";
import { Link, useLocation } from "react-router";
import { Box, Drawer } from "@mui/material";
import { CircleUserRound } from "lucide-react";
import { BrandLockup, BrandMark } from "@/components/common/brand-lockup";
import { useNavGroups } from "@/components/layout/use-nav-groups";
import { getActiveNavPath, getSidebarWidth, type NavItemDef } from "@/config/menu";
const ACCOUNT_PATH = "/account";
function getNavItemAppearance(isActive: boolean, isHover: boolean) {
if (isActive) {
return {
itemColor: "var(--primary)",
iconColor: "var(--primary)",
itemBg: "var(--color-sidebar-item-active-bg)",
};
}
if (isHover) {
return {
itemColor: "var(--primary-hover)",
iconColor: "var(--primary-hover)",
itemBg: "var(--sidebar-accent)",
};
}
return {
itemColor: "var(--foreground)",
iconColor: "var(--muted-foreground)",
itemBg: "transparent",
};
}
type NavItemProps = NavItemDef & {
collapsed: boolean;
active: boolean;
onNavigate?: () => void;
};
function NavItem({ label, icon: Icon, path, collapsed, active, badge, onNavigate }: NavItemProps) {
const [hovered, setHovered] = useState(false);
const isHover = hovered && !active;
const { itemColor, iconColor, itemBg } = getNavItemAppearance(active, isHover);
return (
<Link
to={path}
aria-current={active ? "page" : undefined}
className="no-underline"
title={collapsed ? label : undefined}
onClick={onNavigate}
>
<Box
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
sx={{
position: "relative",
display: "flex",
alignItems: "center",
width: "100%",
height: 40,
gap: "12px",
px: collapsed ? 0 : "12px",
justifyContent: collapsed ? "center" : "flex-start",
borderRadius: "6px",
cursor: "pointer",
fontFamily: "var(--font-sans)",
fontSize: 14,
fontWeight: active ? 600 : 500,
color: itemColor,
backgroundColor: itemBg,
boxShadow: active ? "var(--color-sidebar-item-active-shadow)" : "none",
transition: "all var(--duration-fast, 150ms) var(--ease-default, ease)",
mb: "4px",
textAlign: "left",
}}
>
{active && (
<Box
component="span"
sx={{
position: "absolute",
left: 0,
top: 8,
bottom: 8,
width: 3,
borderRadius: "0 3px 3px 0",
backgroundColor: "var(--primary)",
}}
/>
)}
<Icon size={19} className="shrink-0" style={{ color: iconColor }} />
{!collapsed && (
<>
<Box className="truncate flex-1">{label}</Box>
{badge != null && (
<Box
component="span"
className="inline-flex shrink-0 items-center justify-center"
sx={{
height: 18,
minWidth: 18,
px: "6px",
borderRadius: "9999px",
backgroundColor: "var(--accent)",
color: "#ffffff",
fontFamily: "var(--font-sans)",
fontSize: 10,
fontWeight: 700,
lineHeight: 1,
}}
>
{badge}
</Box>
)}
</>
)}
</Box>
</Link>
);
}
type AppSidebarProps = {
collapsed: boolean;
mobile?: boolean;
open?: boolean;
onClose?: () => void;
};
export function AppSidebar({ collapsed, mobile = false, open = true, onClose }: AppSidebarProps) {
const width = getSidebarWidth(collapsed);
const { pathname } = useLocation();
const activePath = getActiveNavPath(pathname);
const navGroups = useNavGroups();
return (
<Drawer
variant={mobile ? "temporary" : "permanent"}
open={open}
onClose={onClose}
ModalProps={{ keepMounted: true }}
sx={{
width: mobile ? 0 : width,
flexShrink: 0,
"& .MuiDrawer-paper": {
width,
boxSizing: "border-box",
backgroundColor: "var(--color-sidebar-bg)",
borderRight: "1px solid var(--color-sidebar-border)",
borderRadius: 0,
transition: "width 200ms var(--ease-default, ease)",
overflowX: "hidden",
},
}}
>
<Box className="flex h-full flex-col overflow-hidden">
<Box
className="flex shrink-0 items-center"
sx={{
height: "var(--spacing-topbar)",
gap: "10px",
px: collapsed ? 0 : "16px",
justifyContent: collapsed ? "center" : "flex-start",
backgroundColor: "var(--card)",
borderBottom: "1px solid var(--color-sidebar-border)",
}}
>
{collapsed ? <BrandMark size="compact" /> : <BrandLockup size="compact" />}
</Box>
<Box
component="nav"
id="primary-navigation"
aria-label="Primary navigation"
className="flex-1 overflow-y-auto overflow-x-hidden"
sx={{ py: 1 }}
>
{navGroups.map((group, groupIndex) => (
<Box key={group.title || `group-${groupIndex}`}>
{collapsed ? (
groupIndex > 0 && (
<Box
sx={{
height: 1,
backgroundColor: "var(--color-sidebar-border)",
mx: "14px",
my: "10px",
}}
/>
)
) : group.title ? (
<Box
component="p"
className="text-nav-group"
sx={{
m: 0,
px: "10px",
pt: "12px",
pb: "6px",
}}
>
{group.title}
</Box>
) : (
<Box sx={{ height: 8 }} />
)}
<Box component="ul" sx={{ listStyle: "none", m: 0, p: "0 8px" }}>
{group.items.map(({ label, icon, path, badge }) => (
<Box component="li" key={path} sx={{ listStyle: "none" }}>
<NavItem
label={label}
icon={icon}
path={path}
badge={badge}
collapsed={collapsed}
active={path === activePath}
onNavigate={mobile ? onClose : undefined}
/>
</Box>
))}
</Box>
</Box>
))}
</Box>
<Box
component="nav"
aria-label="Account navigation"
sx={{ borderTop: "1px solid var(--color-sidebar-border)", p: "8px" }}
>
<NavItem
label="My Account"
icon={CircleUserRound}
path={ACCOUNT_PATH}
collapsed={collapsed}
active={pathname === ACCOUNT_PATH}
onNavigate={mobile ? onClose : undefined}
/>
</Box>
</Box>
</Drawer>
);
}