yiekheng 429ae0827f fix(web): only ONE nav item highlighted at a time + drop redundant Close
Two related bugs from the same review pass:

1. /settings/users lit up BOTH the Admin and Settings entries in the
   sidebar/drawer. The active-state check was naïve
   `pathname.startsWith(href)`, which matches every parent prefix.
   Replaced with a longest-match helper pickActiveNavKey() in
   nav-config.ts: the most-specific item wins, parents stay quiet,
   '/' only matches an exact pathname, and a strict-descendant check
   (`href + '/'`) prevents `/settingsfoo` from lighting up Settings.

2. <DialogFooter showCloseButton> on the user-row delete (and three
   other dialogs that I missed earlier) was rendering an extra outline
   "Close" button next to the operator's own Cancel + Radix's corner X.
   Stripped the prop from every remaining caller (login, dashboard
   clear-history, reminder actions-bar, settings/users delete) so each
   dialog footer shows just Cancel + the primary action.

Tests:

  - nav-config.test.ts: 7 new cases covering the longest-match contract
    — /settings/users highlights ONLY Admin, /settings highlights ONLY
    Settings, '/' is exact-match only, sibling-prefix /settingsfoo
    matches nothing, and a defense-in-depth probe asserts at-most-one
    nav highlight across a representative pathname set.

  - test/no-dialog-footer-show-close-button.test.ts: static guard that
    grep-walks every production .tsx and fails if anything passes
    `showCloseButton` to <DialogFooter>. Mirrors the existing
    no-button-wrapping-card guard so the prop can't sneak back in.
    Also self-checks the regex (matches single-line + multi-line +
    other-prop combos; ignores clean DialogFooter and same-named props
    on unrelated components).

463 → 477 web tests, all green; typecheck clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 21:08:40 +08:00

318 lines
11 KiB
TypeScript

"use client";
import { useEffect, useState, useTransition } from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { MenuIcon, LogOutIcon, Loader2Icon } from "lucide-react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { logoutAction } from "@/actions/auth";
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
SheetTrigger,
} from "@/components/ui/sheet";
import {
NAV_ITEMS,
navItemsForRole,
pickActiveNavKey,
type NavItem,
type NavRole,
} from "@/components/nav-config";
// ---------------------------------------------------------------------------
// Mobile header (sm:hidden)
//
// Single-row layout:
// ┌──┐ ┌────┐
// │cm│ Page title │menu│
// └──┘ └────┘
//
// The brand mark on the left links home. The page title (derived from
// the current nav route) gives the user a "you are here" cue without
// waiting for the page content to render. The menu button on the right
// opens a Sheet with the full nav list and the theme toggle.
// ---------------------------------------------------------------------------
// ---------------------------------------------------------------------------
// Sign-out button used by both the desktop sidebar footer and the mobile
// drawer footer. Server-action under the hood: clears the session
// cookie and redirects to /login. Disabled while in flight so a
// double-click doesn't fire two redirects.
// ---------------------------------------------------------------------------
function SignOutButton({ username }: { username: string | null }) {
const [pending, start] = useTransition();
return (
<div className="flex items-center justify-between gap-2">
<div className="min-w-0">
{username && (
<p className="text-xs text-muted-foreground truncate">
Signed in as <em className="italic font-medium text-foreground">{username}</em>
</p>
)}
</div>
<Button
type="button"
variant="ghost"
size="sm"
disabled={pending}
onClick={() => start(() => logoutAction())}
aria-label="Sign out"
>
{pending ? (
<Loader2Icon className="size-4 animate-spin" />
) : (
<LogOutIcon className="size-4" />
)}
Sign out
</Button>
</div>
);
}
function MobileHeader({
items,
username,
}: {
items: NavItem[];
username: string | null;
}) {
const pathname = usePathname();
const activeKey = pickActiveNavKey(items, pathname);
const [open, setOpen] = useState(false);
// Close the drawer when the route changes (i.e. the user picked a nav
// item). Without this, navigating leaves the sheet open over the new
// page until the user dismisses it manually.
useEffect(() => {
setOpen(false);
}, [pathname]);
// Use the full list (not the role-filtered one) for the title lookup
// so the page title still shows up correctly when a 'user' role hits
// a route they wouldn't normally see in the nav (e.g. arrives via a
// direct link), even though they can't navigate there from the menu.
const currentItem = NAV_ITEMS.find(({ href }) =>
href === "/" ? pathname === "/" : pathname.startsWith(href),
);
const title = currentItem?.label ?? "WhatsApp Bot";
return (
<header className="fixed top-0 left-0 right-0 z-40 flex h-14 items-center justify-between border-b border-border bg-background/95 backdrop-blur-sm px-3 sm:hidden">
<Link
href="/"
aria-label="Go home"
className="flex size-9 items-center justify-center rounded-lg bg-primary text-xs font-bold uppercase text-primary-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
cm
</Link>
<span className="truncate text-sm font-semibold tracking-tight px-2">
{title}
</span>
<Sheet open={open} onOpenChange={setOpen}>
<SheetTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label="Open menu"
className="size-9"
>
<MenuIcon className="size-5" />
</Button>
</SheetTrigger>
<SheetContent side="right" className="flex w-72 flex-col gap-0 p-0">
<SheetHeader className="gap-1 border-b border-border px-4 py-3">
<SheetTitle className="flex items-center gap-2">
<span
aria-hidden
className="flex size-6 items-center justify-center rounded-md bg-primary text-[10px] font-bold uppercase text-primary-foreground"
>
cm
</span>
WhatsApp Bot
</SheetTitle>
<SheetDescription className="sr-only">
Primary navigation menu
</SheetDescription>
</SheetHeader>
<nav
aria-label="Primary navigation"
className="flex flex-col gap-0.5 p-2"
>
{items.map(({ key, href, label, icon: Icon }) => {
const active = activeKey === key;
return (
<Link
key={key}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
href={href as any}
aria-current={active ? "page" : undefined}
className={cn(
"flex min-h-[44px] items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors",
active
? "bg-accent text-accent-foreground"
: "text-foreground hover:bg-muted",
)}
>
<Icon
size={18}
strokeWidth={active ? 2.5 : 1.75}
aria-hidden
/>
{label}
</Link>
);
})}
</nav>
<div className="mt-auto border-t border-border p-3">
<SignOutButton username={username} />
</div>
</SheetContent>
</Sheet>
</header>
);
}
// ---------------------------------------------------------------------------
// Sidebar (desktop only — hidden below sm)
// ---------------------------------------------------------------------------
function Sidebar({
items,
username,
}: {
items: NavItem[];
username: string | null;
}) {
const pathname = usePathname();
const activeKey = pickActiveNavKey(items, pathname);
return (
<aside className="hidden sm:flex fixed left-0 top-0 bottom-0 z-40 w-56 flex-col border-r border-border bg-sidebar">
{/* Bot name / brand — clickable, returns to the dashboard. */}
<Link
href="/"
aria-label="Go to dashboard"
className="flex h-14 items-center gap-2 px-4 border-b border-sidebar-border shrink-0 hover:bg-sidebar-accent/40 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset"
>
<span
aria-hidden
className="flex size-6 items-center justify-center rounded-md bg-primary text-[10px] font-bold uppercase text-primary-foreground"
>
cm
</span>
<span className="text-sm font-semibold tracking-tight text-sidebar-foreground">
WhatsApp Bot
</span>
</Link>
{/* Nav items */}
<nav aria-label="Primary navigation" className="flex flex-col gap-1 p-3 flex-1">
{items.map(({ key, href, label, icon: Icon }) => {
const active = href === "/" ? pathname === "/" : pathname.startsWith(href);
return (
<Link
key={key}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
href={href as any}
aria-current={active ? "page" : undefined}
className={cn(
"flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors min-h-[44px]",
active
? "bg-sidebar-accent text-sidebar-accent-foreground"
: "text-sidebar-foreground hover:bg-sidebar-accent/60 hover:text-sidebar-accent-foreground",
)}
>
<Icon size={18} strokeWidth={active ? 2.5 : 1.75} aria-hidden />
{label}
</Link>
);
})}
</nav>
{/* Footer: signed-in user + sign-out */}
<div className="border-t border-sidebar-border p-3">
<SignOutButton username={username} />
</div>
</aside>
);
}
// ---------------------------------------------------------------------------
// Bare header for unauthenticated routes (/login). No sidebar, no mobile
// menu, no nav — just the centered brand mark + name. The user explicitly
// asked for nothing else here so the sign-in screen feels like a separate
// surface from the authenticated app.
// ---------------------------------------------------------------------------
function BareHeader() {
return (
<header className="fixed top-0 left-0 right-0 z-40 flex h-14 items-center justify-center border-b border-border bg-background/95 backdrop-blur-sm px-3">
<div className="flex items-center gap-2">
<span
aria-hidden
className="flex size-6 items-center justify-center rounded-md bg-primary text-[10px] font-bold uppercase text-primary-foreground"
>
cm
</span>
<span className="text-sm font-semibold tracking-tight">
WhatsApp Bot
</span>
</div>
</header>
);
}
// ---------------------------------------------------------------------------
// AppShell — the outer container
// ---------------------------------------------------------------------------
interface AppShellProps {
children: React.ReactNode;
/** Role of the signed-in user, or null when unauthenticated. */
role: NavRole | null;
/** Username of the signed-in user, surfaced in the footer + sign-out hint. */
username: string | null;
}
export function AppShell({ children, role, username }: AppShellProps) {
const pathname = usePathname();
const isAuthRoute = pathname === "/login";
if (isAuthRoute) {
return (
<>
<BareHeader />
<main className="min-h-dvh pt-14">{children}</main>
</>
);
}
// Treat unauthenticated render of a protected route (shouldn't happen
// because middleware redirects, but defense-in-depth) as 'user': hides
// the admin-only entries.
const items = navItemsForRole(role ?? "user");
return (
<>
{/* Desktop sidebar */}
<Sidebar items={items} username={username} />
{/* Mobile header (single row: brand · title · menu) */}
<MobileHeader items={items} username={username} />
{/* Main content
Mobile: push down for the h-14 header (56px) plus a small gap
so page titles don't kiss the bottom edge of the nav.
Desktop: push right for the sidebar (sm:pl-56), no top offset. */}
<main className="min-h-dvh pt-16 sm:pl-56 sm:pt-0">
{children}
</main>
</>
);
}