mobile nav, locale-preserving navigation, accent button contrast
All checks were successful
Build and Push / build (push) Successful in 2m25s
All checks were successful
Build and Push / build (push) Successful in 2m25s
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { signOut, useSession } from "next-auth/react";
|
||||
import { usePathname } from "@/i18n/navigation";
|
||||
@@ -13,6 +14,15 @@ function NavBar() {
|
||||
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
|
||||
@@ -21,6 +31,47 @@ function NavBar() {
|
||||
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">
|
||||
@@ -40,98 +91,96 @@ function NavBar() {
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
{/* Nav links */}
|
||||
{/* Desktop nav links */}
|
||||
<nav className="hidden sm:flex items-center gap-1 ml-2">
|
||||
<NavLink href="/dashboard" active={pathname === "/dashboard"}>
|
||||
{t("dashboard")}
|
||||
</NavLink>
|
||||
{/* Slice 7: /team is owner+platform only AND personal
|
||||
accounts are excluded — they have no team to manage
|
||||
(Bug 8). Match server-side gates (`canMutate`,
|
||||
`user.isPersonal === false`). The roles array carries
|
||||
either "owner" or "user" for customer sessions;
|
||||
isPlatform covers the platform side. */}
|
||||
{user &&
|
||||
!user.isPersonal &&
|
||||
(user.isPlatform ||
|
||||
(Array.isArray(user.roles) && user.roles.includes("owner"))) && (
|
||||
<NavLink href="/team" active={pathname === "/team"}>
|
||||
{t("team")}
|
||||
</NavLink>
|
||||
)}
|
||||
{/* Bug 35: /settings is shown to anyone who can mutate org-level
|
||||
state — owners and platform admins. Personal accounts also
|
||||
see it; their billing page is optional but the entry point
|
||||
exists for consistency. `user`-role customers don't see it
|
||||
(canMutate is false). */}
|
||||
{user &&
|
||||
(user.isPlatform ||
|
||||
(Array.isArray(user.roles) && user.roles.includes("owner"))) && (
|
||||
<NavLink
|
||||
href="/settings"
|
||||
active={pathname.startsWith("/settings")}
|
||||
>
|
||||
{t("settings")}
|
||||
</NavLink>
|
||||
)}
|
||||
{/* Phase 3: Billing visible to anyone signed in. The
|
||||
page is org-scoped server-side — non-owner members
|
||||
see the same invoice history their owner does, but
|
||||
actions like "configure billing details" are gated
|
||||
separately on the settings page. Personal accounts
|
||||
see their own (single-tenant) invoices. */}
|
||||
{user && (
|
||||
<NavLink
|
||||
href="/billing"
|
||||
active={pathname.startsWith("/billing")}
|
||||
>
|
||||
{t("billing")}
|
||||
{links.map((l) => (
|
||||
<NavLink key={l.href} href={l.href} active={isActive(l.href)}>
|
||||
{l.label}
|
||||
</NavLink>
|
||||
)}
|
||||
{/* Feature 5: Support is available to every signed-in
|
||||
user. Customers see their own tickets only; platform
|
||||
admins see the queue. */}
|
||||
{user && (
|
||||
<NavLink
|
||||
href="/support"
|
||||
active={pathname.startsWith("/support")}
|
||||
>
|
||||
{t("support")}
|
||||
</NavLink>
|
||||
)}
|
||||
{user?.isPlatform && (
|
||||
<NavLink href="/admin" active={pathname === "/admin"}>
|
||||
{t("admin")}
|
||||
</NavLink>
|
||||
)}
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* Right side */}
|
||||
<div className="flex items-center gap-4">
|
||||
{user && (
|
||||
// For personal accounts the orgName is opaque
|
||||
// ("personal-3f2a8b1c") or a synthetic legacy
|
||||
// "Name (Personal)" — neither is what we want in the nav.
|
||||
// Show the user's display name instead. The detection logic
|
||||
// and fallback chain live in `lib/personal-org.ts`; keeping
|
||||
// a thin inline branch here avoids importing a server-only
|
||||
// helper into a client component.
|
||||
<span className="hidden md:inline text-xs text-text-secondary font-mono">
|
||||
{user.isPersonal
|
||||
? user.name || (user.email ? user.email.split("@")[0] : user.orgName)
|
||||
: user.orgName}
|
||||
{displayName}
|
||||
</span>
|
||||
)}
|
||||
<LanguageSwitcher />
|
||||
<button
|
||||
onClick={() => signOut({ callbackUrl: "/login" })}
|
||||
className="text-xs font-medium text-text-secondary hover:text-error transition-colors cursor-pointer"
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user