231 lines
8.3 KiB
TypeScript
231 lines
8.3 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useEffect } from "react";
|
|
import { useTranslations } from "next-intl";
|
|
import { signOut, useSession } from "next-auth/react";
|
|
import { usePathname } from "@/i18n/navigation";
|
|
import { Link } from "@/i18n/navigation";
|
|
import { SessionProvider } from "next-auth/react";
|
|
import type { Session } from "next-auth";
|
|
import { LanguageSwitcher } from "@/components/ui/language-switcher";
|
|
import { Logo } from "@/components/ui/logo";
|
|
|
|
function NavBar() {
|
|
const t = useTranslations("common");
|
|
const { data: session } = useSession();
|
|
const pathname = usePathname();
|
|
const user = (session as any)?.platformUser;
|
|
|
|
const [mobileOpen, setMobileOpen] = useState(false);
|
|
|
|
// Close the mobile menu on any navigation. Without this the panel
|
|
// would stay open across route changes (the component doesn't
|
|
// unmount — it lives in the layout).
|
|
useEffect(() => {
|
|
setMobileOpen(false);
|
|
}, [pathname]);
|
|
|
|
// Hide the nav entirely on auth-only routes. These pages have no
|
|
// session yet — showing "Dashboard" / "Sign Out" is misleading at
|
|
// best (the buttons would 401 or redirect-loop). Keep this list
|
|
// narrow and route-exact: anything else we add to the auth flow
|
|
// (e.g. password reset) needs to be added here too.
|
|
const isAuthRoute = pathname === "/login" || pathname === "/register";
|
|
if (isAuthRoute) return null;
|
|
|
|
// ------------------------------------------------------------------
|
|
// Visibility gates — computed once, shared by the desktop nav and the
|
|
// mobile panel so the two can never diverge.
|
|
//
|
|
// - team: owner+platform only AND not a personal account (Bug 8 —
|
|
// personal accounts have no team). Matches `canMutate` /
|
|
// `user.isPersonal === false` server-side.
|
|
// - settings: anyone who can mutate org-level state (owners + platform).
|
|
// `user`-role customers don't see it (canMutate is false).
|
|
// - billing / support: any signed-in user (org-scoped server-side).
|
|
// - admin: platform only.
|
|
// ------------------------------------------------------------------
|
|
const isOwner =
|
|
user && Array.isArray(user.roles) && user.roles.includes("owner");
|
|
const showTeam = !!user && !user.isPersonal && (user.isPlatform || isOwner);
|
|
const showSettings = !!user && (user.isPlatform || isOwner);
|
|
const showBilling = !!user;
|
|
const showSupport = !!user;
|
|
const showAdmin = !!user?.isPlatform;
|
|
|
|
// Active-state helper. Dashboard/Admin previously used exact `===`,
|
|
// so sub-routes (/dashboard/new, /admin/billing, …) showed no active
|
|
// item. startsWith keeps the parent lit on its children too.
|
|
const isActive = (href: string) =>
|
|
pathname === href || pathname.startsWith(`${href}/`);
|
|
|
|
const links = [
|
|
{ href: "/dashboard", label: t("dashboard"), show: !!user },
|
|
{ href: "/team", label: t("team"), show: showTeam },
|
|
{ href: "/settings", label: t("settings"), show: showSettings },
|
|
{ href: "/billing", label: t("billing"), show: showBilling },
|
|
{ href: "/support", label: t("support"), show: showSupport },
|
|
{ href: "/admin", label: t("admin"), show: showAdmin },
|
|
].filter((l) => l.show);
|
|
|
|
const displayName = user
|
|
? user.isPersonal
|
|
? user.name || (user.email ? user.email.split("@")[0] : user.orgName)
|
|
: user.orgName
|
|
: "";
|
|
|
|
return (
|
|
<header className="sticky top-0 z-50 border-b border-border bg-surface-1/80 backdrop-blur-md">
|
|
<div className="mx-auto flex h-14 max-w-6xl items-center justify-between px-5">
|
|
{/* Logo / brand */}
|
|
<div className="flex items-center gap-6">
|
|
<Link href="/dashboard" className="flex items-center gap-2.5 group">
|
|
{/* Brand mark */}
|
|
<Logo className="h-7 w-auto text-accent group-hover:text-accent-dim transition-colors" />
|
|
<span className="font-display text-base font-semibold tracking-tight text-text-primary">
|
|
{t("appName")}
|
|
</span>
|
|
<span className="hidden sm:inline text-[11px] font-medium tracking-widest uppercase text-text-muted">
|
|
{t("tagline")}
|
|
</span>
|
|
</Link>
|
|
|
|
{/* Desktop nav links */}
|
|
<nav className="hidden sm:flex items-center gap-1 ml-2">
|
|
{links.map((l) => (
|
|
<NavLink key={l.href} href={l.href} active={isActive(l.href)}>
|
|
{l.label}
|
|
</NavLink>
|
|
))}
|
|
</nav>
|
|
</div>
|
|
|
|
{/* Right side */}
|
|
<div className="flex items-center gap-4">
|
|
{user && (
|
|
<span className="hidden md:inline text-xs text-text-secondary font-mono">
|
|
{displayName}
|
|
</span>
|
|
)}
|
|
<LanguageSwitcher />
|
|
<button
|
|
onClick={() => signOut({ callbackUrl: "/login" })}
|
|
className="hidden sm:inline text-xs font-medium text-text-secondary hover:text-error transition-colors cursor-pointer"
|
|
>
|
|
{t("logout")}
|
|
</button>
|
|
|
|
{/* Mobile menu toggle — only shown below the `sm` breakpoint,
|
|
where the desktop nav and logout button are hidden. */}
|
|
{user && (
|
|
<button
|
|
type="button"
|
|
onClick={() => setMobileOpen((v) => !v)}
|
|
aria-expanded={mobileOpen}
|
|
aria-controls="mobile-nav"
|
|
aria-label={t("menu")}
|
|
className="sm:hidden inline-flex items-center justify-center h-8 w-8 -mr-1 rounded-md text-text-secondary hover:text-text-primary hover:bg-surface-2 transition-colors cursor-pointer"
|
|
>
|
|
<svg
|
|
className="h-5 w-5"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="1.75"
|
|
strokeLinecap="round"
|
|
>
|
|
{mobileOpen ? (
|
|
<path d="M6 6l12 12M18 6L6 18" />
|
|
) : (
|
|
<path d="M4 7h16M4 12h16M4 17h16" />
|
|
)}
|
|
</svg>
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Mobile panel */}
|
|
{user && mobileOpen && (
|
|
<nav
|
|
id="mobile-nav"
|
|
className="sm:hidden border-t border-border bg-surface-1 px-3 py-3"
|
|
>
|
|
<div className="flex flex-col gap-1">
|
|
{links.map((l) => (
|
|
<Link
|
|
key={l.href}
|
|
href={l.href}
|
|
className={`px-3 py-2.5 rounded-md text-sm font-medium transition-colors ${
|
|
isActive(l.href)
|
|
? "bg-surface-3 text-text-primary"
|
|
: "text-text-secondary hover:text-text-primary hover:bg-surface-2"
|
|
}`}
|
|
>
|
|
{l.label}
|
|
</Link>
|
|
))}
|
|
</div>
|
|
|
|
<div className="mt-3 pt-3 border-t border-border flex items-center justify-between px-3">
|
|
<span className="text-xs text-text-secondary font-mono truncate">
|
|
{displayName}
|
|
</span>
|
|
<button
|
|
onClick={() => signOut({ callbackUrl: "/login" })}
|
|
className="text-xs font-medium text-text-secondary hover:text-error transition-colors cursor-pointer shrink-0 ml-3"
|
|
>
|
|
{t("logout")}
|
|
</button>
|
|
</div>
|
|
</nav>
|
|
)}
|
|
</header>
|
|
);
|
|
}
|
|
|
|
function NavLink({
|
|
href,
|
|
active,
|
|
children,
|
|
}: {
|
|
href: string;
|
|
active: boolean;
|
|
children: React.ReactNode;
|
|
}) {
|
|
return (
|
|
<Link
|
|
href={href}
|
|
className={`
|
|
px-3 py-1.5 rounded-md text-sm font-medium transition-colors
|
|
${
|
|
active
|
|
? "bg-surface-3 text-text-primary"
|
|
: "text-text-secondary hover:text-text-primary hover:bg-surface-2"
|
|
}
|
|
`}
|
|
>
|
|
{children}
|
|
</Link>
|
|
);
|
|
}
|
|
|
|
export function NavShell({
|
|
children,
|
|
session,
|
|
}: {
|
|
children: React.ReactNode;
|
|
// Server-resolved session passed down from the locale layout. Seeding
|
|
// SessionProvider with it means useSession() is populated on the first
|
|
// client render, so the nav links render immediately instead of
|
|
// popping in after the client-side session fetch (CLS / flash).
|
|
session: Session | null;
|
|
}) {
|
|
return (
|
|
<SessionProvider session={session}>
|
|
<NavBar />
|
|
<main className="mx-auto max-w-6xl px-5 py-8">{children}</main>
|
|
</SessionProvider>
|
|
);
|
|
}
|