feat: external Learning Hub toggle + applicant help button
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m52s

- 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>
This commit is contained in:
2026-03-03 23:09:29 +01:00
parent 924f8071e1
commit 1d4e31ddd1
6 changed files with 160 additions and 43 deletions

View File

@@ -20,6 +20,7 @@ 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'
@@ -30,6 +31,7 @@ export type NavItem = {
name: string
href: string
icon: LucideIcon
external?: boolean
}
export type RoleNavUser = {
@@ -47,6 +49,8 @@ type RoleNavProps = {
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
@@ -62,7 +66,7 @@ function isNavItemActive(pathname: string, href: string, basePath: string): bool
return pathname === href || (href !== basePath && pathname.startsWith(href))
}
export function RoleNav({ navigation, roleName, user, basePath, statusBadge, editionSelector }: RoleNavProps) {
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()
@@ -94,18 +98,24 @@ export function RoleNav({ navigation, roleName, user, basePath, statusBadge, edi
{/* Desktop nav */}
<nav className="hidden md:flex items-center gap-1">
{navigation.map((item) => {
const isActive = isNavItemActive(pathname, item.href, basePath)
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={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'
)}
>
<Link key={item.name} href={item.href as Route} className={className}>
<item.icon className="h-4 w-4" />
{item.name}
</Link>
@@ -116,6 +126,28 @@ export function RoleNav({ navigation, roleName, user, basePath, statusBadge, edi
{/* 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"
@@ -208,19 +240,24 @@ export function RoleNav({ navigation, roleName, user, basePath, statusBadge, edi
<div className={cn('border-t', !isMobileMenuOpen && 'border-transparent')}>
<nav className="container-app py-4 space-y-1">
{navigation.map((item) => {
const isActive = isNavItemActive(pathname, item.href, basePath)
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={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'
)}
>
<Link key={item.name} href={item.href as Route} onClick={() => setIsMobileMenuOpen(false)} className={className}>
<item.icon className="h-4 w-4" />
{item.name}
</Link>