Skip to content

Commit b34926a

Browse files
0xdevcollinsclaude
andauthored
feat(judge): dedicated judge portal, organizer invitations, completeness gate UX (#550)
* feat: implement judge portal with submission evaluation, invitations, and score calculation workflows * feat: add countdown banner, support read-only score sliders, and integrate submission disqualification into grading modal --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 810bae3 commit b34926a

39 files changed

Lines changed: 4879 additions & 194 deletions

File tree

app/(landing)/hackathons/[slug]/components/header/ActionButtons.tsx

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,18 @@ const ActionButtons = () => {
4343
)
4444
: false;
4545

46+
// Server-derived role for the current viewer. Organizers and assigned
47+
// judges cannot join their own hackathon; surfacing the role lets us
48+
// show the right copy instead of a broken join button.
49+
const viewerRole = (hackathon as any)?.viewerRole as
50+
| 'organizer'
51+
| 'judge'
52+
| 'participant'
53+
| 'guest'
54+
| undefined;
55+
const hasConflictingRole =
56+
viewerRole === 'organizer' || viewerRole === 'judge';
57+
4658
const handleJoin = withAuth(async () => {
4759
try {
4860
await joinMutation.mutateAsync();
@@ -86,7 +98,13 @@ const ActionButtons = () => {
8698

8799
return (
88100
<div className='flex w-full flex-col gap-3 md:w-auto md:flex-row md:items-center'>
89-
{!isParticipant ? (
101+
{hasConflictingRole ? (
102+
<div className='flex h-12 w-full items-center justify-center rounded-xl border border-white/10 bg-white/5 px-8 text-sm font-medium text-gray-300 md:w-auto'>
103+
{viewerRole === 'organizer'
104+
? 'You are managing this hackathon'
105+
: 'You are judging this hackathon'}
106+
</div>
107+
) : !isParticipant ? (
90108
<BoundlessButton
91109
className='s d bg-primary hover:bg-primary/90 h-12 w-full rounded-xl px-8 font-bold text-black disabled:bg-white/5 disabled:text-white/20 md:w-auto'
92110
icon={!isRegistrationClosed && <IconUserPlus size={20} />}

app/(landing)/organizations/[id]/hackathons/[hackathonId]/judging/page.tsx

Lines changed: 278 additions & 157 deletions
Large diffs are not rendered by default.

app/(landing)/organizations/[id]/hackathons/[hackathonId]/participants/page.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,7 @@ const ParticipantsPage: React.FC = () => {
8888
const [isReviewModalOpen, setIsReviewModalOpen] = useState(false);
8989
const [isJudgeModalOpen, setIsJudgeModalOpen] = useState(false);
9090
const [criteria, setCriteria] = useState<
91-
Array<{ title: string; weight: number; description?: string }>
91+
Array<{ id: string; title: string; weight: number; description?: string }>
9292
>([]);
9393
const [isLoadingCriteria, setIsLoadingCriteria] = useState(false);
9494

@@ -194,6 +194,9 @@ const ParticipantsPage: React.FC = () => {
194194
if (response.success) {
195195
setCriteria(
196196
response.data?.judgingCriteria?.map(criterion => ({
197+
// Persisted criteria always have an id (server enforces it).
198+
// Fall back to name only for resilience against historic data.
199+
id: criterion.id ?? criterion.name ?? '',
197200
title: criterion.name || '',
198201
weight: criterion.weight || 0,
199202
description: criterion.description || '',

app/auth/check-email/page.tsx

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,29 @@
33
import { MailIcon } from 'lucide-react';
44
import Link from 'next/link';
55
import { useSearchParams } from 'next/navigation';
6+
import { useEffect } from 'react';
67
import { BoundlessButton } from '@/components/buttons';
78

9+
const POST_VERIFY_KEY = 'boundless:postVerifyCallbackUrl';
10+
811
const CheckEmail = () => {
912
const searchParams = useSearchParams();
1013
const email = searchParams.get('email');
14+
const callbackUrl = searchParams.get('callbackUrl');
15+
16+
// The verification email link Better Auth sends does not preserve query
17+
// params, so stash the desired post-verify destination here. The
18+
// /auth/verify-email page reads this back after the token is consumed.
19+
useEffect(() => {
20+
if (typeof window === 'undefined') return;
21+
if (callbackUrl && callbackUrl.startsWith('/')) {
22+
try {
23+
window.localStorage.setItem(POST_VERIFY_KEY, callbackUrl);
24+
} catch {
25+
// ignore quota / private mode errors
26+
}
27+
}
28+
}, [callbackUrl]);
1129

1230
return (
1331
<div className='flex min-h-screen items-center justify-center p-4'>

app/auth/verify-email/page.tsx

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import { useRouter, useSearchParams } from 'next/navigation';
55
import { useEffect } from 'react';
66
import { toast } from 'sonner';
77

8+
const POST_VERIFY_KEY = 'boundless:postVerifyCallbackUrl';
9+
810
const VerifyEmail = () => {
911
const router = useRouter();
1012
const searchParams = useSearchParams();
@@ -16,7 +18,21 @@ const VerifyEmail = () => {
1618
fetchOptions: {
1719
onSuccess: () => {
1820
toast.success('Email verified successfully');
19-
router.push('/');
21+
// Honor a post-verify destination stashed by the signup flow
22+
// (e.g. a judge invitation page). Same-origin paths only.
23+
let target = '/';
24+
if (typeof window !== 'undefined') {
25+
try {
26+
const stashed = window.localStorage.getItem(POST_VERIFY_KEY);
27+
if (stashed && stashed.startsWith('/')) {
28+
target = stashed;
29+
}
30+
window.localStorage.removeItem(POST_VERIFY_KEY);
31+
} catch {
32+
// ignore storage errors
33+
}
34+
}
35+
router.push(target);
2036
},
2137
onError: () => {
2238
toast.error('Failed to verify email');

app/globals.css

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -618,3 +618,52 @@ input:-webkit-autofill:active {
618618
-webkit-text-fill-color: white !important; /* Optional: Customize text color */
619619
transition: background-color 5000s ease-in-out 0s; /* Optional: Smooth transition */
620620
}
621+
622+
/* Judge portal: score slider */
623+
.judge-slider {
624+
appearance: none;
625+
-webkit-appearance: none;
626+
width: 100%;
627+
height: 6px;
628+
background: linear-gradient(
629+
to right,
630+
#2eedaa 0%,
631+
#2eedaa var(--judge-slider-pct, 0%),
632+
rgba(255, 255, 255, 0.08) var(--judge-slider-pct, 0%),
633+
rgba(255, 255, 255, 0.08) 100%
634+
);
635+
border-radius: 9999px;
636+
outline: none;
637+
cursor: pointer;
638+
}
639+
.judge-slider::-webkit-slider-thumb {
640+
-webkit-appearance: none;
641+
appearance: none;
642+
width: 18px;
643+
height: 18px;
644+
border-radius: 9999px;
645+
background: #2eedaa;
646+
border: 2px solid #030303;
647+
box-shadow:
648+
0 0 0 1px rgba(46, 237, 170, 0.4),
649+
0 2px 6px rgba(0, 0, 0, 0.6);
650+
transition: transform 120ms ease;
651+
}
652+
.judge-slider:active::-webkit-slider-thumb,
653+
.judge-slider:focus-visible::-webkit-slider-thumb {
654+
transform: scale(1.1);
655+
}
656+
.judge-slider::-moz-range-thumb {
657+
width: 18px;
658+
height: 18px;
659+
border-radius: 9999px;
660+
background: #2eedaa;
661+
border: 2px solid #030303;
662+
box-shadow:
663+
0 0 0 1px rgba(46, 237, 170, 0.4),
664+
0 2px 6px rgba(0, 0, 0, 0.6);
665+
}
666+
.judge-slider::-moz-range-track {
667+
background: transparent;
668+
height: 6px;
669+
}

0 commit comments

Comments
 (0)