diff --git a/src/app/(applicant)/applicant/page.tsx b/src/app/(applicant)/applicant/page.tsx index 76f04ad..0ef5337 100644 --- a/src/app/(applicant)/applicant/page.tsx +++ b/src/app/(applicant)/applicant/page.tsx @@ -1,6 +1,6 @@ 'use client' -import { useState } from 'react' +import { useState, useEffect } from 'react' import { useSession } from 'next-auth/react' import Link from 'next/link' import type { Route } from 'next' @@ -38,9 +38,22 @@ import { UserCircle, Trophy, Vote, + Clock, } from 'lucide-react' import { toast } from 'sonner' +function formatCountdown(ms: number): string { + if (ms <= 0) return 'Closed' + const days = Math.floor(ms / (1000 * 60 * 60 * 24)) + const hours = Math.floor((ms % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60)) + const minutes = Math.floor((ms % (1000 * 60 * 60)) / (1000 * 60)) + const parts: string[] = [] + if (days > 0) parts.push(`${days}d`) + if (hours > 0) parts.push(`${hours}h`) + parts.push(`${minutes}m`) + return parts.join(' ') +} + const statusColors: Record = { DRAFT: 'secondary', SUBMITTED: 'default', @@ -80,6 +93,13 @@ export default function ApplicantDashboardPage() { enabled: isAuthenticated, }) + // Live countdown timer for open rounds + const [now, setNow] = useState(() => Date.now()) + useEffect(() => { + const interval = setInterval(() => setNow(Date.now()), 60_000) + return () => clearInterval(interval) + }, []) + if (sessionStatus === 'loading' || (isAuthenticated && isLoading)) { return (
@@ -181,6 +201,48 @@ export default function ApplicantDashboardPage() {
+ {/* Active round deadline banner */} + {!isRejected && openRounds.length > 0 && (() => { + const submissionTypes = new Set(['INTAKE', 'SUBMISSION', 'MENTORING']) + const roundsWithDeadline = openRounds.filter((r) => r.windowCloseAt && submissionTypes.has(r.roundType)) + if (roundsWithDeadline.length === 0) return null + return roundsWithDeadline.map((round) => { + const closeAt = new Date(round.windowCloseAt!).getTime() + const remaining = closeAt - now + const isUrgent = remaining > 0 && remaining < 1000 * 60 * 60 * 24 * 3 // < 3 days + return ( +
+
+ + {round.name} + + {remaining > 0 ? formatCountdown(remaining) + ' left' : 'Closed'} + +
+ + Closes {new Date(round.windowCloseAt!).toLocaleDateString(undefined, { + weekday: 'short', + month: 'short', + day: 'numeric', + year: 'numeric', + })}{' '} + at {new Date(round.windowCloseAt!).toLocaleTimeString(undefined, { + hour: '2-digit', + minute: '2-digit', + })} + +
+ ) + }) + })()} +
{/* Main content */}
@@ -280,53 +342,6 @@ export default function ApplicantDashboardPage() { )} - {/* Quick actions */} - {!isRejected && ( - -
- -
- -
-
-

Documents

-

- {openRounds.length > 0 ? `${openRounds.length} round(s) open` : 'View uploads'} -

-
- - - - -
- -
-
-

Team

-

- {project.teamMembers.length} member(s) -

-
- - - - {project.mentorAssignment && ( - -
- -
-
-

Mentor

-

- {project.mentorAssignment.mentor?.name || 'Assigned'} -

-
- - - )} -
-
- )} {/* Document Completeness */} {docCompleteness && docCompleteness.length > 0 && ( diff --git a/src/components/admin/members-content.tsx b/src/components/admin/members-content.tsx index 5b77cf5..c3ee801 100644 --- a/src/components/admin/members-content.tsx +++ b/src/components/admin/members-content.tsx @@ -5,6 +5,7 @@ import Link from 'next/link' import { useSearchParams, usePathname } from 'next/navigation' import { trpc } from '@/lib/trpc/client' import { Badge } from '@/components/ui/badge' +import { CountryDisplay } from '@/components/shared/country-display' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Checkbox } from '@/components/ui/checkbox' @@ -805,7 +806,7 @@ function ApplicantsTabContent({ search, searchInput, setSearchInput }: { search: )} - {user.nationality || -} + {user.nationality ? : -} {user.institution || -} diff --git a/src/components/admin/round/award-shortlist.tsx b/src/components/admin/round/award-shortlist.tsx index d39c16a..4f5f98e 100644 --- a/src/components/admin/round/award-shortlist.tsx +++ b/src/components/admin/round/award-shortlist.tsx @@ -35,6 +35,7 @@ import { AlertTriangle, Search, } from 'lucide-react' +import { CountryDisplay } from '@/components/shared/country-display' type AwardShortlistProps = { awardId: string @@ -342,7 +343,13 @@ export function AwardShortlist({

- {[e.project.teamName, e.project.country, e.project.competitionCategory].filter(Boolean).join(', ') || '—'} + {[e.project.teamName, e.project.competitionCategory].filter(Boolean).length > 0 || e.project.country ? ( + <> + {[e.project.teamName, e.project.competitionCategory].filter(Boolean).join(', ')} + {(e.project.teamName || e.project.competitionCategory) && e.project.country ? ', ' : ''} + {e.project.country && } + + ) : '—'}

diff --git a/src/components/admin/round/filtering-dashboard.tsx b/src/components/admin/round/filtering-dashboard.tsx index 8d1ecb0..c28c403 100644 --- a/src/components/admin/round/filtering-dashboard.tsx +++ b/src/components/admin/round/filtering-dashboard.tsx @@ -74,6 +74,7 @@ import { motion, AnimatePresence } from 'motion/react' import Link from 'next/link' import type { Route } from 'next' import { AwardShortlist } from './award-shortlist' +import { CountryDisplay } from '@/components/shared/country-display' type FilteringDashboardProps = { competitionId: string @@ -924,7 +925,7 @@ export function FilteringDashboard({ competitionId, roundId }: FilteringDashboar

{result.project?.teamName} - {result.project?.country && ` \u00b7 ${result.project.country}`} + {result.project?.country && <> · }

diff --git a/src/components/admin/round/finalization-tab.tsx b/src/components/admin/round/finalization-tab.tsx index 1085294..8ffaf70 100644 --- a/src/components/admin/round/finalization-tab.tsx +++ b/src/components/admin/round/finalization-tab.tsx @@ -44,6 +44,7 @@ import { import { cn } from '@/lib/utils' import { projectStateConfig } from '@/lib/round-config' import { EmailPreviewDialog } from './email-preview-dialog' +import { CountryDisplay } from '@/components/shared/country-display' // ── Types ────────────────────────────────────────────────────────────────── @@ -233,7 +234,7 @@ export function FinalizationTab({ roundId, roundStatus }: FinalizationTabProps) {project.category === 'STARTUP' ? 'Startup' : project.category === 'BUSINESS_CONCEPT' ? 'Concept' : project.category ?? '-'} - {project.country ?? '-'} + {project.country ? : '-'} diff --git a/src/components/admin/round/project-states-table.tsx b/src/components/admin/round/project-states-table.tsx index 11f7278..498817c 100644 --- a/src/components/admin/round/project-states-table.tsx +++ b/src/components/admin/round/project-states-table.tsx @@ -63,6 +63,7 @@ import { } from 'lucide-react' import Link from 'next/link' import type { Route } from 'next' +import { CountryDisplay } from '@/components/shared/country-display' const PROJECT_STATES = ['PENDING', 'IN_PROGRESS', 'PASSED', 'REJECTED', 'COMPLETED', 'WITHDRAWN'] as const type ProjectState = (typeof PROJECT_STATES)[number] @@ -448,7 +449,7 @@ export function ProjectStatesTable({ competitionId, roundId, roundStatus, compet
- {ps.project?.country || '—'} + {ps.project?.country ? : '—'}
@@ -1087,7 +1088,7 @@ function AddProjectDialog({

{project.title}

{project.teamName} - {project.country && <> · {project.country}} + {project.country && <> · }

{project.competitionCategory && ( @@ -1237,7 +1238,7 @@ function AddProjectDialog({

{project.title}

{project.teamName} - {project.country && <> · {project.country}} + {project.country && <> · }

diff --git a/src/components/admin/round/ranking-dashboard.tsx b/src/components/admin/round/ranking-dashboard.tsx index 27f0604..32158ee 100644 --- a/src/components/admin/round/ranking-dashboard.tsx +++ b/src/components/admin/round/ranking-dashboard.tsx @@ -55,6 +55,7 @@ import { Download, } from 'lucide-react' import type { RankedProjectEntry } from '@/server/services/ai-ranking' +import { CountryDisplay } from '@/components/shared/country-display' // ─── Types ──────────────────────────────────────────────────────────────────── @@ -163,7 +164,7 @@ function SortableProjectRow({ {projectInfo?.teamName && (

{projectInfo.teamName} - {projectInfo.country ? ` · ${projectInfo.country}` : ''} + {projectInfo.country ? <> · : ''}

)}
diff --git a/src/components/admin/semi-finalists-content.tsx b/src/components/admin/semi-finalists-content.tsx index 26a0766..a2016b9 100644 --- a/src/components/admin/semi-finalists-content.tsx +++ b/src/components/admin/semi-finalists-content.tsx @@ -6,6 +6,7 @@ import type { Route } from 'next' import { useRouter } from 'next/navigation' import { trpc } from '@/lib/trpc/client' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { CountryDisplay } from '@/components/shared/country-display' import { Input } from '@/components/ui/input' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' @@ -214,7 +215,7 @@ export function SemiFinalistsContent({ editionId }: SemiFinalistsContentProps) { {categoryLabels[project.category ?? ''] ?? project.category} - {project.country || '—'} + {project.country ? : '—'} {project.currentRound} diff --git a/src/components/applicant/competition-timeline.tsx b/src/components/applicant/competition-timeline.tsx index 3ff1624..a5273e6 100644 --- a/src/components/applicant/competition-timeline.tsx +++ b/src/components/applicant/competition-timeline.tsx @@ -202,13 +202,35 @@ export function CompetitionTimelineSidebar() { // Is this entry after the elimination point? const isAfterElimination = eliminationIndex >= 0 && index > eliminationIndex - // Is this the current round the project is in (regardless of round status)? - const isCurrent = !!entry.projectState && entry.projectState !== 'PASSED' && entry.projectState !== 'COMPLETED' && entry.projectState !== 'REJECTED' + // Is this the current round? Either has an active project state, + // or is the first round the project hasn't passed yet (for seed data + // where project states may be missing). + const hasActiveProjectState = !!entry.projectState && entry.projectState !== 'PASSED' && entry.projectState !== 'COMPLETED' && entry.projectState !== 'REJECTED' + const isCurrent = !isAfterElimination && (hasActiveProjectState || ( + !isPassed && !isRejected && !isCompleted && + data.entries.slice(0, index).every((prev) => + prev.projectState === 'PASSED' || prev.projectState === 'COMPLETED' || + prev.status === 'ROUND_CLOSED' || prev.status === 'ROUND_ARCHIVED' + ) && index > 0 + )) - // Determine connector segment color (no icons, just colored lines) + // Connector color: green up to and including the current round, + // red leading into the rejected round, neutral after. let connectorColor = 'bg-border' - if ((isPassed || isCompleted) && !isAfterElimination) connectorColor = 'bg-emerald-400' - else if (isRejected) connectorColor = 'bg-destructive/30' + const nextEntry = data.entries[index + 1] + const nextIsRejected = nextEntry?.projectState === 'REJECTED' + if (isAfterElimination) { + connectorColor = 'bg-border' + } else if (isRejected) { + // From rejected round onward = neutral + connectorColor = 'bg-border' + } else if (nextIsRejected) { + // Connector leading INTO the rejected round = red + connectorColor = 'bg-destructive/40' + } else if (isCompleted || isPassed) { + // Rounds the project has passed through = green + connectorColor = 'bg-emerald-400' + } // Dot inner content let dotInner: React.ReactNode = null @@ -222,7 +244,7 @@ export function CompetitionTimelineSidebar() { } else if (isGrandFinale && (isCompleted || isPassed)) { dotClasses = 'bg-yellow-500 border-2 border-yellow-500' dotInner = - } else if (isCompleted || isPassed) { + } else if (isPassed || (isCompleted && !isCurrent)) { dotClasses = 'bg-emerald-500 border-2 border-emerald-500' dotInner = } else if (isCurrent) { diff --git a/src/components/observer/dashboard/filtering-panel.tsx b/src/components/observer/dashboard/filtering-panel.tsx index 4fdd5e7..fb2a991 100644 --- a/src/components/observer/dashboard/filtering-panel.tsx +++ b/src/components/observer/dashboard/filtering-panel.tsx @@ -9,6 +9,7 @@ import { Badge } from '@/components/ui/badge' import { Skeleton } from '@/components/ui/skeleton' import { Button } from '@/components/ui/button' import { AnimatedCard } from '@/components/shared/animated-container' +import { CountryDisplay } from '@/components/shared/country-display' import { Select, SelectContent, @@ -200,7 +201,7 @@ export function FilteringPanel({ roundId }: { roundId: string }) { {r.project?.title ?? 'Unknown'}

- {formatCategory(r.project?.competitionCategory)} · {r.project?.country ?? ''} + {formatCategory(r.project?.competitionCategory)} · {r.project?.country ? : ''}

diff --git a/src/components/observer/dashboard/intake-panel.tsx b/src/components/observer/dashboard/intake-panel.tsx index 395acc1..b67d4c8 100644 --- a/src/components/observer/dashboard/intake-panel.tsx +++ b/src/components/observer/dashboard/intake-panel.tsx @@ -7,6 +7,7 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Skeleton } from '@/components/ui/skeleton' import { Badge } from '@/components/ui/badge' import { AnimatedCard } from '@/components/shared/animated-container' +import { CountryDisplay } from '@/components/shared/country-display' import { Inbox, Globe, FolderOpen } from 'lucide-react' function relativeTime(date: Date | string): string { @@ -87,11 +88,11 @@ export function IntakePanel({ roundId, programId }: { roundId: string; programId

{p.title}

- {p.teamName ?? 'No team'} · {p.country ?? ''} + {p.teamName ?? 'No team'} · {p.country ? : ''}

- {p.country ?? ''} + {p.country ? : ''} ))} diff --git a/src/components/observer/dashboard/previous-round-section.tsx b/src/components/observer/dashboard/previous-round-section.tsx index 07b249b..1e563fa 100644 --- a/src/components/observer/dashboard/previous-round-section.tsx +++ b/src/components/observer/dashboard/previous-round-section.tsx @@ -6,6 +6,7 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Badge } from '@/components/ui/badge' import { Skeleton } from '@/components/ui/skeleton' import { AnimatedCard } from '@/components/shared/animated-container' +import { CountryDisplay } from '@/components/shared/country-display' import { ArrowDown, ChevronDown, ChevronUp, TrendingDown } from 'lucide-react' import { cn, formatCategory } from '@/lib/utils' @@ -107,7 +108,7 @@ export function PreviousRoundSection({ currentRoundId }: { currentRoundId: strin
{countryAttrition.map((c: any) => (
- {c.country} + -{c.lost} diff --git a/src/components/observer/dashboard/submission-panel.tsx b/src/components/observer/dashboard/submission-panel.tsx index d998be9..47a7cfb 100644 --- a/src/components/observer/dashboard/submission-panel.tsx +++ b/src/components/observer/dashboard/submission-panel.tsx @@ -7,6 +7,7 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Skeleton } from '@/components/ui/skeleton' import { Badge } from '@/components/ui/badge' import { AnimatedCard } from '@/components/shared/animated-container' +import { CountryDisplay } from '@/components/shared/country-display' import { FileText, Upload, Users } from 'lucide-react' function relativeTime(date: Date | string): string { @@ -146,11 +147,11 @@ export function SubmissionPanel({ roundId, programId }: { roundId: string; progr

{p.title}

- {p.teamName ?? 'No team'} · {p.country ?? ''} + {p.teamName ?? 'No team'} · {p.country ? : ''}

- {p.country ?? '—'} + {p.country ? : '—'} ))} diff --git a/src/components/observer/observer-project-detail.tsx b/src/components/observer/observer-project-detail.tsx index f76accf..06b8aa9 100644 --- a/src/components/observer/observer-project-detail.tsx +++ b/src/components/observer/observer-project-detail.tsx @@ -22,6 +22,7 @@ import { ProjectLogoWithUrl } from '@/components/shared/project-logo-with-url' import { UserAvatar } from '@/components/shared/user-avatar' import { StatusBadge } from '@/components/shared/status-badge' import { AnimatedCard } from '@/components/shared/animated-container' +import { CountryDisplay } from '@/components/shared/country-display' import { AlertCircle, Users, @@ -174,7 +175,7 @@ export function ObserverProjectDetail({ projectId }: { projectId: string }) { {(project.country || project.geographicZone) && ( - {project.country || project.geographicZone} + {project.country ? : project.geographicZone} )} {project.competitionCategory && ( @@ -392,7 +393,7 @@ export function ObserverProjectDetail({ projectId }: { projectId: string }) {

Location

-

{project.geographicZone || project.country}

+

{project.geographicZone}{project.geographicZone && project.country ? ', ' : ''}{project.country ? : null}

)} diff --git a/src/components/observer/observer-projects-content.tsx b/src/components/observer/observer-projects-content.tsx index 737056c..f858609 100644 --- a/src/components/observer/observer-projects-content.tsx +++ b/src/components/observer/observer-projects-content.tsx @@ -13,6 +13,7 @@ import { CardDescription, } from '@/components/ui/card' import { Badge } from '@/components/ui/badge' +import { CountryDisplay } from '@/components/shared/country-display' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Skeleton } from '@/components/ui/skeleton' @@ -394,7 +395,7 @@ export function ObserverProjectsContent() {
- {project.country ?? '-'} + {project.country ? : '-'} diff --git a/src/components/observer/reports/filtering-report-tabs.tsx b/src/components/observer/reports/filtering-report-tabs.tsx index d15418e..3374d79 100644 --- a/src/components/observer/reports/filtering-report-tabs.tsx +++ b/src/components/observer/reports/filtering-report-tabs.tsx @@ -26,6 +26,7 @@ import { ChevronLeft, ChevronRight, ChevronDown, ChevronUp } from 'lucide-react' import { RoundTypeStatsCards } from '@/components/observer/round-type-stats' import { FilteringScreeningBar } from './filtering-screening-bar' import { ProjectPreviewDialog } from './project-preview-dialog' +import { CountryDisplay } from '@/components/shared/country-display' interface FilteringReportTabsProps { roundId: string @@ -176,7 +177,7 @@ export function FilteringReportTabs({ roundId }: FilteringReportTabsProps) { {formatCategory(r.project.competitionCategory) || '—'} - {r.project.country ?? '—'} + {r.project.country ? : '—'} {outcomeBadge(effectiveOutcome)} @@ -258,7 +259,7 @@ export function FilteringReportTabs({ roundId }: FilteringReportTabsProps) {
{r.project.competitionCategory && {formatCategory(r.project.competitionCategory)}} - {r.project.country && {r.project.country}} + {r.project.country && }
diff --git a/src/components/observer/reports/project-preview-dialog.tsx b/src/components/observer/reports/project-preview-dialog.tsx index 2e76994..24d3d56 100644 --- a/src/components/observer/reports/project-preview-dialog.tsx +++ b/src/components/observer/reports/project-preview-dialog.tsx @@ -13,6 +13,7 @@ import { Button } from '@/components/ui/button' import { Skeleton } from '@/components/ui/skeleton' import { Separator } from '@/components/ui/separator' import { StatusBadge } from '@/components/shared/status-badge' +import { CountryDisplay } from '@/components/shared/country-display' import { ExternalLink, MapPin, Waves, Users } from 'lucide-react' import Link from 'next/link' import type { Route } from 'next' @@ -78,7 +79,7 @@ export function ProjectPreviewDialog({ projectId, open, onOpenChange }: ProjectP {data.project.country && ( - {data.project.country} + )} {data.project.competitionCategory && ( diff --git a/src/components/shared/country-display.tsx b/src/components/shared/country-display.tsx new file mode 100644 index 0000000..98afa92 --- /dev/null +++ b/src/components/shared/country-display.tsx @@ -0,0 +1,30 @@ +'use client' + +import { getCountryFlag, getCountryName, normalizeCountryToCode } from '@/lib/countries' + +/** + * Displays a country flag emoji followed by the country name. + * Accepts either an ISO-2 code or a full country name. + */ +export function CountryDisplay({ + country, + showName = true, + className, +}: { + country: string | null | undefined + showName?: boolean + className?: string +}) { + if (!country) return null + + const code = normalizeCountryToCode(country) + const flag = code ? getCountryFlag(code) : null + const name = code ? getCountryName(code) : country + + return ( + + {flag && {flag}} + {showName && name} + + ) +} diff --git a/src/server/routers/assignment.ts b/src/server/routers/assignment.ts index 5df1e9e..c9412c3 100644 --- a/src/server/routers/assignment.ts +++ b/src/server/routers/assignment.ts @@ -907,6 +907,7 @@ export const assignmentRouter = router({ // Verify access if ( userHasRole(ctx.user, 'JURY_MEMBER') && + !userHasRole(ctx.user, 'SUPER_ADMIN', 'PROGRAM_ADMIN') && assignment.userId !== ctx.user.id ) { throw new TRPCError({ diff --git a/src/server/routers/evaluation.ts b/src/server/routers/evaluation.ts index 3c5e940..e18e531 100644 --- a/src/server/routers/evaluation.ts +++ b/src/server/routers/evaluation.ts @@ -115,6 +115,7 @@ export const evaluationRouter = router({ if ( userHasRole(ctx.user, 'JURY_MEMBER') && + !userHasRole(ctx.user, 'SUPER_ADMIN', 'PROGRAM_ADMIN') && assignment.userId !== ctx.user.id ) { throw new TRPCError({ code: 'FORBIDDEN' }) diff --git a/src/server/routers/project.ts b/src/server/routers/project.ts index 1059f7e..8575750 100644 --- a/src/server/routers/project.ts +++ b/src/server/routers/project.ts @@ -177,8 +177,11 @@ export const projectRouter = router({ ] } - // Jury members can only see assigned projects - if (userHasRole(ctx.user, 'JURY_MEMBER')) { + // Jury members can only see assigned projects (but not if they also have admin roles) + if ( + userHasRole(ctx.user, 'JURY_MEMBER') && + !userHasRole(ctx.user, 'SUPER_ADMIN', 'PROGRAM_ADMIN') + ) { where.assignments = { ...((where.assignments as Record) || {}), some: { userId: ctx.user.id }, @@ -506,8 +509,8 @@ export const projectRouter = router({ // ProjectTag table may not exist yet } - // Check access for jury members - if (userHasRole(ctx.user, 'JURY_MEMBER')) { + // Check access for jury members (but not if they also have admin roles) + if (userHasRole(ctx.user, 'JURY_MEMBER') && !userHasRole(ctx.user, 'SUPER_ADMIN', 'PROGRAM_ADMIN')) { const assignment = await ctx.prisma.assignment.findFirst({ where: { projectId: input.id,