Files
HealthLog/src/components/layout/auth-shell.tsx
T
Marc-André Bombeck 32a023dfff chore(ui): apply M7 + L1 + L3 + L4 polish across medication form, motion-reduce, details aria, therapy timeline
W10 design-review carry-over items. M7 lifts the medication-form
day-of-week buttons, the more-horizontal trigger, and the schedule
label-input gaps to the 44 px / space-y-2 standard used by every
other settings surface. L1 pairs every animate-spin/pulse/bounce
with motion-reduce:animate-none across the component tree so users
with prefers-reduced-motion get a still surface. L3 adds the
aria-controls + panel-id pairing to the inventory + coach-evidence
<details> blocks for parity with the Health Score provenance
accordion. L4 inserts a sr-only h4 per therapy-timeline entry so
screen-reader users can jump between medication blocks.
2026-05-15 17:13:42 +02:00

153 lines
5.3 KiB
TypeScript

"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 (
<div className="flex h-dvh items-center justify-center" role="status">
<Loader2 className="text-primary h-6 w-6 animate-spin motion-reduce:animate-none" />
<span className="sr-only">{t("nav.loadingScreen")}</span>
</div>
);
}
// 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 (
<div className="flex h-dvh flex-col">
<MaintainershipBanner />
<main
id="main-content"
className="flex flex-1 items-center justify-center px-4"
>
{children}
</main>
</div>
);
}
// Not authenticated, waiting for redirect
if (!isAuthenticated) {
return (
<div className="flex h-dvh items-center justify-center" role="status">
<Loader2 className="text-primary h-6 w-6 animate-spin motion-reduce:animate-none" />
<span className="sr-only">{t("nav.loadingScreen")}</span>
</div>
);
}
// Onboarding page — minimal shell, no sidebar/nav
if (isOnboardingPage) {
return (
<>
{showUnlockNotifier && user?.id ? (
<AchievementUnlockNotifier userId={user.id} />
) : null}
<div className="flex min-h-dvh flex-col">
<MaintainershipBanner />
<div className="flex flex-1 items-center justify-center px-4 py-8">
{children}
</div>
</div>
</>
);
}
// Authenticated — full app shell
return (
<>
{showUnlockNotifier && user?.id ? (
<AchievementUnlockNotifier userId={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.
*/}
<a
href="#main-content"
className="bg-primary text-primary-foreground pointer-events-none fixed top-2 left-2 z-[100] -translate-y-full rounded-md px-4 py-2 text-sm font-medium opacity-0 transition-transform focus:pointer-events-auto focus:translate-y-0 focus:opacity-100"
>
{t("nav.skipToContent")}
</a>
<div className="flex h-dvh flex-col md:flex-row">
<SidebarNav />
<div className="flex min-h-0 flex-1 flex-col">
<MaintainershipBanner />
<TopBar />
<main
id="main-content"
className="flex-1 overflow-y-auto pb-[calc(4rem+env(safe-area-inset-bottom,0px))] md:pb-0"
>
<div className="mx-auto max-w-[76.8rem] px-4 py-6 md:px-6">
{children}
</div>
</main>
</div>
<BottomNav />
</div>
</>
);
}