diff --git a/app/(landing)/hackathons/[slug]/components/header/ActionButtons.tsx b/app/(landing)/hackathons/[slug]/components/header/ActionButtons.tsx index 236d204a..bb64c87f 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/submissions/SubmissionCard.tsx b/app/(landing)/hackathons/[slug]/components/tabs/contents/submissions/SubmissionCard.tsx index cb549fbe..c1a65ecf 100644 --- a/app/(landing)/hackathons/[slug]/components/tabs/contents/submissions/SubmissionCard.tsx +++ b/app/(landing)/hackathons/[slug]/components/tabs/contents/submissions/SubmissionCard.tsx @@ -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]/submit/page.tsx b/app/(landing)/hackathons/[slug]/submit/page.tsx index af22d975..05e12538 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)/organizations/[id]/hackathons/[hackathonId]/judging/page.tsx b/app/(landing)/organizations/[id]/hackathons/[hackathonId]/judging/page.tsx index 8898ae80..a14da04e 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 3423f4af..956b972f 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); @@ -194,6 +194,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 || '', diff --git a/app/auth/check-email/page.tsx b/app/auth/check-email/page.tsx index c0e33b7e..c692c4bc 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 b9ec42de..1603a53b 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 f76a1125..0c8bb09a 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 00000000..acd93580 --- /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 00000000..8574ab89 --- /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 00000000..c6e48bb9 --- /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 */} +