diff --git a/frontend/src/page/Home.jsx b/frontend/src/page/Home.jsx
index 370365f5..a103c793 100644
--- a/frontend/src/page/Home.jsx
+++ b/frontend/src/page/Home.jsx
@@ -1,6 +1,6 @@
-// Home.js - Updated with Batch Creation AND Batch Payment sections
+// Home.js - dashboard shell: collapsible nav rail on desktop, drawer below lg
-import { useEffect, useState } from "react";
+import { useCallback, useEffect, useState } from "react";
import Box from "@mui/material/Box";
import Drawer from "@mui/material/Drawer";
import List from "@mui/material/List";
@@ -8,18 +8,153 @@ import ListItem from "@mui/material/ListItem";
import ListItemButton from "@mui/material/ListItemButton";
import ListItemIcon from "@mui/material/ListItemIcon";
import ListItemText from "@mui/material/ListItemText";
+import Tooltip from "@mui/material/Tooltip";
import MailOutlineIcon from "@mui/icons-material/MailOutline";
import DraftsIcon from "@mui/icons-material/Drafts";
import AddCircleOutlineIcon from "@mui/icons-material/AddCircleOutline";
import LinkIcon from "@mui/icons-material/Link";
import SettingsIcon from "@mui/icons-material/Settings";
-import MenuItem from "@mui/material/MenuItem";
-import FormControl from "@mui/material/FormControl";
-import Select from "@mui/material/Select";
import { Outlet, useNavigate, useLocation } from "react-router-dom";
-import { FileStackIcon } from "lucide-react";
+import { FileStackIcon, Menu, PanelLeftClose, PanelLeftOpen } from "lucide-react";
import OnboardingProfileDialog from "@/components/OnboardingProfileDialog";
import { useUserProfile } from "@/hooks/useUserProfile";
+import { SHELL } from "@/utils/layout";
+
+const MENU_ITEMS = [
+ {
+ text: "Send Invoice",
+ icon:
,
+ route: "create",
+ color: "#f472b6",
+ },
+ {
+ text: "Send Multiple Invoices",
+ icon:
,
+ route: "batch-invoice",
+ color: "#22c55e",
+ },
+ {
+ text: "Sent Invoices",
+ icon:
,
+ route: "sent",
+ color: "#4ade80",
+ },
+ {
+ text: "Request Invoices",
+ icon:
,
+ route: "generate-link",
+ color: "#a78bfa",
+ },
+ {
+ text: "Received Invoices",
+ icon:
,
+ route: "pending",
+ color: "#60a5fa",
+ },
+ {
+ text: "Settings",
+ icon:
,
+ route: "settings",
+ color: "#9ca3af",
+ },
+];
+
+const RAIL_WIDTH = 248;
+const RAIL_WIDTH_COLLAPSED = 68;
+const RAIL_COLLAPSED_KEY = "chainvoice_dashboard_rail_collapsed";
+const NAV_DRAWER_ID = "dashboard-nav-drawer";
+
+// Tailwind's lg, which the hamburger and the rail are keyed to. MUI's own `lg`
+// is 1200px, so relying on it here would leave the drawer mounted between
+// 1024px and 1199px where the hamburger that closes it is already hidden.
+const LG_QUERY = "(min-width: 1024px)";
+
+/**
+ * Reads the rail preference synchronously. localStorage rather than the
+ * IndexedDB used for app data: an async read would render the rail expanded
+ * and then snap it closed on every load.
+ */
+const readRailCollapsed = () => {
+ try {
+ return window.localStorage.getItem(RAIL_COLLAPSED_KEY) === "true";
+ } catch {
+ return false;
+ }
+};
+
+/**
+ * The dashboard navigation list. Rendered by the desktop rail (expanded or
+ * collapsed to icons) and by the mobile drawer, so the variants cannot drift.
+ */
+function DashboardNav({ activeRoute, onNavigate, collapsed = false }) {
+ return (
+
+ {MENU_ITEMS.map((item) => {
+ const isActive = activeRoute === item.route;
+
+ return (
+
+
+ onNavigate(item.route)}
+ selected={isActive}
+ aria-label={item.text}
+ sx={{
+ borderRadius: "8px",
+ transition: "all 0.2s ease",
+ justifyContent: collapsed ? "center" : "flex-start",
+ backgroundColor: isActive
+ ? "rgba(255, 255, 255, 0.08)"
+ : "transparent",
+ "&:hover": { backgroundColor: "rgba(255, 255, 255, 0.05)" },
+ "&.Mui-selected": { borderLeft: `4px solid ${item.color}` },
+ padding: collapsed ? "10px 0" : "9px 14px",
+ }}
+ >
+
+ {item.icon}
+
+ {!collapsed && (
+
+ )}
+
+
+
+ );
+ })}
+
+ );
+}
+
+/**
+ * The dashboard greeting. The connected address and network deliberately are
+ * not repeated here — the navbar already shows both.
+ */
+function RailGreeting() {
+ return (
+
+ Welcome Back!
+
+ );
+}
export default function Home() {
const navigate = useNavigate();
@@ -31,6 +166,8 @@ export default function Home() {
dismissOnboarding,
} = useUserProfile();
const [showOnboarding, setShowOnboarding] = useState(false);
+ const [navOpen, setNavOpen] = useState(false);
+ const [railCollapsed, setRailCollapsed] = useState(readRailCollapsed);
// First visit only: prompt for the sender details every invoice needs, once
// storage has actually been read and once the user has not already skipped.
@@ -39,55 +176,50 @@ export default function Home() {
setShowOnboarding(!hasProfile && !onboardingDismissed);
}, [profileLoading, hasProfile, onboardingDismissed]);
- // Get current active route for dropdown
- const getCurrentRoute = () => {
- const currentPath = location.pathname;
- const matchedItem = menuItems.find(item => currentPath.includes(item.route));
- return matchedItem ? matchedItem.route : 'create';
- };
+ // Widening past lg hides the drawer and its hamburger by CSS, but the modal
+ // would stay open and keep the body scroll locked with nothing left to close
+ // it. Closing on the breakpoint keeps state and layout in step.
+ useEffect(() => {
+ const mq = window.matchMedia(LG_QUERY);
+ const handleChange = (e) => {
+ if (e.matches) setNavOpen(false);
+ };
- const handleDropdownChange = (event) => {
- navigate(event.target.value);
- };
+ handleChange(mq);
+ mq.addEventListener("change", handleChange);
+ return () => mq.removeEventListener("change", handleChange);
+ }, []);
- const menuItems = [
- {
- text: "Send Invoice",
- icon:
,
- route: "create",
- color: "#f472b6",
- },
- {
- text: "Send Multiple Invoices",
- icon:
,
- route: "batch-invoice",
- color: "#22c55e",
- },
- {
- text: "Sent Invoices",
- icon:
,
- route: "sent",
- color: "#4ade80",
- },
- {
- text: "Request Invoices",
- icon:
,
- route: "generate-link",
- color: "#a78bfa",
- },
- {
- text: "Received Invoices",
- icon:
,
- route: "pending",
- color: "#60a5fa",
- },
- {
- text: "Settings",
- icon:
,
- route: "settings",
- color: "#9ca3af",
+ const activeRoute =
+ MENU_ITEMS.find((item) => location.pathname.includes(item.route))?.route ??
+ "create";
+
+ const activeLabel =
+ MENU_ITEMS.find((item) => item.route === activeRoute)?.text ?? "Dashboard";
+
+ // Navigating from the drawer has to close it, or the overlay stays parked
+ // over the page the user just asked for.
+ const handleNavigate = useCallback(
+ (route) => {
+ navigate(route);
+ setNavOpen(false);
},
- ];
+ [navigate]
+ );
+
+ // The write stays outside the updater: React may call an updater more than
+ // once per dispatch, and updaters are expected to be pure.
+ const toggleRail = useCallback(() => {
+ const next = !railCollapsed;
+ try {
+ window.localStorage.setItem(RAIL_COLLAPSED_KEY, String(next));
+ } catch {
+ // A lost preference is not worth failing the interaction over.
+ }
+ setRailCollapsed(next);
+ }, [railCollapsed]);
+
+ const railWidth = railCollapsed ? RAIL_WIDTH_COLLAPSED : RAIL_WIDTH;
return (
<>
@@ -97,154 +229,96 @@ export default function Home() {
onSkip={dismissOnboarding}
/>
-
-
+ {/* Same gutter as the navbar, so the rail and content share its edges. */}
+
+ {/* Mobile / tablet: the hamburger doubles as the current-section label */}
+
+ setNavOpen(true)}
+ aria-label="Open dashboard menu"
+ aria-expanded={navOpen}
+ aria-controls={NAV_DRAWER_ID}
+ className="flex items-center gap-2 rounded-lg border border-white/10 bg-white/5 px-3 py-2 text-white transition-colors hover:bg-white/10"
+ >
+
+ {activeLabel}
+
+
+
+
setNavOpen(false)}
+ ModalProps={{ keepMounted: true }}
+ sx={{
+ display: "block",
+ [`@media ${LG_QUERY}`]: { display: "none" },
+ "& .MuiDrawer-paper": {
+ width: RAIL_WIDTH,
+ backgroundColor: "#161920",
+ borderRight: "1px solid rgba(255, 255, 255, 0.08)",
+ padding: "12px 8px",
+ },
+ }}
+ >
+
+
+
+
+
- {/* Mobile Dropdown Menu */}
-
-
-
- {menuItems.map((item) => (
-
-
-
- {item.icon}
-
- {item.text}
-
-
- ))}
-
-
-
-
- {/* Desktop Vertical Menu */}
+ {/* Desktop nav rail */}
-
-
- {menuItems.map((item) => (
-
- navigate(item.route)}
- selected={location.pathname.includes(item.route)}
- sx={{
- borderRadius: "8px",
- transition: "all 0.2s ease",
- backgroundColor: location.pathname.includes(item.route)
- ? "rgba(255, 255, 255, 0.08)"
- : "transparent",
- "&:hover": {
- backgroundColor: "rgba(255, 255, 255, 0.05)",
- },
- "&.Mui-selected": {
- borderLeft: "4px solid " + item.color,
- },
- padding: "12px 16px",
- }}
- >
-
- {item.icon}
-
-
-
-
- ))}
-
-
+ {!railCollapsed && }
+
+
+ {railCollapsed ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
{/* Main Content */}
@@ -252,13 +326,12 @@ export default function Home() {
component="main"
sx={{
flexGrow: 1,
- px: { xs: 0, sm: 1 },
- maxHeight: { xs: "none", lg: "calc(100vh - 180px)" },
+ minWidth: 0,
+ pl: { xs: 0, lg: 1.5 },
+ maxHeight: { xs: "none", lg: "calc(100vh - 130px)" },
overflowY: { xs: "visible", lg: "auto" },
scrollbarWidth: "none",
- "&::-webkit-scrollbar": {
- display: "none",
- },
+ "&::-webkit-scrollbar": { display: "none" },
transition: "all 0.3s ease",
borderLeft: { lg: "2px solid #1f2937" },
}}
diff --git a/frontend/src/page/ReceivedInvoice.jsx b/frontend/src/page/ReceivedInvoice.jsx
index 09f2da81..89863bbe 100644
--- a/frontend/src/page/ReceivedInvoice.jsx
+++ b/frontend/src/page/ReceivedInvoice.jsx
@@ -62,6 +62,8 @@ import ErrorIcon from "@mui/icons-material/Error";
import WarningIcon from "@mui/icons-material/Warning";
import { useTokenList } from "@/hooks/useTokenList";
import WalletConnectionAlert from "@/components/WalletConnectionAlert";
+import { PAGE_CONTAINER } from "@/utils/layout";
+import { cn } from "@/lib/utils";
const columns = [
{ id: "select", label: "", minWidth: 50 },
@@ -1045,8 +1047,8 @@ function ReceivedInvoice() {
onDismiss={() => setShowWalletAlert(false)}
/>
-
-
+
+
diff --git a/frontend/src/page/SentInvoice.jsx b/frontend/src/page/SentInvoice.jsx
index cdd31134..83a9a1dd 100644
--- a/frontend/src/page/SentInvoice.jsx
+++ b/frontend/src/page/SentInvoice.jsx
@@ -56,6 +56,8 @@ import WarningIcon from "@mui/icons-material/Warning";
import CurrencyExchangeIcon from "@mui/icons-material/CurrencyExchange";
import { useTokenList } from "@/hooks/useTokenList";
import WalletConnectionAlert from "@/components/WalletConnectionAlert";
+import { PAGE_CONTAINER } from "@/utils/layout";
+import { cn } from "@/lib/utils";
const columns = [
{ id: "fname", label: "Client", minWidth: 120 },
@@ -600,8 +602,8 @@ function SentInvoice() {
onDismiss={() => setShowWalletAlert(false)}
/>
-
-
+
+
Sent Invoices
diff --git a/frontend/src/page/Settings.jsx b/frontend/src/page/Settings.jsx
index 26d04064..43d333b7 100644
--- a/frontend/src/page/Settings.jsx
+++ b/frontend/src/page/Settings.jsx
@@ -1,42 +1,32 @@
import ProductCatalogImport from "../components/ProductCatalogImport";
import UserProfileSettings from "../components/UserProfileSettings";
+import { PAGE_CONTAINER, PAGE_HEADER } from "@/utils/layout";
+import { cn } from "@/lib/utils";
+/**
+ * Each card carries its own heading, so the page does not repeat the same
+ * title immediately above the card that already states it.
+ */
const SETTINGS_SECTIONS = [
- {
- id: "your-information",
- title: "Your Information",
- description:
- "The sender details applied to every invoice you create. Saved on this device only.",
- Content: UserProfileSettings,
- },
- {
- id: "product-catalog",
- title: "Product Catalog",
- description:
- "Manage your products for quick access when creating invoices.",
- Content: ProductCatalogImport,
- },
+ { id: "your-information", Content: UserProfileSettings },
+ { id: "product-catalog", Content: ProductCatalogImport },
];
function Settings() {
return (
-
-
-
+
+
+
Settings
- Manage your account settings and product catalog
+ Manage your sender details and product catalog
-
- {SETTINGS_SECTIONS.map(({ id, title, description, Content }) => (
+
+ {SETTINGS_SECTIONS.map(({ id, Content }) => (
-
-
{title}
-
{description}
-
))}
diff --git a/frontend/src/page/Treasure.jsx b/frontend/src/page/Treasure.jsx
index c83301a1..c7c5b857 100644
--- a/frontend/src/page/Treasure.jsx
+++ b/frontend/src/page/Treasure.jsx
@@ -16,6 +16,8 @@ import {
} from "lucide-react";
import { motion } from "framer-motion";
import toast from "react-hot-toast";
+import { cn } from "@/lib/utils";
+import { SHELL } from "@/utils/layout";
const Treasure = () => {
const [treasureAmount, setTreasureAmount] = useState(0);
@@ -174,7 +176,7 @@ const Treasure = () => {
};
return (
-