Files
edr-platform/apps/edr-freight-web/portal/src/components/AppLayout.tsx

655 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import {
AppShell,
Avatar,
Box,
Divider,
Group,
Menu,
NavLink,
ScrollArea,
Stack,
Text,
UnstyledButton,
useComputedColorScheme,
useMantineColorScheme,
useMantineTheme,
} from "@mantine/core";
import { useDisclosure } from "@mantine/hooks";
import {
Bell,
ChevronDown,
FileSignature,
LogOut,
Menu as MenuIcon,
Moon,
Plus,
Search,
Settings,
Sun,
User,
X,
} from "lucide-react";
import { type CSSProperties, Fragment, type ReactNode } from "react";
export interface SidebarItem {
label: string;
href: string;
icon?: ReactNode;
children?: SidebarItem[];
section?: string;
}
export interface AppLayoutProps {
title?: string;
sidebarItems: SidebarItem[];
activeHref?: string;
onNavigate?: (href: string) => void;
enableThemeToggle?: boolean;
userName?: string;
userEmail?: string;
children: ReactNode;
}
function getInitials(name: string): string {
return name
.split(" ")
.filter(Boolean)
.slice(0, 2)
.map((n) => n[0].toUpperCase())
.join("");
}
function getActivePage(
items: SidebarItem[],
activePath: string,
): { label: string } | null {
const path = activePath.toLowerCase();
for (const item of items) {
if (
path === item.href.toLowerCase() ||
path.startsWith(item.href.toLowerCase() + "/")
) {
return { label: item.label };
}
if (item.children) {
const childMatch = item.children.find(
(c) =>
path === c.href.toLowerCase() ||
path.startsWith(c.href.toLowerCase() + "/"),
);
if (childMatch) return { label: childMatch.label };
}
}
return null;
}
const navClassNames = (active: boolean) => {
if (active) {
return {
root: `rounded-[10px] font-medium transition-all duration-150 bg-[#ECF6F1]! ring-1 ring-inset ring-[#0EA371]/15`,
label: `text-[#0A6F4D]! font-bold!`,
section: `text-[#0A6F4D]!`,
};
}
return {
root: `rounded-[10px] font-medium transition-all duration-150 hover:bg-[#F1F4F7]!`,
label: `text-edr-text! font-semibold! hover:text-[#0C1A2B]!`,
section: `text-edr-text! hover:text-[#0C1A2B]!`,
};
};
export function AppLayout({
title = "EDR Freight",
sidebarItems,
activeHref = "",
onNavigate,
enableThemeToggle = false,
userName = "User",
userEmail,
children,
}: AppLayoutProps) {
const [mobileOpen, { toggle: toggleMobile }] = useDisclosure();
const theme = useMantineTheme();
const { setColorScheme } = useMantineColorScheme();
const computedColorScheme = useComputedColorScheme("light");
const borderColor = theme.colors["edr-border"][6];
const mutedColor = theme.colors["edr-muted"][6];
const textColor = theme.colors["edr-text"][6];
const accentColor = theme.colors["edr-accent"][6];
const bgColor = theme.colors["edr-bg"][6];
const primaryColor = theme.colors["edr-green"][5];
const primaryDarkColor = theme.colors["edr-green"][7];
const activePath = activeHref.toLowerCase();
const navigate = (href: string) => onNavigate?.(href);
const toggleTheme = () => {
setColorScheme(computedColorScheme === "dark" ? "light" : "dark");
};
const initials = getInitials(userName);
const activePage = getActivePage(sidebarItems, activePath);
const isItemActive = (item: SidebarItem) =>
activePath === item.href.toLowerCase() ||
activePath.startsWith(item.href.toLowerCase() + "/");
// Shared header "island" styles — every control is a consistent 36px chip.
const islandStyle: CSSProperties = {
width: 36,
height: 36,
borderRadius: 999,
border: `1px solid ${borderColor}`,
backgroundColor: "#fff",
display: "flex",
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
cursor: "pointer",
};
return (
<AppShell
layout="alt"
navbar={{
width: 260,
breakpoint: "sm",
collapsed: { mobile: !mobileOpen },
}}
header={{ height: 72 }}
padding={0}
>
{/* ── Header (frosted glass; compact floating islands, mirrors the pen) ── */}
<AppShell.Header
withBorder={false}
style={{
backdropFilter: "blur(14px)",
WebkitBackdropFilter: "blur(14px)",
border: "none",
boxShadow: "none",
background: "transparent",
}}
>
<Group
h="100%"
px={24}
pt={12}
pb={8}
justify="space-between"
wrap="nowrap"
>
{/* Left: sidebar toggle + page title */}
<Group gap={12} wrap="nowrap" align="center">
<UnstyledButton
onClick={toggleMobile}
hiddenFrom="sm"
aria-label="Toggle sidebar"
>
<MenuIcon size={18} color={textColor} strokeWidth={1.8} />
</UnstyledButton>
<Text
style={{
fontFamily: '"Inter", sans-serif',
fontSize: 16,
fontWeight: 700,
color: textColor,
lineHeight: 1,
}}
>
{activePage ? activePage.label : title}
</Text>
</Group>
{/* Right: search + bell + avatar */}
<Group gap={10} wrap="nowrap" align="center">
{/* Search pill */}
<Group
gap={8}
align="center"
visibleFrom="sm"
style={{
width: 260,
height: 36,
borderRadius: 999,
border: `1px solid ${borderColor}`,
backgroundColor: "#fff",
padding: "0 14px",
cursor: "text",
}}
>
<Search size={15} color={mutedColor} strokeWidth={1.8} />
<Text size="sm" style={{ color: mutedColor, userSelect: "none" }}>
Search shipments, bookings
</Text>
</Group>
{/* Bell */}
<Box style={{ position: "relative" }}>
<UnstyledButton style={islandStyle} aria-label="Notifications">
<Bell size={17} color={textColor} strokeWidth={1.8} />
</UnstyledButton>
<Box
style={{
position: "absolute",
top: 7,
right: 7,
width: 7,
height: 7,
borderRadius: "50%",
backgroundColor: accentColor,
border: "1.5px solid #fff",
pointerEvents: "none",
}}
/>
</Box>
{enableThemeToggle && (
<UnstyledButton
onClick={toggleTheme}
style={islandStyle}
aria-label="Toggle theme"
>
{computedColorScheme === "dark" ? (
<Sun size={17} color={textColor} strokeWidth={1.8} />
) : (
<Moon size={17} color={textColor} strokeWidth={1.8} />
)}
</UnstyledButton>
)}
{/* Avatar pill */}
<Menu
width={220}
position="bottom-end"
withinPortal
shadow="md"
offset={8}
radius="md"
>
<Menu.Target>
<UnstyledButton
style={{
height: 36,
borderRadius: 999,
border: `1px solid ${borderColor}`,
backgroundColor: "#fff",
padding: "0 10px 0 4px",
display: "flex",
alignItems: "center",
gap: 7,
cursor: "pointer",
}}
>
<Avatar radius="xl" size={28} color="edr-green.5">
<Text fw={700} fz={12} c="white">
{initials}
</Text>
</Avatar>
<ChevronDown size={15} color={mutedColor} strokeWidth={1.8} />
</UnstyledButton>
</Menu.Target>
<Menu.Dropdown>
<Box px="sm" py="xs">
<Text
size="sm"
fw={600}
truncate
style={{ color: textColor }}
>
{userName}
</Text>
{userEmail && (
<Text size="xs" c="dimmed" truncate>
{userEmail}
</Text>
)}
</Box>
<Divider />
<Menu.Item
leftSection={<User size={15} />}
onClick={() => navigate("/profile")}
>
Profile
</Menu.Item>
<Menu.Item
leftSection={<FileSignature size={15} />}
onClick={() => navigate("/signature")}
>
My signature
</Menu.Item>
<Menu.Item
leftSection={<Settings size={15} />}
onClick={() => navigate("/settings")}
>
Settings
</Menu.Item>
<Divider />
<Menu.Item
leftSection={<Plus size={15} />}
color="edr-green"
onClick={() => navigate("/bookings/new")}
>
New Booking
</Menu.Item>
<Divider />
<Menu.Item
leftSection={<LogOut size={15} />}
color="red"
onClick={() => navigate("/logout")}
>
Logout
</Menu.Item>
</Menu.Dropdown>
</Menu>
</Group>
</Group>
</AppShell.Header>
{/* ── Sidebar ── */}
<AppShell.Navbar
withBorder={false}
style={{
backgroundColor: "#ffffff",
borderRight: `1px solid ${borderColor}`,
display: "flex",
flexDirection: "column",
}}
>
{/* Brand */}
<Box
style={{
height: 60,
flexShrink: 0,
display: "flex",
alignItems: "center",
padding: "0 18px",
justifyContent: "space-between",
}}
>
<Group gap={11} wrap="nowrap">
<img
src="/assets/edr-logo.png"
alt="EDR"
style={{
width: 40,
height: 40,
objectFit: "contain",
flexShrink: 0,
}}
/>
<Box>
<Text
style={{
fontFamily: '"Inter", sans-serif',
fontWeight: 800,
fontSize: 19,
letterSpacing: "0.03em",
color: primaryColor,
lineHeight: 1.15,
}}
>
EDR FREIGHT
</Text>
<Text
style={{
fontSize: 10.5,
fontWeight: 500,
color: mutedColor,
lineHeight: 1.2,
}}
>
EthioDjibouti Railway
</Text>
</Box>
</Group>
<UnstyledButton
onClick={toggleMobile}
hiddenFrom="sm"
aria-label="Close sidebar"
>
<X size={18} color={mutedColor} strokeWidth={1.8} />
</UnstyledButton>
</Box>
{/* Nav */}
<ScrollArea flex={1} type="never" p="sm">
<Stack gap={3}>
{sidebarItems.map((item, i) => {
const active = isItemActive(item);
const hasChildren = !!item.children?.length;
const childActive =
item.children?.some((c) =>
activePath.startsWith(c.href.toLowerCase()),
) ?? false;
const prevSection = sidebarItems[i - 1]?.section;
const sectionLabel =
item.section && item.section !== prevSection ? (
<Text
key={`section-${item.section}`}
size="xs"
tt="uppercase"
px="sm"
mt={i === 0 ? 4 : "md"}
mb={4}
style={{
fontWeight: 600,
color: textColor,
fontSize: 12,
}}
>
{item.section}
</Text>
) : null;
if (hasChildren) {
return (
<Fragment key={item.href}>
{sectionLabel}
<NavLink
label={item.label}
leftSection={item.icon}
active={active || childActive}
defaultOpened={childActive}
classNames={navClassNames(active || childActive)}
>
{item.children!.map((child) => {
const cActive = activePath === child.href.toLowerCase();
return (
<NavLink
key={child.href}
label={child.label}
active={cActive}
onClick={() => navigate(child.href)}
classNames={navClassNames(cActive)}
/>
);
})}
</NavLink>
</Fragment>
);
}
return (
<Fragment key={item.href}>
{sectionLabel}
<NavLink
label={item.label}
leftSection={item.icon}
active={active}
onClick={() => navigate(item.href)}
classNames={navClassNames(active)}
/>
</Fragment>
);
})}
</Stack>
</ScrollArea>
{/* Promo card */}
<Box style={{ padding: "0 12px 12px" }}>
<Box
style={{
borderRadius: 16,
overflow: "hidden",
position: "relative",
height: 260,
marginBottom: 10,
}}
>
{/* Train image fills the full card */}
<img
src="/assets/train-edr.jpg"
alt=""
style={{
position: "absolute",
bottom: "-16px",
left: 0,
right: "-70px",
width: "120%",
objectFit: "cover",
}}
/>
{/* Diagonal green overlay: lower-left → upper-right cut */}
<Box
style={{
position: "absolute",
inset: 0,
top: "-75%",
background:
"linear-gradient(165deg, #006149 40%, #0DC9A4 70%, #0DC9A402 80%)",
clipPath: "polygon(0 0, 100% 0, 100% 58%, 0 72%)",
}}
/>
{/* Text sits on top of the green overlay */}
<Box
style={{
position: "relative",
zIndex: 1,
padding: "16px 16px 0",
}}
className="max-w-[180px]"
>
<Text
style={{
fontSize: 15,
fontWeight: 800,
color: "#fff",
lineHeight: 1.3,
marginBottom: 4,
}}
>
Moving Africa Forward
</Text>
<Text
style={{
fontSize: 11,
color: "rgba(255,255,255,0.7)",
lineHeight: 1.5,
}}
>
Reliable. Efficient. Connected.
</Text>
</Box>
{/* Learn More — visible on the image area */}
<Box
style={{
position: "absolute",
bottom: 10,
right: 10,
zIndex: 2,
display: "inline-flex",
alignItems: "center",
gap: 5,
backgroundColor: "#fff",
borderRadius: 8,
padding: "6px 12px",
cursor: "pointer",
}}
>
<Text
style={{
fontSize: 12,
fontWeight: 600,
color: primaryDarkColor,
}}
>
Learn More
</Text>
<span style={{ fontSize: 12, color: primaryDarkColor }}></span>
</Box>
</Box>
{/* Profile */}
<Box
style={{
borderRadius: 12,
border: `1px solid ${borderColor}`,
padding: "10px",
display: "flex",
alignItems: "center",
gap: 10,
cursor: "pointer",
}}
>
<Avatar
radius="xl"
size={38}
color="edr-green.5"
style={{ flexShrink: 0 }}
>
<Text fw={600} fz={13} c="white">
{initials}
</Text>
</Avatar>
<Box style={{ minWidth: 0, flex: 1 }}>
<Text
size="sm"
fw={600}
truncate
style={{ color: textColor, lineHeight: 1.3 }}
>
{userName}
</Text>
{userEmail && (
<Text
size="xs"
truncate
style={{ color: mutedColor, lineHeight: 1.3 }}
>
{userEmail}
</Text>
)}
</Box>
<ChevronDown
size={16}
color={mutedColor}
style={{ flexShrink: 0 }}
/>
</Box>
</Box>
</AppShell.Navbar>
{/* ── Main ── */}
<AppShell.Main
style={{
backgroundColor: bgColor,
backgroundImage:
"radial-gradient(58% 42% at 100% 0%, rgba(14,163,113,0.18) 0%, rgba(14,163,113,0.0.8) 38%, rgba(14,163,113,0.04) 72%)",
backgroundRepeat: "no-repeat",
backgroundAttachment: "fixed",
}}
>
{children}
</AppShell.Main>
</AppShell>
);
}
export default AppLayout;