'use client'; import { useEffect, useState, type ComponentProps, type ReactNode } from 'react'; import { cn } from '@/lib/utils'; import { Sidebar } from '@/components/layout/sidebar'; import { Topbar } from '@/components/layout/topbar'; import { MobileLayoutProvider } from '@/components/layout/mobile/mobile-layout-provider'; import { MobileTopbar } from '@/components/layout/mobile/mobile-topbar'; import { MobileBottomTabs } from '@/components/layout/mobile/mobile-bottom-tabs'; import { MoreSheet } from '@/components/layout/mobile/more-sheet'; import { MobileSearchOverlay } from '@/components/search/mobile-search-overlay'; import { Sheet, SheetContent } from '@/components/ui/sheet'; import { useUIStore } from '@/stores/ui-store'; type SidebarProps = ComponentProps; type TopbarProps = ComponentProps; interface AppShellProps { portRoles: SidebarProps['portRoles']; isSuperAdmin: boolean; user: NonNullable; ports: TopbarProps['ports']; /** Per-port logo URLs resolved server-side. Sidebar picks the entry * matching the currently-active port from the UI store. */ portLogoUrls: Record; /** * Server-rendered form-factor hint (from the request User-Agent). The * shell mounts the matching tree on first render so we never paint the * wrong shell, and only switches if the runtime viewport matchMedia * disagrees (e.g. desktop browser resized below lg). */ initialFormFactor: 'mobile' | 'desktop'; children: ReactNode; } // Three tiers, aligned with Tailwind and the `useViewportTier` hook: // mobile : < 768px (sm and below) → mobile shell (bottom tabs, slide-over more sheet) // tablet : 768-1023 (md) → desktop shell, sidebar wrapped in Sheet, logo trigger in topbar // desktop : >= 1024 (lg and up) → desktop shell, sidebar always visible // // Previously the app used a binary `(max-width: 1023.98px)` split, which // rendered the mobile shell on iPad portrait AND on a half-screen 13" // browser. Neither is really "mobile"; the tablet tier fills that gap. const MOBILE_QUERY = '(max-width: 767.98px)'; const TABLET_QUERY = '(min-width: 768px) and (max-width: 1023.98px)'; type Tier = 'mobile' | 'tablet' | 'desktop'; function computeInitialTier(initialFormFactor: 'mobile' | 'desktop'): Tier { // Server UA classification is binary; we can only distinguish "looks // like a phone/tablet UA" from "looks like a desktop UA." Tablets get // classified as 'mobile' by the UA token check (iPad/Android), so // initialFormFactor=mobile on first paint covers both mobile + tablet. // The matchMedia subscription below corrects to the precise tier on // hydration without a flash because useSyncExternalStore is mismatch-safe. return initialFormFactor === 'mobile' ? 'mobile' : 'desktop'; } /** * #26 + H-09: single-tree responsive shell with stable children subtree. * * The shell renders ONE `
` and ONE `` at all * viewports — only the chrome (sidebar+topbar vs mobile-topbar+bottom-tabs * vs tablet's hidden-sidebar-via-Sheet) conditionally renders. Three payoffs: * * - #26 / first ship: no double-mount of chrome subtrees. * - H-09: `{children}` stays mounted across viewport flips. A rep * editing an inline field on desktop who resizes through the mobile * breakpoint no longer loses the draft mid-edit. * - Tier-aware sidebar: tablet width gets the desktop shell with * sidebar hidden behind a Sheet (slide-over from left, opened by * the topbar logo button) instead of falling all the way back to * the mobile shell. Closes the half-screen-on-13"-Mac usability gap. * * The mobile-only floating panels (MoreSheet, MobileSearchOverlay) only * mount in the mobile branch — they have no desktop counterpart. * * SSR safety: the server passes its UA-classified hint via `initialFormFactor`; * the first client render uses the same value so hydration matches. After * mount, a matchMedia subscription overrides to the precise tier. */ export function AppShell({ portRoles, isSuperAdmin, user, ports, portLogoUrls, initialFormFactor, children, }: AppShellProps) { const [tier, setTier] = useState(computeInitialTier(initialFormFactor)); const [moreOpen, setMoreOpen] = useState(false); const [searchOpen, setSearchOpen] = useState(false); const [tabletSidebarOpen, setTabletSidebarOpen] = useState(false); const currentPortSlug = useUIStore((s) => s.currentPortSlug); const logoUrl = currentPortSlug ? portLogoUrls[currentPortSlug] : null; useEffect(() => { const mqMobile = window.matchMedia(MOBILE_QUERY); const mqTablet = window.matchMedia(TABLET_QUERY); const update = () => { const next: Tier = mqMobile.matches ? 'mobile' : mqTablet.matches ? 'tablet' : 'desktop'; setTier(next); // Persist for the next SSR pass so the server renders the // matching shell on first paint — eliminates the chrome flicker // on refresh when UA-based classification disagrees with the // actual viewport (most common on macOS Safari at sub-1024 // widths). 1-year expiry; SameSite=Lax is fine since the cookie // is read by our own server only. document.cookie = `pn-crm.viewport-tier=${next}; path=/; max-age=31536000; SameSite=Lax`; }; update(); mqMobile.addEventListener('change', update); mqTablet.addEventListener('change', update); return () => { mqMobile.removeEventListener('change', update); mqTablet.removeEventListener('change', update); }; }, []); const isMobile = tier === 'mobile'; const isTablet = tier === 'tablet'; // Close the tablet sheet when crossing breakpoints so it doesn't stay // "open" after a resize back to desktop (Sheet keeps its open prop). useEffect(() => { if (!isTablet && tabletSidebarOpen) { // eslint-disable-next-line react-hooks/set-state-in-effect setTabletSidebarOpen(false); } }, [isTablet, tabletSidebarOpen]); const sidebarProps: SidebarProps = { portRoles, isSuperAdmin, user, ports, portLogoUrls, }; // Chrome subtree per tier. let chrome: ReactNode = null; if (isMobile) { chrome = ; } else if (isTablet) { // Tablet: sidebar lives inside a left-side Sheet, opened by the // topbar's leading logo button. SheetContent has its own width; // override to match the inline Sidebar's width so the layout // reads consistent when the sheet is open. chrome = ( ); } else { chrome = ; } const footer = isMobile ? ( <> setMoreOpen(true)} onSearchClick={() => setSearchOpen(true)} /> ) : null; // Desktop topbar; on tablet it gains a leading logo button that // opens the sidebar Sheet. const desktopTopbar = !isMobile ? ( setTabletSidebarOpen(true)} className="inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-md hover:bg-accent transition-colors" > {logoUrl ? ( ) : ( // Neutral fallback when the port has no branding logo yet — // a three-bar menu icon keeps the affordance discoverable. )} ) : null } /> ) : null; return (
{chrome}
{desktopTopbar}
{children}
{footer}
); }