mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 11:21:18 +00:00
655 lines
19 KiB
TypeScript
655 lines
19 KiB
TypeScript
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,
|
||
}}
|
||
>
|
||
Ethio–Djibouti 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;
|