'use client'; import { useEffect, useState, type ComponentProps, type ReactNode } from 'react'; import { usePathname, useRouter } from 'next/navigation'; import { cn } from '@/lib/utils'; import { usePermissions } from '@/hooks/use-permissions'; import { Sidebar } from '@/components/layout/sidebar'; import { Topbar } from '@/components/layout/topbar'; import { NavigationHistoryTracker } from '@/components/layout/navigation-history-tracker'; 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; /** Per-port `tenancies_module_enabled` resolution. Gates the Tenancies * sidebar entry SSR-side so the nav doesn't flicker in/out. */ tenanciesModuleByPort: Record; /** Per-port `expenses_module_enabled` resolution. Gates the Expenses * + How-to-upload-receipts sidebar entries SSR-side. Defaults to * true so existing ports keep the feature. */ expensesModuleByPort: Record; /** Per-port `residential_module_enabled` resolution. Gates the * "Residential" sidebar section + mobile entry SSR-side. Defaults to * true so existing ports keep the feature. */ residentialModuleByPort: 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, tenanciesModuleByPort, expensesModuleByPort, residentialModuleByPort, 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 currentPortId = useUIStore((s) => s.currentPortId); const logoUrl = currentPortSlug ? portLogoUrls[currentPortSlug] : null; // Residential lockdown: a residential-only user (residential access, no // marina `clients.view`) must never see marina pages — including the marina // dashboard. The API already 403s their data; this guard blocks the *routes*, // redirecting any non-residential path to their residential home. Personal // surfaces (settings, inbox) stay reachable. const pathname = usePathname(); const router = useRouter(); const { can } = usePermissions(); const residentialOnly = !isSuperAdmin && can('residential_clients', 'view') && !can('clients', 'view'); useEffect(() => { if (!residentialOnly || !pathname) return; const [portSeg, ...rest] = pathname.split('/').filter(Boolean); const sub = rest.join('/'); const allowed = sub === '' || sub.startsWith('residential') || sub.startsWith('settings') || sub.startsWith('inbox'); if (!allowed && portSeg) { router.replace(`/${portSeg}/residential/clients`); } }, [residentialOnly, pathname, router]); 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, tenanciesModuleByPort, expensesModuleByPort, residentialModuleByPort, }; // Resolve the current port's residential flag for the mobile More sheet // (the sidebar resolves its own copy internally from the by-port map). const residentialModuleEnabled = currentPortId ? (residentialModuleByPort[currentPortId] ?? true) : true; // 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 ( {/* Records every in-app navigation so useSmartBack can return the rep to the page they were actually on (e.g. Sarah Doe -> Yacht -> Back -> Sarah) instead of always falling back to the logical URL parent. Renders nothing. */}
{chrome}
{desktopTopbar}
{children}
{footer}
); }