diff --git a/src/app/(admin)/admin/dashboard-content.tsx b/src/app/(admin)/admin/dashboard-content.tsx
index 62fe8df..b934224 100644
--- a/src/app/(admin)/admin/dashboard-content.tsx
+++ b/src/app/(admin)/admin/dashboard-content.tsx
@@ -116,20 +116,21 @@ function getContextualActions(
export function DashboardContent({ editionId, sessionName }: DashboardContentProps) {
const { data, isLoading, error } = trpc.dashboard.getStats.useQuery(
{ editionId },
- { enabled: !!editionId, retry: 1, refetchInterval: 30_000 }
+ { enabled: !!editionId, refetchInterval: 60_000 }
)
const { data: recentEvals } = trpc.dashboard.getRecentEvaluations.useQuery(
{ editionId, limit: 8 },
- { enabled: !!editionId, refetchInterval: 30_000 }
+ { enabled: !!editionId, refetchInterval: 60_000 }
)
const { data: liveActivity } = trpc.dashboard.getRecentActivity.useQuery(
{ limit: 8 },
- { enabled: !!editionId, refetchInterval: 5_000 }
+ { enabled: !!editionId, refetchInterval: 30_000 }
)
const { data: semiFinalistStats } = trpc.dashboard.getSemiFinalistStats.useQuery(
{ editionId },
- { enabled: !!editionId, refetchInterval: 60_000 }
+ { enabled: !!editionId, refetchInterval: 120_000 }
)
+ const { data: featureFlags } = trpc.settings.getFeatureFlags.useQuery()
if (isLoading) {
return
@@ -283,6 +284,7 @@ export function DashboardContent({ editionId, sessionName }: DashboardContentPro
byAward={semiFinalistStats.byAward}
unactivatedProjects={semiFinalistStats.unactivatedProjects}
editionId={editionId}
+ reminderThresholdDays={featureFlags?.accountReminderDays}
/>
)}
diff --git a/src/app/(admin)/admin/semi-finalists/page.tsx b/src/app/(admin)/admin/semi-finalists/page.tsx
new file mode 100644
index 0000000..ae842e4
--- /dev/null
+++ b/src/app/(admin)/admin/semi-finalists/page.tsx
@@ -0,0 +1,42 @@
+import type { Metadata } from 'next'
+import { prisma } from '@/lib/prisma'
+import { SemiFinalistsContent } from '@/components/admin/semi-finalists-content'
+
+export const metadata: Metadata = { title: 'Semi-Finalists' }
+export const dynamic = 'force-dynamic'
+
+type PageProps = {
+ searchParams: Promise<{ editionId?: string }>
+}
+
+export default async function SemiFinalistsPage({ searchParams }: PageProps) {
+ const params = await searchParams
+ let editionId = params.editionId || null
+
+ if (!editionId) {
+ const defaultEdition = await prisma.program.findFirst({
+ where: { status: 'ACTIVE' },
+ orderBy: { year: 'desc' },
+ select: { id: true },
+ })
+ editionId = defaultEdition?.id || null
+
+ if (!editionId) {
+ const anyEdition = await prisma.program.findFirst({
+ orderBy: { year: 'desc' },
+ select: { id: true },
+ })
+ editionId = anyEdition?.id || null
+ }
+ }
+
+ if (!editionId) {
+ return (
+
+ No edition found.
+
+ )
+ }
+
+ return
+}
diff --git a/src/app/(applicant)/layout.tsx b/src/app/(applicant)/layout.tsx
index 875947c..a9085d3 100644
--- a/src/app/(applicant)/layout.tsx
+++ b/src/app/(applicant)/layout.tsx
@@ -11,19 +11,22 @@ export default async function ApplicantLayout({
children: React.ReactNode
}) {
const session = await requireRole('APPLICANT')
+ const isImpersonating = !!session.user.impersonating
- // Check if user has completed onboarding
- const user = await prisma.user.findUnique({
- where: { id: session.user.id },
- select: { onboardingCompletedAt: true },
- })
+ // Check if user has completed onboarding (skip during impersonation)
+ if (!isImpersonating) {
+ const user = await prisma.user.findUnique({
+ where: { id: session.user.id },
+ select: { onboardingCompletedAt: true },
+ })
- if (!user) {
- redirect('/login')
- }
+ if (!user) {
+ redirect('/login')
+ }
- if (!user.onboardingCompletedAt) {
- redirect('/onboarding')
+ if (!user.onboardingCompletedAt) {
+ redirect('/onboarding')
+ }
}
return (
diff --git a/src/app/(jury)/layout.tsx b/src/app/(jury)/layout.tsx
index 824098e..68bda79 100644
--- a/src/app/(jury)/layout.tsx
+++ b/src/app/(jury)/layout.tsx
@@ -11,20 +11,22 @@ export default async function JuryLayout({
children: React.ReactNode
}) {
const session = await requireRole('JURY_MEMBER')
+ const isImpersonating = !!session.user.impersonating
- // Check if user has completed onboarding
- const user = await prisma.user.findUnique({
- where: { id: session.user.id },
- select: { onboardingCompletedAt: true },
- })
+ // Check if user has completed onboarding (skip during impersonation)
+ if (!isImpersonating) {
+ const user = await prisma.user.findUnique({
+ where: { id: session.user.id },
+ select: { onboardingCompletedAt: true },
+ })
- if (!user) {
- // User was deleted — session is stale, send to login
- redirect('/login')
- }
+ if (!user) {
+ redirect('/login')
+ }
- if (!user.onboardingCompletedAt) {
- redirect('/onboarding')
+ if (!user.onboardingCompletedAt) {
+ redirect('/onboarding')
+ }
}
return (
diff --git a/src/app/(mentor)/layout.tsx b/src/app/(mentor)/layout.tsx
index 97d3c6b..279ca5e 100644
--- a/src/app/(mentor)/layout.tsx
+++ b/src/app/(mentor)/layout.tsx
@@ -12,16 +12,16 @@ export default async function MentorLayout({
}) {
const session = await requireRole('MENTOR', 'PROGRAM_ADMIN', 'SUPER_ADMIN')
- // Check if user has completed onboarding (for mentors)
+ // Check if user has completed onboarding (for mentors, skip during impersonation)
+ const isImpersonating = !!session.user.impersonating
const userRoles = session.user.roles?.length ? session.user.roles : [session.user.role]
- if (userRoles.includes('MENTOR') && !userRoles.some(r => r === 'SUPER_ADMIN' || r === 'PROGRAM_ADMIN')) {
+ if (!isImpersonating && userRoles.includes('MENTOR') && !userRoles.some(r => r === 'SUPER_ADMIN' || r === 'PROGRAM_ADMIN')) {
const user = await prisma.user.findUnique({
where: { id: session.user.id },
select: { onboardingCompletedAt: true },
})
if (!user) {
- // User was deleted — session is stale, send to login
redirect('/login')
}
diff --git a/src/app/(observer)/layout.tsx b/src/app/(observer)/layout.tsx
index 183b07c..034a73a 100644
--- a/src/app/(observer)/layout.tsx
+++ b/src/app/(observer)/layout.tsx
@@ -12,19 +12,22 @@ export default async function ObserverLayout({
children: React.ReactNode
}) {
const session = await requireRole('OBSERVER')
+ const isImpersonating = !!session.user.impersonating
- // Check if user has completed onboarding
- const user = await prisma.user.findUnique({
- where: { id: session.user.id },
- select: { onboardingCompletedAt: true },
- })
+ // Check if user has completed onboarding (skip during impersonation)
+ if (!isImpersonating) {
+ const user = await prisma.user.findUnique({
+ where: { id: session.user.id },
+ select: { onboardingCompletedAt: true },
+ })
- if (!user) {
- redirect('/login')
- }
+ if (!user) {
+ redirect('/login')
+ }
- if (!user.onboardingCompletedAt) {
- redirect('/onboarding')
+ if (!user.onboardingCompletedAt) {
+ redirect('/onboarding')
+ }
}
return (
diff --git a/src/app/layout.tsx b/src/app/layout.tsx
index 23b1a95..5bdfdad 100644
--- a/src/app/layout.tsx
+++ b/src/app/layout.tsx
@@ -2,6 +2,7 @@ import type { Metadata } from 'next'
import './globals.css'
import { Providers } from './providers'
import { Toaster } from 'sonner'
+import { ImpersonationBanner } from '@/components/shared/impersonation-banner'
export const metadata: Metadata = {
title: {
@@ -22,7 +23,10 @@ export default function RootLayout({
return (
- {children}
+
+
+ {children}
+
{
+ // Retry up to 3 times on server errors (503 cold-start, etc.)
+ if (failureCount >= 3) return false
+ const msg = (error as Error)?.message ?? ''
+ // Retry on JSON parse errors (HTML 503 from nginx) and server errors
+ if (msg.includes('is not valid JSON') || msg.includes('Unexpected token')) return true
+ if (msg.includes('500') || msg.includes('502') || msg.includes('503')) return true
+ return failureCount < 2
+ },
+ retryDelay: (attemptIndex) => Math.min(2000 * (attemptIndex + 1), 8000),
},
},
})
@@ -47,6 +57,21 @@ export function Providers({ children }: { children: React.ReactNode }) {
httpBatchLink({
url: `${getBaseUrl()}/api/trpc`,
transformer: superjson,
+ async fetch(url, options) {
+ const res = await globalThis.fetch(url, options)
+ // Detect nginx 503 / HTML error pages before tRPC tries to JSON.parse
+ if (!res.ok) {
+ const ct = res.headers.get('content-type') ?? ''
+ if (ct.includes('text/html') || !ct.includes('json')) {
+ throw new Error(
+ res.status >= 500
+ ? 'Server is starting up — please wait a moment and try again.'
+ : `Server error (${res.status})`
+ )
+ }
+ }
+ return res
+ },
}),
],
})
diff --git a/src/components/admin/semi-finalists-content.tsx b/src/components/admin/semi-finalists-content.tsx
new file mode 100644
index 0000000..0f38130
--- /dev/null
+++ b/src/components/admin/semi-finalists-content.tsx
@@ -0,0 +1,265 @@
+'use client'
+
+import { useState, useMemo } from 'react'
+import Link from 'next/link'
+import type { Route } from 'next'
+import { trpc } from '@/lib/trpc/client'
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
+import { Input } from '@/components/ui/input'
+import { Badge } from '@/components/ui/badge'
+import { Button } from '@/components/ui/button'
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from '@/components/ui/select'
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from '@/components/ui/table'
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipProvider,
+ TooltipTrigger,
+} from '@/components/ui/tooltip'
+import {
+ Users,
+ Search,
+ CheckCircle2,
+ AlertCircle,
+ Clock,
+ ArrowLeft,
+ Loader2,
+} from 'lucide-react'
+
+const categoryLabels: Record = {
+ STARTUP: 'Startup',
+ BUSINESS_CONCEPT: 'Business Concept',
+}
+
+const statusConfig = {
+ active: { label: 'Active', color: 'bg-emerald-500', icon: CheckCircle2 },
+ invited: { label: 'Invited', color: 'bg-amber-500', icon: Clock },
+ none: { label: 'No Account', color: 'bg-red-500', icon: AlertCircle },
+} as const
+
+type SemiFinalistsContentProps = {
+ editionId: string
+}
+
+export function SemiFinalistsContent({ editionId }: SemiFinalistsContentProps) {
+ const { data, isLoading } = trpc.dashboard.getSemiFinalistDetail.useQuery(
+ { editionId },
+ { enabled: !!editionId }
+ )
+
+ const [search, setSearch] = useState('')
+ const [categoryFilter, setCategoryFilter] = useState('all')
+ const [statusFilter, setStatusFilter] = useState('all')
+
+ const filtered = useMemo(() => {
+ if (!data) return []
+ let items = data
+
+ if (categoryFilter !== 'all') {
+ items = items.filter(p => p.category === categoryFilter)
+ }
+
+ if (statusFilter === 'activated') {
+ items = items.filter(p => p.allActivated)
+ } else if (statusFilter === 'pending') {
+ items = items.filter(p => !p.allActivated)
+ }
+
+ if (search.trim()) {
+ const q = search.toLowerCase()
+ items = items.filter(p =>
+ p.title.toLowerCase().includes(q) ||
+ p.teamName?.toLowerCase().includes(q) ||
+ p.country?.toLowerCase().includes(q) ||
+ p.teamMembers.some(tm =>
+ tm.name?.toLowerCase().includes(q) ||
+ tm.email.toLowerCase().includes(q)
+ )
+ )
+ }
+
+ return items
+ }, [data, search, categoryFilter, statusFilter])
+
+ const stats = useMemo(() => {
+ if (!data) return { total: 0, activated: 0, pending: 0 }
+ return {
+ total: data.length,
+ activated: data.filter(p => p.allActivated).length,
+ pending: data.filter(p => !p.allActivated).length,
+ }
+ }, [data])
+
+ if (isLoading) {
+ return (
+
+
+
+ )
+ }
+
+ return (
+
+ {/* Header */}
+
+
+
+
+
+
+
+ Semi-Finalists
+
+
+ {stats.total} projects · {stats.activated} fully activated · {stats.pending} pending
+
+
+
+
+
+ {/* Filters */}
+
+
+
+
+
+ setSearch(e.target.value)}
+ className="pl-9"
+ />
+
+
+
+
+
+
+
+ {/* Table */}
+
+
+
+
+ {filtered.length} project{filtered.length !== 1 ? 's' : ''}
+
+
+
+ {filtered.length === 0 ? (
+
+ No semi-finalist projects match your filters.
+
+ ) : (
+
+
+
+
+ Project
+ Category
+ Country
+ Current Round
+ Team Members
+ Status
+
+
+
+ {filtered.map((project) => (
+
+
+
+ {project.title}
+
+ {project.teamName && (
+ {project.teamName}
+ )}
+
+
+
+ {categoryLabels[project.category ?? ''] ?? project.category}
+
+
+ {project.country || '—'}
+ {project.currentRound}
+
+
+
+ {project.teamMembers.map((tm, idx) => {
+ const cfg = statusConfig[tm.accountStatus]
+ const Icon = cfg.icon
+ return (
+
+
+
+
+
+ {tm.name || tm.email}
+
+
+
+
+ {tm.email}
+
+ {cfg.label}
+ {tm.lastLogin && ` · Last login: ${new Date(tm.lastLogin).toLocaleDateString()}`}
+
+
+
+ )
+ })}
+
+
+
+
+ {project.allActivated ? (
+
+ ) : (
+
+ )}
+
+
+ ))}
+
+
+
+ )}
+
+
+
+ )
+}
diff --git a/src/components/admin/user-actions.tsx b/src/components/admin/user-actions.tsx
index 55df33c..33bfc53 100644
--- a/src/components/admin/user-actions.tsx
+++ b/src/components/admin/user-actions.tsx
@@ -2,6 +2,9 @@
import { useState } from 'react'
import Link from 'next/link'
+import type { Route } from 'next'
+import { useSession } from 'next-auth/react'
+import { useRouter } from 'next/navigation'
import { trpc } from '@/lib/trpc/client'
import { Button } from '@/components/ui/button'
import {
@@ -33,6 +36,7 @@ import {
Trash2,
Loader2,
Shield,
+ LogIn,
} from 'lucide-react'
type Role = 'SUPER_ADMIN' | 'PROGRAM_ADMIN' | 'JURY_MEMBER' | 'MENTOR' | 'OBSERVER'
@@ -54,9 +58,21 @@ interface UserActionsProps {
currentUserRole?: Role
}
+function getRoleHomePath(role: string): string {
+ switch (role) {
+ case 'JURY_MEMBER': return '/jury'
+ case 'APPLICANT': return '/applicant'
+ case 'MENTOR': return '/mentor'
+ case 'OBSERVER': return '/observer'
+ default: return '/admin'
+ }
+}
+
export function UserActions({ userId, userEmail, userStatus, userRole, userRoles, currentUserRole }: UserActionsProps) {
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
const [isSending, setIsSending] = useState(false)
+ const { data: session, update } = useSession()
+ const router = useRouter()
const utils = trpc.useUtils()
const sendInvitation = trpc.user.sendInvitation.useMutation()
@@ -65,6 +81,7 @@ export function UserActions({ userId, userEmail, userStatus, userRole, userRoles
utils.user.list.invalidate()
},
})
+ const startImpersonation = trpc.user.startImpersonation.useMutation()
const updateRoles = trpc.user.updateRoles.useMutation({
onSuccess: () => {
utils.user.list.invalidate()
@@ -105,6 +122,18 @@ export function UserActions({ userId, userEmail, userStatus, userRole, userRoles
updateRoles.mutate({ userId, roles: newRoles })
}
+ const handleImpersonate = async () => {
+ try {
+ const result = await startImpersonation.mutateAsync({ targetUserId: userId })
+ await update({ impersonate: userId })
+ toast.success(`Now impersonating ${userEmail}`)
+ router.push(getRoleHomePath(result.targetRole) as Route)
+ router.refresh()
+ } catch (error) {
+ toast.error(error instanceof Error ? error.message : 'Failed to start impersonation')
+ }
+ }
+
const handleSendInvitation = async () => {
if (userStatus !== 'NONE' && userStatus !== 'INVITED') {
toast.error('User has already accepted their invitation')
@@ -154,6 +183,19 @@ export function UserActions({ userId, userEmail, userStatus, userRole, userRoles
Edit
+ {isSuperAdmin && session?.user?.id !== userId && (
+
+ {startImpersonation.isPending ? (
+
+ ) : (
+
+ )}
+ Login As
+
+ )}
{canChangeRole && (
@@ -237,8 +279,11 @@ export function UserMobileActions({
currentUserRole,
}: UserMobileActionsProps) {
const [isSending, setIsSending] = useState(false)
+ const { data: session, update } = useSession()
+ const router = useRouter()
const utils = trpc.useUtils()
const sendInvitation = trpc.user.sendInvitation.useMutation()
+ const startImpersonation = trpc.user.startImpersonation.useMutation()
const updateRoles = trpc.user.updateRoles.useMutation({
onSuccess: () => {
utils.user.list.invalidate()
@@ -253,6 +298,18 @@ export function UserMobileActions({
const canChangeRole = isSuperAdmin || (!['SUPER_ADMIN', 'PROGRAM_ADMIN'].includes(userRole))
const currentRoles: Role[] = userRoles?.length ? userRoles : [userRole]
+ const handleImpersonateMobile = async () => {
+ try {
+ const result = await startImpersonation.mutateAsync({ targetUserId: userId })
+ await update({ impersonate: userId })
+ toast.success(`Now impersonating ${userEmail}`)
+ router.push(getRoleHomePath(result.targetRole) as Route)
+ router.refresh()
+ } catch (error) {
+ toast.error(error instanceof Error ? error.message : 'Failed to start impersonation')
+ }
+ }
+
const handleSendInvitation = async () => {
if (userStatus !== 'NONE' && userStatus !== 'INVITED') {
toast.error('User has already accepted their invitation')
@@ -280,6 +337,22 @@ export function UserMobileActions({
Edit
+ {isSuperAdmin && session?.user?.id !== userId && (
+
+ )}