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

@@ -12,6 +12,9 @@ export function ApplicantNav({ user }: ApplicantNavProps) {
const { data: flags } = trpc.applicant.getNavFlags.useQuery(undefined, { const { data: flags } = trpc.applicant.getNavFlags.useQuery(undefined, {
staleTime: 60_000, staleTime: 60_000,
}) })
const { data: featureFlags } = trpc.settings.getFeatureFlags.useQuery(undefined, {
staleTime: 60_000,
})
const navigation: NavItem[] = [ const navigation: NavItem[] = [
{ name: 'Dashboard', href: '/applicant', icon: Home }, { name: 'Dashboard', href: '/applicant', icon: Home },
@@ -33,6 +36,7 @@ export function ApplicantNav({ user }: ApplicantNavProps) {
roleName="Applicant" roleName="Applicant"
user={user} user={user}
basePath="/applicant" basePath="/applicant"
helpEmail={featureFlags?.supportEmail || undefined}
/> />
) )
} }

View File

@@ -46,6 +46,9 @@ export function JuryNav({ user }: JuryNavProps) {
undefined, undefined,
{ refetchInterval: 60000 } { refetchInterval: 60000 }
) )
const { data: flags } = trpc.settings.getFeatureFlags.useQuery()
const useExternal = flags?.learningHubExternal && flags.learningHubExternalUrl
const navigation: NavItem[] = [ const navigation: NavItem[] = [
{ {
@@ -69,8 +72,9 @@ export function JuryNav({ user }: JuryNavProps) {
: []), : []),
{ {
name: 'Learning Hub', name: 'Learning Hub',
href: '/jury/learning', href: useExternal ? flags.learningHubExternalUrl : '/jury/learning',
icon: BookOpen, icon: BookOpen,
external: !!useExternal,
}, },
] ]

View File

@@ -2,12 +2,17 @@
import { BookOpen, Home, Users } from 'lucide-react' import { BookOpen, Home, Users } from 'lucide-react'
import { RoleNav, type NavItem, type RoleNavUser } from '@/components/layouts/role-nav' import { RoleNav, type NavItem, type RoleNavUser } from '@/components/layouts/role-nav'
import { trpc } from '@/lib/trpc/client'
interface MentorNavProps { interface MentorNavProps {
user: RoleNavUser user: RoleNavUser
} }
export function MentorNav({ user }: MentorNavProps) { export function MentorNav({ user }: MentorNavProps) {
const { data: flags } = trpc.settings.getFeatureFlags.useQuery()
const useExternal = flags?.learningHubExternal && flags.learningHubExternalUrl
const navigation: NavItem[] = [ const navigation: NavItem[] = [
{ {
name: 'Dashboard', name: 'Dashboard',
@@ -21,8 +26,9 @@ export function MentorNav({ user }: MentorNavProps) {
}, },
{ {
name: 'Learning Hub', name: 'Learning Hub',
href: '/mentor/resources', href: useExternal ? flags.learningHubExternalUrl : '/mentor/resources',
icon: BookOpen, icon: BookOpen,
external: !!useExternal,
}, },
] ]

View File

@@ -20,6 +20,7 @@ import type { LucideIcon } from 'lucide-react'
import { import {
LogOut, Menu, Moon, Settings, Sun, User, X, LogOut, Menu, Moon, Settings, Sun, User, X,
LayoutDashboard, Scale, Handshake, Eye, ArrowRightLeft, LayoutDashboard, Scale, Handshake, Eye, ArrowRightLeft,
ExternalLink as ExternalLinkIcon, HelpCircle, Mail,
} from 'lucide-react' } from 'lucide-react'
import type { UserRole } from '@prisma/client' import type { UserRole } from '@prisma/client'
import { useTheme } from 'next-themes' import { useTheme } from 'next-themes'
@@ -30,6 +31,7 @@ export type NavItem = {
name: string name: string
href: string href: string
icon: LucideIcon icon: LucideIcon
external?: boolean
} }
export type RoleNavUser = { export type RoleNavUser = {
@@ -47,6 +49,8 @@ type RoleNavProps = {
statusBadge?: React.ReactNode statusBadge?: React.ReactNode
/** Optional slot rendered in the mobile hamburger menu (between nav links and sign out) and desktop header */ /** Optional slot rendered in the mobile hamburger menu (between nav links and sign out) and desktop header */
editionSelector?: React.ReactNode 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 // 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)) 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 pathname = usePathname()
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false) const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false)
const { data: session, status: sessionStatus } = useSession() const { data: session, status: sessionStatus } = useSession()
@@ -94,18 +98,24 @@ export function RoleNav({ navigation, roleName, user, basePath, statusBadge, edi
{/* Desktop nav */} {/* Desktop nav */}
<nav className="hidden md:flex items-center gap-1"> <nav className="hidden md:flex items-center gap-1">
{navigation.map((item) => { {navigation.map((item) => {
const isActive = isNavItemActive(pathname, item.href, basePath) const isActive = !item.external && isNavItemActive(pathname, item.href, basePath)
return ( const className = cn(
<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', 'flex items-center gap-2 rounded-md px-3 py-2 text-sm font-medium transition-colors',
isActive isActive
? 'bg-primary/10 text-primary' ? 'bg-primary/10 text-primary'
: 'text-muted-foreground hover:bg-muted hover:text-foreground' : '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.icon className="h-4 w-4" />
{item.name} {item.name}
</Link> </Link>
@@ -116,6 +126,28 @@ export function RoleNav({ navigation, roleName, user, basePath, statusBadge, edi
{/* User menu & mobile toggle */} {/* User menu & mobile toggle */}
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{editionSelector && <div className="hidden md:block">{editionSelector}</div>} {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 && ( {mounted && (
<Button <Button
variant="ghost" variant="ghost"
@@ -208,19 +240,24 @@ export function RoleNav({ navigation, roleName, user, basePath, statusBadge, edi
<div className={cn('border-t', !isMobileMenuOpen && 'border-transparent')}> <div className={cn('border-t', !isMobileMenuOpen && 'border-transparent')}>
<nav className="container-app py-4 space-y-1"> <nav className="container-app py-4 space-y-1">
{navigation.map((item) => { {navigation.map((item) => {
const isActive = isNavItemActive(pathname, item.href, basePath) const isActive = !item.external && isNavItemActive(pathname, item.href, basePath)
return ( const className = cn(
<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', 'flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors',
isActive isActive
? 'bg-primary/10 text-primary' ? 'bg-primary/10 text-primary'
: 'text-muted-foreground hover:bg-muted hover:text-foreground' : '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.icon className="h-4 w-4" />
{item.name} {item.name}
</Link> </Link>

View File

@@ -123,6 +123,9 @@ export function SettingsContent({ initialSettings, isSuperAdmin = true }: Settin
'default_page_size', 'default_page_size',
'autosave_interval_seconds', 'autosave_interval_seconds',
'display_project_names_uppercase', 'display_project_names_uppercase',
'learning_hub_external',
'learning_hub_external_url',
'support_email',
]) ])
const digestSettings = getSettingsByKeys([ const digestSettings = getSettingsByKeys([
@@ -432,7 +435,7 @@ export function SettingsContent({ initialSettings, isSuperAdmin = true }: Settin
</TabsContent> </TabsContent>
)} )}
<TabsContent value="defaults"> <TabsContent value="defaults" className="space-y-6">
<AnimatedCard> <AnimatedCard>
<Card> <Card>
<CardHeader> <CardHeader>
@@ -446,6 +449,20 @@ export function SettingsContent({ initialSettings, isSuperAdmin = true }: Settin
</CardContent> </CardContent>
</Card> </Card>
</AnimatedCard> </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>
<TabsContent value="digest" className="space-y-6"> <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> }) { function WhatsAppSettingsSection({ settings }: { settings: Record<string, string> }) {
return ( return (
<div className="space-y-4"> <div className="space-y-4">

View File

@@ -25,24 +25,47 @@ function categorizeModel(modelId: string): string {
return 'other' return 'other'
} }
function inferSettingCategory(key: string): 'AI' | 'BRANDING' | 'EMAIL' | 'STORAGE' | 'SECURITY' | 'DEFAULTS' | 'WHATSAPP' | 'FEATURE_FLAGS' {
if (key.startsWith('openai') || key.startsWith('ai_') || key.startsWith('anthropic')) return 'AI'
if (key.startsWith('smtp_') || key.startsWith('email_')) return 'EMAIL'
if (key.startsWith('storage_') || key.startsWith('local_storage') || key.startsWith('max_file') || key.startsWith('avatar_') || key.startsWith('allowed_file')) return 'STORAGE'
if (key.startsWith('brand_') || key.startsWith('logo_') || key.startsWith('primary_') || key.startsWith('theme_')) return 'BRANDING'
if (key.startsWith('whatsapp_')) return 'WHATSAPP'
if (key.startsWith('security_') || key.startsWith('session_')) return 'SECURITY'
if (key.startsWith('learning_hub_') || key.startsWith('jury_compare_') || key.startsWith('support_')) return 'FEATURE_FLAGS'
return 'DEFAULTS'
}
export const settingsRouter = router({ export const settingsRouter = router({
/** /**
* Get public feature flags (no auth required) * Get public feature flags (no auth required)
* These are non-sensitive settings that can be exposed to any user * These are non-sensitive settings that can be exposed to any user
*/ */
getFeatureFlags: protectedProcedure.query(async ({ ctx }) => { getFeatureFlags: protectedProcedure.query(async ({ ctx }) => {
const [whatsappEnabled, juryCompareEnabled] = await Promise.all([ const [whatsappEnabled, juryCompareEnabled, learningHubExternal, learningHubExternalUrl, supportEmail] = await Promise.all([
ctx.prisma.systemSettings.findUnique({ ctx.prisma.systemSettings.findUnique({
where: { key: 'whatsapp_enabled' }, where: { key: 'whatsapp_enabled' },
}), }),
ctx.prisma.systemSettings.findUnique({ ctx.prisma.systemSettings.findUnique({
where: { key: 'jury_compare_enabled' }, where: { key: 'jury_compare_enabled' },
}), }),
ctx.prisma.systemSettings.findUnique({
where: { key: 'learning_hub_external' },
}),
ctx.prisma.systemSettings.findUnique({
where: { key: 'learning_hub_external_url' },
}),
ctx.prisma.systemSettings.findUnique({
where: { key: 'support_email' },
}),
]) ])
return { return {
whatsappEnabled: whatsappEnabled?.value === 'true', whatsappEnabled: whatsappEnabled?.value === 'true',
juryCompareEnabled: juryCompareEnabled?.value === 'true', juryCompareEnabled: juryCompareEnabled?.value === 'true',
learningHubExternal: learningHubExternal?.value === 'true',
learningHubExternalUrl: learningHubExternalUrl?.value || '',
supportEmail: supportEmail?.value || '',
} }
}), }),
@@ -120,12 +143,18 @@ export const settingsRouter = router({
}) })
) )
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
const setting = await ctx.prisma.systemSettings.update({ const setting = await ctx.prisma.systemSettings.upsert({
where: { key: input.key }, where: { key: input.key },
data: { update: {
value: input.value, value: input.value,
updatedBy: ctx.user.id, updatedBy: ctx.user.id,
}, },
create: {
key: input.key,
value: input.value,
category: inferSettingCategory(input.key),
updatedBy: ctx.user.id,
},
}) })
// Clear storage provider cache when storage_provider setting changes // Clear storage provider cache when storage_provider setting changes
@@ -161,23 +190,12 @@ export const settingsRouter = router({
z.object({ z.object({
key: z.string(), key: z.string(),
value: z.string(), value: z.string(),
category: z.enum(['AI', 'BRANDING', 'EMAIL', 'STORAGE', 'SECURITY', 'DEFAULTS', 'WHATSAPP']).optional(), category: z.enum(['AI', 'BRANDING', 'EMAIL', 'STORAGE', 'SECURITY', 'DEFAULTS', 'WHATSAPP', 'FEATURE_FLAGS']).optional(),
}) })
), ),
}) })
) )
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
// Infer category from key prefix if not provided
const inferCategory = (key: string): 'AI' | 'BRANDING' | 'EMAIL' | 'STORAGE' | 'SECURITY' | 'DEFAULTS' | 'WHATSAPP' => {
if (key.startsWith('openai') || key.startsWith('ai_') || key.startsWith('anthropic')) return 'AI'
if (key.startsWith('smtp_') || key.startsWith('email_')) return 'EMAIL'
if (key.startsWith('storage_') || key.startsWith('local_storage') || key.startsWith('max_file') || key.startsWith('avatar_') || key.startsWith('allowed_file')) return 'STORAGE'
if (key.startsWith('brand_') || key.startsWith('logo_') || key.startsWith('primary_') || key.startsWith('theme_')) return 'BRANDING'
if (key.startsWith('whatsapp_')) return 'WHATSAPP'
if (key.startsWith('security_') || key.startsWith('session_')) return 'SECURITY'
return 'DEFAULTS'
}
const results = await Promise.all( const results = await Promise.all(
input.settings.map((s) => input.settings.map((s) =>
ctx.prisma.systemSettings.upsert({ ctx.prisma.systemSettings.upsert({
@@ -189,7 +207,7 @@ export const settingsRouter = router({
create: { create: {
key: s.key, key: s.key,
value: s.value, value: s.value,
category: s.category || inferCategory(s.key), category: s.category || inferSettingCategory(s.key),
updatedBy: ctx.user.id, updatedBy: ctx.user.id,
}, },
}) })