diff --git a/apps/portal/src/App.tsx b/apps/portal/src/App.tsx index 5c743c1..d1bed9c 100644 --- a/apps/portal/src/App.tsx +++ b/apps/portal/src/App.tsx @@ -23,11 +23,13 @@ import { Check, Plus, X, + Menu, } from 'lucide-react'; import { useQuery } from '@tanstack/react-query'; import { clearApiKey, api, type Principal } from './api.js'; import { useTenantBase } from './tenant-base.js'; import { theme } from './theme.js'; +import { useIsMobile } from './lib/useMediaQuery.js'; import { Dashboard } from './pages/Dashboard.js'; import { LinksPage } from './pages/Links.js'; import { CommissionsPage } from './pages/Commissions.js'; @@ -149,6 +151,8 @@ function Shell() { const [auth, setAuth] = useState({ loading: true, principal: null }); const location = useLocation(); const tenantBase = useTenantBase(); + const isMobile = useIsMobile(); + const [drawerOpen, setDrawerOpen] = useState(false); useEffect(() => { api('/auth/whoami') @@ -156,13 +160,26 @@ function Shell() { .catch(() => setAuth({ loading: false, principal: null })); }, []); + // Close the drawer whenever the route changes — a NavItem tap navigates, + // which should dismiss the overlay. Also covers backdrop/programmatic nav. + useEffect(() => { + setDrawerOpen(false); + }, [location.pathname]); + if (auth.loading) return Loading…; if (!auth.principal) return ; return (
- + {isMobile ? ( + setDrawerOpen(false)}> + + + ) : ( + + )}
+ {isMobile && setDrawerOpen(true)} />} } /> @@ -222,7 +239,102 @@ interface ProgramSettings { logoUrl: string | null; } -function Sidebar({ principal }: { principal: Principal }) { +/** Mobile-only top bar: hamburger + brand. Sticky so it stays reachable + * while the page scrolls. Reuses the cached 'program-settings' query so + * the brand name/logo match the drawer without a second fetch. */ +function MobileTopBar({ onMenu }: { onMenu: () => void }) { + const settings = useQuery({ + queryKey: ['program-settings'], + queryFn: () => api('/config/program'), + staleTime: 60_000, + }); + const programName = settings.data?.programName || 'OpenPartner'; + return ( +
+ + {settings.data?.logoUrl ? ( + {programName} + ) : ( + + )} +
{programName}
+
+ ); +} + +/** Slide-in drawer shell for the mobile sidebar. Stays mounted so the + * panel can transition in/out; a tap on the backdrop or any nav link + * inside closes it (route-change in Shell also closes it). */ +function MobileDrawer({ open, onClose, children }: { open: boolean; onClose: () => void; children: ReactNode }) { + return ( + <> +
+
{ + if ((e.target as HTMLElement).closest('a')) onClose(); + }} + style={{ + position: 'fixed', + top: 0, + left: 0, + bottom: 0, + width: 'min(280px, 84vw)', + transform: open ? 'translateX(0)' : 'translateX(-100%)', + transition: 'transform 200ms ease', + boxShadow: open ? '0 0 40px rgba(0,0,0,0.5)' : 'none', + display: 'flex', + zIndex: 50, + }} + > + {children} +
+ + ); +} + +function Sidebar({ principal, variant = 'sidebar' }: { principal: Principal; variant?: 'sidebar' | 'drawer' }) { const nav = useNavigate(); const tenantBase = useTenantBase(); const settings = useQuery({ @@ -233,20 +345,22 @@ function Sidebar({ principal }: { principal: Principal }) { }); const programName = settings.data?.programName || 'OpenPartner'; const supportEmail = settings.data?.supportEmail || null; + const isDrawer = variant === 'drawer'; return (