Files
MOPC-Portal/src/components/layouts/role-nav.tsx
Matt 1d4e31ddd1
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m52s
feat: external Learning Hub toggle + applicant help button
- Add admin settings: learning_hub_external, learning_hub_external_url, support_email
- Jury/Mentor nav respects external Learning Hub URL (opens in new tab)
- RoleNav supports external nav items with ExternalLink icon
- Applicant header shows Help button with configurable support email
- Settings update mutation now upserts (creates on first use)
- Shared inferSettingCategory for consistent category assignment

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 23:09:29 +01:00

307 lines
13 KiB
TypeScript

'use client'
import { useState, useEffect } from 'react'
import Link from 'next/link'
import { usePathname } from 'next/navigation'
import { signOut, useSession } from 'next-auth/react'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import { UserAvatar } from '@/components/shared/user-avatar'
import { trpc } from '@/lib/trpc/client'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import type { Route } from 'next'
import type { LucideIcon } from 'lucide-react'
import {
LogOut, Menu, Moon, Settings, Sun, User, X,
LayoutDashboard, Scale, Handshake, Eye, ArrowRightLeft,
ExternalLink as ExternalLinkIcon, HelpCircle, Mail,
} from 'lucide-react'
import type { UserRole } from '@prisma/client'
import { useTheme } from 'next-themes'
import { Logo } from '@/components/shared/logo'
import { NotificationBell } from '@/components/shared/notification-bell'
export type NavItem = {
name: string
href: string
icon: LucideIcon
external?: boolean
}
export type RoleNavUser = {
name?: string | null
email?: string | null
}
type RoleNavProps = {
navigation: NavItem[]
roleName: string
user: RoleNavUser
/** The base path for the role (e.g., '/jury', '/mentor', '/observer'). Used for active state detection on the dashboard link. */
basePath: string
/** Optional status badge displayed next to the logo (e.g., remaining evaluations count) */
statusBadge?: React.ReactNode
/** Optional slot rendered in the mobile hamburger menu (between nav links and sign out) and desktop header */
editionSelector?: React.ReactNode
/** Optional support email — when provided, shows a Help button in the header */
helpEmail?: string
}
// Role switcher config — maps roles to their dashboard views
const ROLE_SWITCH_OPTIONS: Record<string, { label: string; path: string; icon: typeof LayoutDashboard }> = {
SUPER_ADMIN: { label: 'Admin View', path: '/admin', icon: LayoutDashboard },
PROGRAM_ADMIN: { label: 'Admin View', path: '/admin', icon: LayoutDashboard },
JURY_MEMBER: { label: 'Jury View', path: '/jury', icon: Scale },
MENTOR: { label: 'Mentor View', path: '/mentor', icon: Handshake },
OBSERVER: { label: 'Observer View', path: '/observer', icon: Eye },
}
function isNavItemActive(pathname: string, href: string, basePath: string): boolean {
return pathname === href || (href !== basePath && pathname.startsWith(href))
}
export function RoleNav({ navigation, roleName, user, basePath, statusBadge, editionSelector, helpEmail }: RoleNavProps) {
const pathname = usePathname()
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false)
const { data: session, status: sessionStatus } = useSession()
const isAuthenticated = sessionStatus === 'authenticated'
const { data: avatarUrl } = trpc.avatar.getUrl.useQuery(undefined, {
enabled: isAuthenticated,
})
const { theme, setTheme } = useTheme()
const [mounted, setMounted] = useState(false)
useEffect(() => setMounted(true), [])
// Roles the user can switch to (excluding current view)
const userRoles = (session?.user?.roles as UserRole[] | undefined) ?? []
const switchableRoles = Object.entries(ROLE_SWITCH_OPTIONS)
.filter(([role, opt]) => userRoles.includes(role as UserRole) && opt.path !== basePath)
// Deduplicate admin paths (SUPER_ADMIN and PROGRAM_ADMIN both go to /admin)
.filter((entry, i, arr) => arr.findIndex(([, o]) => o.path === entry[1].path) === i)
return (
<header className="sticky top-0 z-40 border-b bg-card">
<div className="container-app">
<div className="flex h-16 items-center justify-between">
{/* Logo */}
<Link href={basePath as any} className="flex items-center gap-3">
<Logo showText textSuffix={roleName} />
{statusBadge}
</Link>
{/* Desktop nav */}
<nav className="hidden md:flex items-center gap-1">
{navigation.map((item) => {
const isActive = !item.external && isNavItemActive(pathname, item.href, basePath)
const className = cn(
'flex items-center gap-2 rounded-md px-3 py-2 text-sm font-medium transition-colors',
isActive
? 'bg-primary/10 text-primary'
: 'text-muted-foreground hover:bg-muted hover:text-foreground'
)
if (item.external) {
return (
<a key={item.name} href={item.href} target="_blank" rel="noopener noreferrer" className={className}>
<item.icon className="h-4 w-4" />
{item.name}
<ExternalLinkIcon className="h-3 w-3 opacity-50" />
</a>
)
}
return (
<Link key={item.name} href={item.href as Route} className={className}>
<item.icon className="h-4 w-4" />
{item.name}
</Link>
)
})}
</nav>
{/* User menu & mobile toggle */}
<div className="flex items-center gap-2">
{editionSelector && <div className="hidden md:block">{editionSelector}</div>}
{helpEmail && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" aria-label="Help">
<HelpCircle className="h-5 w-5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<div className="px-2 py-2">
<p className="text-sm font-medium">Need Help?</p>
<p className="text-xs text-muted-foreground mt-1">Contact our support team</p>
</div>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<a href={`mailto:${helpEmail}`} className="flex cursor-pointer items-center gap-2">
<Mail className="h-4 w-4" />
{helpEmail}
</a>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
{mounted && (
<Button
variant="ghost"
size="icon"
onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}
aria-label="Toggle theme"
>
{theme === 'dark' ? (
<Sun className="h-5 w-5" />
) : (
<Moon className="h-5 w-5" />
)}
</Button>
)}
<NotificationBell />
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
className="gap-2 hidden sm:flex"
size="sm"
>
<UserAvatar user={user} avatarUrl={avatarUrl} size="xs" />
<span className="max-w-[120px] truncate">
{user.name || user.email}
</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-48">
<DropdownMenuItem disabled className="text-xs">
<User className="mr-2 h-4 w-4 shrink-0" />
{user.email}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link href={"/settings/profile" as Route} className="flex cursor-pointer items-center">
<Settings className="mr-2 h-4 w-4" />
Settings
</Link>
</DropdownMenuItem>
{switchableRoles.length > 0 && (
<>
<DropdownMenuSeparator />
{switchableRoles.map(([, opt]) => (
<DropdownMenuItem key={opt.path} asChild>
<Link href={opt.path as Route} className="flex cursor-pointer items-center">
<opt.icon className="mr-2 h-4 w-4" />
{opt.label}
</Link>
</DropdownMenuItem>
))}
</>
)}
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => signOut({ callbackUrl: '/login' })}
className="text-destructive focus:text-destructive"
>
<LogOut className="mr-2 h-4 w-4" />
Sign Out
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Button
variant="ghost"
size="icon"
className="md:hidden"
aria-label={isMobileMenuOpen ? 'Close menu' : 'Open menu'}
onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)}
>
{isMobileMenuOpen ? (
<X className="h-5 w-5" />
) : (
<Menu className="h-5 w-5" />
)}
</Button>
</div>
</div>
</div>
{/* Mobile menu — animated with CSS grid */}
<div
className={cn(
'grid md:hidden transition-[grid-template-rows] duration-200 ease-out',
isMobileMenuOpen ? 'grid-rows-[1fr]' : 'grid-rows-[0fr]',
)}
>
<div className="overflow-hidden">
<div className={cn('border-t', !isMobileMenuOpen && 'border-transparent')}>
<nav className="container-app py-4 space-y-1">
{navigation.map((item) => {
const isActive = !item.external && isNavItemActive(pathname, item.href, basePath)
const className = cn(
'flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors',
isActive
? 'bg-primary/10 text-primary'
: 'text-muted-foreground hover:bg-muted hover:text-foreground'
)
if (item.external) {
return (
<a key={item.name} href={item.href} target="_blank" rel="noopener noreferrer" onClick={() => setIsMobileMenuOpen(false)} className={className}>
<item.icon className="h-4 w-4" />
{item.name}
<ExternalLinkIcon className="h-3 w-3 opacity-50" />
</a>
)
}
return (
<Link key={item.name} href={item.href as Route} onClick={() => setIsMobileMenuOpen(false)} className={className}>
<item.icon className="h-4 w-4" />
{item.name}
</Link>
)
})}
{editionSelector && (
<div className="border-t pt-4 mt-4 px-3">
{editionSelector}
</div>
)}
{switchableRoles.length > 0 && (
<div className="border-t pt-4 mt-4 space-y-1">
<p className="flex items-center gap-1.5 px-3 text-[11px] font-medium uppercase tracking-wider text-muted-foreground/60">
<ArrowRightLeft className="h-3 w-3" />
Switch View
</p>
{switchableRoles.map(([, opt]) => (
<Link
key={opt.path}
href={opt.path as Route}
onClick={() => setIsMobileMenuOpen(false)}
className="flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium text-muted-foreground hover:bg-muted hover:text-foreground transition-colors"
>
<opt.icon className="h-4 w-4" />
{opt.label}
</Link>
))}
</div>
)}
<div className="border-t pt-4 mt-4">
<Button
variant="ghost"
className="w-full justify-start text-destructive hover:text-destructive"
onClick={() => signOut({ callbackUrl: '/login' })}
>
<LogOut className="mr-2 h-4 w-4" />
Sign Out
</Button>
</div>
</nav>
</div>
</div>
</div>
</header>
)
}