User reported: "when I refresh the page with this size viewport it switches between tablet and desktop view." The root cause was the two-step tier resolution: 1. Server renders shell based on User-Agent (mobile vs desktop only). 2. Client mounts with that hint, useEffect runs matchMedia, may flip. When the UA says "desktop" but the viewport is actually 900px (so matchMedia says "tablet"), the chrome visibly switches mid-render. Most painful on macOS Safari dragged below 1024. Fix: AppShell writes a `pn-crm.viewport-tier` cookie (1-year, Lax) on every matchMedia evaluation. The dashboard layout reads the cookie and prefers it over the UA classifier for `initialFormFactor`. First visit can still flicker (no cookie yet); every subsequent reload uses the resolved tier and renders the correct chrome on first paint. The cookie values are 'mobile' / 'tablet' / 'desktop' but the server's initialFormFactor prop only accepts 'mobile' | 'desktop' (binary by design — AppShell's useEffect resolves the actual tier client-side from matchMedia). 'tablet' from the cookie collapses to 'desktop' on SSR; AppShell's useEffect re-resolves to tablet immediately. The fluent path on cookie hit is desktop -> tablet (no flicker because both shells render the desktop tree; only the sidebar Sheet wrapper differs, and that's invisible until opened). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
240 lines
9.2 KiB
TypeScript
240 lines
9.2 KiB
TypeScript
'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<typeof Sidebar>;
|
|
type TopbarProps = ComponentProps<typeof Topbar>;
|
|
|
|
interface AppShellProps {
|
|
portRoles: SidebarProps['portRoles'];
|
|
isSuperAdmin: boolean;
|
|
user: NonNullable<SidebarProps['user']>;
|
|
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<string, string | null>;
|
|
/**
|
|
* 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 `<main>` and ONE `<MobileLayoutProvider>` 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<Tier>(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 = <MobileTopbar />;
|
|
} 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 = (
|
|
<Sheet open={tabletSidebarOpen} onOpenChange={setTabletSidebarOpen}>
|
|
<SheetContent side="left" className="p-0 w-[var(--width-sidebar)]">
|
|
<Sidebar {...sidebarProps} />
|
|
</SheetContent>
|
|
</Sheet>
|
|
);
|
|
} else {
|
|
chrome = <Sidebar {...sidebarProps} />;
|
|
}
|
|
|
|
const footer = isMobile ? (
|
|
<>
|
|
<MobileBottomTabs
|
|
onMoreClick={() => setMoreOpen(true)}
|
|
onSearchClick={() => setSearchOpen(true)}
|
|
/>
|
|
<MoreSheet open={moreOpen} onOpenChange={setMoreOpen} />
|
|
<MobileSearchOverlay open={searchOpen} onOpenChange={setSearchOpen} />
|
|
</>
|
|
) : null;
|
|
|
|
// Desktop topbar; on tablet it gains a leading logo button that
|
|
// opens the sidebar Sheet.
|
|
const desktopTopbar = !isMobile ? (
|
|
<Topbar
|
|
ports={ports}
|
|
user={user}
|
|
leadingSlot={
|
|
isTablet ? (
|
|
<button
|
|
type="button"
|
|
aria-label="Open menu"
|
|
onClick={() => setTabletSidebarOpen(true)}
|
|
className="inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-md hover:bg-accent transition-colors"
|
|
>
|
|
{logoUrl ? (
|
|
<img src={logoUrl} alt="" className="h-6 w-6 object-contain" />
|
|
) : (
|
|
// Neutral fallback when the port has no branding logo yet —
|
|
// a three-bar menu icon keeps the affordance discoverable.
|
|
<svg
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="2"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
className="h-4 w-4"
|
|
aria-hidden
|
|
>
|
|
<line x1="3" y1="6" x2="21" y2="6" />
|
|
<line x1="3" y1="12" x2="21" y2="12" />
|
|
<line x1="3" y1="18" x2="21" y2="18" />
|
|
</svg>
|
|
)}
|
|
</button>
|
|
) : null
|
|
}
|
|
/>
|
|
) : null;
|
|
|
|
return (
|
|
<MobileLayoutProvider>
|
|
<div
|
|
className={cn(
|
|
'bg-background',
|
|
isMobile ? 'min-h-[100dvh]' : 'flex h-screen overflow-hidden',
|
|
)}
|
|
>
|
|
{chrome}
|
|
<div className={cn(isMobile ? 'contents' : 'flex-1 flex flex-col overflow-hidden min-w-0')}>
|
|
{desktopTopbar}
|
|
<main
|
|
className={cn(
|
|
isMobile
|
|
? 'px-4 min-h-[100dvh] pt-[calc(56px+env(safe-area-inset-top)+1rem)] pb-[calc(56px+env(safe-area-inset-bottom)+2rem)]'
|
|
: 'flex-1 overflow-y-auto bg-background px-6 pt-3 pb-6',
|
|
)}
|
|
>
|
|
{children}
|
|
</main>
|
|
</div>
|
|
{footer}
|
|
</div>
|
|
</MobileLayoutProvider>
|
|
);
|
|
}
|