"use client"; import { Loader2 } from "lucide-react"; import { usePathname, useRouter } from "next/navigation"; import { useEffect } from "react"; import { AchievementUnlockNotifier } from "@/components/gamification/achievement-unlock-notifier"; import { MaintainershipBanner } from "@/components/i18n/maintainership-banner"; import { LayoutCoachMount } from "@/components/insights/layout-coach-mount"; import { useAuth } from "@/hooks/use-auth"; import { useTranslations } from "@/lib/i18n/context"; import { CoachLaunchProvider } from "@/lib/insights/coach-launch-context"; import { BottomNav } from "./bottom-nav"; import { OfflineBanner } from "./offline-banner"; import { SidebarNav } from "./sidebar-nav"; import { TopBar } from "./top-bar"; // v1.4.27 MB6 — `/about` joins the public-path list so the GeoLite2 // CC BY-SA 4.0 attribution stays reachable for unauthenticated // visitors. The `/about` route is already registered in `proxy.ts` // PUBLIC_PATHS; this constant gates the client-side auth shell, and // both lists must agree or the route round-trips through the // sign-in redirect. const PUBLIC_PATHS = [ "/auth/login", "/auth/register", "/privacy", "/about", ]; export function AuthShell({ children }: { children: React.ReactNode }) { const { user, isAuthenticated, isLoading } = useAuth(); const { t } = useTranslations(); const pathname = usePathname(); const router = useRouter(); const isPublicPage = PUBLIC_PATHS.some((p) => pathname.startsWith(p)); // v1.4.26 — `/privacy` is a long-form legal page that brings its own // header + footer. Centering it inside the public-page wrapper would // squash a 3000-word policy into a "login card" layout, so we let it // render edge-to-edge while still resolving as a public page for the // auth gate above. // v1.4.27 MB6 — `/about` follows the same shape (own header, own // footer, full-width body), so it joins the standalone list. const isStandalonePublicPage = pathname.startsWith("/privacy") || pathname.startsWith("/about"); const isAdminPage = pathname.startsWith("/admin"); const isOnboardingPage = pathname === "/onboarding"; const showUnlockNotifier = isAuthenticated && !isPublicPage && !!user?.id; // v1.9.0 — the document-level scrollbar-gutter (globals.css) is reserved // only for the body-scrolled shells (login / register / onboarding / // standalone legal pages). The authenticated branch is height-locked and // scrolls inside its own `
`, which reserves its own gutter; applying // the document gutter there too produced a second, never-painted gutter on // classic-scrollbar platforms. Flag the body-scrolled branches on `` // so the CSS rule scopes to them. const isBodyScrolled = isPublicPage || isOnboardingPage; useEffect(() => { const root = document.documentElement; if (isBodyScrolled) { root.setAttribute("data-scroll", "body"); } else { root.removeAttribute("data-scroll"); } return () => { root.removeAttribute("data-scroll"); }; }, [isBodyScrolled]); useEffect(() => { if (!isLoading && !isAuthenticated && !isPublicPage) { router.replace("/auth/login"); } }, [isLoading, isAuthenticated, isPublicPage, router]); // Redirect non-admins away from /admin useEffect(() => { if ( !isLoading && isAuthenticated && isAdminPage && user?.role !== "ADMIN" ) { router.replace("/"); } }, [isLoading, isAuthenticated, isAdminPage, user?.role, router]); // v1.4.22 C4 — onboarding redirect moved to proxy.ts so it lands on // the first server response instead of post-hydration. The proxy // reads the `hl_onboarding` cookie (mirrored from the DB by the auth // routes) and 307s to /onboarding before this shell ever paints. The // previous `useEffect`-based redirect caused a brief dashboard flash // for users with `onboardingCompletedAt === null`. // Loading state if (isLoading) { return (
{t("nav.loadingScreen")}
); } // Public pages (login, register) — render without any nav if (isPublicPage) { // Long-form legal pages render edge-to-edge with their own chrome. if (isStandalonePublicPage) { return <>{children}; } return (
{children}
); } // Not authenticated, waiting for redirect if (!isAuthenticated) { return (
{t("nav.loadingScreen")}
); } // Onboarding page — minimal shell, no sidebar/nav if (isOnboardingPage) { return ( <> {showUnlockNotifier && user?.id ? ( ) : null}
{children}
); } // Authenticated — full app shell. // // v1.4.34 IW-B — the Coach launch provider lives here (not inside // `app/insights/layout.tsx` anymore) so every authenticated route can // call `askCoach()` from the same context. Pre-hoist the drawer was // only reachable from `/insights/**`; the dashboard hero CTA now opens // it without a route hop. `` consumes the same // context to render the drawer once at the shell level — the // insights-only mobile FAB stays where it is, inside the routed // insights layout, so the bottom-right floating action sits beside // the chart tooltips only on the surfaces that need it. return ( {showUnlockNotifier && user?.id ? ( ) : null} {/* Keyboard-only skip link. The non-focused state has both `pointer-events-none` (so a mouse click in the top-left corner passes through to the logo behind it) AND `-translate-y-full` (so even an accidental tap with stylus / touch can't activate it). Focus restores both — the element jumps back into the viewport and becomes clickable. */} {t("nav.skipToContent")}
{/* v1.4.43 QoL (M5) — `` paints only when `navigator.onLine === false`. Sits above the maintainership banner + top bar so the connection-status hint is always the first chrome line a user sees in the offline branch. */}
{/* v1.4.33 IW9 — container normalised on `max-w-screen-xl` (1280 px) so dashboard / settings / admin / bugreport all hit the same content frame. Pre-v1.4.33 the dashboard shell capped at `max-w-[76.8rem]` (1228 px) while the settings/admin shells used `max-w-screen-xl`, producing a 52 px lateral wobble on every route switch. Same audit note in `.planning/round-v1433-audit-polish.md` §4.2. */}
{children}
); }