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>
318 lines
11 KiB
TypeScript
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>
|
|
</>
|
|
);
|
|
}
|