diff --git a/app/(landing)/hackathons/[slug]/components/header/ActionButtons.tsx b/app/(landing)/hackathons/[slug]/components/header/ActionButtons.tsx index 236d204ae..bb64c87f6 100644 --- a/app/(landing)/hackathons/[slug]/components/header/ActionButtons.tsx +++ b/app/(landing)/hackathons/[slug]/components/header/ActionButtons.tsx @@ -43,6 +43,18 @@ const ActionButtons = () => { ) : false; + // Server-derived role for the current viewer. Organizers and assigned + // judges cannot join their own hackathon; surfacing the role lets us + // show the right copy instead of a broken join button. + const viewerRole = (hackathon as any)?.viewerRole as + | 'organizer' + | 'judge' + | 'participant' + | 'guest' + | undefined; + const hasConflictingRole = + viewerRole === 'organizer' || viewerRole === 'judge'; + const handleJoin = withAuth(async () => { try { await joinMutation.mutateAsync(); @@ -86,7 +98,13 @@ const ActionButtons = () => { return (
- {!isParticipant ? ( + {hasConflictingRole ? ( +
+ {viewerRole === 'organizer' + ? 'You are managing this hackathon' + : 'You are judging this hackathon'} +
+ ) : !isParticipant ? ( } diff --git a/app/(landing)/hackathons/[slug]/components/tabs/contents/FindTeam.tsx b/app/(landing)/hackathons/[slug]/components/tabs/contents/FindTeam.tsx index a31a6989d..3b2569fd9 100644 --- a/app/(landing)/hackathons/[slug]/components/tabs/contents/FindTeam.tsx +++ b/app/(landing)/hackathons/[slug]/components/tabs/contents/FindTeam.tsx @@ -19,6 +19,7 @@ import { import TeamCard from './teams/TeamCard'; import MyTeamView from './teams/MyTeamView'; import { BoundlessButton } from '@/components/buttons/BoundlessButton'; +import Pagination from '@/components/ui/pagination'; import { DropdownMenu, DropdownMenuContent, @@ -88,7 +89,10 @@ const FindTeam = () => { !!hackathon?.id && hackathon.participantType !== 'INDIVIDUAL' ); - const teams = teamsResponse?.data?.teams || []; + const teams = (teamsResponse?.data?.teams || []).filter( + t => !myTeam || t.id !== myTeam.id + ); + const totalPages = teamsResponse?.data?.pagination?.totalPages ?? 1; const handleJoin = (team: Team) => { setSelectedTeam(team); @@ -99,14 +103,6 @@ const FindTeam = () => { const isIndividualOnly = hackathon.participantType === 'INDIVIDUAL'; - if (myTeam) { - return ( - - - - ); - } - return ( {
) : ( <> + {myTeam && ( +
+ +
+
+ )} +
-

Open Teams

+

+ {myTeam ? 'Other Teams' : 'Open Teams'} +

Find builders to collaborate with on your project.

@@ -261,16 +266,23 @@ const FindTeam = () => {

) : teams.length > 0 ? ( -
- {teams.map(team => ( - handleJoin(team)} - onMessageLeader={handleMessageLeader} - /> - ))} -
+ <> +
+ {teams.map(team => ( + handleJoin(team)} + onMessageLeader={handleMessageLeader} + /> + ))} +
+ + ) : (
diff --git a/app/(landing)/hackathons/[slug]/components/tabs/contents/submissions/SubmissionCard.tsx b/app/(landing)/hackathons/[slug]/components/tabs/contents/submissions/SubmissionCard.tsx index cb549fbea..26d00747b 100644 --- a/app/(landing)/hackathons/[slug]/components/tabs/contents/submissions/SubmissionCard.tsx +++ b/app/(landing)/hackathons/[slug]/components/tabs/contents/submissions/SubmissionCard.tsx @@ -94,7 +94,7 @@ const SubmissionCard = ({ submission }: SubmissionCardProps) => { {`${projectName} ) : logo ? ( // eslint-disable-next-line @next/next/no-img-element @@ -113,7 +113,7 @@ const SubmissionCard = ({ submission }: SubmissionCardProps) => { {`${projectName} )}
@@ -130,12 +130,9 @@ const SubmissionCard = ({ submission }: SubmissionCardProps) => { {/* Tags/Categories */}
- - {category} - - {submission.category && ( - - {submission.category} + {category && ( + + {category} )}
diff --git a/app/(landing)/hackathons/[slug]/components/tabs/contents/teams/MyTeamView.tsx b/app/(landing)/hackathons/[slug]/components/tabs/contents/teams/MyTeamView.tsx index b698bd0df..7aaea60b4 100644 --- a/app/(landing)/hackathons/[slug]/components/tabs/contents/teams/MyTeamView.tsx +++ b/app/(landing)/hackathons/[slug]/components/tabs/contents/teams/MyTeamView.tsx @@ -186,7 +186,14 @@ const MyTeamView = ({ team, hackathonSlug }: MyTeamViewProps) => {

- {team.teamName} + + {team.teamName} +

diff --git a/app/(landing)/hackathons/[slug]/components/tabs/contents/teams/TeamCard.tsx b/app/(landing)/hackathons/[slug]/components/tabs/contents/teams/TeamCard.tsx index 9737000b8..603212d4e 100644 --- a/app/(landing)/hackathons/[slug]/components/tabs/contents/teams/TeamCard.tsx +++ b/app/(landing)/hackathons/[slug]/components/tabs/contents/teams/TeamCard.tsx @@ -1,6 +1,7 @@ 'use client'; -import React from 'react'; +import React, { useCallback } from 'react'; +import { useParams } from 'next/navigation'; import { cn } from '@/lib/utils'; import { Team, TeamMember } from '@/lib/api/hackathons/teams'; import GroupAvatar from '@/components/avatars/GroupAvatar'; @@ -14,6 +15,16 @@ interface TeamCardProps { } const TeamCard = ({ team, onJoin, onMessageLeader }: TeamCardProps) => { + const { slug } = useParams<{ slug: string }>(); + + const openTeamDetails = useCallback(() => { + if (!slug || !team.id) return; + window.open( + `/hackathons/${slug}/teams/${team.id}`, + '_blank', + 'noopener,noreferrer' + ); + }, [slug, team.id]); const { teamName, description, @@ -32,7 +43,18 @@ const TeamCard = ({ team, onJoin, onMessageLeader }: TeamCardProps) => { }; return ( -
+
{ + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + openTeamDetails(); + } + }} + className='group hover:border-primary/20 flex h-full cursor-pointer flex-col overflow-hidden rounded-2xl border border-white/5 bg-[#0A0B0D] p-8 transition-all hover:bg-[#0D0F12]' + >
@@ -70,7 +92,10 @@ const TeamCard = ({ team, onJoin, onMessageLeader }: TeamCardProps) => { variant='outline' size='sm' className='h-11 shrink-0 rounded-xl border-white/10 bg-white/5 px-4 text-sm font-bold text-white transition-all hover:bg-white/10' - onClick={e => onMessageLeader(team, e.currentTarget)} + onClick={e => { + e.stopPropagation(); + onMessageLeader(team, e.currentTarget); + }} aria-label='Message team leader' > @@ -81,7 +106,10 @@ const TeamCard = ({ team, onJoin, onMessageLeader }: TeamCardProps) => { variant='outline' size='sm' className='border-primary/20 text-primary hover:bg-primary h-11 w-full rounded-xl bg-[#232B20]/30 px-6 text-sm font-bold transition-all hover:text-black sm:w-auto' - onClick={() => onJoin?.(team)} + onClick={e => { + e.stopPropagation(); + onJoin?.(team); + }} disabled={memberCount >= maxSize} > Join Team diff --git a/app/(landing)/hackathons/[slug]/components/tabs/index.tsx b/app/(landing)/hackathons/[slug]/components/tabs/index.tsx index 051605d14..1678d4e49 100644 --- a/app/(landing)/hackathons/[slug]/components/tabs/index.tsx +++ b/app/(landing)/hackathons/[slug]/components/tabs/index.tsx @@ -14,7 +14,10 @@ import FindTeam from './contents/FindTeam'; import SponsorsTab from './contents/SponsorsTab'; import { useEffect, useState, useMemo } from 'react'; import { useHackathonData } from '@/lib/providers/hackathonProvider'; -import { useHackathonAnnouncements } from '@/hooks/hackathon/use-hackathon-queries'; +import { + useHackathonAnnouncements, + useHackathonTeams, +} from '@/hooks/hackathon/use-hackathon-queries'; import { useCommentSystem } from '@/hooks/use-comment-system'; import { CommentEntityType } from '@/types/comment'; import { Megaphone } from 'lucide-react'; @@ -46,6 +49,17 @@ const HackathonTabs = ({ sidebar }: HackathonTabsProps) => { enabled: !!currentHackathon?.id, }); + const participantType = currentHackathon?.participantType; + const isTeamHackathon = + participantType === 'TEAM' || participantType === 'TEAM_OR_INDIVIDUAL'; + + const { data: teamsCountResponse } = useHackathonTeams( + slug, + { page: 1, limit: 1 }, + !!slug && isTeamHackathon + ); + const teamsTotal = teamsCountResponse?.data?.pagination?.total ?? 0; + const [activeTab, setActiveTab] = useState('overview'); const hackathonTabs = useMemo(() => { @@ -55,10 +69,6 @@ const HackathonTabs = ({ sidebar }: HackathonTabsProps) => { const hasWinners = !!(winners && winners.length > 0); const hasAnnouncements = announcements.length > 0; - const participantType = currentHackathon.participantType; - const isTeamHackathon = - participantType === 'TEAM' || participantType === 'TEAM_OR_INDIVIDUAL'; - const tabs = [ { id: 'overview', label: 'Overview' }, ...(hasParticipants @@ -105,6 +115,7 @@ const HackathonTabs = ({ sidebar }: HackathonTabsProps) => { tabs.push({ id: 'team-formation', label: 'Find Team', + badge: teamsTotal, }); } @@ -159,6 +170,8 @@ const HackathonTabs = ({ sidebar }: HackathonTabsProps) => { announcements, announcementsLoading, generalLoading, + isTeamHackathon, + teamsTotal, ]); useEffect(() => { diff --git a/app/(landing)/hackathons/[slug]/submit/page.tsx b/app/(landing)/hackathons/[slug]/submit/page.tsx index af22d975c..05e125382 100644 --- a/app/(landing)/hackathons/[slug]/submit/page.tsx +++ b/app/(landing)/hackathons/[slug]/submit/page.tsx @@ -156,6 +156,7 @@ export default function SubmitProjectPage({ category: mySubmission.category, description: mySubmission.description, logo: mySubmission.logo, + banner: mySubmission.banner, videoUrl: mySubmission.videoUrl, introduction: mySubmission.introduction, links: mySubmission.links, diff --git a/app/(landing)/hackathons/[slug]/teams/[teamId]/page.tsx b/app/(landing)/hackathons/[slug]/teams/[teamId]/page.tsx new file mode 100644 index 000000000..d5ee5b4f2 --- /dev/null +++ b/app/(landing)/hackathons/[slug]/teams/[teamId]/page.tsx @@ -0,0 +1,253 @@ +'use client'; + +import { use } from 'react'; +import Link from 'next/link'; +import { useHackathon, useTeam } from '@/hooks/hackathon/use-hackathon-queries'; +import { Button } from '@/components/ui/button'; +import BasicAvatar from '@/components/avatars/BasicAvatar'; +import { + ArrowLeft, + Calendar, + Crown, + Loader2, + Mail, + Users, + XCircle, +} from 'lucide-react'; + +export default function TeamDetailsPage({ + params, +}: { + params: Promise<{ slug: string; teamId: string }>; +}) { + const { slug, teamId } = use(params); + + const { data: hackathon } = useHackathon(slug); + const { data: team, isLoading, isError } = useTeam(slug, teamId); + + const formattedCreatedAt = team?.createdAt + ? new Date(team.createdAt).toLocaleDateString('en-US', { + day: '2-digit', + month: 'short', + year: 'numeric', + }) + : ''; + + const status = team?.isOpen ? 'OPEN' : 'CLOSED'; + + return ( +
+
+ + + + + {isLoading ? ( +
+ +

+ Loading team... +

+
+ ) : isError || !team ? ( +
+ +
+

Team not found

+

+ This team may have been disbanded or never existed. +

+
+
+ ) : ( +
+
+
+
+
+ {team.teamName.charAt(0).toUpperCase()} +
+
+

+ {team.teamName} +

+
+ + {status} + + + + {team.memberCount}/{team.maxSize} builders + + {formattedCreatedAt && ( + + + Created {formattedCreatedAt} + + )} +
+
+
+
+ + {team.description && ( +

+ {team.description} +

+ )} +
+ +
+
+
+

+ Members +

+
+ {team.members && team.members.length > 0 ? ( + team.members.map(member => { + const isLeader = member.userId === team.leader?.id; + const profileHref = member.username + ? `/profile/${member.username}` + : null; + const content = ( + <> +
+ + {isLeader && ( + + + Leader + + )} +
+ {!isLeader && ( + + {member.role} + + )} + + ); + return profileHref ? ( + + {content} + + ) : ( +
+ {content} +
+ ); + }) + ) : ( +

+ No members listed. +

+ )} +
+
+ + {team.lookingFor && team.lookingFor.length > 0 && ( +
+

+ Roles Needed +

+
+ {team.lookingFor.map((role, idx) => ( +
+ {role.role} + {role.skills && role.skills.length > 0 && ( + + {role.skills.join(', ')} + + )} +
+ ))} +
+
+ )} +
+ +
+ {team.leader && ( +
+

+ Team Leader +

+ {team.leader.username ? ( + + + + ) : ( + + )} +
+ )} + + {team.contactInfo && ( +
+

+ Contact +

+
+ + {team.contactInfo} +
+ {team.contactMethod && ( +

+ Via {team.contactMethod} +

+ )} +
+ )} +
+
+
+ )} +
+
+ ); +} diff --git a/app/(landing)/organizations/[id]/hackathons/[hackathonId]/judging/page.tsx b/app/(landing)/organizations/[id]/hackathons/[hackathonId]/judging/page.tsx index 8898ae805..a14da04e9 100644 --- a/app/(landing)/organizations/[id]/hackathons/[hackathonId]/judging/page.tsx +++ b/app/(landing)/organizations/[id]/hackathons/[hackathonId]/judging/page.tsx @@ -14,10 +14,12 @@ import { getJudgingResults, getJudgingWinners, publishJudgingResults, + getJudgingCompleteness, type JudgingCriterion, type JudgingSubmission, type JudgingResult, type AggregatedJudgingResults, + type JudgingCompletenessPreview, } from '@/lib/api/hackathons/judging'; import { getSubmissionDetails } from '@/lib/api/hackathons/participants'; import { getOrganizationMembers } from '@/lib/api/organization'; @@ -51,6 +53,7 @@ import { reportError, reportMessage } from '@/lib/error-reporting'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { JudgingCriteriaList } from '@/components/organization/hackathons/judging/JudgingCriteriaList'; import JudgingResultsTable from '@/components/organization/hackathons/judging/JudgingResultsTable'; +import { OrganizerJudgesPanel } from '@/components/organization/hackathons/judging/OrganizerJudgesPanel'; import { Input } from '@/components/ui/input'; import { AlertDialog, @@ -122,6 +125,10 @@ export default function JudgingPage() { const CACHE_TIMEOUT = 30000; // 30 seconds const [isPublishDialogOpen, setIsPublishDialogOpen] = useState(false); + const [completeness, setCompleteness] = + useState(null); + const [completenessLoading, setCompletenessLoading] = useState(false); + const [acceptPartial, setAcceptPartial] = useState(false); const [judgeToRemove, setJudgeToRemove] = useState(null); const canManageJudges = @@ -465,21 +472,52 @@ export default function JudgingPage() { const handlePublishResults = async () => { setIsPublishing(true); try { - const res = await publishJudgingResults(organizationId, hackathonId); + const res = await publishJudgingResults(organizationId, hackathonId, { + acceptPartial, + }); if (res.success) { toast.success('Results published successfully!'); + setIsPublishDialogOpen(false); + setAcceptPartial(false); fetchResults(true); fetchWinners(true); } else { toast.error(res.message || 'Failed to publish results'); } } catch (error: any) { - toast.error(error.message || 'Failed to publish results'); + toast.error(error?.message || 'Failed to publish results'); } finally { setIsPublishing(false); } }; + // Pull the completeness snapshot every time the dialog opens so the + // organizer sees fresh numbers (a judge may have submitted scores + // since the page loaded). + useEffect(() => { + if (!isPublishDialogOpen) { + setAcceptPartial(false); + return; + } + let cancelled = false; + setCompletenessLoading(true); + getJudgingCompleteness(organizationId, hackathonId) + .then(res => { + if (cancelled) return; + if (res.success && res.data) setCompleteness(res.data); + }) + .catch(() => { + // Non-fatal: the dialog still works, organizer just won't see + // the incompleteness summary. + }) + .finally(() => { + if (!cancelled) setCompletenessLoading(false); + }); + return () => { + cancelled = true; + }; + }, [isPublishDialogOpen, organizationId, hackathonId]); + // Use pre-calculated statistics from the API if available, otherwise fallback to local calculation const gradedCount = judgingSummary ? judgingSummary.submissionsScoredCount @@ -756,169 +794,185 @@ export default function JudgingPage() { -
- {/* Current Judges List */} -
-

- Current Judges - {isRefreshingJudges && ( - - )} -

-
- {currentJudges.length === 0 ? ( - - ) : ( - currentJudges.map((judge: any, index: number) => ( -
-
-
- {judge.image ? ( - - ) : ( -
- {judge.name?.[0] || '?'} -
- )} -
-
-

- {judge.name} -

-

- Judge {index + 1} -

-
-
- {canManageJudges && ( - - )} -
- )) - )} -
-
- - {/* Org Members List - Only visible to admin/owner */} - {canManageJudges && ( + setJudgeToRemove(userId)} + onJudgesChanged={fetchJudges} + /> + {/* Legacy org-members picker — superseded by email invitations. + Kept hidden behind a flag so we can re-surface it if a + power-user organizer asks for the direct-add path. */} + {false && ( +
+ {/* Current Judges List */}
-
-

- Add from Organization Members -

-
- - ) => - setMemberSearchTerm(e.target.value) - } +

+ Current Judges + {isRefreshingJudges && ( + + )} +

+
+ {currentJudges.length === 0 ? ( + -
-

- Select members from your organization to assign them as - judges. -

-
-
- {orgMembers - .filter(m => { - const search = memberSearchTerm.toLowerCase(); - return ( - m.name?.toLowerCase().includes(search) || - m.email?.toLowerCase().includes(search) || - m.username?.toLowerCase().includes(search) - ); - }) - .map((member: any) => { - const isAlreadyJudge = currentJudges.some( - j => j.id === member.id || j.userId === member.id - ); - return ( -
-
-
- {member.image ? ( - - ) : ( -
- {member.name?.[0] || - member.username?.[0] || - '?'} -
- )} -
-
-

- {member.name || member.username} -

-

- {member.email} -

-
+ ) : ( + currentJudges.map((judge: any, index: number) => ( +
+
+
+ {judge.image ? ( + + ) : ( +
+ {judge.name?.[0] || '?'} +
+ )} +
+
+

+ {judge.name} +

+

+ Judge {index + 1} +

+
+ {canManageJudges && ( -
- ); - })} - {orgMembers.length === 0 && !isRefreshingJudges && ( - + )} +
+ )) )}
- )} -
+ + {/* Org Members List - Only visible to admin/owner */} + {canManageJudges && ( +
+
+

+ Add from Organization Members +

+
+ + + ) => setMemberSearchTerm(e.target.value)} + /> +
+

+ Select members from your organization to assign them + as judges. +

+
+
+ {orgMembers + .filter(m => { + const search = memberSearchTerm.toLowerCase(); + return ( + m.name?.toLowerCase().includes(search) || + m.email?.toLowerCase().includes(search) || + m.username?.toLowerCase().includes(search) + ); + }) + .map((member: any) => { + const isAlreadyJudge = currentJudges.some( + j => j.id === member.id || j.userId === member.id + ); + return ( +
+
+
+ {member.image ? ( + + ) : ( +
+ {member.name?.[0] || + member.username?.[0] || + '?'} +
+ )} +
+
+

+ {member.name || member.username} +

+

+ {member.email} +

+
+
+ +
+ ); + })} + {orgMembers.length === 0 && !isRefreshingJudges && ( + + )} +
+
+ )} +
+ )} @@ -1051,7 +1105,7 @@ export default function JudgingPage() { open={isPublishDialogOpen} onOpenChange={setIsPublishDialogOpen} > - + Publish Judging Results? @@ -1059,15 +1113,82 @@ export default function JudgingPage() { action is irreversible. + + {completenessLoading && ( +

+ Checking judging progress… +

+ )} + + {completeness && completeness.complete && ( +

+ All {completeness.expectedJudgeCount} active judges have scored + every shortlisted submission. +

+ )} + + {completeness && !completeness.complete && ( +
+
+

Judging is incomplete.

+

+ {completeness.incompleteSubmissionCount} of{' '} + {completeness.totalShortlisted} shortlisted submissions are + missing scores from one or more active judges. +

+
+ {completeness.incompleteJudges.length > 0 && ( +
+

+ Judges with outstanding work +

+
    + {completeness.incompleteJudges.map(j => ( +
  • + {j.name} + + {j.missingCount} left + +
  • + ))} +
+
+ )} + +
+ )} + - + Cancel - Yes, Publish Results + {isPublishing + ? 'Publishing…' + : completeness && !completeness.complete && acceptPartial + ? 'Publish anyway' + : 'Publish results'}
diff --git a/app/(landing)/organizations/[id]/hackathons/[hackathonId]/participants/page.tsx b/app/(landing)/organizations/[id]/hackathons/[hackathonId]/participants/page.tsx index 3423f4af0..3d6c79873 100644 --- a/app/(landing)/organizations/[id]/hackathons/[hackathonId]/participants/page.tsx +++ b/app/(landing)/organizations/[id]/hackathons/[hackathonId]/participants/page.tsx @@ -88,7 +88,7 @@ const ParticipantsPage: React.FC = () => { const [isReviewModalOpen, setIsReviewModalOpen] = useState(false); const [isJudgeModalOpen, setIsJudgeModalOpen] = useState(false); const [criteria, setCriteria] = useState< - Array<{ title: string; weight: number; description?: string }> + Array<{ id: string; title: string; weight: number; description?: string }> >([]); const [isLoadingCriteria, setIsLoadingCriteria] = useState(false); @@ -128,6 +128,10 @@ const ParticipantsPage: React.FC = () => { const [statistics, setStatistics] = useState<{ participantsCount: number; submissionsCount: number; + soloParticipants: number; + totalTeams: number; + soloSubmissions: number; + teamSubmissions: number; } | null>(null); const [statisticsLoading, setStatisticsLoading] = useState(false); @@ -143,10 +147,18 @@ const ParticipantsPage: React.FC = () => { const stats = response.data as { totalSubmissions?: number; activeParticipants?: number; + soloParticipants?: number; + totalTeams?: number; + soloSubmissions?: number; + teamSubmissions?: number; }; setStatistics({ participantsCount: stats.activeParticipants ?? 0, submissionsCount: stats.totalSubmissions ?? 0, + soloParticipants: stats.soloParticipants ?? 0, + totalTeams: stats.totalTeams ?? 0, + soloSubmissions: stats.soloSubmissions ?? 0, + teamSubmissions: stats.teamSubmissions ?? 0, }); } catch (err) { reportError(err, { @@ -194,6 +206,9 @@ const ParticipantsPage: React.FC = () => { if (response.success) { setCriteria( response.data?.judgingCriteria?.map(criterion => ({ + // Persisted criteria always have an id (server enforces it). + // Fall back to name only for resilience against historic data. + id: criterion.id ?? criterion.name ?? '', title: criterion.name || '', weight: criterion.weight || 0, description: criterion.description || '', @@ -277,7 +292,7 @@ const ParticipantsPage: React.FC = () => { }>
-
+
{ } showTrend={true} /> + + (null); const [hackathon, setHackathon] = useState(null); + const [submissionStats, setSubmissionStats] = useState<{ + soloSubmissions: number; + teamSubmissions: number; + totalSubmissions: number; + } | null>(null); + const [submissionStatsLoading, setSubmissionStatsLoading] = useState(false); useEffect(() => { if (hackathonId) { @@ -58,6 +66,35 @@ export default function SubmissionsPage() { } }, [hackathonId]); + useEffect(() => { + if (!organizationId || !hackathonId) return; + const loadStats = async () => { + setSubmissionStatsLoading(true); + try { + const res = await getHackathonStatistics(organizationId, hackathonId); + const stats = res.data as { + totalSubmissions?: number; + soloSubmissions?: number; + teamSubmissions?: number; + }; + setSubmissionStats({ + totalSubmissions: stats.totalSubmissions ?? 0, + soloSubmissions: stats.soloSubmissions ?? 0, + teamSubmissions: stats.teamSubmissions ?? 0, + }); + } catch (err) { + reportError(err, { + context: 'org-submissions-fetchStats', + organizationId, + hackathonId, + }); + } finally { + setSubmissionStatsLoading(false); + } + }; + loadStats(); + }, [organizationId, hackathonId]); + useEffect(() => { const fetchSession = async () => { try { @@ -142,6 +179,36 @@ export default function SubmissionsPage() {
) : (
+
+ + + +
+ ([]); + const [hackathonSlug, setHackathonSlug] = useState(''); + const [loading, setLoading] = useState(false); + const [search, setSearch] = useState(''); + const [statusFilter, setStatusFilter] = useState('all'); + const [submissionFilter, setSubmissionFilter] = + useState('all'); + const [page, setPage] = useState(1); + const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE); + const [totalPages, setTotalPages] = useState(0); + const [totalTeams, setTotalTeams] = useState(0); + + useEffect(() => { + if (!hackathonId) return; + let cancelled = false; + const load = async () => { + setLoading(true); + try { + const limit = Math.min(pageSize, MAX_PAGE_SIZE); + const teamsRes = await getTeams(hackathonId, { + page, + limit, + search: search.trim() || undefined, + openOnly: false, + }); + if (cancelled) return; + setTeams(teamsRes?.data?.teams ?? []); + setTotalPages(teamsRes?.data?.pagination?.totalPages ?? 0); + setTotalTeams(teamsRes?.data?.pagination?.total ?? 0); + } catch (err) { + reportError(err, { + context: 'organizer-teams-load', + organizationId, + hackathonId, + }); + } finally { + if (!cancelled) setLoading(false); + } + }; + load(); + return () => { + cancelled = true; + }; + }, [hackathonId, organizationId, page, pageSize, search]); + + useEffect(() => { + if (!hackathonId) return; + let cancelled = false; + const loadHackathon = async () => { + try { + const hackRes = await getHackathon(hackathonId); + if (!cancelled) { + setHackathonSlug(hackRes?.data?.slug ?? hackathonId); + } + } catch (err) { + reportError(err, { + context: 'organizer-teams-load-hackathon', + organizationId, + hackathonId, + }); + if (!cancelled) setHackathonSlug(hackathonId); + } + }; + loadHackathon(); + return () => { + cancelled = true; + }; + }, [hackathonId, organizationId]); + + const filteredTeams = useMemo(() => { + return teams.filter(team => { + if (statusFilter === 'open' && !team.isOpen) return false; + if (statusFilter === 'closed' && team.isOpen) return false; + if (submissionFilter === 'submitted' && !team.hasSubmission) return false; + if (submissionFilter === 'not_submitted' && team.hasSubmission) + return false; + return true; + }); + }, [teams, statusFilter, submissionFilter]); + + const table = useReactTable({ + data: teams, + columns: [], + getCoreRowModel: getCoreRowModel(), + manualPagination: true, + pageCount: totalPages, + state: { + pagination: { + pageIndex: page - 1, + pageSize, + }, + }, + onPaginationChange: updater => { + if (typeof updater === 'function') { + const next = updater({ pageIndex: page - 1, pageSize }); + if (next.pageSize !== pageSize) { + setPageSize(Math.min(next.pageSize, MAX_PAGE_SIZE)); + setPage(1); + } else { + setPage(next.pageIndex + 1); + } + } + }, + }); + + const stats = useMemo(() => { + const submittedOnPage = teams.filter(t => t.hasSubmission).length; + const openOnPage = teams.filter(t => t.isOpen).length; + return { + total: totalTeams, + open: openOnPage, + submitted: submittedOnPage, + notSubmitted: teams.length - submittedOnPage, + }; + }, [teams, totalTeams]); + + return ( + }> +
+
+
+

+ Teams +

+

+ All teams formed for this hackathon and whether they have + submitted. +

+
+
+ +
+
+ + + + +
+ +
+
+ + { + setSearch(e.target.value); + setPage(1); + }} + className='focus:border-primary focus:ring-primary border-gray-700/50 bg-gray-900/50 pl-10 text-white placeholder:text-gray-500' + /> +
+
+ + +
+
+ + {loading ? ( +
+
+ +

Loading teams...

+
+
+ ) : filteredTeams.length === 0 ? ( +
+ +

+ {teams.length === 0 + ? 'No teams yet for this hackathon.' + : 'No teams match the current filters.'} +

+
+ ) : ( +
+
+
Team
+
Leader
+
Members
+
Submission
+
Status
+
+
    + {filteredTeams.map(team => { + const href = `/hackathons/${hackathonSlug}/teams/${team.id}`; + return ( +
  • + +
    +
    +
    + {team.teamName?.charAt(0).toUpperCase()} +
    +
    +

    + {team.teamName} +

    +

    + Created{' '} + {new Date(team.createdAt).toLocaleDateString()} +

    +
    +
    +
    +
    + {team.leader ? ( +
    + + +
    + ) : ( + + No leader + + )} +
    +
    + + + {team.memberCount}/{team.maxSize} + +
    +
    + {team.hasSubmission ? ( + + + Submitted + + ) : ( + + + Pending + + )} +
    +
    + + {team.isOpen ? 'Open' : 'Closed'} + +
    + +
  • + ); + })} +
+
+ )} + +
+ +
+
+
+
+ ); +} diff --git a/app/auth/check-email/page.tsx b/app/auth/check-email/page.tsx index c0e33b7ee..c692c4bc3 100644 --- a/app/auth/check-email/page.tsx +++ b/app/auth/check-email/page.tsx @@ -3,11 +3,29 @@ import { MailIcon } from 'lucide-react'; import Link from 'next/link'; import { useSearchParams } from 'next/navigation'; +import { useEffect } from 'react'; import { BoundlessButton } from '@/components/buttons'; +const POST_VERIFY_KEY = 'boundless:postVerifyCallbackUrl'; + const CheckEmail = () => { const searchParams = useSearchParams(); const email = searchParams.get('email'); + const callbackUrl = searchParams.get('callbackUrl'); + + // The verification email link Better Auth sends does not preserve query + // params, so stash the desired post-verify destination here. The + // /auth/verify-email page reads this back after the token is consumed. + useEffect(() => { + if (typeof window === 'undefined') return; + if (callbackUrl && callbackUrl.startsWith('/')) { + try { + window.localStorage.setItem(POST_VERIFY_KEY, callbackUrl); + } catch { + // ignore quota / private mode errors + } + } + }, [callbackUrl]); return (
diff --git a/app/auth/verify-email/page.tsx b/app/auth/verify-email/page.tsx index b9ec42def..1603a53bf 100644 --- a/app/auth/verify-email/page.tsx +++ b/app/auth/verify-email/page.tsx @@ -5,6 +5,8 @@ import { useRouter, useSearchParams } from 'next/navigation'; import { useEffect } from 'react'; import { toast } from 'sonner'; +const POST_VERIFY_KEY = 'boundless:postVerifyCallbackUrl'; + const VerifyEmail = () => { const router = useRouter(); const searchParams = useSearchParams(); @@ -16,7 +18,21 @@ const VerifyEmail = () => { fetchOptions: { onSuccess: () => { toast.success('Email verified successfully'); - router.push('/'); + // Honor a post-verify destination stashed by the signup flow + // (e.g. a judge invitation page). Same-origin paths only. + let target = '/'; + if (typeof window !== 'undefined') { + try { + const stashed = window.localStorage.getItem(POST_VERIFY_KEY); + if (stashed && stashed.startsWith('/')) { + target = stashed; + } + window.localStorage.removeItem(POST_VERIFY_KEY); + } catch { + // ignore storage errors + } + } + router.push(target); }, onError: () => { toast.error('Failed to verify email'); diff --git a/app/globals.css b/app/globals.css index f76a11256..0c8bb09a4 100644 --- a/app/globals.css +++ b/app/globals.css @@ -618,3 +618,52 @@ input:-webkit-autofill:active { -webkit-text-fill-color: white !important; /* Optional: Customize text color */ transition: background-color 5000s ease-in-out 0s; /* Optional: Smooth transition */ } + +/* Judge portal: score slider */ +.judge-slider { + appearance: none; + -webkit-appearance: none; + width: 100%; + height: 6px; + background: linear-gradient( + to right, + #2eedaa 0%, + #2eedaa var(--judge-slider-pct, 0%), + rgba(255, 255, 255, 0.08) var(--judge-slider-pct, 0%), + rgba(255, 255, 255, 0.08) 100% + ); + border-radius: 9999px; + outline: none; + cursor: pointer; +} +.judge-slider::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + width: 18px; + height: 18px; + border-radius: 9999px; + background: #2eedaa; + border: 2px solid #030303; + box-shadow: + 0 0 0 1px rgba(46, 237, 170, 0.4), + 0 2px 6px rgba(0, 0, 0, 0.6); + transition: transform 120ms ease; +} +.judge-slider:active::-webkit-slider-thumb, +.judge-slider:focus-visible::-webkit-slider-thumb { + transform: scale(1.1); +} +.judge-slider::-moz-range-thumb { + width: 18px; + height: 18px; + border-radius: 9999px; + background: #2eedaa; + border: 2px solid #030303; + box-shadow: + 0 0 0 1px rgba(46, 237, 170, 0.4), + 0 2px 6px rgba(0, 0, 0, 0.6); +} +.judge-slider::-moz-range-track { + background: transparent; + height: 6px; +} diff --git a/app/judge/[hackathonId]/page.tsx b/app/judge/[hackathonId]/page.tsx new file mode 100644 index 000000000..acd935800 --- /dev/null +++ b/app/judge/[hackathonId]/page.tsx @@ -0,0 +1,254 @@ +'use client'; + +import Link from 'next/link'; +import { useParams, useRouter } from 'next/navigation'; +import { + ArrowRight, + ClipboardList, + Loader2, + Sparkles, + Trophy, +} from 'lucide-react'; +import { AuthGuard } from '@/components/auth/AuthGuard'; +import { Button } from '@/components/ui/button'; +import { ProgressRing } from '@/components/judge/ProgressRing'; +import { DeadlineBadge } from '@/components/judge/DeadlineBadge'; +import { HackathonBanner } from '@/components/judge/HackathonBanner'; +import { CountdownBanner } from '@/components/judge/CountdownBanner'; +import { useJudgeHackathon } from '@/hooks/judge/use-judge-queries'; +import type { JudgingCriterion } from '@/lib/api/hackathons/judging'; +import { cn } from '@/lib/utils'; + +export default function JudgeHackathonPage() { + return ( + + + + ); +} + +function JudgeHackathon() { + const params = useParams<{ hackathonId: string }>(); + const router = useRouter(); + const hackathonId = params?.hackathonId ?? ''; + + const { data, isPending, isError, error } = useJudgeHackathon(hackathonId); + const firstUnscoredId = data?.firstUnscoredSubmissionId ?? null; + + if (isPending) return ; + if (isError || !data) { + return ( +

+ {error instanceof Error + ? error.message + : 'You are not assigned to this hackathon, or it could not be loaded.'} +

+ ); + } + + const remaining = Math.max(0, data.totalSubmissions - data.myScoredCount); + const criteria = (data.judgingCriteria ?? []) as JudgingCriterion[]; + const isDone = data.totalSubmissions > 0 && remaining === 0; + const allScored = data.totalSubmissions > 0 && remaining === 0; + + return ( +
+
+ + ← All assignments + +
+ + +
+
+ {data.organization?.name && ( +

+ {data.organization.name} +

+ )} +

+ {data.name} +

+ {data.tagline && ( +

+ {data.tagline} +

+ )} +
+
+ {data.resultsPublished ? ( + + + Results published + + ) : data.status === 'JUDGING' ? ( + + + Judging open + + ) : ( + + {data.status.toLowerCase()} + + )} + +
+
+
+ + {/* Live countdown — only renders when within 48h. After deadline, + renders a "closed" state. */} + 0 + ? `${remaining} left in your queue` + : undefined + } + closedLabel='Judging is closed' + /> + + {/* Progress + primary CTA */} +
+
+ +
+
+
+

+ Next up +

+

+ {allScored + ? 'You have scored every submission' + : firstUnscoredId + ? `Continue scoring` + : data.totalSubmissions === 0 + ? 'Nothing shortlisted yet' + : 'All scored. Awaiting more.'} +

+

+ {allScored + ? 'When the organizer publishes results, you will see the final ranking here.' + : firstUnscoredId + ? `${remaining} submission${remaining === 1 ? '' : 's'} left in your queue.` + : data.totalSubmissions === 0 + ? 'The organizer has not shortlisted any submissions for judging.' + : 'You are caught up. Check back later.'} +

+
+
+ {firstUnscoredId ? ( + + ) : null} + + + {allScored ? 'Review my scores' : 'See full queue'} + + {data.resultsPublished && ( + + + See final results + + )} +
+
+
+ + {/* Rubric */} +
+
+

+ Rubric +

+ + {criteria.length} criteri{criteria.length === 1 ? 'on' : 'a'} · + score 0–10 + +
+ {criteria.length === 0 ? ( +

+ The organizer has not published a rubric yet. Once they do, you can + start scoring. +

+ ) : ( +
    + {criteria.map(c => ( +
  • +
    +

    {c.title || c.name}

    + + {c.weight}% + +
    + {c.description && ( +

    + {c.description} +

    + )} +
    +
    +
    +
  • + ))} +
+ )} +
+
+ ); +} + +function Skeleton() { + return ( +
+
+
+
+
+
+
+ Loading hackathon… +
+
+ ); +} diff --git a/app/judge/[hackathonId]/results/page.tsx b/app/judge/[hackathonId]/results/page.tsx new file mode 100644 index 000000000..8574ab89e --- /dev/null +++ b/app/judge/[hackathonId]/results/page.tsx @@ -0,0 +1,206 @@ +'use client'; + +import Link from 'next/link'; +import { useParams } from 'next/navigation'; +import { Trophy } from 'lucide-react'; +import { AuthGuard } from '@/components/auth/AuthGuard'; +import { useJudgeResults } from '@/hooks/judge/use-judge-queries'; +import { formatJudgeDate } from '@/components/judge/utils'; +import type { JudgeResultItem } from '@/lib/api/judge'; +import { cn } from '@/lib/utils'; + +export default function JudgeResultsPage() { + return ( + + + + ); +} + +function ResultsPage() { + const params = useParams<{ hackathonId: string }>(); + const hackathonId = params?.hackathonId ?? ''; + const { data, isPending, isError, error } = useJudgeResults(hackathonId); + + if (isPending) { + return ( +
+
+
+
+
+ ); + } + if (isError || !data) { + return ( +

+ {error instanceof Error + ? error.message + : 'Could not load results for this hackathon.'} +

+ ); + } + + const ranked = [...data.results].sort((a, b) => { + const ra = a.rank ?? a.computedRank ?? 9999; + const rb = b.rank ?? b.computedRank ?? 9999; + return ra - rb; + }); + const top3 = ranked.slice(0, 3); + const rest = ranked.slice(3); + + return ( +
+
+ + ← Hackathon overview + +
+ + + +
+

+ Final results +

+

+ {data.resultsPublished + ? `Published ${data.resultsPublishedAt ? formatJudgeDate(data.resultsPublishedAt) : ''}` + : 'Not yet published.'} +

+
+
+
+ + {!data.resultsPublished ? ( +
+ +

+ Results not published yet +

+

+ Once the organizer publishes the final ranking, you can see it here. + Peer scores stay hidden until then. +

+
+ ) : ranked.length === 0 ? ( +

No ranked submissions.

+ ) : ( + <> + {/* Podium for the top three */} + {top3.length > 0 && ( +
+ {top3.map(r => ( + + ))} +
+ )} + + {/* Rest of the leaderboard */} + {rest.length > 0 && ( +
+

+ Leaderboard +

+
    + {rest.map(r => { + const rank = r.rank ?? r.computedRank ?? null; + return ( +
  1. +
    + + {rank ? `#${rank}` : '—'} + +

    + {r.projectName} +

    +
    +
    +

    + {r.averageScore.toFixed(2)} +

    + {r.prize && ( +

    + {r.prize} +

    + )} +
    +
  2. + ); + })} +
+
+ )} + + )} +
+ ); +} + +function PodiumCard({ item }: { item: JudgeResultItem }) { + const rank = item.rank ?? item.computedRank ?? 0; + const tone = + rank === 1 + ? { + border: 'border-amber-700/50', + bg: 'bg-gradient-to-b from-amber-900/40 via-[#101010] to-[#101010]', + ring: 'text-amber-300', + label: '1st place', + } + : rank === 2 + ? { + border: 'border-zinc-500/40', + bg: 'bg-gradient-to-b from-zinc-700/30 via-[#101010] to-[#101010]', + ring: 'text-zinc-200', + label: '2nd place', + } + : { + border: 'border-orange-800/40', + bg: 'bg-gradient-to-b from-orange-900/30 via-[#101010] to-[#101010]', + ring: 'text-orange-300', + label: '3rd place', + }; + + return ( +
+ + {tone.label} + + + #{rank} + +

+ {item.projectName} +

+

+ {item.averageScore.toFixed(2)} +

+

+ avg score +

+ {item.prize && ( + + {item.prize} + + )} +
+ ); +} diff --git a/app/judge/[hackathonId]/submissions/[submissionId]/page.tsx b/app/judge/[hackathonId]/submissions/[submissionId]/page.tsx new file mode 100644 index 000000000..c6e48bb9e --- /dev/null +++ b/app/judge/[hackathonId]/submissions/[submissionId]/page.tsx @@ -0,0 +1,676 @@ +'use client'; + +import Link from 'next/link'; +import { useParams, useRouter } from 'next/navigation'; +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { + ArrowLeft, + ArrowRight, + CheckCircle2, + ExternalLink, + Loader2, + PlayCircle, +} from 'lucide-react'; +import { AuthGuard } from '@/components/auth/AuthGuard'; +import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; +import { Button } from '@/components/ui/button'; +import { ScoreSlider } from '@/components/judge/ScoreSlider'; +import { + KeyboardShortcuts, + ShortcutDef, +} from '@/components/judge/KeyboardShortcuts'; +import { + useJudgeHackathon, + useJudgeQueueNeighbors, + useJudgeSubmission, + useSubmitJudgeScore, +} from '@/hooks/judge/use-judge-queries'; +import { CountdownBanner } from '@/components/judge/CountdownBanner'; +import { getCountdown } from '@/components/judge/utils'; +import type { + CriterionScoreRequest, + JudgingCriterion, +} from '@/lib/api/hackathons/judging'; +import { cn } from '@/lib/utils'; + +export default function JudgeScoringPage() { + return ( + + + + ); +} + +const SHORTCUTS: ShortcutDef[] = [ + { keys: ['Tab'], description: 'Advance to next criterion' }, + { keys: ['⇧', 'Tab'], description: 'Previous criterion' }, + { keys: ['1', '–', '9'], description: 'Set focused criterion (0-10)' }, + { keys: ['⌘', '↵'], description: 'Submit score and continue' }, + { keys: ['J'], description: 'Skip to next submission' }, + { keys: ['K'], description: 'Previous submission' }, + { keys: ['Esc'], description: 'Back to queue' }, + { keys: ['?'], description: 'Show this panel' }, +]; + +function getCriterionKey(c: JudgingCriterion) { + // Server guarantees a non-empty unique id on every persisted + // criterion (Sprint 2 #7). Use it directly; never fall back to name + // or title — that fallback silently merges criteria with the same + // human-readable label. + return c.id; +} + +function ScorePage() { + const params = useParams<{ hackathonId: string; submissionId: string }>(); + const router = useRouter(); + const hackathonId = params?.hackathonId ?? ''; + const submissionId = params?.submissionId ?? ''; + + const { data, isPending, isError } = useJudgeSubmission( + hackathonId, + submissionId + ); + // Pull the hackathon overview for the deadline. Same query is on the + // overview page, so when the judge navigates here from there this is + // a cache hit. + const { data: hackathonOverview } = useJudgeHackathon(hackathonId); + const judgingClosed = (() => { + if (!hackathonOverview?.judgingEnd) return false; + return getCountdown(hackathonOverview.judgingEnd).isPast; + })(); + // Cursor-style lookup so we never fetch the full queue. Powers the + // "X of N" counter, J/K navigation, and auto-advance. + const { data: neighbors } = useJudgeQueueNeighbors(hackathonId, submissionId); + + const criteria = data?.criteria ?? []; + const existingScores = data?.myScore?.criteriaScores ?? []; + + // ---- form state ---- + const [scores, setScores] = useState>({}); + const [comments, setComments] = useState>({}); + const [overall, setOverall] = useState(''); + const [focused, setFocused] = useState(null); + const [errors, setErrors] = useState>({}); + const [autoAdvance, setAutoAdvance] = useState(true); + const [activeTab, setActiveTab] = useState<'overview' | 'demo' | 'links'>( + 'overview' + ); + + const initialScores = useMemo(() => { + const out: Record = {}; + for (const c of criteria) { + const key = getCriterionKey(c); + const existing = existingScores.find( + s => s.criterionId === getCriterionKey(c) + ); + out[key] = existing ? existing.score : ''; + } + return out; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [criteria.length, submissionId, existingScores.length]); + + const initialComments = useMemo(() => { + const out: Record = {}; + for (const c of criteria) { + const key = getCriterionKey(c); + const existing = existingScores.find( + s => s.criterionId === getCriterionKey(c) + ); + out[key] = existing?.comment ?? ''; + } + return out; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [criteria.length, submissionId, existingScores.length]); + + useEffect(() => { + setScores(initialScores); + setComments(initialComments); + setOverall(data?.myScore?.comment ?? ''); + setErrors({}); + setFocused(criteria[0] ? getCriterionKey(criteria[0]) : null); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [submissionId, initialScores, initialComments]); + + // ---- derived: queue position + weighted total ---- + const totalInQueue = neighbors?.total ?? 0; + const scoredInQueue = neighbors?.scoredCount ?? 0; + const positionLabel = + neighbors?.position != null + ? `${neighbors.position} of ${totalInQueue}` + : null; + const nextUnscoredId = neighbors?.nextUnscored?.submissionId ?? null; + const prevId = neighbors?.prev?.submissionId ?? null; + const nextId = neighbors?.next?.submissionId ?? null; + + const weightedRunningTotal = useMemo(() => { + if (criteria.length === 0) return null; + let weightSum = 0; + let scoreSum = 0; + for (const c of criteria) { + const key = getCriterionKey(c); + const s = scores[key]; + const w = typeof c.weight === 'number' ? c.weight : 0; + weightSum += w; + if (typeof s === 'number') scoreSum += s * (w / 100); + } + if (weightSum === 0) return null; + return Math.round(scoreSum * 10) / 10; + }, [scores, criteria]); + + const filledCount = useMemo( + () => + criteria.filter(c => typeof scores[getCriterionKey(c)] === 'number') + .length, + [scores, criteria] + ); + + // ---- handlers ---- + const handleScoreChange = useCallback((key: string, value: number | '') => { + if (value === '') { + setScores(p => ({ ...p, [key]: '' })); + } else { + const clamped = Math.min(10, Math.max(0, value)); + setScores(p => ({ ...p, [key]: clamped })); + } + setErrors(p => (p[key] ? { ...p, [key]: null } : p)); + }, []); + + const advanceFocus = useCallback(() => { + if (!focused) { + if (criteria[0]) setFocused(getCriterionKey(criteria[0])); + return; + } + const idx = criteria.findIndex(c => getCriterionKey(c) === focused); + if (idx < criteria.length - 1) { + setFocused(getCriterionKey(criteria[idx + 1])); + } else { + setFocused(null); + } + }, [criteria, focused]); + + const submit = useSubmitJudgeScore(hackathonId); + + const submitOrError = useCallback(() => { + const newErrors: Record = {}; + let valid = true; + for (const c of criteria) { + const key = getCriterionKey(c); + if (typeof scores[key] !== 'number') { + newErrors[key] = 'Score required'; + valid = false; + } + } + setErrors(newErrors); + if (!valid) return; + + const criteriaScores: CriterionScoreRequest[] = criteria.map(c => { + const key = getCriterionKey(c); + return { + criterionId: c.id, + score: scores[key] as number, + comment: comments[key] || undefined, + }; + }); + + submit.mutate( + { + submissionId, + payload: { criteriaScores, comment: overall || undefined }, + }, + { + onSuccess: () => { + if (autoAdvance && nextUnscoredId) { + router.push(`/judge/${hackathonId}/submissions/${nextUnscoredId}`); + } else if (!nextUnscoredId) { + router.push(`/judge/${hackathonId}`); + } + }, + } + ); + }, [ + criteria, + scores, + comments, + overall, + submit, + submissionId, + autoAdvance, + nextUnscoredId, + router, + hackathonId, + ]); + + // Global key handler: J/K to navigate, Esc to leave, Cmd+Enter to submit + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + const target = e.target as HTMLElement | null; + const inField = + target && + (target.tagName === 'INPUT' || + target.tagName === 'TEXTAREA' || + target.isContentEditable); + + if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') { + e.preventDefault(); + submitOrError(); + return; + } + if (inField) return; + if (e.key === 'Escape') { + router.push(`/judge/${hackathonId}/submissions`); + } else if (e.key === 'j' || e.key === 'J') { + if (nextId) router.push(`/judge/${hackathonId}/submissions/${nextId}`); + } else if (e.key === 'k' || e.key === 'K') { + if (prevId) router.push(`/judge/${hackathonId}/submissions/${prevId}`); + } + }; + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + }, [router, hackathonId, nextId, prevId, submitOrError]); + + if (isPending) return ; + if (isError || !data) return ; + + // Once judging closes, the page becomes read-only. Existing scores + // are still visible (organizer can override on the org side); the + // form is disabled. + const readOnly = judgingClosed; + + const { submission, participant, myScore } = data; + const videoUrl = submission.videoUrl; + const links = submission.links ?? []; + const tabs: Array<{ + id: 'overview' | 'demo' | 'links'; + label: string; + count?: number; + }> = [{ id: 'overview', label: 'Overview' }]; + if (videoUrl) tabs.push({ id: 'demo', label: 'Demo' }); + if (links.length > 0) + tabs.push({ id: 'links', label: 'Links', count: links.length }); + + return ( +
+ {/* Top context bar */} +
+ + Back to queue + +
+ {positionLabel && ( + + Submission{' '} + {positionLabel} + + )} + +
+
+ + {/* Bulk progress strip */} + {totalInQueue > 0 && ( + + )} + + {/* Countdown / closed state. Hidden when judging is comfortably + in the future. Switches to a "Judging is closed" banner once + past the deadline. */} + + +
+ {/* Left column: context */} +
+
+
+ + + + {(participant.user.name ?? '?').slice(0, 2).toUpperCase()} + + +
+

+ {submission.projectName} +

+

+ {participant.teamName + ? `Team · ${participant.teamName}` + : participant.user.name} + {submission.category && ( + <> + · + + {submission.category} + + + )} +

+
+ {myScore && ( +
+

+ Your score +

+

+ {myScore.totalScore.toFixed(1)} +

+
+ )} +
+ + {/* Tabs */} +
+ {tabs.map(t => ( + + ))} +
+ +
+ {activeTab === 'overview' && ( + <> + {submission.introduction && ( +

+ {submission.introduction} +

+ )} + {submission.description ? ( +
+

+ {submission.description} +

+
+ ) : ( +

+ No description provided. +

+ )} + + )} + {activeTab === 'demo' && videoUrl && ( + + + + + +

Watch demo

+

{videoUrl}

+
+ +
+ )} + {activeTab === 'links' && ( + + )} +
+
+
+ + {/* Right column: scoring */} +