From cba28f9a322a69dc498ff9339601b63f6f472cf9 Mon Sep 17 00:00:00 2001 From: Keith Fawcett Date: Mon, 15 Jun 2026 10:05:46 -0700 Subject: [PATCH] feat(portal): make app mobile responsive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The portal was desktop-only (inline CSS-in-JS, fixed 248px sidebar, no media queries). Add a mobile-responsive layer: - useIsMobile/useMediaQuery hook (768px) — branch inline styles by viewport - Both shells (Shell + CreatorShell) collapse the fixed sidebar into a hamburger top bar + slide-in drawer on mobile; sidebar takes a variant prop - Page/Table primitives: responsive padding + stacking header; tables get a horizontal-scroll wrapper - op-grid-collapse class (global.css, !important media query) collapses 22 hardcoded multi-column grids to one column on phones Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/portal/src/App.tsx | 132 ++++++++++++++++-- apps/portal/src/global.css | 16 +++ apps/portal/src/lib/useMediaQuery.ts | 42 ++++++ .../src/pages/AdminPartnerCommission.tsx | 2 +- apps/portal/src/pages/AdminPartnerCoupons.tsx | 2 +- apps/portal/src/pages/AdminPartners.tsx | 2 +- apps/portal/src/pages/AdminPrograms.tsx | 8 +- apps/portal/src/pages/Connect.tsx | 2 +- apps/portal/src/pages/Dashboard.tsx | 4 +- apps/portal/src/pages/Install.tsx | 2 +- apps/portal/src/pages/admin/Admins.tsx | 2 +- apps/portal/src/pages/admin/Billing.tsx | 2 +- .../src/pages/admin/NetworkCreators.tsx | 2 +- apps/portal/src/pages/admin/Settings.tsx | 4 +- apps/portal/src/pages/admin/Webhooks.tsx | 2 +- .../pages/creator/CreatorMyAffiliations.tsx | 1 + .../src/pages/creator/CreatorMyProfile.tsx | 2 +- .../pages/creator/CreatorOfferingDetail.tsx | 1 + .../src/pages/creator/CreatorShareLinks.tsx | 1 + .../portal/src/pages/creator/CreatorShell.tsx | 115 +++++++++++++-- apps/portal/src/ui.tsx | 24 +++- 21 files changed, 323 insertions(+), 45 deletions(-) create mode 100644 apps/portal/src/lib/useMediaQuery.ts diff --git a/apps/portal/src/App.tsx b/apps/portal/src/App.tsx index 5c743c13..d1bed9c0 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 (