feat: external Learning Hub toggle + applicant help button
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m52s
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:
@@ -12,6 +12,9 @@ export function ApplicantNav({ user }: ApplicantNavProps) {
|
||||
const { data: flags } = trpc.applicant.getNavFlags.useQuery(undefined, {
|
||||
staleTime: 60_000,
|
||||
})
|
||||
const { data: featureFlags } = trpc.settings.getFeatureFlags.useQuery(undefined, {
|
||||
staleTime: 60_000,
|
||||
})
|
||||
|
||||
const navigation: NavItem[] = [
|
||||
{ name: 'Dashboard', href: '/applicant', icon: Home },
|
||||
@@ -33,6 +36,7 @@ export function ApplicantNav({ user }: ApplicantNavProps) {
|
||||
roleName="Applicant"
|
||||
user={user}
|
||||
basePath="/applicant"
|
||||
helpEmail={featureFlags?.supportEmail || undefined}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -46,6 +46,9 @@ export function JuryNav({ user }: JuryNavProps) {
|
||||
undefined,
|
||||
{ refetchInterval: 60000 }
|
||||
)
|
||||
const { data: flags } = trpc.settings.getFeatureFlags.useQuery()
|
||||
|
||||
const useExternal = flags?.learningHubExternal && flags.learningHubExternalUrl
|
||||
|
||||
const navigation: NavItem[] = [
|
||||
{
|
||||
@@ -69,8 +72,9 @@ export function JuryNav({ user }: JuryNavProps) {
|
||||
: []),
|
||||
{
|
||||
name: 'Learning Hub',
|
||||
href: '/jury/learning',
|
||||
href: useExternal ? flags.learningHubExternalUrl : '/jury/learning',
|
||||
icon: BookOpen,
|
||||
external: !!useExternal,
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@@ -2,12 +2,17 @@
|
||||
|
||||
import { BookOpen, Home, Users } from 'lucide-react'
|
||||
import { RoleNav, type NavItem, type RoleNavUser } from '@/components/layouts/role-nav'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
|
||||
interface MentorNavProps {
|
||||
user: RoleNavUser
|
||||
}
|
||||
|
||||
export function MentorNav({ user }: MentorNavProps) {
|
||||
const { data: flags } = trpc.settings.getFeatureFlags.useQuery()
|
||||
|
||||
const useExternal = flags?.learningHubExternal && flags.learningHubExternalUrl
|
||||
|
||||
const navigation: NavItem[] = [
|
||||
{
|
||||
name: 'Dashboard',
|
||||
@@ -21,8 +26,9 @@ export function MentorNav({ user }: MentorNavProps) {
|
||||
},
|
||||
{
|
||||
name: 'Learning Hub',
|
||||
href: '/mentor/resources',
|
||||
href: useExternal ? flags.learningHubExternalUrl : '/mentor/resources',
|
||||
icon: BookOpen,
|
||||
external: !!useExternal,
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -123,6 +123,9 @@ export function SettingsContent({ initialSettings, isSuperAdmin = true }: Settin
|
||||
'default_page_size',
|
||||
'autosave_interval_seconds',
|
||||
'display_project_names_uppercase',
|
||||
'learning_hub_external',
|
||||
'learning_hub_external_url',
|
||||
'support_email',
|
||||
])
|
||||
|
||||
const digestSettings = getSettingsByKeys([
|
||||
@@ -432,7 +435,7 @@ export function SettingsContent({ initialSettings, isSuperAdmin = true }: Settin
|
||||
</TabsContent>
|
||||
)}
|
||||
|
||||
<TabsContent value="defaults">
|
||||
<TabsContent value="defaults" className="space-y-6">
|
||||
<AnimatedCard>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
@@ -446,6 +449,20 @@ export function SettingsContent({ initialSettings, isSuperAdmin = true }: Settin
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AnimatedCard>
|
||||
|
||||
<AnimatedCard>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Platform Features</CardTitle>
|
||||
<CardDescription>
|
||||
Configure Learning Hub, support contact, and other platform features
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<PlatformFeaturesSection settings={defaultsSettings} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AnimatedCard>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="digest" className="space-y-6">
|
||||
@@ -805,6 +822,37 @@ function AuditSettingsSection({ settings }: { settings: Record<string, string> }
|
||||
)
|
||||
}
|
||||
|
||||
function PlatformFeaturesSection({ settings }: { settings: Record<string, string> }) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-4">
|
||||
<Label className="text-sm font-medium">Learning Hub</Label>
|
||||
<SettingToggle
|
||||
label="Use External Learning Hub"
|
||||
description="When enabled, jury and mentor navigation links will open the external URL instead of the built-in Learning Hub"
|
||||
settingKey="learning_hub_external"
|
||||
value={settings.learning_hub_external || 'false'}
|
||||
/>
|
||||
<SettingInput
|
||||
label="External URL"
|
||||
description="The URL to redirect jury and mentor users to (e.g. Google Drive, Notion, etc.)"
|
||||
settingKey="learning_hub_external_url"
|
||||
value={settings.learning_hub_external_url || ''}
|
||||
/>
|
||||
</div>
|
||||
<div className="border-t pt-4 space-y-4">
|
||||
<Label className="text-sm font-medium">Support</Label>
|
||||
<SettingInput
|
||||
label="Support Email"
|
||||
description="Shown as a help button on the applicant page header. Leave empty to hide."
|
||||
settingKey="support_email"
|
||||
value={settings.support_email || ''}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function WhatsAppSettingsSection({ settings }: { settings: Record<string, string> }) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
|
||||
Reference in New Issue
Block a user