"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 { useAuth } from "@/hooks/use-auth"; import { useTranslations } from "@/lib/i18n/context"; import { BottomNav } from "./bottom-nav"; import { SidebarNav } from "./sidebar-nav"; import { TopBar } from "./top-bar"; const PUBLIC_PATHS = ["/auth/login", "/auth/register", "/privacy"]; 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. const isStandalonePublicPage = pathname.startsWith("/privacy"); const isAdminPage = pathname.startsWith("/admin"); const isOnboardingPage = pathname === "/onboarding"; const showUnlockNotifier = isAuthenticated && !isPublicPage && !!user?.id; 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 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")}
{children}
); }