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 ? (
+
+ router.push(
+ `/judge/${hackathonId}/submissions/${firstUnscoredId}`
+ )
+ }
+ className='bg-primary hover:bg-primary/90 text-primary-foreground'
+ >
+ Continue scoring
+
+
+ ) : 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 (
+
+
+
+ {rank ? `#${rank}` : '—'}
+
+
+ {r.projectName}
+
+
+
+
+ {r.averageScore.toFixed(2)}
+
+ {r.prize && (
+
+ {r.prize}
+
+ )}
+
+
+ );
+ })}
+
+
+ )}
+ >
+ )}
+
+ );
+}
+
+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 => (
+ setActiveTab(t.id)}
+ className={cn(
+ 'relative -mb-px px-3 py-2 text-xs font-medium transition-colors',
+ activeTab === t.id
+ ? 'text-white'
+ : 'text-gray-500 hover:text-gray-300'
+ )}
+ >
+ {t.label}
+ {typeof t.count === 'number' && (
+ {t.count}
+ )}
+ {activeTab === t.id && (
+
+ )}
+
+ ))}
+
+
+
+ {activeTab === 'overview' && (
+ <>
+ {submission.introduction && (
+
+ {submission.introduction}
+
+ )}
+ {submission.description ? (
+
+
+ {submission.description}
+
+
+ ) : (
+
+ No description provided.
+
+ )}
+ >
+ )}
+ {activeTab === 'demo' && videoUrl && (
+
+
+
+
+
+ Watch demo
+ {videoUrl}
+
+
+
+ )}
+ {activeTab === 'links' && (
+
+ )}
+
+
+
+
+ {/* Right column: scoring */}
+
+
+
+
+
+ Score
+
+
+ {weightedRunningTotal !== null ? (
+ weightedRunningTotal.toFixed(1)
+ ) : (
+ —
+ )}
+ /10
+
+
+
+
+ Filled
+
+
+
+ {filledCount}
+
+ / {criteria.length}
+
+
+
+
+ {criteria.length === 0 ? (
+
+ The organizer has not published a rubric yet.
+
+ ) : (
+
+ {criteria.map(c => {
+ const key = getCriterionKey(c);
+ return (
+ handleScoreChange(key, v)}
+ isFocused={focused === key}
+ onFocus={() => setFocused(key)}
+ onBlur={() => {
+ if (focused === key) setFocused(null);
+ }}
+ onAdvance={advanceFocus}
+ onSubmitWithEnter={submitOrError}
+ comment={comments[key] ?? ''}
+ onCommentChange={v =>
+ setComments(p => ({ ...p, [key]: v }))
+ }
+ error={errors[key] ?? null}
+ inputId={`score-${key}`}
+ disabled={readOnly}
+ />
+ );
+ })}
+
+ )}
+
+
+
+ Overall feedback (optional)
+
+
+
+
+
+ setAutoAdvance(e.target.checked)}
+ className='accent-primary h-3.5 w-3.5'
+ />
+ Auto-advance after submit
+
+
+
+ ⌘
+ {' '}
+
+ ↵
+
+
+
+
+
+ {readOnly ? (
+ 'Judging closed'
+ ) : submit.isPending ? (
+ <>
+
+ {myScore ? 'Updating…' : 'Submitting…'}
+ >
+ ) : nextUnscoredId && autoAdvance ? (
+ <>
+ {myScore ? 'Update' : 'Submit'} & next
+
+ >
+ ) : myScore ? (
+ 'Update score'
+ ) : (
+ 'Submit score'
+ )}
+
+
+ {!nextUnscoredId && filledCount === criteria.length && (
+
+
+ Last unscored submission in your queue.
+
+ )}
+
+
+
+
+ );
+}
+
+function QueueProgress({
+ total,
+ scored,
+ position,
+}: {
+ total: number;
+ scored: number;
+ position: number | null;
+}) {
+ const scoredPct = total > 0 ? (scored / total) * 100 : 0;
+ const positionPct =
+ position != null && total > 0 ? ((position - 0.5) / total) * 100 : null;
+ return (
+
+
+
+ {positionPct != null && (
+
+ )}
+
+
+
+ {scored}
+ / {total} scored
+
+ {position != null && (
+
+ You are at{' '}
+ #{position}
+
+ )}
+
+
+ );
+}
+
+function SkeletonScoringPage() {
+ return (
+
+ );
+}
+
+function UnavailableState({ hackathonId }: { hackathonId: string }) {
+ return (
+
+
Submission unavailable
+
+ It may have been removed from the shortlist, or you are no longer
+ assigned to score this hackathon.
+
+
+ Back to queue →
+
+
+ );
+}
diff --git a/app/judge/[hackathonId]/submissions/page.tsx b/app/judge/[hackathonId]/submissions/page.tsx
new file mode 100644
index 00000000..dcc3ebfb
--- /dev/null
+++ b/app/judge/[hackathonId]/submissions/page.tsx
@@ -0,0 +1,401 @@
+'use client';
+
+import Link from 'next/link';
+import { useParams, useRouter } from 'next/navigation';
+import { useEffect, useMemo, useState } from 'react';
+import {
+ CheckCircle2,
+ ChevronLeft,
+ ChevronRight,
+ Circle,
+ Search,
+} from 'lucide-react';
+import { AuthGuard } from '@/components/auth/AuthGuard';
+import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
+import { useJudgeSubmissions } from '@/hooks/judge/use-judge-queries';
+import type { JudgingSubmission } from '@/lib/api/hackathons/judging';
+import { cn } from '@/lib/utils';
+
+type Filter = 'unscored' | 'scored' | 'all';
+const PAGE_SIZE = 50;
+
+export default function JudgeSubmissionsPage() {
+ return (
+
+
+
+ );
+}
+
+function SubmissionsList() {
+ const params = useParams<{ hackathonId: string }>();
+ const router = useRouter();
+ const hackathonId = params?.hackathonId ?? '';
+
+ const [filter, setFilter] = useState('all');
+ const [search, setSearch] = useState('');
+ const [searchInput, setSearchInput] = useState('');
+ const [page, setPage] = useState(1);
+
+ const { data, isPending, isError, isFetching } = useJudgeSubmissions(
+ hackathonId,
+ {
+ page,
+ limit: PAGE_SIZE,
+ search: search || undefined,
+ }
+ );
+
+ const submissions = data?.submissions ?? [];
+ const total = data?.pagination.total ?? 0;
+ const totalPages = data?.pagination.totalPages ?? 1;
+ // Server-derived: accurate across pages, not derived from the current
+ // window. Falls back to in-page count when older responses are cached.
+ const scoredCount =
+ data?.scoredCount ?? (submissions.filter(s => s.myScore).length || 0);
+
+ const visible = useMemo(() => {
+ if (filter === 'unscored') return submissions.filter(s => !s.myScore);
+ if (filter === 'scored') return submissions.filter(s => s.myScore);
+ return submissions;
+ }, [submissions, filter]);
+
+ const [focusIdx, setFocusIdx] = useState(0);
+ useEffect(() => {
+ if (visible.length === 0) {
+ setFocusIdx(0);
+ return;
+ }
+ setFocusIdx(i => Math.min(Math.max(0, i), visible.length - 1));
+ }, [visible.length]);
+
+ useEffect(() => {
+ const onKey = (e: KeyboardEvent) => {
+ const target = e.target as HTMLElement | null;
+ if (
+ target &&
+ (target.tagName === 'INPUT' ||
+ target.tagName === 'TEXTAREA' ||
+ target.isContentEditable)
+ )
+ return;
+ if (e.key === 'j' || e.key === 'J' || e.key === 'ArrowDown') {
+ e.preventDefault();
+ setFocusIdx(i => Math.min(visible.length - 1, i + 1));
+ } else if (e.key === 'k' || e.key === 'K' || e.key === 'ArrowUp') {
+ e.preventDefault();
+ setFocusIdx(i => Math.max(0, i - 1));
+ } else if (e.key === 'Enter') {
+ const s = visible[focusIdx];
+ if (s) {
+ router.push(`/judge/${hackathonId}/submissions/${s.submission.id}`);
+ }
+ }
+ };
+ window.addEventListener('keydown', onKey);
+ return () => window.removeEventListener('keydown', onKey);
+ }, [visible, focusIdx, router, hackathonId]);
+
+ return (
+
+
+
+ ← Hackathon overview
+
+
+ Submissions
+
+
+
+ {/* Progress strip — accurate even across pages */}
+
+
+
+ {scoredCount}
+ / {total} scored
+
+ {Math.max(0, total - scoredCount)} remaining
+
+
+
+
+ {/* Filter tabs + search */}
+
+
+ setFilter('unscored')}
+ />
+ setFilter('scored')}
+ />
+ setFilter('all')}
+ />
+
+
+
+
+ {isPending && (
+
+ {Array.from({ length: 6 }).map((_, i) => (
+
+ ))}
+
+ )}
+
+ {isError && (
+
Failed to load submissions.
+ )}
+
+ {!isPending && visible.length === 0 && (
+
0}
+ />
+ )}
+
+ {!isPending && visible.length > 0 && (
+
+ {visible.map((s, i) => (
+
+ setFocusIdx(i)}
+ />
+
+ ))}
+
+ )}
+
+ {totalPages > 1 && (
+ {
+ setPage(p);
+ setFocusIdx(0);
+ }}
+ disabled={isFetching}
+ />
+ )}
+
+ {visible.length > 0 && (
+
+ Press{' '}
+
+ ↑
+ {' '}
+
+ ↓
+ {' '}
+ to navigate,{' '}
+
+ ↵
+ {' '}
+ to open
+
+ )}
+
+ );
+}
+
+function FilterTab({
+ label,
+ active,
+ onClick,
+}: {
+ label: string;
+ active: boolean;
+ onClick: () => void;
+}) {
+ return (
+
+ {label}
+
+ );
+}
+
+function Row({
+ hackathonId,
+ submission,
+ focused,
+ onHover,
+}: {
+ hackathonId: string;
+ submission: JudgingSubmission;
+ focused: boolean;
+ onHover: () => void;
+}) {
+ const scored = !!submission.myScore;
+ return (
+
+
+ {scored ? (
+
+ ) : (
+
+ )}
+
+
+
+
+ {(submission.participant.user.name ?? '?').slice(0, 2).toUpperCase()}
+
+
+
+
+
+ {submission.submission.projectName}
+
+ {submission.submission.category && (
+
+ {submission.submission.category}
+
+ )}
+
+
+ {submission.participant.teamName
+ ? `Team · ${submission.participant.teamName}`
+ : submission.participant.user.name}
+
+
+ {scored && submission.myScore && (
+
+ {submission.myScore.totalScore.toFixed(1)}
+
+ )}
+
+
+ );
+}
+
+function Pagination({
+ page,
+ totalPages,
+ onChange,
+ disabled,
+}: {
+ page: number;
+ totalPages: number;
+ onChange: (p: number) => void;
+ disabled?: boolean;
+}) {
+ return (
+
+ onChange(Math.max(1, page - 1))}
+ disabled={page <= 1 || disabled}
+ className='inline-flex items-center gap-1 rounded-md border border-white/10 bg-[#101010] px-3 py-1.5 text-gray-300 disabled:opacity-40'
+ >
+ Previous
+
+
+ Page {page} {' '}
+ / {totalPages}
+
+ onChange(Math.min(totalPages, page + 1))}
+ disabled={page >= totalPages || disabled}
+ className='inline-flex items-center gap-1 rounded-md border border-white/10 bg-[#101010] px-3 py-1.5 text-gray-300 disabled:opacity-40'
+ >
+ Next
+
+
+ );
+}
+
+function EmptyForFilter({
+ filter,
+ hasSearch,
+ hasAnyOnPage,
+}: {
+ filter: Filter;
+ hasSearch: boolean;
+ hasAnyOnPage: boolean;
+}) {
+ return (
+
+ {filter === 'unscored' && !hasSearch && !hasAnyOnPage ? (
+ <>
+
+
+ You are caught up
+
+
+ Every shortlisted submission has your score.
+
+ >
+ ) : (
+ <>
+
Nothing to show
+
+ {hasSearch
+ ? 'No submissions match your search.'
+ : 'Switch filters to see other submissions.'}
+
+ >
+ )}
+
+ );
+}
diff --git a/app/judge/invitations/[token]/page.tsx b/app/judge/invitations/[token]/page.tsx
new file mode 100644
index 00000000..762d0cc5
--- /dev/null
+++ b/app/judge/invitations/[token]/page.tsx
@@ -0,0 +1,264 @@
+'use client';
+
+import Link from 'next/link';
+import { useParams, useRouter } from 'next/navigation';
+import { useState } from 'react';
+import { CalendarClock, Gavel, Loader2 } from 'lucide-react';
+import { authClient } from '@/lib/auth-client';
+import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
+import { Button } from '@/components/ui/button';
+import { HackathonBanner } from '@/components/judge/HackathonBanner';
+import { DeadlineBadge } from '@/components/judge/DeadlineBadge';
+import {
+ useAcceptJudgeInvitation,
+ useDeclineJudgeInvitation,
+ useJudgeInvitationPreview,
+} from '@/hooks/judge/use-judge-queries';
+import { formatJudgeDate } from '@/components/judge/utils';
+
+export default function JudgeInvitationPage() {
+ const params = useParams<{ token: string }>();
+ const router = useRouter();
+ const token = params?.token ?? '';
+
+ const { data: session, isPending: sessionLoading } = authClient.useSession();
+ const { data, isPending, isError, error } = useJudgeInvitationPreview(token);
+
+ const accept = useAcceptJudgeInvitation(token);
+ const decline = useDeclineJudgeInvitation(token);
+
+ const [showDisplayName, setShowDisplayName] = useState(false);
+ const [displayName, setDisplayName] = useState('');
+
+ if (isPending || sessionLoading) {
+ return (
+
+ );
+ }
+
+ if (isError || !data) {
+ return (
+
+ );
+ }
+
+ const isPending_ = data.status === 'PENDING' && !data.isExpired;
+ const emailMismatch =
+ !!session?.user?.email &&
+ session.user.email.toLowerCase() !== data.email.toLowerCase();
+
+ const authReturnPath = `/judge/invitations/${token}`;
+ const signInHref = `/auth?mode=signin&callbackUrl=${encodeURIComponent(authReturnPath)}`;
+ const signUpHref = `/auth?mode=signup&email=${encodeURIComponent(
+ data.email
+ )}&callbackUrl=${encodeURIComponent(authReturnPath)}`;
+
+ const onAccept = () => {
+ if (!session) {
+ router.push(signInHref);
+ return;
+ }
+ accept.mutate(displayName ? { displayName } : {}, {
+ onSuccess: () => router.push(`/judge/${data.hackathon.id}`),
+ });
+ };
+
+ const onDecline = () => {
+ if (!session) {
+ router.push(signInHref);
+ return;
+ }
+ decline.mutate(undefined, {
+ onSuccess: () => router.push('/judge'),
+ });
+ };
+
+ return (
+
+
+
+
+
+ Judge invitation
+
+
+ {data.hackathon.name}
+
+ {data.hackathon.organization?.name && (
+
+ Hosted by {data.hackathon.organization.name}
+
+ )}
+
+
+
+
+ {/* Inviter row */}
+ {data.invitedBy && (
+
+
+
+
+ {(data.invitedBy.name ?? '?').slice(0, 2).toUpperCase()}
+
+
+
+
+ {data.invitedBy.name}
+ {' '}
+ invited{' '}
+ {data.email}
+
+
+ )}
+
+ {/* Optional message */}
+ {data.message && (
+
+ “{data.message}”
+
+ )}
+
+ {/* Meta line */}
+
+
+
+
+ Invitation expires {formatJudgeDate(data.expiresAt)}
+
+
+
+ {/* Email mismatch */}
+ {emailMismatch && (
+
+ You are signed in as {session?.user?.email} . This
+ invitation was sent to {data.email} . Sign out and
+ sign in with the invited email to accept.
+
+ )}
+
+ {/* Action area */}
+ {isPending_ ? (
+
+ {!emailMismatch && (
+ <>
+
+
+ {accept.isPending ? (
+ <>
+
+ Accepting…
+ >
+ ) : session ? (
+ 'Accept and start judging'
+ ) : (
+ 'Sign in to accept'
+ )}
+
+
+ {decline.isPending ? 'Declining…' : 'Decline'}
+
+
+
+ {!session ? (
+
+ New to Boundless?{' '}
+
+ Create an account
+ {' '}
+ with {data.email}.
+
+ ) : showDisplayName ? (
+
+
+ Public display name (optional)
+
+ setDisplayName(e.target.value)}
+ placeholder={
+ data.displayName ?? session?.user?.name ?? ''
+ }
+ className='focus:border-primary/40 focus:ring-primary/30 w-full rounded-md border border-white/10 bg-black/40 px-3 py-2 text-sm text-white placeholder:text-gray-700 focus:ring-1 focus:outline-none'
+ />
+
+ ) : (
+
setShowDisplayName(true)}
+ className='text-center text-xs text-gray-500 hover:text-gray-300'
+ >
+ Customise display name
+
+ )}
+ >
+ )}
+
+ ) : (
+
+
+ {data.isExpired
+ ? 'This invitation has expired. Ask the organizer to send a new one.'
+ : `This invitation is ${data.status.toLowerCase()} and cannot be acted on.`}
+
+
+ Go to judge portal →
+
+
+ )}
+
+
+ );
+}
+
+function FailState({ title, body }: { title: string; body: string }) {
+ return (
+
+
{title}
+
{body}
+
+ Back to judge portal →
+
+
+ );
+}
diff --git a/app/judge/invitations/page.tsx b/app/judge/invitations/page.tsx
new file mode 100644
index 00000000..327d56fc
--- /dev/null
+++ b/app/judge/invitations/page.tsx
@@ -0,0 +1,104 @@
+'use client';
+
+import Link from 'next/link';
+import { CalendarClock, ChevronRight, Mail } from 'lucide-react';
+import { AuthGuard } from '@/components/auth/AuthGuard';
+import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
+import { useMyJudgeInvitations } from '@/hooks/judge/use-judge-queries';
+import { formatJudgeDate } from '@/components/judge/utils';
+
+export default function JudgeInvitationsListPage() {
+ return (
+
+
+
+ );
+}
+
+function InvitationsList() {
+ const { data, isPending, isError } = useMyJudgeInvitations();
+
+ return (
+
+
+
+ {isPending && (
+
+ {Array.from({ length: 3 }).map((_, i) => (
+
+ ))}
+
+ )}
+
+ {isError && (
+
Failed to load invitations.
+ )}
+
+ {!isPending && !isError && (data?.length ?? 0) === 0 && (
+
+
+
+
+
+ No pending invitations
+
+
+ Invitations from organizers will land here.
+
+
+ )}
+
+ {!isPending && data && data.length > 0 && (
+
+ {data.map(inv => (
+
+
+
+
+
+ {(inv.hackathon.organization?.name ?? inv.hackathon.name)
+ .slice(0, 2)
+ .toUpperCase()}
+
+
+
+
+ {inv.hackathon.name}
+
+
+ {inv.hackathon.organization?.name && (
+ {inv.hackathon.organization.name}
+ )}
+
+
+
+
+ Expires {formatJudgeDate(inv.expiresAt)}
+
+
+
+
+ ))}
+
+ )}
+
+ );
+}
diff --git a/app/judge/layout.tsx b/app/judge/layout.tsx
new file mode 100644
index 00000000..1c3a4d98
--- /dev/null
+++ b/app/judge/layout.tsx
@@ -0,0 +1,14 @@
+import type { Metadata } from 'next';
+import { ReactNode } from 'react';
+import { JudgePortalShell } from '@/components/judge/JudgePortalShell';
+
+export const metadata: Metadata = {
+ title: 'Judge Portal — Boundless',
+ description:
+ 'Review and score hackathon submissions you have been invited to judge.',
+ robots: { index: false, follow: false },
+};
+
+export default function JudgeLayout({ children }: { children: ReactNode }) {
+ return {children} ;
+}
diff --git a/app/judge/page.tsx b/app/judge/page.tsx
new file mode 100644
index 00000000..abc59dcc
--- /dev/null
+++ b/app/judge/page.tsx
@@ -0,0 +1,255 @@
+'use client';
+
+import Link from 'next/link';
+import { Gavel, Mail, Trophy } from 'lucide-react';
+import { AuthGuard } from '@/components/auth/AuthGuard';
+import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
+import { DeadlineBadge } from '@/components/judge/DeadlineBadge';
+import { useJudgeHackathons } from '@/hooks/judge/use-judge-queries';
+import type { JudgeAssignment } from '@/lib/api/judge';
+import { cn } from '@/lib/utils';
+
+export default function JudgeHomePage() {
+ return (
+
+
+
+ );
+}
+
+type Bucket = 'open' | 'awaiting' | 'past';
+
+function bucketOf(a: JudgeAssignment): Bucket {
+ if (a.hackathon.resultsPublished) return 'past';
+ if (
+ a.hackathon.status === 'COMPLETED' ||
+ a.hackathon.status === 'CANCELLED' ||
+ a.hackathon.status === 'ARCHIVED'
+ ) {
+ return 'past';
+ }
+ // Anything else where results have not been published yet is either
+ // open for judging or waiting on the organizer to publish.
+ if (
+ a.hackathon.totalSubmissions > 0 &&
+ a.hackathon.myScoredCount >= a.hackathon.totalSubmissions
+ ) {
+ return 'awaiting';
+ }
+ return 'open';
+}
+
+function JudgeHome() {
+ const { data, isPending, isError, error } = useJudgeHackathons();
+
+ const grouped = {
+ open: [] as JudgeAssignment[],
+ awaiting: [] as JudgeAssignment[],
+ past: [] as JudgeAssignment[],
+ };
+ for (const a of data ?? []) grouped[bucketOf(a)].push(a);
+
+ return (
+
+
+
+ {isPending && (
+
+ {Array.from({ length: 3 }).map((_, i) => (
+
+ ))}
+
+ )}
+
+ {isError && (
+
+ {error instanceof Error ? error.message : 'Failed to load'}
+
+ )}
+
+ {!isPending && !isError && (data?.length ?? 0) === 0 &&
}
+
+ {grouped.open.length > 0 && (
+
+
+ {grouped.open.map(a => (
+
+
+
+ ))}
+
+
+ )}
+
+ {grouped.awaiting.length > 0 && (
+
+
+ {grouped.awaiting.map(a => (
+
+
+
+ ))}
+
+
+ )}
+
+ {grouped.past.length > 0 && (
+
+
+ {grouped.past.map(a => (
+
+
+
+ ))}
+
+
+ )}
+
+ );
+}
+
+function Section({
+ title,
+ tone,
+ muted,
+ children,
+}: {
+ title: string;
+ tone?: 'primary';
+ muted?: boolean;
+ children: React.ReactNode;
+}) {
+ return (
+
+
+ {title}
+
+ {children}
+
+ );
+}
+
+function EmptyState() {
+ return (
+
+
+
+
+
+ No assignments yet
+
+
+ Invitations from organizers will land here.
+
+
+
+ Check pending invitations
+
+
+ );
+}
+
+function AssignmentRow({
+ assignment,
+ variant,
+}: {
+ assignment: JudgeAssignment;
+ variant: 'open' | 'awaiting' | 'past';
+}) {
+ const h = assignment.hackathon;
+ const pct =
+ h.totalSubmissions > 0
+ ? Math.round((h.myScoredCount / h.totalSubmissions) * 100)
+ : 0;
+
+ return (
+
+
+
+
+ {(h.organization?.name ?? h.name).slice(0, 2).toUpperCase()}
+
+
+
+
+
+
{h.name}
+
+
+ {h.organization?.name && {h.organization.name} }
+ {variant !== 'past' && (
+ <>
+ ·
+
+ >
+ )}
+ {variant === 'past' && h.resultsPublished && (
+ <>
+ ·
+
+ Results published
+
+ >
+ )}
+
+
+
+ {variant !== 'past' && (
+
+
+
+ {h.myScoredCount}
+ / {h.totalSubmissions}
+
+ {pct}%
+
+
+
+ )}
+
+ );
+}
diff --git a/components/auth/SignupForm.tsx b/components/auth/SignupForm.tsx
index adc9d64d..7c7c4c62 100644
--- a/components/auth/SignupForm.tsx
+++ b/components/auth/SignupForm.tsx
@@ -3,7 +3,7 @@ import { zodResolver } from '@hookform/resolvers/zod';
import { LockIcon, MailIcon, User } from 'lucide-react';
import Image from 'next/image';
import Link from 'next/link';
-import { useRouter } from 'next/navigation';
+import { useRouter, useSearchParams } from 'next/navigation';
import { useEffect } from 'react';
import { useForm } from 'react-hook-form';
import { toast } from 'sonner';
@@ -57,12 +57,25 @@ const SignupForm = ({
lastMethod,
}: SignupFormProps) => {
const router = useRouter();
+ const searchParams = useSearchParams();
const isGoogleLastUsed = lastMethod === 'google';
+ // Allow other flows (e.g. judge invitations) to pre-fill the email and
+ // route the user back to where they came from after the signup +
+ // verify roundtrip. Only honors same-origin paths to avoid open
+ // redirects.
+ const prefilledEmail = searchParams.get('email') ?? '';
+ const rawCallbackUrl = searchParams.get('callbackUrl');
+ const safeCallbackUrl =
+ rawCallbackUrl && rawCallbackUrl.startsWith('/') ? rawCallbackUrl : null;
+
const form = useForm>({
resolver: zodResolver(formSchema),
defaultValues: {
- email: defaultEmail ?? '',
+ // Prefer the explicit prop (most callers pass it through from
+ // their own URL parsing) but fall back to ?email= in the URL so
+ // judge-invitation deep links work even without a parent wrapper.
+ email: defaultEmail ?? prefilledEmail,
firstName: '',
lastName: '',
password: '',
@@ -97,9 +110,9 @@ const SignupForm = ({
toast.success(
'Verification email sent! Please check your email to verify your account. You will be automatically logged in once verified.'
);
- router.push(
- '/auth/check-email?email=' + encodeURIComponent(values.email)
- );
+ const params = new URLSearchParams({ email: values.email });
+ if (safeCallbackUrl) params.set('callbackUrl', safeCallbackUrl);
+ router.push(`/auth/check-email?${params.toString()}`);
},
onError: ctx => {
if (ctx.error.status === 409 || ctx.error.code === 'CONFLICT') {
diff --git a/components/hackathons/submissions/SubmissionForm.tsx b/components/hackathons/submissions/SubmissionForm.tsx
index 6f1eeed8..001f8def 100644
--- a/components/hackathons/submissions/SubmissionForm.tsx
+++ b/components/hackathons/submissions/SubmissionForm.tsx
@@ -92,7 +92,10 @@ const baseSubmissionSchema = z.object({
videoUrl: z
.union([z.string().url('Please enter a valid URL'), z.literal('')])
.optional(),
- introduction: z.string().optional(),
+ introduction: z
+ .string()
+ .max(500, 'Introduction cannot exceed 500 characters')
+ .optional(),
links: z.array(
z.object({
type: z.string(),
@@ -155,12 +158,18 @@ const INITIAL_STEPS: Step[] = [
const LINK_TYPES = [
{ value: 'github', label: 'GitHub' },
{ value: 'demo', label: 'Demo' },
- { value: 'website', label: 'Website' },
- { value: 'documentation', label: 'Documentation' },
+ { value: 'video', label: 'Video' },
+ { value: 'document', label: 'Document' },
+ { value: 'presentation', label: 'Presentation' },
{ value: 'other', label: 'Other' },
];
-const OTHER_LINK_TYPES = ['demo', 'website', 'documentation', 'other'];
+const OTHER_LINK_TYPES = ['demo', 'video', 'document', 'presentation', 'other'];
+
+const MAX_OTHER_LINKS = 5;
+const FIXED_LINK_TYPES = LINK_TYPES.map(t => t.value).filter(
+ v => v !== 'other'
+);
const isValidUrl = (url: string | undefined): boolean => {
if (!url || String(url).trim() === '') return false;
@@ -517,6 +526,9 @@ const SubmissionFormContent: React.FC = ({
};
const handleFillMockData = () => {
+ const participationType: 'INDIVIDUAL' | 'TEAM' = myTeam
+ ? 'TEAM'
+ : 'INDIVIDUAL';
const mockData = {
projectName: 'AI-Powered Task Manager',
category: categoryOptions[0],
@@ -529,7 +541,7 @@ const SubmissionFormContent: React.FC = ({
{ type: 'github', url: 'https://github.com/example/ai-task-manager' },
{ type: 'demo', url: 'https://demo.example.com/ai-task-manager' },
],
- participationType: 'INDIVIDUAL' as const,
+ participationType,
};
form.reset(mockData);
@@ -539,7 +551,27 @@ const SubmissionFormContent: React.FC = ({
const handleAddLink = () => {
const currentLinks = form.getValues('links') || [];
- form.setValue('links', [...currentLinks, { type: 'github', url: '' }], {
+ const usedFixedTypes = new Set(
+ currentLinks.map(l => l.type).filter(t => t !== 'other')
+ );
+ const otherCount = currentLinks.filter(l => l.type === 'other').length;
+
+ const firstUnusedFixed = FIXED_LINK_TYPES.find(t => !usedFixedTypes.has(t));
+
+ let nextType: string;
+ if (firstUnusedFixed) {
+ nextType = firstUnusedFixed;
+ } else if (otherCount < MAX_OTHER_LINKS) {
+ nextType = 'other';
+ } else {
+ toast.error('Cannot add another link', {
+ description: `All fixed link types are used and you have reached the limit of ${MAX_OTHER_LINKS} "Other" links.`,
+ duration: 6000,
+ });
+ return;
+ }
+
+ form.setValue('links', [...currentLinks, { type: nextType, url: '' }], {
shouldValidate: false,
});
};
@@ -582,6 +614,33 @@ const SubmissionFormContent: React.FC = ({
value: string
) => {
const currentLinks = form.getValues('links') || [];
+
+ if (field === 'type') {
+ if (value !== 'other') {
+ const isDuplicate = currentLinks.some(
+ (l, i) => i !== index && l.type === value
+ );
+ if (isDuplicate) {
+ toast.error('Duplicate link type', {
+ description: `"${value}" is already used. Each link type can be used at most once. Choose "Other" for additional links.`,
+ duration: 6000,
+ });
+ return;
+ }
+ } else {
+ const otherCount = currentLinks.filter(
+ (l, i) => i !== index && l.type === 'other'
+ ).length;
+ if (otherCount >= MAX_OTHER_LINKS) {
+ toast.error('Too many "Other" links', {
+ description: `You can include at most ${MAX_OTHER_LINKS} "Other" links per submission.`,
+ duration: 6000,
+ });
+ return;
+ }
+ }
+ }
+
const updatedLinks = [...currentLinks];
updatedLinks[index] = { ...updatedLinks[index], [field]: value };
form.setValue('links', updatedLinks, { shouldValidate: true });
@@ -643,7 +702,7 @@ const SubmissionFormContent: React.FC = ({
if (requireOtherLinks && !hasValidOtherLink) {
form.setError('links', {
message:
- 'At least one additional link (Demo, Website, Documentation, or Other) is required for this hackathon.',
+ 'At least one additional link (Demo, Video, Document, Presentation, or Other) is required for this hackathon.',
});
return;
}
@@ -792,7 +851,7 @@ const SubmissionFormContent: React.FC = ({
if (requireOtherLinks && !hasValidOtherLink) {
form.setError('links', {
message:
- 'At least one additional link (Demo, Website, Documentation, or Other) is required for this hackathon.',
+ 'At least one additional link (Demo, Video, Document, Presentation, or Other) is required for this hackathon.',
});
setCurrentStep(2);
updateStepState(2, 'active');
@@ -1346,12 +1405,13 @@ const SubmissionFormContent: React.FC = ({
- Optional: Additional information about your project
+ Optional. {field.value?.length || 0} / 500 characters max
@@ -1386,12 +1446,17 @@ const SubmissionFormContent: React.FC = ({
{(requireGithub || requireOtherLinks) && (
{requireGithub && requireOtherLinks
- ? 'GitHub repository link and at least one additional link (Demo, Website, Documentation, or Other) are required for this hackathon.'
+ ? 'GitHub repository link and at least one additional link (Demo, Video, Document, Presentation, or Other) are required for this hackathon.'
: requireGithub
? 'GitHub repository link is required for this hackathon.'
- : 'At least one additional link (Demo, Website, Documentation, or Other) is required for this hackathon.'}
+ : 'At least one additional link (Demo, Video, Document, Presentation, or Other) is required for this hackathon.'}
)}
+
+ Each link type can be used at most once. For additional
+ links, choose "Other" (up to {MAX_OTHER_LINKS}{' '}
+ allowed).
+
{formLinks.length === 0 ? (
No links added. Click "Add Link" to add project links.
diff --git a/components/judge/CountdownBanner.tsx b/components/judge/CountdownBanner.tsx
new file mode 100644
index 00000000..273bc1eb
--- /dev/null
+++ b/components/judge/CountdownBanner.tsx
@@ -0,0 +1,92 @@
+'use client';
+
+import { useEffect, useState } from 'react';
+import { Clock } from 'lucide-react';
+import { cn } from '@/lib/utils';
+import { formatCountdown, getCountdown } from './utils';
+
+interface CountdownBannerProps {
+ deadline: string | Date | null | undefined;
+ /** Label shown before the countdown, e.g. "Judging closes in". */
+ label?: string;
+ /** Hides the banner entirely when the deadline is further out than this. */
+ showWithinHours?: number;
+ /** Optional copy rendered after the countdown. */
+ hint?: string;
+ /** Custom message when the deadline has passed. */
+ closedLabel?: string;
+ className?: string;
+}
+
+/**
+ * Live deadline ticker. Ticks every second under one hour, every minute
+ * otherwise. Stays out of the way when the deadline is far off, slides
+ * in when it's near, and switches to a closed state past the deadline.
+ */
+export function CountdownBanner({
+ deadline,
+ label = 'Judging closes in',
+ showWithinHours = 48,
+ hint,
+ closedLabel = 'Judging is closed',
+ className,
+}: CountdownBannerProps) {
+ const [now, setNow] = useState(() => Date.now());
+
+ useEffect(() => {
+ if (!deadline) return;
+ const tick = () => setNow(Date.now());
+ tick();
+ // Tick every second under an hour for the live HH:MM:SS feel,
+ // otherwise every minute to keep this cheap.
+ const parts = getCountdown(deadline);
+ const intervalMs = parts.totalMs < 60 * 60 * 1000 ? 1000 : 60_000;
+ const id = window.setInterval(tick, intervalMs);
+ return () => window.clearInterval(id);
+ }, [deadline]);
+
+ if (!deadline) return null;
+ const parts = getCountdown(deadline, now);
+
+ if (parts.isPast) {
+ return (
+
+
+ {closedLabel}
+ {hint && · {hint} }
+
+ );
+ }
+
+ const hoursLeft = parts.days * 24 + parts.hours;
+ if (hoursLeft > showWithinHours) return null;
+
+ const tone =
+ hoursLeft < 1
+ ? 'border-red-900/40 bg-red-950/30 text-red-200'
+ : hoursLeft < 6
+ ? 'border-amber-800/40 bg-amber-950/30 text-amber-200'
+ : 'border-amber-900/30 bg-amber-950/20 text-amber-200/90';
+
+ return (
+
+
+ {label}
+
+ {formatCountdown(parts)}
+
+ {hint && {hint} }
+
+ );
+}
diff --git a/components/judge/DeadlineBadge.tsx b/components/judge/DeadlineBadge.tsx
new file mode 100644
index 00000000..5df0e6d5
--- /dev/null
+++ b/components/judge/DeadlineBadge.tsx
@@ -0,0 +1,61 @@
+'use client';
+
+import { Clock } from 'lucide-react';
+import { cn } from '@/lib/utils';
+
+interface DeadlineBadgeProps {
+ deadline: string | Date | null | undefined;
+ /** Optional label prefix, eg "Judging ends". Defaults to no prefix. */
+ prefix?: string;
+ className?: string;
+}
+
+function dayDiff(target: Date): number {
+ const ms = target.getTime() - Date.now();
+ return Math.round(ms / (1000 * 60 * 60 * 24));
+}
+
+export function DeadlineBadge({
+ deadline,
+ prefix,
+ className,
+}: DeadlineBadgeProps) {
+ if (!deadline) return null;
+ const d = typeof deadline === 'string' ? new Date(deadline) : deadline;
+ if (Number.isNaN(d.getTime())) return null;
+
+ const days = dayDiff(d);
+ let tone: string;
+ let text: string;
+
+ if (days < 0) {
+ tone = 'border-red-900/40 bg-red-950/40 text-red-300';
+ text = `${Math.abs(days)} day${Math.abs(days) === 1 ? '' : 's'} overdue`;
+ } else if (days === 0) {
+ tone = 'border-amber-800/40 bg-amber-950/40 text-amber-200';
+ text = 'Ends today';
+ } else if (days <= 2) {
+ tone = 'border-amber-800/40 bg-amber-950/40 text-amber-200';
+ text = `${days} day${days === 1 ? '' : 's'} left`;
+ } else if (days <= 7) {
+ tone = 'border-amber-900/30 bg-amber-950/20 text-amber-300/80';
+ text = `${days} days left`;
+ } else {
+ tone = 'border-white/10 bg-white/5 text-gray-400';
+ text = `${days} days left`;
+ }
+
+ return (
+
+
+ {prefix && {prefix} }
+ {text}
+
+ );
+}
diff --git a/components/judge/HackathonBanner.tsx b/components/judge/HackathonBanner.tsx
new file mode 100644
index 00000000..efb76186
--- /dev/null
+++ b/components/judge/HackathonBanner.tsx
@@ -0,0 +1,72 @@
+'use client';
+
+import Image from 'next/image';
+import { cn } from '@/lib/utils';
+
+interface HackathonBannerProps {
+ banner?: string | null;
+ name?: string;
+ /** Height class, eg "h-44" or "h-64". Defaults to a responsive value. */
+ heightClassName?: string;
+ /** Optional overlay content rendered on top of the banner. */
+ children?: React.ReactNode;
+ className?: string;
+}
+
+/**
+ * Hero banner for judge surfaces. Falls back to a brand-tinted gradient
+ * when no image is available, so empty hackathons still feel intentional.
+ */
+export function HackathonBanner({
+ banner,
+ name,
+ heightClassName = 'h-40 sm:h-52',
+ children,
+ className,
+}: HackathonBannerProps) {
+ return (
+
+ {banner ? (
+
+ ) : (
+
+ )}
+
+
+
+ {children && (
+
+ )}
+
+ );
+}
diff --git a/components/judge/JudgePortalShell.tsx b/components/judge/JudgePortalShell.tsx
new file mode 100644
index 00000000..3e2bbc9d
--- /dev/null
+++ b/components/judge/JudgePortalShell.tsx
@@ -0,0 +1,97 @@
+'use client';
+
+import Link from 'next/link';
+import { usePathname } from 'next/navigation';
+import { ReactNode } from 'react';
+import { Gavel } from 'lucide-react';
+import { authClient } from '@/lib/auth-client';
+import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
+import { cn } from '@/lib/utils';
+
+/**
+ * Judge portal shell. Distinct from the org dashboard on purpose:
+ * minimal chrome, sticky top bar, no sidebar. The work happens in the
+ * main column.
+ */
+export function JudgePortalShell({ children }: { children: ReactNode }) {
+ const { data: session } = authClient.useSession();
+ const user = session?.user;
+ const pathname = usePathname();
+
+ return (
+
+
+
+
+
+
+
+
+ Judge Portal
+
+ Boundless
+
+
+
+
+
+
+
+
+
+ Exit portal
+
+ {user && (
+
+
+
+ {(user.name ?? user.email ?? '?').slice(0, 2).toUpperCase()}
+
+
+ )}
+
+
+
+
+
{children}
+
+ );
+}
+
+function ShellLink({
+ href,
+ label,
+ active,
+}: {
+ href: string;
+ label: string;
+ active: boolean;
+}) {
+ return (
+
+ {label}
+
+ );
+}
diff --git a/components/judge/KeyboardShortcuts.tsx b/components/judge/KeyboardShortcuts.tsx
new file mode 100644
index 00000000..d1ea2ec7
--- /dev/null
+++ b/components/judge/KeyboardShortcuts.tsx
@@ -0,0 +1,99 @@
+'use client';
+
+import { useEffect, useState } from 'react';
+import { Keyboard } from 'lucide-react';
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+} from '@/components/ui/dialog';
+
+export interface ShortcutDef {
+ keys: string[];
+ description: string;
+}
+
+interface KeyboardShortcutsProps {
+ shortcuts: ShortcutDef[];
+ /** When provided, pressing this key toggles the dialog. Defaults to '?'. */
+ triggerKey?: string;
+}
+
+export function KeyboardShortcuts({
+ shortcuts,
+ triggerKey = '?',
+}: KeyboardShortcutsProps) {
+ const [open, setOpen] = useState(false);
+
+ useEffect(() => {
+ const onKey = (e: KeyboardEvent) => {
+ const target = e.target as HTMLElement | null;
+ if (
+ target &&
+ (target.tagName === 'INPUT' ||
+ target.tagName === 'TEXTAREA' ||
+ target.isContentEditable)
+ ) {
+ return;
+ }
+ if (e.key === triggerKey || (e.key === '/' && e.shiftKey)) {
+ e.preventDefault();
+ setOpen(p => !p);
+ } else if (e.key === 'Escape') {
+ setOpen(false);
+ }
+ };
+ window.addEventListener('keydown', onKey);
+ return () => window.removeEventListener('keydown', onKey);
+ }, [triggerKey]);
+
+ return (
+ <>
+ setOpen(true)}
+ className='inline-flex items-center gap-1.5 rounded-md border border-white/10 bg-white/5 px-2.5 py-1 text-xs text-gray-400 hover:border-white/20 hover:text-white'
+ title='Keyboard shortcuts (press ?)'
+ >
+
+ Shortcuts
+
+ ?
+
+
+
+
+
+
+ Keyboard shortcuts
+
+ Speed through the queue without leaving the keyboard.
+
+
+
+ {shortcuts.map((s, i) => (
+
+ {s.description}
+
+ {s.keys.map((k, j) => (
+
+ {k}
+
+ ))}
+
+
+ ))}
+
+
+
+ >
+ );
+}
diff --git a/components/judge/ProgressRing.tsx b/components/judge/ProgressRing.tsx
new file mode 100644
index 00000000..3c259436
--- /dev/null
+++ b/components/judge/ProgressRing.tsx
@@ -0,0 +1,86 @@
+'use client';
+
+import { cn } from '@/lib/utils';
+
+interface ProgressRingProps {
+ value: number;
+ total: number;
+ size?: number;
+ strokeWidth?: number;
+ label?: React.ReactNode;
+ sublabel?: React.ReactNode;
+ className?: string;
+ /** When true, the ring renders muted (e.g. results published already). */
+ done?: boolean;
+}
+
+export function ProgressRing({
+ value,
+ total,
+ size = 144,
+ strokeWidth = 10,
+ label,
+ sublabel,
+ className,
+ done = false,
+}: ProgressRingProps) {
+ const safeTotal = Math.max(total, 0);
+ const safeValue = Math.min(Math.max(value, 0), safeTotal);
+ const pct = safeTotal === 0 ? 0 : safeValue / safeTotal;
+ const radius = (size - strokeWidth) / 2;
+ const circumference = radius * 2 * Math.PI;
+ const offset = circumference * (1 - pct);
+ const stroke = done ? 'stroke-gray-600' : 'stroke-primary';
+
+ return (
+
+
+
+
+
+
+
+ {label ?? (
+ <>
+ {safeValue}
+ /{safeTotal}
+ >
+ )}
+
+ {sublabel && (
+
+ {sublabel}
+
+ )}
+
+
+ );
+}
diff --git a/components/judge/ScoreSlider.tsx b/components/judge/ScoreSlider.tsx
new file mode 100644
index 00000000..fbfdff1a
--- /dev/null
+++ b/components/judge/ScoreSlider.tsx
@@ -0,0 +1,170 @@
+'use client';
+
+import { ChangeEvent, useRef } from 'react';
+import { cn } from '@/lib/utils';
+
+interface ScoreSliderProps {
+ value: number | '';
+ onChange: (next: number | '') => void;
+ min?: number;
+ max?: number;
+ step?: number;
+ label: string;
+ weight?: number;
+ description?: string;
+ error?: string | null;
+ isFocused?: boolean;
+ onFocus?: () => void;
+ onBlur?: () => void;
+ inputId?: string;
+ /** Optional comment box rendered below the slider. */
+ comment?: string;
+ onCommentChange?: (value: string) => void;
+ commentPlaceholder?: string;
+ onSubmitWithEnter?: () => void;
+ onAdvance?: () => void;
+ /** Render in read-only mode (post-deadline). */
+ disabled?: boolean;
+}
+
+export function ScoreSlider({
+ value,
+ onChange,
+ min = 0,
+ max = 10,
+ step = 0.1,
+ label,
+ weight,
+ description,
+ error,
+ isFocused,
+ onFocus,
+ onBlur,
+ inputId,
+ comment,
+ onCommentChange,
+ commentPlaceholder,
+ onSubmitWithEnter,
+ onAdvance,
+ disabled = false,
+}: ScoreSliderProps) {
+ const sliderRef = useRef(null);
+ const numericValue = typeof value === 'number' ? value : 0;
+ const pct = ((numericValue - min) / (max - min)) * 100;
+
+ const handleSlider = (e: ChangeEvent) => {
+ const next = parseFloat(e.target.value);
+ if (Number.isNaN(next)) return;
+ onChange(Math.round(next * 10) / 10);
+ };
+
+ const handleNumberInput = (e: ChangeEvent) => {
+ const raw = e.target.value;
+ if (raw === '') {
+ onChange('');
+ return;
+ }
+ const next = parseFloat(raw);
+ if (Number.isNaN(next)) return;
+ onChange(Math.min(max, Math.max(min, Math.round(next * 10) / 10)));
+ };
+
+ return (
+
+
+
+
+
{label}
+ {typeof weight === 'number' && (
+
+ {weight}%
+
+ )}
+
+ {description && (
+
+ {description}
+
+ )}
+
+
+
+ /{max}
+
+
+
+
+ {
+ if (e.key === 'Enter') {
+ e.preventDefault();
+ if (onAdvance) onAdvance();
+ else if (onSubmitWithEnter) onSubmitWithEnter();
+ }
+ }}
+ className='judge-slider w-full'
+ style={
+ {
+ ['--judge-slider-pct' as string]: `${pct}%`,
+ } as React.CSSProperties
+ }
+ aria-label={label}
+ />
+
+
+ {onCommentChange && (
+
+ );
+}
diff --git a/components/judge/utils.ts b/components/judge/utils.ts
new file mode 100644
index 00000000..16c1014b
--- /dev/null
+++ b/components/judge/utils.ts
@@ -0,0 +1,92 @@
+export function formatJudgeDate(
+ value: string | Date | null | undefined
+): string {
+ if (!value) return '—';
+ const d = typeof value === 'string' ? new Date(value) : value;
+ if (Number.isNaN(d.getTime())) return '—';
+ return d.toLocaleDateString(undefined, {
+ day: 'numeric',
+ month: 'short',
+ year: 'numeric',
+ });
+}
+
+export function relativeDeadline(
+ value: string | Date | null | undefined
+): string {
+ if (!value) return '';
+ const d = typeof value === 'string' ? new Date(value) : value;
+ const diffMs = d.getTime() - Date.now();
+ const diffDays = Math.round(diffMs / (1000 * 60 * 60 * 24));
+ if (Number.isNaN(diffMs)) return '';
+ if (diffDays === 0) return 'today';
+ if (diffDays === 1) return 'tomorrow';
+ if (diffDays === -1) return 'yesterday';
+ if (diffDays > 0) return `in ${diffDays} days`;
+ return `${Math.abs(diffDays)} days ago`;
+}
+
+/** Break a future date into a `{days, hours, minutes, seconds}` countdown. */
+export interface CountdownParts {
+ isPast: boolean;
+ totalMs: number;
+ days: number;
+ hours: number;
+ minutes: number;
+ seconds: number;
+}
+
+export function getCountdown(
+ value: string | Date | null | undefined,
+ now: number = Date.now()
+): CountdownParts {
+ if (!value) {
+ return {
+ isPast: true,
+ totalMs: 0,
+ days: 0,
+ hours: 0,
+ minutes: 0,
+ seconds: 0,
+ };
+ }
+ const target =
+ typeof value === 'string' ? new Date(value).getTime() : value.getTime();
+ const diff = target - now;
+ if (Number.isNaN(diff)) {
+ return {
+ isPast: true,
+ totalMs: 0,
+ days: 0,
+ hours: 0,
+ minutes: 0,
+ seconds: 0,
+ };
+ }
+ if (diff <= 0) {
+ return {
+ isPast: true,
+ totalMs: diff,
+ days: 0,
+ hours: 0,
+ minutes: 0,
+ seconds: 0,
+ };
+ }
+ const days = Math.floor(diff / (1000 * 60 * 60 * 24));
+ const hours = Math.floor((diff % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60));
+ const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60));
+ const seconds = Math.floor((diff % (1000 * 60)) / 1000);
+ return { isPast: false, totalMs: diff, days, hours, minutes, seconds };
+}
+
+export function formatCountdown(parts: CountdownParts): string {
+ if (parts.isPast) return 'Closed';
+ if (parts.days >= 1) {
+ return `${parts.days}d ${parts.hours}h`;
+ }
+ if (parts.hours >= 1) {
+ return `${parts.hours}h ${String(parts.minutes).padStart(2, '0')}m`;
+ }
+ return `${parts.minutes}m ${String(parts.seconds).padStart(2, '0')}s`;
+}
diff --git a/components/landing-page/blog/BlogCard.tsx b/components/landing-page/blog/BlogCard.tsx
index 9570d2ca..ebd44dcc 100644
--- a/components/landing-page/blog/BlogCard.tsx
+++ b/components/landing-page/blog/BlogCard.tsx
@@ -24,7 +24,7 @@ const BlogCard = ({ post, onCardClick }: BlogCardProps) => {
src={post.coverImage}
alt={post.title}
fill
- className='object-cover transition-transform duration-500 group-hover:scale-105'
+ className='object-contain transition-transform duration-500 group-hover:scale-105'
sizes='(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw'
/>
{/* Gradient Overlay */}
diff --git a/components/landing-page/blog/BlogPostDetails.tsx b/components/landing-page/blog/BlogPostDetails.tsx
index 43a7fc1c..fe07c03a 100644
--- a/components/landing-page/blog/BlogPostDetails.tsx
+++ b/components/landing-page/blog/BlogPostDetails.tsx
@@ -140,7 +140,7 @@ const BlogPostDetails: React.FC = ({
src={post.coverImage}
alt={post.title}
fill
- className='rounded-lg object-cover'
+ className='rounded-lg object-contain'
priority
sizes='(max-width: 768px) 100vw, (max-width: 1200px) 80vw, 70vw'
/>
@@ -191,7 +191,7 @@ const BlogPostDetails: React.FC = ({
>
= ({
>
@@ -235,7 +235,7 @@ const BlogPostDetails: React.FC = ({
>
= ({
>
void;
getScoreColor: (percentage: number) => string;
+ /**
+ * Returns the relative weight of a criterion as a 0-100 percentage.
+ * Used so the "X% weight" badge stays honest regardless of how the
+ * organizer chose to scale weights.
+ */
+ getWeightPercent?: (criterion: JudgingCriterion) => number;
overallComment: string;
onOverallCommentChange: (value: string) => void;
showComments?: boolean;
@@ -38,12 +44,13 @@ export const ScoringSection = ({
onInputBlur,
onKeyDown,
getScoreColor,
+ getWeightPercent,
overallComment,
onOverallCommentChange,
showComments = true,
}: ScoringSectionProps) => {
const getCriterionKey = (criterion: JudgingCriterion) => {
- return criterion.id || criterion.name || criterion.title;
+ return criterion.id;
};
const scoredCount = criteria.filter(c => {
@@ -94,7 +101,10 @@ export const ScoringSection = ({
{criterionTitle}
- {criterion.weight}% weight
+ {getWeightPercent
+ ? getWeightPercent(criterion)
+ : criterion.weight}
+ % weight
{criterion.description && (
diff --git a/components/organization/cards/GradeSubmissionModal/TotalScoreCard.tsx b/components/organization/cards/GradeSubmissionModal/TotalScoreCard.tsx
index 31878019..ab6bbeb5 100644
--- a/components/organization/cards/GradeSubmissionModal/TotalScoreCard.tsx
+++ b/components/organization/cards/GradeSubmissionModal/TotalScoreCard.tsx
@@ -36,7 +36,7 @@ export const TotalScoreCard = ({